From 1df1bbbd47cc0753308606154a9d606d270a679f Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Tue, 26 May 2026 23:06:31 +0200 Subject: [PATCH] feat(dashboard): add C25 Signal Board + light theme polish - 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 --- Makefile | 7 +- dashboard.py | 657 +++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 558 insertions(+), 106 deletions(-) diff --git a/Makefile b/Makefile index 73e9ac0..eb5d347 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/dashboard.py b/dashboard.py index 1a7771d..160fe14 100644 --- a/dashboard.py +++ b/dashboard.py @@ -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 = """ - +TEMPLATE = """ - - MoneyMaker Dashboard + MoneyMaker + + + -

📈 MoneyMaker

-
DB: {{ db_type }}  ·  Opdateret: {{ now }}  ·  Refresh om {{ refresh }}s
- -
-
-
Net P&L
-
{{ "{:+,.0f}".format(net_pnl) }} kr
-
{{ "{:+.2f}%".format(net_pct) }}
+
+
+

MoneyMaker

+ {{ db_type }}
-
-
Urealiseret
-
{{ "{:+,.0f}".format(unreal) }} kr
-
{{ open_count }} åben{{ 'e' if open_count != 1 else '' }} position{{ 'er' if open_count != 1 else '' }}
+
+ {{ now }}
+ auto-refresh {{ refresh }}s
-
-
Realiseret
-
{{ "{:+,.0f}".format(realised) }} kr
-
Gebyrer: {{ "{:,.0f}".format(fees) }} kr
+
+ + +
+
+
Net P&L
+
{{ "{:+,.0f}".format(net_pnl) }}
+
{{ "{:+.2f}%".format(net_pct) }} af kapital
-
-
C25 i dag
+
+
Urealiseret
+
{{ "{:+,.0f}".format(unreal) }}
+
{{ open_count }} position{{ 'er' if open_count != 1 else '' }}
+
+
+
Realiseret
+
{{ "{:+,.0f}".format(realised) }}
+
gebyrer: {{ "{:,.0f}".format(fees) }} kr
+
+
+
C25 i dag
{% if c25_ret is not none %} -
{{ "{:+.2f}%".format(c25_ret) }}
-
+
{{ "{:+.2f}%".format(c25_ret) }}
+
vs benchmark: {{ "{:+.2f}%".format(vs_bench) if vs_bench is not none else "—" }}
{% else %} -
-
Marked lukket?
+
+
marked lukket
{% endif %}
-
-
Signal accuracy
+
+
Signal accuracy
{% if sig.total_trades > 0 %} -
{{ "{:.0f}%".format(sig.accuracy_pct) }}
-
{{ sig.correct }} / {{ sig.total_trades }} handler korrekte
+
{{ "{:.0f}%".format(sig.accuracy_pct) }}
+
{{ sig.correct }}/{{ sig.total_trades }} handler
{% else %} -
-
Ingen lukkede handler
+
+
ingen handler endnu
{% endif %}
-
-
Kapital
-
{{ "{:,.0f}".format(capital) }} kr
-
Kontant: {{ "{:,.0f}".format(cash) }} kr
+
+
Kapital
+
{{ "{:,.0f}".format(capital) }}
+
kontant: {{ "{:,.0f}".format(cash) }} kr
+
+
+ + +
+
+
Equity curve
+
+
+
+
Win / Loss
+
+
+
+
Positioner P&L
+
-
📊 Åbne positioner
+
+ Aben positioner + {{ open_count }} +
{% if positions %} - + + + + + + {% for p in positions %} - - - - - - - + + + + + + + {% endfor %}
TickerAntalKøbtNuP&LÆndringStopTakeStatus
TickerAntalKobNuP&LAendringStopTakeStatus
{{ p.ticker }}{{ "{:.0f}".format(p.shares) }}{{ "{:,.0f}".format(p.entry) }}{{ "{:,.0f}".format(p.last) }}{{ "{:+,.0f}".format(p.unreal) }}{{ "{:+.1f}%".format(p.pct) }}{{ "{:,.0f}".format(p.stop) }}{{ "{:,.0f}".format(p.take) }}{{ "{:.0f}".format(p.shares) }}{{ "{:,.0f}".format(p.entry) }}{{ "{:,.0f}".format(p.last) }}{{ "{:+,.0f}".format(p.unreal) }}{{ "{:+.1f}%".format(p.pct) }}{{ "{:,.0f}".format(p.stop) }}{{ "{:,.0f}".format(p.take) }} - {% if p.stop_hit %}🔴 STOP - {% elif p.take_hit %}🟡 TAKE - {% else %}⏳ HOLD{% endif %} + {% if p.stop_hit %}STOP + {% elif p.take_hit %}TAKE + {% else %}HOLD{% endif %}
{% else %} -

Ingen åbne positioner.

+
Ingen abne positioner.
{% endif %}
-
🏁 Lukkede handler
+
+ Lukkede handler + {{ trades|length }} +
{% if trades %} - + {% for t in trades %} - - - - - + + + + - + {% endfor %}
TickerHandlingAntalKursTotalP&LSignalDato
TickerTypeAntalKursTotalP&LSignalDato
{{ t.ticker }}{{ t.action.upper() }}{{ "{:.0f}".format(t.shares) }}{{ "{:,.0f}".format(t.price) }}{{ "{:,.0f}".format(t.total_dkk) }} + {{ t.action.upper() }}{{ "{:.0f}".format(t.shares) }}{{ "{:,.0f}".format(t.price) }}{{ "{:,.0f}".format(t.total_dkk) }} {{ "{:+,.0f}".format(t.pnl_dkk) if t.pnl_dkk is not none else "—" }} - {% if t.signal_correct == 1 %}✅ korrekt - {% elif t.signal_correct == 0 %}❌ forkert + {% if t.signal_correct == 1 %}korrekt + {% elif t.signal_correct == 0 %}forkert {% else %}{% endif %} {{ t.event_date }}{{ t.event_date }}
{% else %} -

Ingen handler endnu.

+
Ingen handler endnu.
{% endif %}
- +
-
🔬 NLP signal pipeline
+
NLP signal pipeline
- - - - + + + + + + + + + +
Analyserede signalerAlert-triggers (≥threshold)Gns. score
{{ sig.total }}{{ sig.alerts }}{{ "{:.3f}".format(sig.avg_score) }}Analyserede signalerAlert-triggersGns. scoreKorrekte handler
{{ sig.total }}{{ sig.alerts }}{{ "{:.3f}".format(sig.avg_score) }}{{ sig.correct }} / {{ sig.total_trades }}
- + +
+
+ C25 Signal Board  ·  7 dage + {{ signal_board|length }} +
+ {% if signal_board %} + + + + + + + + + + + {% for s in signal_board %} + + + + + + + + + + {% endfor %} +
SelskabPrisAnalytikerNyhederDækningSignalstyrkeVurdering
+ {{ s.ticker }} + {{ s.name }} + {% if s.alerts %}{{ s.alerts }} alert{% endif %} + {{ s.price }} kr + {{ s.rec_clean }} + {{ s.rec_count }} + {{ s.sent_text }}{{ s.n_articles }} art · {{ s.n_sources }} src + {% if s.n_sources < 3 %} lav{% endif %} + +
+
+
+
+ {{ s.signal_str }} +
+
{{ s.agree_text }}
+ {% else %} +
Ingen signaler de seneste 7 dage — kør: make
+ {% endif %} +
+ + {% if log_tail %}
-
📋 Seneste log ({{ log_file }})
-
{{ log_tail }}
+
+ Runner log {{ log_file }} +
{{ log_tail }}
+
{% endif %} - + +
+ + """ @@ -235,9 +557,9 @@ def _open_positions_live(db) -> list[dict]: last = yf.Ticker(yf_ticker).fast_info.get("lastPrice") or p["entry_price"] except Exception: last = p["entry_price"] - entry = float(p["entry_price"]) + entry = float(p["entry_price"]) shares = float(p["shares"]) - pct = (last - entry) / entry * 100 + pct = (last - entry) / entry * 100 result.append({ "ticker": ticker, "shares": shares, @@ -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 0–0.5 → 0–100%) + 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()