mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-19 19:25:24 +03:00
fix(backtest): score a decision against the direction it claimed
- a Sell that fell counted as a miss and a Sell that rose as a hit - Hold claims no direction, so it reports mean alpha and no hit rate - the report names the window the outcomes were measured over
This commit is contained in:
@@ -186,3 +186,63 @@ def test_pending_note_appears_only_when_something_is_pending(tmp_path):
|
|||||||
settled = [("NVDA", "2026-01-05", DECISION, (0.1, 0.05))]
|
settled = [("NVDA", "2026-01-05", DECISION, (0.1, 0.05))]
|
||||||
assert "Pending" not in summarize(_log_with(tmp_path, settled)).render()
|
assert "Pending" not in summarize(_log_with(tmp_path, settled)).render()
|
||||||
assert "Pending" in summarize(_log_with(tmp_path, settled + [("AAPL", "2026-01-05", DECISION, None)])).render()
|
assert "Pending" in summarize(_log_with(tmp_path, settled + [("AAPL", "2026-01-05", DECISION, None)])).render()
|
||||||
|
|
||||||
|
|
||||||
|
# --- scoring reads the direction the rating claimed ---------------------------
|
||||||
|
|
||||||
|
def _scored(tmp_path, rows):
|
||||||
|
log = _log_with(tmp_path, rows)
|
||||||
|
return summarize(log).by_rating
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_bearish_call_that_fell_counts_as_right(tmp_path):
|
||||||
|
"""Alpha below the benchmark is the outcome a Sell predicted; scoring it as
|
||||||
|
a miss reported the system as wrong exactly when it was right."""
|
||||||
|
scores = _scored(tmp_path, [
|
||||||
|
("NVDA", "2026-01-05", "**Rating**: Sell\n\nx", (-0.08, -0.05)),
|
||||||
|
("AAPL", "2026-01-05", "**Rating**: Underweight\n\nx", (-0.03, -0.02)),
|
||||||
|
])
|
||||||
|
assert scores["Sell"].hit_rate == 1.0
|
||||||
|
assert scores["Underweight"].hit_rate == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_bearish_call_that_rose_counts_as_wrong(tmp_path):
|
||||||
|
scores = _scored(tmp_path, [("NVDA", "2026-01-05", "**Rating**: Sell\n\nx", (0.08, 0.05))])
|
||||||
|
assert scores["Sell"].hit_rate == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_bullish_call_is_scored_the_same_way_as_before(tmp_path):
|
||||||
|
scores = _scored(tmp_path, [
|
||||||
|
("NVDA", "2026-01-05", "**Rating**: Buy\n\nx", (0.10, 0.04)),
|
||||||
|
("AAPL", "2026-01-05", "**Rating**: Buy\n\nx", (-0.02, -0.02)),
|
||||||
|
])
|
||||||
|
assert scores["Buy"].hit_rate == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_hold_claims_no_direction_so_it_gets_no_hit_rate(tmp_path):
|
||||||
|
scores = _scored(tmp_path, [("NVDA", "2026-01-05", "**Rating**: Hold\n\nx", (0.01, 0.005))])
|
||||||
|
assert scores["Hold"].hit_rate is None
|
||||||
|
assert scores["Hold"].mean_alpha == 0.005
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_report_names_the_window_the_scores_cover(tmp_path):
|
||||||
|
text = summarize(_log_with(tmp_path, [
|
||||||
|
("NVDA", "2026-01-05", "**Rating**: Buy\n\nx", (0.1, 0.05))])).render()
|
||||||
|
assert "5" in text and "day" in text.lower()
|
||||||
|
assert "Hold" not in text or "no direction" in text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_window_reported_is_the_one_the_outcomes_used(tmp_path):
|
||||||
|
"""The log records the window each outcome was measured over; the summary
|
||||||
|
must not claim a different one."""
|
||||||
|
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
|
||||||
|
log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx")
|
||||||
|
log.update_with_outcome("NVDA", "2026-01-05", 0.1, 0.04, 21, "note", "2026-02-01")
|
||||||
|
|
||||||
|
assert "21 trading days" in summarize(log).render()
|
||||||
|
|||||||
@@ -81,10 +81,15 @@ class BacktestResult:
|
|||||||
settlement_failures: list[tuple[str, str]] = field(default_factory=list)
|
settlement_failures: list[tuple[str, str]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# What each rating claims will happen, so an outcome can be scored against it.
|
||||||
|
# Hold claims no direction, so nothing about alpha proves it right or wrong.
|
||||||
|
_DIRECTION = {"Buy": 1, "Overweight": 1, "Hold": 0, "Underweight": -1, "Sell": -1}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RatingScore:
|
class RatingScore:
|
||||||
count: int
|
count: int
|
||||||
hit_rate: float
|
hit_rate: float | None
|
||||||
mean_alpha: float
|
mean_alpha: float
|
||||||
|
|
||||||
|
|
||||||
@@ -94,19 +99,23 @@ class BacktestSummary:
|
|||||||
pending: int
|
pending: int
|
||||||
by_rating: dict[str, RatingScore]
|
by_rating: dict[str, RatingScore]
|
||||||
unscored: int = 0
|
unscored: int = 0
|
||||||
|
holding: str = ""
|
||||||
|
|
||||||
def render(self) -> str:
|
def render(self) -> str:
|
||||||
lines = [f"Resolved cells: {self.resolved} · pending: {self.pending}"
|
lines = [f"Resolved cells: {self.resolved} · pending: {self.pending}"
|
||||||
+ (f" · unscored: {self.unscored}" if self.unscored else "")]
|
+ (f" · unscored: {self.unscored}" if self.unscored else "")]
|
||||||
for rating, score in self.by_rating.items():
|
for rating, score in self.by_rating.items():
|
||||||
|
called = (f"called the direction {score.hit_rate:.0%}"
|
||||||
|
if score.hit_rate is not None else "no direction claimed")
|
||||||
lines.append(
|
lines.append(
|
||||||
f"- {rating}: n={score.count}, beat the benchmark "
|
f"- {rating}: n={score.count}, {called}, "
|
||||||
f"{score.hit_rate:.0%}, mean alpha {score.mean_alpha:+.2%}"
|
f"mean alpha {score.mean_alpha:+.2%} vs the benchmark"
|
||||||
)
|
)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
if self.pending:
|
if self.pending:
|
||||||
lines.append("Pending cells are not scored above; re-run to settle them.")
|
lines.append("Pending cells are not scored above; re-run to settle them.")
|
||||||
lines.append(
|
lines.append(
|
||||||
|
f"Alpha is measured over {self.holding} after each analysis date. "
|
||||||
"One model sampling per cell, and text feeds are not archived, so "
|
"One model sampling per cell, and text feeds are not archived, so "
|
||||||
"these figures are indicative rather than repeatable."
|
"these figures are indicative rather than repeatable."
|
||||||
)
|
)
|
||||||
@@ -174,12 +183,17 @@ def summarize(memory_log: TradingMemoryLog) -> BacktestSummary:
|
|||||||
by_rating: dict[str, RatingScore] = {}
|
by_rating: dict[str, RatingScore] = {}
|
||||||
for rating in dict.fromkeys(e["rating"] for e, _ in resolved):
|
for rating in dict.fromkeys(e["rating"] for e, _ in resolved):
|
||||||
alphas = [a for e, a in resolved if e["rating"] == rating]
|
alphas = [a for e, a in resolved if e["rating"] == rating]
|
||||||
|
direction = _DIRECTION.get(rating, 0)
|
||||||
by_rating[rating] = RatingScore(
|
by_rating[rating] = RatingScore(
|
||||||
count=len(alphas),
|
count=len(alphas),
|
||||||
hit_rate=sum(a > 0 for a in alphas) / len(alphas),
|
hit_rate=(sum(a * direction > 0 for a in alphas) / len(alphas)) if direction else None,
|
||||||
mean_alpha=sum(alphas) / len(alphas),
|
mean_alpha=sum(alphas) / len(alphas),
|
||||||
)
|
)
|
||||||
unscored = sum(1 for e in entries if e["rating"] == RATING_REVIEW)
|
unscored = sum(1 for e in entries if e["rating"] == RATING_REVIEW)
|
||||||
|
# Report the window the outcomes were actually measured over, from the log.
|
||||||
|
windows = {f"{e['holding'][:-1]} trading days" for e, _ in resolved
|
||||||
|
if (e.get("holding") or "").endswith("d")}
|
||||||
return BacktestSummary(resolved=len(resolved),
|
return BacktestSummary(resolved=len(resolved),
|
||||||
pending=len(entries) - len(resolved) - unscored,
|
pending=len(entries) - len(resolved) - unscored,
|
||||||
by_rating=by_rating, unscored=unscored)
|
by_rating=by_rating, unscored=unscored,
|
||||||
|
holding=", ".join(sorted(windows)) or "the configured window")
|
||||||
|
|||||||
Reference in New Issue
Block a user