TradingAgents/tradingagents/strategies/scorecard.py

212 lines
6.5 KiB
Python

"""Strategy Effectiveness Scorecard — compare previous signals vs actual price movement.
After each fortnightly run, loads the previous run's strategy signals and compares
their directional calls against actual price movement to track which strategies
are most predictive for the portfolio over time.
"""
from __future__ import annotations
import json
from pathlib import Path
import yfinance as yf
def _load_previous_signals(ticker: str, current_date: str, analyses_dir: Path) -> tuple[list[dict], str]:
"""Find the most recent signals.json for ticker before current_date.
Returns (signals_list, prev_date) or ([], "").
"""
best_date = ""
best_signals: list[dict] = []
if not analyses_dir.exists():
return [], ""
for d in analyses_dir.iterdir():
if not d.is_dir() or not d.name.startswith(f"{ticker}_"):
continue
sf = d / "signals.json"
if not sf.exists():
continue
# Extract date from dirname: TICKER_YYYY-MM-DD
parts = d.name.split("_", 1)
if len(parts) < 2:
continue
d_date = parts[1]
if d_date >= current_date:
continue
if d_date > best_date:
try:
best_signals = json.loads(sf.read_text())
best_date = d_date
except Exception:
continue
return best_signals, best_date
def _get_price_change(ticker: str, from_date: str, to_date: str) -> float | None:
"""Get percentage price change between two dates. Returns None on failure."""
try:
hist = yf.Ticker(ticker).history(start=from_date, end=to_date)
if hist.empty or len(hist) < 2:
return None
return ((hist["Close"].iloc[-1] / hist["Close"].iloc[0]) - 1) * 100
except Exception:
return None
def _score_signal(signal: dict, pct_change: float) -> dict:
"""Score a single signal against actual price movement.
Returns dict with: name, direction, signal, pct_change, correct (bool), detail.
"""
direction = signal.get("direction", "NEUTRAL")
name = signal.get("name", "")
value_label = signal.get("value_label", "")
# SUPPORTS = bullish call, CONTRADICTS = bearish call
if direction == "SUPPORTS":
predicted_up = True
elif direction == "CONTRADICTS":
predicted_up = False
else:
# NEUTRAL — not a directional call, skip scoring
return {
"name": name,
"direction": direction,
"value_label": value_label,
"pct_change": round(pct_change, 2),
"correct": None, # not scored
}
actual_up = pct_change > 0
correct = predicted_up == actual_up
return {
"name": name,
"direction": direction,
"value_label": value_label,
"pct_change": round(pct_change, 2),
"correct": correct,
}
def compute_scorecard(
tickers: set[str],
current_date: str,
analyses_dir: Path,
) -> list[dict]:
"""Compare previous strategy signals vs actual price movement for all tickers.
Returns list of scored signal dicts with keys:
ticker, name, direction, value_label, pct_change, correct, prev_date
"""
results: list[dict] = []
for ticker in sorted(tickers):
prev_signals, prev_date = _load_previous_signals(ticker, current_date, analyses_dir)
if not prev_signals or not prev_date:
continue
pct_change = _get_price_change(ticker, prev_date, current_date)
if pct_change is None:
continue
for sig in prev_signals:
scored = _score_signal(sig, pct_change)
scored["ticker"] = ticker
scored["prev_date"] = prev_date
results.append(scored)
return results
def scorecard_summary(scored: list[dict]) -> dict:
"""Aggregate scorecard results into per-strategy accuracy stats.
Returns {strategy_name: {correct: int, incorrect: int, total: int, accuracy: float}}.
"""
from collections import defaultdict
stats: dict[str, dict] = defaultdict(lambda: {"correct": 0, "incorrect": 0, "total": 0})
for s in scored:
if s.get("correct") is None:
continue # skip NEUTRAL
name = s["name"]
stats[name]["total"] += 1
if s["correct"]:
stats[name]["correct"] += 1
else:
stats[name]["incorrect"] += 1
for name in stats:
t = stats[name]["total"]
stats[name]["accuracy"] = stats[name]["correct"] / t if t > 0 else 0.0
return dict(stats)
def persist_scorecard(scored: list[dict], date: str, data_dir: Path) -> Path:
"""Merge current scorecard results into cumulative data/strategy_scorecard.json.
File structure:
{
"runs": [{"date": "2026-04-14", "scored": 12, "correct": 8}],
"strategies": {
"momentum": {"correct": 5, "incorrect": 2, "total": 7, "accuracy": 0.714},
...
},
"updated": "2026-04-16"
}
Returns path to the scorecard file.
"""
path = data_dir / "strategy_scorecard.json"
# Load existing
cumulative: dict = {"runs": [], "strategies": {}, "updated": ""}
if path.exists():
try:
cumulative = json.loads(path.read_text())
except Exception:
pass
# Skip if this date already recorded
existing_dates = {r["date"] for r in cumulative.get("runs", [])}
if date in existing_dates:
return path
# Merge current run's per-strategy stats
current = scorecard_summary(scored)
strategies = cumulative.get("strategies", {})
for name, stats in current.items():
if name in strategies:
strategies[name]["correct"] += stats["correct"]
strategies[name]["incorrect"] += stats["incorrect"]
strategies[name]["total"] += stats["total"]
else:
strategies[name] = {
"correct": stats["correct"],
"incorrect": stats["incorrect"],
"total": stats["total"],
}
t = strategies[name]["total"]
strategies[name]["accuracy"] = round(strategies[name]["correct"] / t, 4) if t else 0.0
# Append run summary
directional = [s for s in scored if s.get("correct") is not None]
cumulative["runs"].append({
"date": date,
"scored": len(directional),
"correct": sum(1 for s in directional if s["correct"]),
})
cumulative["strategies"] = strategies
cumulative["updated"] = date
data_dir.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(cumulative, indent=2))
return path