Compare commits

..
25 Commits
Author SHA1 Message Date
dennisthiessenandClaude Fable 5 29715ef3d1 Merge branch 'research/earnings-gap-and-sue' — near-close data fix + Task 2 closure
Deploy / lint (push) Successful in 1m0s
Deploy / test (push) Successful in 2m4s
Deploy / deploy (push) Successful in 39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:11:00 +02:00
dennisthiessenandClaude Fable 5 c7c60a64f2 research: Task 2 closed — SUE dead, earnings gap informational
Earnings backfill sourced from the public DoltHub earnings repo at a
pinned commit rather than the FMP API: reproducible for anyone re-running
the study, and it burns no request quota. 12,414 events, 98.6% of symbols
with >=8 announcements, 99.2% paired actual/estimate, no keyed duplicates.

2a earnings-gap diagnostic: INFORMATIONAL, no filter shipped. The
pre-earnings cohort's right tail was better, so the registered
avoid-earnings condition failed. Note the raw 23/266 vs 115/574 incidence
gap is largely a duration confound -- severe losses stop out fast and have
less time to span an announcement -- so it is not evidence that holding
through earnings is safe.

2b SUE: FAIL against the pre-registered +0.03 bar (unconditional IC
+0.0151 over 56 reliable windows, momentum-conditional +0.0213). Signs
stable across eras, so this is a clean null rather than an ambiguous one,
consistent with post-earnings drift having decayed in large caps.

Closes the Tier-1 arc: Task 1 dead on deep evidence, Task 2 dead here,
Task 3 complete as diagnostic. No in-sample research thread remains open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:10:46 +02:00
dennisthiessenandClaude Fable 5 1fa3d70dec fix: fetch today's in-progress bar; name weekday crons
Two independent bugs left the near-close scan running on the previous
session's close, silently degrading live execution to the stale_close
floor (~1.57 Sharpe) instead of the intended ~1.77 close-fill case.

1. OHLCV window never covered the current day. Daily bars are stamped at
   session start (04:00Z under EDT), so an end of midnight-on-end_date
   landed before that day's bar and dropped it. Widening the window alone
   fails the whole request with 'subscription does not permit querying
   recent SIP data', so end is also clamped to now-20min. Today's bar is
   now returned, roughly 20 minutes behind live -- within the staleness
   the near-close design already assumed.

   Intraday runs therefore store a partial bar and ingestion progress
   reaches today, which made incremental resume skip the after-close
   refresh entirely. collect_ohlcv_final() re-pulls the last sessions so
   the consolidated bar overwrites the partial one before outcome eval.

2. APScheduler's from_crontab() passes day-of-week to its own field where
   0=Monday, so '1-5' meant Tue-Sat: every Monday was skipped and the
   scanner ran Saturdays on stale data. Weekday schedules now use names.
   Stored settings already corrected via Admin; this fixes the defaults.

Tests cover both: today's bar inside the window, the delayed-data clamp,
historical windows untruncated, and a week of fire times asserting Monday
is present and weekends are not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:10:35 +02:00
dennisthiessenandClaude Fable 5 86dc24ae8a Merge branch 'research/earnings-gap-and-sue' — Tier-1 closed: sector residual dead on deep evidence; universe x horizon matrix confirms 505 book; earnings scaffolding ready
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:29:25 +02:00
dennisthiessen bb8aa655a1 research: clean up closed Tier-1 scaffolding from branch
Drop intermediate history-depth reports, sector-residual runners/map/code hooks
(evidence stays in final reports + docs), and slim MacBook helper to ssl/earnings/
prod-book-matrix only. SSL bootstrap and archived research conclusions retained.
2026-07-19 14:41:52 +02:00
dennisthiessen 1c38a94dd0 research: interpret prod book universe x horizon matrix; ignore candidate cache
Four-arm results: 505 stays positive (softer on deep history); liquid breadth
destroys book under current knobs. Stop tracking 1.3GB pkl cache under reports/.cache.
2026-07-19 14:25:06 +02:00
Dennis Thiessen a4d5ed7a93 tests done 2026-07-19 14:21:05 +02:00
dennisthiessen 9171e366ee research: prepare prod book universe x horizon 4-arm matrix
Pre-register A-D (4y/2016 x 505/505+liquid) with unchanged production knobs.
Runner caches full GTL candidates then re-ranks per arm; MacBook entry via
run_tier1_macbook.sh --prod-book-matrix.
2026-07-19 11:58:52 +02:00
dennisthiessen 9717d8176b research: archive Task 1 sector residual as CLOSED/REJECTED
Deep masked retest failed the iron IC bar (0.027 < 0.03). Log as rejected #13 in
research README; close sector-residual and history-depth docs. Production market
residual unchanged.
2026-07-19 11:45:15 +02:00
Dennis Thiessen 01007fb6dd tests done 2026-07-19 11:39:43 +02:00
dennisthiessen 003f20de19 fix: sector-resid sanity grades against Alpaca feed floor, not calendar 5000d
SANITY-FAIL report showed megacaps/ETFs already at empirical 2016-01-04 floor
(2649 bars) after deepen; check wrongly required ~2013. Pass when megacaps leave
the old 2021 two-tier floor and match SPY; XLC listing exception retained.
2026-07-19 11:22:06 +02:00
Dennis Thiessen f3d1312a69 tests done 2026-07-19 11:21:05 +02:00
dennisthiessen a9841d92b7 research: sector-resid deep test (deepen shallow + one masked PASS/FAIL)
Terminal follow-up for Task 1: detect/refetch shallow two-tier symbols and sector
ETFs at 5000d, regenerate manifest, run ONE liquid-1500 harness with era split, grade
mom_12_1_sector_resid mechanically. Bundled as run_tier1_macbook.sh --sector-resid-deep.
2026-07-19 10:58:03 +02:00
dennisthiessen 64761f38ba research: interpret history-depth MacBook harness (PARK sector residual wire-in)
Authoritative report history-depth-20260719-103315: race guard pass on deep
research.sqlite. Sector residual still short-window only (no pre-2021); fip sign
flips on broad deep sample; no production retune.
2026-07-19 10:42:01 +02:00
Dennis Thiessen f6e0ca734f tests done 2026-07-19 10:40:19 +02:00
dennisthiessen 06cf054f60 fix: bootstrap SSL/CA for research CLI on corporate MacBooks
Extract app/ssl_bootstrap.py (shared with FastAPI main), wire it into research
scripts, and teach run_tier1_macbook.sh to locate combined-ca-bundle.pem, certifi,
optional USE_CORP_PROXY, plus --ssl-check diagnostics.
2026-07-19 09:46:07 +02:00
dennisthiessen 32bf9c9297 research: bundle MacBook tier-1 pipeline into one bash script
scripts/run_tier1_macbook.sh wraps earnings resume, coverage probe, deep
snapshot rebuild, sector ETF refresh, and history-depth harness with phase flags.
2026-07-19 09:34:49 +02:00
dennisthiessen fa25b6ee68 research: sector residual, earnings gap/SUE, history-depth scaffolding
Tier-1 alpha research (local only, no production deploy):

Sector residual momentum: two-factor SPY+sector residual and sector demean signals, IC harness + A/B. Sector resid clears pre-registered bars narrowly (PROMOTE for human wire design only). Sector demean fails t vs market resid.

Earnings: earnings_events backfill (FMP bulk paid; FMP/AV per-symbol), 2a gap diagnostic report-only, 2b SUE IC (PARK; incomplete 48/506 coverage).

History-depth: pre-registered doc + runner for MacBook deep rebuild/harness.

Do not ship production residual or filters from this branch.
2026-07-19 09:33:34 +02:00
dennisthiessen 8f285acb00 Merge branch 'research/fip-breadth-ic' — park Phase B fip breadth
Brings env-gated liquid-breadth harness hooks, research tooling, compact
evidence, and the completion-manifest race guard. No production behavior
change when liquid env vars are unset. Nothing to deploy.
2026-07-19 00:32:48 +02:00
dennisthiessen 2311999e57 research: park Phase B fip breadth; race guard and compact evidence
Log the 21:14 orphan as a snapshot-build race, rewrite the context table to
authoritative ICs only, and soften the vol-tilt warning. Add extender completion
manifest + breadth refuse guard; strip intermediate/orphaned reports; park the
thread (no book sim, no deploy).
2026-07-19 00:32:20 +02:00
dennisthiessen 7d60e54f5a research: single-source liquid mask; orphan +0.06 fip IC
Harness and diagnostics share _filter_liquid_breadth_week_rich. Recompute
shows unconditional liquid fip IC -0.017 (mask binds 97%); mom-conditional
-0.088/t-4.58 stands. Document +0.0575 as orphaned.
2026-07-19 00:06:04 +02:00
dennisthiessen ceaaadc49f research: fip breadth diagnostics + compositional read
Add lagged/tier/prod-subset/mom-conditional checks on research.sqlite.
Log: unconditional sign is a winner/bleeder tug-of-war; mom-conditional
fip stays negative and reliable; warn on high-vol tilt if universe broadens.
2026-07-18 21:40:05 +02:00
Dennis Thiessen d34c7a21b7 done 2026-07-18 21:26:13 +02:00
dennisthiessen 30286111a8 fix: per-symbol SQLite transactions in research snapshot extender
Avoid inactive-transaction crashes from mixing connection.commit with ORM
Session. Write path is raw SQL, one begin() block per symbol.
2026-07-18 20:34:38 +02:00
dennisthiessen b6892d13fd fix: resolve research universe without system_settings DB
Public/FMP/seed symbol lists no longer touch SystemSetting cache, so the
extender works offline on an empty in-memory session.
2026-07-18 20:32:57 +02:00
42 changed files with 11689 additions and 143 deletions
+1
View File
@@ -46,3 +46,4 @@ backtest_snapshots/
# Rebuildable pickle caches are local accelerators, not decision evidence.
reports/*.pkl
reports/*.pk1
reports/.cache/
+2 -2
View File
@@ -263,7 +263,7 @@ A systematic single-variable sweep (offline prod snapshot, production gate/rank/
Two findings future sessions must not re-litigate:
- **The "inverse-vol sizing win" (July 2026) was mis-attributed — do not resurrect.** The diagnostic sized `notional = equity × 1% / vol_6m`, and the 20% notional cap bound on 95% of entries, so it actually measured "~5 positions × 20% notional each" — a concentration/risk-appetite bump economically equivalent to raising risk to 1.5%, not vol-managed sizing. Genuine inverse-vol sizing (risk budget × median-vol/vol) cuts max drawdown to 18.2% but costs ~58pp total return at flat Sharpe: a risk-preference trade, not edge.
- **`fip_id` — Da/Gurun/Warachka information discreteness over the 12-1 formation window — is the strongest cross-sectional signal measured on this universe: IC 0.045, t = 2.91, correct sign (continuous-information winners outperform).** It clears the iron-rule bar in isolation but does not improve this book (the momentum gate already captures the effect in-sample). It is the prime ranking/gate candidate **if the universe broadens** (e.g. `nasdaq_all`).
- **`fip_id` — Da/Gurun/Warachka information discreteness over the 12-1 formation window — is the strongest cross-sectional signal on the *production* universe: IC 0.045, t = 2.91, correct sign (continuous-information winners outperform).** It clears the iron-rule bar in isolation but does not improve this book (the momentum gate already captures the effect in-sample). **Phase B (liquid-1500, research branch only):** unconditional fip fails iron rule (0.017 / t 1.85); mom-conditional fip (0.088 / t 4.58) is a *book-tilt candidate only* after a baseline breadth mom book is proven. Do **not** cite the orphaned 21:14 row (+0.0575) — it raced a partial `research.sqlite`. See `docs/research/fip-breadth-ic.md`.
### The iron rule for strategy changes
@@ -281,7 +281,7 @@ Corollaries: never let an unvalidated score gate setups; the outcome evaluator m
1. **Forward monitor the promoted strategy** — the production UI now behaves like a portfolio monitor for the current strategy, with selectable lookbacks and SPY comparison. Forward paper-trade months are the only evidence the snapshot cannot provide; the July 2026 tuning pass closed every in-sample lead. (Trailing-stop sensitivity and the max-15 capacity check are done — see the tuning table above.)
2. **Signal context snapshots** — accumulate point-in-time composite/sentiment/fundamental context for every new setup so the discretionary overlay can be tested forward-only.
3. **More breadth, not more history** — widening the ranked universe (e.g. `nasdaq_all`) strengthens each week's cross-section and the IC t-stat, even if only the top slice is traded. Now doubly motivated: it is also where the strong `fip_id` signal (see tuning findings) could become tradeable. (Deeper history was considered and declined.)
3. **Breadth is no longer free leverage** — Phase B found residual-mom t-stat *fell* on liquid-1500 vs the 505-name fingerprint (0.055/1.98 → 0.029/1.33). Any breadth book must clear a pre-registered baseline arm before fip tilts mean anything. (Deeper history was considered and declined.)
## Key Use Cases
+2 -49
View File
@@ -3,56 +3,9 @@
# ---------------------------------------------------------------------------
# SSL + proxy injection — MUST happen before any HTTP client imports
# ---------------------------------------------------------------------------
import os as _os
import ssl as _ssl
from pathlib import Path as _Path
from app.ssl_bootstrap import bootstrap_ssl
_COMBINED_CERT = _Path(__file__).resolve().parent.parent / "combined-ca-bundle.pem"
if _COMBINED_CERT.exists():
_cert_path = str(_COMBINED_CERT)
# Env vars for libraries that respect them (requests, urllib3)
_os.environ["SSL_CERT_FILE"] = _cert_path
_os.environ["REQUESTS_CA_BUNDLE"] = _cert_path
_os.environ["CURL_CA_BUNDLE"] = _cert_path
# Monkey-patch ssl.create_default_context so that ALL libraries
# (aiohttp, httpx, google-genai, alpaca-py, etc.) automatically
# use our combined CA bundle that includes the corporate root cert.
_original_create_default_context = _ssl.create_default_context
def _patched_create_default_context(
purpose=_ssl.Purpose.SERVER_AUTH, *, cafile=None, capath=None, cadata=None
):
ctx = _original_create_default_context(
purpose, cafile=cafile, capath=capath, cadata=cadata
)
# Always load our combined bundle on top of whatever was loaded
ctx.load_verify_locations(cafile=_cert_path)
return ctx
_ssl.create_default_context = _patched_create_default_context
# Also patch aiohttp's cached SSL context objects directly, since
# aiohttp creates them at import time and may have already cached
# a context without our corporate CA bundle.
try:
import aiohttp.connector as _aio_conn
if hasattr(_aio_conn, '_SSL_CONTEXT_VERIFIED') and _aio_conn._SSL_CONTEXT_VERIFIED is not None:
_aio_conn._SSL_CONTEXT_VERIFIED.load_verify_locations(cafile=_cert_path)
if hasattr(_aio_conn, '_SSL_CONTEXT_UNVERIFIED') and _aio_conn._SSL_CONTEXT_UNVERIFIED is not None:
_aio_conn._SSL_CONTEXT_UNVERIFIED.load_verify_locations(cafile=_cert_path)
except ImportError:
pass
# Corporate proxy — needed when Kiro spawns the process (no .zshrc sourced)
# Only enable this if explicitly requested via environment variable.
if _os.environ.get("USE_CORP_PROXY", "0") == "1":
_PROXY = "http://aproxy.corproot.net:8080"
_NO_PROXY = "corproot.net,sharedtcs.net,127.0.0.1,localhost,bix.swisscom.com,swisscom.com"
_os.environ.setdefault("HTTP_PROXY", _PROXY)
_os.environ.setdefault("HTTPS_PROXY", _PROXY)
_os.environ.setdefault("NO_PROXY", _NO_PROXY)
bootstrap_ssl()
import logging
import sys
+31 -3
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
import logging
from datetime import date
from datetime import date, datetime, time, timedelta, timezone
from alpaca.data.historical import StockHistoricalDataClient
from alpaca.data.requests import StockBarsRequest
@@ -16,6 +16,11 @@ from app.providers.protocol import OHLCVData
logger = logging.getLogger(__name__)
# Free plans may not query data from the most recent ~15 minutes, and a window
# reaching into it fails the *entire* request — which would silently leave the
# near-close scan on yesterday's close. Margin over the documented boundary.
_RECENT_DATA_CUTOFF = timedelta(minutes=20)
class AlpacaOHLCVProvider:
"""Fetches daily OHLCV bars from Alpaca Markets Data API."""
@@ -25,6 +30,26 @@ class AlpacaOHLCVProvider:
raise ProviderError("Alpaca API key and secret are required")
self._client = StockHistoricalDataClient(api_key, api_secret)
@staticmethod
def _resolve_window(start_date: date, end_date: date) -> tuple[datetime, datetime]:
"""Return the instants covering ``start_date``..``end_date`` inclusive.
Two boundaries have to be right or today's bar disappears:
* Daily bars are stamped at the session start in UTC (04:00Z under EDT),
so an ``end`` of midnight on ``end_date`` lands *before* that day's bar
and silently drops it — extend to the following midnight instead.
* The window must stay out of the delayed-data period, otherwise the
request is rejected outright with "subscription does not permit
querying recent SIP data". Clamping keeps today's in-progress bar
available, roughly 20 minutes behind live.
"""
start = datetime.combine(start_date, time.min, tzinfo=timezone.utc)
end = datetime.combine(
end_date + timedelta(days=1), time.min, tzinfo=timezone.utc
)
return start, min(end, datetime.now(timezone.utc) - _RECENT_DATA_CUTOFF)
@staticmethod
def _to_alpaca_symbol(symbol: str) -> str:
"""Convert internal symbol format (BRK-B) to Alpaca format (BRK.B)."""
@@ -40,12 +65,15 @@ class AlpacaOHLCVProvider:
) -> list[OHLCVData]:
"""Fetch daily OHLCV bars for *ticker* between *start_date* and *end_date*."""
alpaca_symbol = self._to_alpaca_symbol(ticker)
start, end = self._resolve_window(start_date, end_date)
if end <= start:
return []
try:
request = StockBarsRequest(
symbol_or_symbols=alpaca_symbol,
timeframe=TimeFrame.Day,
start=start_date,
end=end_date,
start=start,
end=end,
adjustment=Adjustment.SPLIT,
)
+45 -11
View File
@@ -487,7 +487,12 @@ def _chunked(symbols: list[str], chunk_size: int) -> list[list[str]]:
# ---------------------------------------------------------------------------
async def collect_ohlcv(full_backfill: bool = False, job_name: str = "data_collector") -> None:
async def collect_ohlcv(
full_backfill: bool = False,
job_name: str = "data_collector",
*,
refetch_days: int = 0,
) -> None:
"""Fetch latest daily OHLCV for all tracked tickers.
Uses AlpacaOHLCVProvider. Processes each ticker independently.
@@ -500,6 +505,10 @@ async def collect_ohlcv(full_backfill: bool = False, job_name: str = "data_colle
``settings.ohlcv_history_days`` window (ignoring incremental resume) — used by
the manual data_backfill job to deepen shallow histories. ``job_name`` lets the
backfill report its own runtime/resume state separate from data_collector.
``refetch_days`` re-pulls the last N days regardless of ingestion progress —
the after-close run uses it to overwrite the day's partial intraday bar, which
resume logic would otherwise skip as "already up to date".
"""
_log_event(logging.INFO, "job_start", job=job_name)
_runtime_start(job_name)
@@ -536,11 +545,14 @@ async def collect_ohlcv(full_backfill: bool = False, job_name: str = "data_colle
return
end_date = date.today()
# Full backfill: pass an explicit start_date so fetch_and_ingest re-pulls
# the whole window instead of resuming from the last stored bar.
backfill_start = (
end_date - timedelta(days=settings.ohlcv_history_days) if full_backfill else None
)
# An explicit start_date makes fetch_and_ingest re-pull that window instead
# of resuming from the last stored bar (upsert overwrites, so this is safe).
if full_backfill:
backfill_start = end_date - timedelta(days=settings.ohlcv_history_days)
elif refetch_days:
backfill_start = end_date - timedelta(days=refetch_days)
else:
backfill_start = None
for symbol in symbols:
_runtime_progress(job_name, processed=processed, total=total, current_ticker=symbol)
@@ -598,6 +610,18 @@ async def backfill_ohlcv() -> None:
await collect_ohlcv(full_backfill=True, job_name="data_backfill")
async def collect_ohlcv_final() -> None:
"""After-close OHLCV refresh that replaces the day's partial bar.
Intraday runs store today's bar while the session is still open, so ingestion
progress already reads "today" and incremental resume would skip the day
entirely — leaving a partial bar as the permanent record. ``refetch_days``
forces the last few sessions to be re-pulled so outcome evaluation and
fill-quality checks grade against the real close.
"""
await collect_ohlcv(refetch_days=_FINAL_REFETCH_DAYS)
# ---------------------------------------------------------------------------
# Job: Sentiment Collector
# ---------------------------------------------------------------------------
@@ -1183,6 +1207,10 @@ async def sync_ticker_universe() -> None:
# — the qualifying full-universe scan runs once near the US close so post-stop
# gate-reset sees one observation per trading day (plus the trade_policy
# distinct-day guard for manual re-scans).
# Sessions re-pulled by the after-close fetch so the consolidated bar overwrites
# the intraday partial one (covers a long weekend / holiday gap).
_FINAL_REFETCH_DAYS = 5
_DAILY_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv"),
("benchmark_collector", "collect_benchmark"),
@@ -1206,6 +1234,8 @@ _DAILY_PIPELINE_STEPS = [
# entries behave like stale_close (still acceptable per execution-recovery matrix).
# No exchange calendar dependency.
_NEAR_CLOSE_PIPELINE_STEPS = [
# Must land today's in-progress bar (~20 min behind live), or the scan falls
# back to the previous close and execution degrades to the stale_close floor.
("data_collector", "collect_ohlcv"),
("rr_scanner", "scan_rr"),
("alerts", "dispatch_alerts_job"),
@@ -1214,7 +1244,7 @@ _NEAR_CLOSE_PIPELINE_STEPS = [
# After close (~16:45 ET MonFri): fresh OHLCV fetch so outcomes resolve on the
# final bar, not the near-close partial bar, then outcome/paper close.
_AFTER_CLOSE_PIPELINE_STEPS = [
("data_collector", "collect_ohlcv"),
("data_collector", "collect_ohlcv_final"),
("outcome_evaluator", "evaluate_outcomes"),
]
@@ -1338,18 +1368,22 @@ def _parse_frequency(freq: str) -> dict[str, int]:
# All wall times are America/New_York after the near-close execution cutover.
# Stored SystemSetting values shadow these defaults — deploy migration 023
# rewrites schedule_* keys so prod does not keep scanning at 07:00 Berlin.
# DAY-OF-WEEK MUST BE NAMES, NEVER NUMBERS. APScheduler's from_crontab() passes
# field 5 straight to its own day_of_week, where 0=Monday — so "1-5" resolves to
# TueSat, silently skipping every Monday and scanning on Saturdays. Names are
# unambiguous in both dialects.
SCHEDULE_DEFAULTS: dict[str, str] = {
"schedule_timezone": "America/New_York",
# Morning data/display refresh (no qualifying R:R scan).
"schedule_daily_pipeline_cron": "0 2 * * *",
# Fetch in-progress bars → scan → Telegram (manual MOC window).
"schedule_near_close_pipeline_cron": "30 15 * * 1-5",
"schedule_near_close_pipeline_cron": "30 15 * * mon-fri",
# Fetch final bars → outcome eval (must not run on the partial near-close bar).
"schedule_after_close_pipeline_cron": "45 16 * * 1-5",
"schedule_after_close_pipeline_cron": "45 16 * * mon-fri",
# Hourly mid-session price + outcome (10:0015:00 ET MonFri).
"schedule_intraday_pipeline_cron": "0 10-15 * * 1-5",
"schedule_intraday_pipeline_cron": "0 10-15 * * mon-fri",
# Weekly fundamentals early Monday NY.
"schedule_fundamentals_cron": "0 1 * * 1",
"schedule_fundamentals_cron": "0 1 * * mon",
}
# job id -> schedule setting key
+341 -75
View File
@@ -30,6 +30,11 @@ Environment variables (see also run_backtest_snapshot.py):
BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1
BACKTEST_RESEARCH_EXITS=1
BACKTEST_MIN_RR_SWEEP=1
Broad-universe signal research (local snapshots only; inert when unset):
BACKTEST_LIQUID_BREADTH=1500 # PIT top-N by 63d median $vol, price floor
BACKTEST_LIQUID_MIN_PRICE=5 # USD close floor at as-of (default 5)
BACKTEST_SIGNAL_EVAL_ONLY=1 # skip portfolio_sim / monitor (signal IC only)
"""
from __future__ import annotations
@@ -808,7 +813,10 @@ def _residual_momentum_12_1(
var_market = sum((x - mean_market) ** 2 for x in market_rets)
if var_market <= 0:
return None
cov = sum((stock_rets[k] - mean_stock) * (market_rets[k] - mean_market) for k in range(len(stock_rets)))
cov = sum(
(stock_rets[k] - mean_stock) * (market_rets[k] - mean_market)
for k in range(len(stock_rets))
)
beta = cov / var_market
return sum(stock_rets[k] - beta * market_rets[k] for k in range(len(stock_rets)))
@@ -876,20 +884,86 @@ def _signal_values(
return out
def _liquid_breadth_top_n() -> int:
"""0 = off (production path). N > 0 enables PIT top-N $vol mask for signal IC."""
raw = os.getenv("BACKTEST_LIQUID_BREADTH", "").strip()
if not raw:
return 0
try:
return max(0, int(raw))
except ValueError:
return 0
def _liquid_min_price() -> float:
raw = os.getenv("BACKTEST_LIQUID_MIN_PRICE", "5").strip() or "5"
try:
return max(0.0, float(raw))
except ValueError:
return 5.0
def _signal_eval_only() -> bool:
return os.getenv("BACKTEST_SIGNAL_EVAL_ONLY", "").strip() in ("1", "true", "yes")
async def _load_research_rank_only_symbols(db: AsyncSession) -> set[str]:
"""Symbols that feed signal IC only (no GTL/candidate replay).
Optional side table ``research_rank_only`` on research snapshots. Missing
table → empty set (production path unchanged).
"""
from sqlalchemy import text
try:
result = await db.execute(text("SELECT symbol FROM research_rank_only"))
return {str(row[0]).upper() for row in result.fetchall() if row[0]}
except Exception:
return set()
def _median_dollar_vol_63(
closes: list[float], volumes: list[float], i: int, lookback: int = 63
) -> float | None:
"""Rolling median of close×volume over ``lookback`` bars ending at ``i`` (inclusive)."""
if i + 1 < lookback or lookback < 2:
return None
dvs: list[float] = []
for k in range(i - lookback + 1, i + 1):
if closes[k] > 0 and volumes[k] >= 0:
dvs.append(closes[k] * float(volumes[k]))
if len(dvs) < max(20, lookback // 2):
return None
dvs_sorted = sorted(dvs)
mid = len(dvs_sorted) // 2
if len(dvs_sorted) % 2:
return dvs_sorted[mid]
return 0.5 * (dvs_sorted[mid - 1] + dvs_sorted[mid])
def _accumulate_signal_series(
records: list,
collected: dict,
benchmark_closes: dict[date, float] | None = None,
*,
symbol: str | None = None,
) -> 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)."""
HORIZON trading days. Mutates ``collected`` (a dict of dict of list).
When ``BACKTEST_LIQUID_BREADTH`` is set, observations are dicts with PIT
liquidity fields for the mask; otherwise plain ``(val, fwd)`` tuples so the
production signal path stays unchanged.
"""
n = len(records)
if n < HORIZON + 21:
return
closes = [float(r.close) for r in records]
highs = [float(r.high) for r in records]
volumes = [float(getattr(r, "volume", 0) or 0) for r in records]
dates = [r.date for r in records]
liquid_mode = _liquid_breadth_top_n() > 0
for i in _weekly_asof_indices(records):
j = i + HORIZON
if j >= n or closes[i] <= 0:
@@ -897,8 +971,18 @@ def _accumulate_signal_series(
fwd = closes[j] / closes[i] - 1.0
iso = records[i].date.isocalendar()
week_key = (iso[0], iso[1])
dvol = _median_dollar_vol_63(closes, volumes, i) if liquid_mode else None
for name, val in _signal_values(dates, closes, highs, i, benchmark_closes).items():
collected[name][week_key].append((val, fwd))
if liquid_mode:
collected[name][week_key].append({
"val": val,
"fwd": fwd,
"close": closes[i],
"median_dvol_63": dvol,
"symbol": symbol,
})
else:
collected[name][week_key].append((val, fwd))
def _rank(xs: list[float]) -> list[float]:
@@ -937,6 +1021,110 @@ def _spearman(xs: list[float], ys: list[float]) -> float | None:
return _pearson(_rank(xs), _rank(ys))
def _obs_val_fwd(rec: object) -> tuple[float, float] | None:
"""Unpack a signal observation: ``(val, fwd)`` or research dict form."""
if isinstance(rec, dict):
try:
return float(rec["val"]), float(rec["fwd"])
except (KeyError, TypeError, ValueError):
return None
if isinstance(rec, (tuple, list)) and len(rec) >= 2:
try:
return float(rec[0]), float(rec[1])
except (TypeError, ValueError):
return None
return None
def _filter_liquid_breadth_week(
recs: list,
*,
top_n: int,
min_price: float,
) -> list[tuple[float, float]]:
"""Point-in-time top-N by median $vol among names with price ≥ floor.
Ranking is relative (IEX volume undercount is OK for order stats). Membership
is recomputed every week from as-of bars — never frozen from today's liquidity.
"""
kept = _filter_liquid_breadth_week_rich(
recs, top_n=top_n, min_price=min_price
)
return [(float(r["val"]), float(r["fwd"])) for r in kept]
def _filter_liquid_breadth_week_rich(
recs: list,
*,
top_n: int,
min_price: float,
) -> list[dict]:
"""Same mask as ``_filter_liquid_breadth_week``, returning rich rows.
Single source for harness IC and research diagnostics. Eligible pool =
dict observations with close ≥ min_price and median_dvol_63 > 0; then
keep top_n by dollar volume (highest first). Non-dict legacy tuples are
not eligible for the liquid mask (they have no dvol).
"""
eligible: list[tuple[float, dict]] = [] # (-dvol, row)
for rec in recs:
if not isinstance(rec, dict):
continue
close = rec.get("close")
dvol = rec.get("median_dvol_63")
if close is None or float(close) < min_price:
continue
if dvol is None or float(dvol) <= 0:
continue
pair = _obs_val_fwd(rec)
if pair is None:
continue
row = {
"val": pair[0],
"fwd": pair[1],
"close": float(close),
"median_dvol_63": float(dvol),
"symbol": rec.get("symbol"),
}
# Preserve optional research fields for mom-conditional diagnostics.
for key in ("mom_12_1", "mom_12_1_resid", "vol_6m", "fip_id"):
if key in rec and rec[key] is not None:
row[key] = rec[key]
eligible.append((-float(dvol), row))
eligible.sort(key=lambda item: item[0])
return [row for _, row in eligible[:top_n]]
def _liquid_breadth_week_stats(
recs: list,
*,
top_n: int,
min_price: float,
) -> dict[str, int | bool]:
"""Pre/post mask counts for reconciling avg_cross_section semantics."""
raw = len(recs)
eligible = 0
for rec in recs:
if not isinstance(rec, dict):
continue
close = rec.get("close")
dvol = rec.get("median_dvol_63")
if close is None or float(close) < min_price:
continue
if dvol is None or float(dvol) <= 0:
continue
if _obs_val_fwd(rec) is None:
continue
eligible += 1
post = min(eligible, top_n) if top_n > 0 else eligible
return {
"raw_pool": raw,
"eligible_pre_mask": eligible,
"post_mask": post,
"mask_binds": bool(top_n > 0 and eligible > top_n),
}
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)
@@ -982,10 +1170,16 @@ def _signal_evaluation(collected: dict) -> list[dict]:
IC is measured on NON-OVERLAPPING forward windows (weeks thinned to ~HORIZON
apart) so the t-stat isn't inflated by autocorrelation. A signal with no edge
lands near IC 0 / spread 0; one with too few independent windows is flagged
lands near IC 0 / score 0; one with too few independent windows is flagged
unreliable rather than trusted on a lucky handful.
When ``BACKTEST_LIQUID_BREADTH=N`` is set, each week's cross-section is first
restricted to the top-N names by point-in-time 63d median dollar volume
(price ≥ BACKTEST_LIQUID_MIN_PRICE). Production path (flag unset) is unchanged.
"""
stride = max(1, round(HORIZON / 5)) # ISO weeks spanned by the forward window
top_n = _liquid_breadth_top_n()
min_price = _liquid_min_price()
rows: list[dict] = []
for name in sorted(collected):
weeks_map = collected[name]
@@ -994,15 +1188,37 @@ def _signal_evaluation(collected: dict) -> list[dict]:
ics: list[float] = []
spreads: list[float] = []
sizes: list[int] = []
raw_sizes: list[int] = []
eligible_sizes: list[int] = []
bind_flags: list[bool] = []
for wk in kept:
recs = weeks_map[wk]
ic = _spearman([r[0] for r in recs], [r[1] for r in recs])
if top_n > 0:
stats = _liquid_breadth_week_stats(
recs, top_n=top_n, min_price=min_price
)
raw_sizes.append(int(stats["raw_pool"]))
eligible_sizes.append(int(stats["eligible_pre_mask"]))
bind_flags.append(bool(stats["mask_binds"]))
pairs = _filter_liquid_breadth_week(
recs, top_n=top_n, min_price=min_price
)
else:
pairs = []
for rec in recs:
pair = _obs_val_fwd(rec)
if pair is not None:
pairs.append(pair)
if len(pairs) < MIN_CROSS_SECTION:
continue
ic = _spearman([p[0] for p in pairs], [p[1] for p in pairs])
if ic is not None:
ics.append(ic)
spread = _quintile_spread(recs)
spread = _quintile_spread(pairs)
if spread is not None:
spreads.append(spread)
sizes.append(len(recs))
# avg_cross_section is ALWAYS post-mask pair count (the IC sample).
sizes.append(len(pairs))
if not ics:
continue
mean_ic = sum(ics) / len(ics)
@@ -1011,7 +1227,7 @@ def _signal_evaluation(collected: dict) -> list[dict]:
else:
std = 0.0
t_stat = mean_ic / std * math.sqrt(len(ics)) if std > 0 else None
rows.append({
row = {
"signal": name,
"weeks": len(ics),
"avg_cross_section": round(sum(sizes) / len(sizes), 1) if sizes else None,
@@ -1020,16 +1236,36 @@ def _signal_evaluation(collected: dict) -> list[dict]:
"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,
"reliable": len(ics) >= MIN_RELIABLE_PERIODS,
})
}
if top_n > 0:
row["liquid_breadth_top_n"] = top_n
row["liquid_min_price"] = min_price
# Explicit pre/post mask diagnostics (reconcile "did top-N bind?").
if raw_sizes:
row["avg_raw_pool"] = round(sum(raw_sizes) / len(raw_sizes), 1)
if eligible_sizes:
row["avg_eligible_pre_mask"] = round(
sum(eligible_sizes) / len(eligible_sizes), 1
)
if bind_flags:
row["mask_binds_pct"] = round(
sum(1 for b in bind_flags if b) / len(bind_flags) * 100, 1
)
rows.append(row)
rows.sort(key=lambda r: r["mean_ic"], reverse=True)
return rows
def _signal_series(records: list, benchmark_closes: dict[date, float] | None = None) -> dict:
def _signal_series(
records: list,
benchmark_closes: dict[date, float] | None = None,
*,
symbol: str | None = None,
) -> dict:
"""Per-ticker signal/forward-return series as a PLAIN (picklable) nested dict
— no defaultdict/lambda — so it can cross a process boundary."""
tmp: dict = defaultdict(lambda: defaultdict(list))
_accumulate_signal_series(records, tmp, benchmark_closes)
_accumulate_signal_series(records, tmp, benchmark_closes, symbol=symbol)
return {name: dict(weeks) for name, weeks in tmp.items()}
@@ -1041,10 +1277,15 @@ def _replay_and_signals(
benchmark_closes: dict[date, float] | None = None,
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
cadence: str = DEFAULT_BACKTEST_CADENCE,
signal_only: bool = False,
) -> tuple[list[dict], dict]:
"""The CPU-bound per-ticker work, as a top-level (picklable) function so it can
run in a worker process. Takes primitive column arrays (cheap to pickle),
rebuilds bar objects, and returns (candidates, signal_series)."""
rebuilds bar objects, and returns (candidates, signal_series).
``signal_only=True`` (research rank-only names): skip GTL/candidate replay so
the production portfolio book is never polluted by broad-universe tickers.
"""
date_ords, opens, highs, lows, closes, volumes = columns
bars = [
SimpleNamespace(
@@ -1052,8 +1293,9 @@ def _replay_and_signals(
)
for o, op, hi, lo, cl, vo in zip(date_ords, opens, highs, lows, closes, volumes)
]
return (
_replay_ticker(
candidates: list[dict] = []
if not signal_only:
candidates = _replay_ticker(
symbol,
bars,
config,
@@ -1061,8 +1303,10 @@ def _replay_and_signals(
benchmark_closes,
target_model,
cadence,
),
_signal_series(bars, benchmark_closes),
)
return (
candidates,
_signal_series(bars, benchmark_closes, symbol=symbol),
)
@@ -3789,6 +4033,12 @@ async def run_backtest(
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
tickers = list(result.scalars().all())
total = len(tickers)
rank_only_symbols = await _load_research_rank_only_symbols(db)
if rank_only_symbols:
logger.info(json.dumps({
"event": "backtest_rank_only_loaded",
"count": len(rank_only_symbols),
}))
candidates: list[dict] = []
# Signal IC remains a weekly, non-overlapping diagnostic regardless of the
@@ -3847,10 +4097,16 @@ async def run_backtest(
continue
if columns is not None:
futures.append(loop.run_in_executor(
pool, _replay_and_signals, ticker.symbol, columns, config, activation,
pool,
_replay_and_signals,
ticker.symbol,
columns,
config,
activation,
benchmark_closes,
target_model,
cadence,
ticker.symbol in rank_only_symbols,
))
for result in await asyncio.gather(*futures, return_exceptions=True):
if isinstance(result, Exception):
@@ -3870,10 +4126,15 @@ async def run_backtest(
columns = await _fetch_columns(db, ticker.symbol)
if columns is not None:
_merge(await asyncio.to_thread(
_replay_and_signals, ticker.symbol, columns, config, activation,
_replay_and_signals,
ticker.symbol,
columns,
config,
activation,
benchmark_closes,
target_model,
cadence,
ticker.symbol in rank_only_symbols,
))
except Exception:
logger.exception("Backtest replay failed for %s", ticker.symbol)
@@ -3916,73 +4177,75 @@ async def run_backtest(
portfolio_monitor_report: dict | None = None
holdout_report: dict | None = None
min_rr_sweep_report: dict | None = None
try:
qual_symbols = sorted({
c["symbol"]
for c in candidates
if c.get("qualified")
or any(_qualifies_strategy_variant(c, cfg) for cfg in STRATEGY_VARIANTS)
})
price_columns: dict[str, tuple] = {}
for sym in qual_symbols:
cols = await _fetch_columns(db, sym)
if cols is not None:
price_columns[sym] = cols
spy_closes: dict | None = None
if not _signal_eval_only():
try:
oldest = min((cols[0][0] for cols in price_columns.values()), default=None)
days_needed = None
if oldest is not None and not _offline_snapshot_mode():
days_needed = (date.today() - date.fromordinal(oldest)).days + 30
spy_closes = await _load_benchmark_closes_for_backtest(
db, days=days_needed, refresh=oldest is not None
)
except Exception:
logger.exception("Benchmark load for the portfolio sim failed")
qual_symbols = sorted({
c["symbol"]
for c in candidates
if c.get("qualified")
or any(_qualifies_strategy_variant(c, cfg) for cfg in STRATEGY_VARIANTS)
})
price_columns: dict[str, tuple] = {}
for sym in qual_symbols:
cols = await _fetch_columns(db, sym)
if cols is not None:
price_columns[sym] = cols
for policy in ("target", "hold"):
sim = _simulate_portfolio(
candidates, price_columns, spy_closes, policy, hold_horizon
)
if sim is not None:
sim_policies.append({"policy": policy, **sim})
strategy_variant_rows = _strategy_variant_sims(
candidates, price_columns, spy_closes, hold_horizon
)
exit_policy_rows = _exit_policy_sims(
candidates, price_columns, spy_closes, hold_horizon
)
live_exit_policy: dict | None = None
try:
from app.services.paper_trade_service import get_exit_policy
spy_closes: dict | None = None
try:
oldest = min((cols[0][0] for cols in price_columns.values()), default=None)
days_needed = None
if oldest is not None and not _offline_snapshot_mode():
days_needed = (date.today() - date.fromordinal(oldest)).days + 30
spy_closes = await _load_benchmark_closes_for_backtest(
db, days=days_needed, refresh=oldest is not None
)
except Exception:
logger.exception("Benchmark load for the portfolio sim failed")
live_exit_policy = await get_exit_policy(db)
except Exception:
logger.exception("Live exit policy load failed; monitor uses defaults")
portfolio_monitor_report = _portfolio_monitor(
candidates, price_columns, spy_closes, hold_horizon,
live_exit_policy=live_exit_policy,
cadence=cadence,
)
split = _holdout_split()
if split is not None:
holdout_report = _holdout_evaluation(
candidates, price_columns, spy_closes, hold_horizon, split,
for policy in ("target", "hold"):
sim = _simulate_portfolio(
candidates, price_columns, spy_closes, policy, hold_horizon
)
if sim is not None:
sim_policies.append({"policy": policy, **sim})
strategy_variant_rows = _strategy_variant_sims(
candidates, price_columns, spy_closes, hold_horizon
)
exit_policy_rows = _exit_policy_sims(
candidates, price_columns, spy_closes, hold_horizon
)
live_exit_policy: dict | None = None
try:
from app.services.paper_trade_service import get_exit_policy
live_exit_policy = await get_exit_policy(db)
except Exception:
logger.exception("Live exit policy load failed; monitor uses defaults")
portfolio_monitor_report = _portfolio_monitor(
candidates, price_columns, spy_closes, hold_horizon,
live_exit_policy=live_exit_policy,
cadence=cadence,
)
if _min_rr_sweep_enabled():
min_rr_sweep_report = _min_rr_sweep(
candidates, price_columns, spy_closes, activation, current_min_pct,
hold_horizon, live_exit_policy=live_exit_policy, cadence=cadence,
)
except Exception:
logger.exception("Portfolio simulation failed")
split = _holdout_split()
if split is not None:
holdout_report = _holdout_evaluation(
candidates, price_columns, spy_closes, hold_horizon, split,
live_exit_policy=live_exit_policy,
cadence=cadence,
)
if _min_rr_sweep_enabled():
min_rr_sweep_report = _min_rr_sweep(
candidates, price_columns, spy_closes, activation, current_min_pct,
hold_horizon, live_exit_policy=live_exit_policy, cadence=cadence,
)
except Exception:
logger.exception("Portfolio simulation failed")
report = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"tickers": total,
"rank_only_tickers": len(rank_only_symbols),
"candidates": len(candidates),
"qualified": len(qualified),
"params": {
@@ -3999,6 +4262,9 @@ async def run_backtest(
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
"production_reentry_policy": PRODUCTION_REENTRY_POLICY,
"liquid_breadth_top_n": _liquid_breadth_top_n() or None,
"liquid_min_price": _liquid_min_price() if _liquid_breadth_top_n() else None,
"signal_eval_only": _signal_eval_only(),
},
"activation": activation,
"overall_qualified": _bucket_stats(qualified),
+136
View File
@@ -0,0 +1,136 @@
"""TLS / corporate-proxy bootstrap for CLI scripts and the API.
Must run **before** httpx / alpaca / aiohttp open connections.
Resolution order for the CA bundle:
1. ``combined-ca-bundle.pem`` in the repo root (gitignored corporate bundle)
2. ``$HOME/combined-ca-bundle.pem`` (MacBook path used by existing tooling)
3. ``SSL_CERT_FILE`` / ``REQUESTS_CA_BUNDLE`` if already set and present
4. ``certifi.where()`` when the package is installed
5. System defaults (no patch)
Optional corporate proxy (Swisscom-style) when ``USE_CORP_PROXY=1``.
"""
from __future__ import annotations
import os
import ssl
from pathlib import Path
_BOOTSTRAPPED = False
def _candidate_ca_paths() -> list[Path]:
root = Path(__file__).resolve().parent.parent
home = Path.home()
env_paths = [
os.environ.get("SSL_CERT_FILE", ""),
os.environ.get("REQUESTS_CA_BUNDLE", ""),
os.environ.get("CURL_CA_BUNDLE", ""),
]
paths = [
root / "combined-ca-bundle.pem",
home / "combined-ca-bundle.pem",
*[Path(p) for p in env_paths if p],
]
try:
import certifi
paths.append(Path(certifi.where()))
except Exception:
pass
return paths
def resolve_ca_bundle() -> str | None:
for path in _candidate_ca_paths():
try:
if path.is_file() and path.stat().st_size > 0:
return str(path.resolve())
except OSError:
continue
return None
def apply_corp_proxy_if_requested() -> None:
if os.environ.get("USE_CORP_PROXY", "0") != "1":
return
proxy = os.environ.get("CORP_HTTP_PROXY", "http://aproxy.corproot.net:8080")
no_proxy = os.environ.get(
"CORP_NO_PROXY",
"corproot.net,sharedtcs.net,127.0.0.1,localhost,bix.swisscom.com,swisscom.com",
)
os.environ.setdefault("HTTP_PROXY", proxy)
os.environ.setdefault("HTTPS_PROXY", proxy)
os.environ.setdefault("NO_PROXY", no_proxy)
os.environ.setdefault("http_proxy", proxy)
os.environ.setdefault("https_proxy", proxy)
os.environ.setdefault("no_proxy", no_proxy)
def bootstrap_ssl(*, force: bool = False) -> str | None:
"""Install CA env vars + patch ``ssl.create_default_context``.
Returns the CA path used, or None if nothing was applied.
Safe to call multiple times.
"""
global _BOOTSTRAPPED
if _BOOTSTRAPPED and not force:
return os.environ.get("SSL_CERT_FILE") or None
apply_corp_proxy_if_requested()
cert_path = resolve_ca_bundle()
if not cert_path:
_BOOTSTRAPPED = True
return None
os.environ["SSL_CERT_FILE"] = cert_path
os.environ["REQUESTS_CA_BUNDLE"] = cert_path
os.environ["CURL_CA_BUNDLE"] = cert_path
original = ssl.create_default_context
def _patched(
purpose=ssl.Purpose.SERVER_AUTH, *, cafile=None, capath=None, cadata=None
):
ctx = original(purpose, cafile=cafile, capath=capath, cadata=cadata)
try:
ctx.load_verify_locations(cafile=cert_path)
except Exception:
pass
return ctx
ssl.create_default_context = _patched # type: ignore[assignment]
# aiohttp may cache SSL contexts at import time.
try:
import aiohttp.connector as aio_conn
for attr in ("_SSL_CONTEXT_VERIFIED", "_SSL_CONTEXT_UNVERIFIED"):
ctx = getattr(aio_conn, attr, None)
if ctx is not None:
try:
ctx.load_verify_locations(cafile=cert_path)
except Exception:
pass
except ImportError:
pass
_BOOTSTRAPPED = True
return cert_path
def ssl_status() -> dict:
"""Diagnostic blob for research scripts / MacBook troubleshooting."""
ca = resolve_ca_bundle()
return {
"ca_bundle": ca,
"ssl_cert_file_env": os.environ.get("SSL_CERT_FILE"),
"use_corp_proxy": os.environ.get("USE_CORP_PROXY", "0"),
"http_proxy": os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY"),
"candidates_exist": {
str(p): p.is_file() for p in _candidate_ca_paths()[:4]
},
}
+9 -3
View File
@@ -47,6 +47,7 @@ score, Structural S/R, the Gate Target Ladder, sentiment, fundamentals) is
| 10 | **Inverse-vol position sizing** | The apparent "win" was **mis-attributed**: the 20% notional cap bound on 95% of entries, so it measured concentration, not vol-sizing. Genuine inverse-vol cuts DD to 18.2% but costs ~58pp return at flat Sharpe | **Rejected** as edge; it's a risk-preference trade | `backtest-20260709-position-sizing*.json` |
| 11 | **FIP path-smoothness** as tie-breaker/filter | Non-monotonic within the qualified set; thinning the entry stream costs more compounding than the tilt returns | **Rejected as a filter** — but see §4, it's the strongest raw signal we've measured | — |
| 12 | **Fixed take-profit sweep** (R-multiples) | No interior optimum ever found — the best TP is "no TP" | **Rejected.** Momentum's edge lives in the right tail | `backtest_service.py:450` |
| 13 | **Sector-residual 12-1** (`mom_12_1_sector_resid` / sector demean) as replacement for market residual | Short-window IC/A/B looked knife-edge green; deep repaired + **liquid-1500** retest: weeks 83, mild +IC **0.027** / t 1.69, **below iron bar 0.03** (FAIL). Demean already weaker | **Rejected / closed.** Keep production market residual. Do not resurrect without a new pre-registered protocol | [sector-residual-momentum.md](sector-residual-momentum.md) · `sector-resid-deep-20260719-113319.json` · history-depth supersession note |
---
@@ -140,9 +141,9 @@ knobs.
| Lead | Why it's interesting | Blocker |
|---|---|---|
| **Near-close / MOC execution (ops)** | Recovers overnight momentum drift left on the table by a morning EU scan; evidence closed | Implement schedule + partial-bar scan path; one qualifying scan/day only |
| **`fip_id`** (information discreteness over the 12-1 window) | **Strongest cross-sectional signal measured on this universe** — IC 0.045, t = 2.91, correct sign; re-derived fingerprint matched Phase A | Doesn't improve *this* book. Revisit when the universe broadens — **after** execution path is decided |
| **Broader universe** (`nasdaq_all`) | Strengthens every week's cross-section and the IC t-stat | Grade under the fill mode you will trade |
| **Near-close / MOC execution (ops)** | Recovers overnight momentum drift left on the table by a morning EU scan; evidence closed | Schedule + fill_mode shipped; live paper validation ongoing |
| **`fip_id` / liquid breadth** | Fingerprint 0.045 / t 2.91; liquid unconditional **0.017 / t 1.85** (not green); mom-conditional **0.088 / t 4.58** | **Parked.** Orphan +0.0575 died (snapshot race). Breadth did not strengthen resid-mom t-stat. Optional reopen = pre-registered two-arm liquid-1500 book first. See [fip-breadth-ic.md](fip-breadth-ic.md) |
| **Broader universe** | Composition changes factor signs (fip tug-of-war); vol-tilt on breadth is only a **directional hypothesis** (auth. 0.048 / t 1.36) | Any prod broaden must re-validate 80/20 tilt; offline research only; research.sqlite requires completion manifest |
| **Forward paper-trade record** | The only true out-of-sample evidence the snapshot cannot give | Time; mark entries at actual near-close fill once ops ships |
| **Better target model for clear-air names** | The return is demonstrably there (#2 wins on raw CAGR in *both* train and test); it's the *flat* 3× ATR target that makes it too expensive in risk | Needs a per-name model, not a constant k×ATR |
@@ -169,6 +170,11 @@ knobs.
6. **Fill timing is part of the strategy.** Close-fill reports are not deployable
numbers for an overnight scanner. Grade promotion under the fill mode you will
actually trade.
7. **Incomplete research artifacts are not results.** The Phase B +0.0575 / t +5.12
liquid-fip row was orphaned within hours: it raced a partially built
`research.sqlite`. Extender now writes a completion manifest; breadth mode
refuses without a match. Same class of protection as calendar-truncation
asserts — do not re-mythologize numbers computed on half a universe.
---
+167
View File
@@ -0,0 +1,167 @@
# Earnings gap diagnostic + SUE / PEAD (Tier-1 alpha research)
**Status:** **CLOSED — SUE DEAD**.
**Branch:** `research/earnings-gap-and-sue`
**Production impact:** none. Local research only; no earnings filter or SUE integration is shipped.
---
## Pre-registration (locked before the final research run)
### Data
- Historical earnings announcements for the production universe, stored in the
real `earnings_events` table and deduplicated on symbol + announcement date.
- The originally requested 2016 start is amended, with user approval, to the
public source's announcement coverage start of 2020-01-22. Earlier EPS-period
history may scale later surprises but may never activate a live signal.
- Report coverage, pairing, duplicates/restatements, annual-rate sanity, and
announcement-session quality before either experiment.
- Point-in-time: an earnings surprise is usable only from announcement date +1
trading day. Same-day use is forbidden.
### Experiment 2a — earnings-gap risk (defense, report-only)
Run the production-config book on the approximately 505-name production
universe with close fills and 0.001 transaction cost per side. Join simulated
trades to earnings by symbol and date.
1. Among closed trades with realized net R ≤ -1.0, report the fraction with an
announcement strictly after entry and before exit, alongside the base rate
for all trades.
2. Compare entries within three trading sessions before an announcement with
all other entries: count, mean/median R, win rate, p05, and p95.
3. Compare stops within one trading session after an announcement with all
other stops and exits.
Verdict is always `INFORMATIONAL`. Report only: no filter arm, recommendation,
or implementation. The right tail must be shown alongside the left tail.
### Experiment 2b — SUE / PEAD (offense)
Signal `sue_latest`:
\[
\text{SUE} = \frac{\text{actual} - \text{estimate}}
{\sigma(\text{trailing 8 surprises})}
\]
Use at least four trailing surprises; if estimate history fails the registered
quality gate, use `(actual - estimate) / price` and name that fallback. Activate
at announcement date +1 trading day, carry for 63 trading days, then drop the
symbol from the cross-section.
Evaluate mean weekly Spearman IC on the existing non-overlapping-window harness.
Always report `sue_latest`, `mom_12_1`, and `mom_12_1_resid` on identical
week-symbol-forward-return cells, plus SUE inside the top momentum quintile.
### Mechanical verdict rule
- **PASS** only if unconditional `sue_latest` has mean IC ≥ +0.03,
`reliable: true` (at least 12 windows), and positive signs in both the pre-2021
and post-2021 eras.
- **FAIL** otherwise, with terminal verdict `SUE DEAD for this stack`.
- PASS stops at `SUE PASS→PENDING_HUMAN`; integration design remains a separate
human decision. FAIL is terminal and no variants are proposed.
---
## Results
### Data quality gate
Approved earnings window: 2020-01-22 to 2026-07-17. Source mode: dolthub_public_bulk_clone.
| check | result |
|---|---:|
| Prod symbols requested / tradable | 506 / 505 |
| Manifest complete + live counts match | True |
| Prod symbols with pre-2021 bars | 491 (97.2%) |
| SPY benchmark depth | 2649 rows, 2016-01-04 to 2026-07-17 |
| Snapshot depth gate | True |
| Bulk source windows / requests logged | 1/1 / 1 |
| Source repository / pinned commit | https://www.dolthub.com/repositories/post-no-preference/earnings @ 9n0et3hpj9j7vue8f3qsldon3qa5sdjj |
| Source license / upstream provider documented | CC-BY-SA-4.0 / False |
| Existing-source conflicts preserved | 940 rows / 1526 fields |
| Symbols with >=8 announcements | 498 (98.6%) |
| Symbols with >=8 paired announcements | 495 (98.0%) |
| Events with estimate + actual | 12311/12414 (99.2%) |
| Duplicate rows in keyed table | 0 |
| Duplicate / restated payload rows fetched | 0 / 940 |
| Mean announcements per active symbol-year | 4.08 (expected about 4) |
| Symbols far off (<2 or >6/year, incl. zero) | 1 |
| Recognised BMO/AMC/during | 92.8% (reliable=True) |
| Point-in-time policy | announce_date_plus_1_trading_day_for_all_events |
| SUE price fallback | not_used |
Deduplication: UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment
Far-off announcement-rate symbols: SPCX
### Experiment 2a - earnings-gap risk diagnostic
Verdict: **INFORMATIONAL**. Report-only; no filter arm or implementation.
Trade cohort is restricted to the approved earnings-coverage window 2020-01-22 to 2026-07-17; 0 simulated trades outside that window were excluded.
| cohort | count | fraction |
|---|---:|---:|
| Realized net R <= -1.0 | 266 | - |
| Losses with announcement strictly inside hold | 23 | 0.0865 |
| All trades with announcement strictly inside hold | 115 | 0.2003 |
| Entry cohort | count | mean R | median R | win rate | p05 R | p95 R |
|---|---:|---:|---:|---:|---:|---:|
| Within 3 sessions before earnings | 27 | 0.4837 | -1.0265 | 0.3333 | -1.1463 | 5.981 |
| All other entries | 547 | 0.2734 | -0.8316 | 0.3565 | -1.1228 | 4.5888 |
Tail deltas (pre minus other): p05=-0.0235, p95=1.3922.
Registered directional tail condition is not present.
| Exit cohort | count | mean R | median R | win rate | p05 R | p95 R |
|---|---:|---:|---:|---:|---:|---:|
| Stops within 1 session after earnings | 26 | -0.6434 | -0.9753 | 0.2308 | -2.4614 | 1.1142 |
| All other stops | 433 | -0.5035 | -1.0278 | 0.1963 | -1.1373 | 1.3591 |
| All other exits | 548 | 0.3273 | -0.8361 | 0.3613 | -1.0644 | 4.7224 |
### Experiment 2b - SUE / post-earnings drift
Mechanical verdict: **FAIL** - SUE DEAD for this stack
Identical cross-sections:
| signal | mean IC | t | windows | avg N | IC positive % | reliable |
|---|---:|---:|---:|---:|---:|---|
| sue_latest | 0.0148 | 1.27 | 56 | 450.9 | 51.8 | true |
| mom_12_1 | 0.0195 | 0.74 | 56 | 450.9 | 58.9 | true |
| mom_12_1_resid | 0.0262 | 1.07 | 56 | 450.9 | 55.4 | true |
Unconditional SUE grade row:
| signal | mean IC | t | windows | avg N | IC positive % | reliable |
|---|---:|---:|---:|---:|---:|---|
| sue_latest | 0.0151 | 1.29 | 56 | 451.4 | 51.8 | true |
Era stability:
| era | mean IC | t | windows | avg N | IC positive % | reliable |
|---|---:|---:|---:|---:|---:|---|
| pre-2021 | 0.0286 | 0.7 | 9 | 398.3 | 55.6 | false |
| post-2021 | 0.0172 | 1.34 | 48 | 461.9 | 64.6 | true |
Coverage: 501 symbols with live SUE; avg weekly N=453.1; scored non-overlap avg N=451.4.
Cross-section is not flagged thin at the registered <100-name read.
Momentum-conditional top-quintile SUE: mean IC=0.0213, t=1.3, windows=56, avg N=89.8.
## Artifacts
- `reports/earnings-2a-gap-20260720-dolthub-final.json` and companion Markdown
- `reports/earnings-2b-sue-20260720-dolthub-final.json` and companion Markdown
- `reports/earnings-backfill-status.json`
Production changes: **none**. No earnings filter or SUE integration was implemented.
## Final status: **Task 2 CLOSED (SUE DEAD)**
+216
View File
@@ -0,0 +1,216 @@
# Broad-universe fip_id IC research (Phase B)
**Status:** **Parked / closed for now.** Unconditional fip not green; mom-conditional lead logged; breadth-momentum thesis challenged. No book sim until reopen.
**Production impact:** none. Display card remains context-only. No deploy from this work.
**Artifacts:** research log + compact reports + env-gated harness hooks; tooling stays for a future reopen.
## Scope
- Research only — production universe, gate, scanner, schedule unchanged.
- Snapshot: `research.sqlite` (~4,650 tickers = prod + nasdaq_all extend).
- Liquid mask: top **1,500** by point-in-time 63d median $vol, price ≥ **$5**/week.
- **Completion manifest required:** extender writes `<snapshot>.manifest.json`; breadth runners refuse without a matching complete manifest (see §Race guard).
## Caveats
- Survivorship bias (todays constituents, history backfilled).
- IEX volume undercount → relative $vol rank only.
- Pool skew: Nasdaq-heavy; missing pure NYSE mid-caps.
- Do not mix multi-signal tables across universe baselines.
- **Do not cite orphaned 21:14 numbers** (see below).
---
## Fingerprint (505-name prod)
| | Expected | Observed |
|---|---:|---:|
| mean IC | 0.045 | **0.045** |
| t-stat | 2.9 | **2.91** |
| weeks / N / reliable | ≥12 / ~500 / true | 35 / 497.7 / true |
**Pass.** Formula + pipeline trustworthy.
Residual momentum on the same fingerprint (what the production book ranks on): **IC +0.055 / t +1.98**.
---
## The orphan (21:14) — root cause
| Source | fip IC (liquid ~1500) | t |
|---|---:|---:|
| Orphan run 21:14 (removed from tree; was `fip-breadth-20260718-211440-breadth.json`) | **+0.0575** | **+5.12** |
| Single-sourced recompute on complete snapshot (2026-07-19) | **0.0168** | **1.85** |
That is a **sign disagreement** on the same intended quantity. Method rule: the number you cannot reconcile is the number you cannot use.
### Verdict: orphaned — raced the snapshot build
**Not** “orphaned, unexplained.” The mechanism is derivable from the table itself:
1. **Code was not the difference.** Reconcile shows the old harness path and the new shared filter produce **identical** results on current data (0.0168 / 1.85). The implementation fork is closed.
2. **Data was the difference.** On todays complete snapshot the liquid mask **binds in 97.1% of weeks** at top-N = 1,500. Dense signals (e.g. `vol_6m`) post-mask at **exactly 1,500**. The orphaned reports `vol_6m` averaged **~1,475** cross-section — a masked run on complete data cannot do that. At 21:14 the eligible pool was smaller than 1,500 and the mask never bound.
3. **Timeline fits.** Extender fixes landed ~20:32 / 20:34; full fetch takes ~30 minutes; breadth run fired **21:14** against a partially built `research.sqlite`. Every number in that report was computed on an incomplete universe.
**Do not cite +0.0575 / t +5.12.** It survived less than six hours of contact with project discipline — that is the system working, not time wasted. The orphan JSON was **deleted from the tree** (still in Git history) so it cannot be re-imported as evidence.
**Kept artifacts**
| File | Role |
|---|---|
| `reports/fip-reconcile-20260719-000520.json` | Authoritative single-sourced ICs (compact; membership dumps stripped) |
| `reports/fip-breadth-20260718-211440-fingerprint.json` | Prod fingerprint pass |
### Race guard (same class as calendar truncation)
| Piece | Behavior |
|---|---|
| `extend_snapshot_universe.py` | Clears any prior manifest on start; on full completion writes `<output>.manifest.json` with `complete=true`, ticker / OHLCV / rank_only counts, `finished_at`. `--limit` smoke runs write `complete=false`. |
| `run_fip_breadth_research.py` / `run_fip_breadth_diagnostics.py` | **Refuse** breadth mode unless a matching complete manifest exists and live counts equal the recorded totals. |
Helper: `scripts/research_snapshot_manifest.py`.
---
## Authoritative unconditional liquid fip (post-reconciliation)
| metric | value |
|---|---:|
| mean_ic | **0.0168** |
| ic_t_stat | **1.85** |
| weeks | 35 |
| avg_cross_section (**post-mask IC sample**) | 1471.2 |
| avg_raw_pool | 3214.4 |
| avg_eligible_pre_mask | **2338.4** |
| mask_binds_pct | **97.1%** |
| reliable | true |
**Mask binds hard** on complete data (eligible ≫ 1500). Post-mask IC N for fip is ~1471 because not every liquid name has a valid 12-1 fip path — that is signal availability, not a non-binding mask. Contrast orphan `vol_6m` avg N ~1475 vs complete-data `vol_6m` avg N **1500**.
Harness `_signal_evaluation` vs manual IC through the same filter: **exact match** (0.0168 / 1.85).
**Iron rule unconditional:** **not green** (|IC| 0.017 < 0.03), correct mild-negative sign.
---
## Context table (orphaned 21:14 vs authoritative) — kill the myth numbers
The context table died with the orphan. **0.16 must not survive in the log.**
| signal (liquid ~1500) | orphaned (21:14) | authoritative (shared filter) | consequence |
|---|---:|---:|---|
| **vol_6m** | 0.16 / t **6.1** | **0.048 / t 1.36** | “High-vol tilt harmful on breadth” **downgrades from finding to directional hypothesis** — not significant |
| **raw mom** (`mom_12_1`) | +0.10 / t +4.6 | **+0.046 / t +1.91** | Below iron-rule bar on this pool |
| **resid mom** (`mom_12_1_resid`) | +0.04 / t +2.3 | **+0.029 / t +1.33** | Ditto, and weaker than raw |
### Breadth-momentum thesis — challenged
That last pair is the sobering one. Momentum on liquid breadth is **marginal**. The “more breadth strengthens the momentum t-stat” thesis that motivated Phase B is **empirically wrong on this pool**: same 35 weeks, triple the names, residual-mom t-stat **fell** versus the 505-name fingerprint (**0.055 / 1.98** → **0.029 / 1.33**). The clean momentum edge lives in the large-cap universe already traded.
Meanwhile the strongest reliable signal on liquid breadth is now **mom-conditional fip** (0.088 / 4.58) — but a fip tilt presupposes a breadth momentum book worth tilting, and that is no longer free.
---
## Compositional story (supported)
`fip_id = sign(PRET)×(%neg%pos)` pools:
- **Continuous winners** → want **negative** IC
- **Continuous bleeders** → want **positive** IC
| check | IC | t | read |
|---|---:|---:|---|
| Prod-universe subset inside liquid | **0.044** | **2.88** | Matches fingerprint → compositional, not regime change |
| Tier 1800 (senior) | **0.035** | **2.99** | Winner leg |
| Tier 8011500 (junior) | **+0.014** | +1.25 | More bleeder / junk weight |
| Lagged membership (prior-week $vol) | 0.010 | 0.93 | Same sign as same-week; not a +5σ leak artifact |
**Do not log “on Nasdaq, jumpy paths outperform.”** That would mythologize an orphaned +0.06.
---
## Platform-relevant test: momentum-conditional fip
Among liquid top-1500, keep **mom_12_1 ≥ P80** (~294 names/week):
| metric | value |
|---|---:|
| mean_ic | **0.0879** |
| ic_t_stat | **4.58** |
| ic_positive_pct | 22.9% |
| weeks | 35 |
| reliable | **true** |
Computed on the **same single-sourced path** as the authoritative 0.017. This is the papers claim and the only version a gate could consume.
| Decision | |
|---|---|
| Unconditional fip | **Closed** for production |
| Mom-conditional fip | **Alive as book-tilt candidate only** — and only after a baseline breadth book proves itself |
| Display card | Stays |
| Production change | **None** |
---
## Vol-tilt warning (softened)
| signal (liquid, single-sourced) | IC | t |
|---|---:|---:|
| vol_6m | 0.048 | **1.36** |
| mom_12_1 | +0.046 | +1.91 |
| mom_12_1_resid | +0.029 | +1.33 |
High-vol names **tend** to underperform on this pool relative to a clean S&P-like book — that is a **directional hypothesis**, not a finding. Production **80/20 high-vol tilt** was validated on S&P-like names. If the universe ever broadens in production, re-validate that tilt; do not treat the orphaned 0.16 / t 6.1 as evidence.
---
## What this means for the book experiment
A fip tilt presupposes a breadth momentum book worth tilting — **that is no longer free.**
**Caution against over-reacting the other way:** modest cross-sectional IC does not preclude a good book. The 505-name book turns resid-mom IC ~0.055 into Sharpe ~2 because the gate trades the **extreme tail**, not the linear sort. The breadth book might still work; it just has to **prove it** before the fip arm means anything. If the baseline cannot clearly beat the existing production books territory, fips future is a footnote regardless of 4.58.
### Parked next step (if reopened): pre-registered two-arm design
Not started — **design only**, pre-register before any sim:
| Arm | Definition |
|---|---|
| **A — baseline** | Top-quintile residual (or raw — pick one and lock) momentum book on liquid-1500; **no fip**; honest costs; next-open or near-close fills; production-like capacity / risk / stops |
| **B — +fip tilt** | Same book + mom-conditional fip tilt (among mom winners, prefer smoother paths / negative fip_id) |
| Grade on | Spec |
|---|---|
| Split | Entry-date train / validation (`BACKTEST_HOLDOUT_SPLIT` naming — not pristine holdout) |
| Metrics | Sharpe + Mertens/Lo SE, PSR, **DSR**; max DD; turnover; cost drag |
| Promote bar | Arm A must be in production-book territory first; Arm B must beat A on validation with DSR-aware multiple-testing honesty |
| Fail-closed | If A fails, fip is a footnote; do not shop tilts on a dead baseline |
---
## How to re-run (research branch only)
```powershell
# 1) Full extend writes completion manifest (required)
.\.venv\Scripts\python.exe scripts\extend_snapshot_universe.py `
--source backtest_snapshots\prod.sqlite `
--output backtest_snapshots\research.sqlite
# 2) Breadth / diagnostics refuse without matching manifest
.\.venv\Scripts\python.exe scripts\run_fip_breadth_diagnostics.py `
--research-snapshot backtest_snapshots\research.sqlite `
--prod-snapshot backtest_snapshots\prod.sqlite `
--workers 6 --allow-spawn
```
---
## Bottom line
1. Formal iron-rule screen: **not green** either before or after reconciliation.
2. **+0.0575 / +5.12 is orphaned: raced the snapshot build** — authoritative unconditional liquid fip is **0.017 / 1.9**; mask binds (~97%) on complete data.
3. Context-table myths die with the orphan: **vol 0.16 is not real**; authoritative vol is **0.048 / t 1.36** (directional only).
4. Compositional tug-of-war is the right story; jumpiness premium is not.
5. **Breadth does not strengthen residual-mom t-stat** on this pool (0.055/1.98 → 0.029/1.33).
6. **Mom-conditional 0.088 / 4.6 stands** on the single-sourced path → optional next step is a **pre-registered two-arm breadth book** (baseline first), not a gate wire-in.
7. Manifest guard is in place so the race cannot recur silently.
+206
View File
@@ -0,0 +1,206 @@
# History-depth extension (Tier-1 alpha research)
**Status:** **CLOSED.** Sector-residual deep test **FAIL** — Task 1 archived as rejected (see supersession).
**Branch:** `research/earnings-gap-and-sue`
**Superseded artifact (do not cite):** `reports/history-depth-20260719-103315.json`**UNMASKED, TWO-TIER SNAPSHOT**
**Authoritative sector grade:** `reports/sector-resid-deep-20260719-113319.json`
**Production impact:** none. **Do not retune any production knob on deep history.**
---
## Pre-registration (locked before rebuild)
### Motivation
All current conclusions rest on ~35 non-overlapping weekly windows in essentially
one post-2021 regime. Extending history toward max Alpaca daily-bar depth adds
the 2018 vol shock and full 2020 crash (where the feed allows).
### Protocol
1. **Empirical coverage first** — bars per calendar year per symbol; document
where the feed thins out. Do **not** assume a uniform start date.
2. **Rebuild the research snapshot completely** from prod source + max history
per symbol (`Adjustment.SPLIT`, ~200 req/min pacing via existing extender).
3. **Race guard (rule 6)** — refuse analysis until completion manifest is
`complete=true` and live counts match.
4. **Re-run full signal harness** (all existing signals incl. sector residual /
SUE if present) on the extended window.
5. **Report per signal:** mean IC, t, window count, and **era split**
(pre-/post-2021) — diagnostic only, **not a tuning input**.
6. **Log prominently:** survivorship bias grows with depth (todays constituents
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
**relative** signal comparisons and IC stability, not levels.
7. **Do not retune** production knobs. If a knobs confirmation looks
overturned on deep history → report only; human decides.
### Success / interpretation (not promotion of a new signal)
| outcome | meaning |
|---|---|
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
| Any production knob looks worse deep | report; no auto-retune |
---
## Data provenance
| check | result |
|---|---|
| Snapshot | MacBook `research.sqlite` |
| Manifest `complete` | **true** (finished 2026-07-19T08:19Z) |
| Live counts match | yes — 4655 tickers / 6,609,926 OHLCV / 4149 rank_only |
| `history_days` | 5000 |
| fetch_ok / fail | 4152 / 0 |
| Race guard | **pass** |
> **SURVIVORSHIP BIAS:** todays constituents backfilled historically. Absolute
> Sharpe/CAGR levels on deep history are optimistic. Use **relative** signal IC
> comparisons and era stability only — not levels.
**Coverage JSON was empty in the auto-written doc** (harness-only phase after
rebuild). Manifest is the race-guard source of truth for this run.
Earlier MacBook files `history-depth-20260719-093853``095156` are intermediate
/ incomplete passes — **do not cite**. Only **103315** is authoritative.
---
## Results (authoritative: 103315)
### Full-window signal IC (broad research universe, deep bars)
| signal | mean_ic | t | weeks | avg_N | notes |
|---|---:|---:|---:|---:|---|
| high_52w | **0.111** | **6.41** | 84 | 2280 | strong on deep breadth |
| mom_12_1 | **0.066** | **5.11** | 83 | 2278 | raw momentum strong |
| trend_200 | 0.055 | 4.30 | 85 | 2308 | |
| mom_6_1 | 0.049 | 4.91 | 88 | 2376 | |
| mom_3_1 | 0.036 | 3.58 | 90 | 2426 | |
| fip_id | **+0.027** | **3.25** | 83 | 2278 | **sign flip vs prod fingerprint** |
| mom_12_1_resid | 0.026 | 2.21 | 83 | 2278 | market residual still + but weaker than raw |
| reversal_1m | ~0 | 0.31 | 91 | 2443 | dead |
| vol_6m | **0.123** | **6.34** | 88 | 2376 | low-vol anomaly strong |
| mom_12_1_sector_resid | 0.058 | 2.34 | **35** | **498** | **not deep-sample — see caveats** |
| mom_12_1_sector_demeaned | 0.034 | 1.32 | **35** | **497** | same short fingerprint |
### Era split (diagnostic only — not a tuning input)
| signal | pre-2021 IC / t / w / N | post-2021 IC / t / w / N |
|---|---|---|
| mom_12_1 | +0.041 / 2.94 / 36 / 1425 | +0.079 / 3.64 / 48 / 2913 |
| mom_12_1_resid | +0.023 / 1.68 / 36 / 1425 | +0.027 / 1.37 / 48 / 2913 |
| fip_id | +0.012 / 1.35 / 36 / 1425 | +0.037 / 2.91 / 48 / 2913 |
| vol_6m | 0.056 / 2.16 / 40 / 1461 | 0.162 / 4.94 / 48 / 3123 |
| high_52w | +0.049 / 2.0 / 36 / 1422 | +0.138 / 4.34 / 48 / 2915 |
| **sector_resid** | **absent** | 0.058 / 2.34 / 35 / 498 (short only) |
| **sector_demeaned** | **absent** | 0.034 / 1.32 / 35 / 497 (short only) |
---
## Critical caveats (must read)
### 1. Sector residual did **not** get a deep-history stress test
`mom_12_1_sector_resid` / `_demeaned` still show **exactly** the Task1 short-window
fingerprint: **35 weeks, N≈498, IC 0.0578, t 2.34**.
On the same run, raw `mom_12_1` has **83 weeks, N≈2278**. So depth worked for
price-only signals, but sector residual is still limited to the **~505 labeled
prod names × short factor calendar** (sector map only covers prod; and/or sector
ETF / two-factor path did not extend usable residual weeks).
**Pre-registered rule:** “Sector residual collapses pre-2021 → PARK Task 1
wire-in.” Pre-2021 sector residual is **absent** from the era table. That is a
**PARK**, not a confirmation of the short-window PROMOTE.
Do **not** claim “sector residual beats market residual on deep history” from
this table — the two rows are **not the same cross-section or window count**.
### 2. `fip_id` sign flips vs production fingerprint
| sample | fip mean IC | t |
|---|---:|---:|
| Prod 505, ~5y (fingerprint) | **0.045** | 2.91 |
| Research breadth, deep (this run) | **+0.027** | +3.25 |
This does **not** authorize resurrecting unconditional FIP as a book filter. It
confirms earlier PhaseB caution: FIP edge is **universe- and sample-dependent**.
Production display card can stay context-only. Nested lookbacks still not OOS.
### 3. Market residual vs raw momentum on deep breadth
On deep broad IC, **raw 121 (0.066 / t 5.1) ≫ market residual (0.026 / t 2.2)**.
That does **not** by itself overturn production residual ranking (book A/B was
on 505 + GTL gate, not pure factor IC), but it is a yellow flag for “residual is
always the better rank key” stories on broad history. **No auto-retune.**
### 4. Low-vol anomaly is the cleanest deep-history result
`vol_6m` IC 0.12 / t 6.3 full; stronger post-2021. Consistent sign across eras.
Production already blends **high**-vol (not low-vol) into the 80/20 rank — this
report does not change that without a separate A/B. Flag for human awareness only.
---
## Verdicts (vs pre-registration)
| question | verdict |
|---|---|
| Task 1 sector residual wire-in | **PARK** — no pre-2021 sector residual; deep-sample IC not established; short-window PROMOTE stays “human design only,” **not strengthened** by this run |
| Sector demean | still **DEAD** for promotion (t 1.32, short only) |
| SUE | **not re-scored here** (no `sue_latest` in harness table) — leave Task 2 **PARK** until full earnings backfill |
| fip unconditional book filter | remains **rejected / parked** despite sign flip on broad deep sample |
| Production residual / 80/20 / trail knobs | **no retune** from this report |
| Overall Task 3 | **COMPLETE as diagnostic** — payload is relative IC + caveats above |
---
## What a human must decide next
1. **Sector residual — decided:** CLOSED / REJECTED (archive complete). No wire-in.
2. **Do not** retune residual vs raw, FIP, or vol blend from deep IC tables
without a pre-registered book A/B on the intended universe.
3. Optional (separate threads only): finish earnings backfill and re-run SUE;
snapshot per-symbol depth guard as tooling.
---
## Artifacts
| file | role |
|---|---|
| `reports/history-depth-20260719-103315.json` | Superseded unmasked/two-tier IC dump (do not cite for sector residual) |
| `reports/sector-resid-deep-20260719-113319.json` | Authoritative sector-resid deep grade |
| `reports/prod-book-universe-horizon-20260719-140737.json` | 505 vs liquid × horizon book matrix |
Intermediate history-depth partials (093853095156) and SANITY-FAIL noise were
removed in branch cleanup.
---
## Supersession notice (2026-07-19 sector-resid deep test)
The table and interpretation from **`history-depth-20260719-103315`** are **UNMASKED, TWO-TIER SNAPSHOT — superseded, directional only, do not cite**. Prod-universe names (and sector residual coverage) were left shallow while breadth names were deepened; sector residual weeks=35 was a data gap.
### Sector-residual deep test outcome: **FAIL** (archived)
**Task 1 CLOSED / REJECTED** — sector residual dead on deep evidence. Archived in
the research log rejected table (#13). Do not resurrect without a new
pre-registered protocol.
| check | result |
|---|---|
| weeks | **83** (data fix worked) |
| mean IC | **0.0268** (below 0.03 bar) → FAIL |
| t vs resid same CS | 1.69 ≥ 1.30 pass |
| era signs | both + pass |
- Artifact: `reports/sector-resid-deep-20260719-113319.json`
- Summary write-up: [sector-residual-momentum.md](sector-residual-momentum.md)
**Future snapshot rebuilds must verify per-symbol depth** (earliest-bar
uniformity across the intended universe) — guard is a to-do, not part of this
order.
+127
View File
@@ -0,0 +1,127 @@
# Production book × universe × horizon matrix
**Status:** PRE-REGISTERED — prepare / MacBook run; no production changes.
**Branch:** `research/earnings-gap-and-sue`
**Runner:** `scripts/run_prod_book_universe_matrix.py`
---
## Question
How does the **live production book** (unchanged knobs) behave when we only vary:
1. **History length** used for entries (≈4y vs since 2016-07)
2. **Tradable universe** (prod ~505 vs 505 + PIT liquid Nasdaq/breadth)
No strategy modifications: same residual gate, 80/20 high-vol rank, GTL entry
machinery, 3× ATR trail, 30d max hold, gate-reset re-entry, `fill_mode=close`,
cost 10 bps/side, max 10, 1% risk.
---
## Pre-registered arms (locked)
| id | label | Entry start | Tradable universe |
|---|---|---|---|
| **A** | prod_4y_505 | **2022-07-01** | Prod ~505 only |
| **B** | prod_4y_505_liquid | **2022-07-01** | Prod liquid top-1500 |
| **C** | prod_2016_505 | **2016-07-01** | Prod ~505 only |
| **D** | prod_2016_505_liquid | **2016-07-01** | Prod liquid top-1500 |
- **End:** last available bar in snapshot (no artificial end).
- **4y start** chosen to align with recent PhaseA / book baselines (~mid2022 → mid2026).
- **2016-07-01** = first full month after typical Alpaca floor (~2016-01); residual 121 needs ~1y bars so first residual ranks appear mid2017 where feed allows.
### Universe definitions
| set | definition |
|---|---|
| **Prod ~505** | Symbols **not** in `research_rank_only` on the research snapshot (the original prod-universe copy). |
| **Liquid top-1500** | Point-in-time: among names with as-of close ≥ **$5** and valid 63d median $vol, keep top **1500** by that $vol. Same definition as breadth IC research. |
| **Prod liquid** | A name may enter the book on date *t* if it is prod **or** in the liquid top-1500 at *t*. |
Cross-sectional residual / vol / 80/20 ranks are **recomputed inside each arms
eligible candidate set** that period (so breadth arms are not ranked against
non-eligible thin names).
### Explicit non-goals
- No sector residual, SUE, FIP filter, gap-cap, take-profit, vol-target, corr-cap
- No retune of trail / cutoff / min_rr
- Survivorship: report levels with the standard caveat; **compare arms relatively**
### Reporting (required table)
Per arm: Sharpe, Sharpe SE (Mertens), CAGR %, max DD %, total return %, trades,
win rate if available, start/end, n qualified longs. One markdown table + JSON.
**No promotion rule** — descriptive matrix only. Human decides whether breadth
or depth changes the risk story.
---
## Snapshot requirements
- Prefer MacBook **deep** `research.sqlite` after sector-resid deepen (prod names
from ~2016, breadth deep, completion manifest `complete=true`).
- Race-guard before run.
- Sector map / sector ETFs optional (not used for ranking).
---
## Results
Generated: `2026-07-19T14:07:37` · artifact
`reports/prod-book-universe-horizon-20260719-140737.json`
Snapshot: MacBook deep `research.sqlite` (506 prod + breadth prices; 2.39M raw
GTL candidates). Strategy knobs = live production (residual 80, 80/20 high-vol
rank, ATR trail 3×, hold 30, gate-reset, `fill_mode=close`).
> Survivorship: today's constituents backfilled. **Compare arms relatively.**
> Absolute deep CAGR/Sharpe are not deployable forecasts.
| arm | universe | entries from | Sharpe | SE | CAGR % | max DD % | total ret % | trades | win % | vs SPY |
|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|
| **A** | 505 only | 2022-07-01 | **1.32** | 0.49 | **31.8** | **18.9** | +205 | 374 | 35.6 | +96.5 |
| **B** | 505 + liquid 1500 | 2022-07-01 | 0.14 | 0.50 | 1.8 | 55.3 | 7 | 706 | 29.3 | +95.0 |
| **C** | 505 only | 2016-07-01 | **0.88** | 0.31 | **16.7** | **24.4** | +369 | 763 | 36.7 | +257 |
| **D** | 505 + liquid 1500 | 2016-07-01 | 0.06 | 0.32 | 7.0 | 73.9 | 52 | 1567 | 28.0 | +254 |
Qualified longs: A 1448 · B 6587 · C 2450 · D 11551.
### Read (relative only)
1. **Same strategy, broader liquid universe kills the book** (A→B and C→D).
Sharpe collapses; DD roughly triples; win rate drops ~68pp; trade count
~doubles. This matches earlier breadth IC work: the production residual +
high-vol package is a **large-cap / prod-universe** edge, not a
“more names = better” edge.
2. **Longer history on 505 stays positive but softer** (A→C). Sharpe 1.32 → 0.88,
CAGR 32% → 17%, DD 19% → 24%. Still well above the liquid-breadth arms.
Levels are optimistic (survivorship); the useful message is “edge does not
vanish when 2018/2020 are included,” not “expect 17% CAGR forever.”
3. **Arm A vs older PhaseA / short-window controls** (~Sharpe 1.72.1): this
matrix re-ranked on deep research.sqlite with a fixed entry start; numbers
need not match prior reports row-for-row. Use **this table for AD
comparisons**, not for rewriting the production baseline number.
4. **No production change implied.** Keep the live ~505 universe. Do not broaden
the tradable set to liquid Nasdaq under current knobs without a new
pre-registered design (and almost certainly a different rank/tilt package).
## Verdict
**Descriptive matrix complete.**
| question | answer from this matrix |
|---|---|
| Prod book @ ~4y / 505 | Positive (arm A) |
| Same + liquid Nasdaq | **No** — large degradation (arm B) |
| Prod book since 2016 / 505 | Still positive, milder (arm C) |
| Same + liquid Nasdaq deep | **No** — worst arm (arm D) |
**PENDING_HUMAN** only for whether to log “universe broaden under current knobs”
as rejected in the main research index. Strategy knobs unchanged either way.
+235
View File
@@ -0,0 +1,235 @@
# Sector-residual momentum (Tier-1 alpha research)
**Status:** **CLOSED / REJECTED** — do not resurrect without a new pre-registered protocol.
**Branch:** `research/earnings-gap-and-sue` (final grade) · earlier short-window work on `research/sector-residual-momentum`
**Production impact:** none. Market residual 12-1 remains the production momentum leg.
**Authoritative deep grade:** `reports/sector-resid-deep-20260719-113319.json` (**FAIL**)
**Short-window A/B (superseded for promotion):** `reports/sector-residual-20260719-083356.json` — knife-edge only; not decisive after deep masked retest.
### Closure (2026-07-19)
Pre-registered deep test on repaired snapshot + liquid-1500 mask:
| check | result |
|---|---|
| weeks extended (≫ 35) | pass (83) |
| sign +, reliable, eras both + | pass |
| t ≥ `mom_12_1_resid` same CS | pass (1.69 ≥ 1.30) |
| \|mean IC\| ≥ 0.03 | **fail (0.0268)** |
**Verdict:** Task 1 CLOSED — sector residual dead on deep evidence.
`mom_12_1_sector_demeaned` remains DEAD for promotion. No further sector-residual variants from this thread.
---
## Pre-registration (locked before first research run)
### Hypothesis
Residualizing 121 momentum against the sector, not only the market, reduces
factor volatility at similar return (Blitz / Huij / Martens-style) → higher
Sharpe on the production book when the residual replaces market-only residual
as the momentum leg.
### Signals (candidates)
| signal | construction |
|---|---|
| `mom_12_1_sector_resid` | Two-factor residual vs SPY + tickers sector ETF. Same window as `mom_12_1_resid`: ≥100 daily obs, 252-bar lookback, 21-bar skip; two-factor OLS betas **without intercept**; cumulate residual returns over the formation window. |
| `mom_12_1_sector_demeaned` | Plain `mom_12_1` minus the **cross-sectional** mean of `mom_12_1` within the same GICS sector that week (≥2 names in sector). No regression. |
### Baselines (same run, same cross-sections — iron rule)
Always report side-by-side with:
- `mom_12_1`
- `mom_12_1_resid`
Computed on the **identical** weekly non-overlapping cross-sections in this run.
Never compare against IC numbers from another report.
### Iron rule (IC harness)
Source of truth: `_signal_evaluation` in `app/services/backtest_service.py`.
- Mean weekly Spearman IC on **non-overlapping** weekly windows
- Bar: \|mean IC\| ≥ ~0.03, **consistent positive sign**, `reliable: true` (≥ 12 windows)
### Promotion to portfolio A/B (candidate → book)
A candidate promotes to A/B **only if**:
1. It clears the iron-rule bar **and**
2. Its IC **t-stat ≥** that of `mom_12_1_resid` on the same cross-sections.
### Portfolio A/B grading (if and only if IC promotion fires)
- Swap candidate in as the **momentum leg** of the production 80/20 momentum/vol
rank **and** as the gate-percentile signal.
- `fill_mode=close`, `COST_PER_SIDE = 0.001`, full config otherwise unchanged.
- Validation window = entries ≥ **2024-07-01** (call it **validation**, not
holdout — contaminated by prior experiments).
- Pre-registered promotion bar:
- validation Sharpe ≥ control 0.5·SE
- full-period Sharpe and max-DD **not worse** than control
- Report Lo / Mertens-adjusted SEs.
### Optional sector-cap sub-experiment
Only if labels are in **and** A/B ran: max **3** positions per sector in the
10-slot book. Same A/B grading. **Tail-trim presumption of guilt** (rule 4):
report entry counts and both tails of the R distribution. Rising win rate with
falling Sharpe/CAGR = red flag → do not promote.
**This run:** sector-cap arm **not executed** (optional; A/B unconstrained book
only). Can be a human-approved follow-up.
### Verdict labels
| label | meaning |
|---|---|
| **PROMOTE** | Clears pre-registered bar; human decides next (wire design separate) |
| **PARK** | Inconclusive / weak; keep machinery, no book change |
| **DEAD** | Failed iron rule or worse than residual baseline with clear sign |
### Explicit non-goals
- No production deploy from this doc
- Do not resurrect: take-profit exits, EV gate, regime entry-blocking,
inverse-vol sizing, gap-caps, unconditional FIP filter
---
## Data provenance
### Snapshot race guard
| check | result |
|---|---|
| Snapshot path | `backtest_snapshots/prod.sqlite` |
| Manifest | none (expected for prod snapshot); bar-count sanity applied |
| Tickers / OHLCV | **506** / **629,263** |
| Bars min / avg / max | 14 / 1246.1 / 1261 |
| OHLCV range | 2021-06-24 → 2026-07-02 |
| Partial-build red flags | none (avg bars healthy) |
Integrity fingerprint on same run: `fip_id` mean IC **0.045** / t **2.91**
(35 weeks, N≈498) — matches the established prod fingerprint.
### Sector labels
| source | count |
|---|---:|
| Public S&P 500 GICS CSV | 496 newly filled |
| FMP profile requests | 10 (all missing after CSV) |
| Mapped / universe | **505 / 506 (99.8%)** |
| With mappable ETF | 505 |
| Still missing | **RHM** only |
Persist path: `data/research/ticker_sector_map.json`.
FMP aliases (`Technology`, `Consumer Defensive`, `Financial Services`) map to
SPDRs via the alias table in `app/services/sector_map.py`.
### Sector ETFs in `benchmark_prices` (auxiliary only — not tradable)
| symbol | bars | min date | max date |
|---|---:|---|---|
| SPY | 1516 | 2020-07-06 | 2026-07-17 |
| XLB…XLY (11) | 1512 each | 2020-07-10 | 2026-07-17 |
Fetched via Alpaca `Adjustment.SPLIT` into **`benchmark_prices`** (same table as
SPY) so they never enter the ticker universe or candidate replay.
---
## Results
Generated: `2026-07-19T08:33:56`
### IC harness (identical cross-sections, production 506-name universe)
| signal | mean_ic | ic_t_stat | weeks | avg_N | reliable | ic+_pct | quintile spread |
|---|---:|---:|---:|---:|---|---:|---:|
| **mom_12_1_sector_resid** | **0.0578** | **2.34** | 35 | 497.7 | true | 65.7 | 0.0245 |
| mom_12_1_resid | 0.0552 | 1.98 | 35 | 497.7 | true | 60.0 | 0.0207 |
| mom_12_1 | 0.0531 | 1.61 | 35 | 497.7 | true | 65.7 | 0.0206 |
| mom_12_1_sector_demeaned | 0.0340 | 1.32 | 35 | 496.7 | true | 62.9 | 0.0154 |
### IC promotion grades
| candidate | iron rule | t ≥ resid | promote_to_ab |
|---|---|---|---|
| `mom_12_1_sector_resid` | pass (IC 0.058, +sign, reliable) | **yes** (2.34 ≥ 1.98) | **yes** |
| `mom_12_1_sector_demeaned` | pass (IC 0.034, +sign, reliable) | **no** (1.32 < 1.98) | **no** |
### Portfolio A/B — `mom_12_1_sector_resid` as residual leg
Config: production 80/20 residual/high-vol rank + gate percentile, `fill_mode=close`,
cost 10 bps/side, ATR trail / gate-reset re-entry as live. Validation split
2024-07-01.
| window | arm | Sharpe | Sharpe SE (Mertens) | CAGR % | max DD % | trades | n_days |
|---|---|---:|---:|---:|---:|---:|---:|
| train | control (resid) | 1.30 | 0.685 | 29.2 | 21.4 | 176 | 525 |
| train | treatment (sector resid) | **1.57** | 0.677 | **35.5** | **19.8** | 176 | 530 |
| validation | control | **2.92** | 0.709 | **76.3** | **11.7** | 150 | 501 |
| validation | treatment | 2.57 | 0.701 | 66.3 | 14.8 | 163 | 501 |
| full | control | 2.09 | 0.497 | 51.6 | 21.4 | 322 | 1000 |
| full | treatment | 2.09 | 0.491 | 51.0 | **19.8** | 337 | 1005 |
**Pre-registered A/B checks**
| check | result |
|---|---|
| val Sharpe ≥ control 0.5·SE | **pass** (2.57 ≥ 2.92 0.5×0.701 = 2.5695) — **knife-edge** |
| full Sharpe not worse | **pass** (2.09 = 2.09) |
| full max DD not worse | **pass** (19.8 < 21.4) |
Qualified long candidates: control 1086 vs treatment 1210 (sector residual
gates a slightly larger set).
---
## Verdict (final — archived)
| signal | verdict | note |
|---|---|---|
| **`mom_12_1_sector_resid`** | **CLOSED / REJECTED** | Deep masked IC 0.0268 &lt; 0.03 bar (`sector-resid-deep-20260719-113319`). Short-window PROMOTE superseded. |
| **`mom_12_1_sector_demeaned`** | **DEAD** | Never cleared t vs market residual; stays dead. |
Short-window evidence below is **historical only** (pre-deep retest). Do not use it
to reopen promotion.
### Read carefully (archived context)
1. Short-window IC (0.058 / t 2.34 vs resid 0.055 / t 1.98 on 35 weeks) and knife-edge
A/B looked openable — that was the data gap era (shallow prod bars).
2. Deep repaired + liquid-1500 retest closed the case: weeks 83, mild +IC, **below bar**.
3. Production keeps **market** residual 12-1. Research harness may still *emit*
sector residual for diagnostics; it is not a promotion candidate.
4. **Do not resurrect** without a new pre-registered protocol and new data.
---
## What a human must decide next
**Nothing on Task 1** — archived. Optional: leave research machinery in tree
(harmless) or delete later as cleanup; not a strategy decision.
---
## Implementation notes
Research runners and sector-residual harness hooks were **removed after close**
(2026-07-19 cleanup). Evidence remains in the report artifacts below. Do not
re-add without a new pre-registered protocol.
---
## Artifacts
| file | role |
|---|---|
| `reports/sector-resid-deep-20260719-113319.json` | **Authoritative deep FAIL** |
| `reports/sector-residual-20260719-083356.json` | Short-window IC/A/B (superseded for promotion) |
+20
View File
@@ -41,3 +41,23 @@ rejected stop-adjustment path, and add no decision evidence beyond the final
daily matrix and narrative. Their matching one-off runners were removed too.
All remain recoverable from Git history. Rebuildable candidate pickle caches
are intentionally ignored and must not be committed.
### Phase B fip breadth IC (2026-07-18/19) — compact evidence
Canonical artifacts:
- `fip-reconcile-20260719-000520.json` — single-sourced authoritative ICs
(unconditional liquid fip, tiers, prod-subset, mom-conditional, context
signals). Membership symbol dumps stripped after the decision; narrative in
[`docs/research/fip-breadth-ic.md`](../docs/research/fip-breadth-ic.md).
- `fip-breadth-20260718-211440-fingerprint.json` — prod-snapshot fingerprint
pass (fip IC 0.045 / t 2.91).
Removed as superseded / dangerous intermediate noise (recoverable from Git):
- `fip-breadth-20260718-211440-breadth.json` (+ wrapper) — **orphaned** +0.0575
/ t +5.12 from racing a partial `research.sqlite`. Kept out of the tree so it
cannot be re-mythologized.
- `fip-breadth-20260718-194828*.json` — fingerprint-only partial run.
- `fip-breadth-diagnostics-20260718-213705.json` and `…-213908.json` — dual-path
diagnostics superseded by the single-sourced reconcile.
@@ -0,0 +1,348 @@
{
"generated_at": "2026-07-20T07:02:10.934892+00:00",
"snapshot": "C:\\Workspace\\signal-platform\\backtest_snapshots\\research.sqlite",
"snapshot_depth": {
"manifest": {
"schema_version": 1,
"snapshot": "research.sqlite",
"snapshot_resolved": "C:\\Workspace\\signal-platform\\backtest_snapshots\\research.sqlite",
"complete": true,
"finished_at": "2026-07-19T14:22:15.706192+00:00",
"ticker_count": 4650,
"ohlcv_row_count": 5081073,
"rank_only_count": 4144,
"sources": {
"pool": "source_snapshot"
},
"history_days": 5000,
"min_bars": 1262,
"fetch_ok": 505,
"fetch_fail": 1,
"limit": null,
"extra": {
"prod_symbols_at_start": 506,
"pool_size": 506,
"to_fetch": 506,
"source_symbols_only": true,
"benchmark_spy_rows": 2649
},
"live_counts": {
"ticker_count": 4650,
"ohlcv_row_count": 5081073,
"rank_only_count": 4144
}
},
"requested_symbols": 506,
"tradable_symbols": 505,
"missing_symbols": [],
"zero_bar_symbols": [
"RHM"
],
"bar_count": {
"min": 24,
"median": 2649,
"max": 2649
},
"symbols_with_pre2021_bars": 491,
"symbols_with_pre2021_bars_pct": 97.2,
"shallow_symbols_lt_1000_bars": [
{
"symbol": "SPCX",
"first_bar": "2026-06-12",
"last_bar": "2026-07-17",
"bars": 24
},
{
"symbol": "Q",
"first_bar": "2025-11-03",
"last_bar": "2026-07-17",
"bars": 176
},
{
"symbol": "PSKY",
"first_bar": "2025-08-07",
"last_bar": "2026-07-17",
"bars": 237
},
{
"symbol": "SNDK",
"first_bar": "2025-02-13",
"last_bar": "2026-07-17",
"bars": 357
},
{
"symbol": "GEV",
"first_bar": "2024-04-02",
"last_bar": "2026-07-17",
"bars": 575
},
{
"symbol": "SOLV",
"first_bar": "2024-04-01",
"last_bar": "2026-07-17",
"bars": 576
},
{
"symbol": "VLTO",
"first_bar": "2023-10-02",
"last_bar": "2026-07-17",
"bars": 700
},
{
"symbol": "KVUE",
"first_bar": "2023-05-04",
"last_bar": "2026-07-17",
"bars": 803
},
{
"symbol": "GEHC",
"first_bar": "2022-12-15",
"last_bar": "2026-07-17",
"bars": 898
}
],
"price_window": {
"min": "2016-01-04",
"max": "2026-07-17"
},
"benchmark_spy": {
"rows": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"gate_threshold": {
"min_tradable_symbols": 505,
"max_missing_or_zero_bar": 1,
"min_symbols_with_pre2021_bars_pct": 80.0,
"benchmark_min_rows": 1000,
"benchmark_must_begin_pre2021": true
},
"gate_pass": true
},
"data_quality": {
"window": {
"from": "2020-01-22",
"to": "2026-07-17"
},
"prod_symbols": 505,
"events": 12414,
"symbols_with_any_event": 504,
"symbols_with_ge8_announcements": 498,
"symbols_with_ge8_announcements_pct": 98.6,
"symbols_with_ge8_paired_announcements": 495,
"symbols_with_ge8_paired_announcements_pct": 98.0,
"events_with_actual_and_estimate": 12311,
"events_with_actual_and_estimate_pct": 99.2,
"duplicate_rows_in_table": 0,
"duplicate_rows_fetched": 0,
"restated_rows_fetched": 940,
"dedupe_policy": "UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment",
"events_per_symbol_year": {
"mean_active_span_rate": 4.08,
"expected": "approximately 4",
"far_off_rule": "active-span rate <2 or >6, plus zero-event symbols",
"far_off_count": 1,
"far_off_symbols": [
{
"symbol": "SPCX",
"events": 0,
"events_per_year": 0.0
}
]
},
"announcement_session": {
"recognised_bmo_amc_or_during": 11520,
"recognised_pct": 92.8,
"reliable": true,
"assessment": "usable"
},
"point_in_time_policy": "announce_date_plus_1_trading_day_for_all_events",
"sue_scaling": {
"primary": "eps_surprise_over_stdev_of_prior_8_surprises_min_4",
"fallback_trigger": "paired event coverage <50% or symbols with >=8 paired events <50%",
"fallback_needed": false,
"fallback_name": "not_used"
},
"backfill": {
"mode": "dolthub_public_bulk_clone",
"window": {
"from": "2020-01-22",
"to": "2026-07-17"
},
"coverage_amendment": {
"approved_by_user": true,
"reason": "FMP free tier blocks historical bulk earnings",
"original_start": "2016-01-04",
"amended_announcement_start": "2020-01-22"
},
"source": {
"repository": "https://www.dolthub.com/repositories/post-no-preference/earnings",
"commit": "9n0et3hpj9j7vue8f3qsldon3qa5sdjj",
"license": "CC-BY-SA-4.0",
"upstream_provider_documented": false
},
"bulk_windows_total": 1,
"bulk_windows_done": 1,
"bulk_requests_logged_total": 1,
"bulk_exports": 2,
"calendar": {
"raw_rows": 117482,
"universe_rows_in_window": 12342,
"deduped_rows_in_window": 12342,
"duplicate_rows": 0,
"restated_rows": 0
},
"eps_history": {
"raw_rows": 165050,
"universe_rows": 18515,
"deduped_rows": 18515,
"duplicate_rows": 0,
"restated_rows": 0,
"complete_actual_and_estimate": 18304
},
"pairing": {
"method": "minimum-cost monotonic alignment per symbol",
"allowed_announce_minus_period_end_days": [
-14,
90
],
"matched_calendar_events": 12271,
"unmatched_calendar_events": 71,
"unmatched_periods_in_pairing_window": 538,
"announce_minus_period_end_days": {
"min": -10,
"median": 30,
"max": 89
},
"pre_2020_eps_history_use": "trailing_surprise_stdev_only; never treated as an announcement or live signal event"
},
"duplicate_rows_logged_total": 0,
"restated_rows_logged_total": 940,
"conflicting_existing_rows": 940,
"conflicting_existing_fields": 1526,
"preserved_existing_fields": 2945,
"existing_enrichment_events_not_in_dolthub_calendar": 72,
"dedupe_policy": "UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment",
"events_in_window": 12414,
"events_with_actual_and_estimate": 12311,
"symbols_done": 506,
"symbols_universe": 506,
"symbols_with_dolthub_calendar": 504,
"symbols_without_dolthub_calendar": [
"RHM",
"SPCX"
],
"announce_date_range": {
"min": "2020-01-22",
"max": "2026-07-17"
},
"complete": true
}
},
"production_impact": "none",
"experiment": "2a",
"result": {
"verdict": "INFORMATIONAL",
"costs": {
"per_side": 0.001,
"r_is_net_of_round_trip_costs": true
},
"closed_trades": 574,
"q1_loss_concentration": {
"loss_definition": "realized_net_R <= -1.0",
"holding_period_definition": "announcement strictly after entry and before exit",
"losses_count": 266,
"losses_with_announcement_count": 23,
"losses_with_announcement_fraction": 0.0865,
"all_trades_with_announcement_count": 115,
"all_trades_with_announcement_fraction": 0.2003
},
"q2_entries_within_3_trading_days_before_announcement": {
"pre_earnings": {
"count": 27,
"mean_r": 0.4837,
"median_r": -1.0265,
"win_rate": 0.3333,
"p05_r": -1.1463,
"p95_r": 5.981,
"min_r": -1.2449,
"max_r": 8.8996
},
"all_other_entries": {
"count": 547,
"mean_r": 0.2734,
"median_r": -0.8316,
"win_rate": 0.3565,
"p05_r": -1.1228,
"p95_r": 4.5888,
"min_r": -6.0161,
"max_r": 19.98
},
"tail_deltas_pre_minus_other": {
"p05_r": -0.0235,
"p95_r": 1.3922
},
"directional_tail_condition_present": false,
"tail_read": "Registered directional tail condition is not present."
},
"q3_stop_exits_within_1_trading_day_after_announcement": {
"stops_after_earnings": {
"count": 26,
"mean_r": -0.6434,
"median_r": -0.9753,
"win_rate": 0.2308,
"p05_r": -2.4614,
"p95_r": 1.1142,
"min_r": -2.6413,
"max_r": 1.7012
},
"all_other_stops": {
"count": 433,
"mean_r": -0.5035,
"median_r": -1.0278,
"win_rate": 0.1963,
"p05_r": -1.1373,
"p95_r": 1.3591,
"min_r": -6.0161,
"max_r": 19.98
},
"all_other_exits": {
"count": 548,
"mean_r": 0.3273,
"median_r": -0.8361,
"win_rate": 0.3613,
"p05_r": -1.0644,
"p95_r": 4.7224,
"min_r": -6.0161,
"max_r": 19.98
}
},
"implementation": "REPORT_ONLY_NO_FILTER_ARM_NO_FILTER_CHANGE",
"analysis_window": {
"from": "2020-01-22",
"to": "2026-07-17",
"rule": "entry_on_or_after_start_and_exit_on_or_before_end",
"simulation_trades_total": 574,
"trades_excluded_outside_earnings_coverage": 0
},
"run_config": {
"universe_symbols": 505,
"fill_mode": "close",
"cost_per_side": 0.001,
"momentum_cutoff": 80.0,
"exit_policy": "atr_trail3",
"hold_days": 30,
"max_positions": 10,
"risk_per_trade": 0.01
},
"sim_summary": {
"start_date": "2020-01-22",
"end_date": "2026-07-13",
"trades": 574,
"sharpe": 1.03,
"cagr_pct": 22.9,
"max_drawdown_pct": 26.6,
"total_return_pct": 280.6
}
}
}
@@ -0,0 +1,58 @@
# Earnings Task 2a - gap diagnostic
### Data quality gate
Approved earnings window: 2020-01-22 to 2026-07-17. Source mode: dolthub_public_bulk_clone.
| check | result |
|---|---:|
| Prod symbols requested / tradable | 506 / 505 |
| Manifest complete + live counts match | True |
| Prod symbols with pre-2021 bars | 491 (97.2%) |
| SPY benchmark depth | 2649 rows, 2016-01-04 to 2026-07-17 |
| Snapshot depth gate | True |
| Bulk source windows / requests logged | 1/1 / 1 |
| Source repository / pinned commit | https://www.dolthub.com/repositories/post-no-preference/earnings @ 9n0et3hpj9j7vue8f3qsldon3qa5sdjj |
| Source license / upstream provider documented | CC-BY-SA-4.0 / False |
| Existing-source conflicts preserved | 940 rows / 1526 fields |
| Symbols with >=8 announcements | 498 (98.6%) |
| Symbols with >=8 paired announcements | 495 (98.0%) |
| Events with estimate + actual | 12311/12414 (99.2%) |
| Duplicate rows in keyed table | 0 |
| Duplicate / restated payload rows fetched | 0 / 940 |
| Mean announcements per active symbol-year | 4.08 (expected about 4) |
| Symbols far off (<2 or >6/year, incl. zero) | 1 |
| Recognised BMO/AMC/during | 92.8% (reliable=True) |
| Point-in-time policy | announce_date_plus_1_trading_day_for_all_events |
| SUE price fallback | not_used |
Deduplication: UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment
Far-off announcement-rate symbols: SPCX
### Experiment 2a - earnings-gap risk diagnostic
Verdict: **INFORMATIONAL**. Report-only; no filter arm or implementation.
Trade cohort is restricted to the approved earnings-coverage window 2020-01-22 to 2026-07-17; 0 simulated trades outside that window were excluded.
| cohort | count | fraction |
|---|---:|---:|
| Realized net R <= -1.0 | 266 | - |
| Losses with announcement strictly inside hold | 23 | 0.0865 |
| All trades with announcement strictly inside hold | 115 | 0.2003 |
| Entry cohort | count | mean R | median R | win rate | p05 R | p95 R |
|---|---:|---:|---:|---:|---:|---:|
| Within 3 sessions before earnings | 27 | 0.4837 | -1.0265 | 0.3333 | -1.1463 | 5.981 |
| All other entries | 547 | 0.2734 | -0.8316 | 0.3565 | -1.1228 | 4.5888 |
Tail deltas (pre minus other): p05=-0.0235, p95=1.3922.
Registered directional tail condition is not present.
| Exit cohort | count | mean R | median R | win rate | p05 R | p95 R |
|---|---:|---:|---:|---:|---:|---:|
| Stops within 1 session after earnings | 26 | -0.6434 | -0.9753 | 0.2308 | -2.4614 | 1.1142 |
| All other stops | 433 | -0.5035 | -1.0278 | 0.1963 | -1.1373 | 1.3591 |
| All other exits | 548 | 0.3273 | -0.8361 | 0.3613 | -1.0644 | 4.7224 |
@@ -0,0 +1,349 @@
{
"generated_at": "2026-07-20T07:02:10.934892+00:00",
"snapshot": "C:\\Workspace\\signal-platform\\backtest_snapshots\\research.sqlite",
"snapshot_depth": {
"manifest": {
"schema_version": 1,
"snapshot": "research.sqlite",
"snapshot_resolved": "C:\\Workspace\\signal-platform\\backtest_snapshots\\research.sqlite",
"complete": true,
"finished_at": "2026-07-19T14:22:15.706192+00:00",
"ticker_count": 4650,
"ohlcv_row_count": 5081073,
"rank_only_count": 4144,
"sources": {
"pool": "source_snapshot"
},
"history_days": 5000,
"min_bars": 1262,
"fetch_ok": 505,
"fetch_fail": 1,
"limit": null,
"extra": {
"prod_symbols_at_start": 506,
"pool_size": 506,
"to_fetch": 506,
"source_symbols_only": true,
"benchmark_spy_rows": 2649
},
"live_counts": {
"ticker_count": 4650,
"ohlcv_row_count": 5081073,
"rank_only_count": 4144
}
},
"requested_symbols": 506,
"tradable_symbols": 505,
"missing_symbols": [],
"zero_bar_symbols": [
"RHM"
],
"bar_count": {
"min": 24,
"median": 2649,
"max": 2649
},
"symbols_with_pre2021_bars": 491,
"symbols_with_pre2021_bars_pct": 97.2,
"shallow_symbols_lt_1000_bars": [
{
"symbol": "SPCX",
"first_bar": "2026-06-12",
"last_bar": "2026-07-17",
"bars": 24
},
{
"symbol": "Q",
"first_bar": "2025-11-03",
"last_bar": "2026-07-17",
"bars": 176
},
{
"symbol": "PSKY",
"first_bar": "2025-08-07",
"last_bar": "2026-07-17",
"bars": 237
},
{
"symbol": "SNDK",
"first_bar": "2025-02-13",
"last_bar": "2026-07-17",
"bars": 357
},
{
"symbol": "GEV",
"first_bar": "2024-04-02",
"last_bar": "2026-07-17",
"bars": 575
},
{
"symbol": "SOLV",
"first_bar": "2024-04-01",
"last_bar": "2026-07-17",
"bars": 576
},
{
"symbol": "VLTO",
"first_bar": "2023-10-02",
"last_bar": "2026-07-17",
"bars": 700
},
{
"symbol": "KVUE",
"first_bar": "2023-05-04",
"last_bar": "2026-07-17",
"bars": 803
},
{
"symbol": "GEHC",
"first_bar": "2022-12-15",
"last_bar": "2026-07-17",
"bars": 898
}
],
"price_window": {
"min": "2016-01-04",
"max": "2026-07-17"
},
"benchmark_spy": {
"rows": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"gate_threshold": {
"min_tradable_symbols": 505,
"max_missing_or_zero_bar": 1,
"min_symbols_with_pre2021_bars_pct": 80.0,
"benchmark_min_rows": 1000,
"benchmark_must_begin_pre2021": true
},
"gate_pass": true
},
"data_quality": {
"window": {
"from": "2020-01-22",
"to": "2026-07-17"
},
"prod_symbols": 505,
"events": 12414,
"symbols_with_any_event": 504,
"symbols_with_ge8_announcements": 498,
"symbols_with_ge8_announcements_pct": 98.6,
"symbols_with_ge8_paired_announcements": 495,
"symbols_with_ge8_paired_announcements_pct": 98.0,
"events_with_actual_and_estimate": 12311,
"events_with_actual_and_estimate_pct": 99.2,
"duplicate_rows_in_table": 0,
"duplicate_rows_fetched": 0,
"restated_rows_fetched": 940,
"dedupe_policy": "UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment",
"events_per_symbol_year": {
"mean_active_span_rate": 4.08,
"expected": "approximately 4",
"far_off_rule": "active-span rate <2 or >6, plus zero-event symbols",
"far_off_count": 1,
"far_off_symbols": [
{
"symbol": "SPCX",
"events": 0,
"events_per_year": 0.0
}
]
},
"announcement_session": {
"recognised_bmo_amc_or_during": 11520,
"recognised_pct": 92.8,
"reliable": true,
"assessment": "usable"
},
"point_in_time_policy": "announce_date_plus_1_trading_day_for_all_events",
"sue_scaling": {
"primary": "eps_surprise_over_stdev_of_prior_8_surprises_min_4",
"fallback_trigger": "paired event coverage <50% or symbols with >=8 paired events <50%",
"fallback_needed": false,
"fallback_name": "not_used"
},
"backfill": {
"mode": "dolthub_public_bulk_clone",
"window": {
"from": "2020-01-22",
"to": "2026-07-17"
},
"coverage_amendment": {
"approved_by_user": true,
"reason": "FMP free tier blocks historical bulk earnings",
"original_start": "2016-01-04",
"amended_announcement_start": "2020-01-22"
},
"source": {
"repository": "https://www.dolthub.com/repositories/post-no-preference/earnings",
"commit": "9n0et3hpj9j7vue8f3qsldon3qa5sdjj",
"license": "CC-BY-SA-4.0",
"upstream_provider_documented": false
},
"bulk_windows_total": 1,
"bulk_windows_done": 1,
"bulk_requests_logged_total": 1,
"bulk_exports": 2,
"calendar": {
"raw_rows": 117482,
"universe_rows_in_window": 12342,
"deduped_rows_in_window": 12342,
"duplicate_rows": 0,
"restated_rows": 0
},
"eps_history": {
"raw_rows": 165050,
"universe_rows": 18515,
"deduped_rows": 18515,
"duplicate_rows": 0,
"restated_rows": 0,
"complete_actual_and_estimate": 18304
},
"pairing": {
"method": "minimum-cost monotonic alignment per symbol",
"allowed_announce_minus_period_end_days": [
-14,
90
],
"matched_calendar_events": 12271,
"unmatched_calendar_events": 71,
"unmatched_periods_in_pairing_window": 538,
"announce_minus_period_end_days": {
"min": -10,
"median": 30,
"max": 89
},
"pre_2020_eps_history_use": "trailing_surprise_stdev_only; never treated as an announcement or live signal event"
},
"duplicate_rows_logged_total": 0,
"restated_rows_logged_total": 940,
"conflicting_existing_rows": 940,
"conflicting_existing_fields": 1526,
"preserved_existing_fields": 2945,
"existing_enrichment_events_not_in_dolthub_calendar": 72,
"dedupe_policy": "UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment",
"events_in_window": 12414,
"events_with_actual_and_estimate": 12311,
"symbols_done": 506,
"symbols_universe": 506,
"symbols_with_dolthub_calendar": 504,
"symbols_without_dolthub_calendar": [
"RHM",
"SPCX"
],
"announce_date_range": {
"min": "2020-01-22",
"max": "2026-07-17"
},
"complete": true
}
},
"production_impact": "none",
"experiment": "2b",
"result": {
"verdict": "FAIL",
"verdict_detail": "SUE DEAD for this stack",
"grade_rule": {
"mean_ic_ge_0_03_positive": false,
"reliable_ge_12_windows": true,
"positive_sign_pre_and_post_2021": true,
"pass": false
},
"sue_unconditional": {
"signal": "sue_latest",
"weeks": 56,
"avg_cross_section": 451.4,
"mean_ic": 0.0151,
"ic_t_stat": 1.29,
"ic_positive_pct": 51.8,
"mean_quintile_spread": 0.0041,
"reliable": true
},
"era_split": {
"pre_2021": {
"signal": "sue_latest",
"weeks": 9,
"avg_cross_section": 398.3,
"mean_ic": 0.0286,
"ic_t_stat": 0.7,
"ic_positive_pct": 55.6,
"mean_quintile_spread": 0.0084,
"reliable": false
},
"post_2021": {
"signal": "sue_latest",
"weeks": 48,
"avg_cross_section": 461.9,
"mean_ic": 0.0172,
"ic_t_stat": 1.34,
"ic_positive_pct": 64.6,
"mean_quintile_spread": 0.0038,
"reliable": true
}
},
"signal_eval_identical_cross_sections": {
"sue_latest": {
"signal": "sue_latest",
"weeks": 56,
"avg_cross_section": 450.9,
"mean_ic": 0.0148,
"ic_t_stat": 1.27,
"ic_positive_pct": 51.8,
"mean_quintile_spread": 0.004,
"reliable": true
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 56,
"avg_cross_section": 450.9,
"mean_ic": 0.0195,
"ic_t_stat": 0.74,
"ic_positive_pct": 58.9,
"mean_quintile_spread": 0.0104,
"reliable": true
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 56,
"avg_cross_section": 450.9,
"mean_ic": 0.0262,
"ic_t_stat": 1.07,
"ic_positive_pct": 55.4,
"mean_quintile_spread": 0.0114,
"reliable": true
}
},
"identical_cross_section_definition": "same week-symbol-forward-return cells where sue_latest, mom_12_1, and mom_12_1_resid are all non-null",
"momentum_conditional_top_quintile": {
"mean_ic": 0.0213,
"ic_t_stat": 1.3,
"weeks": 56,
"avg_cross_section": 89.8,
"population": "top_mom_12_1_quintile_only"
},
"coverage": {
"symbols_with_live_sue": 501,
"avg_weekly_live_n_all_weeks": 453.1,
"avg_cross_section_n_scored_nonoverlap": 451.4,
"thin_cross_section_lt_100": false,
"warning": null
},
"scaling": {
"method": "eps_surprise_over_stdev_of_prior_8_surprises_min_4",
"fallback": "not_used",
"counts": {
"standard_scaled_events": 12149,
"events_scaled_from_period_history": 12149,
"price_fallback_events": 0,
"dropped_insufficient_trailing_history": 95,
"dropped_missing_period_alignment": 67,
"dropped_zero_stdev": 0
},
"pre_coverage_history_policy": "period-end EPS surprises may scale later events but are never treated as live signals without an announcement date",
"availability": "announce_date_plus_1_trading_day",
"carry_trading_days": 63
},
"universe_symbols": 505
}
}
@@ -0,0 +1,62 @@
# Earnings Task 2b - SUE / PEAD
### Data quality gate
Approved earnings window: 2020-01-22 to 2026-07-17. Source mode: dolthub_public_bulk_clone.
| check | result |
|---|---:|
| Prod symbols requested / tradable | 506 / 505 |
| Manifest complete + live counts match | True |
| Prod symbols with pre-2021 bars | 491 (97.2%) |
| SPY benchmark depth | 2649 rows, 2016-01-04 to 2026-07-17 |
| Snapshot depth gate | True |
| Bulk source windows / requests logged | 1/1 / 1 |
| Source repository / pinned commit | https://www.dolthub.com/repositories/post-no-preference/earnings @ 9n0et3hpj9j7vue8f3qsldon3qa5sdjj |
| Source license / upstream provider documented | CC-BY-SA-4.0 / False |
| Existing-source conflicts preserved | 940 rows / 1526 fields |
| Symbols with >=8 announcements | 498 (98.6%) |
| Symbols with >=8 paired announcements | 495 (98.0%) |
| Events with estimate + actual | 12311/12414 (99.2%) |
| Duplicate rows in keyed table | 0 |
| Duplicate / restated payload rows fetched | 0 / 940 |
| Mean announcements per active symbol-year | 4.08 (expected about 4) |
| Symbols far off (<2 or >6/year, incl. zero) | 1 |
| Recognised BMO/AMC/during | 92.8% (reliable=True) |
| Point-in-time policy | announce_date_plus_1_trading_day_for_all_events |
| SUE price fallback | not_used |
Deduplication: UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment
Far-off announcement-rate symbols: SPCX
### Experiment 2b - SUE / post-earnings drift
Mechanical verdict: **FAIL** - SUE DEAD for this stack
Identical cross-sections:
| signal | mean IC | t | windows | avg N | IC positive % | reliable |
|---|---:|---:|---:|---:|---:|---|
| sue_latest | 0.0148 | 1.27 | 56 | 450.9 | 51.8 | true |
| mom_12_1 | 0.0195 | 0.74 | 56 | 450.9 | 58.9 | true |
| mom_12_1_resid | 0.0262 | 1.07 | 56 | 450.9 | 55.4 | true |
Unconditional SUE grade row:
| signal | mean IC | t | windows | avg N | IC positive % | reliable |
|---|---:|---:|---:|---:|---:|---|
| sue_latest | 0.0151 | 1.29 | 56 | 451.4 | 51.8 | true |
Era stability:
| era | mean IC | t | windows | avg N | IC positive % | reliable |
|---|---:|---:|---:|---:|---:|---|
| pre-2021 | 0.0286 | 0.7 | 9 | 398.3 | 55.6 | false |
| post-2021 | 0.0172 | 1.34 | 48 | 461.9 | 64.6 | true |
Coverage: 501 symbols with live SUE; avg weekly N=453.1; scored non-overlap avg N=451.4.
Cross-section is not flagged thin at the registered <100-name read.
Momentum-conditional top-quintile SUE: mean IC=0.0213, t=1.3, windows=56, avg N=89.8.
+75
View File
@@ -0,0 +1,75 @@
{
"mode": "dolthub_public_bulk_clone",
"window": {
"from": "2020-01-22",
"to": "2026-07-17"
},
"coverage_amendment": {
"approved_by_user": true,
"reason": "FMP free tier blocks historical bulk earnings",
"original_start": "2016-01-04",
"amended_announcement_start": "2020-01-22"
},
"source": {
"repository": "https://www.dolthub.com/repositories/post-no-preference/earnings",
"commit": "9n0et3hpj9j7vue8f3qsldon3qa5sdjj",
"license": "CC-BY-SA-4.0",
"upstream_provider_documented": false
},
"bulk_windows_total": 1,
"bulk_windows_done": 1,
"bulk_requests_logged_total": 1,
"bulk_exports": 2,
"calendar": {
"raw_rows": 117482,
"universe_rows_in_window": 12342,
"deduped_rows_in_window": 12342,
"duplicate_rows": 0,
"restated_rows": 0
},
"eps_history": {
"raw_rows": 165050,
"universe_rows": 18515,
"deduped_rows": 18515,
"duplicate_rows": 0,
"restated_rows": 0,
"complete_actual_and_estimate": 18304
},
"pairing": {
"method": "minimum-cost monotonic alignment per symbol",
"allowed_announce_minus_period_end_days": [
-14,
90
],
"matched_calendar_events": 12271,
"unmatched_calendar_events": 71,
"unmatched_periods_in_pairing_window": 538,
"announce_minus_period_end_days": {
"min": -10,
"median": 30,
"max": 89
},
"pre_2020_eps_history_use": "trailing_surprise_stdev_only; never treated as an announcement or live signal event"
},
"duplicate_rows_logged_total": 0,
"restated_rows_logged_total": 940,
"conflicting_existing_rows": 940,
"conflicting_existing_fields": 1526,
"preserved_existing_fields": 2945,
"existing_enrichment_events_not_in_dolthub_calendar": 72,
"dedupe_policy": "UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one calendar row per key; preserve existing non-null session/EPS values from the prior FMP/Alpha Vantage partial backfill, then fill nulls and all remaining symbols from DoltHub; attach DoltHub period-end alignment",
"events_in_window": 12414,
"events_with_actual_and_estimate": 12311,
"symbols_done": 506,
"symbols_universe": 506,
"symbols_with_dolthub_calendar": 504,
"symbols_without_dolthub_calendar": [
"RHM",
"SPCX"
],
"announce_date_range": {
"min": "2020-01-22",
"max": "2026-07-17"
},
"complete": true
}
@@ -0,0 +1,578 @@
{
"generated_at": "2026-07-18T19:16:37.954856+00:00",
"tickers": 506,
"rank_only_tickers": 0,
"candidates": 202765,
"qualified": 1086,
"params": {
"step_days": 5,
"step_sessions": 5,
"entry_cadence": "weekly",
"signal_eval_cadence": "weekly",
"horizon_days": 30,
"min_lookback": 60,
"cost_per_side_pct": 0.1,
"target_model": "production_gtl",
"target_model_label": "Live GTL (production)",
"is_production_target_model": true,
"production_reentry_policy": "gate_reset",
"liquid_breadth_top_n": null,
"liquid_min_price": null,
"signal_eval_only": true
},
"activation": {
"min_momentum_percentile": 80.0,
"min_rr": 2.0,
"min_confidence": 0.0,
"require_high_conviction": false,
"exclude_conflicts": false,
"exclude_neutral": true
},
"overall_qualified": {
"total": 1086,
"wins": 379,
"losses": 591,
"expired": 116,
"hit_rate": 39.1,
"avg_r": 0.255,
"total_r": 276.76,
"net_avg_r": 0.209,
"net_total_r": 226.56,
"best_r": 8.85,
"worst_r": -3.38,
"avg_hold_days": 12.0,
"net_r_per_day": 0.0174,
"median_net_r": -1.031,
"profit_factor": 1.34,
"net_avg_r_ex_top5": 0.049
},
"overall_all": {
"total": 202765,
"wins": 82220,
"losses": 113809,
"expired": 6736,
"hit_rate": 41.9,
"avg_r": -0.04,
"total_r": -8186.18,
"net_avg_r": -0.095,
"net_total_r": -19184.55,
"best_r": 9.24,
"worst_r": -16.42,
"avg_hold_days": 8.2,
"net_r_per_day": -0.0115,
"median_net_r": -1.035,
"profit_factor": 0.85,
"net_avg_r_ex_top5": -0.22
},
"by_direction": {
"long": {
"total": 1086,
"wins": 379,
"losses": 591,
"expired": 116,
"hit_rate": 39.1,
"avg_r": 0.255,
"total_r": 276.76,
"net_avg_r": 0.209,
"net_total_r": 226.56,
"best_r": 8.85,
"worst_r": -3.38,
"avg_hold_days": 12.0,
"net_r_per_day": 0.0174,
"median_net_r": -1.031,
"profit_factor": 1.34,
"net_avg_r_ex_top5": 0.049
},
"short": {
"total": 0,
"wins": 0,
"losses": 0,
"expired": 0,
"hit_rate": null,
"avg_r": null,
"total_r": null,
"net_avg_r": null,
"net_total_r": null,
"best_r": null,
"worst_r": null,
"avg_hold_days": null,
"net_r_per_day": null,
"median_net_r": null,
"profit_factor": null,
"net_avg_r_ex_top5": null
}
},
"min_momentum_percentile": 80.0,
"sweep": [
{
"min_momentum_percentile": 90.0,
"total": 497,
"wins": 177,
"losses": 269,
"expired": 51,
"hit_rate": 39.7,
"avg_r": 0.276,
"total_r": 137.05,
"net_avg_r": 0.235,
"net_total_r": 116.55,
"best_r": 8.85,
"worst_r": -3.38,
"avg_hold_days": 11.8,
"net_r_per_day": 0.0199,
"median_net_r": -1.026,
"profit_factor": 1.39,
"net_avg_r_ex_top5": 0.071
},
{
"min_momentum_percentile": 80.0,
"total": 1086,
"wins": 379,
"losses": 591,
"expired": 116,
"hit_rate": 39.1,
"avg_r": 0.255,
"total_r": 276.76,
"net_avg_r": 0.209,
"net_total_r": 226.56,
"best_r": 8.85,
"worst_r": -3.38,
"avg_hold_days": 12.0,
"net_r_per_day": 0.0174,
"median_net_r": -1.031,
"profit_factor": 1.34,
"net_avg_r_ex_top5": 0.049
},
{
"min_momentum_percentile": 70.0,
"total": 1841,
"wins": 597,
"losses": 1062,
"expired": 182,
"hit_rate": 36.0,
"avg_r": 0.152,
"total_r": 280.26,
"net_avg_r": 0.104,
"net_total_r": 190.75,
"best_r": 8.85,
"worst_r": -4.21,
"avg_hold_days": 11.8,
"net_r_per_day": 0.0088,
"median_net_r": -1.037,
"profit_factor": 1.16,
"net_avg_r_ex_top5": -0.055
},
{
"min_momentum_percentile": 60.0,
"total": 2772,
"wins": 873,
"losses": 1611,
"expired": 288,
"hit_rate": 35.1,
"avg_r": 0.126,
"total_r": 348.07,
"net_avg_r": 0.075,
"net_total_r": 209.25,
"best_r": 8.85,
"worst_r": -4.21,
"avg_hold_days": 12.0,
"net_r_per_day": 0.0063,
"median_net_r": -1.04,
"profit_factor": 1.12,
"net_avg_r_ex_top5": -0.077
},
{
"min_momentum_percentile": 50.0,
"total": 3901,
"wins": 1182,
"losses": 2295,
"expired": 424,
"hit_rate": 34.0,
"avg_r": 0.089,
"total_r": 345.37,
"net_avg_r": 0.038,
"net_total_r": 146.96,
"best_r": 8.85,
"worst_r": -4.86,
"avg_hold_days": 12.1,
"net_r_per_day": 0.0031,
"median_net_r": -1.042,
"profit_factor": 1.06,
"net_avg_r_ex_top5": -0.114
},
{
"min_momentum_percentile": 0.0,
"total": 14588,
"wins": 3719,
"losses": 9271,
"expired": 1598,
"hit_rate": 28.6,
"avg_r": -0.065,
"total_r": -952.08,
"net_avg_r": -0.115,
"net_total_r": -1676.86,
"best_r": 9.24,
"worst_r": -15.94,
"avg_hold_days": 12.1,
"net_r_per_day": -0.0095,
"median_net_r": -1.043,
"profit_factor": 0.84,
"net_avg_r_ex_top5": -0.275
}
],
"gate_ablation": [
{
"variant": "all_floors",
"total": 1086,
"wins": 379,
"losses": 591,
"expired": 116,
"hit_rate": 39.1,
"avg_r": 0.255,
"total_r": 276.76,
"net_avg_r": 0.209,
"net_total_r": 226.56,
"best_r": 8.85,
"worst_r": -3.38,
"avg_hold_days": 12.0,
"net_r_per_day": 0.0174,
"median_net_r": -1.031,
"profit_factor": 1.34,
"net_avg_r_ex_top5": 0.049,
"hold_days": 30,
"hold_avg_r": 0.631,
"hold_net_avg_r": 0.585,
"hold_total_r": 684.97
},
{
"variant": "no_confidence_floor",
"total": 1093,
"wins": 380,
"losses": 596,
"expired": 117,
"hit_rate": 38.9,
"avg_r": 0.25,
"total_r": 273.55,
"net_avg_r": 0.204,
"net_total_r": 222.99,
"best_r": 8.85,
"worst_r": -3.38,
"avg_hold_days": 11.9,
"net_r_per_day": 0.0171,
"median_net_r": -1.031,
"profit_factor": 1.33,
"net_avg_r_ex_top5": 0.045,
"hold_days": 30,
"hold_avg_r": 0.626,
"hold_net_avg_r": 0.58,
"hold_total_r": 684.08
},
{
"variant": "no_rr_floor",
"total": 6849,
"wins": 3235,
"losses": 3409,
"expired": 205,
"hit_rate": 48.7,
"avg_r": 0.112,
"total_r": 770.17,
"net_avg_r": 0.061,
"net_total_r": 418.96,
"best_r": 8.85,
"worst_r": -5.16,
"avg_hold_days": 7.9,
"net_r_per_day": 0.0078,
"median_net_r": -0.061,
"profit_factor": 1.11,
"net_avg_r_ex_top5": -0.061,
"hold_days": 30,
"hold_avg_r": 0.354,
"hold_net_avg_r": 0.303,
"hold_total_r": 2425.86
},
{
"variant": "no_neutral_exclusion",
"total": 2313,
"wins": 770,
"losses": 1279,
"expired": 264,
"hit_rate": 37.6,
"avg_r": 0.2,
"total_r": 462.35,
"net_avg_r": 0.154,
"net_total_r": 355.89,
"best_r": 8.85,
"worst_r": -3.38,
"avg_hold_days": 12.6,
"net_r_per_day": 0.0122,
"median_net_r": -1.031,
"profit_factor": 1.25,
"net_avg_r_ex_top5": 0.004,
"hold_days": 30,
"hold_avg_r": 0.583,
"hold_net_avg_r": 0.537,
"hold_total_r": 1348.89
},
{
"variant": "momentum_only",
"total": 14696,
"wins": 6827,
"losses": 7359,
"expired": 510,
"hit_rate": 48.1,
"avg_r": 0.114,
"total_r": 1669.64,
"net_avg_r": 0.064,
"net_total_r": 936.93,
"best_r": 8.85,
"worst_r": -5.71,
"avg_hold_days": 8.4,
"net_r_per_day": 0.0076,
"median_net_r": -1.013,
"profit_factor": 1.11,
"net_avg_r_ex_top5": -0.055,
"hold_days": 30,
"hold_avg_r": 0.395,
"hold_net_avg_r": 0.345,
"hold_total_r": 5798.82
}
],
"gate_ablation_note": "Each row re-qualifies the same candidates at the current momentum cutoff (80) with one floor removed (long-only while the momentum gate is active). If dropping a floor doesn't hurt net expectancy, that floor isn't pulling its weight. The Hold columns grade the same variants under the hold-to-horizon time exit instead of the S/R target \u2014 the view that matters if the exit policy moves to a fixed hold.",
"time_exit_sweep": [
{
"hold_days": 5,
"total": 1086,
"wins": 603,
"win_rate": 55.5,
"avg_r": 0.175,
"total_r": 190.16,
"net_avg_r": 0.129,
"net_total_r": 139.97,
"best_r": 5.09,
"worst_r": -2.51,
"avg_hold_days": 4.5,
"net_r_per_day": 0.0285,
"median_net_r": 0.115,
"profit_factor": 1.36,
"net_avg_r_ex_top5": -0.002
},
{
"hold_days": 10,
"total": 1086,
"wins": 559,
"win_rate": 51.5,
"avg_r": 0.357,
"total_r": 387.9,
"net_avg_r": 0.311,
"net_total_r": 337.7,
"best_r": 6.73,
"worst_r": -2.51,
"avg_hold_days": 7.9,
"net_r_per_day": 0.0395,
"median_net_r": 0.031,
"profit_factor": 1.67,
"net_avg_r_ex_top5": 0.112
},
{
"hold_days": 21,
"total": 1086,
"wins": 487,
"win_rate": 44.8,
"avg_r": 0.525,
"total_r": 570.33,
"net_avg_r": 0.479,
"net_total_r": 520.14,
"best_r": 9.86,
"worst_r": -3.38,
"avg_hold_days": 13.7,
"net_r_per_day": 0.0349,
"median_net_r": -1.027,
"profit_factor": 1.81,
"net_avg_r_ex_top5": 0.191
},
{
"hold_days": 30,
"total": 1086,
"wins": 434,
"win_rate": 40.0,
"avg_r": 0.631,
"total_r": 684.97,
"net_avg_r": 0.585,
"net_total_r": 634.78,
"best_r": 12.87,
"worst_r": -3.38,
"avg_hold_days": 17.8,
"net_r_per_day": 0.0329,
"median_net_r": -1.033,
"profit_factor": 1.9,
"net_avg_r_ex_top5": 0.212
}
],
"portfolio_sim": {
"params": {
"starting_capital": 10000.0,
"max_positions": 10,
"risk_per_trade_pct": 1.0,
"notional_cap_pct": 20.0,
"cost_per_side_pct": 0.1,
"hold_days": 30
},
"policies": [],
"note": "One capital-constrained book over the same qualified setups the tables above grade per-setup: at most 10 concurrent positions (one per ticker), best momentum first, fixed-fractional risk sizing with a no-leverage cap, entries at the detection close, stops filled at the worse of stop or open. 'target' races the S/R target against the stop (timeout at the horizon); 'hold' keeps the initial stop and exits at the horizon close. SPY return is price-only over the same window. In-sample; no dividends."
},
"strategy_variants": {
"variants": [],
"note": "Research-only hold-to-horizon portfolio variants. Production now uses residual 12-1 momentum at cutoff 80; the remaining rows compare the legacy raw rank, raw cutoff 90, one max-15 capacity check, and volatility overlays."
},
"exit_policy_variants": {
"variants": [],
"note": "Research-only exit policies over the residual/high-vol 80/20 entry candidate. Every row uses the same entry qualification/ranking and changes only the exit discipline."
},
"portfolio_monitor": null,
"production_cadence_comparison": null,
"holdout": null,
"min_rr_sweep": null,
"target_model_diagnostics": {
"target_model": "production_gtl",
"target_model_label": "Live GTL (production)",
"candidate_count": 202765,
"primary_source_counts": {
"pivot_point": 196290,
"range_grid": 180036
},
"primary_round_only": 0,
"primary_strength_100": 138596,
"avg_primary_strength": 80.109,
"avg_primary_distance_atr": 2.293,
"avg_primary_rejection_count": 41.908,
"avg_raw_level_count": 53.204,
"avg_gate_level_count": 53.204
},
"signal_eval": [
{
"signal": "vol_6m",
"weeks": 39,
"avg_cross_section": 498.2,
"mean_ic": 0.0609,
"ic_t_stat": 1.48,
"ic_positive_pct": 64.1,
"mean_quintile_spread": 0.0337,
"reliable": true
},
{
"signal": "mom_12_1_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0552,
"ic_t_stat": 1.98,
"ic_positive_pct": 60.0,
"mean_quintile_spread": 0.0207,
"reliable": true
},
{
"signal": "mom_12_1",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0531,
"ic_t_stat": 1.61,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0206,
"reliable": true
},
{
"signal": "trend_200",
"weeks": 37,
"avg_cross_section": 497.9,
"mean_ic": 0.0161,
"ic_t_stat": 0.44,
"ic_positive_pct": 59.5,
"mean_quintile_spread": 0.006,
"reliable": true
},
{
"signal": "reversal_1m",
"weeks": 43,
"avg_cross_section": 498.7,
"mean_ic": 0.0059,
"ic_t_stat": 0.22,
"ic_positive_pct": 53.5,
"mean_quintile_spread": 0.0053,
"reliable": true
},
{
"signal": "mom_6_1",
"weeks": 39,
"avg_cross_section": 498.2,
"mean_ic": 0.0051,
"ic_t_stat": 0.21,
"ic_positive_pct": 56.4,
"mean_quintile_spread": 0.0087,
"reliable": true
},
{
"signal": "mom_3_1",
"weeks": 42,
"avg_cross_section": 498.5,
"mean_ic": -0.0064,
"ic_t_stat": -0.25,
"ic_positive_pct": 50.0,
"mean_quintile_spread": 0.0046,
"reliable": true
},
{
"signal": "high_52w",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": -0.0086,
"ic_t_stat": -0.26,
"ic_positive_pct": 54.3,
"mean_quintile_spread": -0.0088,
"reliable": true
},
{
"signal": "fip_id",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": -0.045,
"ic_t_stat": -2.91,
"ic_positive_pct": 25.7,
"mean_quintile_spread": -0.0168,
"reliable": true
}
],
"signal_eval_note": "Cross-sectional rank-IC of price-only signals vs the forward 30-day return (min 20 names/window). |IC| \u2273 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 expected negative (mean-reversion / low-vol anomaly). IC is measured on non-overlapping windows; signals with fewer than 12 independent windows are flagged unreliable (too few regimes \u2014 deepen history with the Data Backfill job).",
"note": "Sentiment & fundamentals held neutral (no point-in-time history). Stops fill at the worse of the stop or the bar's open (gaps through the stop are modeled, so a loss can exceed \u22121R); targets never fill better than their level. ~6 months \u2248 one market regime \u2014 treat as directional, not gospel.",
"recommendation": {
"headline": "Trade the qualified list long-only; hold 30 trading days with the initial ATR stop.",
"items": [
{
"topic": "exit",
"text": "Legacy exit diagnostic: hold 30 trading days with the initial stop (+0.58R net/trade vs +0.21R for the S/R target exit)."
},
{
"topic": "gate",
"text": "Gate: the confidence floor adds nothing \u2014 dropping it costs +0.01R/trade and adds 7 trades."
},
{
"topic": "gate",
"text": "Gate: keep the R:R floor (worth +0.28R/trade under the hold exit)."
},
{
"topic": "gate",
"text": "Gate: keep the NEUTRAL exclusion (worth +0.05R/trade under the hold exit)."
},
{
"topic": "cutoff",
"text": "Residual-momentum cutoff: 90 has the best per-trade net (+0.23R over 497 setups)."
},
{
"topic": "robustness",
"text": "Robustness: expectancy survives removing the top 5% of winners (+0.21R net/trade under the recommended 30d hold) \u2014 the edge is not a handful of outliers."
}
],
"note": "Derived from this report's numbers on every run \u2014 the advice flips if the data does."
},
"research_recommendation": {
"items": [],
"note": "Strategy variants unavailable; re-run the backtest after benchmark data is present."
}
}
+125
View File
@@ -0,0 +1,125 @@
{
"generated_at": "2026-07-19T00:05:20.113638",
"research_snapshot": "C:\\Workspace\\signal-platform\\backtest_snapshots\\research.sqlite",
"top_n": 1500,
"min_price": 5.0,
"prod_subset_n": 506,
"panel_tickers": 4403,
"single_source": "diagnostics uses harness _signal_series + _filter_liquid_breadth_week_rich only (no parallel mask)",
"avg_cross_section_semantics": "avg_cross_section = post-mask IC sample size. avg_raw_pool = pre-filter observations. avg_eligible_pre_mask = pass price+dvol before top-N. mask_binds_pct = weeks where eligible_pre_mask > top_n.",
"harness_self_consistent": true,
"checks": {
"fip_harness_signal_eval": {
"note": "Authoritative harness _signal_evaluation on collected fip_id",
"signal": "fip_id",
"weeks": 35,
"avg_cross_section": 1471.2,
"mean_ic": -0.0168,
"ic_t_stat": -1.85,
"ic_positive_pct": 40.0,
"mean_quintile_spread": -0.0052,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3214.4,
"avg_eligible_pre_mask": 2338.4,
"mask_binds_pct": 97.1
},
"fip_same_week_via_shared_filter": {
"note": "Same collected data, IC via shared _filter_liquid_breadth_week_rich",
"mean_ic": -0.0168,
"ic_t_stat": -1.85,
"weeks": 35,
"avg_cross_section": 1471.2,
"ic_positive_pct": 40.0,
"reliable": true
},
"fip_lagged_membership_1w": {
"note": "Top-N by prior-week $vol on current fip pool (shared filter)",
"mean_ic": -0.0102,
"ic_t_stat": -0.93,
"weeks": 35,
"avg_cross_section": 1471.2,
"ic_positive_pct": 40.0,
"reliable": true
},
"fip_tier_1_800": {
"note": "Senior liquid ranks 1\u2013800",
"mean_ic": -0.035,
"ic_t_stat": -2.99,
"weeks": 35,
"avg_cross_section": 791.2,
"ic_positive_pct": 25.7,
"reliable": true
},
"fip_tier_801_1500": {
"note": "Junior liquid ranks 801\u2013top_n",
"mean_ic": 0.0141,
"ic_t_stat": 1.25,
"weeks": 35,
"avg_cross_section": 700.0,
"ic_positive_pct": 60.0,
"reliable": true
},
"fip_prod_universe_subset": {
"note": "Prod.sqlite symbols inside liquid fip set",
"mean_ic": -0.0444,
"ic_t_stat": -2.88,
"weeks": 35,
"avg_cross_section": 497.5,
"ic_positive_pct": 25.7,
"reliable": true
},
"fip_momentum_conditional_top20pct": {
"note": "Among liquid fip set, mom_12_1 \u2265 P80 (paper / gate-relevant)",
"mean_ic": -0.0879,
"ic_t_stat": -4.58,
"weeks": 35,
"avg_cross_section": 294.3,
"ic_positive_pct": 22.9,
"reliable": true
},
"vol_6m_liquid": {
"note": "vol_6m through shared filter",
"mean_ic": -0.0478,
"ic_t_stat": -1.36,
"weeks": 35,
"avg_cross_section": 1500.0,
"ic_positive_pct": 37.1,
"reliable": true
},
"mom_12_1_liquid": {
"note": "raw mom through shared filter",
"mean_ic": 0.0462,
"ic_t_stat": 1.91,
"weeks": 35,
"avg_cross_section": 1471.2,
"ic_positive_pct": 65.7,
"reliable": true
},
"mom_12_1_resid_liquid": {
"note": "residual mom through shared filter",
"mean_ic": 0.0289,
"ic_t_stat": 1.33,
"weeks": 35,
"avg_cross_section": 1471.2,
"ic_positive_pct": 60.0,
"reliable": true
}
},
"interpretation": {
"harness_and_shared_filter_agree": true,
"mask_binds_pct": 97.1,
"avg_eligible_pre_mask": 2338.4,
"avg_raw_pool": 3214.4,
"prod_subset_still_negative": true,
"junior_tier_more_positive": true,
"lag_same_sign_as_same_week": true,
"mom_conditional_negative_and_reliable": true,
"orphan_plus_five_sigma": "Prior report fip-breadth-20260718-211440-breadth.json listed fip IC +0.0575 / t +5.12. This single-sourced recompute is the authoritative number; if it disagrees, the +0.0575 row is orphaned.",
"compositional_story": "fip_id pools continuous winners (neg IC) vs continuous bleeders (pos IC). Prod-subset and senior liquid stay negative; junior liquid is less negative / positive \u2014 composition, not jumpiness premium.",
"vol_tilt_warning": "High-vol names underperform on breadth relative to S&P-like books. Re-validate production 80/20 high-vol tilt before any universe broaden."
},
"platform_verdict": "Mom-conditional fip ALIVE as book-tilt candidate (needs book sim) \u2014 not production wire-in. Unconditional fip not green.",
"membership_dumps_note": "Removed 5 week membership symbol lists from the committed artifact (compact decision evidence). Full dumps recoverable from git history of this file pre-cleanup."
}
+494
View File
@@ -0,0 +1,494 @@
{
"generated_at": "2026-07-19T10:33:15.322673",
"survivorship_banner": "SURVIVORSHIP BIAS: today's constituents backfilled historically. Absolute Sharpe/CAGR levels on deep history are optimistic. Use RELATIVE signal IC comparisons and era stability only \u2014 not levels.",
"coverage": null,
"race_guard": {
"manifest": {
"schema_version": 1,
"snapshot": "research.sqlite",
"snapshot_resolved": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/research.sqlite",
"complete": true,
"finished_at": "2026-07-19T08:19:02.992206+00:00",
"ticker_count": 4655,
"ohlcv_row_count": 6609926,
"rank_only_count": 4149,
"sources": {
"nasdaq_all": "nasdaq_trader",
"sp500": "wikipedia_sp500"
},
"history_days": 5000,
"min_bars": 260,
"fetch_ok": 4152,
"fetch_fail": 0,
"limit": null,
"extra": {
"prod_symbols_at_start": 506,
"pool_size": 4648,
"to_fetch": 4152
},
"live_counts": {
"ticker_count": 4655,
"ohlcv_row_count": 6609926,
"rank_only_count": 4149
}
},
"ok": true
},
"harness": {
"survivorship_banner": "SURVIVORSHIP BIAS: today's constituents backfilled historically. Absolute Sharpe/CAGR levels on deep history are optimistic. Use RELATIVE signal IC comparisons and era stability only \u2014 not levels.",
"signal_eval": [
{
"signal": "high_52w",
"weeks": 84,
"avg_cross_section": 2280.3,
"mean_ic": 0.1111,
"ic_t_stat": 6.41,
"ic_positive_pct": 76.2,
"mean_quintile_spread": -13.5104,
"reliable": true
},
{
"signal": "mom_12_1",
"weeks": 83,
"avg_cross_section": 2277.5,
"mean_ic": 0.0663,
"ic_t_stat": 5.11,
"ic_positive_pct": 74.7,
"mean_quintile_spread": -10.1818,
"reliable": true
},
{
"signal": "mom_12_1_sector_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0245,
"reliable": true
},
{
"signal": "trend_200",
"weeks": 85,
"avg_cross_section": 2308.1,
"mean_ic": 0.0546,
"ic_t_stat": 4.3,
"ic_positive_pct": 70.6,
"mean_quintile_spread": -12.4094,
"reliable": true
},
{
"signal": "mom_6_1",
"weeks": 88,
"avg_cross_section": 2375.7,
"mean_ic": 0.0493,
"ic_t_stat": 4.91,
"ic_positive_pct": 70.5,
"mean_quintile_spread": 3.4224,
"reliable": true
},
{
"signal": "mom_3_1",
"weeks": 90,
"avg_cross_section": 2425.6,
"mean_ic": 0.0363,
"ic_t_stat": 3.58,
"ic_positive_pct": 72.2,
"mean_quintile_spread": 0.9289,
"reliable": true
},
{
"signal": "mom_12_1_sector_demeaned",
"weeks": 35,
"avg_cross_section": 496.7,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"ic_positive_pct": 62.9,
"mean_quintile_spread": 0.0154,
"reliable": true
},
{
"signal": "fip_id",
"weeks": 83,
"avg_cross_section": 2277.5,
"mean_ic": 0.0267,
"ic_t_stat": 3.25,
"ic_positive_pct": 67.5,
"mean_quintile_spread": -0.0017,
"reliable": true
},
{
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 2277.5,
"mean_ic": 0.0256,
"ic_t_stat": 2.21,
"ic_positive_pct": 65.1,
"mean_quintile_spread": 10.1373,
"reliable": true
},
{
"signal": "reversal_1m",
"weeks": 91,
"avg_cross_section": 2443.2,
"mean_ic": 0.003,
"ic_t_stat": 0.31,
"ic_positive_pct": 48.4,
"mean_quintile_spread": -7.0153,
"reliable": true
},
{
"signal": "vol_6m",
"weeks": 88,
"avg_cross_section": 2375.7,
"mean_ic": -0.1226,
"ic_t_stat": -6.34,
"ic_positive_pct": 21.6,
"mean_quintile_spread": 1.4646,
"reliable": true
}
],
"era_split": {
"era_split_date": "2021-01-01",
"note": "Diagnostic only \u2014 not a tuning input. Nested lookbacks are not OOS.",
"full": {
"high_52w": {
"signal": "high_52w",
"weeks": 84,
"avg_cross_section": 2280.3,
"mean_ic": 0.1111,
"ic_t_stat": 6.41,
"ic_positive_pct": 76.2,
"mean_quintile_spread": -13.5104,
"reliable": true
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 83,
"avg_cross_section": 2277.5,
"mean_ic": 0.0663,
"ic_t_stat": 5.11,
"ic_positive_pct": 74.7,
"mean_quintile_spread": -10.1818,
"reliable": true
},
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0245,
"reliable": true
},
"trend_200": {
"signal": "trend_200",
"weeks": 85,
"avg_cross_section": 2308.1,
"mean_ic": 0.0546,
"ic_t_stat": 4.3,
"ic_positive_pct": 70.6,
"mean_quintile_spread": -12.4094,
"reliable": true
},
"mom_6_1": {
"signal": "mom_6_1",
"weeks": 88,
"avg_cross_section": 2375.7,
"mean_ic": 0.0493,
"ic_t_stat": 4.91,
"ic_positive_pct": 70.5,
"mean_quintile_spread": 3.4224,
"reliable": true
},
"mom_3_1": {
"signal": "mom_3_1",
"weeks": 90,
"avg_cross_section": 2425.6,
"mean_ic": 0.0363,
"ic_t_stat": 3.58,
"ic_positive_pct": 72.2,
"mean_quintile_spread": 0.9289,
"reliable": true
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 35,
"avg_cross_section": 496.7,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"ic_positive_pct": 62.9,
"mean_quintile_spread": 0.0154,
"reliable": true
},
"fip_id": {
"signal": "fip_id",
"weeks": 83,
"avg_cross_section": 2277.5,
"mean_ic": 0.0267,
"ic_t_stat": 3.25,
"ic_positive_pct": 67.5,
"mean_quintile_spread": -0.0017,
"reliable": true
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 2277.5,
"mean_ic": 0.0256,
"ic_t_stat": 2.21,
"ic_positive_pct": 65.1,
"mean_quintile_spread": 10.1373,
"reliable": true
},
"reversal_1m": {
"signal": "reversal_1m",
"weeks": 91,
"avg_cross_section": 2443.2,
"mean_ic": 0.003,
"ic_t_stat": 0.31,
"ic_positive_pct": 48.4,
"mean_quintile_spread": -7.0153,
"reliable": true
},
"vol_6m": {
"signal": "vol_6m",
"weeks": 88,
"avg_cross_section": 2375.7,
"mean_ic": -0.1226,
"ic_t_stat": -6.34,
"ic_positive_pct": 21.6,
"mean_quintile_spread": 1.4646,
"reliable": true
}
},
"pre_2021": {
"high_52w": {
"signal": "high_52w",
"weeks": 36,
"avg_cross_section": 1422.2,
"mean_ic": 0.0494,
"ic_t_stat": 2.0,
"ic_positive_pct": 63.9,
"mean_quintile_spread": -31.5146,
"reliable": true
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 36,
"avg_cross_section": 1424.8,
"mean_ic": 0.0413,
"ic_t_stat": 2.94,
"ic_positive_pct": 66.7,
"mean_quintile_spread": -23.3935,
"reliable": true
},
"trend_200": {
"signal": "trend_200",
"weeks": 38,
"avg_cross_section": 1440.9,
"mean_ic": 0.0322,
"ic_t_stat": 2.23,
"ic_positive_pct": 68.4,
"mean_quintile_spread": -27.5982,
"reliable": true
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 36,
"avg_cross_section": 1424.8,
"mean_ic": 0.0226,
"ic_t_stat": 1.68,
"ic_positive_pct": 69.4,
"mean_quintile_spread": 23.4097,
"reliable": true
},
"mom_3_1": {
"signal": "mom_3_1",
"weeks": 42,
"avg_cross_section": 1480.0,
"mean_ic": 0.0217,
"ic_t_stat": 1.84,
"ic_positive_pct": 73.8,
"mean_quintile_spread": 2.0077,
"reliable": true
},
"mom_6_1": {
"signal": "mom_6_1",
"weeks": 40,
"avg_cross_section": 1461.2,
"mean_ic": 0.0205,
"ic_t_stat": 1.63,
"ic_positive_pct": 65.0,
"mean_quintile_spread": 7.4512,
"reliable": true
},
"fip_id": {
"signal": "fip_id",
"weeks": 36,
"avg_cross_section": 1424.8,
"mean_ic": 0.0116,
"ic_t_stat": 1.35,
"ic_positive_pct": 58.3,
"mean_quintile_spread": -0.0118,
"reliable": true
},
"reversal_1m": {
"signal": "reversal_1m",
"weeks": 44,
"avg_cross_section": 1501.4,
"mean_ic": 0.0019,
"ic_t_stat": 0.15,
"ic_positive_pct": 50.0,
"mean_quintile_spread": -14.4299,
"reliable": true
},
"vol_6m": {
"signal": "vol_6m",
"weeks": 40,
"avg_cross_section": 1461.2,
"mean_ic": -0.056,
"ic_t_stat": -2.16,
"ic_positive_pct": 35.0,
"mean_quintile_spread": 3.1335,
"reliable": true
}
},
"post_2021": {
"high_52w": {
"signal": "high_52w",
"weeks": 48,
"avg_cross_section": 2914.5,
"mean_ic": 0.1375,
"ic_t_stat": 4.34,
"ic_positive_pct": 79.2,
"mean_quintile_spread": -0.0856,
"reliable": true
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 48,
"avg_cross_section": 2913.0,
"mean_ic": 0.0791,
"ic_t_stat": 3.64,
"ic_positive_pct": 77.1,
"mean_quintile_spread": -0.0858,
"reliable": true
},
"mom_6_1": {
"signal": "mom_6_1",
"weeks": 48,
"avg_cross_section": 3122.6,
"mean_ic": 0.0779,
"ic_t_stat": 4.36,
"ic_positive_pct": 75.0,
"mean_quintile_spread": -0.0781,
"reliable": true
},
"trend_200": {
"signal": "trend_200",
"weeks": 48,
"avg_cross_section": 3001.5,
"mean_ic": 0.0585,
"ic_t_stat": 2.68,
"ic_positive_pct": 72.9,
"mean_quintile_spread": -0.1129,
"reliable": true
},
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0245,
"reliable": true
},
"fip_id": {
"signal": "fip_id",
"weeks": 48,
"avg_cross_section": 2913.0,
"mean_ic": 0.0366,
"ic_t_stat": 2.91,
"ic_positive_pct": 70.8,
"mean_quintile_spread": -0.0151,
"reliable": true
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 35,
"avg_cross_section": 496.7,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"ic_positive_pct": 62.9,
"mean_quintile_spread": 0.0154,
"reliable": true
},
"mom_3_1": {
"signal": "mom_3_1",
"weeks": 48,
"avg_cross_section": 3234.2,
"mean_ic": 0.0291,
"ic_t_stat": 1.54,
"ic_positive_pct": 66.7,
"mean_quintile_spread": -0.0356,
"reliable": true
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 48,
"avg_cross_section": 2913.0,
"mean_ic": 0.0265,
"ic_t_stat": 1.37,
"ic_positive_pct": 66.7,
"mean_quintile_spread": -0.0405,
"reliable": true
},
"reversal_1m": {
"signal": "reversal_1m",
"weeks": 48,
"avg_cross_section": 3315.8,
"mean_ic": -0.0126,
"ic_t_stat": -0.72,
"ic_positive_pct": 45.8,
"mean_quintile_spread": -0.0387,
"reliable": true
},
"vol_6m": {
"signal": "vol_6m",
"weeks": 48,
"avg_cross_section": 3122.6,
"mean_ic": -0.1623,
"ic_t_stat": -4.94,
"ic_positive_pct": 22.9,
"mean_quintile_spread": 0.0059,
"reliable": true
}
}
},
"params": {
"step_days": 5,
"step_sessions": 5,
"entry_cadence": "weekly",
"signal_eval_cadence": "weekly",
"horizon_days": 30,
"min_lookback": 60,
"cost_per_side_pct": 0.1,
"target_model": "production_gtl",
"target_model_label": "Live GTL (production)",
"is_production_target_model": true,
"production_reentry_policy": "gate_reset",
"liquid_breadth_top_n": null,
"liquid_min_price": null,
"signal_eval_only": true
},
"tickers": 4655,
"generated_at_run": "2026-07-19T08:27:27.206022+00:00"
},
"verdict": "PENDING_HUMAN",
"verdict_detail": "Harness complete \u2014 human interprets relative IC / era stability. No production retune from this artifact.",
"human_next": "- Compare sector residual vs market residual across eras.\n- If pre-2021 IC collapses, park Task 1 wire-in.\n- Do not retune production knobs on deep history levels.",
"report_path": "reports/history-depth-20260719-103315.json"
}
+182
View File
@@ -0,0 +1,182 @@
# History-depth extension (Tier-1 alpha research)
**Status:** **RUN COMPLETE — human interpretation below.**
**Branch:** `research/earnings-gap-and-sue` (MacBook commit `f6e0ca7`)
**Authoritative artifact:** `reports/history-depth-20260719-103315.json`
**Production impact:** none. **Do not retune any production knob on deep history.**
---
## Pre-registration (locked before rebuild)
### Motivation
All current conclusions rest on ~35 non-overlapping weekly windows in essentially
one post-2021 regime. Extending history toward max Alpaca daily-bar depth adds
the 2018 vol shock and full 2020 crash (where the feed allows).
### Protocol
1. **Empirical coverage first** — bars per calendar year per symbol; document
where the feed thins out. Do **not** assume a uniform start date.
2. **Rebuild the research snapshot completely** from prod source + max history
per symbol (`Adjustment.SPLIT`, ~200 req/min pacing via existing extender).
3. **Race guard (rule 6)** — refuse analysis until completion manifest is
`complete=true` and live counts match.
4. **Re-run full signal harness** (all existing signals incl. sector residual /
SUE if present) on the extended window.
5. **Report per signal:** mean IC, t, window count, and **era split**
(pre-/post-2021) — diagnostic only, **not a tuning input**.
6. **Log prominently:** survivorship bias grows with depth (todays constituents
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
**relative** signal comparisons and IC stability, not levels.
7. **Do not retune** production knobs. If a knobs confirmation looks
overturned on deep history → report only; human decides.
### Success / interpretation (not promotion of a new signal)
| outcome | meaning |
|---|---|
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
| Any production knob looks worse deep | report; no auto-retune |
---
## Data provenance
| check | result |
|---|---|
| Snapshot | MacBook `research.sqlite` |
| Manifest `complete` | **true** (finished 2026-07-19T08:19Z) |
| Live counts match | yes — 4655 tickers / 6,609,926 OHLCV / 4149 rank_only |
| `history_days` | 5000 |
| fetch_ok / fail | 4152 / 0 |
| Race guard | **pass** |
> **SURVIVORSHIP BIAS:** todays constituents backfilled historically. Absolute
> Sharpe/CAGR levels on deep history are optimistic. Use **relative** signal IC
> comparisons and era stability only — not levels.
**Coverage JSON was empty in the auto-written doc** (harness-only phase after
rebuild). Manifest is the race-guard source of truth for this run.
Earlier MacBook files `history-depth-20260719-093853``095156` are intermediate
/ incomplete passes — **do not cite**. Only **103315** is authoritative.
---
## Results (authoritative: 103315)
### Full-window signal IC (broad research universe, deep bars)
| signal | mean_ic | t | weeks | avg_N | notes |
|---|---:|---:|---:|---:|---|
| high_52w | **0.111** | **6.41** | 84 | 2280 | strong on deep breadth |
| mom_12_1 | **0.066** | **5.11** | 83 | 2278 | raw momentum strong |
| trend_200 | 0.055 | 4.30 | 85 | 2308 | |
| mom_6_1 | 0.049 | 4.91 | 88 | 2376 | |
| mom_3_1 | 0.036 | 3.58 | 90 | 2426 | |
| fip_id | **+0.027** | **3.25** | 83 | 2278 | **sign flip vs prod fingerprint** |
| mom_12_1_resid | 0.026 | 2.21 | 83 | 2278 | market residual still + but weaker than raw |
| reversal_1m | ~0 | 0.31 | 91 | 2443 | dead |
| vol_6m | **0.123** | **6.34** | 88 | 2376 | low-vol anomaly strong |
| mom_12_1_sector_resid | 0.058 | 2.34 | **35** | **498** | **not deep-sample — see caveats** |
| mom_12_1_sector_demeaned | 0.034 | 1.32 | **35** | **497** | same short fingerprint |
### Era split (diagnostic only — not a tuning input)
| signal | pre-2021 IC / t / w / N | post-2021 IC / t / w / N |
|---|---|---|
| mom_12_1 | +0.041 / 2.94 / 36 / 1425 | +0.079 / 3.64 / 48 / 2913 |
| mom_12_1_resid | +0.023 / 1.68 / 36 / 1425 | +0.027 / 1.37 / 48 / 2913 |
| fip_id | +0.012 / 1.35 / 36 / 1425 | +0.037 / 2.91 / 48 / 2913 |
| vol_6m | 0.056 / 2.16 / 40 / 1461 | 0.162 / 4.94 / 48 / 3123 |
| high_52w | +0.049 / 2.0 / 36 / 1422 | +0.138 / 4.34 / 48 / 2915 |
| **sector_resid** | **absent** | 0.058 / 2.34 / 35 / 498 (short only) |
| **sector_demeaned** | **absent** | 0.034 / 1.32 / 35 / 497 (short only) |
---
## Critical caveats (must read)
### 1. Sector residual did **not** get a deep-history stress test
`mom_12_1_sector_resid` / `_demeaned` still show **exactly** the Task1 short-window
fingerprint: **35 weeks, N≈498, IC 0.0578, t 2.34**.
On the same run, raw `mom_12_1` has **83 weeks, N≈2278**. So depth worked for
price-only signals, but sector residual is still limited to the **~505 labeled
prod names × short factor calendar** (sector map only covers prod; and/or sector
ETF / two-factor path did not extend usable residual weeks).
**Pre-registered rule:** “Sector residual collapses pre-2021 → PARK Task 1
wire-in.” Pre-2021 sector residual is **absent** from the era table. That is a
**PARK**, not a confirmation of the short-window PROMOTE.
Do **not** claim “sector residual beats market residual on deep history” from
this table — the two rows are **not the same cross-section or window count**.
### 2. `fip_id` sign flips vs production fingerprint
| sample | fip mean IC | t |
|---|---:|---:|
| Prod 505, ~5y (fingerprint) | **0.045** | 2.91 |
| Research breadth, deep (this run) | **+0.027** | +3.25 |
This does **not** authorize resurrecting unconditional FIP as a book filter. It
confirms earlier PhaseB caution: FIP edge is **universe- and sample-dependent**.
Production display card can stay context-only. Nested lookbacks still not OOS.
### 3. Market residual vs raw momentum on deep breadth
On deep broad IC, **raw 121 (0.066 / t 5.1) ≫ market residual (0.026 / t 2.2)**.
That does **not** by itself overturn production residual ranking (book A/B was
on 505 + GTL gate, not pure factor IC), but it is a yellow flag for “residual is
always the better rank key” stories on broad history. **No auto-retune.**
### 4. Low-vol anomaly is the cleanest deep-history result
`vol_6m` IC 0.12 / t 6.3 full; stronger post-2021. Consistent sign across eras.
Production already blends **high**-vol (not low-vol) into the 80/20 rank — this
report does not change that without a separate A/B. Flag for human awareness only.
---
## Verdicts (vs pre-registration)
| question | verdict |
|---|---|
| Task 1 sector residual wire-in | **PARK** — no pre-2021 sector residual; deep-sample IC not established; short-window PROMOTE stays “human design only,” **not strengthened** by this run |
| Sector demean | still **DEAD** for promotion (t 1.32, short only) |
| SUE | **not re-scored here** (no `sue_latest` in harness table) — leave Task 2 **PARK** until full earnings backfill |
| fip unconditional book filter | remains **rejected / parked** despite sign flip on broad deep sample |
| Production residual / 80/20 / trail knobs | **no retune** from this report |
| Overall Task 3 | **COMPLETE as diagnostic** — payload is relative IC + caveats above |
---
## What a human must decide next
1. **Sector residual:** keep research-only until either
(a) sector ETF + sector map cover the full deep window **and** IC is re-run
with weeks ≫ 35 on a documented universe, or
(b) explicitly accept short-window-only evidence (weaker case).
2. **Do not** merge sector residual into production from this depth run.
3. **Do not** retune residual vs raw, FIP, or vol blend from these IC tables
without a pre-registered book A/B on the intended universe.
4. Optional follow-up: extend sector ETF history + sector labels to nasdaq_all,
re-run **only** sector residual IC on deep research.sqlite with race guard.
5. Optional: finish earnings backfill (48→506) and re-run SUE; depth alone did
not include SUE.
---
## Artifacts
| file | role |
|---|---|
| `reports/history-depth-20260719-103315.json` | **authoritative** |
| `reports/history-depth-20260719-103315.md` | companion dump |
| `reports/history-depth-20260719-093853``095156` | **ignore** (partial) |
@@ -0,0 +1,137 @@
{
"generated_at": "2026-07-19T14:07:37.458454",
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/research.sqlite",
"snapshot_meta": {
"prod_universe_n": 506,
"price_symbols_n": 4654,
"raw_candidates": 2389258,
"liquid_top_n": 1500,
"liquid_min_price": 5.0,
"short_start": "2022-07-01",
"long_start": "2016-07-01"
},
"strategy": {
"note": "Live production knobs \u2014 no modifications",
"momentum": "residual_12_1 gate 80",
"rank": "residual_high_vol_blend_80_20",
"fill_mode": "close",
"cost_per_side": 0.001,
"exit": {
"mode": "atr_trailing",
"trailing_pct": 12.0,
"atr_multiplier": 3.0,
"hold_days": 30
},
"max_positions": 10,
"risk_per_trade": 0.01,
"reentry": "gate_reset"
},
"arms": [
{
"id": "A_prod_4y_505",
"label": "Prod book \u00b7 ~4y \u00b7 505 only",
"start": "2022-07-01",
"universe": "prod_505",
"n_candidates": 81626,
"n_qualified_longs": 1448,
"fill_mode": "close",
"ranking_key": "residual_high_vol_blend_80_20_score",
"exit_policy": "atr_trail3",
"hold_days": 30,
"sharpe": 1.32,
"sharpe_se": 0.49,
"cagr_pct": 31.8,
"max_drawdown_pct": 18.9,
"total_return_pct": 204.7,
"calmar": 1.68,
"trades": 374,
"win_rate": 35.6,
"n_returns": 1009,
"psr": 0.9965,
"start_date": "2022-07-01",
"end_date": "2026-07-13",
"spy_return_pct": 96.5,
"final_equity": 30467.86
},
{
"id": "B_prod_4y_505_liquid",
"label": "Prod book \u00b7 ~4y \u00b7 505 + liquid top-1500",
"start": "2022-07-01",
"universe": "prod_plus_liquid",
"n_candidates": 267579,
"n_qualified_longs": 6587,
"fill_mode": "close",
"ranking_key": "residual_high_vol_blend_80_20_score",
"exit_policy": "atr_trail3",
"hold_days": 30,
"sharpe": 0.14,
"sharpe_se": 0.499,
"cagr_pct": -1.8,
"max_drawdown_pct": 55.3,
"total_return_pct": -7.1,
"calmar": -0.03,
"trades": 706,
"win_rate": 29.3,
"n_returns": 1013,
"psr": 0.6107,
"start_date": "2022-07-01",
"end_date": "2026-07-17",
"spy_return_pct": 95.0,
"final_equity": 9287.52
},
{
"id": "C_prod_2016_505",
"label": "Prod book \u00b7 since 2016-07 \u00b7 505 only",
"start": "2016-07-01",
"universe": "prod_505",
"n_candidates": 190179,
"n_qualified_longs": 2450,
"fill_mode": "close",
"ranking_key": "residual_high_vol_blend_80_20_score",
"exit_policy": "atr_trail3",
"hold_days": 30,
"sharpe": 0.88,
"sharpe_se": 0.314,
"cagr_pct": 16.7,
"max_drawdown_pct": 24.4,
"total_return_pct": 369.4,
"calmar": 0.68,
"trades": 763,
"win_rate": 36.7,
"n_returns": 2519,
"psr": 0.9975,
"start_date": "2016-07-01",
"end_date": "2026-07-13",
"spy_return_pct": 256.9,
"final_equity": 46938.66
},
{
"id": "D_prod_2016_505_liquid",
"label": "Prod book \u00b7 since 2016-07 \u00b7 505 + liquid top-1500",
"start": "2016-07-01",
"universe": "prod_plus_liquid",
"n_candidates": 649305,
"n_qualified_longs": 11551,
"fill_mode": "close",
"ranking_key": "residual_high_vol_blend_80_20_score",
"exit_policy": "atr_trail3",
"hold_days": 30,
"sharpe": -0.06,
"sharpe_se": 0.316,
"cagr_pct": -7.0,
"max_drawdown_pct": 73.9,
"total_return_pct": -52.0,
"calmar": -0.1,
"trades": 1567,
"win_rate": 28.0,
"n_returns": 2523,
"psr": 0.4287,
"start_date": "2016-07-01",
"end_date": "2026-07-17",
"spy_return_pct": 254.1,
"final_equity": 4800.5
}
],
"survivorship_banner": "Today's constituents backfilled. Relative arm comparison only.",
"pending_human": true
}
@@ -0,0 +1,54 @@
# Production book × universe × horizon — results
Generated: `2026-07-19T14:07:37.458454`
> Survivorship: today's constituents backfilled. Compare arms relatively; do not treat deep CAGR/Sharpe levels as deployable forecasts.
## Arms
| arm | universe | entry start | Sharpe | SE | CAGR % | max DD % | ret % | trades | qual longs | span |
|---|---|---|---:|---:|---:|---:|---:|---:|---:|---|
| A_prod_4y_505 | prod_505 | 2022-07-01 | 1.32 | 0.49 | 31.8 | 18.9 | 204.7 | 374 | 1448 | 2022-07-01→2026-07-13 |
| B_prod_4y_505_liquid | prod_plus_liquid | 2022-07-01 | 0.14 | 0.499 | -1.8 | 55.3 | -7.1 | 706 | 6587 | 2022-07-01→2026-07-17 |
| C_prod_2016_505 | prod_505 | 2016-07-01 | 0.88 | 0.314 | 16.7 | 24.4 | 369.4 | 763 | 2450 | 2016-07-01→2026-07-13 |
| D_prod_2016_505_liquid | prod_plus_liquid | 2016-07-01 | -0.06 | 0.316 | -7.0 | 73.9 | -52.0 | 1567 | 11551 | 2016-07-01→2026-07-17 |
## Config (production, unchanged)
```json
{
"note": "Live production knobs \u2014 no modifications",
"momentum": "residual_12_1 gate 80",
"rank": "residual_high_vol_blend_80_20",
"fill_mode": "close",
"cost_per_side": 0.001,
"exit": {
"mode": "atr_trailing",
"trailing_pct": 12.0,
"atr_multiplier": 3.0,
"hold_days": 30
},
"max_positions": 10,
"risk_per_trade": 0.01,
"reentry": "gate_reset"
}
```
## Snapshot
```json
{
"prod_universe_n": 506,
"price_symbols_n": 4654,
"raw_candidates": 2389258,
"liquid_top_n": 1500,
"liquid_min_price": 5.0,
"short_start": "2022-07-01",
"long_start": "2016-07-01"
}
```
PENDING_HUMAN — descriptive matrix only; no auto promotion.
JSON: `reports/prod-book-universe-horizon-20260719-140737.json`
@@ -0,0 +1,990 @@
{
"generated_at": "2026-07-19T11:33:19.102779",
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/research.sqlite",
"pre_registration": {
"iron_ic": 0.03,
"min_weeks_deep": 50,
"liquid_breadth": 1500,
"min_price": 5.0,
"rule": "PASS = |IC|>=0.03, +sign, reliable, weeks>=50, t>=resid on same CS, era signs both +"
},
"step1": {
"skipped": true,
"sanity": {
"passed": true,
"megacap": {
"AAPL": {
"symbol": "AAPL",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"MSFT": {
"symbol": "MSFT",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"JPM": {
"symbol": "JPM",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"XOM": {
"symbol": "XOM",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"JNJ": {
"symbol": "JNJ",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
}
},
"megacap_ok": true,
"megacap_reasons": [],
"feed_floor": "2016-01-04",
"spy_benchmark": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"old_shallow_floor": "2020-01-01",
"sector_etfs": {
"XLB": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLC": {
"n": 2030,
"min": "2018-06-19",
"max": "2026-07-17"
},
"XLE": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLF": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLI": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLK": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLP": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLRE": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLU": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLV": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLY": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
}
},
"sector_etfs_deep_count": 11,
"sector_etfs_ok": true,
"sector_etf_reasons": [],
"still_shallow_count": 2882,
"still_shallow_sample": [
"AACB",
"AACBR",
"AACBU",
"AACI",
"AACIU",
"AACIW",
"AACO",
"AACOU",
"AACOW",
"AACP",
"AACPR",
"AACPU",
"AACPW",
"AAPG",
"AARD",
"ABAT",
"ABCL",
"ABLV",
"ABLVW",
"ABNB"
],
"still_shallow_note": "Remaining 'shallow' names are mostly post-2017 IPOs/listings \u2014 expected, not a two-tier defect.",
"xlc_note": "XLC lists mid-2018 \u2192 Communication Services residual coverage from ~mid-2019.",
"feed_note": "Empirical Alpaca floor observed via SPY: 2016-01-04 (n=2649). Calendar history_days=5000 is a request cap, not a guarantee \u2014 sanity grades against the feed floor, not 5000 calendar days.",
"target_history_days": 5000
}
},
"harness": {
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"survivorship_banner": "SURVIVORSHIP BIAS: today's constituents backfilled. Relative IC only \u2014 not levels.",
"signal_eval": [
{
"signal": "high_52w",
"weeks": 84,
"avg_cross_section": 1499.2,
"mean_ic": 0.0761,
"ic_t_stat": 4.46,
"ic_positive_pct": 71.4,
"mean_quintile_spread": 0.0118,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2564.4,
"avg_eligible_pre_mask": 2047.0,
"mask_binds_pct": 94.0
},
{
"signal": "trend_200",
"weeks": 85,
"avg_cross_section": 1499.6,
"mean_ic": 0.0371,
"ic_t_stat": 2.63,
"ic_positive_pct": 62.4,
"mean_quintile_spread": -0.7839,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2583.0,
"avg_eligible_pre_mask": 2065.8,
"mask_binds_pct": 96.5
},
{
"signal": "mom_12_1",
"weeks": 83,
"avg_cross_section": 1499.4,
"mean_ic": 0.0355,
"ic_t_stat": 2.41,
"ic_positive_pct": 62.7,
"mean_quintile_spread": 0.0141,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2553.0,
"avg_eligible_pre_mask": 2045.4,
"mask_binds_pct": 95.2
},
{
"signal": "mom_12_1_sector_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0268,
"ic_t_stat": 1.69,
"ic_positive_pct": 60.2,
"mean_quintile_spread": 0.0126,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
{
"signal": "mom_3_1",
"weeks": 90,
"avg_cross_section": 1498.6,
"mean_ic": 0.0256,
"ic_t_stat": 2.24,
"ic_positive_pct": 61.1,
"mean_quintile_spread": -0.0048,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2679.7,
"avg_eligible_pre_mask": 2128.6,
"mask_binds_pct": 95.6
},
{
"signal": "reversal_1m",
"weeks": 89,
"avg_cross_section": 1499.0,
"mean_ic": 0.0156,
"ic_t_stat": 1.37,
"ic_positive_pct": 59.6,
"mean_quintile_spread": -0.7882,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2700.0,
"avg_eligible_pre_mask": 2085.8,
"mask_binds_pct": 94.5
},
{
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 1499.4,
"mean_ic": 0.0148,
"ic_t_stat": 1.02,
"ic_positive_pct": 57.8,
"mean_quintile_spread": 0.0181,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2553.0,
"avg_eligible_pre_mask": 2045.4,
"mask_binds_pct": 95.2
},
{
"signal": "mom_6_1",
"weeks": 88,
"avg_cross_section": 1499.3,
"mean_ic": 0.0101,
"ic_t_stat": 0.91,
"ic_positive_pct": 58.0,
"mean_quintile_spread": 0.0108,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2641.3,
"avg_eligible_pre_mask": 2098.8,
"mask_binds_pct": 95.5
},
{
"signal": "mom_12_1_sector_demeaned",
"weeks": 83,
"avg_cross_section": 483.2,
"mean_ic": 0.0076,
"ic_t_stat": 0.46,
"ic_positive_pct": 55.4,
"mean_quintile_spread": 0.0054,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 484.4,
"avg_eligible_pre_mask": 483.2,
"mask_binds_pct": 0.0
},
{
"signal": "fip_id",
"weeks": 83,
"avg_cross_section": 1499.4,
"mean_ic": -0.0184,
"ic_t_stat": -2.11,
"ic_positive_pct": 45.8,
"mean_quintile_spread": -0.0059,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2553.0,
"avg_eligible_pre_mask": 2045.4,
"mask_binds_pct": 95.2
},
{
"signal": "vol_6m",
"weeks": 88,
"avg_cross_section": 1499.3,
"mean_ic": -0.0704,
"ic_t_stat": -3.14,
"ic_positive_pct": 35.2,
"mean_quintile_spread": 0.0051,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2641.3,
"avg_eligible_pre_mask": 2098.8,
"mask_binds_pct": 95.5
}
],
"signal_eval_by_name": {
"high_52w": {
"signal": "high_52w",
"weeks": 84,
"avg_cross_section": 1499.2,
"mean_ic": 0.0761,
"ic_t_stat": 4.46,
"ic_positive_pct": 71.4,
"mean_quintile_spread": 0.0118,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2564.4,
"avg_eligible_pre_mask": 2047.0,
"mask_binds_pct": 94.0
},
"trend_200": {
"signal": "trend_200",
"weeks": 85,
"avg_cross_section": 1499.6,
"mean_ic": 0.0371,
"ic_t_stat": 2.63,
"ic_positive_pct": 62.4,
"mean_quintile_spread": -0.7839,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2583.0,
"avg_eligible_pre_mask": 2065.8,
"mask_binds_pct": 96.5
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 83,
"avg_cross_section": 1499.4,
"mean_ic": 0.0355,
"ic_t_stat": 2.41,
"ic_positive_pct": 62.7,
"mean_quintile_spread": 0.0141,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2553.0,
"avg_eligible_pre_mask": 2045.4,
"mask_binds_pct": 95.2
},
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0268,
"ic_t_stat": 1.69,
"ic_positive_pct": 60.2,
"mean_quintile_spread": 0.0126,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_3_1": {
"signal": "mom_3_1",
"weeks": 90,
"avg_cross_section": 1498.6,
"mean_ic": 0.0256,
"ic_t_stat": 2.24,
"ic_positive_pct": 61.1,
"mean_quintile_spread": -0.0048,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2679.7,
"avg_eligible_pre_mask": 2128.6,
"mask_binds_pct": 95.6
},
"reversal_1m": {
"signal": "reversal_1m",
"weeks": 89,
"avg_cross_section": 1499.0,
"mean_ic": 0.0156,
"ic_t_stat": 1.37,
"ic_positive_pct": 59.6,
"mean_quintile_spread": -0.7882,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2700.0,
"avg_eligible_pre_mask": 2085.8,
"mask_binds_pct": 94.5
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 1499.4,
"mean_ic": 0.0148,
"ic_t_stat": 1.02,
"ic_positive_pct": 57.8,
"mean_quintile_spread": 0.0181,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2553.0,
"avg_eligible_pre_mask": 2045.4,
"mask_binds_pct": 95.2
},
"mom_6_1": {
"signal": "mom_6_1",
"weeks": 88,
"avg_cross_section": 1499.3,
"mean_ic": 0.0101,
"ic_t_stat": 0.91,
"ic_positive_pct": 58.0,
"mean_quintile_spread": 0.0108,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2641.3,
"avg_eligible_pre_mask": 2098.8,
"mask_binds_pct": 95.5
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 83,
"avg_cross_section": 483.2,
"mean_ic": 0.0076,
"ic_t_stat": 0.46,
"ic_positive_pct": 55.4,
"mean_quintile_spread": 0.0054,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 484.4,
"avg_eligible_pre_mask": 483.2,
"mask_binds_pct": 0.0
},
"fip_id": {
"signal": "fip_id",
"weeks": 83,
"avg_cross_section": 1499.4,
"mean_ic": -0.0184,
"ic_t_stat": -2.11,
"ic_positive_pct": 45.8,
"mean_quintile_spread": -0.0059,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2553.0,
"avg_eligible_pre_mask": 2045.4,
"mask_binds_pct": 95.2
},
"vol_6m": {
"signal": "vol_6m",
"weeks": 88,
"avg_cross_section": 1499.3,
"mean_ic": -0.0704,
"ic_t_stat": -3.14,
"ic_positive_pct": 35.2,
"mean_quintile_spread": 0.0051,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2641.3,
"avg_eligible_pre_mask": 2098.8,
"mask_binds_pct": 95.5
}
},
"era_split": {
"era_split_date": "2021-01-01",
"note": "Diagnostic only \u2014 not a tuning input.",
"pre_2021": {
"high_52w": {
"signal": "high_52w",
"weeks": 36,
"avg_cross_section": 1498.1,
"mean_ic": 0.0535,
"ic_t_stat": 2.26,
"ic_positive_pct": 69.4,
"mean_quintile_spread": -0.0027,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1894.7,
"avg_eligible_pre_mask": 1670.6,
"mask_binds_pct": 86.1
},
"trend_200": {
"signal": "trend_200",
"weeks": 38,
"avg_cross_section": 1499.1,
"mean_ic": 0.0373,
"ic_t_stat": 1.99,
"ic_positive_pct": 65.8,
"mean_quintile_spread": -1.7748,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1913.9,
"avg_eligible_pre_mask": 1687.4,
"mask_binds_pct": 92.1
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 36,
"avg_cross_section": 1498.6,
"mean_ic": 0.037,
"ic_t_stat": 1.92,
"ic_positive_pct": 63.9,
"mean_quintile_spread": 0.0096,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1897.3,
"avg_eligible_pre_mask": 1673.1,
"mask_binds_pct": 88.9
},
"mom_3_1": {
"signal": "mom_3_1",
"weeks": 42,
"avg_cross_section": 1497.0,
"mean_ic": 0.0273,
"ic_t_stat": 2.03,
"ic_positive_pct": 64.3,
"mean_quintile_spread": -0.027,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1954.5,
"avg_eligible_pre_mask": 1717.3,
"mask_binds_pct": 90.5
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 36,
"avg_cross_section": 1498.6,
"mean_ic": 0.0216,
"ic_t_stat": 1.09,
"ic_positive_pct": 63.9,
"mean_quintile_spread": 0.0284,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1897.3,
"avg_eligible_pre_mask": 1673.1,
"mask_binds_pct": 88.9
},
"reversal_1m": {
"signal": "reversal_1m",
"weeks": 42,
"avg_cross_section": 1497.8,
"mean_ic": 0.0208,
"ic_t_stat": 1.45,
"ic_positive_pct": 64.3,
"mean_quintile_spread": -1.6753,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1976.6,
"avg_eligible_pre_mask": 1650.9,
"mask_binds_pct": 88.6
},
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 36,
"avg_cross_section": 460.7,
"mean_ic": 0.0149,
"ic_t_stat": 0.64,
"ic_positive_pct": 58.3,
"mean_quintile_spread": 0.0063,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 462.8,
"avg_eligible_pre_mask": 460.7,
"mask_binds_pct": 0.0
},
"mom_6_1": {
"signal": "mom_6_1",
"weeks": 40,
"avg_cross_section": 1498.5,
"mean_ic": 0.0082,
"ic_t_stat": 0.58,
"ic_positive_pct": 57.5,
"mean_quintile_spread": 0.0163,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1935.1,
"avg_eligible_pre_mask": 1702.2,
"mask_binds_pct": 90.0
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 36,
"avg_cross_section": 469.4,
"mean_ic": 0.0031,
"ic_t_stat": 0.13,
"ic_positive_pct": 55.6,
"mean_quintile_spread": 0.0009,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 471.5,
"avg_eligible_pre_mask": 469.4,
"mask_binds_pct": 0.0
},
"fip_id": {
"signal": "fip_id",
"weeks": 36,
"avg_cross_section": 1498.6,
"mean_ic": -0.0116,
"ic_t_stat": -0.93,
"ic_positive_pct": 52.8,
"mean_quintile_spread": -0.0037,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1897.3,
"avg_eligible_pre_mask": 1673.1,
"mask_binds_pct": 88.9
},
"vol_6m": {
"signal": "vol_6m",
"weeks": 40,
"avg_cross_section": 1498.5,
"mean_ic": -0.0219,
"ic_t_stat": -0.77,
"ic_positive_pct": 40.0,
"mean_quintile_spread": 0.0381,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1935.1,
"avg_eligible_pre_mask": 1702.2,
"mask_binds_pct": 90.0
}
},
"post_2021": {
"high_52w": {
"signal": "high_52w",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": 0.0831,
"ic_t_stat": 2.36,
"ic_positive_pct": 68.8,
"mean_quintile_spread": 0.0102,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3046.7,
"avg_eligible_pre_mask": 2325.3,
"mask_binds_pct": 100.0
},
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 48,
"avg_cross_section": 494.5,
"mean_ic": 0.033,
"ic_t_stat": 1.39,
"ic_positive_pct": 60.4,
"mean_quintile_spread": 0.0158,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 495.1,
"avg_eligible_pre_mask": 494.5,
"mask_binds_pct": 0.0
},
"mom_6_1": {
"signal": "mom_6_1",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": 0.0314,
"ic_t_stat": 1.51,
"ic_positive_pct": 58.3,
"mean_quintile_spread": 0.0109,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3214.4,
"avg_eligible_pre_mask": 2425.9,
"mask_binds_pct": 100.0
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": 0.0279,
"ic_t_stat": 1.14,
"ic_positive_pct": 62.5,
"mean_quintile_spread": 0.0206,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3045.2,
"avg_eligible_pre_mask": 2324.2,
"mask_binds_pct": 100.0
},
"trend_200": {
"signal": "trend_200",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": 0.0254,
"ic_t_stat": 1.03,
"ic_positive_pct": 60.4,
"mean_quintile_spread": 0.0064,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3113.5,
"avg_eligible_pre_mask": 2365.0,
"mask_binds_pct": 100.0
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 48,
"avg_cross_section": 493.5,
"mean_ic": 0.0071,
"ic_t_stat": 0.3,
"ic_positive_pct": 52.1,
"mean_quintile_spread": 0.0078,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 494.1,
"avg_eligible_pre_mask": 493.5,
"mask_binds_pct": 0.0
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": 0.0067,
"ic_t_stat": 0.29,
"ic_positive_pct": 54.2,
"mean_quintile_spread": 0.0149,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3045.2,
"avg_eligible_pre_mask": 2324.2,
"mask_binds_pct": 100.0
},
"mom_3_1": {
"signal": "mom_3_1",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": -0.0014,
"ic_t_stat": -0.06,
"ic_positive_pct": 52.1,
"mean_quintile_spread": -0.0028,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3305.8,
"avg_eligible_pre_mask": 2486.0,
"mask_binds_pct": 100.0
},
"reversal_1m": {
"signal": "reversal_1m",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": -0.013,
"ic_t_stat": -0.69,
"ic_positive_pct": 41.7,
"mean_quintile_spread": -0.0099,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3367.0,
"avg_eligible_pre_mask": 2486.9,
"mask_binds_pct": 100.0
},
"fip_id": {
"signal": "fip_id",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": -0.019,
"ic_t_stat": -1.56,
"ic_positive_pct": 41.7,
"mean_quintile_spread": -0.015,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3045.2,
"avg_eligible_pre_mask": 2324.2,
"mask_binds_pct": 100.0
},
"vol_6m": {
"signal": "vol_6m",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": -0.0943,
"ic_t_stat": -2.41,
"ic_positive_pct": 31.2,
"mean_quintile_spread": -0.0031,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3214.4,
"avg_eligible_pre_mask": 2425.9,
"mask_binds_pct": 100.0
}
}
},
"identical_subset_sector_cs": {
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0268,
"ic_t_stat": 1.69,
"ic_positive_pct": 60.2,
"mean_quintile_spread": 0.0126,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0251,
"ic_t_stat": 1.3,
"ic_positive_pct": 57.8,
"mean_quintile_spread": 0.011,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0192,
"ic_t_stat": 0.92,
"ic_positive_pct": 56.6,
"mean_quintile_spread": 0.0099,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 83,
"avg_cross_section": 479.0,
"mean_ic": 0.0063,
"ic_t_stat": 0.39,
"ic_positive_pct": 55.4,
"mean_quintile_spread": 0.0048,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 480.2,
"avg_eligible_pre_mask": 479.0,
"mask_binds_pct": 0.0
}
},
"mask_diagnostics": {
"reference_signal": "vol_6m",
"avg_cross_section": 1499.3,
"avg_raw_pool": 2641.3,
"avg_eligible_pre_mask": 2098.8,
"mask_binds_pct": 95.5,
"weeks": 88
},
"sector_map_size": 505,
"sector_etfs_loaded": [
"XLB",
"XLC",
"XLE",
"XLF",
"XLI",
"XLK",
"XLP",
"XLRE",
"XLU",
"XLV",
"XLY"
],
"spy_bars": 2649
},
"grade": {
"verdict": "FAIL",
"reason": "failed one or more pre-registered checks (see checks)",
"checks": {
"sector_row": {
"signal": "mom_12_1_sector_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0268,
"ic_t_stat": 1.69,
"ic_positive_pct": 60.2,
"mean_quintile_spread": 0.0126,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"resid_row_for_t": {
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0251,
"ic_t_stat": 1.3,
"ic_positive_pct": 57.8,
"mean_quintile_spread": 0.011,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"resid_t_source": "identical_subset",
"pre_2021": {
"signal": "mom_12_1_sector_resid",
"weeks": 36,
"avg_cross_section": 460.7,
"mean_ic": 0.0149,
"ic_t_stat": 0.64,
"ic_positive_pct": 58.3,
"mean_quintile_spread": 0.0063,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 462.8,
"avg_eligible_pre_mask": 460.7,
"mask_binds_pct": 0.0
},
"post_2021": {
"signal": "mom_12_1_sector_resid",
"weeks": 48,
"avg_cross_section": 494.5,
"mean_ic": 0.033,
"ic_t_stat": 1.39,
"ic_positive_pct": 60.4,
"mean_quintile_spread": 0.0158,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 495.1,
"avg_eligible_pre_mask": 494.5,
"mask_binds_pct": 0.0
},
"abs_mean_ic_ge_0_03": false,
"sign_positive": true,
"reliable": true,
"weeks_ge_50": true,
"weeks": 83,
"t_ge_resid_same_cs": true,
"sector_t": 1.69,
"resid_t": 1.3,
"era_both_present": true,
"era_sign_consistent_positive": true,
"pre_ic": 0.0149,
"post_ic": 0.033,
"avg_cross_section": 480.0
},
"headline": "Task 1 CLOSED \u2014 sector residual dead on deep evidence."
},
"pending_human": true,
"note": "Nothing merged into production. Thread ends at PASS/FAIL."
}
@@ -0,0 +1,341 @@
# Sector-residual deep test (masked, repaired snapshot)
Generated: `2026-07-19T11:33:19.102779`
> **SURVIVORSHIP BIAS: today's constituents backfilled. Relative IC only — not levels.**
## Pre-registered grade (mechanical)
**Verdict: FAIL**
Task 1 CLOSED — sector residual dead on deep evidence.
Reason: failed one or more pre-registered checks (see checks)
```json
{
"sector_row": {
"signal": "mom_12_1_sector_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0268,
"ic_t_stat": 1.69,
"ic_positive_pct": 60.2,
"mean_quintile_spread": 0.0126,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"resid_row_for_t": {
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0251,
"ic_t_stat": 1.3,
"ic_positive_pct": 57.8,
"mean_quintile_spread": 0.011,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"resid_t_source": "identical_subset",
"pre_2021": {
"signal": "mom_12_1_sector_resid",
"weeks": 36,
"avg_cross_section": 460.7,
"mean_ic": 0.0149,
"ic_t_stat": 0.64,
"ic_positive_pct": 58.3,
"mean_quintile_spread": 0.0063,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 462.8,
"avg_eligible_pre_mask": 460.7,
"mask_binds_pct": 0.0
},
"post_2021": {
"signal": "mom_12_1_sector_resid",
"weeks": 48,
"avg_cross_section": 494.5,
"mean_ic": 0.033,
"ic_t_stat": 1.39,
"ic_positive_pct": 60.4,
"mean_quintile_spread": 0.0158,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 495.1,
"avg_eligible_pre_mask": 494.5,
"mask_binds_pct": 0.0
},
"abs_mean_ic_ge_0_03": false,
"sign_positive": true,
"reliable": true,
"weeks_ge_50": true,
"weeks": 83,
"t_ge_resid_same_cs": true,
"sector_t": 1.69,
"resid_t": 1.3,
"era_both_present": true,
"era_sign_consistent_positive": true,
"pre_ic": 0.0149,
"post_ic": 0.033,
"avg_cross_section": 480.0
}
```
## Step-1 sanity
```json
{
"skipped": true,
"sanity": {
"passed": true,
"megacap": {
"AAPL": {
"symbol": "AAPL",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"MSFT": {
"symbol": "MSFT",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"JPM": {
"symbol": "JPM",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"XOM": {
"symbol": "XOM",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"JNJ": {
"symbol": "JNJ",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
}
},
"megacap_ok": true,
"megacap_reasons": [],
"feed_floor": "2016-01-04",
"spy_benchmark": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"old_shallow_floor": "2020-01-01",
"sector_etfs": {
"XLB": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLC": {
"n": 2030,
"min": "2018-06-19",
"max": "2026-07-17"
},
"XLE": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLF": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLI": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLK": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLP": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLRE": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLU": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLV": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLY": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
}
},
"sector_etfs_deep_count": 11,
"sector_etfs_ok": true,
"sector_etf_reasons": [],
"still_shallow_count": 2882,
"still_shallow_sample": [
"AACB",
"AACBR",
"AACBU",
"AACI",
"AACIU",
"AACIW",
"AACO",
"AACOU",
"AACOW",
"AACP",
"AACPR",
"AACPU",
"AACPW",
"AAPG",
"AARD",
"ABAT",
"ABCL",
"ABLV",
"ABLVW",
"ABNB"
],
"still_shallow_note": "Remaining 'shallow' names are mostly post-2017 IPOs/listings \u2014 expected, not a two-tier defect.",
"xlc_note": "XLC lists mid-2018 \u2192 Communication Services residual coverage from ~mid-2019.",
"feed_note": "Empirical Alpaca floor observed via SPY: 2016-01-04 (n=2649). Calendar history_days=5000 is a request cap, not a guarantee \u2014 sanity grades against the feed floor, not 5000 calendar days.",
"target_history_days": 5000
}
}
```
## Mask diagnostics
```json
{
"reference_signal": "vol_6m",
"avg_cross_section": 1499.3,
"avg_raw_pool": 2641.3,
"avg_eligible_pre_mask": 2098.8,
"mask_binds_pct": 95.5,
"weeks": 88
}
```
## Signal table (rows only — no narrative for non-sector signals)
| signal | mean_ic | t | weeks | avg_N | reliable |
|---|---:|---:|---:|---:|---|
| fip_id | -0.0184 | -2.11 | 83 | 1499.4 | True |
| high_52w | 0.0761 | 4.46 | 84 | 1499.2 | True |
| mom_12_1 | 0.0355 | 2.41 | 83 | 1499.4 | True |
| mom_12_1_resid | 0.0148 | 1.02 | 83 | 1499.4 | True |
| mom_12_1_sector_demeaned | 0.0076 | 0.46 | 83 | 483.2 | True |
| mom_12_1_sector_resid | 0.0268 | 1.69 | 83 | 480.0 | True |
| mom_3_1 | 0.0256 | 2.24 | 90 | 1498.6 | True |
| mom_6_1 | 0.0101 | 0.91 | 88 | 1499.3 | True |
| reversal_1m | 0.0156 | 1.37 | 89 | 1499.0 | True |
| trend_200 | 0.0371 | 2.63 | 85 | 1499.6 | True |
| vol_6m | -0.0704 | -3.14 | 88 | 1499.3 | True |
### Era split — mom_12_1_sector_resid only (for grade)
| era | IC | t | weeks | N |
|---|---:|---:|---:|---:|
| pre_2021 | 0.0149 | 0.64 | 36 | 460.7 |
| post_2021 | 0.033 | 1.39 | 48 | 494.5 |
### Identical-subset baselines (sector CS)
```json
{
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0268,
"ic_t_stat": 1.69,
"ic_positive_pct": 60.2,
"mean_quintile_spread": 0.0126,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0251,
"ic_t_stat": 1.3,
"ic_positive_pct": 57.8,
"mean_quintile_spread": 0.011,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0192,
"ic_t_stat": 0.92,
"ic_positive_pct": 56.6,
"mean_quintile_spread": 0.0099,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 83,
"avg_cross_section": 479.0,
"mean_ic": 0.0063,
"ic_t_stat": 0.39,
"ic_positive_pct": 55.4,
"mean_quintile_spread": 0.0048,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 480.2,
"avg_eligible_pre_mask": 479.0,
"mask_binds_pct": 0.0
}
}
```
## Status
PENDING_HUMAN beyond the mechanical PASS/FAIL above. Nothing merged into production docs or prod code.
JSON: `reports/sector-resid-deep-20260719-113319.json`
@@ -0,0 +1,412 @@
{
"generated_at": "2026-07-19T08:33:56.651229",
"snapshot_guard": {
"snapshot": "C:\\Workspace\\signal-platform\\backtest_snapshots\\prod.sqlite",
"manifest": null,
"manifest_ok": null,
"note": "No completion manifest (prod.sqlite is expected without one). Bar-count sanity still applied.",
"ticker_count": 506,
"ohlcv_row_count": 629263,
"bars_min_avg_max": {
"min": 14,
"avg": 1246.1,
"max": 1261
},
"ohlcv_date_range": {
"min": "2021-06-24",
"max": "2026-07-02"
},
"benchmark_prices": [
{
"symbol": "SPY",
"n": 1516,
"min": "2020-07-06",
"max": "2026-07-17"
},
{
"symbol": "XLB",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLC",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLE",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLF",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLI",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLK",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLP",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLRE",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLU",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLV",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLY",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
}
],
"missing_sector_etfs": []
},
"sector_coverage": {
"universe": 506,
"mapped": 505,
"mapped_pct": 99.8,
"with_etf": 505,
"missing": [
"RHM"
],
"by_sector": {
"Industrials": 81,
"Financials": 75,
"Information Technology": 72,
"Health Care": 58,
"Consumer Discretionary": 47,
"Consumer Staples": 34,
"Real Estate": 31,
"Utilities": 31,
"Materials": 26,
"Communication Services": 23,
"Energy": 22,
"Consumer Defensive": 2,
"Technology": 2,
"Financial Services": 1
}
},
"sector_map_path": "C:\\Workspace\\signal-platform\\data\\research\\ticker_sector_map.json",
"signal_eval": [
{
"signal": "vol_6m",
"weeks": 39,
"avg_cross_section": 498.2,
"mean_ic": 0.0609,
"ic_t_stat": 1.48,
"ic_positive_pct": 64.1,
"mean_quintile_spread": 0.0337,
"reliable": true
},
{
"signal": "mom_12_1_sector_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0245,
"reliable": true
},
{
"signal": "mom_12_1_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0552,
"ic_t_stat": 1.98,
"ic_positive_pct": 60.0,
"mean_quintile_spread": 0.0207,
"reliable": true
},
{
"signal": "mom_12_1",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0531,
"ic_t_stat": 1.61,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0206,
"reliable": true
},
{
"signal": "mom_12_1_sector_demeaned",
"weeks": 35,
"avg_cross_section": 496.7,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"ic_positive_pct": 62.9,
"mean_quintile_spread": 0.0154,
"reliable": true
},
{
"signal": "trend_200",
"weeks": 37,
"avg_cross_section": 497.9,
"mean_ic": 0.0161,
"ic_t_stat": 0.44,
"ic_positive_pct": 59.5,
"mean_quintile_spread": 0.006,
"reliable": true
},
{
"signal": "reversal_1m",
"weeks": 43,
"avg_cross_section": 498.7,
"mean_ic": 0.0059,
"ic_t_stat": 0.22,
"ic_positive_pct": 53.5,
"mean_quintile_spread": 0.0053,
"reliable": true
},
{
"signal": "mom_6_1",
"weeks": 39,
"avg_cross_section": 498.2,
"mean_ic": 0.0051,
"ic_t_stat": 0.21,
"ic_positive_pct": 56.4,
"mean_quintile_spread": 0.0087,
"reliable": true
},
{
"signal": "mom_3_1",
"weeks": 42,
"avg_cross_section": 498.5,
"mean_ic": -0.0064,
"ic_t_stat": -0.25,
"ic_positive_pct": 50.0,
"mean_quintile_spread": 0.0046,
"reliable": true
},
{
"signal": "high_52w",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": -0.0086,
"ic_t_stat": -0.26,
"ic_positive_pct": 54.3,
"mean_quintile_spread": -0.0088,
"reliable": true
},
{
"signal": "fip_id",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": -0.045,
"ic_t_stat": -2.91,
"ic_positive_pct": 25.7,
"mean_quintile_spread": -0.0168,
"reliable": true
}
],
"ic_grades": {
"mom_12_1_sector_resid": {
"promote_to_ab": true,
"checks": {
"sign_ok": true,
"abs_mean_ic_ge_0_03": true,
"reliable": true,
"t_ge_resid": true,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"resid_ic_t_stat": 1.98,
"weeks": 35
},
"reason": "clears iron rule and t \u2265 mom_12_1_resid \u2014 authorized for A/B only",
"row": {
"signal": "mom_12_1_sector_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0245,
"reliable": true
}
},
"mom_12_1_sector_demeaned": {
"promote_to_ab": false,
"checks": {
"sign_ok": true,
"abs_mean_ic_ge_0_03": true,
"reliable": true,
"t_ge_resid": false,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"resid_ic_t_stat": 1.98,
"weeks": 35
},
"reason": "does not clear pre-registered IC promotion bar",
"row": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 35,
"avg_cross_section": 496.7,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"ic_positive_pct": 62.9,
"mean_quintile_spread": 0.0154,
"reliable": true
}
}
},
"portfolio_ab": {
"signal": "mom_12_1_sector_resid",
"ranking_key": "residual_high_vol_blend_80_20_score",
"fill_mode": "close",
"validation_split": "2024-07-01",
"control": {
"label": "control_mom_12_1_resid",
"n_qualified_longs": 1086,
"windows": {
"train": {
"sharpe": 1.3,
"sharpe_se": 0.685,
"cagr_pct": 29.2,
"max_drawdown_pct": 21.4,
"total_return_pct": 70.9,
"trades": 176,
"win_rate_pct": null,
"avg_r": null,
"n_returns": 525,
"return_skew": 0.3722,
"return_kurtosis": 4.6208,
"psr": 0.971
},
"validation": {
"sharpe": 2.92,
"sharpe_se": 0.709,
"cagr_pct": 76.3,
"max_drawdown_pct": 11.7,
"total_return_pct": 210.7,
"trades": 150,
"win_rate_pct": null,
"avg_r": null,
"n_returns": 501,
"return_skew": 0.1734,
"return_kurtosis": 4.4625,
"psr": 1.0
},
"full": {
"sharpe": 2.09,
"sharpe_se": 0.497,
"cagr_pct": 51.6,
"max_drawdown_pct": 21.4,
"total_return_pct": 424.6,
"trades": 322,
"win_rate_pct": null,
"avg_r": null,
"n_returns": 1000,
"return_skew": 0.2686,
"return_kurtosis": 4.5653,
"psr": 1.0
}
}
},
"treatment": {
"label": "treatment_mom_12_1_sector_resid",
"n_qualified_longs": 1210,
"windows": {
"train": {
"sharpe": 1.57,
"sharpe_se": 0.677,
"cagr_pct": 35.5,
"max_drawdown_pct": 19.8,
"total_return_pct": 90.0,
"trades": 176,
"win_rate_pct": null,
"avg_r": null,
"n_returns": 530,
"return_skew": 0.466,
"return_kurtosis": 4.4413,
"psr": 0.99
},
"validation": {
"sharpe": 2.57,
"sharpe_se": 0.701,
"cagr_pct": 66.3,
"max_drawdown_pct": 14.8,
"total_return_pct": 176.4,
"trades": 163,
"win_rate_pct": null,
"avg_r": null,
"n_returns": 501,
"return_skew": 0.3003,
"return_kurtosis": 4.4305,
"psr": 0.9999
},
"full": {
"sharpe": 2.09,
"sharpe_se": 0.491,
"cagr_pct": 51.0,
"max_drawdown_pct": 19.8,
"total_return_pct": 421.3,
"trades": 337,
"win_rate_pct": null,
"avg_r": null,
"n_returns": 1005,
"return_skew": 0.4066,
"return_kurtosis": 4.3627,
"psr": 1.0
}
}
},
"promotion": {
"promote": true,
"checks": {
"validation_sharpe_ge_control_minus_half_se": true,
"full_sharpe_not_worse": true,
"full_maxdd_not_worse": true,
"control_validation_sharpe": 2.92,
"treatment_validation_sharpe": 2.57,
"se_used": 0.701,
"control_full_sharpe": 2.09,
"treatment_full_sharpe": 2.09,
"control_full_maxdd": 21.4,
"treatment_full_maxdd": 19.8
},
"reason": "clears pre-registered A/B bar \u2014 human decides wire-in"
}
},
"verdict": "PROMOTE",
"verdict_detail": "mom_12_1_sector_resid cleared IC + A/B bars. Human must design wire-in; do not ship from this branch.",
"human_next": "- Approve or reject production residual swap vs dual-signal design.\n- If sector-cap arm ran, review tail-trim diagnostics before any cap.",
"report_path": "reports/sector-residual-20260719-083356.json",
"pre_registration": {
"iron_ic_bar": 0.03,
"validation_split": "2024-07-01",
"fill_mode": "close",
"cost_per_side": 0.001,
"ab_rule": "val Sharpe >= control - 0.5*SE; full Sharpe & maxDD not worse"
}
}
+237
View File
@@ -0,0 +1,237 @@
# Sector-residual momentum (Tier-1 alpha research)
**Status:** **PROMOTE (to human design decision only)** — IC + A/B bars cleared; **do not ship**.
**Branch:** `research/sector-residual-momentum`
**Production impact:** none. Local research only. No scheduler / gate / prod-config changes.
**Artifacts:** `reports/sector-residual-20260719-083356.json` (+ companion `.md`)
---
## Pre-registration (locked before first research run)
### Hypothesis
Residualizing 121 momentum against the sector, not only the market, reduces
factor volatility at similar return (Blitz / Huij / Martens-style) → higher
Sharpe on the production book when the residual replaces market-only residual
as the momentum leg.
### Signals (candidates)
| signal | construction |
|---|---|
| `mom_12_1_sector_resid` | Two-factor residual vs SPY + tickers sector ETF. Same window as `mom_12_1_resid`: ≥100 daily obs, 252-bar lookback, 21-bar skip; two-factor OLS betas **without intercept**; cumulate residual returns over the formation window. |
| `mom_12_1_sector_demeaned` | Plain `mom_12_1` minus the **cross-sectional** mean of `mom_12_1` within the same GICS sector that week (≥2 names in sector). No regression. |
### Baselines (same run, same cross-sections — iron rule)
Always report side-by-side with:
- `mom_12_1`
- `mom_12_1_resid`
Computed on the **identical** weekly non-overlapping cross-sections in this run.
Never compare against IC numbers from another report.
### Iron rule (IC harness)
Source of truth: `_signal_evaluation` in `app/services/backtest_service.py`.
- Mean weekly Spearman IC on **non-overlapping** weekly windows
- Bar: \|mean IC\| ≥ ~0.03, **consistent positive sign**, `reliable: true` (≥ 12 windows)
### Promotion to portfolio A/B (candidate → book)
A candidate promotes to A/B **only if**:
1. It clears the iron-rule bar **and**
2. Its IC **t-stat ≥** that of `mom_12_1_resid` on the same cross-sections.
### Portfolio A/B grading (if and only if IC promotion fires)
- Swap candidate in as the **momentum leg** of the production 80/20 momentum/vol
rank **and** as the gate-percentile signal.
- `fill_mode=close`, `COST_PER_SIDE = 0.001`, full config otherwise unchanged.
- Validation window = entries ≥ **2024-07-01** (call it **validation**, not
holdout — contaminated by prior experiments).
- Pre-registered promotion bar:
- validation Sharpe ≥ control 0.5·SE
- full-period Sharpe and max-DD **not worse** than control
- Report Lo / Mertens-adjusted SEs.
### Optional sector-cap sub-experiment
Only if labels are in **and** A/B ran: max **3** positions per sector in the
10-slot book. Same A/B grading. **Tail-trim presumption of guilt** (rule 4):
report entry counts and both tails of the R distribution. Rising win rate with
falling Sharpe/CAGR = red flag → do not promote.
**This run:** sector-cap arm **not executed** (optional; A/B unconstrained book
only). Can be a human-approved follow-up.
### Verdict labels
| label | meaning |
|---|---|
| **PROMOTE** | Clears pre-registered bar; human decides next (wire design separate) |
| **PARK** | Inconclusive / weak; keep machinery, no book change |
| **DEAD** | Failed iron rule or worse than residual baseline with clear sign |
### Explicit non-goals
- No production deploy from this doc
- Do not resurrect: take-profit exits, EV gate, regime entry-blocking,
inverse-vol sizing, gap-caps, unconditional FIP filter
---
## Data provenance
### Snapshot race guard
| check | result |
|---|---|
| Snapshot path | `backtest_snapshots/prod.sqlite` |
| Manifest | none (expected for prod snapshot); bar-count sanity applied |
| Tickers / OHLCV | **506** / **629,263** |
| Bars min / avg / max | 14 / 1246.1 / 1261 |
| OHLCV range | 2021-06-24 → 2026-07-02 |
| Partial-build red flags | none (avg bars healthy) |
Integrity fingerprint on same run: `fip_id` mean IC **0.045** / t **2.91**
(35 weeks, N≈498) — matches the established prod fingerprint.
### Sector labels
| source | count |
|---|---:|
| Public S&P 500 GICS CSV | 496 newly filled |
| FMP profile requests | 10 (all missing after CSV) |
| Mapped / universe | **505 / 506 (99.8%)** |
| With mappable ETF | 505 |
| Still missing | **RHM** only |
Persist path: `data/research/ticker_sector_map.json`.
FMP aliases (`Technology`, `Consumer Defensive`, `Financial Services`) map to
SPDRs via the alias table in `app/services/sector_map.py`.
### Sector ETFs in `benchmark_prices` (auxiliary only — not tradable)
| symbol | bars | min date | max date |
|---|---:|---|---|
| SPY | 1516 | 2020-07-06 | 2026-07-17 |
| XLB…XLY (11) | 1512 each | 2020-07-10 | 2026-07-17 |
Fetched via Alpaca `Adjustment.SPLIT` into **`benchmark_prices`** (same table as
SPY) so they never enter the ticker universe or candidate replay.
---
## Results
Generated: `2026-07-19T08:33:56`
### IC harness (identical cross-sections, production 506-name universe)
| signal | mean_ic | ic_t_stat | weeks | avg_N | reliable | ic+_pct | quintile spread |
|---|---:|---:|---:|---:|---|---:|---:|
| **mom_12_1_sector_resid** | **0.0578** | **2.34** | 35 | 497.7 | true | 65.7 | 0.0245 |
| mom_12_1_resid | 0.0552 | 1.98 | 35 | 497.7 | true | 60.0 | 0.0207 |
| mom_12_1 | 0.0531 | 1.61 | 35 | 497.7 | true | 65.7 | 0.0206 |
| mom_12_1_sector_demeaned | 0.0340 | 1.32 | 35 | 496.7 | true | 62.9 | 0.0154 |
### IC promotion grades
| candidate | iron rule | t ≥ resid | promote_to_ab |
|---|---|---|---|
| `mom_12_1_sector_resid` | pass (IC 0.058, +sign, reliable) | **yes** (2.34 ≥ 1.98) | **yes** |
| `mom_12_1_sector_demeaned` | pass (IC 0.034, +sign, reliable) | **no** (1.32 < 1.98) | **no** |
### Portfolio A/B — `mom_12_1_sector_resid` as residual leg
Config: production 80/20 residual/high-vol rank + gate percentile, `fill_mode=close`,
cost 10 bps/side, ATR trail / gate-reset re-entry as live. Validation split
2024-07-01.
| window | arm | Sharpe | Sharpe SE (Mertens) | CAGR % | max DD % | trades | n_days |
|---|---|---:|---:|---:|---:|---:|---:|
| train | control (resid) | 1.30 | 0.685 | 29.2 | 21.4 | 176 | 525 |
| train | treatment (sector resid) | **1.57** | 0.677 | **35.5** | **19.8** | 176 | 530 |
| validation | control | **2.92** | 0.709 | **76.3** | **11.7** | 150 | 501 |
| validation | treatment | 2.57 | 0.701 | 66.3 | 14.8 | 163 | 501 |
| full | control | 2.09 | 0.497 | 51.6 | 21.4 | 322 | 1000 |
| full | treatment | 2.09 | 0.491 | 51.0 | **19.8** | 337 | 1005 |
**Pre-registered A/B checks**
| check | result |
|---|---|
| val Sharpe ≥ control 0.5·SE | **pass** (2.57 ≥ 2.92 0.5×0.701 = 2.5695) — **knife-edge** |
| full Sharpe not worse | **pass** (2.09 = 2.09) |
| full max DD not worse | **pass** (19.8 < 21.4) |
Qualified long candidates: control 1086 vs treatment 1210 (sector residual
gates a slightly larger set).
---
## Verdict
| signal | verdict | note |
|---|---|---|
| **`mom_12_1_sector_resid`** | **PROMOTE → human wire-in decision** | IC modestly beats market residual; A/B clears pre-reg bar narrowly. **Do not ship from this branch.** |
| **`mom_12_1_sector_demeaned`** | **DEAD** (for promotion) | Iron-rule IC magnitude ok, but t-stat loses to `mom_12_1_resid`. Cheap variant not competitive. |
### Read carefully (for the human)
1. **IC edge is real but small.** Sector residual IC 0.0578 / t 2.34 vs market
residual 0.0552 / t 1.98 on the **same** 35 windows — better consistency
(ic+ 65.7% vs 60%) and slightly higher mean, not a different factor class.
2. **A/B is not a clear Sharpe win.** Full-period Sharpe is flat (2.09).
Validation Sharpe is **lower** than control (2.57 vs 2.92) and only clears
the pre-registered “within 0.5 SE” cushion by ~0.001. Train improves;
validation worsens — classic regime-split noise on ~2 years.
3. **Risk side is friendly.** Full max DD improves (19.8% vs 21.4%); train DD
also better. Matches the “lower factor vol” half of the hypothesis more than
the “higher Sharpe” half on this window.
4. **Survivorship / short history.** Same caveats as all current research:
todays constituents, ~35 independent weekly windows, one post-2021 regime
dominant. Task 3 (history depth) should re-check IC stability before any
wire-in.
5. **Not shipped.** Machinery lives on the research branch; production residual
path is untouched.
---
## What a human must decide next
1. **Accept or reject** replacing `mom_12_1_resid` with `mom_12_1_sector_resid`
as the production residual (gate + 80/20 mom leg), **or** keep market residual
and treat sector residual as research-only.
2. If leaning accept: require **Task 3 history-depth** confirmation (IC era split
pre/post-2021) before any production PR.
3. Optional: run **sector-cap ≤3** A/B with full tail diagnostics (not run here).
4. **Do not** merge this verdict into main strategy docs without review.
5. Wire-in design (live sector map refresh, ETF series ops, fallback when sector
missing) is a **separate** approved engineering step.
---
## Implementation notes (research machinery)
| piece | role |
|---|---|
| `app/services/sector_map.py` | GICS→ETF map, symbol normalise, JSON load/save |
| `app/services/backtest_service.py` | multi-factor residual; `mom_12_1_sector_resid` in `_signal_values`; demean inject |
| `scripts/build_ticker_sector_map.py` | SP500 CSV + FMP gap fill |
| `scripts/fetch_sector_etfs_to_snapshot.py` | Alpaca → snapshot `benchmark_prices` |
| `scripts/run_sector_residual_research.py` | race guard, IC, optional A/B, reports |
| `data/research/ticker_sector_map.json` | persisted labels (research only) |
---
## Artifacts
- JSON: `reports/sector-residual-20260719-083356.json`
- MD copy: `reports/sector-residual-20260719-083356.md`
+515
View File
@@ -0,0 +1,515 @@
"""Bulk-only historical earnings backfill for a local SQLite snapshot.
The job uses FMP's date-range earnings-calendar endpoint. One request covers all
symbols in a date window; per-symbol endpoints are intentionally not available
in this task runner. Successful windows are committed independently so a later
run resumes after a daily quota boundary without repeating completed windows.
Example:
python scripts/backfill_earnings_events.py --snapshot backtest_snapshots/prod.sqlite \
--from-date 2012-01-01 --window-days 30 --limit 250
"""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import httpx
from sqlalchemy import create_engine, text
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.ssl_bootstrap import bootstrap_ssl # noqa: E402
bootstrap_ssl()
FMP_STABLE = "https://financialmodelingprep.com/stable"
EVENTS_DDL = """
CREATE TABLE IF NOT EXISTS earnings_events (
id INTEGER PRIMARY KEY,
symbol TEXT NOT NULL,
announce_date TEXT NOT NULL,
announce_time TEXT,
eps_estimate REAL,
eps_actual REAL,
revenue_estimate REAL,
revenue_actual REAL,
source TEXT NOT NULL,
fetched_at TEXT NOT NULL,
UNIQUE(symbol, announce_date)
)
"""
META_DDL = """
CREATE TABLE IF NOT EXISTS earnings_backfill_meta (
symbol TEXT PRIMARY KEY,
status TEXT NOT NULL,
n_events INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
note TEXT
)
"""
WINDOW_DDL = """
CREATE TABLE IF NOT EXISTS earnings_backfill_windows (
from_date TEXT NOT NULL,
to_date TEXT NOT NULL,
status TEXT NOT NULL,
requests INTEGER NOT NULL DEFAULT 0,
rows_raw INTEGER NOT NULL DEFAULT 0,
rows_universe INTEGER NOT NULL DEFAULT 0,
duplicate_rows INTEGER NOT NULL DEFAULT 0,
restated_rows INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
note TEXT,
PRIMARY KEY(from_date, to_date)
)
"""
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--snapshot", default="backtest_snapshots/prod.sqlite")
parser.add_argument("--from-date", default="2012-01-01")
parser.add_argument("--to-date", default=None)
parser.add_argument("--window-days", type=int, default=30)
parser.add_argument("--limit", type=int, default=250)
parser.add_argument("--sleep", type=float, default=0.35)
parser.add_argument(
"--refetch-windows",
action="store_true",
help="Re-fetch date windows already logged as done.",
)
return parser.parse_args()
def _ensure_tables(engine) -> None:
with engine.begin() as conn:
conn.execute(text(EVENTS_DDL))
conn.execute(text(META_DDL))
conn.execute(text(WINDOW_DDL))
def _number(value: Any) -> float | None:
if value is None or value == "":
return None
try:
result = float(value)
except (TypeError, ValueError):
return None
return result if math.isfinite(result) else None
def _normalise_session(value: Any) -> str | None:
if value is None:
return None
cleaned = str(value).strip().lower().replace("_", " ").replace("-", " ")
aliases = {
"bmo": "bmo",
"before market open": "bmo",
"before open": "bmo",
"amc": "amc",
"after market close": "amc",
"after close": "amc",
"during market hours": "during",
"dmh": "during",
}
return aliases.get(cleaned, cleaned or None)
def _parse_bulk_item(item: dict) -> dict | None:
symbol = str(item.get("symbol") or "").strip().upper().replace(".", "-")
raw_date = item.get("date") or item.get("earningsDate")
if not symbol or not raw_date:
return None
return {
"symbol": symbol,
"announce_date": str(raw_date)[:10],
"announce_time": _normalise_session(
item.get("time") or item.get("announceTime")
),
"eps_estimate": _number(
item.get("epsEstimated")
if item.get("epsEstimated") is not None
else item.get("estimatedEarning")
),
"eps_actual": _number(
item.get("epsActual")
if item.get("epsActual") is not None
else item.get("eps")
),
"revenue_estimate": _number(item.get("revenueEstimated")),
"revenue_actual": _number(item.get("revenueActual")),
}
def _windows(start: date, end: date, window_days: int) -> list[tuple[date, date]]:
if window_days < 1:
raise ValueError("window_days must be positive")
result: list[tuple[date, date]] = []
cursor = start
while cursor <= end:
window_end = min(end, cursor + timedelta(days=window_days - 1))
result.append((cursor, window_end))
cursor = window_end + timedelta(days=1)
return result
def _dedupe_bulk_rows(rows: list[dict]) -> tuple[list[dict], int, int]:
"""Prefer the most complete duplicate; use the later row as the tie-break."""
fields = (
"announce_time",
"eps_estimate",
"eps_actual",
"revenue_estimate",
"revenue_actual",
)
chosen: dict[tuple[str, str], dict] = {}
duplicate_extras = 0
restated = 0
for row in rows:
key = (str(row["symbol"]), str(row["announce_date"]))
previous = chosen.get(key)
if previous is None:
chosen[key] = row
continue
duplicate_extras += 1
if any(
previous.get(field) is not None
and row.get(field) is not None
and previous.get(field) != row.get(field)
for field in fields
):
restated += 1
previous_score = sum(previous.get(field) is not None for field in fields)
new_score = sum(row.get(field) is not None for field in fields)
if new_score >= previous_score:
chosen[key] = row
return list(chosen.values()), duplicate_extras, restated
def _upsert_events(conn, rows: list[dict]) -> int:
if not rows:
return 0
fetched_at = datetime.now(timezone.utc).isoformat()
statement = text(
"""
INSERT INTO earnings_events (
symbol, announce_date, announce_time, eps_estimate, eps_actual,
revenue_estimate, revenue_actual, source, fetched_at
) VALUES (
:symbol, :announce_date, :announce_time, :eps_estimate, :eps_actual,
:revenue_estimate, :revenue_actual, 'fmp_earnings_calendar', :fetched_at
)
ON CONFLICT(symbol, announce_date) DO UPDATE SET
announce_time=COALESCE(excluded.announce_time, earnings_events.announce_time),
eps_estimate=COALESCE(excluded.eps_estimate, earnings_events.eps_estimate),
eps_actual=COALESCE(excluded.eps_actual, earnings_events.eps_actual),
revenue_estimate=COALESCE(excluded.revenue_estimate, earnings_events.revenue_estimate),
revenue_actual=COALESCE(excluded.revenue_actual, earnings_events.revenue_actual),
source=excluded.source,
fetched_at=excluded.fetched_at
"""
)
conn.execute(statement, [{**row, "fetched_at": fetched_at} for row in rows])
return len(rows)
async def _fetch_bulk_window(
client: httpx.AsyncClient, api_key: str, start: date, end: date
) -> tuple[list[dict], int, str | None]:
response = await client.get(
f"{FMP_STABLE}/earnings-calendar",
params={"from": start.isoformat(), "to": end.isoformat(), "apikey": api_key},
)
if response.status_code in (402, 403):
return [], response.status_code, "bulk_endpoint_unavailable"
if response.status_code == 429:
return [], response.status_code, "daily_limit_reached"
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
return [], response.status_code, f"unexpected_payload:{type(payload).__name__}"
rows = []
for item in payload:
if isinstance(item, dict):
parsed = _parse_bulk_item(item)
if parsed:
rows.append(parsed)
return rows, response.status_code, None
def _write_window_status(
engine,
*,
start: date,
end: date,
status: str,
raw_n: int = 0,
universe_n: int = 0,
duplicate_n: int = 0,
restated_n: int = 0,
note: str | None = None,
) -> None:
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO earnings_backfill_windows(
from_date, to_date, status, requests, rows_raw, rows_universe,
duplicate_rows, restated_rows, updated_at, note
) VALUES (:a, :b, :status, 1, :raw, :uni, :dup, :rest, :now, :note)
ON CONFLICT(from_date, to_date) DO UPDATE SET
status=excluded.status,
requests=earnings_backfill_windows.requests + 1,
rows_raw=excluded.rows_raw,
rows_universe=excluded.rows_universe,
duplicate_rows=excluded.duplicate_rows,
restated_rows=excluded.restated_rows,
updated_at=excluded.updated_at,
note=excluded.note
"""
),
{
"a": start.isoformat(),
"b": end.isoformat(),
"status": status,
"raw": raw_n,
"uni": universe_n,
"dup": duplicate_n,
"rest": restated_n,
"now": datetime.now(timezone.utc).isoformat(),
"note": note,
},
)
async def _main() -> None:
args = _parse_args()
snapshot = Path(args.snapshot)
if not snapshot.exists():
raise SystemExit(f"Snapshot not found: {snapshot}")
from app.config import settings
if not settings.fmp_api_key:
raise SystemExit("FMP_API_KEY required")
start = date.fromisoformat(args.from_date)
end = date.fromisoformat(args.to_date) if args.to_date else date.today()
if start > end:
raise SystemExit("--from-date must not be after --to-date")
engine = create_engine(f"sqlite:///{snapshot.resolve().as_posix()}", future=True)
_ensure_tables(engine)
all_windows = _windows(start, end, int(args.window_days))
with engine.connect() as conn:
symbols = [
str(row[0]).upper().replace(".", "-")
for row in conn.execute(text("SELECT symbol FROM tickers ORDER BY symbol"))
]
completed = {
(str(row[0]), str(row[1]))
for row in conn.execute(
text(
"SELECT from_date, to_date FROM earnings_backfill_windows "
"WHERE status='done'"
)
)
}
pending = [
window
for window in all_windows
if args.refetch_windows
or (window[0].isoformat(), window[1].isoformat()) not in completed
]
universe = set(symbols)
print(f"Snapshot: {snapshot}")
print(f"Universe: {len(symbols)} symbols")
print(f"Window: {start} -> {end}")
print(
f"Bulk windows: {len(all_windows)} total; "
f"{len(all_windows) - len(pending)} done; {len(pending)} pending"
)
print("Provider: FMP bulk earnings-calendar only")
requests_this_run = 0
rows_upserted = 0
duplicate_rows = 0
restated_rows = 0
stop_note: str | None = None
async with httpx.AsyncClient(timeout=60.0) as client:
for index, (window_start, window_end) in enumerate(pending, 1):
if requests_this_run >= int(args.limit):
stop_note = "request_budget_exhausted"
break
try:
raw_rows, status_code, error = await _fetch_bulk_window(
client, settings.fmp_api_key, window_start, window_end
)
except Exception as exc:
raw_rows, status_code = [], 0
error = f"request_error:{type(exc).__name__}:{exc}"
requests_this_run += 1
if error:
_write_window_status(
engine,
start=window_start,
end=window_end,
status="error",
note=f"http={status_code} {error}"[:300],
)
stop_note = error
print(
f"STOP {window_start}..{window_end}: {error} "
f"(http={status_code}, request={requests_this_run})"
)
break
in_universe = [row for row in raw_rows if row["symbol"] in universe]
deduped, duplicate_n, restated_n = _dedupe_bulk_rows(in_universe)
with engine.begin() as conn:
rows_upserted += _upsert_events(conn, deduped)
_write_window_status(
engine,
start=window_start,
end=window_end,
status="done",
raw_n=len(raw_rows),
universe_n=len(deduped),
duplicate_n=duplicate_n,
restated_n=restated_n,
note="bulk",
)
duplicate_rows += duplicate_n
restated_rows += restated_n
if index == 1 or index % 10 == 0 or index == len(pending):
print(
f"progress windows={index}/{len(pending)} "
f"requests={requests_this_run}/{args.limit} "
f"last={window_start}..{window_end} rows={len(deduped)}"
)
if args.sleep > 0:
await asyncio.sleep(float(args.sleep))
with engine.begin() as conn:
windows_done = int(
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_backfill_windows "
"WHERE status='done' AND from_date >= :a AND to_date <= :b"
),
{"a": start.isoformat(), "b": end.isoformat()},
).scalar_one()
)
complete = windows_done >= len(all_windows)
if complete:
now = datetime.now(timezone.utc).isoformat()
for symbol in symbols:
count = int(
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_events "
"WHERE symbol=:symbol AND announce_date BETWEEN :a AND :b"
),
{"symbol": symbol, "a": start.isoformat(), "b": end.isoformat()},
).scalar_one()
)
conn.execute(
text(
"""
INSERT INTO earnings_backfill_meta(symbol, status, n_events, updated_at, note)
VALUES (:symbol, 'done', :count, :now, 'bulk_complete')
ON CONFLICT(symbol) DO UPDATE SET
status='done', n_events=excluded.n_events,
updated_at=excluded.updated_at, note=excluded.note
"""
),
{"symbol": symbol, "count": count, "now": now},
)
params = {"a": start.isoformat(), "b": end.isoformat()}
total_events = int(
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_events "
"WHERE symbol IN (SELECT symbol FROM tickers) "
"AND announce_date BETWEEN :a AND :b"
),
params,
).scalar_one()
)
paired_events = int(
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_events "
"WHERE symbol IN (SELECT symbol FROM tickers) "
"AND announce_date BETWEEN :a AND :b "
"AND eps_actual IS NOT NULL AND eps_estimate IS NOT NULL"
),
params,
).scalar_one()
)
date_range = conn.execute(
text(
"SELECT MIN(announce_date), MAX(announce_date) FROM earnings_events "
"WHERE symbol IN (SELECT symbol FROM tickers) "
"AND announce_date BETWEEN :a AND :b"
),
params,
).fetchone()
done_symbols = int(
conn.execute(
text("SELECT COUNT(*) FROM earnings_backfill_meta WHERE status='done'")
).scalar_one()
)
totals = conn.execute(
text(
"SELECT COALESCE(SUM(requests),0), COALESCE(SUM(duplicate_rows),0), "
"COALESCE(SUM(restated_rows),0) FROM earnings_backfill_windows "
"WHERE from_date >= :a AND to_date <= :b"
),
params,
).fetchone()
summary = {
"mode": "fmp_bulk_date_range_only",
"window": {"from": start.isoformat(), "to": end.isoformat()},
"window_days": int(args.window_days),
"bulk_windows_total": len(all_windows),
"bulk_windows_done": windows_done,
"bulk_requests_this_run": requests_this_run,
"bulk_requests_logged_total": int(totals[0]),
"rows_upserted_this_run": rows_upserted,
"duplicate_rows_this_run": duplicate_rows,
"restated_rows_this_run": restated_rows,
"duplicate_rows_logged_total": int(totals[1]),
"restated_rows_logged_total": int(totals[2]),
"dedupe_policy": (
"UNIQUE(symbol, announce_date); prefer more non-null fields, then "
"the provider's later occurrence; non-null bulk fields replace prior "
"values while null bulk fields retain existing values"
),
"events_in_window": total_events,
"events_with_actual_and_estimate": paired_events,
"symbols_done": done_symbols,
"symbols_universe": len(symbols),
"announce_date_range": {"min": date_range[0], "max": date_range[1]},
"request_budget": int(args.limit),
"stop_note": stop_note,
"complete": complete,
}
output = Path("reports/earnings-backfill-status.json")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
print(json.dumps(summary, indent=2))
print(f"Wrote {output}")
if __name__ == "__main__":
asyncio.run(_main())
+490
View File
@@ -0,0 +1,490 @@
"""Extend a *copy* of the production backtest snapshot with broad-universe OHLCV.
Research only never writes to production Postgres.
Pipeline
--------
1. Copy ``--source`` snapshot (default ``backtest_snapshots/prod.sqlite``) to
``--output`` (default ``backtest_snapshots/research.sqlite``).
2. Resolve symbol pool = nasdaq_all sp500 via ``ticker_universe_service``.
3. Fetch ~5y daily bars from Alpaca for symbols missing (or short) in the copy.
4. Insert new tickers + OHLCV; mark them in side table ``research_rank_only``
so the harness can feed signal IC without GTL/candidate replay.
5. Write a **completion manifest** (``<output>.manifest.json``) with ticker /
OHLCV / rank_only counts and finished-at. Breadth runners refuse to start
without a matching complete manifest same class of guard as calendar
truncation (see 2026-07-18 21:14 race: orphaned +0.0575 on a partial pool).
Resume-friendly: re-running skips symbols that already have ``--min-bars``.
A ``--limit`` smoke run writes ``complete: false`` so breadth mode still refuses.
Example
-------
python scripts/extend_snapshot_universe.py \\
--source backtest_snapshots/prod.sqlite \\
--output backtest_snapshots/research.sqlite \\
--force-copy
# smoke: first 50 missing symbols only
python scripts/extend_snapshot_universe.py --limit 50
"""
from __future__ import annotations
import argparse
import asyncio
import shutil
import sys
import time
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from sqlalchemy import create_engine, text
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.ssl_bootstrap import bootstrap_ssl # noqa: E402
bootstrap_ssl()
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument(
"--source",
default="backtest_snapshots/prod.sqlite",
help="Existing prod snapshot to copy (read-only after copy).",
)
p.add_argument(
"--output",
default="backtest_snapshots/research.sqlite",
help="Research snapshot path (created/updated).",
)
p.add_argument(
"--force-copy",
action="store_true",
help="Overwrite output by re-copying from source first.",
)
p.add_argument(
"--history-days",
type=int,
default=1825,
help="OHLCV lookback days (~5y). Default 1825.",
)
p.add_argument(
"--min-bars",
type=int,
default=260,
help="Skip re-fetch when a symbol already has this many bars.",
)
p.add_argument(
"--limit",
type=int,
default=None,
help="Max *new* symbols to fetch (smoke tests).",
)
p.add_argument(
"--sleep",
type=float,
default=0.15,
help="Seconds between Alpaca symbol requests (rate-limit cushion).",
)
p.add_argument(
"--max-retries",
type=int,
default=5,
help="Retries per symbol on RateLimitError.",
)
p.add_argument(
"--source-symbols-only",
action="store_true",
help=(
"Refresh only symbols present in --source. Useful for repairing "
"per-symbol depth without re-fetching the broad rank-only pool."
),
)
p.add_argument("--quiet", action="store_true")
return p.parse_args()
def _ensure_rank_only_table(engine) -> None:
"""DDL in its own connection/transaction (don't share with ORM Session)."""
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS research_rank_only (
ticker_id INTEGER PRIMARY KEY,
symbol TEXT NOT NULL UNIQUE
)
"""
)
)
async def _resolve_pool() -> tuple[list[str], dict[str, str]]:
"""Return sorted unique symbols and source labels.
Offline-safe: does **not** use production Postgres or SystemSetting cache
(those require a schema). Public sources first, then FMP, then seeds.
"""
from app.services.ticker_universe_service import (
_SEED_UNIVERSES,
_fetch_universe_symbols_from_fmp,
_fetch_universe_symbols_from_public,
_normalise_symbols,
)
sources: dict[str, str] = {}
symbols: set[str] = set()
for universe in ("nasdaq_all", "sp500"):
cleaned: list[str] = []
src = "none"
public_symbols, public_failures, public_source = (
await _fetch_universe_symbols_from_public(universe)
)
cleaned = _normalise_symbols(public_symbols)
if cleaned:
src = public_source or "public"
else:
if public_failures:
print(
f" WARNING: public fetch {universe}: "
f"{'; '.join(public_failures[:3])}"
)
try:
fmp_symbols = await _fetch_universe_symbols_from_fmp(universe)
cleaned = _normalise_symbols(fmp_symbols)
if cleaned:
src = "fmp"
except Exception as exc:
print(f" WARNING: FMP fetch {universe}: {exc}")
if not cleaned:
cleaned = _normalise_symbols(_SEED_UNIVERSES.get(universe, []))
if cleaned:
src = "seed"
print(
f" WARNING: {universe} fell back to seed list "
f"({len(cleaned)} symbols) — not full universe"
)
if not cleaned:
print(f" WARNING: universe {universe} returned no symbols")
continue
sources[universe] = src
symbols.update(cleaned)
print(f" {universe}: {len(cleaned)} symbols (source={src})")
return sorted(symbols), sources
async def _fetch_symbol_bars(
provider,
symbol: str,
start: date,
end: date,
*,
max_retries: int,
sleep_s: float,
) -> list:
from app.exceptions import ProviderError, RateLimitError
for attempt in range(max_retries):
try:
bars = await provider.fetch_ohlcv(symbol, start, end)
if sleep_s > 0:
await asyncio.sleep(sleep_s)
return bars
except RateLimitError:
wait = min(60.0, 2.0 ** attempt)
print(f" rate limited on {symbol}; sleep {wait:.0f}s")
await asyncio.sleep(wait)
except ProviderError as exc:
if attempt + 1 >= max_retries:
raise
await asyncio.sleep(1.0)
_ = exc
return []
async def _main() -> None:
# ROOT is already on sys.path; keep the helper import path-local.
from research_snapshot_manifest import ( # type: ignore[import-not-found]
clear_manifest,
write_completion_manifest,
)
args = _parse_args()
source = Path(args.source)
output = Path(args.output)
if not source.exists():
raise SystemExit(f"Source snapshot not found: {source}")
source_engine = create_engine(
f"sqlite:///{source.resolve().as_posix()}", future=True
)
with source_engine.connect() as conn:
source_symbols = {
str(row[0]) for row in conn.execute(text("SELECT symbol FROM tickers"))
}
source_engine.dispose()
# Any rebuild/update invalidates prior completion until we finish cleanly.
clear_manifest(output)
if args.force_copy or not output.exists():
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
output.unlink()
print(f"Copying {source}{output}")
shutil.copy2(source, output)
else:
print(f"Updating existing research snapshot: {output}")
from app.config import settings
from app.providers.alpaca import AlpacaOHLCVProvider
if not settings.alpaca_api_key or not settings.alpaca_api_secret:
raise SystemExit("ALPACA_API_KEY / ALPACA_API_SECRET required in .env")
provider = AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret)
end = date.today()
start = end - timedelta(days=int(args.history_days))
print("Resolving universe pool (nasdaq_all sp500)…")
if args.source_symbols_only:
pool = sorted(source_symbols)
sources = {"pool": "source_snapshot"}
print(" source snapshot: symbol pool selected")
else:
pool, sources = await _resolve_pool()
print(f"Pool size: {len(pool)} (sources={sources})")
# Sync sqlite via raw SQL — one short transaction per symbol so a failed
# write never leaves the session in "transaction is inactive".
engine = create_engine(
f"sqlite:///{output.resolve().as_posix()}",
future=True,
)
_ensure_rank_only_table(engine)
with engine.connect() as conn:
existing_rows = conn.execute(
text("SELECT id, symbol FROM tickers")
).fetchall()
existing_ids = {str(sym): int(tid) for tid, sym in existing_rows}
prod_symbols = set(source_symbols)
bar_counts: dict[str, int] = {}
for sym, tid in existing_ids.items():
n = conn.execute(
text("SELECT COUNT(*) FROM ohlcv_records WHERE ticker_id = :tid"),
{"tid": tid},
).scalar_one()
bar_counts[sym] = int(n)
to_fetch: list[str] = []
for sym in pool:
if sym in existing_ids and bar_counts.get(sym, 0) >= args.min_bars:
continue
to_fetch.append(sym)
if args.limit is not None:
to_fetch = to_fetch[: max(0, int(args.limit))]
print(f"Symbols to fetch/extend: {len(to_fetch)}")
ok = 0
fail = 0
t0 = time.monotonic()
insert_ohlcv = text(
"""
INSERT INTO ohlcv_records
(ticker_id, date, open, high, low, close, volume, created_at)
VALUES
(:ticker_id, :date, :open, :high, :low, :close, :volume, :created_at)
"""
)
for index, sym in enumerate(to_fetch, 1):
try:
bars = await _fetch_symbol_bars(
provider,
sym,
start,
end,
max_retries=args.max_retries,
sleep_s=args.sleep,
)
except Exception as exc:
fail += 1
if not args.quiet:
print(f" [{index}/{len(to_fetch)}] {sym} FAIL {exc}")
continue
if not bars:
fail += 1
if not args.quiet:
print(f" [{index}/{len(to_fetch)}] {sym} empty")
continue
try:
with engine.begin() as write:
ticker_id = existing_ids.get(sym)
is_new = ticker_id is None
if is_new:
write.execute(
text(
"INSERT INTO tickers (symbol, name, created_at) "
"VALUES (:sym, NULL, :created)"
),
{
"sym": sym,
"created": datetime.now(timezone.utc).isoformat(),
},
)
ticker_id = int(
write.execute(
text("SELECT id FROM tickers WHERE symbol = :sym"),
{"sym": sym},
).scalar_one()
)
existing_ids[sym] = ticker_id
write.execute(
text(
"DELETE FROM ohlcv_records WHERE ticker_id = :tid "
"AND date >= :start AND date <= :end"
),
{
"tid": ticker_id,
"start": start.isoformat(),
"end": end.isoformat(),
},
)
now = datetime.now(timezone.utc).replace(tzinfo=None)
write.execute(
insert_ohlcv,
[
{
"ticker_id": ticker_id,
"date": b.date.isoformat(),
"open": float(b.open),
"high": float(b.high),
"low": float(b.low),
"close": float(b.close),
"volume": int(b.volume),
"created_at": now.isoformat(),
}
for b in bars
],
)
if is_new and sym not in prod_symbols:
write.execute(
text(
"INSERT OR REPLACE INTO research_rank_only "
"(ticker_id, symbol) VALUES (:tid, :sym)"
),
{"tid": ticker_id, "sym": sym},
)
except Exception as exc:
fail += 1
if not args.quiet:
print(f" [{index}/{len(to_fetch)}] {sym} WRITE FAIL {exc}")
continue
ok += 1
if not args.quiet and (index % 25 == 0 or index == len(to_fetch)):
elapsed = time.monotonic() - t0
print(
f" progress {index}/{len(to_fetch)} ok={ok} fail={fail} "
f"elapsed={elapsed/60:.1f}m last={sym} bars={len(bars)}"
)
benchmark_rows = 0
try:
benchmark_bars = await _fetch_symbol_bars(
provider,
"SPY",
start,
end,
max_retries=args.max_retries,
sleep_s=args.sleep,
)
with engine.begin() as write:
write.execute(
text(
"DELETE FROM benchmark_prices WHERE symbol='SPY' "
"AND date >= :start AND date <= :end"
),
{"start": start.isoformat(), "end": end.isoformat()},
)
if benchmark_bars:
write.execute(
text(
"INSERT INTO benchmark_prices(symbol, date, close) "
"VALUES ('SPY', :date, :close)"
),
[
{"date": bar.date.isoformat(), "close": float(bar.close)}
for bar in benchmark_bars
],
)
benchmark_rows = len(benchmark_bars)
except Exception as exc:
print(f" benchmark SPY refresh FAIL {exc}")
rank_only_n = conn.execute(
text("SELECT COUNT(*) FROM research_rank_only")
).scalar_one()
ticker_n = conn.execute(text("SELECT COUNT(*) FROM tickers")).scalar_one()
ohlcv_n = conn.execute(
text("SELECT COUNT(*) FROM ohlcv_records")
).scalar_one()
# Full planned work only when --limit is unset. Smoke runs stay incomplete
# so breadth mode cannot mythologize a 50-symbol toy pool.
is_complete = args.limit is None
manifest_path = write_completion_manifest(
output,
complete=is_complete,
sources=sources,
history_days=int(args.history_days),
min_bars=int(args.min_bars),
fetch_ok=ok,
fetch_fail=fail,
limit=args.limit,
extra={
"prod_symbols_at_start": len(prod_symbols),
"pool_size": len(pool),
"to_fetch": len(to_fetch),
"source_symbols_only": bool(args.source_symbols_only),
"benchmark_spy_rows": benchmark_rows,
},
)
print("Done.")
print(f" output: {output}")
print(f" tickers: {ticker_n}")
print(f" ohlcv rows: {ohlcv_n}")
print(f" research_rank_only: {rank_only_n}")
print(f" fetched ok/fail: {ok}/{fail}")
print(
f" completion manifest: {manifest_path} "
f"(complete={is_complete})"
)
if not is_complete:
print(
" NOTE: --limit set → complete=false; breadth runners will refuse "
"this snapshot until a full extend finishes."
)
if __name__ == "__main__":
asyncio.run(_main())
+657
View File
@@ -0,0 +1,657 @@
"""Import the public post-no-preference/earnings DoltHub database.
The earnings calendar and EPS history are separate tables in the source. This
importer aligns them monotonically per symbol, keeps every calendar event for
the defensive gap study, and stores the longer EPS history separately for SUE
scaling. EPS history without an announcement date is never exposed as a live
signal event.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import sqlite3
from collections import defaultdict
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
EVENTS_DDL = """
CREATE TABLE IF NOT EXISTS earnings_events (
id INTEGER PRIMARY KEY,
symbol TEXT NOT NULL,
announce_date TEXT NOT NULL,
announce_time TEXT,
eps_estimate REAL,
eps_actual REAL,
revenue_estimate REAL,
revenue_actual REAL,
source TEXT NOT NULL,
fetched_at TEXT NOT NULL,
period_end_date TEXT,
UNIQUE(symbol, announce_date)
)
"""
META_DDL = """
CREATE TABLE IF NOT EXISTS earnings_backfill_meta (
symbol TEXT PRIMARY KEY,
status TEXT NOT NULL,
n_events INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
note TEXT
)
"""
SURPRISE_HISTORY_DDL = """
CREATE TABLE IF NOT EXISTS earnings_surprise_history (
symbol TEXT NOT NULL,
period_end_date TEXT NOT NULL,
eps_estimate REAL,
eps_actual REAL,
source TEXT NOT NULL,
fetched_at TEXT NOT NULL,
PRIMARY KEY(symbol, period_end_date)
)
"""
SKIP_EVENT_COST = 45.0
SKIP_PERIOD_COST = 45.0
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--snapshot", default="backtest_snapshots/prod.sqlite")
parser.add_argument("--calendar-csv", required=True)
parser.add_argument("--history-csv", required=True)
parser.add_argument("--from-date", default="2020-01-22")
parser.add_argument("--to-date", required=True)
parser.add_argument("--source-commit", required=True)
parser.add_argument(
"--source-url",
default="https://www.dolthub.com/repositories/post-no-preference/earnings",
)
parser.add_argument("--max-period-lag-days", type=int, default=90)
parser.add_argument("--max-period-lead-days", type=int, default=14)
parser.add_argument(
"--status-output", default="reports/earnings-backfill-status.json"
)
return parser.parse_args()
def _normalise_symbol(value: Any) -> str:
return str(value or "").strip().upper().replace(".", "-")
def _normalise_session(value: Any) -> str | None:
cleaned = str(value or "").strip().lower().replace("_", " ").replace("-", " ")
aliases = {
"before market open": "bmo",
"before open": "bmo",
"bmo": "bmo",
"after market close": "amc",
"after close": "amc",
"amc": "amc",
"during market hours": "during",
"dmh": "during",
}
return aliases.get(cleaned, cleaned or None)
def _number(value: Any) -> float | None:
if value is None or str(value).strip() == "":
return None
try:
result = float(value)
except (TypeError, ValueError):
return None
return result if math.isfinite(result) else None
def _read_calendar(
path: Path,
universe: set[str],
start: date,
end: date,
) -> tuple[dict[str, list[dict[str, Any]]], dict[str, int]]:
by_key: dict[tuple[str, date], dict[str, Any]] = {}
raw_rows = 0
universe_rows = 0
duplicate_rows = 0
restated_rows = 0
with path.open(newline="", encoding="utf-8-sig") as handle:
for raw in csv.DictReader(handle):
raw_rows += 1
symbol = _normalise_symbol(raw.get("act_symbol"))
raw_date = str(raw.get("date") or "")[:10]
if symbol not in universe or not raw_date:
continue
event_date = date.fromisoformat(raw_date)
if not start <= event_date <= end:
continue
universe_rows += 1
row = {
"symbol": symbol,
"announce_date": event_date,
"announce_time": _normalise_session(raw.get("when")),
}
key = (symbol, event_date)
previous = by_key.get(key)
if previous is not None:
duplicate_rows += 1
if (
previous.get("announce_time") is not None
and row.get("announce_time") is not None
and previous["announce_time"] != row["announce_time"]
):
restated_rows += 1
if row.get("announce_time") is not None:
by_key[key] = row
else:
by_key[key] = row
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in by_key.values():
grouped[row["symbol"]].append(row)
for rows in grouped.values():
rows.sort(key=lambda item: item["announce_date"])
return grouped, {
"raw_rows": raw_rows,
"universe_rows_in_window": universe_rows,
"deduped_rows_in_window": len(by_key),
"duplicate_rows": duplicate_rows,
"restated_rows": restated_rows,
}
def _read_history(
path: Path, universe: set[str]
) -> tuple[dict[str, list[dict[str, Any]]], dict[str, int]]:
by_key: dict[tuple[str, date], dict[str, Any]] = {}
raw_rows = 0
universe_rows = 0
duplicate_rows = 0
restated_rows = 0
fields = ("eps_actual", "eps_estimate")
with path.open(newline="", encoding="utf-8-sig") as handle:
for raw in csv.DictReader(handle):
raw_rows += 1
symbol = _normalise_symbol(raw.get("act_symbol"))
raw_date = str(raw.get("period_end_date") or "")[:10]
if symbol not in universe or not raw_date:
continue
universe_rows += 1
period_end = date.fromisoformat(raw_date)
row = {
"symbol": symbol,
"period_end_date": period_end,
"eps_actual": _number(raw.get("reported")),
"eps_estimate": _number(raw.get("estimate")),
}
key = (symbol, period_end)
previous = by_key.get(key)
if previous is not None:
duplicate_rows += 1
if any(
previous.get(field) is not None
and row.get(field) is not None
and previous[field] != row[field]
for field in fields
):
restated_rows += 1
previous_score = sum(previous.get(field) is not None for field in fields)
row_score = sum(row.get(field) is not None for field in fields)
if row_score >= previous_score:
by_key[key] = row
else:
by_key[key] = row
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in by_key.values():
grouped[row["symbol"]].append(row)
for rows in grouped.values():
rows.sort(key=lambda item: item["period_end_date"])
return grouped, {
"raw_rows": raw_rows,
"universe_rows": universe_rows,
"deduped_rows": len(by_key),
"duplicate_rows": duplicate_rows,
"restated_rows": restated_rows,
}
def _match_cost(event: dict[str, Any], period: dict[str, Any]) -> float:
delta = (event["announce_date"] - period["period_end_date"]).days
missing_session_penalty = 3.0 if event.get("announce_time") is None else 0.0
return float(abs(delta - 30)) + missing_session_penalty
def _align_symbol(
events: list[dict[str, Any]],
periods: list[dict[str, Any]],
*,
max_lag_days: int,
max_lead_days: int,
) -> tuple[list[tuple[int, int]], list[int], list[int]]:
"""Return a minimum-cost monotonic calendar-to-period alignment."""
n_events = len(events)
n_periods = len(periods)
scores = [[0.0] * (n_periods + 1) for _ in range(n_events + 1)]
choices = [[""] * (n_periods + 1) for _ in range(n_events + 1)]
for event_index in range(n_events - 1, -1, -1):
scores[event_index][n_periods] = (
scores[event_index + 1][n_periods] + SKIP_EVENT_COST
)
choices[event_index][n_periods] = "event"
for period_index in range(n_periods - 1, -1, -1):
scores[n_events][period_index] = (
scores[n_events][period_index + 1] + SKIP_PERIOD_COST
)
choices[n_events][period_index] = "period"
for event_index in range(n_events - 1, -1, -1):
for period_index in range(n_periods - 1, -1, -1):
options = [
(
scores[event_index + 1][period_index] + SKIP_EVENT_COST,
2,
"event",
),
(
scores[event_index][period_index + 1] + SKIP_PERIOD_COST,
1,
"period",
),
]
delta = (
events[event_index]["announce_date"]
- periods[period_index]["period_end_date"]
).days
if -max_lead_days <= delta <= max_lag_days:
options.append(
(
scores[event_index + 1][period_index + 1]
+ _match_cost(events[event_index], periods[period_index]),
0,
"match",
)
)
score, _, choice = min(options)
scores[event_index][period_index] = score
choices[event_index][period_index] = choice
matches: list[tuple[int, int]] = []
unmatched_events: list[int] = []
unmatched_periods: list[int] = []
event_index = 0
period_index = 0
while event_index < n_events or period_index < n_periods:
if event_index >= n_events:
unmatched_periods.extend(range(period_index, n_periods))
break
if period_index >= n_periods:
unmatched_events.extend(range(event_index, n_events))
break
choice = choices[event_index][period_index]
if choice == "match":
matches.append((event_index, period_index))
event_index += 1
period_index += 1
elif choice == "period":
unmatched_periods.append(period_index)
period_index += 1
else:
unmatched_events.append(event_index)
event_index += 1
return matches, unmatched_events, unmatched_periods
def _ensure_schema(connection: sqlite3.Connection) -> None:
connection.execute(EVENTS_DDL)
columns = {
str(row[1])
for row in connection.execute("PRAGMA table_info(earnings_events)")
}
if "period_end_date" not in columns:
connection.execute("ALTER TABLE earnings_events ADD COLUMN period_end_date TEXT")
connection.execute(META_DDL)
connection.execute(SURPRISE_HISTORY_DDL)
def _main() -> None:
args = _parse_args()
snapshot = Path(args.snapshot)
calendar_csv = Path(args.calendar_csv)
history_csv = Path(args.history_csv)
for path in (snapshot, calendar_csv, history_csv):
if not path.exists():
raise SystemExit(f"Missing input: {path}")
start = date.fromisoformat(args.from_date)
end = date.fromisoformat(args.to_date)
if start > end:
raise SystemExit("--from-date must not be after --to-date")
connection = sqlite3.connect(snapshot)
try:
universe = {
_normalise_symbol(row[0])
for row in connection.execute("SELECT symbol FROM tickers")
}
finally:
connection.close()
calendar, calendar_stats = _read_calendar(calendar_csv, universe, start, end)
history, history_stats = _read_history(history_csv, universe)
aligned_events: list[dict[str, Any]] = []
pairing_deltas: list[int] = []
unmatched_calendar = 0
unmatched_periods_in_pairing_window = 0
matched = 0
for symbol in sorted(universe):
events = calendar.get(symbol, [])
lower = start - timedelta(days=int(args.max_period_lag_days))
upper = end + timedelta(days=int(args.max_period_lead_days))
periods = [
row
for row in history.get(symbol, [])
if lower <= row["period_end_date"] <= upper
]
matches, unmatched_events, unmatched_periods = _align_symbol(
events,
periods,
max_lag_days=int(args.max_period_lag_days),
max_lead_days=int(args.max_period_lead_days),
)
matched_by_event = {event_index: period_index for event_index, period_index in matches}
matched += len(matches)
unmatched_calendar += len(unmatched_events)
unmatched_periods_in_pairing_window += len(unmatched_periods)
for event_index, event in enumerate(events):
row = dict(event)
period_index = matched_by_event.get(event_index)
if period_index is None:
row.update(
{
"period_end_date": None,
"eps_actual": None,
"eps_estimate": None,
}
)
else:
period = periods[period_index]
row.update(
{
"period_end_date": period["period_end_date"],
"eps_actual": period["eps_actual"],
"eps_estimate": period["eps_estimate"],
}
)
pairing_deltas.append(
(event["announce_date"] - period["period_end_date"]).days
)
aligned_events.append(row)
now = datetime.now(timezone.utc).isoformat()
source = f"dolthub_post_no_preference@{args.source_commit}"
conflicting_existing_rows = 0
conflicting_existing_fields = 0
preserved_existing_fields = 0
incoming_keys = {
(row["symbol"], row["announce_date"].isoformat()) for row in aligned_events
}
connection = sqlite3.connect(snapshot)
try:
_ensure_schema(connection)
existing = {
(str(row[0]), str(row[1])): row
for row in connection.execute(
"""
SELECT symbol, announce_date, announce_time, eps_estimate,
eps_actual, period_end_date, source
FROM earnings_events
WHERE announce_date BETWEEN ? AND ?
""",
(start.isoformat(), end.isoformat()),
)
}
upsert = """
INSERT INTO earnings_events(
symbol, announce_date, announce_time, eps_estimate, eps_actual,
revenue_estimate, revenue_actual, source, fetched_at, period_end_date
) VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?)
ON CONFLICT(symbol, announce_date) DO UPDATE SET
announce_time=COALESCE(earnings_events.announce_time, excluded.announce_time),
eps_estimate=COALESCE(earnings_events.eps_estimate, excluded.eps_estimate),
eps_actual=COALESCE(earnings_events.eps_actual, excluded.eps_actual),
period_end_date=COALESCE(excluded.period_end_date, earnings_events.period_end_date),
source=excluded.source,
fetched_at=excluded.fetched_at
"""
for row in aligned_events:
key = (row["symbol"], row["announce_date"].isoformat())
old = existing.get(key)
retained = 0
conflicts = 0
if old is not None:
old_values = {
"announce_time": old[2],
"eps_estimate": old[3],
"eps_actual": old[4],
"period_end_date": old[5],
}
new_values = {
"announce_time": row.get("announce_time"),
"eps_estimate": row.get("eps_estimate"),
"eps_actual": row.get("eps_actual"),
"period_end_date": (
row["period_end_date"].isoformat()
if row.get("period_end_date")
else None
),
}
for field, new_value in new_values.items():
old_value = old_values[field]
if field != "period_end_date" and old_value is not None:
retained += 1
if new_value is not None and old_value is not None:
if field in {"eps_estimate", "eps_actual"}:
differs = not math.isclose(
float(new_value), float(old_value), rel_tol=0.0, abs_tol=1e-9
)
else:
differs = str(new_value) != str(old_value)
conflicts += int(differs)
conflicting_existing_rows += int(conflicts > 0)
conflicting_existing_fields += conflicts
preserved_existing_fields += retained
row_source = source
if retained and old is not None:
row_source = f"{old[6]}+calendar:{source}"
connection.execute(
upsert,
(
row["symbol"],
row["announce_date"].isoformat(),
row.get("announce_time"),
row.get("eps_estimate"),
row.get("eps_actual"),
row_source,
now,
(
row["period_end_date"].isoformat()
if row.get("period_end_date")
else None
),
),
)
history_upsert = """
INSERT INTO earnings_surprise_history(
symbol, period_end_date, eps_estimate, eps_actual, source, fetched_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(symbol, period_end_date) DO UPDATE SET
eps_estimate=COALESCE(excluded.eps_estimate, earnings_surprise_history.eps_estimate),
eps_actual=COALESCE(excluded.eps_actual, earnings_surprise_history.eps_actual),
source=excluded.source,
fetched_at=excluded.fetched_at
"""
for symbol, rows in history.items():
connection.executemany(
history_upsert,
[
(
symbol,
row["period_end_date"].isoformat(),
row.get("eps_estimate"),
row.get("eps_actual"),
source,
now,
)
for row in rows
],
)
for symbol in sorted(universe):
count = int(
connection.execute(
"""
SELECT COUNT(*) FROM earnings_events
WHERE symbol=? AND announce_date BETWEEN ? AND ?
""",
(symbol, start.isoformat(), end.isoformat()),
).fetchone()[0]
)
connection.execute(
"""
INSERT INTO earnings_backfill_meta(symbol, status, n_events, updated_at, note)
VALUES (?, 'done', ?, ?, 'dolthub_bulk_complete')
ON CONFLICT(symbol) DO UPDATE SET
status='done', n_events=excluded.n_events,
updated_at=excluded.updated_at, note=excluded.note
""",
(symbol, count, now),
)
connection.commit()
params = (start.isoformat(), end.isoformat())
total_events = int(
connection.execute(
"""
SELECT COUNT(*) FROM earnings_events
WHERE symbol IN (SELECT symbol FROM tickers)
AND announce_date BETWEEN ? AND ?
""",
params,
).fetchone()[0]
)
paired_events = int(
connection.execute(
"""
SELECT COUNT(*) FROM earnings_events
WHERE symbol IN (SELECT symbol FROM tickers)
AND announce_date BETWEEN ? AND ?
AND eps_actual IS NOT NULL AND eps_estimate IS NOT NULL
""",
params,
).fetchone()[0]
)
date_range = connection.execute(
"""
SELECT MIN(announce_date), MAX(announce_date) FROM earnings_events
WHERE symbol IN (SELECT symbol FROM tickers)
AND announce_date BETWEEN ? AND ?
""",
params,
).fetchone()
source_symbols = set(calendar)
history_complete = int(
connection.execute(
"""
SELECT COUNT(*) FROM earnings_surprise_history
WHERE symbol IN (SELECT symbol FROM tickers)
AND eps_actual IS NOT NULL AND eps_estimate IS NOT NULL
"""
).fetchone()[0]
)
finally:
connection.close()
deltas = sorted(pairing_deltas)
summary = {
"mode": "dolthub_public_bulk_clone",
"window": {"from": start.isoformat(), "to": end.isoformat()},
"coverage_amendment": {
"approved_by_user": True,
"reason": "FMP free tier blocks historical bulk earnings",
"original_start": "2016-01-04",
"amended_announcement_start": start.isoformat(),
},
"source": {
"repository": args.source_url,
"commit": args.source_commit,
"license": "CC-BY-SA-4.0",
"upstream_provider_documented": False,
},
"bulk_windows_total": 1,
"bulk_windows_done": 1,
"bulk_requests_logged_total": 1,
"bulk_exports": 2,
"calendar": calendar_stats,
"eps_history": {**history_stats, "complete_actual_and_estimate": history_complete},
"pairing": {
"method": "minimum-cost monotonic alignment per symbol",
"allowed_announce_minus_period_end_days": [
-int(args.max_period_lead_days),
int(args.max_period_lag_days),
],
"matched_calendar_events": matched,
"unmatched_calendar_events": unmatched_calendar,
"unmatched_periods_in_pairing_window": unmatched_periods_in_pairing_window,
"announce_minus_period_end_days": {
"min": min(deltas) if deltas else None,
"median": deltas[len(deltas) // 2] if deltas else None,
"max": max(deltas) if deltas else None,
},
"pre_2020_eps_history_use": (
"trailing_surprise_stdev_only; never treated as an announcement "
"or live signal event"
),
},
"duplicate_rows_logged_total": (
calendar_stats["duplicate_rows"] + history_stats["duplicate_rows"]
),
"restated_rows_logged_total": (
calendar_stats["restated_rows"]
+ history_stats["restated_rows"]
+ conflicting_existing_rows
),
"conflicting_existing_rows": conflicting_existing_rows,
"conflicting_existing_fields": conflicting_existing_fields,
"preserved_existing_fields": preserved_existing_fields,
"existing_enrichment_events_not_in_dolthub_calendar": max(
0, total_events - len(incoming_keys)
),
"dedupe_policy": (
"UNIQUE(symbol, announce_date); normalise dot/dash symbols; retain one "
"calendar row per key; preserve existing non-null session/EPS values from "
"the prior FMP/Alpha Vantage partial backfill, then fill nulls and all "
"remaining symbols from DoltHub; attach DoltHub period-end alignment"
),
"events_in_window": total_events,
"events_with_actual_and_estimate": paired_events,
"symbols_done": len(universe),
"symbols_universe": len(universe),
"symbols_with_dolthub_calendar": len(source_symbols),
"symbols_without_dolthub_calendar": sorted(universe - source_symbols),
"announce_date_range": {"min": date_range[0], "max": date_range[1]},
"complete": True,
}
output = Path(args.status_output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
print(json.dumps(summary, indent=2))
print(f"Wrote {output}")
if __name__ == "__main__":
_main()
+172
View File
@@ -0,0 +1,172 @@
"""Completion manifest for research.sqlite — cheap race guard.
The 2026-07-18 21:14 breadth run fired while ``extend_snapshot_universe`` was
still (or had just been) building the snapshot. Harness and shared-filter
recomputes agree on *complete* data, so the orphaned +0.0575 was incomplete
universe, not a code path bug.
Same class of protection as calendar-truncation assertions in the research
matrix: refuse to read results from a half-built artifact.
Layout
------
Sidecar path: ``<snapshot>.manifest.json`` next to the sqlite file
(e.g. ``backtest_snapshots/research.sqlite.manifest.json``).
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from sqlalchemy import create_engine, text
MANIFEST_SCHEMA_VERSION = 1
def manifest_path_for(snapshot: Path) -> Path:
"""Sidecar path for a research snapshot."""
return Path(str(snapshot) + ".manifest.json")
def _count_snapshot(snapshot: Path) -> dict[str, int]:
engine = create_engine(
f"sqlite:///{snapshot.resolve().as_posix()}",
future=True,
)
try:
with engine.connect() as conn:
ticker_n = int(conn.execute(text("SELECT COUNT(*) FROM tickers")).scalar_one())
ohlcv_n = int(
conn.execute(text("SELECT COUNT(*) FROM ohlcv_records")).scalar_one()
)
try:
rank_only_n = int(
conn.execute(text("SELECT COUNT(*) FROM research_rank_only")).scalar_one()
)
except Exception:
rank_only_n = 0
finally:
engine.dispose()
return {
"ticker_count": ticker_n,
"ohlcv_row_count": ohlcv_n,
"rank_only_count": rank_only_n,
}
def write_completion_manifest(
snapshot: Path,
*,
complete: bool,
sources: dict[str, str] | None = None,
history_days: int | None = None,
min_bars: int | None = None,
fetch_ok: int | None = None,
fetch_fail: int | None = None,
limit: int | None = None,
extra: dict[str, Any] | None = None,
) -> Path:
"""Write (or overwrite) the sidecar completion manifest for *snapshot*."""
snapshot = Path(snapshot)
counts = _count_snapshot(snapshot) if snapshot.exists() else {
"ticker_count": 0,
"ohlcv_row_count": 0,
"rank_only_count": 0,
}
payload: dict[str, Any] = {
"schema_version": MANIFEST_SCHEMA_VERSION,
"snapshot": snapshot.name,
"snapshot_resolved": str(snapshot.resolve()) if snapshot.exists() else str(snapshot),
"complete": bool(complete),
"finished_at": datetime.now(timezone.utc).isoformat(),
**counts,
"sources": sources or {},
"history_days": history_days,
"min_bars": min_bars,
"fetch_ok": fetch_ok,
"fetch_fail": fetch_fail,
"limit": limit,
}
if extra:
payload["extra"] = extra
path = manifest_path_for(snapshot)
path.write_text(json.dumps(payload, indent=2, default=str) + "\n", encoding="utf-8")
return path
def clear_manifest(snapshot: Path) -> None:
"""Remove any existing completion manifest (start of a rebuild)."""
path = manifest_path_for(Path(snapshot))
if path.exists():
path.unlink()
def load_manifest(snapshot: Path) -> dict[str, Any] | None:
path = manifest_path_for(Path(snapshot))
if not path.exists():
return None
return json.loads(path.read_text(encoding="utf-8"))
def assert_research_snapshot_complete(snapshot: Path) -> dict[str, Any]:
"""Refuse breadth-mode work unless the extender finished cleanly.
Raises ``SystemExit`` with a clear message on any failure (missing
manifest, incomplete flag, or live counts that no longer match the
recorded totals e.g. a mid-run overwrite of the sqlite file).
"""
snapshot = Path(snapshot)
if not snapshot.exists():
raise SystemExit(
f"Research snapshot missing: {snapshot}\n"
"Build it with: python scripts/extend_snapshot_universe.py"
)
path = manifest_path_for(snapshot)
if not path.exists():
raise SystemExit(
f"Research snapshot completion manifest missing: {path}\n"
"Refusing breadth run — this is the guard that would have caught "
"the 2026-07-18 21:14 race against a half-built research.sqlite.\n"
"Re-run extend_snapshot_universe.py to completion (no --limit), "
"or for a trusted existing full snapshot:\n"
" python -c \"from pathlib import Path; "
"from scripts.research_snapshot_manifest import write_completion_manifest; "
f"write_completion_manifest(Path(r'{snapshot}'), complete=True)\""
)
try:
manifest = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise SystemExit(f"Corrupt research snapshot manifest {path}: {exc}") from exc
if not manifest.get("complete"):
raise SystemExit(
f"Research snapshot marked incomplete in {path}\n"
f"(finished_at={manifest.get('finished_at')}, limit={manifest.get('limit')}).\n"
"Re-run extend_snapshot_universe.py without --limit until Done."
)
live = _count_snapshot(snapshot)
mismatches: list[str] = []
for key in ("ticker_count", "ohlcv_row_count", "rank_only_count"):
recorded = manifest.get(key)
if recorded is None:
mismatches.append(f"{key}: missing in manifest")
continue
if int(recorded) != int(live[key]):
mismatches.append(
f"{key}: manifest={recorded} live={live[key]}"
)
if mismatches:
raise SystemExit(
"Research snapshot does not match its completion manifest "
f"({path}). Likely a partial rewrite or concurrent extend:\n - "
+ "\n - ".join(mismatches)
+ "\nRe-run extend_snapshot_universe.py to completion."
)
return {**manifest, "live_counts": live}
File diff suppressed because it is too large Load Diff
+669
View File
@@ -0,0 +1,669 @@
"""fip_id breadth diagnostics — single-sourced through harness mask helpers.
Uses the same collection + ``_filter_liquid_breadth_week_rich`` as
``run_backtest`` signal_eval. No parallel mask implementation.
Single-sourced liquid-breadth fip diagnostics through harness mask helpers.
Re-runs unconditional / tier / prod-subset / mom-conditional ICs and context
signals. Requires a complete research.sqlite completion manifest.
Research branch only. Example:
.\\.venv\\Scripts\\python.exe scripts\\run_fip_breadth_diagnostics.py ^
--research-snapshot backtest_snapshots\\research.sqlite ^
--prod-snapshot backtest_snapshots\\prod.sqlite ^
--workers 6 --allow-spawn
"""
from __future__ import annotations
import argparse
import json
import math
import multiprocessing as mp
import os
import sys
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor, as_completed
from datetime import date, datetime
from pathlib import Path
from typing import Any
from sqlalchemy import create_engine, text
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
# Match production signal_eval cadence / reliability bars.
MIN_CROSS = 20
MIN_RELIABLE = 12
MOM_WINNER_PCT = 80.0
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--research-snapshot", default="backtest_snapshots/research.sqlite")
p.add_argument("--prod-snapshot", default="backtest_snapshots/prod.sqlite")
p.add_argument("--top-n", type=int, default=1500)
p.add_argument("--min-price", type=float, default=5.0)
p.add_argument("--workers", type=int, default=max(1, (mp.cpu_count() or 4) - 1))
p.add_argument("--allow-spawn", action="store_true")
p.add_argument(
"--dump-weeks",
type=int,
default=0,
help="Weeks of liquid membership symbol lists to embed (default 0 — keep reports compact)",
)
p.add_argument("--out", default=None)
p.add_argument("--quiet", action="store_true")
return p.parse_args()
def _week_ord(wk: tuple[int, int]) -> int:
return int(wk[0]) * 53 + int(wk[1])
def _nonoverlap(weeks: list[tuple[int, int]], stride: int) -> list[tuple[int, int]]:
from app.services.backtest_service import _nonoverlapping_weeks
return _nonoverlapping_weeks(weeks, stride)
def _ic_from_weekly(
week_pairs: dict[tuple[int, int], list[tuple[float, float]]],
) -> dict[str, Any]:
from app.services.backtest_service import HORIZON, _spearman
stride = max(1, round(HORIZON / 5))
usable = [wk for wk, ps in week_pairs.items() if len(ps) >= MIN_CROSS]
kept = _nonoverlap(usable, stride)
ics: list[float] = []
sizes: list[int] = []
for wk in kept:
ps = week_pairs[wk]
if len(ps) < MIN_CROSS:
continue
ic = _spearman([p[0] for p in ps], [p[1] for p in ps])
if ic is not None:
ics.append(ic)
sizes.append(len(ps))
if not ics:
return {
"mean_ic": None,
"ic_t_stat": None,
"weeks": 0,
"avg_cross_section": None,
"ic_positive_pct": None,
"reliable": False,
}
mean_ic = sum(ics) / len(ics)
if len(ics) > 1:
var = sum((x - mean_ic) ** 2 for x in ics) / (len(ics) - 1)
std = math.sqrt(var) if var > 0 else 0.0
t_stat = mean_ic / std * math.sqrt(len(ics)) if std > 0 else None
else:
t_stat = None
return {
"mean_ic": round(mean_ic, 4),
"ic_t_stat": round(t_stat, 2) if t_stat is not None else None,
"weeks": len(ics),
"avg_cross_section": round(sum(sizes) / len(sizes), 1),
"ic_positive_pct": round(sum(1 for x in ics if x > 0) / len(ics) * 100, 1),
"reliable": len(ics) >= MIN_RELIABLE,
}
def _worker(payload: tuple) -> dict:
"""Return harness-style signal series for one ticker (liquid-mode dicts)."""
symbol, ords, opens, highs, lows, closes, volumes, spy = payload
from types import SimpleNamespace
from app.services.backtest_service import _signal_series
bars = [
SimpleNamespace(
date=date.fromordinal(int(o)),
open=float(op),
high=float(hi),
low=float(lo),
close=float(cl),
volume=float(vo),
)
for o, op, hi, lo, cl, vo in zip(ords, opens, highs, lows, closes, volumes)
]
return _signal_series(bars, spy, symbol=symbol)
def _load_spy(conn) -> dict[date, float]:
rows = conn.execute(
text("SELECT date, close FROM benchmark_prices WHERE symbol='SPY' ORDER BY date")
).fetchall()
out: dict[date, float] = {}
for d, c in rows:
if isinstance(d, str):
d = date.fromisoformat(d[:10])
out[d] = float(c)
return out
def _load_job(conn, symbol: str, spy: dict) -> tuple | None:
tid = conn.execute(
text("SELECT id FROM tickers WHERE symbol=:s"), {"s": symbol}
).scalar()
if tid is None:
return None
rows = conn.execute(
text(
"SELECT date, open, high, low, close, volume FROM ohlcv_records "
"WHERE ticker_id=:t ORDER BY date"
),
{"t": tid},
).fetchall()
if len(rows) < 90:
return None
ords, opens, highs, lows, closes, vols = [], [], [], [], [], []
for d, o, h, l, c, v in rows:
if isinstance(d, str):
d = date.fromisoformat(d[:10])
ords.append(d.toordinal())
opens.append(float(o))
highs.append(float(h))
lows.append(float(l))
closes.append(float(c))
vols.append(float(v or 0))
return (symbol, ords, opens, highs, lows, closes, vols, spy)
def main() -> None:
args = _parse_args()
research = Path(args.research_snapshot)
prod = Path(args.prod_snapshot)
# Refuse half-built research.sqlite (2026-07-18 21:14 race).
scripts_dir = Path(__file__).resolve().parent
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from research_snapshot_manifest import ( # type: ignore[import-not-found]
assert_research_snapshot_complete,
)
manifest = assert_research_snapshot_complete(research)
if not args.quiet:
print(
f"Manifest ok: tickers={manifest.get('ticker_count')} "
f"ohlcv={manifest.get('ohlcv_row_count')} "
f"finished_at={manifest.get('finished_at')}",
flush=True,
)
# Force harness liquid-mode collection (same env as breadth run).
os.environ["BACKTEST_LIQUID_BREADTH"] = str(int(args.top_n))
os.environ["BACKTEST_LIQUID_MIN_PRICE"] = str(float(args.min_price))
if args.allow_spawn:
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
from app.services.backtest_service import (
HORIZON,
_filter_liquid_breadth_week_rich,
_liquid_breadth_week_stats,
_signal_evaluation,
)
eng = create_engine(f"sqlite:///{research.resolve().as_posix()}")
prod_symbols: set[str] = set()
if prod.exists():
peng = create_engine(f"sqlite:///{prod.resolve().as_posix()}")
with peng.connect() as c:
prod_symbols = {
str(r[0]) for r in c.execute(text("SELECT symbol FROM tickers"))
}
peng.dispose()
with eng.connect() as conn:
spy = _load_spy(conn)
symbols = [
str(r[0])
for r in conn.execute(text("SELECT symbol FROM tickers ORDER BY symbol"))
]
jobs = []
for i, sym in enumerate(symbols, 1):
job = _load_job(conn, sym, spy)
if job is not None:
jobs.append(job)
if not args.quiet and i % 500 == 0:
print(f" queued {i}/{len(symbols)}", flush=True)
if not args.quiet:
print(f"Collecting harness signal series for {len(jobs)} tickers…", flush=True)
collected: dict = defaultdict(lambda: defaultdict(list))
workers = max(1, int(args.workers))
def _merge(series: dict) -> None:
for name, weeks in series.items():
for wk, recs in weeks.items():
# week keys may arrive as lists after JSON; normalize to tuple
key = tuple(wk) if not isinstance(wk, tuple) else wk
collected[name][key].extend(recs)
if workers == 1:
for j, job in enumerate(jobs, 1):
_merge(_worker(job))
if not args.quiet and j % 200 == 0:
print(f" series {j}/{len(jobs)}", flush=True)
else:
ctx = mp.get_context("spawn") if args.allow_spawn or sys.platform == "win32" else None
with ProcessPoolExecutor(max_workers=workers, mp_context=ctx) as pool:
futs = [pool.submit(_worker, job) for job in jobs]
for j, fut in enumerate(as_completed(futs), 1):
try:
_merge(fut.result())
except Exception as exc:
if not args.quiet:
print(f" worker error: {exc}", flush=True)
if not args.quiet and j % 200 == 0:
print(f" series {j}/{len(jobs)}", flush=True)
# --- Harness signal_eval (authoritative unconditional ICs) ---
harness_rows = _signal_evaluation(dict(collected))
harness_by_name = {r["signal"]: r for r in harness_rows}
top_n = int(args.top_n)
min_price = float(args.min_price)
fip_weeks = collected.get("fip_id") or {}
mom_weeks = collected.get("mom_12_1") or {}
vol_weeks = collected.get("vol_6m") or {}
momr_weeks = collected.get("mom_12_1_resid") or {}
# Index mom/vol by (week, symbol) for joins
def _index(weeks_map: dict) -> dict[tuple, dict]:
out: dict[tuple, dict] = {}
for wk, recs in weeks_map.items():
key_wk = tuple(wk) if not isinstance(wk, tuple) else wk
for rec in recs:
if not isinstance(rec, dict):
continue
sym = rec.get("symbol")
if not sym:
continue
out[(key_wk, str(sym))] = rec
return out
mom_ix = _index(mom_weeks)
vol_ix = _index(vol_weeks)
momr_ix = _index(momr_weeks)
# Per-week membership + extended checks via shared rich filter
same_week: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
lag_week: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
tier_hi: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
tier_lo: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
prod_sub: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
mom_cond: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
vol_pairs: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
mom_pairs: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
momr_pairs: dict[tuple, list[tuple[float, float]]] = defaultdict(list)
ordered = sorted((tuple(w) for w in fip_weeks.keys()), key=_week_ord)
prev: dict[tuple, tuple] = {}
for i, wk in enumerate(ordered):
if i:
prev[wk] = ordered[i - 1]
# Prior-week dvol for lag: (symbol, week) from fip recs
dvol_sw: dict[tuple[str, tuple], float] = {}
for wk, recs in fip_weeks.items():
key_wk = tuple(wk) if not isinstance(wk, tuple) else wk
for rec in recs:
if isinstance(rec, dict) and rec.get("symbol") and rec.get("median_dvol_63"):
dvol_sw[(str(rec["symbol"]), key_wk)] = float(rec["median_dvol_63"])
membership_dumps: list[dict] = []
dump_count = 0
stride = max(1, round(HORIZON / 5))
dump_weeks = _nonoverlap(ordered, stride)[: max(0, int(args.dump_weeks))]
for wk_raw, recs in fip_weeks.items():
wk = tuple(wk_raw) if not isinstance(wk_raw, tuple) else wk_raw
stats = _liquid_breadth_week_stats(recs, top_n=top_n, min_price=min_price)
rich = _filter_liquid_breadth_week_rich(
recs, top_n=top_n, min_price=min_price
)
for rank, row in enumerate(rich, 1):
same_week[wk].append((float(row["val"]), float(row["fwd"])))
if rank <= 800:
tier_hi[wk].append((float(row["val"]), float(row["fwd"])))
elif rank <= top_n:
tier_lo[wk].append((float(row["val"]), float(row["fwd"])))
sym = row.get("symbol")
if sym and str(sym) in prod_symbols:
prod_sub[wk].append((float(row["val"]), float(row["fwd"])))
# Join mom for conditional
mrec = mom_ix.get((wk, str(sym))) if sym else None
if mrec is not None:
row["mom_12_1"] = mrec.get("val")
# Mom-conditional among liquid fip set
with_mom = [
r for r in rich
if r.get("mom_12_1") is not None or mom_ix.get((wk, str(r.get("symbol"))))
]
# ensure mom filled
for r in with_mom:
if r.get("mom_12_1") is None and r.get("symbol"):
m = mom_ix.get((wk, str(r["symbol"])))
if m is not None:
r["mom_12_1"] = m["val"]
with_mom = [r for r in rich if r.get("mom_12_1") is not None]
if len(with_mom) >= MIN_CROSS:
with_mom.sort(key=lambda r: float(r["mom_12_1"]))
cut = int(math.floor(len(with_mom) * (MOM_WINNER_PCT / 100.0)))
for r in with_mom[cut:]:
mom_cond[wk].append((float(r["val"]), float(r["fwd"])))
# Context signals via same shared filter on their own pools
for r in _filter_liquid_breadth_week_rich(
vol_weeks.get(wk_raw) or vol_weeks.get(wk) or [],
top_n=top_n,
min_price=min_price,
):
vol_pairs[wk].append((float(r["val"]), float(r["fwd"])))
for r in _filter_liquid_breadth_week_rich(
mom_weeks.get(wk_raw) or mom_weeks.get(wk) or [],
top_n=top_n,
min_price=min_price,
):
mom_pairs[wk].append((float(r["val"]), float(r["fwd"])))
for r in _filter_liquid_breadth_week_rich(
momr_weeks.get(wk_raw) or momr_weeks.get(wk) or [],
top_n=top_n,
min_price=min_price,
):
momr_pairs[wk].append((float(r["val"]), float(r["fwd"])))
# Lagged membership using prior week dvol on current fip pool
pw = prev.get(wk)
if pw is not None:
lagged_recs = []
for rec in recs:
if not isinstance(rec, dict) or not rec.get("symbol"):
continue
pdv = dvol_sw.get((str(rec["symbol"]), pw))
if pdv is None or pdv <= 0:
continue
# Clone with lag dvol for ranking
lagged_recs.append({
**rec,
"median_dvol_63": pdv,
})
for r in _filter_liquid_breadth_week_rich(
lagged_recs, top_n=top_n, min_price=min_price
):
lag_week[wk].append((float(r["val"]), float(r["fwd"])))
if wk in dump_weeks and dump_count < args.dump_weeks:
membership_dumps.append({
"week": list(wk),
"stats": stats,
"symbols": sorted(
str(r["symbol"]) for r in rich if r.get("symbol")
),
"n_symbols": len(rich),
})
dump_count += 1
# IC rows
checks = {
"fip_harness_signal_eval": {
"note": "Authoritative harness _signal_evaluation on collected fip_id",
**(harness_by_name.get("fip_id") or {}),
},
"fip_same_week_via_shared_filter": {
"note": "Same collected data, IC via shared _filter_liquid_breadth_week_rich",
**_ic_from_weekly(same_week),
},
"fip_lagged_membership_1w": {
"note": "Top-N by prior-week $vol on current fip pool (shared filter)",
**_ic_from_weekly(lag_week),
},
"fip_tier_1_800": {
"note": "Senior liquid ranks 1800",
**_ic_from_weekly(tier_hi),
},
"fip_tier_801_1500": {
"note": "Junior liquid ranks 801top_n",
**_ic_from_weekly(tier_lo),
},
"fip_prod_universe_subset": {
"note": "Prod.sqlite symbols inside liquid fip set",
**_ic_from_weekly(prod_sub),
},
"fip_momentum_conditional_top20pct": {
"note": (
f"Among liquid fip set, mom_12_1 ≥ P{MOM_WINNER_PCT:.0f} "
"(paper / gate-relevant)"
),
**_ic_from_weekly(mom_cond),
},
"vol_6m_liquid": {
"note": "vol_6m through shared filter",
**_ic_from_weekly(vol_pairs),
},
"mom_12_1_liquid": {
"note": "raw mom through shared filter",
**_ic_from_weekly(mom_pairs),
},
"mom_12_1_resid_liquid": {
"note": "residual mom through shared filter",
**_ic_from_weekly(momr_pairs),
},
}
h = checks["fip_harness_signal_eval"]
s = checks["fip_same_week_via_shared_filter"]
cond = checks["fip_momentum_conditional_top20pct"]
prod = checks["fip_prod_universe_subset"]
hi = checks["fip_tier_1_800"]
lo = checks["fip_tier_801_1500"]
lag = checks["fip_lagged_membership_1w"]
# Self-consistency: harness eval vs manual IC on same filter must match
harness_ic = h.get("mean_ic")
shared_ic = s.get("mean_ic")
consistent = (
harness_ic is not None
and shared_ic is not None
and abs(float(harness_ic) - float(shared_ic)) < 0.005
)
mom_alive = (
cond.get("mean_ic") is not None
and float(cond["mean_ic"]) < 0
and abs(float(cond["mean_ic"])) >= 0.03
and bool(cond.get("reliable"))
)
results = {
"generated_at": datetime.now().isoformat(),
"research_snapshot": str(research.resolve()),
"top_n": top_n,
"min_price": min_price,
"prod_subset_n": len(prod_symbols),
"panel_tickers": len(jobs),
"single_source": (
"diagnostics uses harness _signal_series + "
"_filter_liquid_breadth_week_rich only (no parallel mask)"
),
"avg_cross_section_semantics": (
"avg_cross_section = post-mask IC sample size. "
"avg_raw_pool = pre-filter observations. "
"avg_eligible_pre_mask = pass price+dvol before top-N. "
"mask_binds_pct = weeks where eligible_pre_mask > top_n."
),
"harness_self_consistent": consistent,
"checks": checks,
"membership_dumps": membership_dumps,
"interpretation": {
"harness_and_shared_filter_agree": consistent,
"mask_binds_pct": h.get("mask_binds_pct"),
"avg_eligible_pre_mask": h.get("avg_eligible_pre_mask"),
"avg_raw_pool": h.get("avg_raw_pool"),
"prod_subset_still_negative": (
prod.get("mean_ic") is not None and float(prod["mean_ic"]) < 0
),
"junior_tier_more_positive": (
lo.get("mean_ic") is not None
and hi.get("mean_ic") is not None
and float(lo["mean_ic"]) > float(hi["mean_ic"])
),
"lag_same_sign_as_same_week": (
lag.get("mean_ic") is not None
and s.get("mean_ic") is not None
and (float(lag["mean_ic"]) < 0) == (float(s["mean_ic"]) < 0)
),
"mom_conditional_negative_and_reliable": mom_alive,
"orphan_plus_five_sigma": (
"Orphaned 21:14 row (+0.0575 / t +5.12) raced a partial "
"research.sqlite and was removed from reports/ (Git history only). "
"Harness path and shared filter agree on complete data."
),
"compositional_story": (
"fip_id pools continuous winners (neg IC) vs continuous bleeders "
"(pos IC). Prod-subset and senior liquid stay negative; junior "
"liquid is less negative / positive — composition, not jumpiness premium."
),
"vol_tilt_warning": (
"Authoritative liquid vol_6m IC ≈ 0.048 / t ≈ 1.36 — directional "
"hypothesis only, not significant. Do not cite the orphaned 0.16 / "
"t 6.1. Re-validate production 80/20 high-vol tilt before any "
"universe broaden; it is not a settled finding on this pool."
),
"breadth_momentum_thesis": (
"Residual mom on liquid-1500 is +0.029 / t +1.33 vs fingerprint "
"0.055 / t 1.98 on 505 names — more breadth did not strengthen the "
"momentum t-stat on this pool. Clean mom edge lives in the large-cap "
"universe already traded. A fip tilt presupposes a breadth mom book "
"worth tilting; that baseline must be proven first."
),
},
"platform_verdict": (
"Mom-conditional fip ALIVE as book-tilt candidate only — requires a "
"pre-registered two-arm breadth book (baseline liquid-1500 mom vs +fip "
"tilt) before any gate talk. Unconditional fip not green. Production: none."
if mom_alive
else (
"fip CLOSED for production: mom-conditional does not clear iron rule "
"on single-sourced path. Display card is the resting place."
)
),
"research_snapshot_manifest": {
"finished_at": manifest.get("finished_at"),
"ticker_count": manifest.get("ticker_count"),
"ohlcv_row_count": manifest.get("ohlcv_row_count"),
"rank_only_count": manifest.get("rank_only_count"),
"complete": manifest.get("complete"),
},
}
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = Path(args.out) if args.out else Path("reports") / f"fip-reconcile-{stamp}.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(results, indent=2, default=str), encoding="utf-8")
# Append a machine reconciliation stub next to the JSON only — never clobber
# the curated research log at docs/research/fip-breadth-ic.md.
_update_md(out.with_suffix(".md"), results, out)
if not args.quiet:
print("=== Harness fip_id (authoritative) ===")
print(json.dumps(h, indent=2, default=str))
print("=== Shared-filter same-week (must match) ===")
print(json.dumps(s, indent=2, default=str))
print("=== Mom-conditional ===")
print(json.dumps(cond, indent=2, default=str))
print("self_consistent:", consistent)
print("platform_verdict:", results["platform_verdict"])
print(f"Wrote {out}")
def _update_md(path: Path, results: dict, artifact: Path) -> None:
checks = results["checks"]
interp = results["interpretation"]
h = checks.get("fip_harness_signal_eval") or {}
lines = [
"",
"---",
"",
f"## Reconciliation ({results['generated_at'][:10]})",
"",
"### Problem",
"",
"Machine stub only — curated narrative lives in `docs/research/fip-breadth-ic.md`.",
"",
f"- **Single source:** {results.get('single_source')}",
f"- Harness vs shared-filter agree: "
f"**{interp.get('harness_and_shared_filter_agree')}**",
"",
"### Authoritative unconditional fip (liquid top-N, post-mask)",
"",
f"| metric | value |",
f"|---|---|",
f"| mean_ic | {h.get('mean_ic')} |",
f"| ic_t_stat | {h.get('ic_t_stat')} |",
f"| weeks | {h.get('weeks')} |",
f"| avg_cross_section (post-mask) | {h.get('avg_cross_section')} |",
f"| avg_raw_pool | {h.get('avg_raw_pool')} |",
f"| avg_eligible_pre_mask | {h.get('avg_eligible_pre_mask')} |",
f"| mask_binds_pct | {h.get('mask_binds_pct')} |",
f"| reliable | {h.get('reliable')} |",
"",
"### Checks (single-sourced)",
"",
"| check | mean_ic | t | weeks | avg N | reliable |",
"|---|---:|---:|---:|---:|---|",
]
for key in [
"fip_harness_signal_eval",
"fip_same_week_via_shared_filter",
"fip_lagged_membership_1w",
"fip_tier_1_800",
"fip_tier_801_1500",
"fip_prod_universe_subset",
"fip_momentum_conditional_top20pct",
"vol_6m_liquid",
"mom_12_1_liquid",
"mom_12_1_resid_liquid",
]:
row = checks.get(key) or {}
lines.append(
f"| {key} | {row.get('mean_ic')} | {row.get('ic_t_stat')} | "
f"{row.get('weeks')} | {row.get('avg_cross_section')} | {row.get('reliable')} |"
)
lines.extend([
"",
"### Flags",
"",
f"- Prod subset still negative: **{interp.get('prod_subset_still_negative')}**",
f"- Junior tier more positive than senior: **{interp.get('junior_tier_more_positive')}**",
f"- Lag same sign as same-week: **{interp.get('lag_same_sign_as_same_week')}**",
f"- Mom-conditional negative + reliable: **{interp.get('mom_conditional_negative_and_reliable')}**",
"",
"### Platform verdict (post-reconciliation)",
"",
results.get("platform_verdict", ""),
"",
"### Vol-tilt / breadth-momentum notes",
"",
interp.get("vol_tilt_warning", ""),
"",
interp.get("breadth_momentum_thesis", ""),
"",
f"Artifact: `{artifact.as_posix()}`",
"",
])
# Always overwrite the machine stub (never the curated research log).
path.write_text("\n".join(lines).lstrip() + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
+315
View File
@@ -0,0 +1,315 @@
"""Phase B: fip_id IC on liquid-breadth cross-section (local research only).
1. Fingerprint check on the unextended prod snapshot (must IC 0.045 / t 2.9).
2. Assert research.sqlite has a matching **completion manifest** (race guard).
3. Run signal_eval on research.sqlite with BACKTEST_LIQUID_BREADTH=1500 PIT mask.
4. Write a research report under docs/research/ and reports/.
Does not modify production DB, gate, scanner, or schedule.
Example
-------
# After extend_snapshot_universe.py has built research.sqlite:
python scripts/run_fip_breadth_research.py \\
--prod-snapshot backtest_snapshots/prod.sqlite \\
--research-snapshot backtest_snapshots/research.sqlite \\
--workers 6 --allow-spawn
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
FINGERPRINT_IC = -0.045
FINGERPRINT_T = -2.9
FINGERPRINT_IC_TOL = 0.015
FINGERPRINT_T_TOL = 0.6
def _sqlite_url(path: Path) -> str:
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--prod-snapshot", default="backtest_snapshots/prod.sqlite")
p.add_argument("--research-snapshot", default="backtest_snapshots/research.sqlite")
p.add_argument("--workers", type=int, default=6)
p.add_argument("--allow-spawn", action="store_true")
p.add_argument("--skip-fingerprint", action="store_true")
p.add_argument("--skip-research", action="store_true")
p.add_argument("--liquid-breadth", type=int, default=1500)
p.add_argument("--min-price", type=float, default=5.0)
p.add_argument(
"--out",
default=None,
help="JSON report path (default reports/fip-breadth-YYYYMMDD.json)",
)
p.add_argument("--quiet", action="store_true")
return p.parse_args()
def _find_fip(signal_eval: list[dict]) -> dict | None:
for row in signal_eval or []:
if row.get("signal") == "fip_id":
return row
return None
def _verdict(row: dict | None) -> dict:
if row is None:
return {
"green": False,
"reason": "fip_id missing from signal_eval",
}
mean_ic = row.get("mean_ic")
t_stat = row.get("ic_t_stat")
reliable = bool(row.get("reliable"))
if mean_ic is None or t_stat is None:
return {"green": False, "reason": "missing mean_ic or ic_t_stat", "row": row}
sign_ok = mean_ic < 0
mag_ok = abs(float(mean_ic)) >= 0.03
green = sign_ok and mag_ok and reliable
return {
"green": green,
"reason": (
"iron rule cleared — follow-up proposal only, not production wire-in"
if green
else "iron rule not met on liquid-breadth cross-section"
),
"checks": {
"mean_ic": mean_ic,
"abs_mean_ic_ge_0_03": mag_ok,
"sign_negative": sign_ok,
"ic_t_stat": t_stat,
"reliable": reliable,
"weeks": row.get("weeks"),
"avg_cross_section": row.get("avg_cross_section"),
},
"row": row,
}
async def _run_signal_eval(snapshot: Path, *, workers: int, quiet: bool) -> dict:
from app.config import settings
from app.services.backtest_service import run_backtest
settings.backtest_workers = workers
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
def progress(done: int, total: int, symbol: str) -> None:
if quiet:
return
print(f" progress {done}/{total} {symbol}", end="\r")
try:
async with Session() as db:
report = await run_backtest(db, progress_cb=progress, cadence="weekly")
finally:
await engine.dispose()
if not quiet:
print()
return report
def _write_md(path: Path, payload: dict) -> None:
fp = payload.get("fingerprint") or {}
br = payload.get("breadth") or {}
v = payload.get("verdict") or {}
lines = [
"# Broad-universe fip_id IC research (Phase B)",
"",
f"Generated: {payload.get('generated_at')}",
"",
"## Scope",
"",
"- **Research only** — production universe, gate, scanner, schedule unchanged.",
"- Price-only signal harness; no sentiment/fundamentals on the broad tier.",
"- Point-in-time liquidity mask: top "
f"**{payload.get('liquid_breadth_top_n')}** by 63d median $vol, "
f"price ≥ **${payload.get('liquid_min_price')}** at as-of.",
"",
"## Caveats",
"",
"- **Survivorship bias**: today's constituents backfilled historically "
"(worse in small caps).",
"- **IEX volume undercount**: relative $vol rank only, not absolute floors.",
"- **Pool skew**: nasdaq_all sp500 tilts tech/biotech; missing pure NYSE mid-caps.",
"",
"## Fingerprint (505-name prod snapshot)",
"",
f"- Expected: IC ≈ {FINGERPRINT_IC}, t ≈ {FINGERPRINT_T}",
f"- Observed: IC = {fp.get('mean_ic')}, t = {fp.get('ic_t_stat')}, "
f"weeks = {fp.get('weeks')}, reliable = {fp.get('reliable')}",
f"- Pass: **{fp.get('pass')}**",
"",
"## Liquid-breadth signal_eval (fip_id)",
"",
]
row = br.get("row") or br
if row:
lines.extend([
f"| metric | value |",
f"|---|---|",
f"| mean_ic | {row.get('mean_ic')} |",
f"| ic_t_stat | {row.get('ic_t_stat')} |",
f"| ic_positive_pct | {row.get('ic_positive_pct')} |",
f"| weeks | {row.get('weeks')} |",
f"| avg_cross_section | {row.get('avg_cross_section')} |",
f"| reliable | {row.get('reliable')} |",
f"| mean_quintile_spread | {row.get('mean_quintile_spread')} |",
"",
])
else:
lines.append("_No breadth result (run skipped or failed)._")
lines.append("")
lines.extend([
"## Verdict (iron rule)",
"",
f"- **Green: {v.get('green')}**",
f"- {v.get('reason')}",
f"- Checks: `{json.dumps(v.get('checks') or {}, default=str)}`",
"",
"A green verdict authorizes a **follow-up proposal** only "
"(two-tier universe / gate revalidation) — **not** production wire-in.",
"",
"## Artifacts",
"",
f"- Fingerprint report: `{payload.get('fingerprint_report_path')}`",
f"- Breadth report: `{payload.get('breadth_report_path')}`",
"",
])
path.write_text("\n".join(lines), encoding="utf-8")
async def _main() -> None:
args = _parse_args()
prod = Path(args.prod_snapshot)
research = Path(args.research_snapshot)
if not prod.exists():
raise SystemExit(f"Prod snapshot missing: {prod}")
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
if args.allow_spawn:
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
os.environ["BACKTEST_SIGNAL_EVAL_ONLY"] = "1"
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out_json = Path(args.out) if args.out else Path("reports") / f"fip-breadth-{stamp}.json"
out_json.parent.mkdir(parents=True, exist_ok=True)
# Never clobber the curated research log (docs/research/fip-breadth-ic.md).
# Machine summary goes next to the JSON report only.
out_md = out_json.with_suffix(".md")
payload: dict = {
"generated_at": datetime.now().isoformat(),
"liquid_breadth_top_n": args.liquid_breadth,
"liquid_min_price": args.min_price,
"fingerprint": None,
"breadth": None,
"verdict": None,
}
# --- 1) Fingerprint ---
if not args.skip_fingerprint:
# Clear liquid breadth for fingerprint
os.environ.pop("BACKTEST_LIQUID_BREADTH", None)
os.environ.pop("BACKTEST_LIQUID_MIN_PRICE", None)
if not args.quiet:
print(f"Fingerprint run on {prod}")
fp_report = await _run_signal_eval(prod, workers=args.workers, quiet=args.quiet)
fp_path = out_json.with_name(out_json.stem + "-fingerprint.json")
fp_path.write_text(json.dumps(fp_report, indent=2, default=str), encoding="utf-8")
fip = _find_fip(fp_report.get("signal_eval") or [])
if fip is None:
raise SystemExit("ABORT: fip_id missing from fingerprint signal_eval")
ic_ok = abs(float(fip["mean_ic"]) - FINGERPRINT_IC) <= FINGERPRINT_IC_TOL
t_ok = abs(float(fip["ic_t_stat"]) - FINGERPRINT_T) <= FINGERPRINT_T_TOL
passed = ic_ok and t_ok and bool(fip.get("reliable"))
payload["fingerprint"] = {
**fip,
"pass": passed,
"expected_ic": FINGERPRINT_IC,
"expected_t": FINGERPRINT_T,
}
payload["fingerprint_report_path"] = str(fp_path)
if not args.quiet:
print(
f"Fingerprint fip_id IC={fip.get('mean_ic')} t={fip.get('ic_t_stat')} "
f"pass={passed}"
)
if not passed:
out_json.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
raise SystemExit(
"ABORT: fingerprint mismatch — investigate before trusting breadth runs "
f"(got IC={fip.get('mean_ic')} t={fip.get('ic_t_stat')})"
)
# --- 2) Breadth ---
if not args.skip_research:
# Refuse half-built research.sqlite (2026-07-18 21:14 race).
scripts_dir = Path(__file__).resolve().parent
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from research_snapshot_manifest import ( # type: ignore[import-not-found]
assert_research_snapshot_complete,
)
manifest = assert_research_snapshot_complete(research)
payload["research_snapshot_manifest"] = {
"finished_at": manifest.get("finished_at"),
"ticker_count": manifest.get("ticker_count"),
"ohlcv_row_count": manifest.get("ohlcv_row_count"),
"rank_only_count": manifest.get("rank_only_count"),
"complete": manifest.get("complete"),
}
os.environ["BACKTEST_LIQUID_BREADTH"] = str(int(args.liquid_breadth))
os.environ["BACKTEST_LIQUID_MIN_PRICE"] = str(float(args.min_price))
if not args.quiet:
print(
f"Breadth run on {research} "
f"(top {args.liquid_breadth}, min_price={args.min_price}; "
f"manifest ok tickers={manifest.get('ticker_count')} "
f"finished_at={manifest.get('finished_at')})…"
)
br_report = await _run_signal_eval(
research, workers=args.workers, quiet=args.quiet
)
br_path = out_json.with_name(out_json.stem + "-breadth.json")
br_path.write_text(json.dumps(br_report, indent=2, default=str), encoding="utf-8")
fip_b = _find_fip(br_report.get("signal_eval") or [])
payload["breadth"] = fip_b or {"error": "fip_id missing"}
payload["breadth_report_path"] = str(br_path)
payload["breadth_tickers"] = br_report.get("tickers")
payload["breadth_rank_only_tickers"] = br_report.get("rank_only_tickers")
payload["verdict"] = _verdict(fip_b)
if not args.quiet:
print(
f"Breadth fip_id IC={ (fip_b or {}).get('mean_ic') } "
f"t={ (fip_b or {}).get('ic_t_stat') } "
f"green={payload['verdict'].get('green')}"
)
out_json.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
out_md.parent.mkdir(parents=True, exist_ok=True)
_write_md(out_md, payload)
if not args.quiet:
print(f"Wrote {out_json}")
print(f"Wrote {out_md}")
if __name__ == "__main__":
asyncio.run(_main())
+706
View File
@@ -0,0 +1,706 @@
#!/usr/bin/env python3
"""Production book × universe × horizon matrix (research only).
Four pre-registered arms same live strategy knobs; only entry start date and
tradable universe change. See docs/research/prod-book-universe-horizon.md.
A 2022-07-01 prod ~505
B 2022-07-01 prod liquid top-1500
C 2016-07-01 prod ~505
D 2016-07-01 prod liquid top-1500
Example (MacBook, deep research.sqlite)
---------------------------------------
python scripts/run_prod_book_universe_matrix.py \\
--snapshot backtest_snapshots/research.sqlite \\
--workers 8 --allow-spawn \\
--candidate-cache reports/.cache/prod-book-univ-cands.pkl
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import pickle
import sys
import time
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor
from datetime import date, datetime
from pathlib import Path
from typing import Any
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.ssl_bootstrap import bootstrap_ssl # noqa: E402
bootstrap_ssl()
SHORT_START = date(2022, 7, 1)
LONG_START = date(2016, 7, 1)
LIQUID_TOP_N = 1500
LIQUID_MIN_PRICE = 5.0
CACHE_VERSION = "prod-book-universe-horizon-v1"
ARMS: tuple[dict[str, Any], ...] = (
{
"id": "A_prod_4y_505",
"label": "Prod book · ~4y · 505 only",
"start": SHORT_START,
"universe": "prod_505",
},
{
"id": "B_prod_4y_505_liquid",
"label": "Prod book · ~4y · 505 + liquid top-1500",
"start": SHORT_START,
"universe": "prod_plus_liquid",
},
{
"id": "C_prod_2016_505",
"label": "Prod book · since 2016-07 · 505 only",
"start": LONG_START,
"universe": "prod_505",
},
{
"id": "D_prod_2016_505_liquid",
"label": "Prod book · since 2016-07 · 505 + liquid top-1500",
"start": LONG_START,
"universe": "prod_plus_liquid",
},
)
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--snapshot", default="backtest_snapshots/research.sqlite")
p.add_argument("--workers", type=int, default=8)
p.add_argument("--allow-spawn", action="store_true")
p.add_argument("--quiet", action="store_true")
p.add_argument(
"--candidate-cache",
default="reports/.cache/prod-book-universe-cands.pkl",
help="Pickle cache for full GTL candidate pass (expensive).",
)
p.add_argument(
"--rebuild-cache",
action="store_true",
help="Ignore existing candidate cache.",
)
p.add_argument("--out", default=None)
p.add_argument(
"--skip-race-guard",
action="store_true",
help="Allow run without completion manifest (not recommended).",
)
return p.parse_args()
def _sqlite_url(path: Path) -> str:
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
def _load_prod_and_all_symbols(snapshot: Path) -> tuple[set[str], list[str]]:
engine = create_engine(
f"sqlite:///{snapshot.resolve().as_posix()}",
future=True,
)
try:
with engine.connect() as conn:
all_syms = [
str(r[0]).upper()
for r in conn.execute(text("SELECT symbol FROM tickers ORDER BY 1"))
]
try:
rank_only = {
str(r[0]).upper()
for r in conn.execute(text("SELECT symbol FROM research_rank_only"))
}
except Exception:
rank_only = set()
finally:
engine.dispose()
prod = {s for s in all_syms if s not in rank_only}
return prod, all_syms
def _median(xs: list[float]) -> float | None:
if len(xs) < 20:
return None
s = sorted(xs)
mid = len(s) // 2
if len(s) % 2:
return s[mid]
return 0.5 * (s[mid - 1] + s[mid])
def _build_liquid_membership(
prices: dict[str, tuple],
*,
top_n: int,
min_price: float,
) -> dict[date, set[str]]:
"""For each calendar date present in any series, top-N by 63d median $vol."""
# Collect per-symbol (date -> (close, dvol63))
per_sym: dict[str, dict[date, tuple[float, float | None]]] = {}
all_dates: set[date] = set()
for sym, cols in prices.items():
ords, _o, _h, _l, closes, vols = cols
dates = [date.fromordinal(int(o)) for o in ords]
n = len(dates)
series: dict[date, tuple[float, float | None]] = {}
for i in range(n):
d = dates[i]
c = float(closes[i])
dvol = None
if i + 1 >= 63:
dvs = []
for k in range(i - 62, i + 1):
ck = float(closes[k])
vk = float(vols[k] or 0)
if ck > 0 and vk >= 0:
dvs.append(ck * vk)
dvol = _median(dvs)
series[d] = (c, dvol)
all_dates.add(d)
per_sym[sym] = series
membership: dict[date, set[str]] = {}
for d in sorted(all_dates):
eligible: list[tuple[float, str]] = []
for sym, series in per_sym.items():
row = series.get(d)
if row is None:
continue
c, dvol = row
if c < min_price or dvol is None or dvol <= 0:
continue
eligible.append((-dvol, sym)) # highest dvol first
eligible.sort()
membership[d] = {sym for _, sym in eligible[:top_n]}
return membership
def _worker_replay(
symbol: str,
columns: tuple,
config: dict,
activation: dict,
spy: dict,
cadence: str,
) -> list[dict]:
"""Picklable full GTL+signals candidate replay (no signal-only)."""
from app.services import backtest_service as bt
cands, _series = bt._replay_and_signals(
symbol,
columns,
config,
activation,
spy,
bt.PRODUCTION_GTL_TARGET_MODEL,
cadence,
False, # always full replay for book matrix
None,
None,
)
return cands
async def _load_or_build_candidates(
snapshot: Path,
*,
cache_path: Path | None,
rebuild: bool,
workers: int,
quiet: bool,
) -> tuple[list[dict], dict[str, tuple], dict, set[str], dict]:
from app.config import settings
from app.services import backtest_service as bt
from app.services.admin_service import get_activation_config
from app.services.recommendation_service import get_recommendation_config
from app.services.paper_trade_service import get_exit_policy
from app.services.benchmark_service import load_benchmark_closes
from app.models.ticker import Ticker
from sqlalchemy import select
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
settings.backtest_workers = max(1, workers)
prod_set, all_syms = _load_prod_and_all_symbols(snapshot)
print(f"Symbols: all={len(all_syms)} prod_505={len(prod_set)}")
cache_key = {
"version": CACHE_VERSION,
"snapshot": str(snapshot.resolve()),
"prod_n": len(prod_set),
"all_n": len(all_syms),
}
if cache_path and cache_path.exists() and not rebuild:
with cache_path.open("rb") as fh:
blob = pickle.load(fh)
if blob.get("key") == cache_key and blob.get("candidates"):
print(f"Loaded candidate cache: {cache_path} ({len(blob['candidates'])} rows)")
return (
blob["candidates"],
blob["prices"],
blob["spy"],
set(blob["prod_set"]),
blob["exit_config"],
)
print("Cache key mismatch — rebuilding candidates")
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
candidates: list[dict] = []
prices: dict[str, tuple] = {}
try:
async with Session() as db:
config = await get_recommendation_config(db)
activation = await get_activation_config(db)
exit_config = await get_exit_policy(db)
spy = await load_benchmark_closes(db, "SPY")
tickers = list(
(await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars()
)
# Fetch all price columns first (I/O).
for idx, t in enumerate(tickers):
if not quiet and idx % 100 == 0:
print(f" fetch prices {idx}/{len(tickers)}", end="\r", flush=True)
cols = await bt._fetch_columns(db, t.symbol)
if cols is not None:
prices[t.symbol.upper()] = cols
if not quiet:
print()
# Parallel GTL replay for every symbol with prices.
syms = sorted(prices)
print(f"GTL replay on {len(syms)} symbols (workers={workers})…")
t0 = time.monotonic()
if workers <= 1:
for i, sym in enumerate(syms):
if not quiet and i % 50 == 0:
print(f" replay {i}/{len(syms)}", end="\r", flush=True)
candidates.extend(
_worker_replay(
sym, prices[sym], config, activation, spy, "weekly"
)
)
else:
# Process pool: pass column batches.
import multiprocessing as mp
ctx = mp.get_context("spawn")
chunk = max(1, workers * 2)
with ProcessPoolExecutor(max_workers=workers, mp_context=ctx) as pool:
for start in range(0, len(syms), chunk):
batch = syms[start : start + chunk]
futs = [
pool.submit(
_worker_replay,
sym,
prices[sym],
config,
activation,
spy,
"weekly",
)
for sym in batch
]
for fut in futs:
try:
candidates.extend(fut.result())
except Exception as exc:
print(f" worker error: {exc}")
if not quiet:
print(
f" replay {min(start+chunk, len(syms))}/{len(syms)} "
f"cands={len(candidates)} "
f"elapsed={(time.monotonic()-t0)/60:.1f}m",
end="\r",
flush=True,
)
if not quiet:
print()
finally:
await engine.dispose()
print(f"Total raw candidates: {len(candidates)}")
if cache_path:
cache_path.parent.mkdir(parents=True, exist_ok=True)
with cache_path.open("wb") as fh:
pickle.dump(
{
"key": cache_key,
"candidates": candidates,
"prices": prices,
"spy": spy,
"prod_set": sorted(prod_set),
"exit_config": exit_config,
},
fh,
protocol=pickle.HIGHEST_PROTOCOL,
)
print(f"Wrote cache {cache_path}")
return candidates, prices, spy, prod_set, exit_config
def _candidate_eligible(
cand: dict,
*,
prod_set: set[str],
universe: str,
liquid_by_date: dict[date, set[str]],
) -> bool:
if cand.get("direction") != "long":
return False
sym = str(cand.get("symbol") or "").upper()
if not sym:
return False
if universe == "prod_505":
return sym in prod_set
# prod_plus_liquid
if sym in prod_set:
return True
try:
d = date.fromisoformat(str(cand["date"])[:10])
except Exception:
return False
return sym in (liquid_by_date.get(d) or set())
def _run_arm(
arm: dict[str, Any],
*,
all_candidates: list[dict],
prices: dict[str, tuple],
spy: dict,
prod_set: set[str],
liquid_by_date: dict[date, set[str]],
exit_config: dict,
) -> dict[str, Any]:
from app.services import backtest_service as bt
start: date = arm["start"]
universe: str = arm["universe"]
filtered: list[dict] = []
for c in all_candidates:
try:
d = date.fromisoformat(str(c["date"])[:10])
except Exception:
continue
if d < start:
continue
if not _candidate_eligible(
c, prod_set=prod_set, universe=universe, liquid_by_date=liquid_by_date
):
continue
filtered.append(dict(c))
# Re-rank inside this arm's universe (production percentile logic).
bt._assign_momentum_percentiles(filtered)
bt._assign_residual_momentum_percentiles(filtered)
bt._assign_low_volatility_percentiles(filtered)
bt._assign_activation_momentum_percentiles(filtered)
bt._assign_residual_high_vol_blend(filtered)
for c in filtered:
c["qualified"] = bt._momentum_qualifies(c, 80.0)
longs = [
c for c in filtered if c.get("qualified") and c.get("direction") == "long"
]
strategy = next(s for s in bt.PORTFOLIO_MONITOR_STRATEGIES if s.get("is_production"))
entry_cfg = bt._entry_variant_config(str(strategy["entry_variant"]))
assert entry_cfg is not None
ranking_key = str(
entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]
)
exit_policy = bt.LIVE_EXIT_MODE_TO_SIM.get(
str(exit_config.get("mode", "atr_trailing")), "atr_trail3"
)
hold_days = int(exit_config.get("hold_days", 30))
trail = float(exit_config.get("atr_multiplier", bt.ATR_TRAIL_MULTIPLIER))
risk = float(entry_cfg["risk_per_trade"])
max_pos = int(entry_cfg["max_positions"])
reentry = bt._make_gate_reset_reentry_fn(
longs, prices, cadence="weekly", ranking_key=ranking_key
)
sim = bt._simulate_portfolio(
longs,
prices,
spy,
exit_policy,
hold_days,
ranking_key=ranking_key,
max_positions=max_pos,
risk_per_trade=risk,
atr_trail_multiplier=trail,
post_stop_reentry_fn=reentry,
start_date=start,
end_date=None,
fill_mode=bt.FILL_MODE_CLOSE,
include_trades=False,
)
if sim is None:
return {
"id": arm["id"],
"label": arm["label"],
"start": start.isoformat(),
"universe": universe,
"n_candidates": len(filtered),
"n_qualified_longs": 0,
"error": "no_trades",
}
keep = {
k: sim.get(k)
for k in (
"sharpe",
"sharpe_se",
"cagr_pct",
"max_drawdown_pct",
"total_return_pct",
"calmar",
"trades",
"win_rate",
"n_returns",
"psr",
"start_date",
"end_date",
"spy_return_pct",
"final_equity",
)
}
return {
"id": arm["id"],
"label": arm["label"],
"start": start.isoformat(),
"universe": universe,
"n_candidates": len(filtered),
"n_qualified_longs": len(longs),
"fill_mode": "close",
"ranking_key": ranking_key,
"exit_policy": exit_policy,
"hold_days": hold_days,
**keep,
}
def _write_outputs(payload: dict, out_json: Path, doc_path: Path) -> None:
out_json.parent.mkdir(parents=True, exist_ok=True)
out_json.write_text(
json.dumps(payload, indent=2, default=str) + "\n", encoding="utf-8"
)
lines = [
"# Production book × universe × horizon — results",
"",
f"Generated: `{payload.get('generated_at')}`",
"",
"> Survivorship: today's constituents backfilled. Compare arms relatively; "
"do not treat deep CAGR/Sharpe levels as deployable forecasts.",
"",
"## Arms",
"",
"| arm | universe | entry start | Sharpe | SE | CAGR % | max DD % | ret % | trades | qual longs | span |",
"|---|---|---|---:|---:|---:|---:|---:|---:|---:|---|",
]
for row in payload.get("arms") or []:
if row.get("error"):
lines.append(
f"| {row.get('id')} | {row.get('universe')} | {row.get('start')} | "
f"ERR | | | | | | {row.get('n_qualified_longs')} | {row.get('error')} |"
)
continue
lines.append(
f"| {row.get('id')} | {row.get('universe')} | {row.get('start')} | "
f"{row.get('sharpe')} | {row.get('sharpe_se')} | {row.get('cagr_pct')} | "
f"{row.get('max_drawdown_pct')} | {row.get('total_return_pct')} | "
f"{row.get('trades')} | {row.get('n_qualified_longs')} | "
f"{row.get('start_date')}{row.get('end_date')} |"
)
lines.extend([
"",
"## Config (production, unchanged)",
"",
f"```json\n{json.dumps(payload.get('strategy') or {}, indent=2)}\n```",
"",
"## Snapshot",
"",
f"```json\n{json.dumps(payload.get('snapshot_meta') or {}, indent=2, default=str)}\n```",
"",
"PENDING_HUMAN — descriptive matrix only; no auto promotion.",
"",
f"JSON: `{out_json.as_posix()}`",
"",
])
out_json.with_suffix(".md").write_text("\n".join(lines) + "\n", encoding="utf-8")
# Fill results section of the research doc.
if doc_path.exists():
text = doc_path.read_text(encoding="utf-8")
marker = "## Results"
idx = text.find(marker)
header = text[:idx] if idx >= 0 else text
# Drop old results/verdict tail
for m in ("## Results", "## Verdict"):
pass
body = [
header.rstrip(),
"",
"## Results",
"",
f"Generated: `{payload.get('generated_at')}`",
"",
"| arm | universe | entry start | Sharpe | SE | CAGR % | max DD % | trades |",
"|---|---|---|---:|---:|---:|---:|---:|",
]
for row in payload.get("arms") or []:
body.append(
f"| {row.get('id')} | {row.get('universe')} | {row.get('start')} | "
f"{row.get('sharpe', '')} | {row.get('sharpe_se', '')} | "
f"{row.get('cagr_pct', '')} | {row.get('max_drawdown_pct', '')} | "
f"{row.get('trades', '')} |"
)
body.extend([
"",
f"Full report: `{out_json.as_posix()}`",
"",
"## Verdict",
"",
"**PENDING_HUMAN** — descriptive only; production knobs unchanged.",
"",
])
doc_path.write_text("\n".join(body) + "\n", encoding="utf-8")
async def _main() -> None:
args = _parse_args()
snapshot = Path(args.snapshot)
if not snapshot.exists():
raise SystemExit(f"Missing snapshot: {snapshot}")
if args.allow_spawn:
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
if not args.skip_race_guard:
try:
from scripts.research_snapshot_manifest import (
assert_research_snapshot_complete,
)
manifest = assert_research_snapshot_complete(snapshot)
print(
f"Race guard OK: tickers={manifest.get('ticker_count')} "
f"ohlcv={manifest.get('ohlcv_row_count')}"
)
except SystemExit as exc:
# Prod-only snapshot without manifest: allow with warning if ~505.
engine = create_engine(
f"sqlite:///{snapshot.resolve().as_posix()}",
future=True,
)
try:
with engine.connect() as conn:
n = int(conn.execute(text("SELECT COUNT(*) FROM tickers")).scalar_one())
finally:
engine.dispose()
if n < 400:
raise
print(f"WARNING: no research manifest ({exc}); proceeding n_tickers={n}")
cache = Path(args.candidate_cache) if args.candidate_cache else None
candidates, prices, spy, prod_set, exit_config = await _load_or_build_candidates(
snapshot,
cache_path=cache,
rebuild=args.rebuild_cache,
workers=args.workers,
quiet=args.quiet,
)
print("Building PIT liquid membership (top-1500, price≥5)…")
t0 = time.monotonic()
liquid_by_date = _build_liquid_membership(
prices, top_n=LIQUID_TOP_N, min_price=LIQUID_MIN_PRICE
)
print(
f" liquid dates={len(liquid_by_date)} "
f"elapsed={(time.monotonic()-t0)/60:.1f}m"
)
arms_out = []
for arm in ARMS:
print(f"Running arm {arm['id']}")
row = _run_arm(
arm,
all_candidates=candidates,
prices=prices,
spy=spy,
prod_set=prod_set,
liquid_by_date=liquid_by_date,
exit_config=exit_config,
)
arms_out.append(row)
print(
f" Sharpe={row.get('sharpe')} CAGR={row.get('cagr_pct')} "
f"DD={row.get('max_drawdown_pct')} trades={row.get('trades')} "
f"qual={row.get('n_qualified_longs')}"
)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = (
Path(args.out)
if args.out
else Path("reports") / f"prod-book-universe-horizon-{stamp}.json"
)
payload = {
"generated_at": datetime.now().isoformat(),
"snapshot": str(snapshot.resolve()),
"snapshot_meta": {
"prod_universe_n": len(prod_set),
"price_symbols_n": len(prices),
"raw_candidates": len(candidates),
"liquid_top_n": LIQUID_TOP_N,
"liquid_min_price": LIQUID_MIN_PRICE,
"short_start": SHORT_START.isoformat(),
"long_start": LONG_START.isoformat(),
},
"strategy": {
"note": "Live production knobs — no modifications",
"momentum": "residual_12_1 gate 80",
"rank": "residual_high_vol_blend_80_20",
"fill_mode": "close",
"cost_per_side": 0.001,
"exit": exit_config,
"max_positions": 10,
"risk_per_trade": 0.01,
"reentry": "gate_reset",
},
"arms": arms_out,
"survivorship_banner": (
"Today's constituents backfilled. Relative arm comparison only."
),
"pending_human": True,
}
_write_outputs(
payload,
out,
Path("docs/research/prod-book-universe-horizon.md"),
)
print(f"Wrote {out}")
print(f"Wrote {out.with_suffix('.md')}")
if __name__ == "__main__":
asyncio.run(_main())
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
# Research helpers for a high-CPU MacBook (local only).
#
# Kept after Tier-1 cleanup:
# --ssl-check diagnose corporate CA / proxy
# --earnings-only resume FMP earnings backfill + 2a/2b (parked)
# --prod-book-matrix re-run 505 vs liquid universe × horizon book matrix
#
# Prerequisites: git checkout research branch, .env, deep research.sqlite for
# book matrix, combined-ca-bundle.pem or certifi when on corp network.
#
# chmod +x scripts/run_tier1_macbook.sh
# ./scripts/run_tier1_macbook.sh --ssl-check
# ./scripts/run_tier1_macbook.sh --prod-book-matrix
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
RESEARCH_SNAP="${RESEARCH_SNAP:-backtest_snapshots/research.sqlite}"
PROD_SNAP="${PROD_SNAP:-backtest_snapshots/prod.sqlite}"
WORKERS="${WORKERS:-8}"
FMP_LIMIT="${FMP_LIMIT:-250}"
FMP_SLEEP="${FMP_SLEEP:-0.35}"
PYTHON="${PYTHON:-python3}"
USE_CORP_PROXY="${USE_CORP_PROXY:-0}"
PHASE=""
usage() {
sed -n '2,16p' "$0" | sed 's/^# \?//'
exit "${1:-0}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--ssl-check) PHASE=ssl; shift ;;
--earnings-only) PHASE=earnings; shift ;;
--prod-book-matrix) PHASE=prod_book; shift ;;
--corp-proxy) USE_CORP_PROXY=1; shift ;;
--workers) WORKERS="$2"; shift 2 ;;
--python) PYTHON="$2"; shift 2 ;;
-h|--help) usage 0 ;;
*) echo "Unknown flag: $1" >&2; usage 1 ;;
esac
done
if [[ -z "$PHASE" ]]; then
echo "Pick a phase: --ssl-check | --earnings-only | --prod-book-matrix" >&2
usage 1
fi
if [[ -x .venv/bin/python ]]; then
PYTHON=".venv/bin/python"
elif command -v "$PYTHON" >/dev/null 2>&1; then
:
else
echo "ERROR: no Python found" >&2
exit 1
fi
log() { printf '\n==> %s\n' "$*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
need_file() { [[ -f "$1" ]] || die "missing $1"; }
setup_ssl() {
export USE_CORP_PROXY
if [[ -z "${SSL_CERT_FILE:-}" ]]; then
if [[ -f "$ROOT/combined-ca-bundle.pem" ]]; then
export SSL_CERT_FILE="$ROOT/combined-ca-bundle.pem"
elif [[ -f "$HOME/combined-ca-bundle.pem" ]]; then
export SSL_CERT_FILE="$HOME/combined-ca-bundle.pem"
fi
fi
if [[ -n "${SSL_CERT_FILE:-}" && -f "$SSL_CERT_FILE" ]]; then
export REQUESTS_CA_BUNDLE="$SSL_CERT_FILE" CURL_CA_BUNDLE="$SSL_CERT_FILE"
log "SSL CA bundle: $SSL_CERT_FILE"
else
local certifi_path
certifi_path="$("$PYTHON" -c 'import certifi; print(certifi.where())' 2>/dev/null || true)"
if [[ -n "$certifi_path" && -f "$certifi_path" ]]; then
export SSL_CERT_FILE="$certifi_path" REQUESTS_CA_BUNDLE="$certifi_path" CURL_CA_BUNDLE="$certifi_path"
log "SSL CA bundle (certifi): $SSL_CERT_FILE"
else
log "WARNING: no CA bundle found — SSL may fail on corp networks"
fi
fi
if [[ "$USE_CORP_PROXY" == "1" ]]; then
export HTTP_PROXY="${HTTP_PROXY:-http://aproxy.corproot.net:8080}"
export HTTPS_PROXY="${HTTPS_PROXY:-http://aproxy.corproot.net:8080}"
export NO_PROXY="${NO_PROXY:-corproot.net,sharedtcs.net,127.0.0.1,localhost}"
export http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY" no_proxy="$NO_PROXY"
log "Corp proxy enabled: $HTTPS_PROXY"
fi
export PYTHONPATH="${ROOT}${PYTHONPATH:+:$PYTHONPATH}"
}
ssl_check() {
setup_ssl
"$PYTHON" - <<'PY'
from app.ssl_bootstrap import bootstrap_ssl, ssl_status
import json, urllib.request
print(json.dumps(ssl_status(), indent=2))
print("bootstrap ->", bootstrap_ssl())
for url in (
"https://data.alpaca.markets/v2/stocks/SPY/bars?timeframe=1Day&limit=1",
"https://financialmodelingprep.com/stable/profile?symbol=AAPL",
):
try:
req = urllib.request.Request(url, headers={"User-Agent": "ssl-check"})
with urllib.request.urlopen(req, timeout=20) as resp:
print(f"OK {resp.status} {url[:60]}")
except Exception as exc:
print(f"FAIL {type(exc).__name__}: {exc}")
PY
}
setup_ssl
case "$PHASE" in
ssl) ssl_check ;;
earnings)
need_file "$PROD_SNAP"
need_file "$RESEARCH_SNAP"
log "Earnings Task 2 bulk backfill + registered 2a/2b closeout"
"$PYTHON" scripts/backfill_earnings_events.py \
--snapshot "$PROD_SNAP" --from-date 2016-01-04 --window-days 30 \
--limit "$FMP_LIMIT" --sleep "$FMP_SLEEP"
"$PYTHON" scripts/run_earnings_research.py \
--snapshot "$RESEARCH_SNAP" --universe-snapshot "$PROD_SNAP" \
--earnings-snapshot "$PROD_SNAP" --workers "$WORKERS" --allow-spawn
;;
prod_book)
need_file "$RESEARCH_SNAP"
log "Production book universe × horizon matrix"
"$PYTHON" scripts/run_prod_book_universe_matrix.py \
--snapshot "$RESEARCH_SNAP" --workers "$WORKERS" --allow-spawn \
--candidate-cache reports/.cache/prod-book-universe-cands.pkl
;;
*) die "unknown phase $PHASE" ;;
esac
log "Done."
+88
View File
@@ -0,0 +1,88 @@
"""Alpaca fetch window / feed selection.
Regression cover for the 2026-07-20 outage: the near-close scan silently ran on
the previous session's close because ``end`` resolved to midnight on end_date,
which is *before* that day's bar timestamp (04:00Z under EDT). Widening the
window also has to stay clear of the delayed-data period, which rejects the whole
request.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
import pytest
from app.providers.alpaca import AlpacaOHLCVProvider
class _CapturingClient:
"""Stands in for StockHistoricalDataClient, recording the request."""
def __init__(self) -> None:
self.request = None
def get_stock_bars(self, request):
self.request = request
return {"AAPL": []}
def _provider() -> tuple[AlpacaOHLCVProvider, _CapturingClient]:
provider = AlpacaOHLCVProvider("key", "secret")
client = _CapturingClient()
provider._client = client
return provider, client
def _midnight(day: date) -> datetime:
"""Naive-UTC midnight — the SDK strips tzinfo from request datetimes."""
return datetime.combine(day, datetime.min.time())
def _utcnow() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
@pytest.mark.asyncio
async def test_todays_in_progress_bar_is_inside_the_window():
"""The whole near-close design depends on today's bar being fetchable."""
provider, client = _provider()
today = date.today()
await provider.fetch_ohlcv("AAPL", today - timedelta(days=5), today)
# Daily bars are stamped at session start (04:00Z); a midnight end drops them.
assert client.request.end > _midnight(today)
@pytest.mark.asyncio
async def test_window_stays_out_of_the_delayed_data_period():
"""A window reaching the last ~15 minutes fails the entire request."""
provider, client = _provider()
await provider.fetch_ohlcv("AAPL", date.today() - timedelta(days=5), date.today())
assert client.request.end <= _utcnow() - timedelta(minutes=15)
@pytest.mark.asyncio
async def test_completed_past_day_is_fully_covered():
"""Clamping must not swallow the last day of a historical window."""
provider, client = _provider()
end_date = date.today() - timedelta(days=3)
await provider.fetch_ohlcv("AAPL", end_date - timedelta(days=5), end_date)
assert client.request.end > _midnight(end_date)
@pytest.mark.asyncio
async def test_window_collapsing_to_nothing_skips_the_call():
"""A start inside the delayed period yields no request at all, not an error."""
provider, client = _provider()
tomorrow = date.today() + timedelta(days=1)
records = await provider.fetch_ohlcv("AAPL", tomorrow, tomorrow)
assert records == []
assert client.request is None
+225
View File
@@ -0,0 +1,225 @@
from datetime import date, timedelta
from scripts.backfill_earnings_events import _dedupe_bulk_rows, _windows
from scripts.import_dolthub_earnings import _align_symbol
from scripts.run_earnings_research import (
_analyse_2a_trades,
_build_sue_series,
_mechanical_sue_grade,
)
def test_dolthub_alignment_is_monotonic_across_close_calendar_events() -> None:
events = [
{"announce_date": date(2020, 3, 17), "announce_time": "bmo"},
{"announce_date": date(2020, 4, 30), "announce_time": "bmo"},
]
periods = [
{"period_end_date": date(2019, 12, 31)},
{"period_end_date": date(2020, 3, 31)},
]
matches, unmatched_events, unmatched_periods = _align_symbol(
events, periods, max_lag_days=90, max_lead_days=14
)
assert matches == [(0, 0), (1, 1)]
assert unmatched_events == []
assert unmatched_periods == []
def test_dolthub_alignment_allows_fiscal_period_label_after_announcement() -> None:
events = [
{"announce_date": date(2023, 2, 28), "announce_time": "bmo"},
{"announce_date": date(2023, 5, 23), "announce_time": "bmo"},
]
periods = [
{"period_end_date": date(2023, 2, 28)},
{"period_end_date": date(2023, 5, 31)},
]
matches, _, _ = _align_symbol(
events, periods, max_lag_days=90, max_lead_days=14
)
assert matches == [(0, 0), (1, 1)]
def test_bulk_windows_cover_range_without_overlap() -> None:
result = _windows(date(2020, 1, 1), date(2020, 1, 10), 4)
assert result == [
(date(2020, 1, 1), date(2020, 1, 4)),
(date(2020, 1, 5), date(2020, 1, 8)),
(date(2020, 1, 9), date(2020, 1, 10)),
]
def test_bulk_dedupe_prefers_more_complete_and_counts_restatement() -> None:
rows = [
{
"symbol": "AAPL",
"announce_date": "2024-01-01",
"announce_time": None,
"eps_estimate": 1.0,
"eps_actual": 1.1,
"revenue_estimate": None,
"revenue_actual": None,
},
{
"symbol": "AAPL",
"announce_date": "2024-01-01",
"announce_time": "amc",
"eps_estimate": 1.0,
"eps_actual": 1.2,
"revenue_estimate": 10.0,
"revenue_actual": 11.0,
},
]
deduped, duplicates, restated = _dedupe_bulk_rows(rows)
assert duplicates == 1
assert restated == 1
assert deduped == [rows[1]]
def test_2a_uses_net_r_strict_hold_and_next_session_stop() -> None:
calendar = [
date(2024, 1, 2),
date(2024, 1, 3),
date(2024, 1, 4),
date(2024, 1, 5),
date(2024, 1, 8),
date(2024, 1, 9),
]
events = [
{
"symbol": "AAPL",
"announce_date": date(2024, 1, 5),
}
]
trades = [
{
"symbol": "AAPL",
"entry_date": "2024-01-03",
"exit_date": "2024-01-08",
"entry": 100.0,
"initial_stop": 90.0,
"fill": 90.0,
"r": -1.0,
"reason": "stop",
},
{
"symbol": "MSFT",
"entry_date": "2024-01-02",
"exit_date": "2024-01-09",
"entry": 100.0,
"initial_stop": 90.0,
"fill": 110.0,
"r": 1.0,
"reason": "time",
},
]
result = _analyse_2a_trades(
trades, events, calendar, cost_per_side=0.001
)
assert result["q1_loss_concentration"]["losses_count"] == 1
assert result["q1_loss_concentration"]["losses_with_announcement_count"] == 1
assert (
result["q2_entries_within_3_trading_days_before_announcement"][
"pre_earnings"
]["count"]
== 1
)
assert (
result["q3_stop_exits_within_1_trading_day_after_announcement"][
"stops_after_earnings"
]["count"]
== 1
)
assert result["q1_loss_concentration"]["loss_definition"] == (
"realized_net_R <= -1.0"
)
def test_sue_needs_four_prior_surprises_and_starts_next_trading_day() -> None:
dates = [date(2024, 1, 1) + timedelta(days=index) for index in range(100)]
columns = (
[value.toordinal() for value in dates],
[100.0] * len(dates),
[101.0] * len(dates),
[99.0] * len(dates),
[100.0] * len(dates),
[1_000_000] * len(dates),
)
event_dates = [date(2024, 1, 2) + timedelta(days=10 * index) for index in range(5)]
surprises = [0.1, -0.2, 0.3, -0.1, 0.4]
events = {
"AAPL": [
{
"announce_date": event_date,
"eps_actual": 1.0 + surprise,
"eps_estimate": 1.0,
}
for event_date, surprise in zip(event_dates, surprises)
]
}
series, counts = _build_sue_series(
events, {"AAPL": columns}, use_price_fallback=False
)
first_live = event_dates[-1] + timedelta(days=1)
assert first_live in series["AAPL"]
assert event_dates[-1] not in series["AAPL"]
assert counts["standard_scaled_events"] == 1
assert counts["price_fallback_events"] == 0
def test_sue_uses_period_history_only_for_scaling() -> None:
dates = [date(2020, 1, 1) + timedelta(days=index) for index in range(100)]
columns = (
[value.toordinal() for value in dates],
[100.0] * len(dates),
[101.0] * len(dates),
[99.0] * len(dates),
[100.0] * len(dates),
[1_000_000] * len(dates),
)
event_date = date(2020, 2, 3)
events = {
"AAPL": [
{
"announce_date": event_date,
"period_end_date": date(2019, 12, 31),
"eps_actual": 1.4,
"eps_estimate": 1.0,
}
]
}
history = {
"AAPL": [
{
"period_end_date": date(2018, 12, 31)
+ timedelta(days=90 * index),
"eps_actual": 1.0 + surprise,
"eps_estimate": 1.0,
}
for index, surprise in enumerate([0.1, -0.2, 0.3, -0.1])
]
}
series, counts = _build_sue_series(
events,
{"AAPL": columns},
use_price_fallback=False,
surprise_history_by_symbol=history,
)
assert event_date + timedelta(days=1) in series["AAPL"]
assert event_date not in series["AAPL"]
assert counts["events_scaled_from_period_history"] == 1
def test_sue_grade_requires_positive_both_eras() -> None:
full = {"mean_ic": 0.03, "reliable": True}
passed, stable = _mechanical_sue_grade(
full, {"mean_ic": 0.01}, {"mean_ic": 0.02}
)
assert passed is True
assert stable is True
failed, stable = _mechanical_sue_grade(
full, {"mean_ic": -0.01}, {"mean_ic": 0.02}
)
assert failed is False
assert stable is False
@@ -0,0 +1,133 @@
"""Completion-manifest guard for research.sqlite breadth runs."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from sqlalchemy import create_engine, text
ROOT = Path(__file__).resolve().parents[2]
SCRIPTS = ROOT / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
from research_snapshot_manifest import ( # noqa: E402
assert_research_snapshot_complete,
clear_manifest,
load_manifest,
manifest_path_for,
write_completion_manifest,
)
def _tiny_research_db(path: Path, *, tickers: int = 3, bars_each: int = 5) -> None:
engine = create_engine(f"sqlite:///{path.resolve().as_posix()}", future=True)
with engine.begin() as conn:
conn.execute(
text(
"CREATE TABLE tickers ("
"id INTEGER PRIMARY KEY, symbol TEXT NOT NULL UNIQUE, "
"name TEXT, created_at TEXT)"
)
)
conn.execute(
text(
"CREATE TABLE ohlcv_records ("
"id INTEGER PRIMARY KEY, ticker_id INTEGER, date TEXT, "
"open REAL, high REAL, low REAL, close REAL, volume INTEGER, "
"created_at TEXT)"
)
)
conn.execute(
text(
"CREATE TABLE research_rank_only ("
"ticker_id INTEGER PRIMARY KEY, symbol TEXT NOT NULL UNIQUE)"
)
)
for i in range(tickers):
sym = f"T{i}"
conn.execute(
text(
"INSERT INTO tickers (id, symbol, name, created_at) "
"VALUES (:id, :sym, NULL, '2026-01-01')"
),
{"id": i + 1, "sym": sym},
)
if i > 0:
conn.execute(
text(
"INSERT INTO research_rank_only (ticker_id, symbol) "
"VALUES (:id, :sym)"
),
{"id": i + 1, "sym": sym},
)
for d in range(bars_each):
conn.execute(
text(
"INSERT INTO ohlcv_records "
"(ticker_id, date, open, high, low, close, volume, created_at) "
"VALUES (:tid, :date, 1,1,1,1,100, '2026-01-01')"
),
{"tid": i + 1, "date": f"2026-01-{d+1:02d}"},
)
engine.dispose()
def test_write_and_assert_complete(tmp_path: Path) -> None:
snap = tmp_path / "research.sqlite"
_tiny_research_db(snap)
path = write_completion_manifest(snap, complete=True, sources={"t": "unit"})
assert path == manifest_path_for(snap)
assert path.exists()
m = assert_research_snapshot_complete(snap)
assert m["complete"] is True
assert m["ticker_count"] == 3
assert m["ohlcv_row_count"] == 15
assert m["rank_only_count"] == 2
assert m["live_counts"]["ticker_count"] == 3
def test_refuse_missing_manifest(tmp_path: Path) -> None:
snap = tmp_path / "research.sqlite"
_tiny_research_db(snap)
with pytest.raises(SystemExit, match="manifest missing"):
assert_research_snapshot_complete(snap)
def test_refuse_incomplete_flag(tmp_path: Path) -> None:
snap = tmp_path / "research.sqlite"
_tiny_research_db(snap)
write_completion_manifest(snap, complete=False, limit=50)
with pytest.raises(SystemExit, match="marked incomplete"):
assert_research_snapshot_complete(snap)
def test_refuse_count_mismatch(tmp_path: Path) -> None:
snap = tmp_path / "research.sqlite"
_tiny_research_db(snap)
write_completion_manifest(snap, complete=True)
# Tamper: change live DB after manifest written
engine = create_engine(f"sqlite:///{snap.resolve().as_posix()}", future=True)
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO tickers (id, symbol, name, created_at) "
"VALUES (99, 'EXTRA', NULL, '2026-01-01')"
)
)
engine.dispose()
with pytest.raises(SystemExit, match="does not match"):
assert_research_snapshot_complete(snap)
def test_clear_manifest(tmp_path: Path) -> None:
snap = tmp_path / "research.sqlite"
_tiny_research_db(snap)
write_completion_manifest(snap, complete=True)
assert load_manifest(snap) is not None
clear_manifest(snap)
assert load_manifest(snap) is None
+49
View File
@@ -32,6 +32,55 @@ class TestValidateCron:
validate_cron("0 7 * * *", "Mars/Phobos")
class TestTradingDayCrons:
"""APScheduler's from_crontab() uses 0=Monday, so numeric "1-5" means
TueSat: it skips every Monday and fires on Saturdays. Weekday schedules
must therefore be spelled with day *names*.
"""
_WEEKDAY_KEYS = (
"schedule_near_close_pipeline_cron",
"schedule_after_close_pipeline_cron",
"schedule_intraday_pipeline_cron",
)
@pytest.mark.parametrize("key", _WEEKDAY_KEYS)
def test_fires_monday_and_never_saturday(self, key: str):
from datetime import datetime, timedelta
from apscheduler.triggers.cron import CronTrigger
trigger = CronTrigger.from_crontab(
SCHEDULE_DEFAULTS[key], timezone=SCHEDULE_DEFAULTS["schedule_timezone"]
)
# Walk a full week of fire times from a known Sunday.
cursor = datetime(2026, 7, 19, tzinfo=trigger.timezone)
weekdays = set()
previous = None
for _ in range(12):
fire = trigger.get_next_fire_time(previous, cursor)
weekdays.add(fire.strftime("%a"))
previous = fire
cursor = fire + timedelta(seconds=1)
assert "Mon" in weekdays, f"{key} skips Mondays — numeric day-of-week?"
assert {"Sat", "Sun"}.isdisjoint(weekdays), f"{key} fires on a weekend"
def test_fundamentals_runs_on_monday(self):
from datetime import datetime
from apscheduler.triggers.cron import CronTrigger
trigger = CronTrigger.from_crontab(
SCHEDULE_DEFAULTS["schedule_fundamentals_cron"],
timezone=SCHEDULE_DEFAULTS["schedule_timezone"],
)
fire = trigger.get_next_fire_time(
None, datetime(2026, 7, 19, tzinfo=trigger.timezone)
)
assert fire.strftime("%a") == "Mon"
class TestScheduleConfig:
async def test_defaults_when_unset(self, session: AsyncSession):
config = await get_schedule_config(session)