feat(dashboard): add C25 Signal Board + light theme polish
Some checks failed
Build and Deploy MoneyMaker / build-and-deploy (push) Failing after 48m10s

- Add signal board section showing per-ticker NLP analysis from DB
- Badge-yellow CSS class added
- _signal_board_data() helper: maps company_stats() + analyst_rec()
  to clean badge classes, signal strength bar, agreement status
- Error-safe: falls back to empty list on import/query failure
- badge-yellow added to CSS palette
This commit is contained in:
Henrik Jess Nielsen
2026-05-26 23:06:31 +02:00
parent ec8c0704ee
commit 1df1bbbd47
2 changed files with 558 additions and 106 deletions

View File

@@ -1,6 +1,6 @@
PY := .venv/bin/python
.PHONY: help run signals buy fetch rss analyze force dry company saxo saxo-buy saxo-sell saxo-login saxo-status
.PHONY: help run signals buy fetch rss analyze force dry company saxo saxo-buy saxo-sell saxo-login saxo-status dashboard
help:
@echo ""
@@ -12,7 +12,7 @@ help:
@echo " make buy → vis kun køb-kandidater"
@echo " make fetch → hent nye artikler fra Ground News"
@echo " make rss → hent danske RSS feeds (Børsen, Finans, Politiken)"
@echo " make orders → vis dagens køb/sælg/hold forslag"
@echo " make dashboard → start dashboard på http://localhost:5001"
@echo " make portfolio → vis åbne positioner + P&L"
@echo " make company → TICKER=NOVO-B make company"
@echo " make saxo → vis Saxo SIM konto status + positioner"
@@ -43,6 +43,9 @@ fetch:
rss:
$(PY) rss_feeds.py
dashboard:
$(PY) dashboard.py
orders:
$(PY) portfolio.py orders

View File

@@ -6,11 +6,12 @@ Usage:
python dashboard.py --port 5002
Auto-refreshes every 60 seconds. Shows:
Portfolio P&L + C25 benchmark
• Open positions with live prices
• Closed trades (win/loss)
• Signal accuracy
• Recent runner log tail
- Portfolio P&L + C25 benchmark
- Equity curve, win/loss, position charts (Apache ECharts)
- Open positions with live prices
- Closed trades (win/loss)
- Signal accuracy
- Recent runner log tail
"""
import argparse
import json
@@ -20,7 +21,7 @@ from datetime import datetime, timezone
from pathlib import Path
import yfinance as yf
from flask import Flask, render_template_string
from flask import Flask, jsonify, render_template_string
from db import get_conn, DB_TYPE
from report import _c25_day_return, _unrealised_pnl, _realised_pnl, _total_fees, _signal_accuracy
@@ -31,175 +32,496 @@ REFRESH = 60 # seconds
app = Flask(__name__)
# ── HTML template ────────────────────────────────────────────────────────────
# ── HTML template ────────────────────────────────────────────────────────────
TEMPLATE = """
<!DOCTYPE html>
TEMPLATE = """<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="{{ refresh }}">
<title>MoneyMaker Dashboard</title>
<title>MoneyMaker</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
:root {
--bg: #eef2f7;
--surface: #ffffff;
--border: #e2e8f0;
--text: #0f172a;
--muted: #64748b;
--accent: #2563eb;
--green: #16a34a;
--red: #dc2626;
--yellow: #d97706;
--shadow-sm: 0 1px 2px rgba(0,0,0,.05);
--shadow: 0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.04);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e2e8f0; padding: 24px; }
h1 { font-size: 1.4rem; font-weight: 700; color: #7dd3fc; margin-bottom: 4px; }
.subtitle { font-size: 0.8rem; color: #64748b; margin-bottom: 24px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.card { background: #1e2535; border-radius: 10px; padding: 18px 20px; border: 1px solid #2d3748; }
.card .label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: .06em; color: #64748b; margin-bottom: 6px; }
.card .value { font-size: 1.6rem; font-weight: 700; }
.card .sub { font-size: 0.78rem; color: #94a3b8; margin-top: 4px; }
.pos { color: #4ade80; }
.neg { color: #f87171; }
.neu { color: #94a3b8; }
.warn { color: #fbbf24; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { text-align: left; padding: 8px 12px; background: #1e2535; color: #64748b; font-size: 0.72rem; text-transform: uppercase; letter-spacing: .06em; border-bottom: 1px solid #2d3748; }
td { padding: 9px 12px; border-bottom: 1px solid #1a2030; }
tr:hover td { background: #1e2535; }
.section { background: #161c2d; border-radius: 10px; border: 1px solid #2d3748; margin-bottom: 20px; overflow: hidden; }
.section-title { padding: 14px 18px; font-size: 0.8rem; font-weight: 600; color: #7dd3fc; text-transform: uppercase; letter-spacing: .08em; border-bottom: 1px solid #2d3748; background: #1a2236; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 99px; font-size: 0.72rem; font-weight: 600; }
.badge-green { background: #14532d; color: #4ade80; }
.badge-red { background: #450a0a; color: #f87171; }
.badge-gray { background: #1e2535; color: #94a3b8; }
pre { font-family: 'Cascadia Code', 'Fira Mono', monospace; font-size: 0.75rem; color: #94a3b8; padding: 16px 18px; overflow-x: auto; white-space: pre-wrap; line-height: 1.6; }
.footer { font-size: 0.72rem; color: #334155; text-align: center; margin-top: 32px; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
padding: 24px 32px 52px;
min-height: 100vh;
}
/* ─ Header ─ */
.header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 22px; padding-bottom: 18px;
border-bottom: 1px solid var(--border);
}
.header-left { display: flex; align-items: center; gap: 12px; }
.header h1 { font-size: 1.25rem; font-weight: 800; letter-spacing: -.03em; color: var(--text); }
.header h1 span { color: var(--accent); }
.header-pill {
font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em;
color: var(--accent); background: rgba(37,99,235,.09);
padding: 3px 9px; border-radius: 20px; border: 1px solid rgba(37,99,235,.18);
}
.header-meta { font-size: 0.7rem; color: var(--muted); text-align: right; line-height: 1.9; }
/* ─ KPI strip ─ */
.kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-bottom: 18px; }
@media (max-width: 1100px) { .kpis { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 600px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
.kpi {
background: var(--surface);
border-radius: 10px; padding: 15px 16px;
box-shadow: var(--shadow);
border-top: 3px solid transparent;
}
.kpi.kpi-neutral { border-top-color: var(--border); }
.kpi.kpi-blue { border-top-color: var(--accent); }
.kpi.kpi-green { border-top-color: var(--green); }
.kpi.kpi-red { border-top-color: var(--red); }
.kpi-label { font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; color: var(--muted); margin-bottom: 9px; }
.kpi-value { font-size: 1.6rem; font-weight: 800; line-height: 1; letter-spacing: -.04em; }
.kpi-sub { font-size: 0.71rem; color: var(--muted); margin-top: 6px; }
/* ─ Charts row ─ */
.charts { display: grid; grid-template-columns: 1fr 260px 260px; gap: 10px; margin-bottom: 18px; }
@media (max-width: 1100px) { .charts { grid-template-columns: 1fr; } }
.chart-card { background: var(--surface); border-radius: 10px; box-shadow: var(--shadow); }
.chart-card-header {
padding: 13px 16px 0;
font-size: 0.64rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .09em; color: var(--muted);
}
/* ─ Sections ─ */
.section { background: var(--surface); border-radius: 10px; box-shadow: var(--shadow); margin-bottom: 10px; overflow: hidden; }
.section-header {
padding: 11px 16px;
font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em;
color: var(--muted); background: #f8fafc;
border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center;
}
.section-header .count {
font-family: 'JetBrains Mono', monospace; font-size: 0.68rem;
color: var(--accent); background: rgba(37,99,235,.08);
padding: 1px 8px; border-radius: 20px;
}
/* ─ Tables ─ */
table { width: 100%; border-collapse: collapse; }
th {
padding: 8px 14px; font-size: 0.63rem; font-weight: 700;
text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); text-align: left;
border-bottom: 1px solid var(--border); white-space: nowrap;
background: #f8fafc;
}
td { padding: 10px 14px; font-size: 0.81rem; border-bottom: 1px solid #f1f5f9; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(37,99,235,.025); }
td.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.77rem; }
td strong { font-weight: 700; color: var(--text); }
.empty { padding: 18px 16px; font-size: 0.8rem; color: var(--muted); font-style: italic; }
/* ─ Colors ─ */
.pos { color: var(--green); font-weight: 600; }
.neg { color: var(--red); font-weight: 600; }
.neu { color: var(--muted); }
/* ─ Badges ─ */
.badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 20px; font-size: 0.64rem; font-weight: 700; letter-spacing: .04em; }
.badge-green { background: rgba(22,163,74,.1); color: var(--green); }
.badge-red { background: rgba(220,38,38,.1); color: var(--red); }
.badge-blue { background: rgba(37,99,235,.1); color: var(--accent); }
.badge-yellow { background: rgba(215,119,6,.1); color: var(--yellow); }
.badge-gray { background: rgba(100,116,139,.1); color: var(--muted); }
/* ─ Log ─ */
details summary {
padding: 11px 16px; font-size: 0.64rem; font-weight: 700;
text-transform: uppercase; letter-spacing: .09em;
color: var(--muted); background: #f8fafc;
cursor: pointer; list-style: none;
display: flex; justify-content: space-between;
}
details summary::-webkit-details-marker { display: none; }
details summary::after { content: '+'; font-size: 1rem; color: var(--accent); }
details[open] summary::after { content: ''; }
pre {
font-family: 'JetBrains Mono', monospace; font-size: 0.71rem; color: var(--muted);
padding: 12px 16px 16px; white-space: pre-wrap; line-height: 1.65;
overflow-x: auto; background: #fafbfc; border-top: 1px solid var(--border);
}
/* ─ Footer ─ */
.footer { font-size: 0.64rem; color: #cbd5e1; text-align: center; margin-top: 32px; letter-spacing: .05em; }
/* ─ Refresh bar ─ */
#refresh-bar { position: fixed; bottom: 0; left: 0; height: 2px; background: var(--accent); opacity: .45; transition: width 1s linear; }
</style>
</head>
<body>
<h1>📈 MoneyMaker</h1>
<div class="subtitle">DB: {{ db_type }} &nbsp;·&nbsp; Opdateret: {{ now }} &nbsp;·&nbsp; Refresh om {{ refresh }}s</div>
<!-- KPI cards -->
<div class="grid">
<div class="card">
<div class="label">Net P&amp;L</div>
<div class="value {{ 'pos' if net_pnl >= 0 else 'neg' }}">{{ "{:+,.0f}".format(net_pnl) }} kr</div>
<div class="sub">{{ "{:+.2f}%".format(net_pct) }}</div>
<div class="header">
<div class="header-left">
<h1>Money<span>Maker</span></h1>
<span class="header-pill">{{ db_type }}</span>
</div>
<div class="card">
<div class="label">Urealiseret</div>
<div class="value {{ 'pos' if unreal >= 0 else 'neg' }}">{{ "{:+,.0f}".format(unreal) }} kr</div>
<div class="sub">{{ open_count }} åben{{ 'e' if open_count != 1 else '' }} position{{ 'er' if open_count != 1 else '' }}</div>
<div class="header-meta">
{{ now }}<br>
auto-refresh {{ refresh }}s
</div>
<div class="card">
<div class="label">Realiseret</div>
<div class="value {{ 'pos' if realised >= 0 else 'neg' }}">{{ "{:+,.0f}".format(realised) }} kr</div>
<div class="sub">Gebyrer: {{ "{:,.0f}".format(fees) }} kr</div>
</div>
<div class="card">
<div class="label">C25 i dag</div>
<!-- KPI strip -->
<div class="kpis">
<div class="kpi {{ 'kpi-green' if net_pnl >= 0 else 'kpi-red' }}">
<div class="kpi-label">Net P&amp;L</div>
<div class="kpi-value {{ 'pos' if net_pnl >= 0 else 'neg' }}">{{ "{:+,.0f}".format(net_pnl) }}</div>
<div class="kpi-sub {{ 'pos' if net_pct >= 0 else 'neg' }}">{{ "{:+.2f}%".format(net_pct) }} af kapital</div>
</div>
<div class="kpi {{ 'kpi-green' if unreal >= 0 else 'kpi-red' }}">
<div class="kpi-label">Urealiseret</div>
<div class="kpi-value {{ 'pos' if unreal >= 0 else 'neg' }}">{{ "{:+,.0f}".format(unreal) }}</div>
<div class="kpi-sub">{{ open_count }} position{{ 'er' if open_count != 1 else '' }}</div>
</div>
<div class="kpi {{ 'kpi-green' if realised >= 0 else 'kpi-neutral' }}">
<div class="kpi-label">Realiseret</div>
<div class="kpi-value {{ 'pos' if realised >= 0 else 'neg' }}">{{ "{:+,.0f}".format(realised) }}</div>
<div class="kpi-sub neu">gebyrer: {{ "{:,.0f}".format(fees) }} kr</div>
</div>
<div class="kpi {{ 'kpi-green' if c25_ret and c25_ret >= 0 else 'kpi-neutral' }}">
<div class="kpi-label">C25 i dag</div>
{% if c25_ret is not none %}
<div class="value {{ 'pos' if c25_ret >= 0 else 'neg' }}">{{ "{:+.2f}%".format(c25_ret) }}</div>
<div class="sub {{ 'pos' if vs_bench and vs_bench >= 0 else 'neg' }}">
<div class="kpi-value {{ 'pos' if c25_ret >= 0 else 'neg' }}">{{ "{:+.2f}%".format(c25_ret) }}</div>
<div class="kpi-sub {{ 'pos' if vs_bench and vs_bench >= 0 else 'neg' }}">
vs benchmark: {{ "{:+.2f}%".format(vs_bench) if vs_bench is not none else "" }}
</div>
{% else %}
<div class="value neu">—</div>
<div class="sub">Marked lukket?</div>
<div class="kpi-value neu">—</div>
<div class="kpi-sub">marked lukket</div>
{% endif %}
</div>
<div class="card">
<div class="label">Signal accuracy</div>
<div class="kpi {{ 'kpi-green' if sig.total_trades > 0 and sig.accuracy_pct >= 50 else 'kpi-neutral' }}">
<div class="kpi-label">Signal accuracy</div>
{% if sig.total_trades > 0 %}
<div class="value {{ 'pos' if sig.accuracy_pct >= 50 else 'neg' }}">{{ "{:.0f}%".format(sig.accuracy_pct) }}</div>
<div class="sub">{{ sig.correct }} / {{ sig.total_trades }} handler korrekte</div>
<div class="kpi-value {{ 'pos' if sig.accuracy_pct >= 50 else 'neg' }}">{{ "{:.0f}%".format(sig.accuracy_pct) }}</div>
<div class="kpi-sub">{{ sig.correct }}/{{ sig.total_trades }} handler</div>
{% else %}
<div class="value neu">—</div>
<div class="sub">Ingen lukkede handler</div>
<div class="kpi-value neu">—</div>
<div class="kpi-sub">ingen handler endnu</div>
{% endif %}
</div>
<div class="card">
<div class="label">Kapital</div>
<div class="value neu">{{ "{:,.0f}".format(capital) }} kr</div>
<div class="sub">Kontant: {{ "{:,.0f}".format(cash) }} kr</div>
<div class="kpi kpi-blue">
<div class="kpi-label">Kapital</div>
<div class="kpi-value neu">{{ "{:,.0f}".format(capital) }}</div>
<div class="kpi-sub">kontant: {{ "{:,.0f}".format(cash) }} kr</div>
</div>
</div>
<!-- Charts row -->
<div class="charts">
<div class="chart-card">
<div class="chart-card-header">Equity curve</div>
<div id="chart-equity" style="height:220px;"></div>
</div>
<div class="chart-card">
<div class="chart-card-header">Win / Loss</div>
<div id="chart-winloss" style="height:220px;"></div>
</div>
<div class="chart-card">
<div class="chart-card-header">Positioner P&amp;L</div>
<div id="chart-positions" style="height:220px;"></div>
</div>
</div>
<!-- Open positions -->
<div class="section">
<div class="section-title">📊 Åbne positioner</div>
<div class="section-header">
Aben positioner
<span class="count">{{ open_count }}</span>
</div>
{% if positions %}
<table>
<tr><th>Ticker</th><th>Antal</th><th>Købt</th><th>Nu</th><th>P&amp;L</th><th>Ændring</th><th>Stop</th><th>Take</th><th>Status</th></tr>
<tr>
<th>Ticker</th><th>Antal</th>
<th>Kob</th><th>Nu</th>
<th>P&amp;L</th><th>Aendring</th>
<th>Stop</th><th>Take</th><th>Status</th>
</tr>
{% for p in positions %}
<tr>
<td><strong>{{ p.ticker }}</strong></td>
<td>{{ "{:.0f}".format(p.shares) }}</td>
<td>{{ "{:,.0f}".format(p.entry) }}</td>
<td>{{ "{:,.0f}".format(p.last) }}</td>
<td class="{{ 'pos' if p.unreal >= 0 else 'neg' }}">{{ "{:+,.0f}".format(p.unreal) }}</td>
<td class="{{ 'pos' if p.pct >= 0 else 'neg' }}">{{ "{:+.1f}%".format(p.pct) }}</td>
<td class="neg">{{ "{:,.0f}".format(p.stop) }}</td>
<td class="pos">{{ "{:,.0f}".format(p.take) }}</td>
<td class="mono">{{ "{:.0f}".format(p.shares) }}</td>
<td class="mono">{{ "{:,.0f}".format(p.entry) }}</td>
<td class="mono">{{ "{:,.0f}".format(p.last) }}</td>
<td class="mono {{ 'pos' if p.unreal >= 0 else 'neg' }}">{{ "{:+,.0f}".format(p.unreal) }}</td>
<td class="mono {{ 'pos' if p.pct >= 0 else 'neg' }}">{{ "{:+.1f}%".format(p.pct) }}</td>
<td class="mono neg">{{ "{:,.0f}".format(p.stop) }}</td>
<td class="mono pos">{{ "{:,.0f}".format(p.take) }}</td>
<td>
{% if p.stop_hit %}<span class="badge badge-red">🔴 STOP</span>
{% elif p.take_hit %}<span class="badge badge-green">🟡 TAKE</span>
{% else %}<span class="badge badge-gray">HOLD</span>{% endif %}
{% if p.stop_hit %}<span class="badge badge-red">STOP</span>
{% elif p.take_hit %}<span class="badge badge-green">TAKE</span>
{% else %}<span class="badge badge-blue">HOLD</span>{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p style="padding:16px 18px; color:#64748b; font-size:0.85rem;">Ingen åbne positioner.</p>
<div class="empty">Ingen abne positioner.</div>
{% endif %}
</div>
<!-- Closed trades -->
<div class="section">
<div class="section-title">🏁 Lukkede handler</div>
<div class="section-header">
Lukkede handler
<span class="count">{{ trades|length }}</span>
</div>
{% if trades %}
<table>
<tr><th>Ticker</th><th>Handling</th><th>Antal</th><th>Kurs</th><th>Total</th><th>P&amp;L</th><th>Signal</th><th>Dato</th></tr>
<tr><th>Ticker</th><th>Type</th><th>Antal</th><th>Kurs</th><th>Total</th><th>P&amp;L</th><th>Signal</th><th>Dato</th></tr>
{% for t in trades %}
<tr>
<td><strong>{{ t.ticker }}</strong></td>
<td>{{ t.action.upper() }}</td>
<td>{{ "{:.0f}".format(t.shares) }}</td>
<td>{{ "{:,.0f}".format(t.price) }}</td>
<td>{{ "{:,.0f}".format(t.total_dkk) }}</td>
<td class="{{ 'pos' if t.pnl_dkk and t.pnl_dkk >= 0 else ('neg' if t.pnl_dkk else 'neu') }}">
<td><span class="badge {{ 'badge-green' if t.action == 'buy' else 'badge-red' }}">{{ t.action.upper() }}</span></td>
<td class="mono">{{ "{:.0f}".format(t.shares) }}</td>
<td class="mono">{{ "{:,.0f}".format(t.price) }}</td>
<td class="mono">{{ "{:,.0f}".format(t.total_dkk) }}</td>
<td class="mono {{ 'pos' if t.pnl_dkk and t.pnl_dkk >= 0 else ('neg' if t.pnl_dkk else 'neu') }}">
{{ "{:+,.0f}".format(t.pnl_dkk) if t.pnl_dkk is not none else "" }}
</td>
<td>
{% if t.signal_correct == 1 %}<span class="badge badge-green">korrekt</span>
{% elif t.signal_correct == 0 %}<span class="badge badge-red">forkert</span>
{% if t.signal_correct == 1 %}<span class="badge badge-green">korrekt</span>
{% elif t.signal_correct == 0 %}<span class="badge badge-red">forkert</span>
{% else %}<span class="badge badge-gray">—</span>{% endif %}
</td>
<td>{{ t.event_date }}</td>
<td class="mono" style="color:var(--muted)">{{ t.event_date }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p style="padding:16px 18px; color:#64748b; font-size:0.85rem;">Ingen handler endnu.</p>
<div class="empty">Ingen handler endnu.</div>
{% endif %}
</div>
<!-- Signal pipeline stats -->
<!-- Signal pipeline -->
<div class="section">
<div class="section-title">🔬 NLP signal pipeline</div>
<div class="section-header">NLP signal pipeline</div>
<table>
<tr><th>Analyserede signaler</th><th>Alert-triggers (≥threshold)</th><th>Gns. score</th></tr>
<tr>
<td>{{ sig.total }}</td>
<td>{{ sig.alerts }}</td>
<td>{{ "{:.3f}".format(sig.avg_score) }}</td>
<th>Analyserede signaler</th>
<th>Alert-triggers</th>
<th>Gns. score</th>
<th>Korrekte handler</th>
</tr>
<tr>
<td class="mono">{{ sig.total }}</td>
<td class="mono">{{ sig.alerts }}</td>
<td class="mono">{{ "{:.3f}".format(sig.avg_score) }}</td>
<td class="mono">{{ sig.correct }} / {{ sig.total_trades }}</td>
</tr>
</table>
</div>
<!-- Log tail -->
<!-- Signal Board -->
<div class="section">
<div class="section-header">
C25 Signal Board &nbsp;·&nbsp; 7 dage
<span class="count">{{ signal_board|length }}</span>
</div>
{% if signal_board %}
<table>
<tr>
<th>Selskab</th>
<th>Pris</th>
<th>Analytiker</th>
<th>Nyheder</th>
<th>Dækning</th>
<th style="min-width:140px">Signalstyrke</th>
<th>Vurdering</th>
</tr>
{% for s in signal_board %}
<tr>
<td>
<strong>{{ s.ticker }}</strong>
<span style="font-size:.72rem;color:var(--muted);margin-left:6px">{{ s.name }}</span>
{% if s.alerts %}<span class="badge badge-red" style="margin-left:6px">{{ s.alerts }} alert</span>{% endif %}
</td>
<td class="mono">{{ s.price }} kr</td>
<td>
<span class="badge {{ s.rec_class }}">{{ s.rec_clean }}</span>
<span style="font-size:.7rem;color:var(--muted);margin-left:4px">{{ s.rec_count }}</span>
</td>
<td><span class="badge {{ s.sent_class }}">{{ s.sent_text }}</span></td>
<td class="mono" style="font-size:.75rem">{{ s.n_articles }} art &middot; {{ s.n_sources }} src
{% if s.n_sources < 3 %}<span style="color:var(--red);font-size:.65rem"> lav</span>{% endif %}
</td>
<td>
<div style="display:flex;align-items:center;gap:8px">
<div style="flex:1;height:5px;background:var(--border);border-radius:3px">
<div style="height:100%;width:{{ s.signal_pct }}%;background:{{ s.sig_color }};border-radius:3px;transition:width .4s"></div>
</div>
<span class="mono" style="font-size:.7rem;color:var(--muted);white-space:nowrap">{{ s.signal_str }}</span>
</div>
</td>
<td><span class="badge {{ s.agree_class }}">{{ s.agree_text }}</span></td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="empty">Ingen signaler de seneste 7 dage &mdash; kør: make</div>
{% endif %}
</div>
<!-- Log (collapsible) -->
{% if log_tail %}
<div class="section">
<div class="section-title">📋 Seneste log ({{ log_file }})</div>
<details>
<summary>Runner log <span style="margin-left:8px;font-family:JetBrains Mono,monospace;font-size:.68rem;color:var(--accent)">{{ log_file }}</span></summary>
<pre>{{ log_tail }}</pre>
</details>
</div>
{% endif %}
<div class="footer">MoneyMaker · {{ db_type }} · hjess-desktop · auto-refresh {{ refresh }}s</div>
<div class="footer">MoneyMaker &nbsp;·&nbsp; {{ db_type }} &nbsp;·&nbsp; refresh {{ refresh }}s</div>
<div id="refresh-bar" style="width:100%"></div>
<script>
// ── ECharts theme ────────────────────────────────────────────────────────
const C = {
bg: '#ffffff',
border: '#e2e8f0',
muted: '#94a3b8',
text: '#0f172a',
green: '#16a34a',
red: '#dc2626',
blue: '#2563eb',
yellow: '#d97706',
};
const axisBase = {
axisLine: { lineStyle: { color: C.border } },
splitLine: { lineStyle: { color: C.border, type: 'dashed' } },
axisLabel: { color: C.muted, fontSize: 10 },
axisTick: { show: false },
};
// ── Equity chart ─────────────────────────────────────────────────────────
const equityData = {{ equity_json }};
const eqChart = echarts.init(document.getElementById('chart-equity'), null, { renderer: 'svg' });
eqChart.setOption({
backgroundColor: 'transparent',
grid: { top: 20, right: 16, bottom: 28, left: 52 },
tooltip: {
trigger: 'axis',
backgroundColor: '#f8fafc',
borderColor: C.border,
textStyle: { color: C.text, fontSize: 11 },
formatter: params => {
const d = params[0].data;
return `${params[0].axisValue}<br/><b style="color:${d[1]>=0?C.green:C.red}">${d[1]>=0?'+':''}${d[1].toFixed(0)} kr</b>`;
}
},
xAxis: { ...axisBase, type: 'category', data: equityData.map(d => d[0]), boundaryGap: false },
yAxis: { ...axisBase, type: 'value', splitNumber: 3 },
series: [{
type: 'line',
data: equityData.map(d => d[1]),
smooth: true,
showSymbol: false,
lineStyle: { color: C.blue, width: 2 },
areaStyle: {
color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [{ offset: 0, color: 'rgba(59,130,246,.2)' }, { offset: 1, color: 'rgba(59,130,246,0)' }] }
},
markLine: {
silent: true, symbol: 'none',
data: [{ yAxis: 0, lineStyle: { color: C.muted, type: 'dashed', width: 1 } }]
}
}]
});
// ── Win/Loss donut ────────────────────────────────────────────────────────
const wl = {{ winloss_json }};
const wlChart = echarts.init(document.getElementById('chart-winloss'), null, { renderer: 'svg' });
wlChart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: '#f8fafc',
borderColor: C.border,
textStyle: { color: C.text, fontSize: 11 },
},
legend: { show: false },
series: [{
type: 'pie',
radius: ['48%', '70%'],
center: ['50%', '52%'],
itemStyle: { borderRadius: 4, borderColor: C.bg, borderWidth: 2 },
label: {
show: true, position: 'center',
formatter: () => wl.total > 0 ? `${Math.round(wl.win/wl.total*100)}%\nkorrekt` : '',
color: C.text, fontSize: 13, fontWeight: '700', lineHeight: 18,
fontFamily: 'Inter, system-ui'
},
data: wl.total > 0
? [
{ value: wl.win, name: 'Korrekt', itemStyle: { color: C.green } },
{ value: wl.loss, name: 'Forkert', itemStyle: { color: C.red } },
{ value: wl.open, name: 'Aaben', itemStyle: { color: C.muted } },
]
: [{ value: 1, name: 'Ingen data', itemStyle: { color: C.border } }]
}]
});
// ── Positions bar ─────────────────────────────────────────────────────────
const posData = {{ positions_json }};
const posChart = echarts.init(document.getElementById('chart-positions'), null, { renderer: 'svg' });
const posColors = posData.map(d => d[1] >= 0 ? C.green : C.red);
posChart.setOption({
backgroundColor: 'transparent',
grid: { top: 12, right: 16, bottom: 40, left: 52 },
tooltip: {
trigger: 'axis', axisPointer: { type: 'none' },
backgroundColor: '#f8fafc', borderColor: C.border,
textStyle: { color: C.text, fontSize: 11 },
formatter: params => `${params[0].name}<br/><b style="color:${params[0].data>=0?C.green:C.red}">${params[0].data>=0?'+':''}${params[0].data.toFixed(0)} kr</b>`
},
xAxis: { ...axisBase, type: 'category', data: posData.map(d => d[0]),
axisLabel: { color: C.muted, fontSize: 10, rotate: posData.length > 5 ? 30 : 0 } },
yAxis: { ...axisBase, type: 'value', splitNumber: 3 },
series: [{
type: 'bar', data: posData.map(d => d[1]),
barMaxWidth: 40,
itemStyle: { color: p => posColors[p.dataIndex], borderRadius: [3,3,0,0] },
markLine: {
silent: true, symbol: 'none',
data: [{ yAxis: 0, lineStyle: { color: C.muted, type: 'dashed', width: 1 } }]
}
}]
});
// ── Resize ────────────────────────────────────────────────────────────────
window.addEventListener('resize', () => { eqChart.resize(); wlChart.resize(); posChart.resize(); });
// ── Refresh countdown bar ─────────────────────────────────────────────────
const REFRESH = {{ refresh }};
const bar = document.getElementById('refresh-bar');
let elapsed = 0;
const tick = setInterval(() => {
elapsed++;
bar.style.width = (100 - elapsed / REFRESH * 100) + '%';
if (elapsed >= REFRESH) { clearInterval(tick); location.reload(); }
}, 1000);
</script>
</body>
</html>
"""
@@ -262,7 +584,120 @@ def _closed_trades(db, limit: int = 20) -> list[dict]:
return [dict(r) for r in rows]
# ── Routes ────────────────────────────────────────────────────────────────────
def _equity_curve(db) -> list[list]:
"""Cumulative realised P&L per day from position_events (sell events only)."""
rows = db.execute("""
SELECT event_date, SUM(COALESCE(pnl_dkk, 0)) as daily_pnl
FROM position_events
WHERE action = 'sell'
GROUP BY event_date
ORDER BY event_date
""").fetchall()
cumulative = 0.0
result = []
for r in rows:
cumulative += float(r["daily_pnl"] or 0)
result.append([r["event_date"], round(cumulative, 0)])
if not result:
from datetime import date
result = [[str(date.today()), 0.0]]
return result
def _winloss_data(db) -> dict:
"""Win/loss/open counts for donut chart."""
rows = db.execute("""
SELECT signal_correct, COUNT(*) as cnt
FROM position_events
WHERE action = 'sell'
GROUP BY signal_correct
""").fetchall()
counts = {r["signal_correct"]: r["cnt"] for r in rows}
return {
"win": counts.get(1, 0),
"loss": counts.get(0, 0),
"open": counts.get(None, 0),
"total": sum(counts.values()),
}
def _signal_board_data(db) -> list[dict]:
"""Build signal board rows from DB + Yahoo Finance analyst consensus."""
from signals import company_stats, analyst_rec, C25
rows = company_stats(db, days=7)
result = []
for r in rows:
ticker = r["ticker"]
company = C25.get(ticker, {})
avg_s = r["avg_sentiment"] or 0
# Sentiment badge
if avg_s > 0.1:
sent_class, sent_text = "badge-green", "Positiv"
elif avg_s < -0.1:
sent_class, sent_text = "badge-red", "Negativ"
else:
sent_class, sent_text = "badge-gray", "Neutral"
# Analyst rec badge (signals.py caches per-process)
rec = analyst_rec(ticker)
if rec["mean"] is None:
rec_class, rec_clean = "badge-gray", "Ukendt"
elif rec["mean"] <= 1.5:
rec_class, rec_clean = "badge-green", "Stærk KØB"
elif rec["mean"] <= 2.5:
rec_class, rec_clean = "badge-green", "KØB"
elif rec["mean"] <= 3.5:
rec_class, rec_clean = "badge-yellow", "HOLD"
elif rec["mean"] <= 4.5:
rec_class, rec_clean = "badge-red", "SÆLG"
else:
rec_class, rec_clean = "badge-red", "Stærk SÆLG"
# Agreement between news and analysts
news_bull = avg_s > 0.1
news_bear = avg_s < -0.1
ana_bull = rec["mean"] is not None and rec["mean"] <= 2.5
ana_bear = rec["mean"] is not None and rec["mean"] >= 3.5
if (news_bull and ana_bull) or (news_bear and ana_bear):
agree_class, agree_text = "badge-green", "Enige"
elif (news_bull and ana_bear) or (news_bear and ana_bull):
agree_class, agree_text = "badge-red", "Uenige"
else:
agree_class, agree_text = "badge-gray", ""
# Signal strength bar (scale 00.5 → 0100%)
sig = r.get("max_signal") or 0
signal_pct = min(int(sig * 200), 100)
if sig >= 0.5: sig_color = "var(--green)"
elif sig >= 0.2: sig_color = "var(--yellow)"
elif sig > 0: sig_color = "var(--accent)"
else: sig_color = "var(--border)"
result.append({
"ticker": ticker,
"name": company.get("name", ticker),
"price": company.get("price_dkk_approx", "?"),
"rec_class": rec_class,
"rec_clean": rec_clean,
"rec_count": rec["count"],
"sent_class": sent_class,
"sent_text": sent_text,
"n_articles": r["mention_articles"],
"n_sources": int(r["avg_sources"] or 0),
"signal": sig,
"signal_str": f"{sig:.2f}",
"signal_pct": signal_pct,
"sig_color": sig_color,
"agree_class": agree_class,
"agree_text": agree_text,
"alerts": int(r.get("alert_count") or 0),
})
return result
# ── Routes ─────────────────────────────────────────────────────────────────
@app.route("/")
def index():
@@ -283,6 +718,16 @@ def index():
log_file, log_tail = _latest_log_tail()
now = datetime.now(timezone.utc).strftime("%d %b %Y %H:%M UTC")
equity = _equity_curve(db)
winloss = _winloss_data(db)
pos_bars = [[p["ticker"], round(p["unreal"], 0)] for p in positions] or [["", 0]]
try:
signal_board = _signal_board_data(db)
except Exception as exc:
signal_board = []
app.logger.warning("signal_board error: %s", exc)
return render_template_string(
TEMPLATE,
now=now,
@@ -303,6 +748,10 @@ def index():
sig=type("Sig", (), sig)(),
log_file=log_file,
log_tail=log_tail,
equity_json=json.dumps(equity),
winloss_json=json.dumps(winloss),
positions_json=json.dumps(pos_bars),
signal_board=signal_board,
)
finally:
db.close()