Metrics
All checks were successful
Build and Deploy MoneyMaker / build-and-deploy (push) Successful in 12m22s

This commit is contained in:
Henrik Jess Nielsen
2026-05-28 13:11:29 +02:00
parent 7feff0589f
commit 2294f5bc07
2 changed files with 377 additions and 9 deletions

View File

@@ -29,6 +29,7 @@ import math
import sqlite3
import logging
import warnings
from datetime import datetime
from pathlib import Path
# Silence transformer noise before importing
@@ -78,6 +79,37 @@ MIN_COVERAGE_SPREAD = 0.0 # disabled: signal_score naturally zeros out single-
FINBERT_MIN_CONF = 0.70 # drop neutral articles below this FinBERT confidence
ALERT_THRESHOLD = 0.35 # signal_score > this → alert
# ---------------------------------------------------------------------------
# Claude metrics
# ---------------------------------------------------------------------------
METRICS_FILE = Path(__file__).parent / "metrics.json"
# Pricing: Claude Haiku 4.5 — https://www.anthropic.com/pricing
_PRICE_INPUT_PER_TOKEN = 0.80 / 1_000_000 # $0.80 per MTok
_PRICE_OUTPUT_PER_TOKEN = 4.00 / 1_000_000 # $4.00 per MTok
def calc_cost(input_tokens: int, output_tokens: int) -> float:
return round(input_tokens * _PRICE_INPUT_PER_TOKEN + output_tokens * _PRICE_OUTPUT_PER_TOKEN, 6)
def update_metrics(input_tokens: int, output_tokens: int) -> None:
"""Accumulate Claude token usage into metrics.json."""
cost = calc_cost(input_tokens, output_tokens)
data: dict = {}
if METRICS_FILE.exists():
try:
data = json.loads(METRICS_FILE.read_text())
except Exception:
pass
data["total_calls"] = data.get("total_calls", 0) + 1
data["total_input_tokens"] = data.get("total_input_tokens", 0) + input_tokens
data["total_output_tokens"] = data.get("total_output_tokens", 0) + output_tokens
data["total_cost_usd"] = round(data.get("total_cost_usd", 0.0) + cost, 6)
data["last_updated"] = datetime.now().isoformat(timespec="seconds")
METRICS_FILE.write_text(json.dumps(data, indent=2))
# ---------------------------------------------------------------------------
# Model loading
@@ -195,16 +227,16 @@ def coverage_spread_score(row) -> float:
# Claude structured extraction
# ---------------------------------------------------------------------------
def claude_extract(title: str, text: str, tickers: list[str]) -> dict:
def claude_extract(title: str, text: str, tickers: list[str]) -> tuple[dict, int, int]:
"""
Use Claude Haiku to extract structured financial signal.
Returns {"confirmed_tickers", "magnitude", "timeframe", "reasoning"}.
Returns ({"confirmed_tickers", "magnitude", "timeframe", "reasoning"}, input_tokens, output_tokens).
"""
import anthropic
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
return {"confirmed_tickers": tickers, "magnitude": 5, "timeframe": "days", "reasoning": "(no API key)"}
return {"confirmed_tickers": tickers, "magnitude": 5, "timeframe": "days", "reasoning": "(no API key)"}, 0, 0
client = anthropic.Anthropic(api_key=api_key)
ticker_ctx = "\n".join(
@@ -245,10 +277,10 @@ Fields:
raw = msg.content[0].text.strip()
raw = re.sub(r"^```(?:json)?\n?", "", raw)
raw = re.sub(r"\n?```$", "", raw)
return json.loads(raw)
return json.loads(raw), msg.usage.input_tokens, msg.usage.output_tokens
except Exception as e:
print(f" [warn] Claude failed: {e}")
return {"confirmed_tickers": tickers, "magnitude": 5, "timeframe": "days", "reasoning": str(e)[:120]}
return {"confirmed_tickers": tickers, "magnitude": 5, "timeframe": "days", "reasoning": str(e)[:120]}, 0, 0
# ---------------------------------------------------------------------------
@@ -476,7 +508,9 @@ def analyze_articles(
claude_data: dict = {}
if use_claude and not dry_run and os.environ.get("ANTHROPIC_API_KEY"):
print(f" [claude] {slug[:50]}")
claude_data = claude_extract(title, full_text, list(matches.keys()))
claude_data, _in_tok, _out_tok = claude_extract(title, full_text, list(matches.keys()))
if _in_tok:
update_metrics(_in_tok, _out_tok)
# ------------------------------------------------------------------
# Phase 6 — yfinance momentum + scoring

View File

@@ -28,10 +28,281 @@ from report import _c25_day_return, _unrealised_pnl, _realised_pnl, _total_fees,
CAPITAL = 10_000
LOG_DIR = Path(os.getenv("LOG_DIR", str(Path(__file__).parent / "logs")))
METRICS_FILE = Path(__file__).parent / "metrics.json"
REFRESH = 60 # seconds
CAPITAL = 10_000
LOG_DIR = Path(os.getenv("LOG_DIR", str(Path(__file__).parent / "logs")))
METRICS_FILE = Path(__file__).parent / "metrics.json"
REFRESH = 60 # seconds
app = Flask(__name__)
# ── Metrics dashboard template ────────────────────────────────────────────────
METRICS_TEMPLATE = """<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MoneyMaker · AI Costs</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
:root {
--bg: #eef2f7;
--surface: #ffffff;
--border: #e2e8f0;
--text: #0f172a;
--muted: #64748b;
--accent: #2563eb;
--green: #16a34a;
--red: #dc2626;
--yellow: #d97706;
--purple: #7c3aed;
--shadow-sm: 0 1px 2px rgba(0,0,0,.05);
--shadow: 0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.04);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
padding: 24px 32px 52px;
min-height: 100vh;
}
.header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 22px; padding-bottom: 18px;
border-bottom: 1px solid var(--border);
}
.header-left { display: flex; align-items: center; gap: 12px; }
.header h1 { font-size: 1.25rem; font-weight: 800; letter-spacing: -.03em; }
.header h1 span { color: var(--accent); }
.header-pill {
font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em;
color: var(--purple); background: rgba(124,58,237,.09);
padding: 3px 9px; border-radius: 20px; border: 1px solid rgba(124,58,237,.18);
}
.header-meta { font-size: 0.7rem; color: var(--muted); text-align: right; line-height: 1.9; }
.back-link {
font-size: 0.72rem; color: var(--accent); text-decoration: none; font-weight: 600;
display: inline-flex; align-items: center; gap: 4px;
}
.back-link:hover { text-decoration: underline; }
/* KPI strip */
.kpis { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 18px; }
@media (max-width: 1000px) { .kpis { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 600px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
.kpi {
background: var(--surface);
border-radius: 10px; padding: 15px 16px;
box-shadow: var(--shadow);
border-top: 3px solid transparent;
}
.kpi.blue { border-top-color: var(--accent); }
.kpi.green { border-top-color: var(--green); }
.kpi.red { border-top-color: var(--red); }
.kpi.yellow { border-top-color: var(--yellow); }
.kpi.purple { border-top-color: var(--purple); }
.kpi-label { font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; color: var(--muted); margin-bottom: 9px; }
.kpi-value { font-size: 1.6rem; font-weight: 800; line-height: 1; letter-spacing: -.04em; font-family: 'JetBrains Mono', monospace; }
.kpi-value.sm { font-size: 1.15rem; }
.kpi-sub { font-size: 0.71rem; color: var(--muted); margin-top: 6px; }
/* Charts row */
.charts { display: grid; grid-template-columns: 1fr 320px; gap: 10px; margin-bottom: 18px; }
@media (max-width: 900px) { .charts { grid-template-columns: 1fr; } }
.chart-card {
background: var(--surface); border-radius: 10px; box-shadow: var(--shadow);
padding: 14px 16px 8px;
}
.chart-card-title {
font-size: 0.64rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .09em; color: var(--muted); margin-bottom: 4px;
}
/* Burn rate bar */
.burn-section { background: var(--surface); border-radius: 10px; box-shadow: var(--shadow); padding: 16px 20px; margin-bottom: 18px; }
.burn-title { font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; color: var(--muted); margin-bottom: 14px; }
.burn-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
.burn-label { font-size: 0.75rem; color: var(--muted); width: 130px; flex-shrink: 0; }
.burn-bar-bg { flex: 1; height: 8px; background: #f1f5f9; border-radius: 4px; overflow: hidden; }
.burn-bar-fg { height: 100%; border-radius: 4px; transition: width .6s ease; }
.burn-val { font-size: 0.72rem; font-family: 'JetBrains Mono', monospace; color: var(--text); width: 80px; text-align: right; flex-shrink: 0; }
/* Info table */
.section { background: var(--surface); border-radius: 10px; box-shadow: var(--shadow); overflow: hidden; }
.section-header {
padding: 11px 16px; font-size: 0.64rem; font-weight: 700;
text-transform: uppercase; letter-spacing: .09em;
color: var(--muted); background: #f8fafc;
border-bottom: 1px solid var(--border);
}
table { width: 100%; border-collapse: collapse; }
th { padding: 8px 14px; font-size: 0.63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); text-align: left; border-bottom: 1px solid var(--border); background: #f8fafc; }
td { padding: 10px 14px; font-size: 0.81rem; border-bottom: 1px solid #f1f5f9; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(37,99,235,.025); }
td.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.77rem; }
.empty { padding: 24px 16px; font-size: 0.8rem; color: var(--muted); font-style: italic; text-align: center; }
.footer { font-size: 0.64rem; color: #cbd5e1; text-align: center; margin-top: 32px; letter-spacing: .05em; }
.no-data { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 60px 20px; }
.no-data-icon { font-size: 2.5rem; }
.no-data-text { font-size: 0.88rem; color: var(--muted); }
</style>
</head>
<body>
<div class="header">
<div class="header-left">
<h1>Money<span>Maker</span></h1>
<span class="header-pill">AI Costs</span>
</div>
<div class="header-meta">
{{ now }}<br>
<a class="back-link" href="/">← Dashboard</a>
</div>
</div>
{% if no_data %}
<div class="section">
<div class="no-data">
<div class="no-data-icon">📊</div>
<div class="no-data-text">Ingen data endnu — kør analyze.py med Claude for at begynde at tracke forbrug.</div>
</div>
</div>
{% else %}
<div class="kpis">
<div class="kpi red">
<div class="kpi-label">Total Cost</div>
<div class="kpi-value{% if total_cost_usd >= 1 %} sm{% endif %}">${{ "%.4f"|format(total_cost_usd) }}</div>
<div class="kpi-sub">USD spent</div>
</div>
<div class="kpi blue">
<div class="kpi-label">Claude Calls</div>
<div class="kpi-value">{{ total_calls }}</div>
<div class="kpi-sub">articles analyzed</div>
</div>
<div class="kpi yellow">
<div class="kpi-label">Input Tokens</div>
<div class="kpi-value sm">{{ "{:,}".format(total_input_tokens) }}</div>
<div class="kpi-sub">${{ "%.4f"|format(cost_input) }} · {{ input_pct }}%</div>
</div>
<div class="kpi purple">
<div class="kpi-label">Output Tokens</div>
<div class="kpi-value sm">{{ "{:,}".format(total_output_tokens) }}</div>
<div class="kpi-sub">${{ "%.4f"|format(cost_output) }} · {{ output_pct }}%</div>
</div>
<div class="kpi green">
<div class="kpi-label">Cost / Article</div>
<div class="kpi-value{% if cost_per_call >= 0.01 %} sm{% endif %}">${{ "%.5f"|format(cost_per_call) }}</div>
<div class="kpi-sub">avg per Claude call</div>
</div>
</div>
<div class="charts">
<div class="chart-card">
<div class="chart-card-title">Token Distribution — Input vs Output</div>
<div id="bar-chart" style="height:220px;"></div>
</div>
<div class="chart-card">
<div class="chart-card-title">Cost Split</div>
<div id="donut-chart" style="height:220px;"></div>
</div>
</div>
<div class="burn-section">
<div class="burn-title">Token Breakdown</div>
<div class="burn-row">
<div class="burn-label">Input tokens</div>
<div class="burn-bar-bg"><div class="burn-bar-fg" style="width:{{ input_pct }}%; background: var(--yellow);"></div></div>
<div class="burn-val">{{ "{:,}".format(total_input_tokens) }}</div>
</div>
<div class="burn-row">
<div class="burn-label">Output tokens</div>
<div class="burn-bar-bg"><div class="burn-bar-fg" style="width:{{ output_pct }}%; background: var(--purple);"></div></div>
<div class="burn-val">{{ "{:,}".format(total_output_tokens) }}</div>
</div>
<div class="burn-row">
<div class="burn-label">Total tokens</div>
<div class="burn-bar-bg"><div class="burn-bar-fg" style="width:100%; background: var(--accent);"></div></div>
<div class="burn-val">{{ "{:,}".format(total_input_tokens + total_output_tokens) }}</div>
</div>
</div>
<div class="section">
<div class="section-header">Model Details</div>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
<th>Note</th>
</tr>
</thead>
<tbody>
<tr><td>Model</td><td class="mono">claude-haiku-4-5</td><td>Anthropic</td></tr>
<tr><td>Input price</td><td class="mono">$0.80 / MTok</td><td>${{ "%.6f"|format(0.80/1_000_000) }} per token</td></tr>
<tr><td>Output price</td><td class="mono">$4.00 / MTok</td><td>${{ "%.6f"|format(4.00/1_000_000) }} per token</td></tr>
<tr><td>Total calls</td><td class="mono">{{ total_calls }}</td><td>articles sent to Claude</td></tr>
<tr><td>Total input tokens</td><td class="mono">{{ "{:,}".format(total_input_tokens) }}</td><td>cost ${{ "%.5f"|format(cost_input) }}</td></tr>
<tr><td>Total output tokens</td><td class="mono">{{ "{:,}".format(total_output_tokens) }}</td><td>cost ${{ "%.5f"|format(cost_output) }}</td></tr>
<tr><td>Total cost</td><td class="mono">${{ "%.6f"|format(total_cost_usd) }}</td><td>cumulative</td></tr>
<tr><td>Last updated</td><td class="mono">{{ last_updated }}</td><td></td></tr>
</tbody>
</table>
</div>
<script>
// ── Bar chart: tokens per type ──────────────────────────────────────────────
const barChart = echarts.init(document.getElementById('bar-chart'));
barChart.setOption({
grid: { top: 24, right: 20, bottom: 28, left: 70 },
tooltip: { trigger: 'axis', formatter: p => p[0].name + '<br/>' + p[0].marker + ' ' + p[0].seriesName + ': <b>' + p[0].value.toLocaleString() + '</b>' },
xAxis: { type: 'category', data: ['Input', 'Output'], axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', axisLabel: { fontSize: 10, formatter: v => v >= 1000 ? (v/1000).toFixed(0)+'k' : v } },
series: [{
name: 'Tokens',
type: 'bar', barMaxWidth: 60,
data: [
{ value: {{ total_input_tokens }}, itemStyle: { color: '#d97706', borderRadius: [4,4,0,0] } },
{ value: {{ total_output_tokens }}, itemStyle: { color: '#7c3aed', borderRadius: [4,4,0,0] } },
],
label: { show: true, position: 'top', formatter: p => p.value.toLocaleString(), fontSize: 10, color: '#64748b' }
}]
});
// ── Donut: cost split ───────────────────────────────────────────────────────
const donut = echarts.init(document.getElementById('donut-chart'));
donut.setOption({
tooltip: { trigger: 'item', formatter: '{b}: ${c} ({d}%)' },
legend: { bottom: 0, left: 'center', textStyle: { fontSize: 10, color: '#64748b' } },
series: [{
type: 'pie', radius: ['42%', '68%'],
center: ['50%', '44%'],
label: { show: false },
emphasis: { label: { show: true, fontSize: 12, fontWeight: 'bold' } },
data: [
{ value: {{ "%.6f"|format(cost_input) }}, name: 'Input', itemStyle: { color: '#d97706' } },
{ value: {{ "%.6f"|format(cost_output) }}, name: 'Output', itemStyle: { color: '#7c3aed' } },
]
}]
});
window.addEventListener('resize', () => { barChart.resize(); donut.resize(); });
</script>
{% endif %}
<div class="footer">MoneyMaker · claude-haiku-4-5 · metrics updated on each analyze run</div>
</body>
</html>
"""
# ── HTML template ─────────────────────────────────────────────────────────────
TEMPLATE = """<!DOCTYPE html>
@@ -762,6 +1033,69 @@ 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():