deepen OHLCV history + make the factor-IC pass honest about overlap/regime
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:
@@ -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",
|
||||
|
||||
@@ -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)}}
|
||||
|
||||
Reference in New Issue
Block a user