add cross-sectional signal evaluation (factor rank-IC) to the backtest
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 40s
Deploy / deploy (push) Successful in 26s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 17:58:40 +02:00
parent c34f3cb1a4
commit 402025692a
5 changed files with 432 additions and 1 deletions
+198
View File
@@ -16,6 +16,8 @@ from __future__ import annotations
import json import json
import logging import logging
import math
from collections import defaultdict
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace 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)] _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]: def _wrap_levels(level_dicts: list[dict]) -> list[Any]:
return [ return [
@@ -270,6 +281,180 @@ def _calibration(cands: list[dict]) -> list[dict]:
return rows 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( async def run_backtest(
db: AsyncSession, db: AsyncSession,
progress_cb: Callable[[int, int, str], None] | None = None, progress_cb: Callable[[int, int, str], None] | None = None,
@@ -283,12 +468,15 @@ async def run_backtest(
total = len(tickers) total = len(tickers)
candidates: list[dict] = [] 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): for index, ticker in enumerate(tickers):
if progress_cb is not None: if progress_cb is not None:
progress_cb(index, total, ticker.symbol) progress_cb(index, total, ticker.symbol)
try: try:
records = await query_ohlcv(db, ticker.symbol) records = await query_ohlcv(db, ticker.symbol)
candidates.extend(_replay_ticker(ticker.symbol, records, config, activation)) candidates.extend(_replay_ticker(ticker.symbol, records, config, activation))
_accumulate_signal_series(records, collected)
except Exception: except Exception:
logger.exception("Backtest replay failed for %s", ticker.symbol) logger.exception("Backtest replay failed for %s", ticker.symbol)
@@ -327,6 +515,16 @@ async def run_backtest(
"min_expected_value": current_min_ev, "min_expected_value": current_min_ev,
"sweep": sweep, "sweep": sweep,
"calibration": _calibration(candidates), "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": ( "note": (
"Sentiment & fundamentals held neutral (no point-in-time history). " "Sentiment & fundamentals held neutral (no point-in-time history). "
"~6 months ≈ one market regime — treat as directional, not gospel." "~6 months ≈ one market regime — treat as directional, not gospel."
@@ -22,6 +22,29 @@ function rColor(v: number | null): string {
return 'text-gray-300'; return 'text-gray-300';
} }
const SIGNAL_LABELS: Record<string, string> = {
mom_12_1: '121 month momentum',
mom_6_1: '61 month momentum',
mom_3_1: '31 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 { function timeAgo(iso: string): string {
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000); const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
if (mins < 1) return 'just now'; if (mins < 1) return 'just now';
@@ -247,6 +270,65 @@ export function BacktestPanel() {
)} )}
</div> </div>
{report.signal_eval && report.signal_eval.length > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Signal edge (cross-sectional)
</p>
<p className="mb-2 text-[11px] text-gray-500">
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. <span className="text-emerald-400">|IC| {IC_EDGE_THRESHOLD}</span> with a
consistent sign (high IC&gt;0 %) is a real, if small, edge; near 0 means it sorts nothing.
Momentum skips the last month; <em>reversal_1m is expected negative</em> if the universe
mean-reverts. Q5Q1 is the top-minus-bottom-quintile forward return.
</p>
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Signal</th>
<th className="px-4 py-2.5 text-right">Weeks</th>
<th className="px-4 py-2.5 text-right">Avg N</th>
<th className="px-4 py-2.5 text-right">Mean IC</th>
<th className="px-4 py-2.5 text-right">t-stat</th>
<th className="px-4 py-2.5 text-right">IC&gt;0 %</th>
<th className="px-4 py-2.5 text-right">Q5Q1 fwd</th>
</tr>
</thead>
<tbody>
{report.signal_eval.map((row) => {
const edge = Math.abs(row.mean_ic) >= IC_EDGE_THRESHOLD;
return (
<tr key={row.signal} className={`border-b border-white/[0.04] ${edge ? 'bg-emerald-400/[0.06]' : ''}`}>
<td className="px-4 py-2.5 font-medium text-gray-200">
{edge && <span className="mr-1 text-emerald-300"></span>}
{SIGNAL_LABELS[row.signal] ?? row.signal}
</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{row.weeks}</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{row.avg_cross_section ?? '—'}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${icColor(row.mean_ic)}`}>
{row.mean_ic.toFixed(3)}
</td>
<td className="num px-4 py-2.5 text-right text-gray-300">
{row.ic_t_stat === null ? '—' : row.ic_t_stat.toFixed(2)}
</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{fmtPct(row.ic_positive_pct)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(row.mean_quintile_spread)}`}>
{fmtSpread(row.mean_quintile_spread)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{report.signal_eval_note && (
<p className="mt-2 text-[11px] text-gray-600">{report.signal_eval_note}</p>
)}
</div>
)}
<p className="text-[11px] text-gray-600">{report.note}</p> <p className="text-[11px] text-gray-600">{report.note}</p>
</> </>
)} )}
+12
View File
@@ -224,6 +224,16 @@ export interface BacktestSweepRow extends BacktestBucket {
min_expected_value: number; 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 { export interface BacktestReport {
generated_at: string; generated_at: string;
tickers: number; tickers: number;
@@ -236,6 +246,8 @@ export interface BacktestReport {
min_expected_value: number; min_expected_value: number;
sweep: BacktestSweepRow[]; sweep: BacktestSweepRow[];
calibration: BacktestCalibrationRow[]; calibration: BacktestCalibrationRow[];
signal_eval?: BacktestSignalEvalRow[];
signal_eval_note?: string;
note: string; note: string;
} }
+1 -1
View File
@@ -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"} {"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"}
+139
View File
@@ -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)