""" dashboard.py — MoneyMaker live monitoring dashboard. Usage: python dashboard.py # starts on http://localhost:5001 python dashboard.py --port 5002 Auto-refreshes every 60 seconds. Shows: - 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 import os import time from datetime import datetime, timezone from pathlib import Path import yfinance as yf 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 CAPITAL = 10_000 LOG_DIR = Path(os.getenv("LOG_DIR", str(Path(__file__).parent / "logs"))) METRICS_FILE = Path(os.getenv("DATA_DIR", str(Path(__file__).parent / "data"))) / "metrics.json" REFRESH = 60 # seconds app = Flask(__name__) # ── Metrics dashboard template ──────────────────────────────────────────────── METRICS_TEMPLATE = """ MoneyMaker · AI Costs

MoneyMaker

AI Costs
{{ now }}
← Dashboard
{% if no_data %}
📊
Ingen data endnu — kør analyze.py med Claude for at begynde at tracke forbrug.
{% else %}
Total Cost
${{ "%.4f"|format(total_cost_usd) }}
USD spent
Claude Calls
{{ total_calls }}
articles analyzed
Input Tokens
{{ "{:,}".format(total_input_tokens) }}
${{ "%.4f"|format(cost_input) }} · {{ input_pct }}%
Output Tokens
{{ "{:,}".format(total_output_tokens) }}
${{ "%.4f"|format(cost_output) }} · {{ output_pct }}%
Cost / Article
${{ "%.5f"|format(cost_per_call) }}
avg per Claude call
Token Distribution — Input vs Output
Cost Split
Token Breakdown
Input tokens
{{ "{:,}".format(total_input_tokens) }}
Output tokens
{{ "{:,}".format(total_output_tokens) }}
Total tokens
{{ "{:,}".format(total_input_tokens + total_output_tokens) }}
Model Details
Metric Value Note
Modelclaude-3-haiku-20240307Anthropic
Input price$0.25 / MTok${{ "%.6f"|format(0.25/1_000_000) }} per token
Output price$1.25 / MTok${{ "%.6f"|format(1.25/1_000_000) }} per token
Total calls{{ total_calls }}articles sent to Claude
Total input tokens{{ "{:,}".format(total_input_tokens) }}cost ${{ "%.5f"|format(cost_input) }}
Total output tokens{{ "{:,}".format(total_output_tokens) }}cost ${{ "%.5f"|format(cost_output) }}
Total cost${{ "%.6f"|format(total_cost_usd) }}cumulative
Last updated{{ last_updated }}
{% endif %} """ # ── HTML template ───────────────────────────────────────────────────────────── TEMPLATE = """ MoneyMaker

MoneyMaker

{{ db_type }}
{{ now }}
auto-refresh {{ refresh }}s
Net P&L
{{ "{:+,.0f}".format(net_pnl) }}
{{ "{:+.2f}%".format(net_pct) }} af kapital
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) }}
vs benchmark: {{ "{:+.2f}%".format(vs_bench) if vs_bench is not none else "—" }}
{% else %}
marked lukket
{% endif %}
Signal accuracy
{% if sig.total_trades > 0 %}
{{ "{:.0f}%".format(sig.accuracy_pct) }}
{{ sig.correct }}/{{ sig.total_trades }} handler
{% else %}
ingen handler endnu
{% endif %}
Kapital
{{ "{:,.0f}".format(capital) }}
kontant: {{ "{:,.0f}".format(cash) }} kr
Equity curve
Win / Loss
Positioner P&L
Aben positioner {{ open_count }}
{% if positions %} {% for p in positions %} {% endfor %}
TickerAntal KobNu P&LAendring StopTakeStatus
{{ 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) }} {% if p.stop_hit %}STOP {% elif p.take_hit %}TAKE {% else %}HOLD{% endif %}
{% else %}
Ingen abne positioner.
{% endif %}
Lukkede handler {{ trades|length }}
{% if trades %} {% for t in trades %} {% endfor %}
TickerTypeAntalKursTotalP&LSignalDato
{{ t.ticker }} {{ 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 {% else %}{% endif %} {{ t.event_date }}
{% else %}
Ingen handler endnu.
{% endif %}
NLP signal pipeline
Analyserede signaler Alert-triggers Gns. score Korrekte 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 %}
Selskab Pris Analytiker Nyheder Dækning Signalstyrke Vurdering
{{ 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 %}
Runner log {{ log_file }}
{{ log_tail }}
{% endif %}
""" # ── Data helpers ───────────────────────────────────────────────────────────── def _latest_log_tail(lines: int = 40) -> tuple[str, str]: """Return (filename, last N lines) from most recent runner log.""" if not LOG_DIR.exists(): return "", "" logs = sorted(LOG_DIR.glob("runner_*.log"), reverse=True) if not logs: return "", "" path = logs[0] try: content = path.read_text(errors="replace") tail = "\n".join(content.splitlines()[-lines:]) return path.name, tail except Exception: return path.name, "" def _open_positions_live(db) -> list[dict]: """Fetch open positions with live yfinance prices.""" rows = db.execute("SELECT * FROM positions").fetchall() result = [] for p in rows: ticker = p["ticker"] from report import C25 yf_ticker = C25.get(ticker, {}).get("ticker_yahoo", ticker + ".CO") try: last = yf.Ticker(yf_ticker).fast_info.get("lastPrice") or p["entry_price"] except Exception: last = p["entry_price"] entry = float(p["entry_price"]) shares = float(p["shares"]) pct = (last - entry) / entry * 100 result.append({ "ticker": ticker, "shares": shares, "entry": entry, "last": last, "unreal": shares * (last - entry), "pct": pct, "stop": float(p["stop_loss"]), "take": float(p["take_profit"]), "stop_hit": last <= p["stop_loss"], "take_hit": last >= p["take_profit"], }) return result def _closed_trades(db, limit: int = 20) -> list[dict]: rows = db.execute(""" SELECT ticker, action, shares, price, total_dkk, pnl_dkk, signal_correct, event_date FROM position_events ORDER BY id DESC LIMIT ? """, (limit,)).fetchall() return [dict(r) for r in rows] 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(): db = get_conn() try: positions = _open_positions_live(db) trades = _closed_trades(db) unreal = sum(p["unreal"] for p in positions) invested = sum(p["shares"] * p["entry"] for p in positions) realised = _realised_pnl(db) fees = _total_fees(db) sig = _signal_accuracy(db) c25_ret = _c25_day_return() net_pnl = realised + unreal - fees net_pct = net_pnl / CAPITAL * 100 vs_bench = (net_pct - c25_ret) if c25_ret is not None else None cash = CAPITAL - invested 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, db_type=DB_TYPE, refresh=REFRESH, capital=CAPITAL, cash=cash, unreal=unreal, realised=realised, fees=fees, net_pnl=net_pnl, net_pct=net_pct, c25_ret=c25_ret, vs_bench=vs_bench, positions=positions, open_count=len(positions), trades=trades, 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() @app.route("/health") def health(): return {"status": "ok", "db": DB_TYPE, "ts": time.time()} @app.route("/metrics") def metrics(): data: dict = {} if METRICS_FILE.exists(): try: data = json.loads(METRICS_FILE.read_text()) except Exception: pass # Total articles with Claude signal from DB try: db = get_conn() row = db.execute( "SELECT COUNT(DISTINCT article_slug) AS cnt FROM article_signals WHERE claude_reasoning != '' AND claude_reasoning IS NOT NULL" ).fetchone() data["total_articles_with_claude"] = row[0] if row else 0 db.close() except Exception: pass return jsonify(data) @app.route("/metrics-dash") def metrics_dash(): data: dict = {} if METRICS_FILE.exists(): try: data = json.loads(METRICS_FILE.read_text()) except Exception: pass total_calls = data.get("total_calls", 0) total_input_tokens = data.get("total_input_tokens", 0) total_output_tokens = data.get("total_output_tokens", 0) total_cost_usd = data.get("total_cost_usd", 0.0) last_updated = data.get("last_updated", "—") total_tokens = total_input_tokens + total_output_tokens input_pct = round(total_input_tokens / total_tokens * 100) if total_tokens else 0 output_pct = round(total_output_tokens / total_tokens * 100) if total_tokens else 0 cost_input = round(total_input_tokens * 0.80 / 1_000_000, 6) cost_output = round(total_output_tokens * 4.00 / 1_000_000, 6) cost_per_call = round(total_cost_usd / total_calls, 6) if total_calls else 0.0 return render_template_string( METRICS_TEMPLATE, no_data=total_calls == 0, now=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), total_calls=total_calls, total_input_tokens=total_input_tokens, total_output_tokens=total_output_tokens, total_cost_usd=total_cost_usd, last_updated=last_updated, input_pct=input_pct, output_pct=output_pct, cost_input=cost_input, cost_output=cost_output, cost_per_call=cost_per_call, ) # ── Main ────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="MoneyMaker Dashboard") parser.add_argument("--port", type=int, default=int(os.getenv("PORT", 5001))) parser.add_argument("--host", default=os.getenv("HOST", "0.0.0.0")) args = parser.parse_args() from db import init_schema init_schema() print(f"\n MoneyMaker Dashboard -> http://localhost:{args.port}\n") app.run(host=args.host, port=args.port, debug=False) if __name__ == "__main__": main()