First commit
This commit is contained in:
327
dashboard.py
Normal file
327
dashboard.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
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 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(__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=5001)
|
||||
parser.add_argument("--host", default="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()
|
||||
Reference in New Issue
Block a user