Files
mmd/dashboard.py
Henrik Jess Nielsen 6f1ee72e10
Some checks failed
Build and Deploy MoneyMaker / build-and-deploy (push) Failing after 15s
feat: containerize for mmd.i80.dk deployment via Gitea/Nomad
- Add Dockerfile (python:3.12-slim, HF_HOME=/app/data/hf-cache)
- Add mmd.nomad (multi-task: web=dashboard, worker=scheduler)
- Add .gitea/workflows/deploy.yml (build->Harbor, deploy->Nomad)
- Add scheduler.py (stdlib scheduler replaces cron in container)
- Add requirements.txt
- dashboard.py: LOG_DIR + PORT/HOST from env vars
- saxo_auth.py: TOKEN_FILE from SAXO_TOKEN_FILE env var
- .gitignore: proper ignores for container project

Volume moneymaker-data (/app/data) holds:
  - logs/ (shared between web+worker)
  - .saxo_token.json (pre-copy once after first deploy)
  - hf-cache/ (HuggingFace FinBERT cache)

Gitea secrets required: DATABASE_URL, ANTHROPIC_API_KEY,
SAXO_APP_KEY, SAXO_APP_SECRET_1, HARBOR_ROBOT_TOKEN
2026-05-26 22:30:38 +02:00

329 lines
13 KiB
Python

"""
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
• 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, 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")))
REFRESH = 60 # seconds
app = Flask(__name__)
# ── HTML template ────────────────────────────────────────────────────────────
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>
<style>
* { 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; }
</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>
<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>
<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>
{% 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' }}">
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>
{% endif %}
</div>
<div class="card">
<div class="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>
{% else %}
<div class="value neu">—</div>
<div class="sub">Ingen lukkede handler</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>
</div>
<!-- Open positions -->
<div class="section">
<div class="section-title">📊 Åbne positioner</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>
{% 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>
{% 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 %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p style="padding:16px 18px; color:#64748b; font-size:0.85rem;">Ingen åbne positioner.</p>
{% endif %}
</div>
<!-- Closed trades -->
<div class="section">
<div class="section-title">🏁 Lukkede handler</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>
{% 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') }}">
{{ "{:+,.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>
{% else %}<span class="badge badge-gray">—</span>{% endif %}
</td>
<td>{{ t.event_date }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p style="padding:16px 18px; color:#64748b; font-size:0.85rem;">Ingen handler endnu.</p>
{% endif %}
</div>
<!-- Signal pipeline stats -->
<div class="section">
<div class="section-title">🔬 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>
</tr>
</table>
</div>
<!-- Log tail -->
{% if log_tail %}
<div class="section">
<div class="section-title">📋 Seneste log ({{ log_file }})</div>
<pre>{{ log_tail }}</pre>
</div>
{% endif %}
<div class="footer">MoneyMaker · {{ db_type }} · hjess-desktop · auto-refresh {{ refresh }}s</div>
</body>
</html>
"""
# ── 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]
# ── 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")
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,
)
finally:
db.close()
@app.route("/health")
def health():
return {"status": "ok", "db": DB_TYPE, "ts": time.time()}
# ── 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()
print(f"\n MoneyMaker Dashboard -> http://localhost:{args.port}\n")
app.run(host=args.host, port=args.port, debug=False)
if __name__ == "__main__":
main()