- Track Claude token usage + cost in metrics.json after each call - Add /metrics JSON endpoint - Add /metrics-dash visual dashboard (KPIs, charts, burn bars) - Switch model to claude-3-haiku-20240307 (3.2x cheaper) - Add per-run cap (50 calls) and daily spend cap ($2.00, env: CLAUDE_DAILY_CAP_USD)
1109 lines
45 KiB
Python
1109 lines
45 KiB
Python
"""
|
||
dashboard.py — MoneyMaker live monitoring dashboard.
|
||
|
||
Usage:
|
||
python dashboard.py # starts on http://localhost:5001
|
||
python dashboard.py --port 5002
|
||
|
||
Auto-refreshes every 60 seconds. Shows:
|
||
- Portfolio P&L + C25 benchmark
|
||
- 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
|
||
|
||
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-3-haiku-20240307</td><td>Anthropic</td></tr>
|
||
<tr><td>Input price</td><td class="mono">$0.25 / MTok</td><td>${{ "%.6f"|format(0.25/1_000_000) }} per token</td></tr>
|
||
<tr><td>Output price</td><td class="mono">$1.25 / MTok</td><td>${{ "%.6f"|format(1.25/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-3-haiku-20240307 · 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&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&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&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&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 · 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 · {{ 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 — 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 · {{ db_type }} · 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 0–0.5 → 0–100%)
|
||
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()
|