feat(dashboard): add C25 Signal Board + light theme polish
Some checks failed
Build and Deploy MoneyMaker / build-and-deploy (push) Failing after 48m10s
Some checks failed
Build and Deploy MoneyMaker / build-and-deploy (push) Failing after 48m10s
- Add signal board section showing per-ticker NLP analysis from DB - Badge-yellow CSS class added - _signal_board_data() helper: maps company_stats() + analyst_rec() to clean badge classes, signal strength bar, agreement status - Error-safe: falls back to empty list on import/query failure - badge-yellow added to CSS palette
This commit is contained in:
7
Makefile
7
Makefile
@@ -1,6 +1,6 @@
|
|||||||
PY := .venv/bin/python
|
PY := .venv/bin/python
|
||||||
|
|
||||||
.PHONY: help run signals buy fetch rss analyze force dry company saxo saxo-buy saxo-sell saxo-login saxo-status
|
.PHONY: help run signals buy fetch rss analyze force dry company saxo saxo-buy saxo-sell saxo-login saxo-status dashboard
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo ""
|
@echo ""
|
||||||
@@ -12,7 +12,7 @@ help:
|
|||||||
@echo " make buy → vis kun køb-kandidater"
|
@echo " make buy → vis kun køb-kandidater"
|
||||||
@echo " make fetch → hent nye artikler fra Ground News"
|
@echo " make fetch → hent nye artikler fra Ground News"
|
||||||
@echo " make rss → hent danske RSS feeds (Børsen, Finans, Politiken)"
|
@echo " make rss → hent danske RSS feeds (Børsen, Finans, Politiken)"
|
||||||
@echo " make orders → vis dagens køb/sælg/hold forslag"
|
@echo " make dashboard → start dashboard på http://localhost:5001"
|
||||||
@echo " make portfolio → vis åbne positioner + P&L"
|
@echo " make portfolio → vis åbne positioner + P&L"
|
||||||
@echo " make company → TICKER=NOVO-B make company"
|
@echo " make company → TICKER=NOVO-B make company"
|
||||||
@echo " make saxo → vis Saxo SIM konto status + positioner"
|
@echo " make saxo → vis Saxo SIM konto status + positioner"
|
||||||
@@ -43,6 +43,9 @@ fetch:
|
|||||||
rss:
|
rss:
|
||||||
$(PY) rss_feeds.py
|
$(PY) rss_feeds.py
|
||||||
|
|
||||||
|
dashboard:
|
||||||
|
$(PY) dashboard.py
|
||||||
|
|
||||||
orders:
|
orders:
|
||||||
$(PY) portfolio.py orders
|
$(PY) portfolio.py orders
|
||||||
|
|
||||||
|
|||||||
657
dashboard.py
657
dashboard.py
@@ -6,11 +6,12 @@ Usage:
|
|||||||
python dashboard.py --port 5002
|
python dashboard.py --port 5002
|
||||||
|
|
||||||
Auto-refreshes every 60 seconds. Shows:
|
Auto-refreshes every 60 seconds. Shows:
|
||||||
• Portfolio P&L + C25 benchmark
|
- Portfolio P&L + C25 benchmark
|
||||||
• Open positions with live prices
|
- Equity curve, win/loss, position charts (Apache ECharts)
|
||||||
• Closed trades (win/loss)
|
- Open positions with live prices
|
||||||
• Signal accuracy
|
- Closed trades (win/loss)
|
||||||
• Recent runner log tail
|
- Signal accuracy
|
||||||
|
- Recent runner log tail
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -20,7 +21,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
from flask import Flask, render_template_string
|
from flask import Flask, jsonify, render_template_string
|
||||||
|
|
||||||
from db import get_conn, DB_TYPE
|
from db import get_conn, DB_TYPE
|
||||||
from report import _c25_day_return, _unrealised_pnl, _realised_pnl, _total_fees, _signal_accuracy
|
from report import _c25_day_return, _unrealised_pnl, _realised_pnl, _total_fees, _signal_accuracy
|
||||||
@@ -31,175 +32,496 @@ REFRESH = 60 # seconds
|
|||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# ── HTML template ────────────────────────────────────────────────────────────
|
# ── HTML template ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
TEMPLATE = """
|
TEMPLATE = """<!DOCTYPE html>
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="da">
|
<html lang="da">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta http-equiv="refresh" content="{{ refresh }}">
|
<title>MoneyMaker</title>
|
||||||
<title>MoneyMaker Dashboard</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>
|
<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; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e2e8f0; padding: 24px; }
|
body {
|
||||||
h1 { font-size: 1.4rem; font-weight: 700; color: #7dd3fc; margin-bottom: 4px; }
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
.subtitle { font-size: 0.8rem; color: #64748b; margin-bottom: 24px; }
|
background: var(--bg);
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
color: var(--text);
|
||||||
.card { background: #1e2535; border-radius: 10px; padding: 18px 20px; border: 1px solid #2d3748; }
|
padding: 24px 32px 52px;
|
||||||
.card .label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: .06em; color: #64748b; margin-bottom: 6px; }
|
min-height: 100vh;
|
||||||
.card .value { font-size: 1.6rem; font-weight: 700; }
|
}
|
||||||
.card .sub { font-size: 0.78rem; color: #94a3b8; margin-top: 4px; }
|
/* ─ Header ─ */
|
||||||
.pos { color: #4ade80; }
|
.header {
|
||||||
.neg { color: #f87171; }
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
.neu { color: #94a3b8; }
|
margin-bottom: 22px; padding-bottom: 18px;
|
||||||
.warn { color: #fbbf24; }
|
border-bottom: 1px solid var(--border);
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
}
|
||||||
th { text-align: left; padding: 8px 12px; background: #1e2535; color: #64748b; font-size: 0.72rem; text-transform: uppercase; letter-spacing: .06em; border-bottom: 1px solid #2d3748; }
|
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||||
td { padding: 9px 12px; border-bottom: 1px solid #1a2030; }
|
.header h1 { font-size: 1.25rem; font-weight: 800; letter-spacing: -.03em; color: var(--text); }
|
||||||
tr:hover td { background: #1e2535; }
|
.header h1 span { color: var(--accent); }
|
||||||
.section { background: #161c2d; border-radius: 10px; border: 1px solid #2d3748; margin-bottom: 20px; overflow: hidden; }
|
.header-pill {
|
||||||
.section-title { padding: 14px 18px; font-size: 0.8rem; font-weight: 600; color: #7dd3fc; text-transform: uppercase; letter-spacing: .08em; border-bottom: 1px solid #2d3748; background: #1a2236; }
|
font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em;
|
||||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 99px; font-size: 0.72rem; font-weight: 600; }
|
color: var(--accent); background: rgba(37,99,235,.09);
|
||||||
.badge-green { background: #14532d; color: #4ade80; }
|
padding: 3px 9px; border-radius: 20px; border: 1px solid rgba(37,99,235,.18);
|
||||||
.badge-red { background: #450a0a; color: #f87171; }
|
}
|
||||||
.badge-gray { background: #1e2535; color: #94a3b8; }
|
.header-meta { font-size: 0.7rem; color: var(--muted); text-align: right; line-height: 1.9; }
|
||||||
pre { font-family: 'Cascadia Code', 'Fira Mono', monospace; font-size: 0.75rem; color: #94a3b8; padding: 16px 18px; overflow-x: auto; white-space: pre-wrap; line-height: 1.6; }
|
/* ─ KPI strip ─ */
|
||||||
.footer { font-size: 0.72rem; color: #334155; text-align: center; margin-top: 32px; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>📈 MoneyMaker</h1>
|
|
||||||
<div class="subtitle">DB: {{ db_type }} · Opdateret: {{ now }} · Refresh om {{ refresh }}s</div>
|
|
||||||
|
|
||||||
<!-- KPI cards -->
|
<div class="header">
|
||||||
<div class="grid">
|
<div class="header-left">
|
||||||
<div class="card">
|
<h1>Money<span>Maker</span></h1>
|
||||||
<div class="label">Net P&L</div>
|
<span class="header-pill">{{ db_type }}</span>
|
||||||
<div class="value {{ 'pos' if net_pnl >= 0 else 'neg' }}">{{ "{:+,.0f}".format(net_pnl) }} kr</div>
|
|
||||||
<div class="sub">{{ "{:+.2f}%".format(net_pct) }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="header-meta">
|
||||||
<div class="label">Urealiseret</div>
|
{{ now }}<br>
|
||||||
<div class="value {{ 'pos' if unreal >= 0 else 'neg' }}">{{ "{:+,.0f}".format(unreal) }} kr</div>
|
auto-refresh {{ refresh }}s
|
||||||
<div class="sub">{{ open_count }} åben{{ 'e' if open_count != 1 else '' }} position{{ 'er' if open_count != 1 else '' }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
</div>
|
||||||
<div class="label">Realiseret</div>
|
|
||||||
<div class="value {{ 'pos' if realised >= 0 else 'neg' }}">{{ "{:+,.0f}".format(realised) }} kr</div>
|
<!-- KPI strip -->
|
||||||
<div class="sub">Gebyrer: {{ "{:,.0f}".format(fees) }} kr</div>
|
<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>
|
||||||
<div class="card">
|
<div class="kpi {{ 'kpi-green' if unreal >= 0 else 'kpi-red' }}">
|
||||||
<div class="label">C25 i dag</div>
|
<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 %}
|
{% if c25_ret is not none %}
|
||||||
<div class="value {{ 'pos' if c25_ret >= 0 else 'neg' }}">{{ "{:+.2f}%".format(c25_ret) }}</div>
|
<div class="kpi-value {{ 'pos' if c25_ret >= 0 else 'neg' }}">{{ "{:+.2f}%".format(c25_ret) }}</div>
|
||||||
<div class="sub {{ 'pos' if vs_bench and vs_bench >= 0 else 'neg' }}">
|
<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 "—" }}
|
vs benchmark: {{ "{:+.2f}%".format(vs_bench) if vs_bench is not none else "—" }}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="value neu">—</div>
|
<div class="kpi-value neu">—</div>
|
||||||
<div class="sub">Marked lukket?</div>
|
<div class="kpi-sub">marked lukket</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="kpi {{ 'kpi-green' if sig.total_trades > 0 and sig.accuracy_pct >= 50 else 'kpi-neutral' }}">
|
||||||
<div class="label">Signal accuracy</div>
|
<div class="kpi-label">Signal accuracy</div>
|
||||||
{% if sig.total_trades > 0 %}
|
{% if sig.total_trades > 0 %}
|
||||||
<div class="value {{ 'pos' if sig.accuracy_pct >= 50 else 'neg' }}">{{ "{:.0f}%".format(sig.accuracy_pct) }}</div>
|
<div class="kpi-value {{ 'pos' if sig.accuracy_pct >= 50 else 'neg' }}">{{ "{:.0f}%".format(sig.accuracy_pct) }}</div>
|
||||||
<div class="sub">{{ sig.correct }} / {{ sig.total_trades }} handler korrekte</div>
|
<div class="kpi-sub">{{ sig.correct }}/{{ sig.total_trades }} handler</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="value neu">—</div>
|
<div class="kpi-value neu">—</div>
|
||||||
<div class="sub">Ingen lukkede handler</div>
|
<div class="kpi-sub">ingen handler endnu</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="kpi kpi-blue">
|
||||||
<div class="label">Kapital</div>
|
<div class="kpi-label">Kapital</div>
|
||||||
<div class="value neu">{{ "{:,.0f}".format(capital) }} kr</div>
|
<div class="kpi-value neu">{{ "{:,.0f}".format(capital) }}</div>
|
||||||
<div class="sub">Kontant: {{ "{:,.0f}".format(cash) }} kr</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Open positions -->
|
<!-- Open positions -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">📊 Åbne positioner</div>
|
<div class="section-header">
|
||||||
|
Aben positioner
|
||||||
|
<span class="count">{{ open_count }}</span>
|
||||||
|
</div>
|
||||||
{% if positions %}
|
{% if positions %}
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Ticker</th><th>Antal</th><th>Købt</th><th>Nu</th><th>P&L</th><th>Ændring</th><th>Stop</th><th>Take</th><th>Status</th></tr>
|
<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 %}
|
{% for p in positions %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{ p.ticker }}</strong></td>
|
<td><strong>{{ p.ticker }}</strong></td>
|
||||||
<td>{{ "{:.0f}".format(p.shares) }}</td>
|
<td class="mono">{{ "{:.0f}".format(p.shares) }}</td>
|
||||||
<td>{{ "{:,.0f}".format(p.entry) }}</td>
|
<td class="mono">{{ "{:,.0f}".format(p.entry) }}</td>
|
||||||
<td>{{ "{:,.0f}".format(p.last) }}</td>
|
<td class="mono">{{ "{:,.0f}".format(p.last) }}</td>
|
||||||
<td class="{{ 'pos' if p.unreal >= 0 else 'neg' }}">{{ "{:+,.0f}".format(p.unreal) }}</td>
|
<td class="mono {{ 'pos' if p.unreal >= 0 else 'neg' }}">{{ "{:+,.0f}".format(p.unreal) }}</td>
|
||||||
<td class="{{ 'pos' if p.pct >= 0 else 'neg' }}">{{ "{:+.1f}%".format(p.pct) }}</td>
|
<td class="mono {{ 'pos' if p.pct >= 0 else 'neg' }}">{{ "{:+.1f}%".format(p.pct) }}</td>
|
||||||
<td class="neg">{{ "{:,.0f}".format(p.stop) }}</td>
|
<td class="mono neg">{{ "{:,.0f}".format(p.stop) }}</td>
|
||||||
<td class="pos">{{ "{:,.0f}".format(p.take) }}</td>
|
<td class="mono pos">{{ "{:,.0f}".format(p.take) }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if p.stop_hit %}<span class="badge badge-red">🔴 STOP</span>
|
{% if p.stop_hit %}<span class="badge badge-red">STOP</span>
|
||||||
{% elif p.take_hit %}<span class="badge badge-green">🟡 TAKE</span>
|
{% elif p.take_hit %}<span class="badge badge-green">TAKE</span>
|
||||||
{% else %}<span class="badge badge-gray">⏳ HOLD</span>{% endif %}
|
{% else %}<span class="badge badge-blue">HOLD</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="padding:16px 18px; color:#64748b; font-size:0.85rem;">Ingen åbne positioner.</p>
|
<div class="empty">Ingen abne positioner.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Closed trades -->
|
<!-- Closed trades -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">🏁 Lukkede handler</div>
|
<div class="section-header">
|
||||||
|
Lukkede handler
|
||||||
|
<span class="count">{{ trades|length }}</span>
|
||||||
|
</div>
|
||||||
{% if trades %}
|
{% if trades %}
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Ticker</th><th>Handling</th><th>Antal</th><th>Kurs</th><th>Total</th><th>P&L</th><th>Signal</th><th>Dato</th></tr>
|
<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 %}
|
{% for t in trades %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{{ t.ticker }}</strong></td>
|
<td><strong>{{ t.ticker }}</strong></td>
|
||||||
<td>{{ t.action.upper() }}</td>
|
<td><span class="badge {{ 'badge-green' if t.action == 'buy' else 'badge-red' }}">{{ t.action.upper() }}</span></td>
|
||||||
<td>{{ "{:.0f}".format(t.shares) }}</td>
|
<td class="mono">{{ "{:.0f}".format(t.shares) }}</td>
|
||||||
<td>{{ "{:,.0f}".format(t.price) }}</td>
|
<td class="mono">{{ "{:,.0f}".format(t.price) }}</td>
|
||||||
<td>{{ "{:,.0f}".format(t.total_dkk) }}</td>
|
<td class="mono">{{ "{:,.0f}".format(t.total_dkk) }}</td>
|
||||||
<td class="{{ 'pos' if t.pnl_dkk and t.pnl_dkk >= 0 else ('neg' if t.pnl_dkk else 'neu') }}">
|
<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 "—" }}
|
{{ "{:+,.0f}".format(t.pnl_dkk) if t.pnl_dkk is not none else "—" }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if t.signal_correct == 1 %}<span class="badge badge-green">✅ korrekt</span>
|
{% if t.signal_correct == 1 %}<span class="badge badge-green">korrekt</span>
|
||||||
{% elif t.signal_correct == 0 %}<span class="badge badge-red">❌ forkert</span>
|
{% elif t.signal_correct == 0 %}<span class="badge badge-red">forkert</span>
|
||||||
{% else %}<span class="badge badge-gray">—</span>{% endif %}
|
{% else %}<span class="badge badge-gray">—</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ t.event_date }}</td>
|
<td class="mono" style="color:var(--muted)">{{ t.event_date }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="padding:16px 18px; color:#64748b; font-size:0.85rem;">Ingen handler endnu.</p>
|
<div class="empty">Ingen handler endnu.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Signal pipeline stats -->
|
<!-- Signal pipeline -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">🔬 NLP signal pipeline</div>
|
<div class="section-header">NLP signal pipeline</div>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Analyserede signaler</th><th>Alert-triggers (≥threshold)</th><th>Gns. score</th></tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ sig.total }}</td>
|
<th>Analyserede signaler</th>
|
||||||
<td>{{ sig.alerts }}</td>
|
<th>Alert-triggers</th>
|
||||||
<td>{{ "{:.3f}".format(sig.avg_score) }}</td>
|
<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>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Log tail -->
|
<!-- 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 %}
|
{% if log_tail %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">📋 Seneste log ({{ log_file }})</div>
|
<details>
|
||||||
<pre>{{ log_tail }}</pre>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="footer">MoneyMaker · {{ db_type }} · hjess-desktop · auto-refresh {{ refresh }}s</div>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
@@ -235,9 +557,9 @@ def _open_positions_live(db) -> list[dict]:
|
|||||||
last = yf.Ticker(yf_ticker).fast_info.get("lastPrice") or p["entry_price"]
|
last = yf.Ticker(yf_ticker).fast_info.get("lastPrice") or p["entry_price"]
|
||||||
except Exception:
|
except Exception:
|
||||||
last = p["entry_price"]
|
last = p["entry_price"]
|
||||||
entry = float(p["entry_price"])
|
entry = float(p["entry_price"])
|
||||||
shares = float(p["shares"])
|
shares = float(p["shares"])
|
||||||
pct = (last - entry) / entry * 100
|
pct = (last - entry) / entry * 100
|
||||||
result.append({
|
result.append({
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"shares": shares,
|
"shares": shares,
|
||||||
@@ -262,7 +584,120 @@ def _closed_trades(db, limit: int = 20) -> list[dict]:
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
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("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
@@ -283,6 +718,16 @@ def index():
|
|||||||
log_file, log_tail = _latest_log_tail()
|
log_file, log_tail = _latest_log_tail()
|
||||||
now = datetime.now(timezone.utc).strftime("%d %b %Y %H:%M UTC")
|
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(
|
return render_template_string(
|
||||||
TEMPLATE,
|
TEMPLATE,
|
||||||
now=now,
|
now=now,
|
||||||
@@ -303,6 +748,10 @@ def index():
|
|||||||
sig=type("Sig", (), sig)(),
|
sig=type("Sig", (), sig)(),
|
||||||
log_file=log_file,
|
log_file=log_file,
|
||||||
log_tail=log_tail,
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user