Files
mmd/dashboard.py
Henrik Jess Nielsen 2294f5bc07
All checks were successful
Build and Deploy MoneyMaker / build-and-deploy (push) Successful in 12m22s
Metrics
2026-05-28 13:11:29 +02:00

1114 lines
46 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")))
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
app = Flask(__name__)
# ── 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 ─────────────────────────────────────────────────────────────
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()}
@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,
)
# ── 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()
from db import init_schema
init_schema()
print(f"\n MoneyMaker Dashboard -> http://localhost:{args.port}\n")
app.run(host=args.host, port=args.port, debug=False)
if __name__ == "__main__":
main()