Some checks failed
Build and Deploy MoneyMaker / build-and-deploy (push) Failing after 15s
- 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
329 lines
13 KiB
Python
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 }} · Opdateret: {{ now }} · Refresh om {{ refresh }}s</div>
|
|
|
|
<!-- KPI cards -->
|
|
<div class="grid">
|
|
<div class="card">
|
|
<div class="label">Net P&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&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&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()
|