add cross-sectional signal evaluation (factor rank-IC) to the backtest
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:
@@ -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."
|
||||
|
||||
@@ -22,6 +22,29 @@ function rColor(v: number | null): string {
|
||||
return 'text-gray-300';
|
||||
}
|
||||
|
||||
const SIGNAL_LABELS: Record<string, string> = {
|
||||
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() {
|
||||
)}
|
||||
</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>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. Q5−Q1 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>0 %</th>
|
||||
<th className="px-4 py-2.5 text-right">Q5−Q1 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user