From 2294f5bc072d957e47948ca95c2fb835b88f08e7 Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Thu, 28 May 2026 13:11:29 +0200 Subject: [PATCH 1/3] Metrics --- analyze.py | 46 ++++++- dashboard.py | 340 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 377 insertions(+), 9 deletions(-) diff --git a/analyze.py b/analyze.py index 6313121..9492ca1 100644 --- a/analyze.py +++ b/analyze.py @@ -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 diff --git a/dashboard.py b/dashboard.py index cdc0178..c3c508c 100644 --- a/dashboard.py +++ b/dashboard.py @@ -26,12 +26,283 @@ from flask import Flask, jsonify, render_template_string from db import get_conn, DB_TYPE from report import _c25_day_return, _unrealised_pnl, _realised_pnl, _total_fees, _signal_accuracy -CAPITAL = 10_000 -LOG_DIR = Path(os.getenv("LOG_DIR", str(Path(__file__).parent / "logs"))) -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 + +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 = """ + + + + + MoneyMaker · AI Costs + + + + + + + +
+
+

MoneyMaker

+ AI Costs +
+
+ {{ now }}
+ ← Dashboard +
+
+ +{% if no_data %} +
+
+
📊
+
Ingen data endnu — kør analyze.py med Claude for at begynde at tracke forbrug.
+
+
+{% else %} + +
+
+
Total Cost
+
${{ "%.4f"|format(total_cost_usd) }}
+
USD spent
+
+
+
Claude Calls
+
{{ total_calls }}
+
articles analyzed
+
+
+
Input Tokens
+
{{ "{:,}".format(total_input_tokens) }}
+
${{ "%.4f"|format(cost_input) }} · {{ input_pct }}%
+
+
+
Output Tokens
+
{{ "{:,}".format(total_output_tokens) }}
+
${{ "%.4f"|format(cost_output) }} · {{ output_pct }}%
+
+
+
Cost / Article
+
${{ "%.5f"|format(cost_per_call) }}
+
avg per Claude call
+
+
+ +
+
+
Token Distribution — Input vs Output
+
+
+
+
Cost Split
+
+
+
+ +
+
Token Breakdown
+
+
Input tokens
+
+
{{ "{:,}".format(total_input_tokens) }}
+
+
+
Output tokens
+
+
{{ "{:,}".format(total_output_tokens) }}
+
+
+
Total tokens
+
+
{{ "{:,}".format(total_input_tokens + total_output_tokens) }}
+
+
+ +
+
Model Details
+ + + + + + + + + + + + + + + + + + +
MetricValueNote
Modelclaude-haiku-4-5Anthropic
Input price$0.80 / MTok${{ "%.6f"|format(0.80/1_000_000) }} per token
Output price$4.00 / MTok${{ "%.6f"|format(4.00/1_000_000) }} per token
Total calls{{ total_calls }}articles sent to Claude
Total input tokens{{ "{:,}".format(total_input_tokens) }}cost ${{ "%.5f"|format(cost_input) }}
Total output tokens{{ "{:,}".format(total_output_tokens) }}cost ${{ "%.5f"|format(cost_output) }}
Total cost${{ "%.6f"|format(total_cost_usd) }}cumulative
Last updated{{ last_updated }}
+
+ + +{% endif %} + + + + +""" + # ── HTML template ───────────────────────────────────────────────────────────── TEMPLATE = """ @@ -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(): From 6a407cf2165904d3f493e25137f37abc034626d0 Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Thu, 28 May 2026 13:41:53 +0200 Subject: [PATCH 2/3] Add /metrics + /metrics-dash endpoints, Claude cost tracking and safety caps - Track Claude token usage + cost in metrics.json after each call - Add /metrics JSON endpoint - Add /metrics-dash visual dashboard (KPIs, charts, burn bars) - Switch model to claude-3-haiku-20240307 (3.2x cheaper) - Add per-run cap (50 calls) and daily spend cap ($2.00, env: CLAUDE_DAILY_CAP_USD) --- analyze.py | 61 ++++++++++++++++++++++++++++++++++++++++++---------- dashboard.py | 13 ++++------- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/analyze.py b/analyze.py index 9492ca1..5f5aa76 100644 --- a/analyze.py +++ b/analyze.py @@ -85,9 +85,9 @@ ALERT_THRESHOLD = 0.35 # signal_score > this → alert 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 +# Pricing: Claude 3 Haiku — https://www.anthropic.com/pricing +_PRICE_INPUT_PER_TOKEN = 0.25 / 1_000_000 # $0.25 per MTok +_PRICE_OUTPUT_PER_TOKEN = 1.25 / 1_000_000 # $1.25 per MTok def calc_cost(input_tokens: int, output_tokens: int) -> float: @@ -270,7 +270,7 @@ Fields: try: msg = client.messages.create( - model="claude-haiku-4-5", + model="claude-3-haiku-20240307", max_tokens=256, messages=[{"role": "user", "content": prompt}], ) @@ -372,6 +372,7 @@ def analyze_articles( dry_run: bool = False, use_claude: bool = True, auto_fetch: bool = True, + max_claude_calls: int = 50, ) -> None: db = get_db() migrate_db(db) @@ -486,6 +487,16 @@ def analyze_articles( now = int(time.time()) signals_written = 0 alerts_triggered = 0 + claude_calls_this_run = 0 + + # Daily spend guard — read current metrics before starting + _existing = {} + if METRICS_FILE.exists(): + try: + _existing = json.loads(METRICS_FILE.read_text()) + except Exception: + pass + DAILY_COST_CAP = float(os.getenv("CLAUDE_DAILY_CAP_USD", "2.0")) for row, matches, cov, full_text in final: slug = row["slug"] @@ -507,10 +518,19 @@ 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, _in_tok, _out_tok = claude_extract(title, full_text, list(matches.keys())) - if _in_tok: - update_metrics(_in_tok, _out_tok) + if claude_calls_this_run >= max_claude_calls: + print(f" [claude] ⚠️ per-run cap ({max_claude_calls}) reached — skipping remaining articles") + use_claude = False + elif _existing.get("total_cost_usd", 0.0) >= DAILY_COST_CAP: + print(f" [claude] ⚠️ daily spend cap ${DAILY_COST_CAP} reached — skipping Claude for remaining articles") + use_claude = False + else: + print(f" [claude] {slug[:50]}") + claude_data, _in_tok, _out_tok = claude_extract(title, full_text, list(matches.keys())) + if _in_tok: + update_metrics(_in_tok, _out_tok) + _existing["total_cost_usd"] = _existing.get("total_cost_usd", 0.0) + calc_cost(_in_tok, _out_tok) + claude_calls_this_run += 1 # ------------------------------------------------------------------ # Phase 6 — yfinance momentum + scoring @@ -587,9 +607,28 @@ def analyze_articles( def main() -> None: import sys - force = "--force" in sys.argv - dry_run = "--dry" in sys.argv - analyze_articles(force=force, dry_run=dry_run) + force = "--force" in sys.argv + dry_run = "--dry-run" in sys.argv or "--dry" in sys.argv + no_claude = "--no-claude" in sys.argv + + # --max-claude=N override per-run cap + max_calls = 50 + for arg in sys.argv: + if arg.startswith("--max-claude="): + try: + max_calls = int(arg.split("=", 1)[1]) + except ValueError: + pass + + if force: + print(f"[analyze] ⚠️ --force mode: re-analyzing ALL articles (claude cap={max_calls})") + + analyze_articles( + force=force, + dry_run=dry_run, + use_claude=not no_claude, + max_claude_calls=max_calls, + ) if __name__ == "__main__": diff --git a/dashboard.py b/dashboard.py index c3c508c..029e599 100644 --- a/dashboard.py +++ b/dashboard.py @@ -31,11 +31,6 @@ 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 ──────────────────────────────────────────────── @@ -246,9 +241,9 @@ METRICS_TEMPLATE = """ - Modelclaude-haiku-4-5Anthropic - Input price$0.80 / MTok${{ "%.6f"|format(0.80/1_000_000) }} per token - Output price$4.00 / MTok${{ "%.6f"|format(4.00/1_000_000) }} per token + Modelclaude-3-haiku-20240307Anthropic + Input price$0.25 / MTok${{ "%.6f"|format(0.25/1_000_000) }} per token + Output price$1.25 / MTok${{ "%.6f"|format(1.25/1_000_000) }} per token Total calls{{ total_calls }}articles sent to Claude Total input tokens{{ "{:,}".format(total_input_tokens) }}cost ${{ "%.5f"|format(cost_input) }} Total output tokens{{ "{:,}".format(total_output_tokens) }}cost ${{ "%.5f"|format(cost_output) }} @@ -298,7 +293,7 @@ window.addEventListener('resize', () => { barChart.resize(); donut.resize(); }); {% endif %} - + """ From 026b470b310fd0b59c212cf3954056032d0f4f94 Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Thu, 28 May 2026 13:42:36 +0200 Subject: [PATCH 3/3] Fix metrics.json path to persistent data volume (/app/data) --- analyze.py | 2 +- dashboard.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/analyze.py b/analyze.py index 5f5aa76..1ae9aa3 100644 --- a/analyze.py +++ b/analyze.py @@ -83,7 +83,7 @@ ALERT_THRESHOLD = 0.35 # signal_score > this → alert # Claude metrics # --------------------------------------------------------------------------- -METRICS_FILE = Path(__file__).parent / "metrics.json" +METRICS_FILE = Path(os.getenv("DATA_DIR", str(Path(__file__).parent / "data"))) / "metrics.json" # Pricing: Claude 3 Haiku — https://www.anthropic.com/pricing _PRICE_INPUT_PER_TOKEN = 0.25 / 1_000_000 # $0.25 per MTok diff --git a/dashboard.py b/dashboard.py index 029e599..2bcd436 100644 --- a/dashboard.py +++ b/dashboard.py @@ -28,7 +28,7 @@ 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" +METRICS_FILE = Path(os.getenv("DATA_DIR", str(Path(__file__).parent / "data"))) / "metrics.json" REFRESH = 60 # seconds app = Flask(__name__)