From 402025692af698471d42f79538169c5465b7e18a Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Tue, 23 Jun 2026 17:58:40 +0200 Subject: [PATCH] add cross-sectional signal evaluation (factor rank-IC) to the backtest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-setup hit-rate report can't tell whether a signal predicts returns — only how a target/stop structure built on one performs. This adds a cross-sectional factor-IC pass: each week the universe is ranked by a price-only signal and graded by its rank correlation (Spearman IC) and top-minus-bottom- quintile spread against the forward 30-day return. Candidate signals (point-in-time from price; sentiment/fundamentals have no history in the replay): 12-1/6-1/3-1 month momentum, 1-month reversal, price-vs-200d SMA, proximity to the 52-week high (George/Hwang), and 126-day realized volatility (low-vol anomaly). Reuses the existing per-ticker replay loop (no new data, no second DB pass); results land in the cached backtest_report as `signal_eval` and render as a "Signal edge" table in BacktestPanel beside the calibration curve. 330 backend tests pass (10 new in test_signal_eval); frontend build clean. Co-Authored-By: Claude Opus 4.8 --- app/services/backtest_service.py | 198 ++++++++++++++++++ .../src/components/signals/BacktestPanel.tsx | 82 ++++++++ frontend/src/lib/types.ts | 12 ++ frontend/tsconfig.tsbuildinfo | 2 +- tests/unit/test_signal_eval.py | 139 ++++++++++++ 5 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_signal_eval.py diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index f1f5206..95cd537 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -16,6 +16,8 @@ from __future__ import annotations import json import logging +import math +from collections import defaultdict from collections.abc import Callable from datetime import datetime, timezone from types import SimpleNamespace @@ -70,6 +72,15 @@ ATR_MULTIPLIER = 1.5 _CAL_BUCKETS = [(0, 20), (20, 40), (40, 60), (60, 80), (80, 100.01)] +# Cross-sectional signal evaluation (factor IC). Each candidate signal is a +# point-in-time number computed from closes alone (sentiment/fundamentals have no +# history here), sampled one as-of per ISO week, and graded by how its rank +# correlates with the forward HORIZON-day return ACROSS the universe — i.e. does +# ranking stocks by this signal sort tomorrow's winners from losers. This is the +# test the per-setup hit-rate report can't do: it measures predictive power of a +# signal, not the outcome of a target/stop structure built on top of one. +MIN_CROSS_SECTION = 20 # min tickers present in a week to score that week + def _wrap_levels(level_dicts: list[dict]) -> list[Any]: return [ @@ -270,6 +281,180 @@ def _calibration(cands: list[dict]) -> list[dict]: return rows +# --------------------------------------------------------------------------- +# Cross-sectional signal evaluation (factor information-coefficient) +# --------------------------------------------------------------------------- + +def _weekly_asof_indices(records: list) -> list[int]: + """Index of the last bar in each ISO week — the weekly rebalance as-of bars. + + Keying on the calendar week (not the raw bar index) makes every ticker's + as-of dates line up, so the cross-section on a given week is comparable. + """ + last_by_week: dict[tuple[int, int], int] = {} + for idx, r in enumerate(records): + iso = r.date.isocalendar() + last_by_week[(iso[0], iso[1])] = idx + return sorted(last_by_week.values()) + + +def _signal_values(closes: list[float], highs: list[float], i: int) -> dict[str, float]: + """Point-in-time candidate signals at as-of index ``i`` (price-only). + + Momentum factors follow the standard "skip the last month" convention + (return up to ~1 month ago) to avoid the short-term reversal effect, which + ``reversal_1m`` isolates on purpose — we expect its IC to be negative if the + universe mean-reverts. ``trend_200`` is price vs its 200-bar SMA. ``high_52w`` + is closeness to the trailing 52-week high (George/Hwang anchoring effect: + higher = nearer the high, expect positive IC). ``vol_6m`` is 126-day realized + volatility (expect negative IC if the low-volatility anomaly holds). + """ + out: dict[str, float] = {} + if i - 252 >= 0 and closes[i - 252] > 0: + out["mom_12_1"] = closes[i - 21] / closes[i - 252] - 1.0 + if i - 126 >= 0 and closes[i - 126] > 0: + out["mom_6_1"] = closes[i - 21] / closes[i - 126] - 1.0 + if i - 63 >= 0 and closes[i - 63] > 0: + out["mom_3_1"] = closes[i - 21] / closes[i - 63] - 1.0 + if i - 21 >= 0 and closes[i - 21] > 0: + out["reversal_1m"] = closes[i] / closes[i - 21] - 1.0 + if i - 199 >= 0: + sma = sum(closes[i - 199 : i + 1]) / 200.0 + if sma > 0: + out["trend_200"] = closes[i] / sma - 1.0 + if i - 251 >= 0: + high_52w = max(highs[i - 251 : i + 1]) + if high_52w > 0: + out["high_52w"] = closes[i] / high_52w + if i - 126 >= 0: + rets = [ + closes[k] / closes[k - 1] - 1.0 + for k in range(i - 125, i + 1) + if closes[k - 1] > 0 + ] + if len(rets) >= 2: + mean = sum(rets) / len(rets) + var = sum((x - mean) ** 2 for x in rets) / (len(rets) - 1) + out["vol_6m"] = math.sqrt(var) + return out + + +def _accumulate_signal_series(records: list, collected: dict) -> None: + """For each weekly as-of bar, emit (signal, forward-return) pairs keyed by ISO + week into ``collected[name][week_key]``. Forward return is close-to-close over + HORIZON trading days. Mutates ``collected`` (a dict of dict of list).""" + n = len(records) + if n < HORIZON + 21: + return + closes = [float(r.close) for r in records] + highs = [float(r.high) for r in records] + for i in _weekly_asof_indices(records): + j = i + HORIZON + if j >= n or closes[i] <= 0: + continue + fwd = closes[j] / closes[i] - 1.0 + iso = records[i].date.isocalendar() + week_key = (iso[0], iso[1]) + for name, val in _signal_values(closes, highs, i).items(): + collected[name][week_key].append((val, fwd)) + + +def _rank(xs: list[float]) -> list[float]: + """Average (tie-corrected) ranks, 1-based.""" + order = sorted(range(len(xs)), key=lambda k: xs[k]) + ranks = [0.0] * len(xs) + i = 0 + while i < len(xs): + j = i + while j + 1 < len(xs) and xs[order[j + 1]] == xs[order[i]]: + j += 1 + avg_rank = (i + j) / 2.0 + 1.0 + for k in range(i, j + 1): + ranks[order[k]] = avg_rank + i = j + 1 + return ranks + + +def _pearson(a: list[float], b: list[float]) -> float | None: + n = len(a) + if n < 3: + return None + ma, mb = sum(a) / n, sum(b) / n + va = sum((x - ma) ** 2 for x in a) + vb = sum((y - mb) ** 2 for y in b) + if va <= 0 or vb <= 0: + return None + cov = sum((a[k] - ma) * (b[k] - mb) for k in range(n)) + return cov / math.sqrt(va * vb) + + +def _spearman(xs: list[float], ys: list[float]) -> float | None: + """Rank correlation = Pearson on the ranks. None if too few/degenerate.""" + if len(xs) < 3: + return None + return _pearson(_rank(xs), _rank(ys)) + + +def _quintile_spread(pairs: list[tuple[float, float]]) -> float | None: + """Mean forward return of the top signal-quintile minus the bottom quintile.""" + n = len(pairs) + if n < 10: + return None + ordered = sorted(pairs, key=lambda p: p[0]) + k = n // 5 + top = ordered[-k:] + bottom = ordered[:k] + return sum(p[1] for p in top) / k - sum(p[1] for p in bottom) / k + + +def _signal_evaluation(collected: dict) -> list[dict]: + """Per-signal factor diagnostics, one row per candidate signal: + + mean_ic average weekly rank-IC (Spearman of signal vs fwd ret) + ic_t_stat mean_ic / stderr — is the IC reliably non-zero? + ic_positive_pct share of weeks the IC is positive (consistency) + mean_quintile_spread avg top-minus-bottom-quintile forward return + + A signal with no edge lands near IC 0 and spread 0. Caveat: weekly rebalances + with a HORIZON-day forward window overlap, so the t-stat overstates + significance — read it as directional, alongside ic_positive_pct. + """ + rows: list[dict] = [] + for name in sorted(collected): + ics: list[float] = [] + spreads: list[float] = [] + sizes: list[int] = [] + for recs in collected[name].values(): + if len(recs) < MIN_CROSS_SECTION: + continue + ic = _spearman([r[0] for r in recs], [r[1] for r in recs]) + if ic is not None: + ics.append(ic) + spread = _quintile_spread(recs) + if spread is not None: + spreads.append(spread) + sizes.append(len(recs)) + if not ics: + continue + mean_ic = sum(ics) / len(ics) + if len(ics) > 1: + std = math.sqrt(sum((x - mean_ic) ** 2 for x in ics) / (len(ics) - 1)) + else: + std = 0.0 + t_stat = mean_ic / std * math.sqrt(len(ics)) if std > 0 else None + rows.append({ + "signal": name, + "weeks": len(ics), + "avg_cross_section": round(sum(sizes) / len(sizes), 1) if sizes else None, + "mean_ic": round(mean_ic, 4), + "ic_t_stat": round(t_stat, 2) if t_stat is not None else None, + "ic_positive_pct": round(sum(1 for x in ics if x > 0) / len(ics) * 100, 1), + "mean_quintile_spread": round(sum(spreads) / len(spreads), 4) if spreads else None, + }) + rows.sort(key=lambda r: r["mean_ic"], reverse=True) + return rows + + async def run_backtest( db: AsyncSession, progress_cb: Callable[[int, int, str], None] | None = None, @@ -283,12 +468,15 @@ async def run_backtest( total = len(tickers) candidates: list[dict] = [] + # collected[signal_name][iso_week] -> list of (signal_value, forward_return) + collected: dict = defaultdict(lambda: defaultdict(list)) for index, ticker in enumerate(tickers): if progress_cb is not None: progress_cb(index, total, ticker.symbol) try: records = await query_ohlcv(db, ticker.symbol) candidates.extend(_replay_ticker(ticker.symbol, records, config, activation)) + _accumulate_signal_series(records, collected) except Exception: logger.exception("Backtest replay failed for %s", ticker.symbol) @@ -327,6 +515,16 @@ async def run_backtest( "min_expected_value": current_min_ev, "sweep": sweep, "calibration": _calibration(candidates), + "signal_eval": _signal_evaluation(collected), + "signal_eval_note": ( + "Cross-sectional rank-IC of price-only signals vs the forward " + f"{HORIZON}-day return (weekly rebalance, min {MIN_CROSS_SECTION} " + "names/week). |IC| ≳ 0.03 with a consistent sign is a real (if small) " + "edge; near 0 means ranking on it sorts nothing. Momentum factors and " + "high_52w are expected positive; reversal_1m and vol_6m are expected " + "negative (mean-reversion / low-vol anomaly). Overlapping windows inflate " + "the t-stat — read directionally." + ), "note": ( "Sentiment & fundamentals held neutral (no point-in-time history). " "~6 months ≈ one market regime — treat as directional, not gospel." diff --git a/frontend/src/components/signals/BacktestPanel.tsx b/frontend/src/components/signals/BacktestPanel.tsx index ac6a701..eba1607 100644 --- a/frontend/src/components/signals/BacktestPanel.tsx +++ b/frontend/src/components/signals/BacktestPanel.tsx @@ -22,6 +22,29 @@ function rColor(v: number | null): string { return 'text-gray-300'; } +const SIGNAL_LABELS: Record = { + mom_12_1: '12–1 month momentum', + mom_6_1: '6–1 month momentum', + mom_3_1: '3–1 month momentum', + reversal_1m: '1-month reversal', + trend_200: 'Price vs 200-day SMA', + high_52w: 'Proximity to 52-week high', + vol_6m: '6-month realized volatility', +}; + +// An |IC| this large, with a consistent sign, is a real (if small) edge worth +// building on; below it, ranking on the signal sorts essentially nothing. +const IC_EDGE_THRESHOLD = 0.03; + +function icColor(v: number): string { + if (Math.abs(v) < 0.02) return 'text-gray-400'; + return v > 0 ? 'text-emerald-400' : 'text-red-400'; +} +function fmtSpread(v: number | null): string { + if (v === null) return '—'; + return `${v > 0 ? '+' : ''}${(v * 100).toFixed(2)}%`; +} + function timeAgo(iso: string): string { const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000); if (mins < 1) return 'just now'; @@ -247,6 +270,65 @@ export function BacktestPanel() { )} + {report.signal_eval && report.signal_eval.length > 0 && ( +
+

+ Signal edge (cross-sectional) +

+

+ Does ranking the universe by a signal predict the forward {report.params.horizon_days}-day + return? Mean IC is the rank correlation between signal and return, averaged over weekly + rebalances. |IC| ≳ {IC_EDGE_THRESHOLD} with a + consistent sign (high IC>0 %) is a real, if small, edge; near 0 means it sorts nothing. + Momentum skips the last month; reversal_1m is expected negative if the universe + mean-reverts. Q5−Q1 is the top-minus-bottom-quintile forward return. +

+
+ + + + + + + + + + + + + + {report.signal_eval.map((row) => { + const edge = Math.abs(row.mean_ic) >= IC_EDGE_THRESHOLD; + return ( + + + + + + + + + + ); + })} + +
SignalWeeksAvg NMean ICt-statIC>0 %Q5−Q1 fwd
+ {edge && } + {SIGNAL_LABELS[row.signal] ?? row.signal} + {row.weeks}{row.avg_cross_section ?? '—'} + {row.mean_ic.toFixed(3)} + + {row.ic_t_stat === null ? '—' : row.ic_t_stat.toFixed(2)} + {fmtPct(row.ic_positive_pct)} + {fmtSpread(row.mean_quintile_spread)} +
+
+ {report.signal_eval_note && ( +

{report.signal_eval_note}

+ )} +
+ )} +

{report.note}

)} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 9daf4dc..0657e45 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -224,6 +224,16 @@ export interface BacktestSweepRow extends BacktestBucket { min_expected_value: number; } +export interface BacktestSignalEvalRow { + signal: string; + weeks: number; + avg_cross_section: number | null; + mean_ic: number; + ic_t_stat: number | null; + ic_positive_pct: number; + mean_quintile_spread: number | null; +} + export interface BacktestReport { generated_at: string; tickers: number; @@ -236,6 +246,8 @@ export interface BacktestReport { min_expected_value: number; sweep: BacktestSweepRow[]; calibration: BacktestCalibrationRow[]; + signal_eval?: BacktestSignalEvalRow[]; + signal_eval_note?: string; note: string; } diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index d7f9825..148d09a 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/papertrades.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/dashboard/opentradespanel.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/backtestpanel.tsx","./src/components/signals/mytradespanel.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/usepapertrades.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/papertrade.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/papertrades.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/schedulesettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/dashboard/opentradespanel.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/backtestpanel.tsx","./src/components/signals/mytradespanel.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/usepapertrades.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/papertrade.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"} \ No newline at end of file diff --git a/tests/unit/test_signal_eval.py b/tests/unit/test_signal_eval.py new file mode 100644 index 0000000..5edb009 --- /dev/null +++ b/tests/unit/test_signal_eval.py @@ -0,0 +1,139 @@ +"""Tests for the cross-sectional signal-evaluation (factor IC) pass.""" + +from __future__ import annotations + +import random +from datetime import date, timedelta +from types import SimpleNamespace + +from app.services import backtest_service as bt + + +# --------------------------------------------------------------------------- +# Rank-correlation primitives +# --------------------------------------------------------------------------- + +def test_spearman_monotonic_is_one(): + xs = [1.0, 2.0, 3.0, 4.0, 5.0] + ys = [10.0, 20.0, 30.0, 40.0, 50.0] + assert bt._spearman(xs, ys) == 1.0 + + +def test_spearman_inverse_is_minus_one(): + xs = [1.0, 2.0, 3.0, 4.0, 5.0] + ys = [5.0, 4.0, 3.0, 2.0, 1.0] + assert bt._spearman(xs, ys) == -1.0 + + +def test_spearman_handles_ties_without_crashing(): + xs = [1.0, 1.0, 2.0, 2.0, 3.0] + ys = [1.0, 2.0, 2.0, 3.0, 3.0] + ic = bt._spearman(xs, ys) + assert ic is not None and 0.0 < ic <= 1.0 + + +def test_spearman_none_when_degenerate(): + # A flat array has zero variance → correlation undefined. + assert bt._spearman([1.0, 1.0, 1.0, 1.0], [1.0, 2.0, 3.0, 4.0]) is None + assert bt._spearman([1.0], [2.0]) is None + + +def test_quintile_spread_sign_follows_signal(): + # signal == fwd return: top quintile clearly beats bottom → positive spread. + pairs = [(float(i), float(i)) for i in range(20)] + spread = bt._quintile_spread(pairs) + assert spread is not None and spread > 0 + # Top quintile mean (17,18,19,16) - bottom (0,1,2,3) = 16.0 + assert spread == (17 + 18 + 19 + 16) / 4 - (0 + 1 + 2 + 3) / 4 + + +def test_quintile_spread_none_when_too_few(): + assert bt._quintile_spread([(1.0, 1.0)] * 9) is None + + +# --------------------------------------------------------------------------- +# Signal value extraction (point-in-time, price-only) +# --------------------------------------------------------------------------- + +def test_signal_values_momentum_and_trend(): + # Steadily rising series so every lookback is positive and trend is above SMA. + closes = [100.0 * (1.01 ** k) for k in range(300)] + i = 299 + vals = bt._signal_values(closes, closes, i) + assert vals["mom_12_1"] > 0 # up over the 12→1 month window + assert vals["trend_200"] > 0 # price above its 200-bar SMA in an uptrend + # 12-1 momentum skips the last month: close[i-21] / close[i-252] - 1 + assert vals["mom_12_1"] == closes[i - 21] / closes[i - 252] - 1.0 + # Strictly rising → today IS the 52-week high (highs==closes here) → ratio 1.0 + assert vals["high_52w"] == 1.0 + assert vals["vol_6m"] > 0 # realized vol is defined and positive + + +def test_signal_values_drops_signals_without_enough_history(): + closes = [100.0 + k for k in range(80)] # only 80 bars + vals = bt._signal_values(closes, closes, 79) + assert "mom_3_1" in vals # needs 63 bars of lookback — present + assert "mom_6_1" not in vals # needs 126 — absent + assert "mom_12_1" not in vals # needs 252 — absent + assert "trend_200" not in vals # needs 200 — absent + assert "high_52w" not in vals # needs 252 — absent + assert "vol_6m" not in vals # needs 126 — absent + + +# --------------------------------------------------------------------------- +# End-to-end aggregation: a predictive signal scores, noise does not +# --------------------------------------------------------------------------- + +def _records(closes: list[float]) -> list[SimpleNamespace]: + start = date(2020, 1, 1) + return [ + SimpleNamespace(date=start + timedelta(days=k), close=c, high=c) + for k, c in enumerate(closes) + ] + + +def test_signal_evaluation_separates_edge_from_noise(): + rng = random.Random(42) + # Build a synthetic cross-section directly: 30 weeks, 40 names each. + # "edge" perfectly orders the forward return; "noise" is independent of it. + collected: dict = { + "edge": {}, + "noise": {}, + } + for week in range(30): + edge_recs = [] + noise_recs = [] + for _ in range(40): + fwd = rng.gauss(0, 0.05) + edge_recs.append((fwd, fwd)) # signal == fwd → IC = 1 + noise_recs.append((rng.gauss(0, 1), fwd)) # signal ⟂ fwd → IC ≈ 0 + collected["edge"][(2020, week)] = edge_recs + collected["noise"][(2020, week)] = noise_recs + + rows = {r["signal"]: r for r in bt._signal_evaluation(collected)} + + assert rows["edge"]["mean_ic"] == 1.0 + assert rows["edge"]["ic_positive_pct"] == 100.0 + assert rows["edge"]["mean_quintile_spread"] > 0 + assert abs(rows["noise"]["mean_ic"]) < 0.15 # indistinguishable from zero + # Rows are sorted by mean_ic descending: the real signal ranks first. + assert bt._signal_evaluation(collected)[0]["signal"] == "edge" + + +def test_signal_evaluation_skips_thin_weeks(): + # A week with fewer than MIN_CROSS_SECTION names is ignored entirely. + collected: dict = {"edge": {(2020, 1): [(1.0, 1.0)] * (bt.MIN_CROSS_SECTION - 1)}} + assert bt._signal_evaluation(collected) == [] + + +def test_accumulate_signal_series_emits_weekly_pairs(): + closes = [100.0 * (1.005 ** k) for k in range(400)] + collected: dict = {} + from collections import defaultdict + collected = defaultdict(lambda: defaultdict(list)) + bt._accumulate_signal_series(_records(closes), collected) + # The long, rising series should yield momentum + trend observations... + assert "mom_12_1" in collected and len(collected["mom_12_1"]) > 0 + # ...one per ISO week, with a forward return attached to each pair. + sample = next(iter(collected["mom_12_1"].values())) + assert all(len(pair) == 2 for pair in sample)