Files
mmd/dashboard.py

1114 lines
46 KiB
Python
Raw Normal View History

2026-05-26 22:21:27 +02:00
"""
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
2026-05-26 22:21:27 +02:00
"""
import argparse
import json
import os
2026-05-26 22:21:27 +02:00
import time
from datetime import datetime, timezone
from pathlib import Path
import yfinance as yf
from flask import Flask, jsonify, render_template_string
2026-05-26 22:21:27 +02:00
from db import get_conn, DB_TYPE
from report import _c25_day_return, _unrealised_pnl, _realised_pnl, _total_fees, _signal_accuracy
2026-05-28 13:11:29 +02:00
CAPITAL = 10_000
LOG_DIR = Path(os.getenv("LOG_DIR", str(Path(__file__).parent / "logs")))
METRICS_FILE = Path(__file__).parent / "metrics.json"
REFRESH = 60 # seconds
CAPITAL = 10_000
LOG_DIR = Path(os.getenv("LOG_DIR", str(Path(__file__).parent / "logs")))
METRICS_FILE = Path(__file__).parent / "metrics.json"
REFRESH = 60 # seconds
2026-05-26 22:21:27 +02:00
app = Flask(__name__)
2026-05-28 13:11:29 +02:00
# ── Metrics dashboard template ────────────────────────────────────────────────
METRICS_TEMPLATE = """<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MoneyMaker · AI Costs</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;
--purple: #7c3aed;
--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 {
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; }
.header h1 span { color: var(--accent); }
.header-pill {
font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em;
color: var(--purple); background: rgba(124,58,237,.09);
padding: 3px 9px; border-radius: 20px; border: 1px solid rgba(124,58,237,.18);
}
.header-meta { font-size: 0.7rem; color: var(--muted); text-align: right; line-height: 1.9; }
.back-link {
font-size: 0.72rem; color: var(--accent); text-decoration: none; font-weight: 600;
display: inline-flex; align-items: center; gap: 4px;
}
.back-link:hover { text-decoration: underline; }
/* KPI strip */
.kpis { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 18px; }
@media (max-width: 1000px) { .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.blue { border-top-color: var(--accent); }
.kpi.green { border-top-color: var(--green); }
.kpi.red { border-top-color: var(--red); }
.kpi.yellow { border-top-color: var(--yellow); }
.kpi.purple { border-top-color: var(--purple); }
.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; font-family: 'JetBrains Mono', monospace; }
.kpi-value.sm { font-size: 1.15rem; }
.kpi-sub { font-size: 0.71rem; color: var(--muted); margin-top: 6px; }
/* Charts row */
.charts { display: grid; grid-template-columns: 1fr 320px; gap: 10px; margin-bottom: 18px; }
@media (max-width: 900px) { .charts { grid-template-columns: 1fr; } }
.chart-card {
background: var(--surface); border-radius: 10px; box-shadow: var(--shadow);
padding: 14px 16px 8px;
}
.chart-card-title {
font-size: 0.64rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .09em; color: var(--muted); margin-bottom: 4px;
}
/* Burn rate bar */
.burn-section { background: var(--surface); border-radius: 10px; box-shadow: var(--shadow); padding: 16px 20px; margin-bottom: 18px; }
.burn-title { font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; color: var(--muted); margin-bottom: 14px; }
.burn-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.burn-label { font-size: 0.75rem; color: var(--muted); width: 130px; flex-shrink: 0; }
.burn-bar-bg { flex: 1; height: 8px; background: #f1f5f9; border-radius: 4px; overflow: hidden; }
.burn-bar-fg { height: 100%; border-radius: 4px; transition: width .6s ease; }
.burn-val { font-size: 0.72rem; font-family: 'JetBrains Mono', monospace; color: var(--text); width: 80px; text-align: right; flex-shrink: 0; }
/* Info table */
.section { background: var(--surface); border-radius: 10px; box-shadow: var(--shadow); 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);
}
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); 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; }
.empty { padding: 24px 16px; font-size: 0.8rem; color: var(--muted); font-style: italic; text-align: center; }
.footer { font-size: 0.64rem; color: #cbd5e1; text-align: center; margin-top: 32px; letter-spacing: .05em; }
.no-data { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 60px 20px; }
.no-data-icon { font-size: 2.5rem; }
.no-data-text { font-size: 0.88rem; color: var(--muted); }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<h1>Money<span>Maker</span></h1>
<span class="header-pill">AI Costs</span>
</div>
<div class="header-meta">
{{ now }}<br>
<a class="back-link" href="/"> Dashboard</a>
</div>
</div>
{% if no_data %}
<div class="section">
<div class="no-data">
<div class="no-data-icon">📊</div>
<div class="no-data-text">Ingen data endnu kør analyze.py med Claude for at begynde at tracke forbrug.</div>
</div>
</div>
{% else %}
<div class="kpis">
<div class="kpi red">
<div class="kpi-label">Total Cost</div>
<div class="kpi-value{% if total_cost_usd >= 1 %} sm{% endif %}">${{ "%.4f"|format(total_cost_usd) }}</div>
<div class="kpi-sub">USD spent</div>
</div>
<div class="kpi blue">
<div class="kpi-label">Claude Calls</div>
<div class="kpi-value">{{ total_calls }}</div>
<div class="kpi-sub">articles analyzed</div>
</div>
<div class="kpi yellow">
<div class="kpi-label">Input Tokens</div>
<div class="kpi-value sm">{{ "{:,}".format(total_input_tokens) }}</div>
<div class="kpi-sub">${{ "%.4f"|format(cost_input) }} · {{ input_pct }}%</div>
</div>
<div class="kpi purple">
<div class="kpi-label">Output Tokens</div>
<div class="kpi-value sm">{{ "{:,}".format(total_output_tokens) }}</div>
<div class="kpi-sub">${{ "%.4f"|format(cost_output) }} · {{ output_pct }}%</div>
</div>
<div class="kpi green">
<div class="kpi-label">Cost / Article</div>
<div class="kpi-value{% if cost_per_call >= 0.01 %} sm{% endif %}">${{ "%.5f"|format(cost_per_call) }}</div>
<div class="kpi-sub">avg per Claude call</div>
</div>
</div>
<div class="charts">
<div class="chart-card">
<div class="chart-card-title">Token Distribution Input vs Output</div>
<div id="bar-chart" style="height:220px;"></div>
</div>
<div class="chart-card">
<div class="chart-card-title">Cost Split</div>
<div id="donut-chart" style="height:220px;"></div>
</div>
</div>
<div class="burn-section">
<div class="burn-title">Token Breakdown</div>
<div class="burn-row">
<div class="burn-label">Input tokens</div>
<div class="burn-bar-bg"><div class="burn-bar-fg" style="width:{{ input_pct }}%; background: var(--yellow);"></div></div>
<div class="burn-val">{{ "{:,}".format(total_input_tokens) }}</div>
</div>
<div class="burn-row">
<div class="burn-label">Output tokens</div>
<div class="burn-bar-bg"><div class="burn-bar-fg" style="width:{{ output_pct }}%; background: var(--purple);"></div></div>
<div class="burn-val">{{ "{:,}".format(total_output_tokens) }}</div>
</div>
<div class="burn-row">
<div class="burn-label">Total tokens</div>
<div class="burn-bar-bg"><div class="burn-bar-fg" style="width:100%; background: var(--accent);"></div></div>
<div class="burn-val">{{ "{:,}".format(total_input_tokens + total_output_tokens) }}</div>
</div>
</div>
<div class="section">
<div class="section-header">Model Details</div>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
<th>Note</th>
</tr>
</thead>
<tbody>
<tr><td>Model</td><td class="mono">claude-haiku-4-5</td><td>Anthropic</td></tr>
<tr><td>Input price</td><td class="mono">$0.80 / MTok</td><td>${{ "%.6f"|format(0.80/1_000_000) }} per token</td></tr>
<tr><td>Output price</td><td class="mono">$4.00 / MTok</td><td>${{ "%.6f"|format(4.00/1_000_000) }} per token</td></tr>
<tr><td>Total calls</td><td class="mono">{{ total_calls }}</td><td>articles sent to Claude</td></tr>
<tr><td>Total input tokens</td><td class="mono">{{ "{:,}".format(total_input_tokens) }}</td><td>cost ${{ "%.5f"|format(cost_input) }}</td></tr>
<tr><td>Total output tokens</td><td class="mono">{{ "{:,}".format(total_output_tokens) }}</td><td>cost ${{ "%.5f"|format(cost_output) }}</td></tr>
<tr><td>Total cost</td><td class="mono">${{ "%.6f"|format(total_cost_usd) }}</td><td>cumulative</td></tr>
<tr><td>Last updated</td><td class="mono">{{ last_updated }}</td><td></td></tr>
</tbody>
</table>
</div>
<script>
// Bar chart: tokens per type
const barChart = echarts.init(document.getElementById('bar-chart'));
barChart.setOption({
grid: { top: 24, right: 20, bottom: 28, left: 70 },
tooltip: { trigger: 'axis', formatter: p => p[0].name + '<br/>' + p[0].marker + ' ' + p[0].seriesName + ': <b>' + p[0].value.toLocaleString() + '</b>' },
xAxis: { type: 'category', data: ['Input', 'Output'], axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', axisLabel: { fontSize: 10, formatter: v => v >= 1000 ? (v/1000).toFixed(0)+'k' : v } },
series: [{
name: 'Tokens',
type: 'bar', barMaxWidth: 60,
data: [
{ value: {{ total_input_tokens }}, itemStyle: { color: '#d97706', borderRadius: [4,4,0,0] } },
{ value: {{ total_output_tokens }}, itemStyle: { color: '#7c3aed', borderRadius: [4,4,0,0] } },
],
label: { show: true, position: 'top', formatter: p => p.value.toLocaleString(), fontSize: 10, color: '#64748b' }
}]
});
// Donut: cost split
const donut = echarts.init(document.getElementById('donut-chart'));
donut.setOption({
tooltip: { trigger: 'item', formatter: '{b}: ${c} ({d}%)' },
legend: { bottom: 0, left: 'center', textStyle: { fontSize: 10, color: '#64748b' } },
series: [{
type: 'pie', radius: ['42%', '68%'],
center: ['50%', '44%'],
label: { show: false },
emphasis: { label: { show: true, fontSize: 12, fontWeight: 'bold' } },
data: [
{ value: {{ "%.6f"|format(cost_input) }}, name: 'Input', itemStyle: { color: '#d97706' } },
{ value: {{ "%.6f"|format(cost_output) }}, name: 'Output', itemStyle: { color: '#7c3aed' } },
]
}]
});
window.addEventListener('resize', () => { barChart.resize(); donut.resize(); });
</script>
{% endif %}
<div class="footer">MoneyMaker · claude-haiku-4-5 · metrics updated on each analyze run</div>
</body>
</html>
"""
# ── HTML template ─────────────────────────────────────────────────────────────
2026-05-26 22:21:27 +02:00
TEMPLATE = """<!DOCTYPE html>
2026-05-26 22:21:27 +02:00
<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>
2026-05-26 22:21:27 +02:00
<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);
}
2026-05-26 22:21:27 +02:00
* { 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; }
2026-05-26 22:21:27 +02:00
</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
2026-05-26 22:21:27 +02:00
</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>
2026-05-26 22:21:27 +02:00
</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>
2026-05-26 22:21:27 +02:00
</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>
2026-05-26 22:21:27 +02:00
{% 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' }}">
2026-05-26 22:21:27 +02:00
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>
2026-05-26 22:21:27 +02:00
{% 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>
2026-05-26 22:21:27 +02:00
{% 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>
2026-05-26 22:21:27 +02:00
{% else %}
<div class="kpi-value neu"></div>
<div class="kpi-sub">ingen handler endnu</div>
2026-05-26 22:21:27 +02:00
{% 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>
2026-05-26 22:21:27 +02:00
</div>
</div>
<!-- Open positions -->
<div class="section">
<div class="section-header">
Aben positioner
<span class="count">{{ open_count }}</span>
</div>
2026-05-26 22:21:27 +02:00
{% 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>
2026-05-26 22:21:27 +02:00
{% 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>
2026-05-26 22:21:27 +02:00
<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 %}
2026-05-26 22:21:27 +02:00
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="empty">Ingen abne positioner.</div>
2026-05-26 22:21:27 +02:00
{% endif %}
</div>
<!-- Closed trades -->
<div class="section">
<div class="section-header">
Lukkede handler
<span class="count">{{ trades|length }}</span>
</div>
2026-05-26 22:21:27 +02:00
{% 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>
2026-05-26 22:21:27 +02:00
{% 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') }}">
2026-05-26 22:21:27 +02:00
{{ "{:+,.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>
2026-05-26 22:21:27 +02:00
{% else %}<span class="badge badge-gray"></span>{% endif %}
</td>
<td class="mono" style="color:var(--muted)">{{ t.event_date }}</td>
2026-05-26 22:21:27 +02:00
</tr>
{% endfor %}
</table>
{% else %}
<div class="empty">Ingen handler endnu.</div>
2026-05-26 22:21:27 +02:00
{% endif %}
</div>
<!-- Signal pipeline -->
2026-05-26 22:21:27 +02:00
<div class="section">
<div class="section-header">NLP signal pipeline</div>
2026-05-26 22:21:27 +02:00
<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>
2026-05-26 22:21:27 +02:00
</tr>
{% endfor %}
2026-05-26 22:21:27 +02:00
</table>
{% else %}
<div class="empty">Ingen signaler de seneste 7 dage &mdash; kør: make</div>
{% endif %}
2026-05-26 22:21:27 +02:00
</div>
<!-- Log (collapsible) -->
2026-05-26 22:21:27 +02:00
{% 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>
2026-05-26 22:21:27 +02:00
</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>
2026-05-26 22:21:27 +02:00
</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"])
2026-05-26 22:21:27 +02:00
shares = float(p["shares"])
pct = (last - entry) / entry * 100
2026-05-26 22:21:27 +02:00
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 ─────────────────────────────────────────────────────────────────
2026-05-26 22:21:27 +02:00
@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)
2026-05-26 22:21:27 +02:00
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,
2026-05-26 22:21:27 +02:00
)
finally:
db.close()
@app.route("/health")
def health():
return {"status": "ok", "db": DB_TYPE, "ts": time.time()}
2026-05-28 13:11:29 +02:00
@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,
)
2026-05-26 22:21:27 +02:00
# ── 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"))
2026-05-26 22:21:27 +02:00
args = parser.parse_args()
from db import init_schema
init_schema()
print(f"\n MoneyMaker Dashboard -> http://localhost:{args.port}\n")
2026-05-26 22:21:27 +02:00
app.run(host=args.host, port=args.port, debug=False)
if __name__ == "__main__":
main()