deepen OHLCV history + make the factor-IC pass honest about overlap/regime
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 39s
Deploy / deploy (push) Successful in 25s

Two changes so the cross-sectional signal results can actually be trusted.

(a) History depth — the binding constraint. Ingestion defaulted to 365 days, so
long-lookback factors (12-month momentum, 52-week high) were only computable on a
handful of weeks at the tail, and every IC reflected a single market regime.
- New `settings.ohlcv_history_days` (default 1825 ≈ 5y); new tickers backfill this
  far instead of 1 year.
- New manual "data_backfill" job (Admin → Jobs) re-fetches the full window for
  every ticker, ignoring incremental resume — run once to deepen existing
  1-year histories. Idempotent (upsert); resumes after rate limits.

(b) Factor-IC honesty. The IC was averaged over weekly rebalances whose 30-day
forward windows overlap, inflating the t-stat ~sqrt(6)x.
- IC now measured on NON-OVERLAPPING windows (weeks thinned to ~HORIZON apart).
- Each signal carries a `reliable` flag (>= 12 independent windows); BacktestPanel
  greys out and de-stars thin signals so a lucky 9-week IC of 0.3 can't masquerade
  as an edge.

332 backend tests pass; frontend build clean. No migration (config + job + an
added JSON field on the cached backtest report).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 18:20:59 +02:00
parent 402025692a
commit 099846513b
9 changed files with 148 additions and 38 deletions
+2
View File
@@ -79,6 +79,7 @@ class TestConfigureScheduler:
job_ids = {j.id for j in jobs}
assert job_ids == {
"data_collector",
"data_backfill",
"sentiment_collector",
"fundamental_collector",
"rr_scanner",
@@ -103,6 +104,7 @@ class TestConfigureScheduler:
"daily_pipeline",
"intraday_pipeline",
"data_collector",
"data_backfill",
"fundamental_collector",
"market_regime",
"outcome_evaluator",
+28 -9
View File
@@ -94,18 +94,17 @@ def _records(closes: list[float]) -> list[SimpleNamespace]:
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):
# 120 consecutive weeks, 40 names each. After non-overlapping thinning
# (stride = HORIZON/5 = 6) that leaves 20 independent windows — above the
# reliability bar. "edge" perfectly orders the forward return; "noise" is
# independent of it.
collected: dict = {"edge": {}, "noise": {}}
for week in range(120):
edge_recs = []
noise_recs = []
for _ in range(40):
fwd = rng.gauss(0, 0.05)
edge_recs.append((fwd, fwd)) # signal == fwd → IC = 1
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
@@ -113,13 +112,33 @@ def test_signal_evaluation_separates_edge_from_noise():
rows = {r["signal"]: r for r in bt._signal_evaluation(collected)}
assert rows["edge"]["mean_ic"] == 1.0
assert rows["edge"]["weeks"] == 20 # 120 weeks thinned to non-overlapping
assert rows["edge"]["reliable"] is True
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
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_flags_too_few_windows_unreliable():
# 5 adjacent weeks collapse to a single non-overlapping window → unreliable.
collected: dict = {
"edge": {(2020, w): [(float(i), float(i)) for i in range(40)] for w in range(5)}
}
row = bt._signal_evaluation(collected)[0]
assert row["weeks"] == 1
assert row["reliable"] is False
def test_nonoverlapping_weeks_thins_by_stride():
weeks = [(2020, w) for w in range(1, 13)] # 12 consecutive ISO weeks
kept = bt._nonoverlapping_weeks(weeks, stride=6)
assert kept == [(2020, 1), (2020, 7)] # 6 apart, no overlap
# Stride 1 keeps everything; ordering is chronological.
assert bt._nonoverlapping_weeks(list(reversed(weeks)), stride=1) == weeks
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)}}