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:
|
2026-05-26 23:06:31 +02:00
|
|
|
|
- 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
|
2026-05-26 22:30:38 +02:00
|
|
|
|
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
|
2026-05-26 23:06:31 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
CAPITAL = 10_000
|
2026-05-26 22:30:38 +02:00
|
|
|
|
LOG_DIR = Path(os.getenv("LOG_DIR", str(Path(__file__).parent / "logs")))
|
2026-05-26 22:21:27 +02:00
|
|
|
|
REFRESH = 60 # seconds
|
|
|
|
|
|
|
|
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
|
2026-05-26 23:06:31 +02:00
|
|
|
|
# ── HTML template ─────────────────────────────────────────────────────────────
|
2026-05-26 22:21:27 +02:00
|
|
|
|
|
2026-05-26 23:06:31 +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">
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
: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; }
|
2026-05-26 23:06:31 +02:00
|
|
|
|
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>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
</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>
|
2026-05-26 22:21:27 +02:00
|
|
|
|
</div>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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 %}
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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 %}
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<div class="kpi-value neu">—</div>
|
|
|
|
|
|
<div class="kpi-sub">marked lukket</div>
|
2026-05-26 22:21:27 +02:00
|
|
|
|
{% endif %}
|
|
|
|
|
|
</div>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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 %}
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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 %}
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<div class="kpi-value neu">—</div>
|
|
|
|
|
|
<div class="kpi-sub">ingen handler endnu</div>
|
2026-05-26 22:21:27 +02:00
|
|
|
|
{% endif %}
|
|
|
|
|
|
</div>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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>
|
2026-05-26 22:21:27 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Open positions -->
|
|
|
|
|
|
<div class="section">
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
Aben positioner
|
|
|
|
|
|
<span class="count">{{ open_count }}</span>
|
|
|
|
|
|
</div>
|
2026-05-26 22:21:27 +02:00
|
|
|
|
{% if positions %}
|
|
|
|
|
|
<table>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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>
|
2026-05-26 22:21:27 +02:00
|
|
|
|
{% for p in positions %}
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><strong>{{ p.ticker }}</strong></td>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
{% 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 %}
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<div class="empty">Ingen abne positioner.</div>
|
2026-05-26 22:21:27 +02:00
|
|
|
|
{% endif %}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Closed trades -->
|
|
|
|
|
|
<div class="section">
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
Lukkede handler
|
|
|
|
|
|
<span class="count">{{ trades|length }}</span>
|
|
|
|
|
|
</div>
|
2026-05-26 22:21:27 +02:00
|
|
|
|
{% if trades %}
|
|
|
|
|
|
<table>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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>
|
2026-05-26 22:21:27 +02:00
|
|
|
|
{% for t in trades %}
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><strong>{{ t.ticker }}</strong></td>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
{% 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>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<td class="mono" style="color:var(--muted)">{{ t.event_date }}</td>
|
2026-05-26 22:21:27 +02:00
|
|
|
|
</tr>
|
|
|
|
|
|
{% endfor %}
|
|
|
|
|
|
</table>
|
|
|
|
|
|
{% else %}
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<div class="empty">Ingen handler endnu.</div>
|
2026-05-26 22:21:27 +02:00
|
|
|
|
{% endif %}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<!-- Signal pipeline -->
|
2026-05-26 22:21:27 +02:00
|
|
|
|
<div class="section">
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<div class="section-header">NLP signal pipeline</div>
|
2026-05-26 22:21:27 +02:00
|
|
|
|
<table>
|
|
|
|
|
|
<tr>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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>
|
2026-05-26 22:21:27 +02:00
|
|
|
|
</tr>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
{% endfor %}
|
2026-05-26 22:21:27 +02:00
|
|
|
|
</table>
|
2026-05-26 23:06:31 +02:00
|
|
|
|
{% else %}
|
|
|
|
|
|
<div class="empty">Ingen signaler de seneste 7 dage — kør: make</div>
|
|
|
|
|
|
{% endif %}
|
2026-05-26 22:21:27 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<!-- Log (collapsible) -->
|
2026-05-26 22:21:27 +02:00
|
|
|
|
{% if log_tail %}
|
|
|
|
|
|
<div class="section">
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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 %}
|
|
|
|
|
|
|
2026-05-26 23:06:31 +02:00
|
|
|
|
<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>
|
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"]
|
2026-05-26 23:06:31 +02:00
|
|
|
|
entry = float(p["entry_price"])
|
2026-05-26 22:21:27 +02:00
|
|
|
|
shares = float(p["shares"])
|
2026-05-26 23:06:31 +02:00
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 23:06:31 +02:00
|
|
|
|
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 ─────────────────────────────────────────────────────────────────
|
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")
|
|
|
|
|
|
|
2026-05-26 23:06:31 +02:00
|
|
|
|
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,
|
2026-05-26 23:06:31 +02:00
|
|
|
|
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()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
|
parser = argparse.ArgumentParser(description="MoneyMaker Dashboard")
|
2026-05-26 22:30:38 +02:00
|
|
|
|
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()
|
2026-05-26 22:30:38 +02:00
|
|
|
|
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()
|