Compare commits
25
Commits
c2c7244d1a
...
29715ef3d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29715ef3d1 | ||
|
|
c7c60a64f2 | ||
|
|
1fa3d70dec | ||
|
|
86dc24ae8a | ||
|
|
bb8aa655a1 | ||
|
|
1c38a94dd0 | ||
|
|
a4d5ed7a93 | ||
|
|
9171e366ee | ||
|
|
9717d8176b | ||
|
|
01007fb6dd | ||
|
|
003f20de19 | ||
|
|
f3d1312a69 | ||
|
|
a9841d92b7 | ||
|
|
64761f38ba | ||
|
|
f6e0ca734f | ||
|
|
06cf054f60 | ||
|
|
32bf9c9297 | ||
|
|
fa25b6ee68 | ||
|
|
8f285acb00 | ||
|
|
2311999e57 | ||
|
|
7d60e54f5a | ||
|
|
ceaaadc49f | ||
|
|
d34c7a21b7 | ||
|
|
30286111a8 | ||
|
|
b6892d13fd |
@@ -46,3 +46,4 @@ backtest_snapshots/
|
|||||||
# Rebuildable pickle caches are local accelerators, not decision evidence.
|
# Rebuildable pickle caches are local accelerators, not decision evidence.
|
||||||
reports/*.pkl
|
reports/*.pkl
|
||||||
reports/*.pk1
|
reports/*.pk1
|
||||||
|
reports/.cache/
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ A systematic single-variable sweep (offline prod snapshot, production gate/rank/
|
|||||||
Two findings future sessions must not re-litigate:
|
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.
|
- **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
|
### 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.)
|
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.
|
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
|
## Key Use Cases
|
||||||
|
|
||||||
|
|||||||
+2
-49
@@ -3,56 +3,9 @@
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SSL + proxy injection — MUST happen before any HTTP client imports
|
# SSL + proxy injection — MUST happen before any HTTP client imports
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
import os as _os
|
from app.ssl_bootstrap import bootstrap_ssl
|
||||||
import ssl as _ssl
|
|
||||||
from pathlib import Path as _Path
|
|
||||||
|
|
||||||
_COMBINED_CERT = _Path(__file__).resolve().parent.parent / "combined-ca-bundle.pem"
|
bootstrap_ssl()
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
+31
-3
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import date
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
|
||||||
from alpaca.data.historical import StockHistoricalDataClient
|
from alpaca.data.historical import StockHistoricalDataClient
|
||||||
from alpaca.data.requests import StockBarsRequest
|
from alpaca.data.requests import StockBarsRequest
|
||||||
@@ -16,6 +16,11 @@ from app.providers.protocol import OHLCVData
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class AlpacaOHLCVProvider:
|
||||||
"""Fetches daily OHLCV bars from Alpaca Markets Data API."""
|
"""Fetches daily OHLCV bars from Alpaca Markets Data API."""
|
||||||
@@ -25,6 +30,26 @@ class AlpacaOHLCVProvider:
|
|||||||
raise ProviderError("Alpaca API key and secret are required")
|
raise ProviderError("Alpaca API key and secret are required")
|
||||||
self._client = StockHistoricalDataClient(api_key, api_secret)
|
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
|
@staticmethod
|
||||||
def _to_alpaca_symbol(symbol: str) -> str:
|
def _to_alpaca_symbol(symbol: str) -> str:
|
||||||
"""Convert internal symbol format (BRK-B) to Alpaca format (BRK.B)."""
|
"""Convert internal symbol format (BRK-B) to Alpaca format (BRK.B)."""
|
||||||
@@ -40,12 +65,15 @@ class AlpacaOHLCVProvider:
|
|||||||
) -> list[OHLCVData]:
|
) -> list[OHLCVData]:
|
||||||
"""Fetch daily OHLCV bars for *ticker* between *start_date* and *end_date*."""
|
"""Fetch daily OHLCV bars for *ticker* between *start_date* and *end_date*."""
|
||||||
alpaca_symbol = self._to_alpaca_symbol(ticker)
|
alpaca_symbol = self._to_alpaca_symbol(ticker)
|
||||||
|
start, end = self._resolve_window(start_date, end_date)
|
||||||
|
if end <= start:
|
||||||
|
return []
|
||||||
try:
|
try:
|
||||||
request = StockBarsRequest(
|
request = StockBarsRequest(
|
||||||
symbol_or_symbols=alpaca_symbol,
|
symbol_or_symbols=alpaca_symbol,
|
||||||
timeframe=TimeFrame.Day,
|
timeframe=TimeFrame.Day,
|
||||||
start=start_date,
|
start=start,
|
||||||
end=end_date,
|
end=end,
|
||||||
adjustment=Adjustment.SPLIT,
|
adjustment=Adjustment.SPLIT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+45
-11
@@ -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.
|
"""Fetch latest daily OHLCV for all tracked tickers.
|
||||||
|
|
||||||
Uses AlpacaOHLCVProvider. Processes each ticker independently.
|
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
|
``settings.ohlcv_history_days`` window (ignoring incremental resume) — used by
|
||||||
the manual data_backfill job to deepen shallow histories. ``job_name`` lets the
|
the manual data_backfill job to deepen shallow histories. ``job_name`` lets the
|
||||||
backfill report its own runtime/resume state separate from data_collector.
|
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)
|
_log_event(logging.INFO, "job_start", job=job_name)
|
||||||
_runtime_start(job_name)
|
_runtime_start(job_name)
|
||||||
@@ -536,11 +545,14 @@ async def collect_ohlcv(full_backfill: bool = False, job_name: str = "data_colle
|
|||||||
return
|
return
|
||||||
|
|
||||||
end_date = date.today()
|
end_date = date.today()
|
||||||
# Full backfill: pass an explicit start_date so fetch_and_ingest re-pulls
|
# An explicit start_date makes fetch_and_ingest re-pull that window instead
|
||||||
# the whole window instead of resuming from the last stored bar.
|
# of resuming from the last stored bar (upsert overwrites, so this is safe).
|
||||||
backfill_start = (
|
if full_backfill:
|
||||||
end_date - timedelta(days=settings.ohlcv_history_days) if full_backfill else None
|
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:
|
for symbol in symbols:
|
||||||
_runtime_progress(job_name, processed=processed, total=total, current_ticker=symbol)
|
_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")
|
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
|
# 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
|
# — 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
|
# gate-reset sees one observation per trading day (plus the trade_policy
|
||||||
# distinct-day guard for manual re-scans).
|
# 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 = [
|
_DAILY_PIPELINE_STEPS = [
|
||||||
("data_collector", "collect_ohlcv"),
|
("data_collector", "collect_ohlcv"),
|
||||||
("benchmark_collector", "collect_benchmark"),
|
("benchmark_collector", "collect_benchmark"),
|
||||||
@@ -1206,6 +1234,8 @@ _DAILY_PIPELINE_STEPS = [
|
|||||||
# entries behave like stale_close (still acceptable per execution-recovery matrix).
|
# entries behave like stale_close (still acceptable per execution-recovery matrix).
|
||||||
# No exchange calendar dependency.
|
# No exchange calendar dependency.
|
||||||
_NEAR_CLOSE_PIPELINE_STEPS = [
|
_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"),
|
("data_collector", "collect_ohlcv"),
|
||||||
("rr_scanner", "scan_rr"),
|
("rr_scanner", "scan_rr"),
|
||||||
("alerts", "dispatch_alerts_job"),
|
("alerts", "dispatch_alerts_job"),
|
||||||
@@ -1214,7 +1244,7 @@ _NEAR_CLOSE_PIPELINE_STEPS = [
|
|||||||
# After close (~16:45 ET Mon–Fri): fresh OHLCV fetch so outcomes resolve on the
|
# After close (~16:45 ET Mon–Fri): fresh OHLCV fetch so outcomes resolve on the
|
||||||
# final bar, not the near-close partial bar, then outcome/paper close.
|
# final bar, not the near-close partial bar, then outcome/paper close.
|
||||||
_AFTER_CLOSE_PIPELINE_STEPS = [
|
_AFTER_CLOSE_PIPELINE_STEPS = [
|
||||||
("data_collector", "collect_ohlcv"),
|
("data_collector", "collect_ohlcv_final"),
|
||||||
("outcome_evaluator", "evaluate_outcomes"),
|
("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.
|
# All wall times are America/New_York after the near-close execution cutover.
|
||||||
# Stored SystemSetting values shadow these defaults — deploy migration 023
|
# Stored SystemSetting values shadow these defaults — deploy migration 023
|
||||||
# rewrites schedule_* keys so prod does not keep scanning at 07:00 Berlin.
|
# 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
|
||||||
|
# Tue–Sat, silently skipping every Monday and scanning on Saturdays. Names are
|
||||||
|
# unambiguous in both dialects.
|
||||||
SCHEDULE_DEFAULTS: dict[str, str] = {
|
SCHEDULE_DEFAULTS: dict[str, str] = {
|
||||||
"schedule_timezone": "America/New_York",
|
"schedule_timezone": "America/New_York",
|
||||||
# Morning data/display refresh (no qualifying R:R scan).
|
# Morning data/display refresh (no qualifying R:R scan).
|
||||||
"schedule_daily_pipeline_cron": "0 2 * * *",
|
"schedule_daily_pipeline_cron": "0 2 * * *",
|
||||||
# Fetch in-progress bars → scan → Telegram (manual MOC window).
|
# 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).
|
# 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:00–15:00 ET Mon–Fri).
|
# Hourly mid-session price + outcome (10:00–15:00 ET Mon–Fri).
|
||||||
"schedule_intraday_pipeline_cron": "0 10-15 * * 1-5",
|
"schedule_intraday_pipeline_cron": "0 10-15 * * mon-fri",
|
||||||
# Weekly fundamentals early Monday NY.
|
# Weekly fundamentals early Monday NY.
|
||||||
"schedule_fundamentals_cron": "0 1 * * 1",
|
"schedule_fundamentals_cron": "0 1 * * mon",
|
||||||
}
|
}
|
||||||
|
|
||||||
# job id -> schedule setting key
|
# job id -> schedule setting key
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ Environment variables (see also run_backtest_snapshot.py):
|
|||||||
BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1
|
BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1
|
||||||
BACKTEST_RESEARCH_EXITS=1
|
BACKTEST_RESEARCH_EXITS=1
|
||||||
BACKTEST_MIN_RR_SWEEP=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
|
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)
|
var_market = sum((x - mean_market) ** 2 for x in market_rets)
|
||||||
if var_market <= 0:
|
if var_market <= 0:
|
||||||
return None
|
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
|
beta = cov / var_market
|
||||||
return sum(stock_rets[k] - beta * market_rets[k] for k in range(len(stock_rets)))
|
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
|
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(
|
def _accumulate_signal_series(
|
||||||
records: list,
|
records: list,
|
||||||
collected: dict,
|
collected: dict,
|
||||||
benchmark_closes: dict[date, float] | None = None,
|
benchmark_closes: dict[date, float] | None = None,
|
||||||
|
*,
|
||||||
|
symbol: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""For each weekly as-of bar, emit (signal, forward-return) pairs keyed by ISO
|
"""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
|
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)
|
n = len(records)
|
||||||
if n < HORIZON + 21:
|
if n < HORIZON + 21:
|
||||||
return
|
return
|
||||||
closes = [float(r.close) for r in records]
|
closes = [float(r.close) for r in records]
|
||||||
highs = [float(r.high) 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]
|
dates = [r.date for r in records]
|
||||||
|
liquid_mode = _liquid_breadth_top_n() > 0
|
||||||
for i in _weekly_asof_indices(records):
|
for i in _weekly_asof_indices(records):
|
||||||
j = i + HORIZON
|
j = i + HORIZON
|
||||||
if j >= n or closes[i] <= 0:
|
if j >= n or closes[i] <= 0:
|
||||||
@@ -897,8 +971,18 @@ def _accumulate_signal_series(
|
|||||||
fwd = closes[j] / closes[i] - 1.0
|
fwd = closes[j] / closes[i] - 1.0
|
||||||
iso = records[i].date.isocalendar()
|
iso = records[i].date.isocalendar()
|
||||||
week_key = (iso[0], iso[1])
|
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():
|
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]:
|
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))
|
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:
|
def _quintile_spread(pairs: list[tuple[float, float]]) -> float | None:
|
||||||
"""Mean forward return of the top signal-quintile minus the bottom quintile."""
|
"""Mean forward return of the top signal-quintile minus the bottom quintile."""
|
||||||
n = len(pairs)
|
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
|
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
|
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.
|
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
|
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] = []
|
rows: list[dict] = []
|
||||||
for name in sorted(collected):
|
for name in sorted(collected):
|
||||||
weeks_map = collected[name]
|
weeks_map = collected[name]
|
||||||
@@ -994,15 +1188,37 @@ def _signal_evaluation(collected: dict) -> list[dict]:
|
|||||||
ics: list[float] = []
|
ics: list[float] = []
|
||||||
spreads: list[float] = []
|
spreads: list[float] = []
|
||||||
sizes: list[int] = []
|
sizes: list[int] = []
|
||||||
|
raw_sizes: list[int] = []
|
||||||
|
eligible_sizes: list[int] = []
|
||||||
|
bind_flags: list[bool] = []
|
||||||
for wk in kept:
|
for wk in kept:
|
||||||
recs = weeks_map[wk]
|
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:
|
if ic is not None:
|
||||||
ics.append(ic)
|
ics.append(ic)
|
||||||
spread = _quintile_spread(recs)
|
spread = _quintile_spread(pairs)
|
||||||
if spread is not None:
|
if spread is not None:
|
||||||
spreads.append(spread)
|
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:
|
if not ics:
|
||||||
continue
|
continue
|
||||||
mean_ic = sum(ics) / len(ics)
|
mean_ic = sum(ics) / len(ics)
|
||||||
@@ -1011,7 +1227,7 @@ def _signal_evaluation(collected: dict) -> list[dict]:
|
|||||||
else:
|
else:
|
||||||
std = 0.0
|
std = 0.0
|
||||||
t_stat = mean_ic / std * math.sqrt(len(ics)) if std > 0 else None
|
t_stat = mean_ic / std * math.sqrt(len(ics)) if std > 0 else None
|
||||||
rows.append({
|
row = {
|
||||||
"signal": name,
|
"signal": name,
|
||||||
"weeks": len(ics),
|
"weeks": len(ics),
|
||||||
"avg_cross_section": round(sum(sizes) / len(sizes), 1) if sizes else None,
|
"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),
|
"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,
|
"mean_quintile_spread": round(sum(spreads) / len(spreads), 4) if spreads else None,
|
||||||
"reliable": len(ics) >= MIN_RELIABLE_PERIODS,
|
"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)
|
rows.sort(key=lambda r: r["mean_ic"], reverse=True)
|
||||||
return rows
|
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
|
"""Per-ticker signal/forward-return series as a PLAIN (picklable) nested dict
|
||||||
— no defaultdict/lambda — so it can cross a process boundary."""
|
— no defaultdict/lambda — so it can cross a process boundary."""
|
||||||
tmp: dict = defaultdict(lambda: defaultdict(list))
|
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()}
|
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,
|
benchmark_closes: dict[date, float] | None = None,
|
||||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
|
signal_only: bool = False,
|
||||||
) -> tuple[list[dict], dict]:
|
) -> tuple[list[dict], dict]:
|
||||||
"""The CPU-bound per-ticker work, as a top-level (picklable) function so it can
|
"""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),
|
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
|
date_ords, opens, highs, lows, closes, volumes = columns
|
||||||
bars = [
|
bars = [
|
||||||
SimpleNamespace(
|
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)
|
for o, op, hi, lo, cl, vo in zip(date_ords, opens, highs, lows, closes, volumes)
|
||||||
]
|
]
|
||||||
return (
|
candidates: list[dict] = []
|
||||||
_replay_ticker(
|
if not signal_only:
|
||||||
|
candidates = _replay_ticker(
|
||||||
symbol,
|
symbol,
|
||||||
bars,
|
bars,
|
||||||
config,
|
config,
|
||||||
@@ -1061,8 +1303,10 @@ def _replay_and_signals(
|
|||||||
benchmark_closes,
|
benchmark_closes,
|
||||||
target_model,
|
target_model,
|
||||||
cadence,
|
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))
|
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
|
||||||
tickers = list(result.scalars().all())
|
tickers = list(result.scalars().all())
|
||||||
total = len(tickers)
|
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] = []
|
candidates: list[dict] = []
|
||||||
# Signal IC remains a weekly, non-overlapping diagnostic regardless of the
|
# Signal IC remains a weekly, non-overlapping diagnostic regardless of the
|
||||||
@@ -3847,10 +4097,16 @@ async def run_backtest(
|
|||||||
continue
|
continue
|
||||||
if columns is not None:
|
if columns is not None:
|
||||||
futures.append(loop.run_in_executor(
|
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,
|
benchmark_closes,
|
||||||
target_model,
|
target_model,
|
||||||
cadence,
|
cadence,
|
||||||
|
ticker.symbol in rank_only_symbols,
|
||||||
))
|
))
|
||||||
for result in await asyncio.gather(*futures, return_exceptions=True):
|
for result in await asyncio.gather(*futures, return_exceptions=True):
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
@@ -3870,10 +4126,15 @@ async def run_backtest(
|
|||||||
columns = await _fetch_columns(db, ticker.symbol)
|
columns = await _fetch_columns(db, ticker.symbol)
|
||||||
if columns is not None:
|
if columns is not None:
|
||||||
_merge(await asyncio.to_thread(
|
_merge(await asyncio.to_thread(
|
||||||
_replay_and_signals, ticker.symbol, columns, config, activation,
|
_replay_and_signals,
|
||||||
|
ticker.symbol,
|
||||||
|
columns,
|
||||||
|
config,
|
||||||
|
activation,
|
||||||
benchmark_closes,
|
benchmark_closes,
|
||||||
target_model,
|
target_model,
|
||||||
cadence,
|
cadence,
|
||||||
|
ticker.symbol in rank_only_symbols,
|
||||||
))
|
))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Backtest replay failed for %s", ticker.symbol)
|
logger.exception("Backtest replay failed for %s", ticker.symbol)
|
||||||
@@ -3916,73 +4177,75 @@ async def run_backtest(
|
|||||||
portfolio_monitor_report: dict | None = None
|
portfolio_monitor_report: dict | None = None
|
||||||
holdout_report: dict | None = None
|
holdout_report: dict | None = None
|
||||||
min_rr_sweep_report: dict | None = None
|
min_rr_sweep_report: dict | None = None
|
||||||
try:
|
if not _signal_eval_only():
|
||||||
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
|
|
||||||
try:
|
try:
|
||||||
oldest = min((cols[0][0] for cols in price_columns.values()), default=None)
|
qual_symbols = sorted({
|
||||||
days_needed = None
|
c["symbol"]
|
||||||
if oldest is not None and not _offline_snapshot_mode():
|
for c in candidates
|
||||||
days_needed = (date.today() - date.fromordinal(oldest)).days + 30
|
if c.get("qualified")
|
||||||
spy_closes = await _load_benchmark_closes_for_backtest(
|
or any(_qualifies_strategy_variant(c, cfg) for cfg in STRATEGY_VARIANTS)
|
||||||
db, days=days_needed, refresh=oldest is not None
|
})
|
||||||
)
|
price_columns: dict[str, tuple] = {}
|
||||||
except Exception:
|
for sym in qual_symbols:
|
||||||
logger.exception("Benchmark load for the portfolio sim failed")
|
cols = await _fetch_columns(db, sym)
|
||||||
|
if cols is not None:
|
||||||
|
price_columns[sym] = cols
|
||||||
|
|
||||||
for policy in ("target", "hold"):
|
spy_closes: dict | None = None
|
||||||
sim = _simulate_portfolio(
|
try:
|
||||||
candidates, price_columns, spy_closes, policy, hold_horizon
|
oldest = min((cols[0][0] for cols in price_columns.values()), default=None)
|
||||||
)
|
days_needed = None
|
||||||
if sim is not None:
|
if oldest is not None and not _offline_snapshot_mode():
|
||||||
sim_policies.append({"policy": policy, **sim})
|
days_needed = (date.today() - date.fromordinal(oldest)).days + 30
|
||||||
strategy_variant_rows = _strategy_variant_sims(
|
spy_closes = await _load_benchmark_closes_for_backtest(
|
||||||
candidates, price_columns, spy_closes, hold_horizon
|
db, days=days_needed, refresh=oldest is not None
|
||||||
)
|
)
|
||||||
exit_policy_rows = _exit_policy_sims(
|
except Exception:
|
||||||
candidates, price_columns, spy_closes, hold_horizon
|
logger.exception("Benchmark load for the portfolio sim failed")
|
||||||
)
|
|
||||||
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)
|
for policy in ("target", "hold"):
|
||||||
except Exception:
|
sim = _simulate_portfolio(
|
||||||
logger.exception("Live exit policy load failed; monitor uses defaults")
|
candidates, price_columns, spy_closes, policy, hold_horizon
|
||||||
portfolio_monitor_report = _portfolio_monitor(
|
)
|
||||||
candidates, price_columns, spy_closes, hold_horizon,
|
if sim is not None:
|
||||||
live_exit_policy=live_exit_policy,
|
sim_policies.append({"policy": policy, **sim})
|
||||||
cadence=cadence,
|
strategy_variant_rows = _strategy_variant_sims(
|
||||||
)
|
candidates, price_columns, spy_closes, hold_horizon
|
||||||
split = _holdout_split()
|
)
|
||||||
if split is not None:
|
exit_policy_rows = _exit_policy_sims(
|
||||||
holdout_report = _holdout_evaluation(
|
candidates, price_columns, spy_closes, hold_horizon
|
||||||
candidates, price_columns, spy_closes, hold_horizon, split,
|
)
|
||||||
|
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,
|
live_exit_policy=live_exit_policy,
|
||||||
cadence=cadence,
|
cadence=cadence,
|
||||||
)
|
)
|
||||||
if _min_rr_sweep_enabled():
|
split = _holdout_split()
|
||||||
min_rr_sweep_report = _min_rr_sweep(
|
if split is not None:
|
||||||
candidates, price_columns, spy_closes, activation, current_min_pct,
|
holdout_report = _holdout_evaluation(
|
||||||
hold_horizon, live_exit_policy=live_exit_policy, cadence=cadence,
|
candidates, price_columns, spy_closes, hold_horizon, split,
|
||||||
)
|
live_exit_policy=live_exit_policy,
|
||||||
except Exception:
|
cadence=cadence,
|
||||||
logger.exception("Portfolio simulation failed")
|
)
|
||||||
|
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 = {
|
report = {
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"tickers": total,
|
"tickers": total,
|
||||||
|
"rank_only_tickers": len(rank_only_symbols),
|
||||||
"candidates": len(candidates),
|
"candidates": len(candidates),
|
||||||
"qualified": len(qualified),
|
"qualified": len(qualified),
|
||||||
"params": {
|
"params": {
|
||||||
@@ -3999,6 +4262,9 @@ async def run_backtest(
|
|||||||
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
|
"target_model_label": BACKTEST_TARGET_MODELS[target_model],
|
||||||
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
|
"is_production_target_model": target_model == PRODUCTION_GTL_TARGET_MODEL,
|
||||||
"production_reentry_policy": PRODUCTION_REENTRY_POLICY,
|
"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,
|
"activation": activation,
|
||||||
"overall_qualified": _bucket_stats(qualified),
|
"overall_qualified": _bucket_stats(qualified),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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` |
|
| 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 | — |
|
| 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` |
|
| 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 |
|
| 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 |
|
| **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`** (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 |
|
| **`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** (`nasdaq_all`) | Strengthens every week's cross-section and the IC t-stat | Grade under the fill mode you will trade |
|
| **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 |
|
| **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 |
|
| **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
|
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
|
numbers for an overnight scanner. Grade promotion under the fill mode you will
|
||||||
actually trade.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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)**
|
||||||
@@ -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 (today’s 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 today’s 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 report’s `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 1–800 (senior) | **−0.035** | **−2.99** | Winner leg |
|
||||||
|
| Tier 801–1500 (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 paper’s 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 book’s territory, fip’s 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.
|
||||||
@@ -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 (today’s 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 knob’s 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:** today’s 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 Task‑1 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 Phase‑B 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 12‑1 (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 (093853–095156) 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.
|
||||||
@@ -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 Phase‑A / book baselines (~mid‑2022 → mid‑2026).
|
||||||
|
- **2016-07-01** = first full month after typical Alpaca floor (~2016-01); residual 12‑1 needs ~1y bars so first residual ranks appear mid‑2017 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 arm’s
|
||||||
|
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 1 448 · B 6 587 · C 2 450 · D 11 551.
|
||||||
|
|
||||||
|
### 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 ~6–8pp; 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 Phase‑A / short-window controls** (~Sharpe 1.7–2.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 A–D
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -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 12–1 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 + ticker’s 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 < 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) |
|
||||||
@@ -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.
|
daily matrix and narrative. Their matching one-off runners were removed too.
|
||||||
All remain recoverable from Git history. Rebuildable candidate pickle caches
|
All remain recoverable from Git history. Rebuildable candidate pickle caches
|
||||||
are intentionally ignored and must not be committed.
|
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.
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 (today’s 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 knob’s 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:** today’s 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 Task‑1 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 Phase‑B 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 12‑1 (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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 12–1 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 + ticker’s 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:
|
||||||
|
today’s 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`
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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 1–800",
|
||||||
|
**_ic_from_weekly(tier_hi),
|
||||||
|
},
|
||||||
|
"fip_tier_801_1500": {
|
||||||
|
"note": "Junior liquid ranks 801–top_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()
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
Executable
+141
@@ -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."
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -32,6 +32,55 @@ class TestValidateCron:
|
|||||||
validate_cron("0 7 * * *", "Mars/Phobos")
|
validate_cron("0 7 * * *", "Mars/Phobos")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTradingDayCrons:
|
||||||
|
"""APScheduler's from_crontab() uses 0=Monday, so numeric "1-5" means
|
||||||
|
Tue–Sat: 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:
|
class TestScheduleConfig:
|
||||||
async def test_defaults_when_unset(self, session: AsyncSession):
|
async def test_defaults_when_unset(self, session: AsyncSession):
|
||||||
config = await get_schedule_config(session)
|
config = await get_schedule_config(session)
|
||||||
|
|||||||
Reference in New Issue
Block a user