Files
mmd/dashboard.py
Henrik Jess Nielsen 1df1bbbd47
Some checks failed
Build and Deploy MoneyMaker / build-and-deploy (push) Failing after 48m10s
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
2026-05-26 23:06:31 +02:00

778 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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")))
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">
<title>MoneyMaker</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
:root {
--bg: #eef2f7;
--surface: #ffffff;
--border: #e2e8f0;
--text: #0f172a;
--muted: #64748b;
--accent: #2563eb;
--green: #16a34a;
--red: #dc2626;
--yellow: #d97706;
--shadow-sm: 0 1px 2px rgba(0,0,0,.05);
--shadow: 0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.04);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
padding: 24px 32px 52px;
min-height: 100vh;
}
/* ─ Header ─ */
.header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 22px; padding-bottom: 18px;
border-bottom: 1px solid var(--border);
}
.header-left { display: flex; align-items: center; gap: 12px; }
.header h1 { font-size: 1.25rem; font-weight: 800; letter-spacing: -.03em; color: var(--text); }
.header h1 span { color: var(--accent); }
.header-pill {
font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em;
color: var(--accent); background: rgba(37,99,235,.09);
padding: 3px 9px; border-radius: 20px; border: 1px solid rgba(37,99,235,.18);
}
.header-meta { font-size: 0.7rem; color: var(--muted); text-align: right; line-height: 1.9; }
/* ─ KPI strip ─ */
.kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-bottom: 18px; }
@media (max-width: 1100px) { .kpis { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 600px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
.kpi {
background: var(--surface);
border-radius: 10px; padding: 15px 16px;
box-shadow: var(--shadow);
border-top: 3px solid transparent;
}
.kpi.kpi-neutral { border-top-color: var(--border); }
.kpi.kpi-blue { border-top-color: var(--accent); }
.kpi.kpi-green { border-top-color: var(--green); }
.kpi.kpi-red { border-top-color: var(--red); }
.kpi-label { font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; color: var(--muted); margin-bottom: 9px; }
.kpi-value { font-size: 1.6rem; font-weight: 800; line-height: 1; letter-spacing: -.04em; }
.kpi-sub { font-size: 0.71rem; color: var(--muted); margin-top: 6px; }
/* ─ Charts row ─ */
.charts { display: grid; grid-template-columns: 1fr 260px 260px; gap: 10px; margin-bottom: 18px; }
@media (max-width: 1100px) { .charts { grid-template-columns: 1fr; } }
.chart-card { background: var(--surface); border-radius: 10px; box-shadow: var(--shadow); }
.chart-card-header {
padding: 13px 16px 0;
font-size: 0.64rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .09em; color: var(--muted);
}
/* ─ Sections ─ */
.section { background: var(--surface); border-radius: 10px; box-shadow: var(--shadow); margin-bottom: 10px; overflow: hidden; }
.section-header {
padding: 11px 16px;
font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em;
color: var(--muted); background: #f8fafc;
border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center;
}
.section-header .count {
font-family: 'JetBrains Mono', monospace; font-size: 0.68rem;
color: var(--accent); background: rgba(37,99,235,.08);
padding: 1px 8px; border-radius: 20px;
}
/* ─ Tables ─ */
table { width: 100%; border-collapse: collapse; }
th {
padding: 8px 14px; font-size: 0.63rem; font-weight: 700;
text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); text-align: left;
border-bottom: 1px solid var(--border); white-space: nowrap;
background: #f8fafc;
}
td { padding: 10px 14px; font-size: 0.81rem; border-bottom: 1px solid #f1f5f9; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(37,99,235,.025); }
td.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.77rem; }
td strong { font-weight: 700; color: var(--text); }
.empty { padding: 18px 16px; font-size: 0.8rem; color: var(--muted); font-style: italic; }
/* ─ Colors ─ */
.pos { color: var(--green); font-weight: 600; }
.neg { color: var(--red); font-weight: 600; }
.neu { color: var(--muted); }
/* ─ Badges ─ */
.badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 20px; font-size: 0.64rem; font-weight: 700; letter-spacing: .04em; }
.badge-green { background: rgba(22,163,74,.1); color: var(--green); }
.badge-red { background: rgba(220,38,38,.1); color: var(--red); }
.badge-blue { background: rgba(37,99,235,.1); color: var(--accent); }
.badge-yellow { background: rgba(215,119,6,.1); color: var(--yellow); }
.badge-gray { background: rgba(100,116,139,.1); color: var(--muted); }
/* ─ Log ─ */
details summary {
padding: 11px 16px; font-size: 0.64rem; font-weight: 700;
text-transform: uppercase; letter-spacing: .09em;
color: var(--muted); background: #f8fafc;
cursor: pointer; list-style: none;
display: flex; justify-content: space-between;
}
details summary::-webkit-details-marker { display: none; }
details summary::after { content: '+'; font-size: 1rem; color: var(--accent); }
details[open] summary::after { content: ''; }
pre {
font-family: 'JetBrains Mono', monospace; font-size: 0.71rem; color: var(--muted);
padding: 12px 16px 16px; white-space: pre-wrap; line-height: 1.65;
overflow-x: auto; background: #fafbfc; border-top: 1px solid var(--border);
}
/* ─ Footer ─ */
.footer { font-size: 0.64rem; color: #cbd5e1; text-align: center; margin-top: 32px; letter-spacing: .05em; }
/* ─ Refresh bar ─ */
#refresh-bar { position: fixed; bottom: 0; left: 0; height: 2px; background: var(--accent); opacity: .45; transition: width 1s linear; }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<h1>Money<span>Maker</span></h1>
<span class="header-pill">{{ db_type }}</span>
</div>
<div class="header-meta">
{{ now }}<br>
auto-refresh {{ refresh }}s
</div>
</div>
<!-- KPI strip -->
<div class="kpis">
<div class="kpi {{ 'kpi-green' if net_pnl >= 0 else 'kpi-red' }}">
<div class="kpi-label">Net P&amp;L</div>
<div class="kpi-value {{ 'pos' if net_pnl >= 0 else 'neg' }}">{{ "{:+,.0f}".format(net_pnl) }}</div>
<div class="kpi-sub {{ 'pos' if net_pct >= 0 else 'neg' }}">{{ "{:+.2f}%".format(net_pct) }} af kapital</div>
</div>
<div class="kpi {{ 'kpi-green' if unreal >= 0 else 'kpi-red' }}">
<div class="kpi-label">Urealiseret</div>
<div class="kpi-value {{ 'pos' if unreal >= 0 else 'neg' }}">{{ "{:+,.0f}".format(unreal) }}</div>
<div class="kpi-sub">{{ open_count }} position{{ 'er' if open_count != 1 else '' }}</div>
</div>
<div class="kpi {{ 'kpi-green' if realised >= 0 else 'kpi-neutral' }}">
<div class="kpi-label">Realiseret</div>
<div class="kpi-value {{ 'pos' if realised >= 0 else 'neg' }}">{{ "{:+,.0f}".format(realised) }}</div>
<div class="kpi-sub neu">gebyrer: {{ "{:,.0f}".format(fees) }} kr</div>
</div>
<div class="kpi {{ 'kpi-green' if c25_ret and c25_ret >= 0 else 'kpi-neutral' }}">
<div class="kpi-label">C25 i dag</div>
{% if c25_ret is not none %}
<div class="kpi-value {{ 'pos' if c25_ret >= 0 else 'neg' }}">{{ "{:+.2f}%".format(c25_ret) }}</div>
<div class="kpi-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="kpi-value neu">—</div>
<div class="kpi-sub">marked lukket</div>
{% endif %}
</div>
<div class="kpi {{ 'kpi-green' if sig.total_trades > 0 and sig.accuracy_pct >= 50 else 'kpi-neutral' }}">
<div class="kpi-label">Signal accuracy</div>
{% if sig.total_trades > 0 %}
<div class="kpi-value {{ 'pos' if sig.accuracy_pct >= 50 else 'neg' }}">{{ "{:.0f}%".format(sig.accuracy_pct) }}</div>
<div class="kpi-sub">{{ sig.correct }}/{{ sig.total_trades }} handler</div>
{% else %}
<div class="kpi-value neu">—</div>
<div class="kpi-sub">ingen handler endnu</div>
{% endif %}
</div>
<div class="kpi kpi-blue">
<div class="kpi-label">Kapital</div>
<div class="kpi-value neu">{{ "{:,.0f}".format(capital) }}</div>
<div class="kpi-sub">kontant: {{ "{:,.0f}".format(cash) }} kr</div>
</div>
</div>
<!-- Charts row -->
<div class="charts">
<div class="chart-card">
<div class="chart-card-header">Equity curve</div>
<div id="chart-equity" style="height:220px;"></div>
</div>
<div class="chart-card">
<div class="chart-card-header">Win / Loss</div>
<div id="chart-winloss" style="height:220px;"></div>
</div>
<div class="chart-card">
<div class="chart-card-header">Positioner P&amp;L</div>
<div id="chart-positions" style="height:220px;"></div>
</div>
</div>
<!-- Open positions -->
<div class="section">
<div class="section-header">
Aben positioner
<span class="count">{{ open_count }}</span>
</div>
{% if positions %}
<table>
<tr>
<th>Ticker</th><th>Antal</th>
<th>Kob</th><th>Nu</th>
<th>P&amp;L</th><th>Aendring</th>
<th>Stop</th><th>Take</th><th>Status</th>
</tr>
{% for p in positions %}
<tr>
<td><strong>{{ p.ticker }}</strong></td>
<td class="mono">{{ "{:.0f}".format(p.shares) }}</td>
<td class="mono">{{ "{:,.0f}".format(p.entry) }}</td>
<td class="mono">{{ "{:,.0f}".format(p.last) }}</td>
<td class="mono {{ 'pos' if p.unreal >= 0 else 'neg' }}">{{ "{:+,.0f}".format(p.unreal) }}</td>
<td class="mono {{ 'pos' if p.pct >= 0 else 'neg' }}">{{ "{:+.1f}%".format(p.pct) }}</td>
<td class="mono neg">{{ "{:,.0f}".format(p.stop) }}</td>
<td class="mono 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-blue">HOLD</span>{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="empty">Ingen abne positioner.</div>
{% endif %}
</div>
<!-- Closed trades -->
<div class="section">
<div class="section-header">
Lukkede handler
<span class="count">{{ trades|length }}</span>
</div>
{% if trades %}
<table>
<tr><th>Ticker</th><th>Type</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><span class="badge {{ 'badge-green' if t.action == 'buy' else 'badge-red' }}">{{ t.action.upper() }}</span></td>
<td class="mono">{{ "{:.0f}".format(t.shares) }}</td>
<td class="mono">{{ "{:,.0f}".format(t.price) }}</td>
<td class="mono">{{ "{:,.0f}".format(t.total_dkk) }}</td>
<td class="mono {{ '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 class="mono" style="color:var(--muted)">{{ t.event_date }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="empty">Ingen handler endnu.</div>
{% endif %}
</div>
<!-- Signal pipeline -->
<div class="section">
<div class="section-header">NLP signal pipeline</div>
<table>
<tr>
<th>Analyserede signaler</th>
<th>Alert-triggers</th>
<th>Gns. score</th>
<th>Korrekte handler</th>
</tr>
<tr>
<td class="mono">{{ sig.total }}</td>
<td class="mono">{{ sig.alerts }}</td>
<td class="mono">{{ "{:.3f}".format(sig.avg_score) }}</td>
<td class="mono">{{ sig.correct }} / {{ sig.total_trades }}</td>
</tr>
</table>
</div>
<!-- Signal Board -->
<div class="section">
<div class="section-header">
C25 Signal Board &nbsp;·&nbsp; 7 dage
<span class="count">{{ signal_board|length }}</span>
</div>
{% if signal_board %}
<table>
<tr>
<th>Selskab</th>
<th>Pris</th>
<th>Analytiker</th>
<th>Nyheder</th>
<th>Dækning</th>
<th style="min-width:140px">Signalstyrke</th>
<th>Vurdering</th>
</tr>
{% for s in signal_board %}
<tr>
<td>
<strong>{{ s.ticker }}</strong>
<span style="font-size:.72rem;color:var(--muted);margin-left:6px">{{ s.name }}</span>
{% if s.alerts %}<span class="badge badge-red" style="margin-left:6px">{{ s.alerts }} alert</span>{% endif %}
</td>
<td class="mono">{{ s.price }} kr</td>
<td>
<span class="badge {{ s.rec_class }}">{{ s.rec_clean }}</span>
<span style="font-size:.7rem;color:var(--muted);margin-left:4px">{{ s.rec_count }}</span>
</td>
<td><span class="badge {{ s.sent_class }}">{{ s.sent_text }}</span></td>
<td class="mono" style="font-size:.75rem">{{ s.n_articles }} art &middot; {{ s.n_sources }} src
{% if s.n_sources < 3 %}<span style="color:var(--red);font-size:.65rem"> lav</span>{% endif %}
</td>
<td>
<div style="display:flex;align-items:center;gap:8px">
<div style="flex:1;height:5px;background:var(--border);border-radius:3px">
<div style="height:100%;width:{{ s.signal_pct }}%;background:{{ s.sig_color }};border-radius:3px;transition:width .4s"></div>
</div>
<span class="mono" style="font-size:.7rem;color:var(--muted);white-space:nowrap">{{ s.signal_str }}</span>
</div>
</td>
<td><span class="badge {{ s.agree_class }}">{{ s.agree_text }}</span></td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="empty">Ingen signaler de seneste 7 dage &mdash; kør: make</div>
{% endif %}
</div>
<!-- Log (collapsible) -->
{% if log_tail %}
<div class="section">
<details>
<summary>Runner log <span style="margin-left:8px;font-family:JetBrains Mono,monospace;font-size:.68rem;color:var(--accent)">{{ log_file }}</span></summary>
<pre>{{ log_tail }}</pre>
</details>
</div>
{% endif %}
<div class="footer">MoneyMaker &nbsp;·&nbsp; {{ db_type }} &nbsp;·&nbsp; refresh {{ refresh }}s</div>
<div id="refresh-bar" style="width:100%"></div>
<script>
// ── ECharts theme ────────────────────────────────────────────────────────
const C = {
bg: '#ffffff',
border: '#e2e8f0',
muted: '#94a3b8',
text: '#0f172a',
green: '#16a34a',
red: '#dc2626',
blue: '#2563eb',
yellow: '#d97706',
};
const axisBase = {
axisLine: { lineStyle: { color: C.border } },
splitLine: { lineStyle: { color: C.border, type: 'dashed' } },
axisLabel: { color: C.muted, fontSize: 10 },
axisTick: { show: false },
};
// ── Equity chart ─────────────────────────────────────────────────────────
const equityData = {{ equity_json }};
const eqChart = echarts.init(document.getElementById('chart-equity'), null, { renderer: 'svg' });
eqChart.setOption({
backgroundColor: 'transparent',
grid: { top: 20, right: 16, bottom: 28, left: 52 },
tooltip: {
trigger: 'axis',
backgroundColor: '#f8fafc',
borderColor: C.border,
textStyle: { color: C.text, fontSize: 11 },
formatter: params => {
const d = params[0].data;
return `${params[0].axisValue}<br/><b style="color:${d[1]>=0?C.green:C.red}">${d[1]>=0?'+':''}${d[1].toFixed(0)} kr</b>`;
}
},
xAxis: { ...axisBase, type: 'category', data: equityData.map(d => d[0]), boundaryGap: false },
yAxis: { ...axisBase, type: 'value', splitNumber: 3 },
series: [{
type: 'line',
data: equityData.map(d => d[1]),
smooth: true,
showSymbol: false,
lineStyle: { color: C.blue, width: 2 },
areaStyle: {
color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [{ offset: 0, color: 'rgba(59,130,246,.2)' }, { offset: 1, color: 'rgba(59,130,246,0)' }] }
},
markLine: {
silent: true, symbol: 'none',
data: [{ yAxis: 0, lineStyle: { color: C.muted, type: 'dashed', width: 1 } }]
}
}]
});
// ── Win/Loss donut ────────────────────────────────────────────────────────
const wl = {{ winloss_json }};
const wlChart = echarts.init(document.getElementById('chart-winloss'), null, { renderer: 'svg' });
wlChart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: '#f8fafc',
borderColor: C.border,
textStyle: { color: C.text, fontSize: 11 },
},
legend: { show: false },
series: [{
type: 'pie',
radius: ['48%', '70%'],
center: ['50%', '52%'],
itemStyle: { borderRadius: 4, borderColor: C.bg, borderWidth: 2 },
label: {
show: true, position: 'center',
formatter: () => wl.total > 0 ? `${Math.round(wl.win/wl.total*100)}%\nkorrekt` : '',
color: C.text, fontSize: 13, fontWeight: '700', lineHeight: 18,
fontFamily: 'Inter, system-ui'
},
data: wl.total > 0
? [
{ value: wl.win, name: 'Korrekt', itemStyle: { color: C.green } },
{ value: wl.loss, name: 'Forkert', itemStyle: { color: C.red } },
{ value: wl.open, name: 'Aaben', itemStyle: { color: C.muted } },
]
: [{ value: 1, name: 'Ingen data', itemStyle: { color: C.border } }]
}]
});
// ── Positions bar ─────────────────────────────────────────────────────────
const posData = {{ positions_json }};
const posChart = echarts.init(document.getElementById('chart-positions'), null, { renderer: 'svg' });
const posColors = posData.map(d => d[1] >= 0 ? C.green : C.red);
posChart.setOption({
backgroundColor: 'transparent',
grid: { top: 12, right: 16, bottom: 40, left: 52 },
tooltip: {
trigger: 'axis', axisPointer: { type: 'none' },
backgroundColor: '#f8fafc', borderColor: C.border,
textStyle: { color: C.text, fontSize: 11 },
formatter: params => `${params[0].name}<br/><b style="color:${params[0].data>=0?C.green:C.red}">${params[0].data>=0?'+':''}${params[0].data.toFixed(0)} kr</b>`
},
xAxis: { ...axisBase, type: 'category', data: posData.map(d => d[0]),
axisLabel: { color: C.muted, fontSize: 10, rotate: posData.length > 5 ? 30 : 0 } },
yAxis: { ...axisBase, type: 'value', splitNumber: 3 },
series: [{
type: 'bar', data: posData.map(d => d[1]),
barMaxWidth: 40,
itemStyle: { color: p => posColors[p.dataIndex], borderRadius: [3,3,0,0] },
markLine: {
silent: true, symbol: 'none',
data: [{ yAxis: 0, lineStyle: { color: C.muted, type: 'dashed', width: 1 } }]
}
}]
});
// ── Resize ────────────────────────────────────────────────────────────────
window.addEventListener('resize', () => { eqChart.resize(); wlChart.resize(); posChart.resize(); });
// ── Refresh countdown bar ─────────────────────────────────────────────────
const REFRESH = {{ refresh }};
const bar = document.getElementById('refresh-bar');
let elapsed = 0;
const tick = setInterval(() => {
elapsed++;
bar.style.width = (100 - elapsed / REFRESH * 100) + '%';
if (elapsed >= REFRESH) { clearInterval(tick); location.reload(); }
}, 1000);
</script>
</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]
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 00.5 → 0100%)
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()}
# ── 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()