Merge branch 'research/earnings-gap-and-sue' — Tier-1 closed: sector residual dead on deep evidence; universe x horizon matrix confirms 505 book; earnings scaffolding ready

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 15:29:25 +02:00
co-authored by Claude Fable 5
25 changed files with 6595 additions and 50 deletions
+1
View File
@@ -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/
+2 -49
View File
@@ -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
+4 -1
View File
@@ -813,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)))
+136
View File
@@ -0,0 +1,136 @@
"""TLS / corporate-proxy bootstrap for CLI scripts and the API.
Must run **before** httpx / alpaca / aiohttp open connections.
Resolution order for the CA bundle:
1. ``combined-ca-bundle.pem`` in the repo root (gitignored corporate bundle)
2. ``$HOME/combined-ca-bundle.pem`` (MacBook path used by existing tooling)
3. ``SSL_CERT_FILE`` / ``REQUESTS_CA_BUNDLE`` if already set and present
4. ``certifi.where()`` when the package is installed
5. System defaults (no patch)
Optional corporate proxy (Swisscom-style) when ``USE_CORP_PROXY=1``.
"""
from __future__ import annotations
import os
import ssl
from pathlib import Path
_BOOTSTRAPPED = False
def _candidate_ca_paths() -> list[Path]:
root = Path(__file__).resolve().parent.parent
home = Path.home()
env_paths = [
os.environ.get("SSL_CERT_FILE", ""),
os.environ.get("REQUESTS_CA_BUNDLE", ""),
os.environ.get("CURL_CA_BUNDLE", ""),
]
paths = [
root / "combined-ca-bundle.pem",
home / "combined-ca-bundle.pem",
*[Path(p) for p in env_paths if p],
]
try:
import certifi
paths.append(Path(certifi.where()))
except Exception:
pass
return paths
def resolve_ca_bundle() -> str | None:
for path in _candidate_ca_paths():
try:
if path.is_file() and path.stat().st_size > 0:
return str(path.resolve())
except OSError:
continue
return None
def apply_corp_proxy_if_requested() -> None:
if os.environ.get("USE_CORP_PROXY", "0") != "1":
return
proxy = os.environ.get("CORP_HTTP_PROXY", "http://aproxy.corproot.net:8080")
no_proxy = os.environ.get(
"CORP_NO_PROXY",
"corproot.net,sharedtcs.net,127.0.0.1,localhost,bix.swisscom.com,swisscom.com",
)
os.environ.setdefault("HTTP_PROXY", proxy)
os.environ.setdefault("HTTPS_PROXY", proxy)
os.environ.setdefault("NO_PROXY", no_proxy)
os.environ.setdefault("http_proxy", proxy)
os.environ.setdefault("https_proxy", proxy)
os.environ.setdefault("no_proxy", no_proxy)
def bootstrap_ssl(*, force: bool = False) -> str | None:
"""Install CA env vars + patch ``ssl.create_default_context``.
Returns the CA path used, or None if nothing was applied.
Safe to call multiple times.
"""
global _BOOTSTRAPPED
if _BOOTSTRAPPED and not force:
return os.environ.get("SSL_CERT_FILE") or None
apply_corp_proxy_if_requested()
cert_path = resolve_ca_bundle()
if not cert_path:
_BOOTSTRAPPED = True
return None
os.environ["SSL_CERT_FILE"] = cert_path
os.environ["REQUESTS_CA_BUNDLE"] = cert_path
os.environ["CURL_CA_BUNDLE"] = cert_path
original = ssl.create_default_context
def _patched(
purpose=ssl.Purpose.SERVER_AUTH, *, cafile=None, capath=None, cadata=None
):
ctx = original(purpose, cafile=cafile, capath=capath, cadata=cadata)
try:
ctx.load_verify_locations(cafile=cert_path)
except Exception:
pass
return ctx
ssl.create_default_context = _patched # type: ignore[assignment]
# aiohttp may cache SSL contexts at import time.
try:
import aiohttp.connector as aio_conn
for attr in ("_SSL_CONTEXT_VERIFIED", "_SSL_CONTEXT_UNVERIFIED"):
ctx = getattr(aio_conn, attr, None)
if ctx is not None:
try:
ctx.load_verify_locations(cafile=cert_path)
except Exception:
pass
except ImportError:
pass
_BOOTSTRAPPED = True
return cert_path
def ssl_status() -> dict:
"""Diagnostic blob for research scripts / MacBook troubleshooting."""
ca = resolve_ca_bundle()
return {
"ca_bundle": ca,
"ssl_cert_file_env": os.environ.get("SSL_CERT_FILE"),
"use_corp_proxy": os.environ.get("USE_CORP_PROXY", "0"),
"http_proxy": os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY"),
"candidates_exist": {
str(p): p.is_file() for p in _candidate_ca_paths()[:4]
},
}
+1
View File
@@ -47,6 +47,7 @@ score, Structural S/R, the Gate Target Ladder, sentiment, fundamentals) is
| 10 | **Inverse-vol position sizing** | The apparent "win" was **mis-attributed**: the 20% notional cap bound on 95% of entries, so it measured concentration, not vol-sizing. Genuine inverse-vol cuts DD to 18.2% but costs ~58pp return at flat Sharpe | **Rejected** as edge; it's a risk-preference trade | `backtest-20260709-position-sizing*.json` | | 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 |
--- ---
+202
View File
@@ -0,0 +1,202 @@
# Earnings gap diagnostic + SUE / PEAD (Tier-1 alpha research)
**Status:** **PARK** (incomplete earnings coverage; SUE fails iron rule on available sample).
**Branch:** `research/earnings-gap-and-sue`
**Production impact:** none. Local research only. **No filters shipped from 2a.**
**Artifacts:** `reports/earnings-gap-sue-20260719-093129.json` (+ companion `.md`)
---
## Pre-registration (locked before first research run)
### Data
- Historical earnings calendar for the production universe over the full snapshot
window (and deeper if the feed provides it).
- Preferred source: FMP **date-range earnings-calendar** (bulk). If unavailable on
free tier, fall back to per-symbol `/stable/earnings` with request accounting.
- Store in a real local table `earnings_events` (symbol + announce_date key).
- Point-in-time: a surprise is usable only from **announce date + 1 trading day**
onward.
### Experiment 2a — earnings-gap risk (defense, report-only)
Join simulated production-config trades (`fill_mode=close`) with earnings dates.
**Pre-registered questions:**
1. What fraction of losses worse than **1R** occur with an earnings announcement
**between entry and exit** (inclusive of the holding window)?
2. What is the mean R of entries taken within **3 trading days BEFORE** an
announcement vs all other entries — report **both tails** of the R
distribution (rule 4: any earnings-avoid entry filter is presumed guilty of
right-tail trimming until the win distribution shows otherwise)?
**Output:** distributions and counts only.
**No filter is shipped.** If numbers argue for a filter → report and stop.
### Experiment 2b — SUE / PEAD (offense)
Signal `sue_latest`:
\[
\text{SUE} = \frac{\text{actual} - \text{estimate}}{\sigma(\text{trailing 8 surprises})}
\]
Fallback if estimate history is thin: scale surprise by price.
Carry forward from announce+1 for **63 trading days**, else NaN (name drops out
of that cross-section).
**Iron rule (IC harness):** mean weekly Spearman IC on non-overlapping weeks;
\|mean IC\| ≥ ~0.03, **positive** sign (drift), `reliable: true` (≥12 windows).
Always side-by-side with `mom_12_1` and `mom_12_1_resid` on **identical**
cross-sections.
Also report **momentum-conditional** IC (within top momentum quintile).
**If it passes iron rule:** STOP and report. Book-integration design is a
separate human-approved step — do not wire.
### Verdict labels
| label | meaning |
|---|---|
| **PROMOTE** | (2b only) iron rule cleared → human designs tilt/gate |
| **PARK** | Interesting but incomplete / weak |
| **DEAD** | No edge / diagnostic argues against action |
| **REPORT-ONLY** | (2a) always — never auto-filter |
---
## Data provenance
| item | result |
|---|---|
| Snapshot | `backtest_snapshots/prod.sqlite` (506 names) |
| FMP bulk `earnings-calendar` | **402 Premium** — not available on free tier |
| FMP per-symbol `/stable/earnings` | used; hit daily rate limit ~225 reqs |
| Alpha Vantage `EARNINGS` | used for +24 symbols (announce = `reportedDate`) |
| Symbols with events | **48 / 506 (9.5%)** |
| Total events | 5,612 (5,018 with actual+estimate) |
| Announce range | 1985-08-31 → 2026-07-16 |
| FMP requests (first day) | 260 FMP + 25 AV (see `reports/earnings-backfill-status.json`) |
**Incomplete backfill is first-class.** 2a under-detects earnings overlaps; 2b SUE
cross-section averages **~47 names**, not ~500. Resume:
```bash
# Day N (FMP free ~250/day; AV free ~25/day — prefer FMP after reset)
python scripts/backfill_earnings_events.py \
--snapshot backtest_snapshots/prod.sqlite \
--provider fmp --force-symbol --limit 250 --sleep 0.4
# When done==506:
python scripts/run_earnings_research.py \
--snapshot backtest_snapshots/prod.sqlite \
--workers 6 --allow-spawn
```
---
## Results
Generated: `2026-07-19T09:31:29`
### 2a — Earnings-gap risk (report-only)
Production book sim: Sharpe 2.09 (SE 0.497), CAGR 51.6%, max DD 21.4%, **322 trades**,
`fill_mode=close`.
#### Q1 — Losses worse than 1R with earnings in hold
| metric | value |
|---|---:|
| n losses &lt; 1R | 28 |
| of which earnings in hold | **1** |
| fraction | **3.6%** |
| all trades with earnings in hold | 14 / 322 (4.4%) |
**Read:** On incomplete earnings labels this is a **lower bound** on earnings
overlap, not a clean “earnings rarely hurt.” Do **not** conclude earnings risk is
immaterial until coverage ≥ ~95% of the books names.
#### Q2 — Entry within 3 trading days before announce (both tails)
| cohort | n | mean R | win rate | p05 | p50 | p95 | max |
|---|---:|---:|---:|---:|---:|---:|---:|
| pre-earn (≤3d before) | **4** | 1.94 | 50% | 1.24 | 1.12 | 6.26 | 6.84 |
| other | 318 | 0.70 | 37% | 1.11 | 0.83 | 6.08 | **12.87** |
| all | 322 | 0.71 | 37% | 1.12 | 0.83 | 6.22 | 12.87 |
**Tail-trim presumption:** n=4 is not a sample. Point estimate does **not** show
right-tail destruction of pre-earn entries (p95 similar; max actually higher in
“other”). **No earnings-avoid filter is supported.** Re-run after full backfill.
---
### 2b — SUE / PEAD IC
#### Full-universe harness (mom on ~500; SUE only where labeled)
| signal | mean_ic | ic_t_stat | weeks | avg_N | reliable |
|---|---:|---:|---:|---:|---|
| mom_12_1_sector_resid | 0.0578 | 2.34 | 35 | 497.7 | true |
| mom_12_1_resid | 0.0552 | 1.98 | 35 | 497.7 | true |
| mom_12_1 | 0.0531 | 1.61 | 35 | 497.7 | true |
| **sue_latest** | **0.0172** | **0.6** | 44 | **47.4** | true |
| fip_id | 0.045 | 2.91 | 35 | 497.7 | true |
#### Identical SUE subset (fair side-by-side — use this while coverage is thin)
| signal | mean_ic | ic_t_stat | weeks | avg_N |
|---|---:|---:|---:|---:|
| sue_latest | 0.0172 | 0.6 | 44 | 47.4 |
| mom_12_1 | 0.0174 | 0.42 | 35 | 47.3 |
| mom_12_1_resid | 0.0104 | 0.27 | 35 | 47.3 |
On the thin labeled subset, momentum itself is noise — so the subset is not yet
a meaningful PEAD test.
#### Momentum-conditional SUE (top mom quintile)
| metric | value |
|---|---:|
| mean IC | **0.0065** |
| t | 0.1 |
| weeks | 35 |
Wrong sign vs “ride positive surprises inside the momentum gate.”
**Iron rule:** fail (\|IC\| 0.017 &lt; 0.03; t 0.6). **No promote.**
---
## Verdict
| piece | verdict |
|---|---|
| **2a earnings-gap** | **REPORT-ONLY** — no filter. Coverage too thin for risk claims; tails do not argue for an avoid-filter on n=4. |
| **2b SUE** | **PARK** (effectively not green). Mild positive IC on ~48 names; fails iron bar; mom-conditional flat/negative. Re-score after full backfill before DEAD. |
| **Production** | **no change** |
---
## What a human must decide next
1. Resume multi-day earnings backfill to **506/506**, then re-run
`run_earnings_research.py` (heavy — MacBook OK).
2. Do **not** ship an earnings-avoid entry filter from 2a.
3. Do **not** wire SUE until a full-coverage IC clears the iron rule (and
preferably mom-conditional &gt; 0).
4. Do not merge into main strategy docs without review.
---
## Implementation notes
| piece | role |
|---|---|
| `scripts/backfill_earnings_events.py` | bulk attempt → FMP/AV per-symbol; `earnings_events` + meta on snapshot |
| `scripts/run_earnings_research.py` | 2a trade join + 2b SUE IC / mom-conditional |
| Snapshot table `earnings_events` | real table (not SystemSetting JSON) |
+206
View File
@@ -0,0 +1,206 @@
# History-depth extension (Tier-1 alpha research)
**Status:** **CLOSED.** Sector-residual deep test **FAIL** — Task 1 archived as rejected (see supersession).
**Branch:** `research/earnings-gap-and-sue`
**Superseded artifact (do not cite):** `reports/history-depth-20260719-103315.json`**UNMASKED, TWO-TIER SNAPSHOT**
**Authoritative sector grade:** `reports/sector-resid-deep-20260719-113319.json`
**Production impact:** none. **Do not retune any production knob on deep history.**
---
## Pre-registration (locked before rebuild)
### Motivation
All current conclusions rest on ~35 non-overlapping weekly windows in essentially
one post-2021 regime. Extending history toward max Alpaca daily-bar depth adds
the 2018 vol shock and full 2020 crash (where the feed allows).
### Protocol
1. **Empirical coverage first** — bars per calendar year per symbol; document
where the feed thins out. Do **not** assume a uniform start date.
2. **Rebuild the research snapshot completely** from prod source + max history
per symbol (`Adjustment.SPLIT`, ~200 req/min pacing via existing extender).
3. **Race guard (rule 6)** — refuse analysis until completion manifest is
`complete=true` and live counts match.
4. **Re-run full signal harness** (all existing signals incl. sector residual /
SUE if present) on the extended window.
5. **Report per signal:** mean IC, t, window count, and **era split**
(pre-/post-2021) — diagnostic only, **not a tuning input**.
6. **Log prominently:** survivorship bias grows with depth (todays constituents
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
**relative** signal comparisons and IC stability, not levels.
7. **Do not retune** production knobs. If a knobs confirmation looks
overturned on deep history → report only; human decides.
### Success / interpretation (not promotion of a new signal)
| outcome | meaning |
|---|---|
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
| Any production knob looks worse deep | report; no auto-retune |
---
## Data provenance
| check | result |
|---|---|
| Snapshot | MacBook `research.sqlite` |
| Manifest `complete` | **true** (finished 2026-07-19T08:19Z) |
| Live counts match | yes — 4655 tickers / 6,609,926 OHLCV / 4149 rank_only |
| `history_days` | 5000 |
| fetch_ok / fail | 4152 / 0 |
| Race guard | **pass** |
> **SURVIVORSHIP BIAS:** todays constituents backfilled historically. Absolute
> Sharpe/CAGR levels on deep history are optimistic. Use **relative** signal IC
> comparisons and era stability only — not levels.
**Coverage JSON was empty in the auto-written doc** (harness-only phase after
rebuild). Manifest is the race-guard source of truth for this run.
Earlier MacBook files `history-depth-20260719-093853``095156` are intermediate
/ incomplete passes — **do not cite**. Only **103315** is authoritative.
---
## Results (authoritative: 103315)
### Full-window signal IC (broad research universe, deep bars)
| signal | mean_ic | t | weeks | avg_N | notes |
|---|---:|---:|---:|---:|---|
| high_52w | **0.111** | **6.41** | 84 | 2280 | strong on deep breadth |
| mom_12_1 | **0.066** | **5.11** | 83 | 2278 | raw momentum strong |
| trend_200 | 0.055 | 4.30 | 85 | 2308 | |
| mom_6_1 | 0.049 | 4.91 | 88 | 2376 | |
| mom_3_1 | 0.036 | 3.58 | 90 | 2426 | |
| fip_id | **+0.027** | **3.25** | 83 | 2278 | **sign flip vs prod fingerprint** |
| mom_12_1_resid | 0.026 | 2.21 | 83 | 2278 | market residual still + but weaker than raw |
| reversal_1m | ~0 | 0.31 | 91 | 2443 | dead |
| vol_6m | **0.123** | **6.34** | 88 | 2376 | low-vol anomaly strong |
| mom_12_1_sector_resid | 0.058 | 2.34 | **35** | **498** | **not deep-sample — see caveats** |
| mom_12_1_sector_demeaned | 0.034 | 1.32 | **35** | **497** | same short fingerprint |
### Era split (diagnostic only — not a tuning input)
| signal | pre-2021 IC / t / w / N | post-2021 IC / t / w / N |
|---|---|---|
| mom_12_1 | +0.041 / 2.94 / 36 / 1425 | +0.079 / 3.64 / 48 / 2913 |
| mom_12_1_resid | +0.023 / 1.68 / 36 / 1425 | +0.027 / 1.37 / 48 / 2913 |
| fip_id | +0.012 / 1.35 / 36 / 1425 | +0.037 / 2.91 / 48 / 2913 |
| vol_6m | 0.056 / 2.16 / 40 / 1461 | 0.162 / 4.94 / 48 / 3123 |
| high_52w | +0.049 / 2.0 / 36 / 1422 | +0.138 / 4.34 / 48 / 2915 |
| **sector_resid** | **absent** | 0.058 / 2.34 / 35 / 498 (short only) |
| **sector_demeaned** | **absent** | 0.034 / 1.32 / 35 / 497 (short only) |
---
## Critical caveats (must read)
### 1. Sector residual did **not** get a deep-history stress test
`mom_12_1_sector_resid` / `_demeaned` still show **exactly** the Task1 short-window
fingerprint: **35 weeks, N≈498, IC 0.0578, t 2.34**.
On the same run, raw `mom_12_1` has **83 weeks, N≈2278**. So depth worked for
price-only signals, but sector residual is still limited to the **~505 labeled
prod names × short factor calendar** (sector map only covers prod; and/or sector
ETF / two-factor path did not extend usable residual weeks).
**Pre-registered rule:** “Sector residual collapses pre-2021 → PARK Task 1
wire-in.” Pre-2021 sector residual is **absent** from the era table. That is a
**PARK**, not a confirmation of the short-window PROMOTE.
Do **not** claim “sector residual beats market residual on deep history” from
this table — the two rows are **not the same cross-section or window count**.
### 2. `fip_id` sign flips vs production fingerprint
| sample | fip mean IC | t |
|---|---:|---:|
| Prod 505, ~5y (fingerprint) | **0.045** | 2.91 |
| Research breadth, deep (this run) | **+0.027** | +3.25 |
This does **not** authorize resurrecting unconditional FIP as a book filter. It
confirms earlier PhaseB caution: FIP edge is **universe- and sample-dependent**.
Production display card can stay context-only. Nested lookbacks still not OOS.
### 3. Market residual vs raw momentum on deep breadth
On deep broad IC, **raw 121 (0.066 / t 5.1) ≫ market residual (0.026 / t 2.2)**.
That does **not** by itself overturn production residual ranking (book A/B was
on 505 + GTL gate, not pure factor IC), but it is a yellow flag for “residual is
always the better rank key” stories on broad history. **No auto-retune.**
### 4. Low-vol anomaly is the cleanest deep-history result
`vol_6m` IC 0.12 / t 6.3 full; stronger post-2021. Consistent sign across eras.
Production already blends **high**-vol (not low-vol) into the 80/20 rank — this
report does not change that without a separate A/B. Flag for human awareness only.
---
## Verdicts (vs pre-registration)
| question | verdict |
|---|---|
| Task 1 sector residual wire-in | **PARK** — no pre-2021 sector residual; deep-sample IC not established; short-window PROMOTE stays “human design only,” **not strengthened** by this run |
| Sector demean | still **DEAD** for promotion (t 1.32, short only) |
| SUE | **not re-scored here** (no `sue_latest` in harness table) — leave Task 2 **PARK** until full earnings backfill |
| fip unconditional book filter | remains **rejected / parked** despite sign flip on broad deep sample |
| Production residual / 80/20 / trail knobs | **no retune** from this report |
| Overall Task 3 | **COMPLETE as diagnostic** — payload is relative IC + caveats above |
---
## What a human must decide next
1. **Sector residual — decided:** CLOSED / REJECTED (archive complete). No wire-in.
2. **Do not** retune residual vs raw, FIP, or vol blend from deep IC tables
without a pre-registered book A/B on the intended universe.
3. Optional (separate threads only): finish earnings backfill and re-run SUE;
snapshot per-symbol depth guard as tooling.
---
## Artifacts
| file | role |
|---|---|
| `reports/history-depth-20260719-103315.json` | Superseded unmasked/two-tier IC dump (do not cite for sector residual) |
| `reports/sector-resid-deep-20260719-113319.json` | Authoritative sector-resid deep grade |
| `reports/prod-book-universe-horizon-20260719-140737.json` | 505 vs liquid × horizon book matrix |
Intermediate history-depth partials (093853095156) and SANITY-FAIL noise were
removed in branch cleanup.
---
## Supersession notice (2026-07-19 sector-resid deep test)
The table and interpretation from **`history-depth-20260719-103315`** are **UNMASKED, TWO-TIER SNAPSHOT — superseded, directional only, do not cite**. Prod-universe names (and sector residual coverage) were left shallow while breadth names were deepened; sector residual weeks=35 was a data gap.
### Sector-residual deep test outcome: **FAIL** (archived)
**Task 1 CLOSED / REJECTED** — sector residual dead on deep evidence. Archived in
the research log rejected table (#13). Do not resurrect without a new
pre-registered protocol.
| check | result |
|---|---|
| weeks | **83** (data fix worked) |
| mean IC | **0.0268** (below 0.03 bar) → FAIL |
| t vs resid same CS | 1.69 ≥ 1.30 pass |
| era signs | both + pass |
- Artifact: `reports/sector-resid-deep-20260719-113319.json`
- Summary write-up: [sector-residual-momentum.md](sector-residual-momentum.md)
**Future snapshot rebuilds must verify per-symbol depth** (earliest-bar
uniformity across the intended universe) — guard is a to-do, not part of this
order.
+127
View File
@@ -0,0 +1,127 @@
# Production book × universe × horizon matrix
**Status:** PRE-REGISTERED — prepare / MacBook run; no production changes.
**Branch:** `research/earnings-gap-and-sue`
**Runner:** `scripts/run_prod_book_universe_matrix.py`
---
## Question
How does the **live production book** (unchanged knobs) behave when we only vary:
1. **History length** used for entries (≈4y vs since 2016-07)
2. **Tradable universe** (prod ~505 vs 505 + PIT liquid Nasdaq/breadth)
No strategy modifications: same residual gate, 80/20 high-vol rank, GTL entry
machinery, 3× ATR trail, 30d max hold, gate-reset re-entry, `fill_mode=close`,
cost 10 bps/side, max 10, 1% risk.
---
## Pre-registered arms (locked)
| id | label | Entry start | Tradable universe |
|---|---|---|---|
| **A** | prod_4y_505 | **2022-07-01** | Prod ~505 only |
| **B** | prod_4y_505_liquid | **2022-07-01** | Prod liquid top-1500 |
| **C** | prod_2016_505 | **2016-07-01** | Prod ~505 only |
| **D** | prod_2016_505_liquid | **2016-07-01** | Prod liquid top-1500 |
- **End:** last available bar in snapshot (no artificial end).
- **4y start** chosen to align with recent PhaseA / book baselines (~mid2022 → mid2026).
- **2016-07-01** = first full month after typical Alpaca floor (~2016-01); residual 121 needs ~1y bars so first residual ranks appear mid2017 where feed allows.
### Universe definitions
| set | definition |
|---|---|
| **Prod ~505** | Symbols **not** in `research_rank_only` on the research snapshot (the original prod-universe copy). |
| **Liquid top-1500** | Point-in-time: among names with as-of close ≥ **$5** and valid 63d median $vol, keep top **1500** by that $vol. Same definition as breadth IC research. |
| **Prod liquid** | A name may enter the book on date *t* if it is prod **or** in the liquid top-1500 at *t*. |
Cross-sectional residual / vol / 80/20 ranks are **recomputed inside each arms
eligible candidate set** that period (so breadth arms are not ranked against
non-eligible thin names).
### Explicit non-goals
- No sector residual, SUE, FIP filter, gap-cap, take-profit, vol-target, corr-cap
- No retune of trail / cutoff / min_rr
- Survivorship: report levels with the standard caveat; **compare arms relatively**
### Reporting (required table)
Per arm: Sharpe, Sharpe SE (Mertens), CAGR %, max DD %, total return %, trades,
win rate if available, start/end, n qualified longs. One markdown table + JSON.
**No promotion rule** — descriptive matrix only. Human decides whether breadth
or depth changes the risk story.
---
## Snapshot requirements
- Prefer MacBook **deep** `research.sqlite` after sector-resid deepen (prod names
from ~2016, breadth deep, completion manifest `complete=true`).
- Race-guard before run.
- Sector map / sector ETFs optional (not used for ranking).
---
## Results
Generated: `2026-07-19T14:07:37` · artifact
`reports/prod-book-universe-horizon-20260719-140737.json`
Snapshot: MacBook deep `research.sqlite` (506 prod + breadth prices; 2.39M raw
GTL candidates). Strategy knobs = live production (residual 80, 80/20 high-vol
rank, ATR trail 3×, hold 30, gate-reset, `fill_mode=close`).
> Survivorship: today's constituents backfilled. **Compare arms relatively.**
> Absolute deep CAGR/Sharpe are not deployable forecasts.
| arm | universe | entries from | Sharpe | SE | CAGR % | max DD % | total ret % | trades | win % | vs SPY |
|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|
| **A** | 505 only | 2022-07-01 | **1.32** | 0.49 | **31.8** | **18.9** | +205 | 374 | 35.6 | +96.5 |
| **B** | 505 + liquid 1500 | 2022-07-01 | 0.14 | 0.50 | 1.8 | 55.3 | 7 | 706 | 29.3 | +95.0 |
| **C** | 505 only | 2016-07-01 | **0.88** | 0.31 | **16.7** | **24.4** | +369 | 763 | 36.7 | +257 |
| **D** | 505 + liquid 1500 | 2016-07-01 | 0.06 | 0.32 | 7.0 | 73.9 | 52 | 1567 | 28.0 | +254 |
Qualified longs: A 1448 · B 6587 · C 2450 · D 11551.
### Read (relative only)
1. **Same strategy, broader liquid universe kills the book** (A→B and C→D).
Sharpe collapses; DD roughly triples; win rate drops ~68pp; trade count
~doubles. This matches earlier breadth IC work: the production residual +
high-vol package is a **large-cap / prod-universe** edge, not a
“more names = better” edge.
2. **Longer history on 505 stays positive but softer** (A→C). Sharpe 1.32 → 0.88,
CAGR 32% → 17%, DD 19% → 24%. Still well above the liquid-breadth arms.
Levels are optimistic (survivorship); the useful message is “edge does not
vanish when 2018/2020 are included,” not “expect 17% CAGR forever.”
3. **Arm A vs older PhaseA / short-window controls** (~Sharpe 1.72.1): this
matrix re-ranked on deep research.sqlite with a fixed entry start; numbers
need not match prior reports row-for-row. Use **this table for AD
comparisons**, not for rewriting the production baseline number.
4. **No production change implied.** Keep the live ~505 universe. Do not broaden
the tradable set to liquid Nasdaq under current knobs without a new
pre-registered design (and almost certainly a different rank/tilt package).
## Verdict
**Descriptive matrix complete.**
| question | answer from this matrix |
|---|---|
| Prod book @ ~4y / 505 | Positive (arm A) |
| Same + liquid Nasdaq | **No** — large degradation (arm B) |
| Prod book since 2016 / 505 | Still positive, milder (arm C) |
| Same + liquid Nasdaq deep | **No** — worst arm (arm D) |
**PENDING_HUMAN** only for whether to log “universe broaden under current knobs”
as rejected in the main research index. Strategy knobs unchanged either way.
+235
View File
@@ -0,0 +1,235 @@
# Sector-residual momentum (Tier-1 alpha research)
**Status:** **CLOSED / REJECTED** — do not resurrect without a new pre-registered protocol.
**Branch:** `research/earnings-gap-and-sue` (final grade) · earlier short-window work on `research/sector-residual-momentum`
**Production impact:** none. Market residual 12-1 remains the production momentum leg.
**Authoritative deep grade:** `reports/sector-resid-deep-20260719-113319.json` (**FAIL**)
**Short-window A/B (superseded for promotion):** `reports/sector-residual-20260719-083356.json` — knife-edge only; not decisive after deep masked retest.
### Closure (2026-07-19)
Pre-registered deep test on repaired snapshot + liquid-1500 mask:
| check | result |
|---|---|
| weeks extended (≫ 35) | pass (83) |
| sign +, reliable, eras both + | pass |
| t ≥ `mom_12_1_resid` same CS | pass (1.69 ≥ 1.30) |
| \|mean IC\| ≥ 0.03 | **fail (0.0268)** |
**Verdict:** Task 1 CLOSED — sector residual dead on deep evidence.
`mom_12_1_sector_demeaned` remains DEAD for promotion. No further sector-residual variants from this thread.
---
## Pre-registration (locked before first research run)
### Hypothesis
Residualizing 121 momentum against the sector, not only the market, reduces
factor volatility at similar return (Blitz / Huij / Martens-style) → higher
Sharpe on the production book when the residual replaces market-only residual
as the momentum leg.
### Signals (candidates)
| signal | construction |
|---|---|
| `mom_12_1_sector_resid` | Two-factor residual vs SPY + tickers sector ETF. Same window as `mom_12_1_resid`: ≥100 daily obs, 252-bar lookback, 21-bar skip; two-factor OLS betas **without intercept**; cumulate residual returns over the formation window. |
| `mom_12_1_sector_demeaned` | Plain `mom_12_1` minus the **cross-sectional** mean of `mom_12_1` within the same GICS sector that week (≥2 names in sector). No regression. |
### Baselines (same run, same cross-sections — iron rule)
Always report side-by-side with:
- `mom_12_1`
- `mom_12_1_resid`
Computed on the **identical** weekly non-overlapping cross-sections in this run.
Never compare against IC numbers from another report.
### Iron rule (IC harness)
Source of truth: `_signal_evaluation` in `app/services/backtest_service.py`.
- Mean weekly Spearman IC on **non-overlapping** weekly windows
- Bar: \|mean IC\| ≥ ~0.03, **consistent positive sign**, `reliable: true` (≥ 12 windows)
### Promotion to portfolio A/B (candidate → book)
A candidate promotes to A/B **only if**:
1. It clears the iron-rule bar **and**
2. Its IC **t-stat ≥** that of `mom_12_1_resid` on the same cross-sections.
### Portfolio A/B grading (if and only if IC promotion fires)
- Swap candidate in as the **momentum leg** of the production 80/20 momentum/vol
rank **and** as the gate-percentile signal.
- `fill_mode=close`, `COST_PER_SIDE = 0.001`, full config otherwise unchanged.
- Validation window = entries ≥ **2024-07-01** (call it **validation**, not
holdout — contaminated by prior experiments).
- Pre-registered promotion bar:
- validation Sharpe ≥ control 0.5·SE
- full-period Sharpe and max-DD **not worse** than control
- Report Lo / Mertens-adjusted SEs.
### Optional sector-cap sub-experiment
Only if labels are in **and** A/B ran: max **3** positions per sector in the
10-slot book. Same A/B grading. **Tail-trim presumption of guilt** (rule 4):
report entry counts and both tails of the R distribution. Rising win rate with
falling Sharpe/CAGR = red flag → do not promote.
**This run:** sector-cap arm **not executed** (optional; A/B unconstrained book
only). Can be a human-approved follow-up.
### Verdict labels
| label | meaning |
|---|---|
| **PROMOTE** | Clears pre-registered bar; human decides next (wire design separate) |
| **PARK** | Inconclusive / weak; keep machinery, no book change |
| **DEAD** | Failed iron rule or worse than residual baseline with clear sign |
### Explicit non-goals
- No production deploy from this doc
- Do not resurrect: take-profit exits, EV gate, regime entry-blocking,
inverse-vol sizing, gap-caps, unconditional FIP filter
---
## Data provenance
### Snapshot race guard
| check | result |
|---|---|
| Snapshot path | `backtest_snapshots/prod.sqlite` |
| Manifest | none (expected for prod snapshot); bar-count sanity applied |
| Tickers / OHLCV | **506** / **629,263** |
| Bars min / avg / max | 14 / 1246.1 / 1261 |
| OHLCV range | 2021-06-24 → 2026-07-02 |
| Partial-build red flags | none (avg bars healthy) |
Integrity fingerprint on same run: `fip_id` mean IC **0.045** / t **2.91**
(35 weeks, N≈498) — matches the established prod fingerprint.
### Sector labels
| source | count |
|---|---:|
| Public S&P 500 GICS CSV | 496 newly filled |
| FMP profile requests | 10 (all missing after CSV) |
| Mapped / universe | **505 / 506 (99.8%)** |
| With mappable ETF | 505 |
| Still missing | **RHM** only |
Persist path: `data/research/ticker_sector_map.json`.
FMP aliases (`Technology`, `Consumer Defensive`, `Financial Services`) map to
SPDRs via the alias table in `app/services/sector_map.py`.
### Sector ETFs in `benchmark_prices` (auxiliary only — not tradable)
| symbol | bars | min date | max date |
|---|---:|---|---|
| SPY | 1516 | 2020-07-06 | 2026-07-17 |
| XLB…XLY (11) | 1512 each | 2020-07-10 | 2026-07-17 |
Fetched via Alpaca `Adjustment.SPLIT` into **`benchmark_prices`** (same table as
SPY) so they never enter the ticker universe or candidate replay.
---
## Results
Generated: `2026-07-19T08:33:56`
### IC harness (identical cross-sections, production 506-name universe)
| signal | mean_ic | ic_t_stat | weeks | avg_N | reliable | ic+_pct | quintile spread |
|---|---:|---:|---:|---:|---|---:|---:|
| **mom_12_1_sector_resid** | **0.0578** | **2.34** | 35 | 497.7 | true | 65.7 | 0.0245 |
| mom_12_1_resid | 0.0552 | 1.98 | 35 | 497.7 | true | 60.0 | 0.0207 |
| mom_12_1 | 0.0531 | 1.61 | 35 | 497.7 | true | 65.7 | 0.0206 |
| mom_12_1_sector_demeaned | 0.0340 | 1.32 | 35 | 496.7 | true | 62.9 | 0.0154 |
### IC promotion grades
| candidate | iron rule | t ≥ resid | promote_to_ab |
|---|---|---|---|
| `mom_12_1_sector_resid` | pass (IC 0.058, +sign, reliable) | **yes** (2.34 ≥ 1.98) | **yes** |
| `mom_12_1_sector_demeaned` | pass (IC 0.034, +sign, reliable) | **no** (1.32 < 1.98) | **no** |
### Portfolio A/B — `mom_12_1_sector_resid` as residual leg
Config: production 80/20 residual/high-vol rank + gate percentile, `fill_mode=close`,
cost 10 bps/side, ATR trail / gate-reset re-entry as live. Validation split
2024-07-01.
| window | arm | Sharpe | Sharpe SE (Mertens) | CAGR % | max DD % | trades | n_days |
|---|---|---:|---:|---:|---:|---:|---:|
| train | control (resid) | 1.30 | 0.685 | 29.2 | 21.4 | 176 | 525 |
| train | treatment (sector resid) | **1.57** | 0.677 | **35.5** | **19.8** | 176 | 530 |
| validation | control | **2.92** | 0.709 | **76.3** | **11.7** | 150 | 501 |
| validation | treatment | 2.57 | 0.701 | 66.3 | 14.8 | 163 | 501 |
| full | control | 2.09 | 0.497 | 51.6 | 21.4 | 322 | 1000 |
| full | treatment | 2.09 | 0.491 | 51.0 | **19.8** | 337 | 1005 |
**Pre-registered A/B checks**
| check | result |
|---|---|
| val Sharpe ≥ control 0.5·SE | **pass** (2.57 ≥ 2.92 0.5×0.701 = 2.5695) — **knife-edge** |
| full Sharpe not worse | **pass** (2.09 = 2.09) |
| full max DD not worse | **pass** (19.8 < 21.4) |
Qualified long candidates: control 1086 vs treatment 1210 (sector residual
gates a slightly larger set).
---
## Verdict (final — archived)
| signal | verdict | note |
|---|---|---|
| **`mom_12_1_sector_resid`** | **CLOSED / REJECTED** | Deep masked IC 0.0268 &lt; 0.03 bar (`sector-resid-deep-20260719-113319`). Short-window PROMOTE superseded. |
| **`mom_12_1_sector_demeaned`** | **DEAD** | Never cleared t vs market residual; stays dead. |
Short-window evidence below is **historical only** (pre-deep retest). Do not use it
to reopen promotion.
### Read carefully (archived context)
1. Short-window IC (0.058 / t 2.34 vs resid 0.055 / t 1.98 on 35 weeks) and knife-edge
A/B looked openable — that was the data gap era (shallow prod bars).
2. Deep repaired + liquid-1500 retest closed the case: weeks 83, mild +IC, **below bar**.
3. Production keeps **market** residual 12-1. Research harness may still *emit*
sector residual for diagnostics; it is not a promotion candidate.
4. **Do not resurrect** without a new pre-registered protocol and new data.
---
## What a human must decide next
**Nothing on Task 1** — archived. Optional: leave research machinery in tree
(harmless) or delete later as cleanup; not a strategy decision.
---
## Implementation notes
Research runners and sector-residual harness hooks were **removed after close**
(2026-07-19 cleanup). Evidence remains in the report artifacts below. Do not
re-add without a new pre-registered protocol.
---
## Artifacts
| file | role |
|---|---|
| `reports/sector-resid-deep-20260719-113319.json` | **Authoritative deep FAIL** |
| `reports/sector-residual-20260719-083356.json` | Short-window IC/A/B (superseded for promotion) |
+15
View File
@@ -0,0 +1,15 @@
{
"mode": "per_symbol",
"fmp_requests": 25,
"events_written_this_run": 2541,
"total_events": 5612,
"symbols_done": 48,
"symbols_universe": 506,
"announce_date_range": {
"min": "1985-08-31",
"max": "2026-07-16"
},
"events_with_actual_and_estimate": 5018,
"budget": 25,
"complete": false
}
@@ -0,0 +1,331 @@
{
"generated_at": "2026-07-19T09:31:29.078611",
"data_provenance": {
"snapshot": "C:\\Workspace\\signal-platform\\backtest_snapshots\\prod.sqlite",
"n_earnings_events": 5612,
"backfill_meta": {
"done": 48,
"universe_tickers": 506
},
"announce_range": {
"min": "1985-08-31",
"max": "2026-07-16"
},
"with_actual_and_estimate": 5018
},
"experiment_2a": {
"sim_summary": {
"sharpe": 2.09,
"sharpe_se": 0.497,
"cagr_pct": 51.6,
"max_drawdown_pct": 21.4,
"trades": 322,
"total_return_pct": 424.6
},
"n_trades_parsed": 322,
"q1_losses_worse_than_minus_1r": {
"n_losses_lt_minus_1r": 28,
"n_with_earnings_in_hold": 1,
"fraction_with_earnings": 0.0357,
"all_trades_with_earnings_in_hold": 14,
"fraction_all_trades_with_earnings": 0.0435
},
"q2_entry_within_3d_before_announce": {
"pre_earn_entries": {
"n": 4,
"mean": 1.9379,
"win_rate": 0.5,
"p05": -1.2428,
"p25": -0.8833,
"p50": 1.1209,
"p75": 3.942,
"p95": 6.2623,
"min": -1.3327,
"max": 6.8424
},
"other_entries": {
"n": 318,
"mean": 0.6965,
"win_rate": 0.3711,
"p05": -1.1052,
"p25": -1.0,
"p50": -0.8259,
"p75": 2.1053,
"p95": 6.077,
"min": -3.2587,
"max": 12.8654
},
"all_entries": {
"n": 322,
"mean": 0.7119,
"win_rate": 0.3727,
"p05": -1.1209,
"p25": -1.0,
"p50": -0.8251,
"p75": 2.1595,
"p95": 6.2246,
"min": -3.2587,
"max": 12.8654
},
"tail_trim_note": "Compare p95/max and mean of pre_earn vs other. Rising win_rate with falling mean/p95 = right-tail trim red flag."
},
"note": "REPORT-ONLY \u2014 no filter shipped."
},
"experiment_2b": {
"signal_eval_side_by_side": {
"mom_12_1": {
"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
},
"mom_12_1_resid": {
"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
},
"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
},
"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
},
"sue_latest": {
"signal": "sue_latest",
"weeks": 44,
"avg_cross_section": 47.4,
"mean_ic": 0.0172,
"ic_t_stat": 0.6,
"ic_positive_pct": 47.7,
"mean_quintile_spread": 0.0064,
"reliable": true
},
"fip_id": {
"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_identical_sue_subset": {
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 35,
"avg_cross_section": 47.3,
"mean_ic": -0.0174,
"ic_t_stat": -0.42,
"ic_positive_pct": 45.7,
"mean_quintile_spread": 0.0077,
"reliable": true
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 35,
"avg_cross_section": 47.3,
"mean_ic": -0.0104,
"ic_t_stat": -0.27,
"ic_positive_pct": 51.4,
"mean_quintile_spread": 0.0075,
"reliable": true
},
"sue_latest": {
"signal": "sue_latest",
"weeks": 44,
"avg_cross_section": 47.4,
"mean_ic": 0.0172,
"ic_t_stat": 0.6,
"ic_positive_pct": 47.7,
"mean_quintile_spread": 0.0064,
"reliable": true
}
},
"identical_subset_note": "Mom baselines re-scored only on (week, symbol) cells where SUE exists. Use this table when backfill is incomplete \u2014 full-universe mom N is not comparable.",
"full_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": "sue_latest",
"weeks": 44,
"avg_cross_section": 47.4,
"mean_ic": 0.0172,
"ic_t_stat": 0.6,
"ic_positive_pct": 47.7,
"mean_quintile_spread": 0.0064,
"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
}
],
"sue_grade": {
"green": false,
"checks": {
"mean_ic": 0.0172,
"sign_positive": true,
"abs_ge_0_03": false,
"reliable": true,
"ic_t_stat": 0.6,
"weeks": 44
},
"reason": "iron rule not met",
"row": {
"signal": "sue_latest",
"weeks": 44,
"avg_cross_section": 47.4,
"mean_ic": 0.0172,
"ic_t_stat": 0.6,
"ic_positive_pct": 47.7,
"mean_quintile_spread": 0.0064,
"reliable": true
}
},
"momentum_conditional_sue": {
"mean_ic": -0.0065,
"ic_t_stat": -0.1,
"weeks": 35,
"note": "IC of sue_latest within top mom_12_1 quintile (non-overlapping weeks)"
},
"sue_coverage": {
"symbols_with_sue": 48,
"avg_weeks_with_sue": 47.1,
"weeks_with_min_cross_section": 256
}
},
"verdict": "PARK",
"verdict_detail": "SUE IC=0.0172 below iron bar or unreliable; keep data, no wire.",
"human_next": "- No SUE book change.\n- Read 2a tails before considering any earnings-avoid filter.",
"report_path": "reports/earnings-gap-sue-20260719-093129.json",
"fmp_note": "Bulk earnings-calendar is paid (402 on free tier). Backfill used per-symbol /stable/earnings; see earnings-backfill-status.json."
}
+202
View File
@@ -0,0 +1,202 @@
# Earnings gap diagnostic + SUE / PEAD (Tier-1 alpha research)
**Status:** **PARK** (incomplete earnings coverage; SUE fails iron rule on available sample).
**Branch:** `research/earnings-gap-and-sue`
**Production impact:** none. Local research only. **No filters shipped from 2a.**
**Artifacts:** `reports/earnings-gap-sue-20260719-093129.json` (+ companion `.md`)
---
## Pre-registration (locked before first research run)
### Data
- Historical earnings calendar for the production universe over the full snapshot
window (and deeper if the feed provides it).
- Preferred source: FMP **date-range earnings-calendar** (bulk). If unavailable on
free tier, fall back to per-symbol `/stable/earnings` with request accounting.
- Store in a real local table `earnings_events` (symbol + announce_date key).
- Point-in-time: a surprise is usable only from **announce date + 1 trading day**
onward.
### Experiment 2a — earnings-gap risk (defense, report-only)
Join simulated production-config trades (`fill_mode=close`) with earnings dates.
**Pre-registered questions:**
1. What fraction of losses worse than **1R** occur with an earnings announcement
**between entry and exit** (inclusive of the holding window)?
2. What is the mean R of entries taken within **3 trading days BEFORE** an
announcement vs all other entries — report **both tails** of the R
distribution (rule 4: any earnings-avoid entry filter is presumed guilty of
right-tail trimming until the win distribution shows otherwise)?
**Output:** distributions and counts only.
**No filter is shipped.** If numbers argue for a filter → report and stop.
### Experiment 2b — SUE / PEAD (offense)
Signal `sue_latest`:
\[
\text{SUE} = \frac{\text{actual} - \text{estimate}}{\sigma(\text{trailing 8 surprises})}
\]
Fallback if estimate history is thin: scale surprise by price.
Carry forward from announce+1 for **63 trading days**, else NaN (name drops out
of that cross-section).
**Iron rule (IC harness):** mean weekly Spearman IC on non-overlapping weeks;
\|mean IC\| ≥ ~0.03, **positive** sign (drift), `reliable: true` (≥12 windows).
Always side-by-side with `mom_12_1` and `mom_12_1_resid` on **identical**
cross-sections.
Also report **momentum-conditional** IC (within top momentum quintile).
**If it passes iron rule:** STOP and report. Book-integration design is a
separate human-approved step — do not wire.
### Verdict labels
| label | meaning |
|---|---|
| **PROMOTE** | (2b only) iron rule cleared → human designs tilt/gate |
| **PARK** | Interesting but incomplete / weak |
| **DEAD** | No edge / diagnostic argues against action |
| **REPORT-ONLY** | (2a) always — never auto-filter |
---
## Data provenance
| item | result |
|---|---|
| Snapshot | `backtest_snapshots/prod.sqlite` (506 names) |
| FMP bulk `earnings-calendar` | **402 Premium** — not available on free tier |
| FMP per-symbol `/stable/earnings` | used; hit daily rate limit ~225 reqs |
| Alpha Vantage `EARNINGS` | used for +24 symbols (announce = `reportedDate`) |
| Symbols with events | **48 / 506 (9.5%)** |
| Total events | 5,612 (5,018 with actual+estimate) |
| Announce range | 1985-08-31 → 2026-07-16 |
| FMP requests (first day) | 260 FMP + 25 AV (see `reports/earnings-backfill-status.json`) |
**Incomplete backfill is first-class.** 2a under-detects earnings overlaps; 2b SUE
cross-section averages **~47 names**, not ~500. Resume:
```bash
# Day N (FMP free ~250/day; AV free ~25/day — prefer FMP after reset)
python scripts/backfill_earnings_events.py \
--snapshot backtest_snapshots/prod.sqlite \
--provider fmp --force-symbol --limit 250 --sleep 0.4
# When done==506:
python scripts/run_earnings_research.py \
--snapshot backtest_snapshots/prod.sqlite \
--workers 6 --allow-spawn
```
---
## Results
Generated: `2026-07-19T09:31:29`
### 2a — Earnings-gap risk (report-only)
Production book sim: Sharpe 2.09 (SE 0.497), CAGR 51.6%, max DD 21.4%, **322 trades**,
`fill_mode=close`.
#### Q1 — Losses worse than 1R with earnings in hold
| metric | value |
|---|---:|
| n losses &lt; 1R | 28 |
| of which earnings in hold | **1** |
| fraction | **3.6%** |
| all trades with earnings in hold | 14 / 322 (4.4%) |
**Read:** On incomplete earnings labels this is a **lower bound** on earnings
overlap, not a clean “earnings rarely hurt.” Do **not** conclude earnings risk is
immaterial until coverage ≥ ~95% of the books names.
#### Q2 — Entry within 3 trading days before announce (both tails)
| cohort | n | mean R | win rate | p05 | p50 | p95 | max |
|---|---:|---:|---:|---:|---:|---:|---:|
| pre-earn (≤3d before) | **4** | 1.94 | 50% | 1.24 | 1.12 | 6.26 | 6.84 |
| other | 318 | 0.70 | 37% | 1.11 | 0.83 | 6.08 | **12.87** |
| all | 322 | 0.71 | 37% | 1.12 | 0.83 | 6.22 | 12.87 |
**Tail-trim presumption:** n=4 is not a sample. Point estimate does **not** show
right-tail destruction of pre-earn entries (p95 similar; max actually higher in
“other”). **No earnings-avoid filter is supported.** Re-run after full backfill.
---
### 2b — SUE / PEAD IC
#### Full-universe harness (mom on ~500; SUE only where labeled)
| signal | mean_ic | ic_t_stat | weeks | avg_N | reliable |
|---|---:|---:|---:|---:|---|
| mom_12_1_sector_resid | 0.0578 | 2.34 | 35 | 497.7 | true |
| mom_12_1_resid | 0.0552 | 1.98 | 35 | 497.7 | true |
| mom_12_1 | 0.0531 | 1.61 | 35 | 497.7 | true |
| **sue_latest** | **0.0172** | **0.6** | 44 | **47.4** | true |
| fip_id | 0.045 | 2.91 | 35 | 497.7 | true |
#### Identical SUE subset (fair side-by-side — use this while coverage is thin)
| signal | mean_ic | ic_t_stat | weeks | avg_N |
|---|---:|---:|---:|---:|
| sue_latest | 0.0172 | 0.6 | 44 | 47.4 |
| mom_12_1 | 0.0174 | 0.42 | 35 | 47.3 |
| mom_12_1_resid | 0.0104 | 0.27 | 35 | 47.3 |
On the thin labeled subset, momentum itself is noise — so the subset is not yet
a meaningful PEAD test.
#### Momentum-conditional SUE (top mom quintile)
| metric | value |
|---|---:|
| mean IC | **0.0065** |
| t | 0.1 |
| weeks | 35 |
Wrong sign vs “ride positive surprises inside the momentum gate.”
**Iron rule:** fail (\|IC\| 0.017 &lt; 0.03; t 0.6). **No promote.**
---
## Verdict
| piece | verdict |
|---|---|
| **2a earnings-gap** | **REPORT-ONLY** — no filter. Coverage too thin for risk claims; tails do not argue for an avoid-filter on n=4. |
| **2b SUE** | **PARK** (effectively not green). Mild positive IC on ~48 names; fails iron bar; mom-conditional flat/negative. Re-score after full backfill before DEAD. |
| **Production** | **no change** |
---
## What a human must decide next
1. Resume multi-day earnings backfill to **506/506**, then re-run
`run_earnings_research.py` (heavy — MacBook OK).
2. Do **not** ship an earnings-avoid entry filter from 2a.
3. Do **not** wire SUE until a full-coverage IC clears the iron rule (and
preferably mom-conditional &gt; 0).
4. Do not merge into main strategy docs without review.
---
## Implementation notes
| piece | role |
|---|---|
| `scripts/backfill_earnings_events.py` | bulk attempt → FMP/AV per-symbol; `earnings_events` + meta on snapshot |
| `scripts/run_earnings_research.py` | 2a trade join + 2b SUE IC / mom-conditional |
| Snapshot table `earnings_events` | real table (not SystemSetting JSON) |
+494
View File
@@ -0,0 +1,494 @@
{
"generated_at": "2026-07-19T10:33:15.322673",
"survivorship_banner": "SURVIVORSHIP BIAS: today's constituents backfilled historically. Absolute Sharpe/CAGR levels on deep history are optimistic. Use RELATIVE signal IC comparisons and era stability only \u2014 not levels.",
"coverage": null,
"race_guard": {
"manifest": {
"schema_version": 1,
"snapshot": "research.sqlite",
"snapshot_resolved": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/research.sqlite",
"complete": true,
"finished_at": "2026-07-19T08:19:02.992206+00:00",
"ticker_count": 4655,
"ohlcv_row_count": 6609926,
"rank_only_count": 4149,
"sources": {
"nasdaq_all": "nasdaq_trader",
"sp500": "wikipedia_sp500"
},
"history_days": 5000,
"min_bars": 260,
"fetch_ok": 4152,
"fetch_fail": 0,
"limit": null,
"extra": {
"prod_symbols_at_start": 506,
"pool_size": 4648,
"to_fetch": 4152
},
"live_counts": {
"ticker_count": 4655,
"ohlcv_row_count": 6609926,
"rank_only_count": 4149
}
},
"ok": true
},
"harness": {
"survivorship_banner": "SURVIVORSHIP BIAS: today's constituents backfilled historically. Absolute Sharpe/CAGR levels on deep history are optimistic. Use RELATIVE signal IC comparisons and era stability only \u2014 not levels.",
"signal_eval": [
{
"signal": "high_52w",
"weeks": 84,
"avg_cross_section": 2280.3,
"mean_ic": 0.1111,
"ic_t_stat": 6.41,
"ic_positive_pct": 76.2,
"mean_quintile_spread": -13.5104,
"reliable": true
},
{
"signal": "mom_12_1",
"weeks": 83,
"avg_cross_section": 2277.5,
"mean_ic": 0.0663,
"ic_t_stat": 5.11,
"ic_positive_pct": 74.7,
"mean_quintile_spread": -10.1818,
"reliable": true
},
{
"signal": "mom_12_1_sector_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0245,
"reliable": true
},
{
"signal": "trend_200",
"weeks": 85,
"avg_cross_section": 2308.1,
"mean_ic": 0.0546,
"ic_t_stat": 4.3,
"ic_positive_pct": 70.6,
"mean_quintile_spread": -12.4094,
"reliable": true
},
{
"signal": "mom_6_1",
"weeks": 88,
"avg_cross_section": 2375.7,
"mean_ic": 0.0493,
"ic_t_stat": 4.91,
"ic_positive_pct": 70.5,
"mean_quintile_spread": 3.4224,
"reliable": true
},
{
"signal": "mom_3_1",
"weeks": 90,
"avg_cross_section": 2425.6,
"mean_ic": 0.0363,
"ic_t_stat": 3.58,
"ic_positive_pct": 72.2,
"mean_quintile_spread": 0.9289,
"reliable": true
},
{
"signal": "mom_12_1_sector_demeaned",
"weeks": 35,
"avg_cross_section": 496.7,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"ic_positive_pct": 62.9,
"mean_quintile_spread": 0.0154,
"reliable": true
},
{
"signal": "fip_id",
"weeks": 83,
"avg_cross_section": 2277.5,
"mean_ic": 0.0267,
"ic_t_stat": 3.25,
"ic_positive_pct": 67.5,
"mean_quintile_spread": -0.0017,
"reliable": true
},
{
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 2277.5,
"mean_ic": 0.0256,
"ic_t_stat": 2.21,
"ic_positive_pct": 65.1,
"mean_quintile_spread": 10.1373,
"reliable": true
},
{
"signal": "reversal_1m",
"weeks": 91,
"avg_cross_section": 2443.2,
"mean_ic": 0.003,
"ic_t_stat": 0.31,
"ic_positive_pct": 48.4,
"mean_quintile_spread": -7.0153,
"reliable": true
},
{
"signal": "vol_6m",
"weeks": 88,
"avg_cross_section": 2375.7,
"mean_ic": -0.1226,
"ic_t_stat": -6.34,
"ic_positive_pct": 21.6,
"mean_quintile_spread": 1.4646,
"reliable": true
}
],
"era_split": {
"era_split_date": "2021-01-01",
"note": "Diagnostic only \u2014 not a tuning input. Nested lookbacks are not OOS.",
"full": {
"high_52w": {
"signal": "high_52w",
"weeks": 84,
"avg_cross_section": 2280.3,
"mean_ic": 0.1111,
"ic_t_stat": 6.41,
"ic_positive_pct": 76.2,
"mean_quintile_spread": -13.5104,
"reliable": true
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 83,
"avg_cross_section": 2277.5,
"mean_ic": 0.0663,
"ic_t_stat": 5.11,
"ic_positive_pct": 74.7,
"mean_quintile_spread": -10.1818,
"reliable": true
},
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0245,
"reliable": true
},
"trend_200": {
"signal": "trend_200",
"weeks": 85,
"avg_cross_section": 2308.1,
"mean_ic": 0.0546,
"ic_t_stat": 4.3,
"ic_positive_pct": 70.6,
"mean_quintile_spread": -12.4094,
"reliable": true
},
"mom_6_1": {
"signal": "mom_6_1",
"weeks": 88,
"avg_cross_section": 2375.7,
"mean_ic": 0.0493,
"ic_t_stat": 4.91,
"ic_positive_pct": 70.5,
"mean_quintile_spread": 3.4224,
"reliable": true
},
"mom_3_1": {
"signal": "mom_3_1",
"weeks": 90,
"avg_cross_section": 2425.6,
"mean_ic": 0.0363,
"ic_t_stat": 3.58,
"ic_positive_pct": 72.2,
"mean_quintile_spread": 0.9289,
"reliable": true
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 35,
"avg_cross_section": 496.7,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"ic_positive_pct": 62.9,
"mean_quintile_spread": 0.0154,
"reliable": true
},
"fip_id": {
"signal": "fip_id",
"weeks": 83,
"avg_cross_section": 2277.5,
"mean_ic": 0.0267,
"ic_t_stat": 3.25,
"ic_positive_pct": 67.5,
"mean_quintile_spread": -0.0017,
"reliable": true
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 2277.5,
"mean_ic": 0.0256,
"ic_t_stat": 2.21,
"ic_positive_pct": 65.1,
"mean_quintile_spread": 10.1373,
"reliable": true
},
"reversal_1m": {
"signal": "reversal_1m",
"weeks": 91,
"avg_cross_section": 2443.2,
"mean_ic": 0.003,
"ic_t_stat": 0.31,
"ic_positive_pct": 48.4,
"mean_quintile_spread": -7.0153,
"reliable": true
},
"vol_6m": {
"signal": "vol_6m",
"weeks": 88,
"avg_cross_section": 2375.7,
"mean_ic": -0.1226,
"ic_t_stat": -6.34,
"ic_positive_pct": 21.6,
"mean_quintile_spread": 1.4646,
"reliable": true
}
},
"pre_2021": {
"high_52w": {
"signal": "high_52w",
"weeks": 36,
"avg_cross_section": 1422.2,
"mean_ic": 0.0494,
"ic_t_stat": 2.0,
"ic_positive_pct": 63.9,
"mean_quintile_spread": -31.5146,
"reliable": true
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 36,
"avg_cross_section": 1424.8,
"mean_ic": 0.0413,
"ic_t_stat": 2.94,
"ic_positive_pct": 66.7,
"mean_quintile_spread": -23.3935,
"reliable": true
},
"trend_200": {
"signal": "trend_200",
"weeks": 38,
"avg_cross_section": 1440.9,
"mean_ic": 0.0322,
"ic_t_stat": 2.23,
"ic_positive_pct": 68.4,
"mean_quintile_spread": -27.5982,
"reliable": true
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 36,
"avg_cross_section": 1424.8,
"mean_ic": 0.0226,
"ic_t_stat": 1.68,
"ic_positive_pct": 69.4,
"mean_quintile_spread": 23.4097,
"reliable": true
},
"mom_3_1": {
"signal": "mom_3_1",
"weeks": 42,
"avg_cross_section": 1480.0,
"mean_ic": 0.0217,
"ic_t_stat": 1.84,
"ic_positive_pct": 73.8,
"mean_quintile_spread": 2.0077,
"reliable": true
},
"mom_6_1": {
"signal": "mom_6_1",
"weeks": 40,
"avg_cross_section": 1461.2,
"mean_ic": 0.0205,
"ic_t_stat": 1.63,
"ic_positive_pct": 65.0,
"mean_quintile_spread": 7.4512,
"reliable": true
},
"fip_id": {
"signal": "fip_id",
"weeks": 36,
"avg_cross_section": 1424.8,
"mean_ic": 0.0116,
"ic_t_stat": 1.35,
"ic_positive_pct": 58.3,
"mean_quintile_spread": -0.0118,
"reliable": true
},
"reversal_1m": {
"signal": "reversal_1m",
"weeks": 44,
"avg_cross_section": 1501.4,
"mean_ic": 0.0019,
"ic_t_stat": 0.15,
"ic_positive_pct": 50.0,
"mean_quintile_spread": -14.4299,
"reliable": true
},
"vol_6m": {
"signal": "vol_6m",
"weeks": 40,
"avg_cross_section": 1461.2,
"mean_ic": -0.056,
"ic_t_stat": -2.16,
"ic_positive_pct": 35.0,
"mean_quintile_spread": 3.1335,
"reliable": true
}
},
"post_2021": {
"high_52w": {
"signal": "high_52w",
"weeks": 48,
"avg_cross_section": 2914.5,
"mean_ic": 0.1375,
"ic_t_stat": 4.34,
"ic_positive_pct": 79.2,
"mean_quintile_spread": -0.0856,
"reliable": true
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 48,
"avg_cross_section": 2913.0,
"mean_ic": 0.0791,
"ic_t_stat": 3.64,
"ic_positive_pct": 77.1,
"mean_quintile_spread": -0.0858,
"reliable": true
},
"mom_6_1": {
"signal": "mom_6_1",
"weeks": 48,
"avg_cross_section": 3122.6,
"mean_ic": 0.0779,
"ic_t_stat": 4.36,
"ic_positive_pct": 75.0,
"mean_quintile_spread": -0.0781,
"reliable": true
},
"trend_200": {
"signal": "trend_200",
"weeks": 48,
"avg_cross_section": 3001.5,
"mean_ic": 0.0585,
"ic_t_stat": 2.68,
"ic_positive_pct": 72.9,
"mean_quintile_spread": -0.1129,
"reliable": true
},
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0245,
"reliable": true
},
"fip_id": {
"signal": "fip_id",
"weeks": 48,
"avg_cross_section": 2913.0,
"mean_ic": 0.0366,
"ic_t_stat": 2.91,
"ic_positive_pct": 70.8,
"mean_quintile_spread": -0.0151,
"reliable": true
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 35,
"avg_cross_section": 496.7,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"ic_positive_pct": 62.9,
"mean_quintile_spread": 0.0154,
"reliable": true
},
"mom_3_1": {
"signal": "mom_3_1",
"weeks": 48,
"avg_cross_section": 3234.2,
"mean_ic": 0.0291,
"ic_t_stat": 1.54,
"ic_positive_pct": 66.7,
"mean_quintile_spread": -0.0356,
"reliable": true
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 48,
"avg_cross_section": 2913.0,
"mean_ic": 0.0265,
"ic_t_stat": 1.37,
"ic_positive_pct": 66.7,
"mean_quintile_spread": -0.0405,
"reliable": true
},
"reversal_1m": {
"signal": "reversal_1m",
"weeks": 48,
"avg_cross_section": 3315.8,
"mean_ic": -0.0126,
"ic_t_stat": -0.72,
"ic_positive_pct": 45.8,
"mean_quintile_spread": -0.0387,
"reliable": true
},
"vol_6m": {
"signal": "vol_6m",
"weeks": 48,
"avg_cross_section": 3122.6,
"mean_ic": -0.1623,
"ic_t_stat": -4.94,
"ic_positive_pct": 22.9,
"mean_quintile_spread": 0.0059,
"reliable": true
}
}
},
"params": {
"step_days": 5,
"step_sessions": 5,
"entry_cadence": "weekly",
"signal_eval_cadence": "weekly",
"horizon_days": 30,
"min_lookback": 60,
"cost_per_side_pct": 0.1,
"target_model": "production_gtl",
"target_model_label": "Live GTL (production)",
"is_production_target_model": true,
"production_reentry_policy": "gate_reset",
"liquid_breadth_top_n": null,
"liquid_min_price": null,
"signal_eval_only": true
},
"tickers": 4655,
"generated_at_run": "2026-07-19T08:27:27.206022+00:00"
},
"verdict": "PENDING_HUMAN",
"verdict_detail": "Harness complete \u2014 human interprets relative IC / era stability. No production retune from this artifact.",
"human_next": "- Compare sector residual vs market residual across eras.\n- If pre-2021 IC collapses, park Task 1 wire-in.\n- Do not retune production knobs on deep history levels.",
"report_path": "reports/history-depth-20260719-103315.json"
}
+182
View File
@@ -0,0 +1,182 @@
# History-depth extension (Tier-1 alpha research)
**Status:** **RUN COMPLETE — human interpretation below.**
**Branch:** `research/earnings-gap-and-sue` (MacBook commit `f6e0ca7`)
**Authoritative artifact:** `reports/history-depth-20260719-103315.json`
**Production impact:** none. **Do not retune any production knob on deep history.**
---
## Pre-registration (locked before rebuild)
### Motivation
All current conclusions rest on ~35 non-overlapping weekly windows in essentially
one post-2021 regime. Extending history toward max Alpaca daily-bar depth adds
the 2018 vol shock and full 2020 crash (where the feed allows).
### Protocol
1. **Empirical coverage first** — bars per calendar year per symbol; document
where the feed thins out. Do **not** assume a uniform start date.
2. **Rebuild the research snapshot completely** from prod source + max history
per symbol (`Adjustment.SPLIT`, ~200 req/min pacing via existing extender).
3. **Race guard (rule 6)** — refuse analysis until completion manifest is
`complete=true` and live counts match.
4. **Re-run full signal harness** (all existing signals incl. sector residual /
SUE if present) on the extended window.
5. **Report per signal:** mean IC, t, window count, and **era split**
(pre-/post-2021) — diagnostic only, **not a tuning input**.
6. **Log prominently:** survivorship bias grows with depth (todays constituents
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
**relative** signal comparisons and IC stability, not levels.
7. **Do not retune** production knobs. If a knobs confirmation looks
overturned on deep history → report only; human decides.
### Success / interpretation (not promotion of a new signal)
| outcome | meaning |
|---|---|
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
| Any production knob looks worse deep | report; no auto-retune |
---
## Data provenance
| check | result |
|---|---|
| Snapshot | MacBook `research.sqlite` |
| Manifest `complete` | **true** (finished 2026-07-19T08:19Z) |
| Live counts match | yes — 4655 tickers / 6,609,926 OHLCV / 4149 rank_only |
| `history_days` | 5000 |
| fetch_ok / fail | 4152 / 0 |
| Race guard | **pass** |
> **SURVIVORSHIP BIAS:** todays constituents backfilled historically. Absolute
> Sharpe/CAGR levels on deep history are optimistic. Use **relative** signal IC
> comparisons and era stability only — not levels.
**Coverage JSON was empty in the auto-written doc** (harness-only phase after
rebuild). Manifest is the race-guard source of truth for this run.
Earlier MacBook files `history-depth-20260719-093853``095156` are intermediate
/ incomplete passes — **do not cite**. Only **103315** is authoritative.
---
## Results (authoritative: 103315)
### Full-window signal IC (broad research universe, deep bars)
| signal | mean_ic | t | weeks | avg_N | notes |
|---|---:|---:|---:|---:|---|
| high_52w | **0.111** | **6.41** | 84 | 2280 | strong on deep breadth |
| mom_12_1 | **0.066** | **5.11** | 83 | 2278 | raw momentum strong |
| trend_200 | 0.055 | 4.30 | 85 | 2308 | |
| mom_6_1 | 0.049 | 4.91 | 88 | 2376 | |
| mom_3_1 | 0.036 | 3.58 | 90 | 2426 | |
| fip_id | **+0.027** | **3.25** | 83 | 2278 | **sign flip vs prod fingerprint** |
| mom_12_1_resid | 0.026 | 2.21 | 83 | 2278 | market residual still + but weaker than raw |
| reversal_1m | ~0 | 0.31 | 91 | 2443 | dead |
| vol_6m | **0.123** | **6.34** | 88 | 2376 | low-vol anomaly strong |
| mom_12_1_sector_resid | 0.058 | 2.34 | **35** | **498** | **not deep-sample — see caveats** |
| mom_12_1_sector_demeaned | 0.034 | 1.32 | **35** | **497** | same short fingerprint |
### Era split (diagnostic only — not a tuning input)
| signal | pre-2021 IC / t / w / N | post-2021 IC / t / w / N |
|---|---|---|
| mom_12_1 | +0.041 / 2.94 / 36 / 1425 | +0.079 / 3.64 / 48 / 2913 |
| mom_12_1_resid | +0.023 / 1.68 / 36 / 1425 | +0.027 / 1.37 / 48 / 2913 |
| fip_id | +0.012 / 1.35 / 36 / 1425 | +0.037 / 2.91 / 48 / 2913 |
| vol_6m | 0.056 / 2.16 / 40 / 1461 | 0.162 / 4.94 / 48 / 3123 |
| high_52w | +0.049 / 2.0 / 36 / 1422 | +0.138 / 4.34 / 48 / 2915 |
| **sector_resid** | **absent** | 0.058 / 2.34 / 35 / 498 (short only) |
| **sector_demeaned** | **absent** | 0.034 / 1.32 / 35 / 497 (short only) |
---
## Critical caveats (must read)
### 1. Sector residual did **not** get a deep-history stress test
`mom_12_1_sector_resid` / `_demeaned` still show **exactly** the Task1 short-window
fingerprint: **35 weeks, N≈498, IC 0.0578, t 2.34**.
On the same run, raw `mom_12_1` has **83 weeks, N≈2278**. So depth worked for
price-only signals, but sector residual is still limited to the **~505 labeled
prod names × short factor calendar** (sector map only covers prod; and/or sector
ETF / two-factor path did not extend usable residual weeks).
**Pre-registered rule:** “Sector residual collapses pre-2021 → PARK Task 1
wire-in.” Pre-2021 sector residual is **absent** from the era table. That is a
**PARK**, not a confirmation of the short-window PROMOTE.
Do **not** claim “sector residual beats market residual on deep history” from
this table — the two rows are **not the same cross-section or window count**.
### 2. `fip_id` sign flips vs production fingerprint
| sample | fip mean IC | t |
|---|---:|---:|
| Prod 505, ~5y (fingerprint) | **0.045** | 2.91 |
| Research breadth, deep (this run) | **+0.027** | +3.25 |
This does **not** authorize resurrecting unconditional FIP as a book filter. It
confirms earlier PhaseB caution: FIP edge is **universe- and sample-dependent**.
Production display card can stay context-only. Nested lookbacks still not OOS.
### 3. Market residual vs raw momentum on deep breadth
On deep broad IC, **raw 121 (0.066 / t 5.1) ≫ market residual (0.026 / t 2.2)**.
That does **not** by itself overturn production residual ranking (book A/B was
on 505 + GTL gate, not pure factor IC), but it is a yellow flag for “residual is
always the better rank key” stories on broad history. **No auto-retune.**
### 4. Low-vol anomaly is the cleanest deep-history result
`vol_6m` IC 0.12 / t 6.3 full; stronger post-2021. Consistent sign across eras.
Production already blends **high**-vol (not low-vol) into the 80/20 rank — this
report does not change that without a separate A/B. Flag for human awareness only.
---
## Verdicts (vs pre-registration)
| question | verdict |
|---|---|
| Task 1 sector residual wire-in | **PARK** — no pre-2021 sector residual; deep-sample IC not established; short-window PROMOTE stays “human design only,” **not strengthened** by this run |
| Sector demean | still **DEAD** for promotion (t 1.32, short only) |
| SUE | **not re-scored here** (no `sue_latest` in harness table) — leave Task 2 **PARK** until full earnings backfill |
| fip unconditional book filter | remains **rejected / parked** despite sign flip on broad deep sample |
| Production residual / 80/20 / trail knobs | **no retune** from this report |
| Overall Task 3 | **COMPLETE as diagnostic** — payload is relative IC + caveats above |
---
## What a human must decide next
1. **Sector residual:** keep research-only until either
(a) sector ETF + sector map cover the full deep window **and** IC is re-run
with weeks ≫ 35 on a documented universe, or
(b) explicitly accept short-window-only evidence (weaker case).
2. **Do not** merge sector residual into production from this depth run.
3. **Do not** retune residual vs raw, FIP, or vol blend from these IC tables
without a pre-registered book A/B on the intended universe.
4. Optional follow-up: extend sector ETF history + sector labels to nasdaq_all,
re-run **only** sector residual IC on deep research.sqlite with race guard.
5. Optional: finish earnings backfill (48→506) and re-run SUE; depth alone did
not include SUE.
---
## Artifacts
| file | role |
|---|---|
| `reports/history-depth-20260719-103315.json` | **authoritative** |
| `reports/history-depth-20260719-103315.md` | companion dump |
| `reports/history-depth-20260719-093853``095156` | **ignore** (partial) |
@@ -0,0 +1,137 @@
{
"generated_at": "2026-07-19T14:07:37.458454",
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/research.sqlite",
"snapshot_meta": {
"prod_universe_n": 506,
"price_symbols_n": 4654,
"raw_candidates": 2389258,
"liquid_top_n": 1500,
"liquid_min_price": 5.0,
"short_start": "2022-07-01",
"long_start": "2016-07-01"
},
"strategy": {
"note": "Live production knobs \u2014 no modifications",
"momentum": "residual_12_1 gate 80",
"rank": "residual_high_vol_blend_80_20",
"fill_mode": "close",
"cost_per_side": 0.001,
"exit": {
"mode": "atr_trailing",
"trailing_pct": 12.0,
"atr_multiplier": 3.0,
"hold_days": 30
},
"max_positions": 10,
"risk_per_trade": 0.01,
"reentry": "gate_reset"
},
"arms": [
{
"id": "A_prod_4y_505",
"label": "Prod book \u00b7 ~4y \u00b7 505 only",
"start": "2022-07-01",
"universe": "prod_505",
"n_candidates": 81626,
"n_qualified_longs": 1448,
"fill_mode": "close",
"ranking_key": "residual_high_vol_blend_80_20_score",
"exit_policy": "atr_trail3",
"hold_days": 30,
"sharpe": 1.32,
"sharpe_se": 0.49,
"cagr_pct": 31.8,
"max_drawdown_pct": 18.9,
"total_return_pct": 204.7,
"calmar": 1.68,
"trades": 374,
"win_rate": 35.6,
"n_returns": 1009,
"psr": 0.9965,
"start_date": "2022-07-01",
"end_date": "2026-07-13",
"spy_return_pct": 96.5,
"final_equity": 30467.86
},
{
"id": "B_prod_4y_505_liquid",
"label": "Prod book \u00b7 ~4y \u00b7 505 + liquid top-1500",
"start": "2022-07-01",
"universe": "prod_plus_liquid",
"n_candidates": 267579,
"n_qualified_longs": 6587,
"fill_mode": "close",
"ranking_key": "residual_high_vol_blend_80_20_score",
"exit_policy": "atr_trail3",
"hold_days": 30,
"sharpe": 0.14,
"sharpe_se": 0.499,
"cagr_pct": -1.8,
"max_drawdown_pct": 55.3,
"total_return_pct": -7.1,
"calmar": -0.03,
"trades": 706,
"win_rate": 29.3,
"n_returns": 1013,
"psr": 0.6107,
"start_date": "2022-07-01",
"end_date": "2026-07-17",
"spy_return_pct": 95.0,
"final_equity": 9287.52
},
{
"id": "C_prod_2016_505",
"label": "Prod book \u00b7 since 2016-07 \u00b7 505 only",
"start": "2016-07-01",
"universe": "prod_505",
"n_candidates": 190179,
"n_qualified_longs": 2450,
"fill_mode": "close",
"ranking_key": "residual_high_vol_blend_80_20_score",
"exit_policy": "atr_trail3",
"hold_days": 30,
"sharpe": 0.88,
"sharpe_se": 0.314,
"cagr_pct": 16.7,
"max_drawdown_pct": 24.4,
"total_return_pct": 369.4,
"calmar": 0.68,
"trades": 763,
"win_rate": 36.7,
"n_returns": 2519,
"psr": 0.9975,
"start_date": "2016-07-01",
"end_date": "2026-07-13",
"spy_return_pct": 256.9,
"final_equity": 46938.66
},
{
"id": "D_prod_2016_505_liquid",
"label": "Prod book \u00b7 since 2016-07 \u00b7 505 + liquid top-1500",
"start": "2016-07-01",
"universe": "prod_plus_liquid",
"n_candidates": 649305,
"n_qualified_longs": 11551,
"fill_mode": "close",
"ranking_key": "residual_high_vol_blend_80_20_score",
"exit_policy": "atr_trail3",
"hold_days": 30,
"sharpe": -0.06,
"sharpe_se": 0.316,
"cagr_pct": -7.0,
"max_drawdown_pct": 73.9,
"total_return_pct": -52.0,
"calmar": -0.1,
"trades": 1567,
"win_rate": 28.0,
"n_returns": 2523,
"psr": 0.4287,
"start_date": "2016-07-01",
"end_date": "2026-07-17",
"spy_return_pct": 254.1,
"final_equity": 4800.5
}
],
"survivorship_banner": "Today's constituents backfilled. Relative arm comparison only.",
"pending_human": true
}
@@ -0,0 +1,54 @@
# Production book × universe × horizon — results
Generated: `2026-07-19T14:07:37.458454`
> Survivorship: today's constituents backfilled. Compare arms relatively; do not treat deep CAGR/Sharpe levels as deployable forecasts.
## Arms
| arm | universe | entry start | Sharpe | SE | CAGR % | max DD % | ret % | trades | qual longs | span |
|---|---|---|---:|---:|---:|---:|---:|---:|---:|---|
| A_prod_4y_505 | prod_505 | 2022-07-01 | 1.32 | 0.49 | 31.8 | 18.9 | 204.7 | 374 | 1448 | 2022-07-01→2026-07-13 |
| B_prod_4y_505_liquid | prod_plus_liquid | 2022-07-01 | 0.14 | 0.499 | -1.8 | 55.3 | -7.1 | 706 | 6587 | 2022-07-01→2026-07-17 |
| C_prod_2016_505 | prod_505 | 2016-07-01 | 0.88 | 0.314 | 16.7 | 24.4 | 369.4 | 763 | 2450 | 2016-07-01→2026-07-13 |
| D_prod_2016_505_liquid | prod_plus_liquid | 2016-07-01 | -0.06 | 0.316 | -7.0 | 73.9 | -52.0 | 1567 | 11551 | 2016-07-01→2026-07-17 |
## Config (production, unchanged)
```json
{
"note": "Live production knobs \u2014 no modifications",
"momentum": "residual_12_1 gate 80",
"rank": "residual_high_vol_blend_80_20",
"fill_mode": "close",
"cost_per_side": 0.001,
"exit": {
"mode": "atr_trailing",
"trailing_pct": 12.0,
"atr_multiplier": 3.0,
"hold_days": 30
},
"max_positions": 10,
"risk_per_trade": 0.01,
"reentry": "gate_reset"
}
```
## Snapshot
```json
{
"prod_universe_n": 506,
"price_symbols_n": 4654,
"raw_candidates": 2389258,
"liquid_top_n": 1500,
"liquid_min_price": 5.0,
"short_start": "2022-07-01",
"long_start": "2016-07-01"
}
```
PENDING_HUMAN — descriptive matrix only; no auto promotion.
JSON: `reports/prod-book-universe-horizon-20260719-140737.json`
@@ -0,0 +1,990 @@
{
"generated_at": "2026-07-19T11:33:19.102779",
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/research.sqlite",
"pre_registration": {
"iron_ic": 0.03,
"min_weeks_deep": 50,
"liquid_breadth": 1500,
"min_price": 5.0,
"rule": "PASS = |IC|>=0.03, +sign, reliable, weeks>=50, t>=resid on same CS, era signs both +"
},
"step1": {
"skipped": true,
"sanity": {
"passed": true,
"megacap": {
"AAPL": {
"symbol": "AAPL",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"MSFT": {
"symbol": "MSFT",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"JPM": {
"symbol": "JPM",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"XOM": {
"symbol": "XOM",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"JNJ": {
"symbol": "JNJ",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
}
},
"megacap_ok": true,
"megacap_reasons": [],
"feed_floor": "2016-01-04",
"spy_benchmark": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"old_shallow_floor": "2020-01-01",
"sector_etfs": {
"XLB": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLC": {
"n": 2030,
"min": "2018-06-19",
"max": "2026-07-17"
},
"XLE": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLF": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLI": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLK": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLP": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLRE": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLU": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLV": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLY": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
}
},
"sector_etfs_deep_count": 11,
"sector_etfs_ok": true,
"sector_etf_reasons": [],
"still_shallow_count": 2882,
"still_shallow_sample": [
"AACB",
"AACBR",
"AACBU",
"AACI",
"AACIU",
"AACIW",
"AACO",
"AACOU",
"AACOW",
"AACP",
"AACPR",
"AACPU",
"AACPW",
"AAPG",
"AARD",
"ABAT",
"ABCL",
"ABLV",
"ABLVW",
"ABNB"
],
"still_shallow_note": "Remaining 'shallow' names are mostly post-2017 IPOs/listings \u2014 expected, not a two-tier defect.",
"xlc_note": "XLC lists mid-2018 \u2192 Communication Services residual coverage from ~mid-2019.",
"feed_note": "Empirical Alpaca floor observed via SPY: 2016-01-04 (n=2649). Calendar history_days=5000 is a request cap, not a guarantee \u2014 sanity grades against the feed floor, not 5000 calendar days.",
"target_history_days": 5000
}
},
"harness": {
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"survivorship_banner": "SURVIVORSHIP BIAS: today's constituents backfilled. Relative IC only \u2014 not levels.",
"signal_eval": [
{
"signal": "high_52w",
"weeks": 84,
"avg_cross_section": 1499.2,
"mean_ic": 0.0761,
"ic_t_stat": 4.46,
"ic_positive_pct": 71.4,
"mean_quintile_spread": 0.0118,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2564.4,
"avg_eligible_pre_mask": 2047.0,
"mask_binds_pct": 94.0
},
{
"signal": "trend_200",
"weeks": 85,
"avg_cross_section": 1499.6,
"mean_ic": 0.0371,
"ic_t_stat": 2.63,
"ic_positive_pct": 62.4,
"mean_quintile_spread": -0.7839,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2583.0,
"avg_eligible_pre_mask": 2065.8,
"mask_binds_pct": 96.5
},
{
"signal": "mom_12_1",
"weeks": 83,
"avg_cross_section": 1499.4,
"mean_ic": 0.0355,
"ic_t_stat": 2.41,
"ic_positive_pct": 62.7,
"mean_quintile_spread": 0.0141,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2553.0,
"avg_eligible_pre_mask": 2045.4,
"mask_binds_pct": 95.2
},
{
"signal": "mom_12_1_sector_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0268,
"ic_t_stat": 1.69,
"ic_positive_pct": 60.2,
"mean_quintile_spread": 0.0126,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
{
"signal": "mom_3_1",
"weeks": 90,
"avg_cross_section": 1498.6,
"mean_ic": 0.0256,
"ic_t_stat": 2.24,
"ic_positive_pct": 61.1,
"mean_quintile_spread": -0.0048,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2679.7,
"avg_eligible_pre_mask": 2128.6,
"mask_binds_pct": 95.6
},
{
"signal": "reversal_1m",
"weeks": 89,
"avg_cross_section": 1499.0,
"mean_ic": 0.0156,
"ic_t_stat": 1.37,
"ic_positive_pct": 59.6,
"mean_quintile_spread": -0.7882,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2700.0,
"avg_eligible_pre_mask": 2085.8,
"mask_binds_pct": 94.5
},
{
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 1499.4,
"mean_ic": 0.0148,
"ic_t_stat": 1.02,
"ic_positive_pct": 57.8,
"mean_quintile_spread": 0.0181,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2553.0,
"avg_eligible_pre_mask": 2045.4,
"mask_binds_pct": 95.2
},
{
"signal": "mom_6_1",
"weeks": 88,
"avg_cross_section": 1499.3,
"mean_ic": 0.0101,
"ic_t_stat": 0.91,
"ic_positive_pct": 58.0,
"mean_quintile_spread": 0.0108,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2641.3,
"avg_eligible_pre_mask": 2098.8,
"mask_binds_pct": 95.5
},
{
"signal": "mom_12_1_sector_demeaned",
"weeks": 83,
"avg_cross_section": 483.2,
"mean_ic": 0.0076,
"ic_t_stat": 0.46,
"ic_positive_pct": 55.4,
"mean_quintile_spread": 0.0054,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 484.4,
"avg_eligible_pre_mask": 483.2,
"mask_binds_pct": 0.0
},
{
"signal": "fip_id",
"weeks": 83,
"avg_cross_section": 1499.4,
"mean_ic": -0.0184,
"ic_t_stat": -2.11,
"ic_positive_pct": 45.8,
"mean_quintile_spread": -0.0059,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2553.0,
"avg_eligible_pre_mask": 2045.4,
"mask_binds_pct": 95.2
},
{
"signal": "vol_6m",
"weeks": 88,
"avg_cross_section": 1499.3,
"mean_ic": -0.0704,
"ic_t_stat": -3.14,
"ic_positive_pct": 35.2,
"mean_quintile_spread": 0.0051,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2641.3,
"avg_eligible_pre_mask": 2098.8,
"mask_binds_pct": 95.5
}
],
"signal_eval_by_name": {
"high_52w": {
"signal": "high_52w",
"weeks": 84,
"avg_cross_section": 1499.2,
"mean_ic": 0.0761,
"ic_t_stat": 4.46,
"ic_positive_pct": 71.4,
"mean_quintile_spread": 0.0118,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2564.4,
"avg_eligible_pre_mask": 2047.0,
"mask_binds_pct": 94.0
},
"trend_200": {
"signal": "trend_200",
"weeks": 85,
"avg_cross_section": 1499.6,
"mean_ic": 0.0371,
"ic_t_stat": 2.63,
"ic_positive_pct": 62.4,
"mean_quintile_spread": -0.7839,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2583.0,
"avg_eligible_pre_mask": 2065.8,
"mask_binds_pct": 96.5
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 83,
"avg_cross_section": 1499.4,
"mean_ic": 0.0355,
"ic_t_stat": 2.41,
"ic_positive_pct": 62.7,
"mean_quintile_spread": 0.0141,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2553.0,
"avg_eligible_pre_mask": 2045.4,
"mask_binds_pct": 95.2
},
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0268,
"ic_t_stat": 1.69,
"ic_positive_pct": 60.2,
"mean_quintile_spread": 0.0126,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_3_1": {
"signal": "mom_3_1",
"weeks": 90,
"avg_cross_section": 1498.6,
"mean_ic": 0.0256,
"ic_t_stat": 2.24,
"ic_positive_pct": 61.1,
"mean_quintile_spread": -0.0048,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2679.7,
"avg_eligible_pre_mask": 2128.6,
"mask_binds_pct": 95.6
},
"reversal_1m": {
"signal": "reversal_1m",
"weeks": 89,
"avg_cross_section": 1499.0,
"mean_ic": 0.0156,
"ic_t_stat": 1.37,
"ic_positive_pct": 59.6,
"mean_quintile_spread": -0.7882,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2700.0,
"avg_eligible_pre_mask": 2085.8,
"mask_binds_pct": 94.5
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 1499.4,
"mean_ic": 0.0148,
"ic_t_stat": 1.02,
"ic_positive_pct": 57.8,
"mean_quintile_spread": 0.0181,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2553.0,
"avg_eligible_pre_mask": 2045.4,
"mask_binds_pct": 95.2
},
"mom_6_1": {
"signal": "mom_6_1",
"weeks": 88,
"avg_cross_section": 1499.3,
"mean_ic": 0.0101,
"ic_t_stat": 0.91,
"ic_positive_pct": 58.0,
"mean_quintile_spread": 0.0108,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2641.3,
"avg_eligible_pre_mask": 2098.8,
"mask_binds_pct": 95.5
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 83,
"avg_cross_section": 483.2,
"mean_ic": 0.0076,
"ic_t_stat": 0.46,
"ic_positive_pct": 55.4,
"mean_quintile_spread": 0.0054,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 484.4,
"avg_eligible_pre_mask": 483.2,
"mask_binds_pct": 0.0
},
"fip_id": {
"signal": "fip_id",
"weeks": 83,
"avg_cross_section": 1499.4,
"mean_ic": -0.0184,
"ic_t_stat": -2.11,
"ic_positive_pct": 45.8,
"mean_quintile_spread": -0.0059,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2553.0,
"avg_eligible_pre_mask": 2045.4,
"mask_binds_pct": 95.2
},
"vol_6m": {
"signal": "vol_6m",
"weeks": 88,
"avg_cross_section": 1499.3,
"mean_ic": -0.0704,
"ic_t_stat": -3.14,
"ic_positive_pct": 35.2,
"mean_quintile_spread": 0.0051,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 2641.3,
"avg_eligible_pre_mask": 2098.8,
"mask_binds_pct": 95.5
}
},
"era_split": {
"era_split_date": "2021-01-01",
"note": "Diagnostic only \u2014 not a tuning input.",
"pre_2021": {
"high_52w": {
"signal": "high_52w",
"weeks": 36,
"avg_cross_section": 1498.1,
"mean_ic": 0.0535,
"ic_t_stat": 2.26,
"ic_positive_pct": 69.4,
"mean_quintile_spread": -0.0027,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1894.7,
"avg_eligible_pre_mask": 1670.6,
"mask_binds_pct": 86.1
},
"trend_200": {
"signal": "trend_200",
"weeks": 38,
"avg_cross_section": 1499.1,
"mean_ic": 0.0373,
"ic_t_stat": 1.99,
"ic_positive_pct": 65.8,
"mean_quintile_spread": -1.7748,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1913.9,
"avg_eligible_pre_mask": 1687.4,
"mask_binds_pct": 92.1
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 36,
"avg_cross_section": 1498.6,
"mean_ic": 0.037,
"ic_t_stat": 1.92,
"ic_positive_pct": 63.9,
"mean_quintile_spread": 0.0096,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1897.3,
"avg_eligible_pre_mask": 1673.1,
"mask_binds_pct": 88.9
},
"mom_3_1": {
"signal": "mom_3_1",
"weeks": 42,
"avg_cross_section": 1497.0,
"mean_ic": 0.0273,
"ic_t_stat": 2.03,
"ic_positive_pct": 64.3,
"mean_quintile_spread": -0.027,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1954.5,
"avg_eligible_pre_mask": 1717.3,
"mask_binds_pct": 90.5
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 36,
"avg_cross_section": 1498.6,
"mean_ic": 0.0216,
"ic_t_stat": 1.09,
"ic_positive_pct": 63.9,
"mean_quintile_spread": 0.0284,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1897.3,
"avg_eligible_pre_mask": 1673.1,
"mask_binds_pct": 88.9
},
"reversal_1m": {
"signal": "reversal_1m",
"weeks": 42,
"avg_cross_section": 1497.8,
"mean_ic": 0.0208,
"ic_t_stat": 1.45,
"ic_positive_pct": 64.3,
"mean_quintile_spread": -1.6753,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1976.6,
"avg_eligible_pre_mask": 1650.9,
"mask_binds_pct": 88.6
},
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 36,
"avg_cross_section": 460.7,
"mean_ic": 0.0149,
"ic_t_stat": 0.64,
"ic_positive_pct": 58.3,
"mean_quintile_spread": 0.0063,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 462.8,
"avg_eligible_pre_mask": 460.7,
"mask_binds_pct": 0.0
},
"mom_6_1": {
"signal": "mom_6_1",
"weeks": 40,
"avg_cross_section": 1498.5,
"mean_ic": 0.0082,
"ic_t_stat": 0.58,
"ic_positive_pct": 57.5,
"mean_quintile_spread": 0.0163,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1935.1,
"avg_eligible_pre_mask": 1702.2,
"mask_binds_pct": 90.0
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 36,
"avg_cross_section": 469.4,
"mean_ic": 0.0031,
"ic_t_stat": 0.13,
"ic_positive_pct": 55.6,
"mean_quintile_spread": 0.0009,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 471.5,
"avg_eligible_pre_mask": 469.4,
"mask_binds_pct": 0.0
},
"fip_id": {
"signal": "fip_id",
"weeks": 36,
"avg_cross_section": 1498.6,
"mean_ic": -0.0116,
"ic_t_stat": -0.93,
"ic_positive_pct": 52.8,
"mean_quintile_spread": -0.0037,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1897.3,
"avg_eligible_pre_mask": 1673.1,
"mask_binds_pct": 88.9
},
"vol_6m": {
"signal": "vol_6m",
"weeks": 40,
"avg_cross_section": 1498.5,
"mean_ic": -0.0219,
"ic_t_stat": -0.77,
"ic_positive_pct": 40.0,
"mean_quintile_spread": 0.0381,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 1935.1,
"avg_eligible_pre_mask": 1702.2,
"mask_binds_pct": 90.0
}
},
"post_2021": {
"high_52w": {
"signal": "high_52w",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": 0.0831,
"ic_t_stat": 2.36,
"ic_positive_pct": 68.8,
"mean_quintile_spread": 0.0102,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3046.7,
"avg_eligible_pre_mask": 2325.3,
"mask_binds_pct": 100.0
},
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 48,
"avg_cross_section": 494.5,
"mean_ic": 0.033,
"ic_t_stat": 1.39,
"ic_positive_pct": 60.4,
"mean_quintile_spread": 0.0158,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 495.1,
"avg_eligible_pre_mask": 494.5,
"mask_binds_pct": 0.0
},
"mom_6_1": {
"signal": "mom_6_1",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": 0.0314,
"ic_t_stat": 1.51,
"ic_positive_pct": 58.3,
"mean_quintile_spread": 0.0109,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3214.4,
"avg_eligible_pre_mask": 2425.9,
"mask_binds_pct": 100.0
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": 0.0279,
"ic_t_stat": 1.14,
"ic_positive_pct": 62.5,
"mean_quintile_spread": 0.0206,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3045.2,
"avg_eligible_pre_mask": 2324.2,
"mask_binds_pct": 100.0
},
"trend_200": {
"signal": "trend_200",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": 0.0254,
"ic_t_stat": 1.03,
"ic_positive_pct": 60.4,
"mean_quintile_spread": 0.0064,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3113.5,
"avg_eligible_pre_mask": 2365.0,
"mask_binds_pct": 100.0
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 48,
"avg_cross_section": 493.5,
"mean_ic": 0.0071,
"ic_t_stat": 0.3,
"ic_positive_pct": 52.1,
"mean_quintile_spread": 0.0078,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 494.1,
"avg_eligible_pre_mask": 493.5,
"mask_binds_pct": 0.0
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": 0.0067,
"ic_t_stat": 0.29,
"ic_positive_pct": 54.2,
"mean_quintile_spread": 0.0149,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3045.2,
"avg_eligible_pre_mask": 2324.2,
"mask_binds_pct": 100.0
},
"mom_3_1": {
"signal": "mom_3_1",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": -0.0014,
"ic_t_stat": -0.06,
"ic_positive_pct": 52.1,
"mean_quintile_spread": -0.0028,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3305.8,
"avg_eligible_pre_mask": 2486.0,
"mask_binds_pct": 100.0
},
"reversal_1m": {
"signal": "reversal_1m",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": -0.013,
"ic_t_stat": -0.69,
"ic_positive_pct": 41.7,
"mean_quintile_spread": -0.0099,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3367.0,
"avg_eligible_pre_mask": 2486.9,
"mask_binds_pct": 100.0
},
"fip_id": {
"signal": "fip_id",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": -0.019,
"ic_t_stat": -1.56,
"ic_positive_pct": 41.7,
"mean_quintile_spread": -0.015,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3045.2,
"avg_eligible_pre_mask": 2324.2,
"mask_binds_pct": 100.0
},
"vol_6m": {
"signal": "vol_6m",
"weeks": 48,
"avg_cross_section": 1500.0,
"mean_ic": -0.0943,
"ic_t_stat": -2.41,
"ic_positive_pct": 31.2,
"mean_quintile_spread": -0.0031,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 3214.4,
"avg_eligible_pre_mask": 2425.9,
"mask_binds_pct": 100.0
}
}
},
"identical_subset_sector_cs": {
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0268,
"ic_t_stat": 1.69,
"ic_positive_pct": 60.2,
"mean_quintile_spread": 0.0126,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0251,
"ic_t_stat": 1.3,
"ic_positive_pct": 57.8,
"mean_quintile_spread": 0.011,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0192,
"ic_t_stat": 0.92,
"ic_positive_pct": 56.6,
"mean_quintile_spread": 0.0099,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 83,
"avg_cross_section": 479.0,
"mean_ic": 0.0063,
"ic_t_stat": 0.39,
"ic_positive_pct": 55.4,
"mean_quintile_spread": 0.0048,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 480.2,
"avg_eligible_pre_mask": 479.0,
"mask_binds_pct": 0.0
}
},
"mask_diagnostics": {
"reference_signal": "vol_6m",
"avg_cross_section": 1499.3,
"avg_raw_pool": 2641.3,
"avg_eligible_pre_mask": 2098.8,
"mask_binds_pct": 95.5,
"weeks": 88
},
"sector_map_size": 505,
"sector_etfs_loaded": [
"XLB",
"XLC",
"XLE",
"XLF",
"XLI",
"XLK",
"XLP",
"XLRE",
"XLU",
"XLV",
"XLY"
],
"spy_bars": 2649
},
"grade": {
"verdict": "FAIL",
"reason": "failed one or more pre-registered checks (see checks)",
"checks": {
"sector_row": {
"signal": "mom_12_1_sector_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0268,
"ic_t_stat": 1.69,
"ic_positive_pct": 60.2,
"mean_quintile_spread": 0.0126,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"resid_row_for_t": {
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0251,
"ic_t_stat": 1.3,
"ic_positive_pct": 57.8,
"mean_quintile_spread": 0.011,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"resid_t_source": "identical_subset",
"pre_2021": {
"signal": "mom_12_1_sector_resid",
"weeks": 36,
"avg_cross_section": 460.7,
"mean_ic": 0.0149,
"ic_t_stat": 0.64,
"ic_positive_pct": 58.3,
"mean_quintile_spread": 0.0063,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 462.8,
"avg_eligible_pre_mask": 460.7,
"mask_binds_pct": 0.0
},
"post_2021": {
"signal": "mom_12_1_sector_resid",
"weeks": 48,
"avg_cross_section": 494.5,
"mean_ic": 0.033,
"ic_t_stat": 1.39,
"ic_positive_pct": 60.4,
"mean_quintile_spread": 0.0158,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 495.1,
"avg_eligible_pre_mask": 494.5,
"mask_binds_pct": 0.0
},
"abs_mean_ic_ge_0_03": false,
"sign_positive": true,
"reliable": true,
"weeks_ge_50": true,
"weeks": 83,
"t_ge_resid_same_cs": true,
"sector_t": 1.69,
"resid_t": 1.3,
"era_both_present": true,
"era_sign_consistent_positive": true,
"pre_ic": 0.0149,
"post_ic": 0.033,
"avg_cross_section": 480.0
},
"headline": "Task 1 CLOSED \u2014 sector residual dead on deep evidence."
},
"pending_human": true,
"note": "Nothing merged into production. Thread ends at PASS/FAIL."
}
@@ -0,0 +1,341 @@
# Sector-residual deep test (masked, repaired snapshot)
Generated: `2026-07-19T11:33:19.102779`
> **SURVIVORSHIP BIAS: today's constituents backfilled. Relative IC only — not levels.**
## Pre-registered grade (mechanical)
**Verdict: FAIL**
Task 1 CLOSED — sector residual dead on deep evidence.
Reason: failed one or more pre-registered checks (see checks)
```json
{
"sector_row": {
"signal": "mom_12_1_sector_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0268,
"ic_t_stat": 1.69,
"ic_positive_pct": 60.2,
"mean_quintile_spread": 0.0126,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"resid_row_for_t": {
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0251,
"ic_t_stat": 1.3,
"ic_positive_pct": 57.8,
"mean_quintile_spread": 0.011,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"resid_t_source": "identical_subset",
"pre_2021": {
"signal": "mom_12_1_sector_resid",
"weeks": 36,
"avg_cross_section": 460.7,
"mean_ic": 0.0149,
"ic_t_stat": 0.64,
"ic_positive_pct": 58.3,
"mean_quintile_spread": 0.0063,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 462.8,
"avg_eligible_pre_mask": 460.7,
"mask_binds_pct": 0.0
},
"post_2021": {
"signal": "mom_12_1_sector_resid",
"weeks": 48,
"avg_cross_section": 494.5,
"mean_ic": 0.033,
"ic_t_stat": 1.39,
"ic_positive_pct": 60.4,
"mean_quintile_spread": 0.0158,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 495.1,
"avg_eligible_pre_mask": 494.5,
"mask_binds_pct": 0.0
},
"abs_mean_ic_ge_0_03": false,
"sign_positive": true,
"reliable": true,
"weeks_ge_50": true,
"weeks": 83,
"t_ge_resid_same_cs": true,
"sector_t": 1.69,
"resid_t": 1.3,
"era_both_present": true,
"era_sign_consistent_positive": true,
"pre_ic": 0.0149,
"post_ic": 0.033,
"avg_cross_section": 480.0
}
```
## Step-1 sanity
```json
{
"skipped": true,
"sanity": {
"passed": true,
"megacap": {
"AAPL": {
"symbol": "AAPL",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"MSFT": {
"symbol": "MSFT",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"JPM": {
"symbol": "JPM",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"XOM": {
"symbol": "XOM",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
},
"JNJ": {
"symbol": "JNJ",
"bars": 2649,
"min_date": "2016-01-04",
"max_date": "2026-07-17"
}
},
"megacap_ok": true,
"megacap_reasons": [],
"feed_floor": "2016-01-04",
"spy_benchmark": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"old_shallow_floor": "2020-01-01",
"sector_etfs": {
"XLB": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLC": {
"n": 2030,
"min": "2018-06-19",
"max": "2026-07-17"
},
"XLE": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLF": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLI": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLK": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLP": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLRE": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLU": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLV": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
"XLY": {
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
}
},
"sector_etfs_deep_count": 11,
"sector_etfs_ok": true,
"sector_etf_reasons": [],
"still_shallow_count": 2882,
"still_shallow_sample": [
"AACB",
"AACBR",
"AACBU",
"AACI",
"AACIU",
"AACIW",
"AACO",
"AACOU",
"AACOW",
"AACP",
"AACPR",
"AACPU",
"AACPW",
"AAPG",
"AARD",
"ABAT",
"ABCL",
"ABLV",
"ABLVW",
"ABNB"
],
"still_shallow_note": "Remaining 'shallow' names are mostly post-2017 IPOs/listings \u2014 expected, not a two-tier defect.",
"xlc_note": "XLC lists mid-2018 \u2192 Communication Services residual coverage from ~mid-2019.",
"feed_note": "Empirical Alpaca floor observed via SPY: 2016-01-04 (n=2649). Calendar history_days=5000 is a request cap, not a guarantee \u2014 sanity grades against the feed floor, not 5000 calendar days.",
"target_history_days": 5000
}
}
```
## Mask diagnostics
```json
{
"reference_signal": "vol_6m",
"avg_cross_section": 1499.3,
"avg_raw_pool": 2641.3,
"avg_eligible_pre_mask": 2098.8,
"mask_binds_pct": 95.5,
"weeks": 88
}
```
## Signal table (rows only — no narrative for non-sector signals)
| signal | mean_ic | t | weeks | avg_N | reliable |
|---|---:|---:|---:|---:|---|
| fip_id | -0.0184 | -2.11 | 83 | 1499.4 | True |
| high_52w | 0.0761 | 4.46 | 84 | 1499.2 | True |
| mom_12_1 | 0.0355 | 2.41 | 83 | 1499.4 | True |
| mom_12_1_resid | 0.0148 | 1.02 | 83 | 1499.4 | True |
| mom_12_1_sector_demeaned | 0.0076 | 0.46 | 83 | 483.2 | True |
| mom_12_1_sector_resid | 0.0268 | 1.69 | 83 | 480.0 | True |
| mom_3_1 | 0.0256 | 2.24 | 90 | 1498.6 | True |
| mom_6_1 | 0.0101 | 0.91 | 88 | 1499.3 | True |
| reversal_1m | 0.0156 | 1.37 | 89 | 1499.0 | True |
| trend_200 | 0.0371 | 2.63 | 85 | 1499.6 | True |
| vol_6m | -0.0704 | -3.14 | 88 | 1499.3 | True |
### Era split — mom_12_1_sector_resid only (for grade)
| era | IC | t | weeks | N |
|---|---:|---:|---:|---:|
| pre_2021 | 0.0149 | 0.64 | 36 | 460.7 |
| post_2021 | 0.033 | 1.39 | 48 | 494.5 |
### Identical-subset baselines (sector CS)
```json
{
"mom_12_1_sector_resid": {
"signal": "mom_12_1_sector_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0268,
"ic_t_stat": 1.69,
"ic_positive_pct": 60.2,
"mean_quintile_spread": 0.0126,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_12_1_resid": {
"signal": "mom_12_1_resid",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0251,
"ic_t_stat": 1.3,
"ic_positive_pct": 57.8,
"mean_quintile_spread": 0.011,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_12_1": {
"signal": "mom_12_1",
"weeks": 83,
"avg_cross_section": 480.0,
"mean_ic": 0.0192,
"ic_t_stat": 0.92,
"ic_positive_pct": 56.6,
"mean_quintile_spread": 0.0099,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 481.2,
"avg_eligible_pre_mask": 480.0,
"mask_binds_pct": 0.0
},
"mom_12_1_sector_demeaned": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 83,
"avg_cross_section": 479.0,
"mean_ic": 0.0063,
"ic_t_stat": 0.39,
"ic_positive_pct": 55.4,
"mean_quintile_spread": 0.0048,
"reliable": true,
"liquid_breadth_top_n": 1500,
"liquid_min_price": 5.0,
"avg_raw_pool": 480.2,
"avg_eligible_pre_mask": 479.0,
"mask_binds_pct": 0.0
}
}
```
## Status
PENDING_HUMAN beyond the mechanical PASS/FAIL above. Nothing merged into production docs or prod code.
JSON: `reports/sector-resid-deep-20260719-113319.json`
@@ -0,0 +1,412 @@
{
"generated_at": "2026-07-19T08:33:56.651229",
"snapshot_guard": {
"snapshot": "C:\\Workspace\\signal-platform\\backtest_snapshots\\prod.sqlite",
"manifest": null,
"manifest_ok": null,
"note": "No completion manifest (prod.sqlite is expected without one). Bar-count sanity still applied.",
"ticker_count": 506,
"ohlcv_row_count": 629263,
"bars_min_avg_max": {
"min": 14,
"avg": 1246.1,
"max": 1261
},
"ohlcv_date_range": {
"min": "2021-06-24",
"max": "2026-07-02"
},
"benchmark_prices": [
{
"symbol": "SPY",
"n": 1516,
"min": "2020-07-06",
"max": "2026-07-17"
},
{
"symbol": "XLB",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLC",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLE",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLF",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLI",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLK",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLP",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLRE",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLU",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLV",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
},
{
"symbol": "XLY",
"n": 1512,
"min": "2020-07-10",
"max": "2026-07-17"
}
],
"missing_sector_etfs": []
},
"sector_coverage": {
"universe": 506,
"mapped": 505,
"mapped_pct": 99.8,
"with_etf": 505,
"missing": [
"RHM"
],
"by_sector": {
"Industrials": 81,
"Financials": 75,
"Information Technology": 72,
"Health Care": 58,
"Consumer Discretionary": 47,
"Consumer Staples": 34,
"Real Estate": 31,
"Utilities": 31,
"Materials": 26,
"Communication Services": 23,
"Energy": 22,
"Consumer Defensive": 2,
"Technology": 2,
"Financial Services": 1
}
},
"sector_map_path": "C:\\Workspace\\signal-platform\\data\\research\\ticker_sector_map.json",
"signal_eval": [
{
"signal": "vol_6m",
"weeks": 39,
"avg_cross_section": 498.2,
"mean_ic": 0.0609,
"ic_t_stat": 1.48,
"ic_positive_pct": 64.1,
"mean_quintile_spread": 0.0337,
"reliable": true
},
{
"signal": "mom_12_1_sector_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0245,
"reliable": true
},
{
"signal": "mom_12_1_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0552,
"ic_t_stat": 1.98,
"ic_positive_pct": 60.0,
"mean_quintile_spread": 0.0207,
"reliable": true
},
{
"signal": "mom_12_1",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0531,
"ic_t_stat": 1.61,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0206,
"reliable": true
},
{
"signal": "mom_12_1_sector_demeaned",
"weeks": 35,
"avg_cross_section": 496.7,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"ic_positive_pct": 62.9,
"mean_quintile_spread": 0.0154,
"reliable": true
},
{
"signal": "trend_200",
"weeks": 37,
"avg_cross_section": 497.9,
"mean_ic": 0.0161,
"ic_t_stat": 0.44,
"ic_positive_pct": 59.5,
"mean_quintile_spread": 0.006,
"reliable": true
},
{
"signal": "reversal_1m",
"weeks": 43,
"avg_cross_section": 498.7,
"mean_ic": 0.0059,
"ic_t_stat": 0.22,
"ic_positive_pct": 53.5,
"mean_quintile_spread": 0.0053,
"reliable": true
},
{
"signal": "mom_6_1",
"weeks": 39,
"avg_cross_section": 498.2,
"mean_ic": 0.0051,
"ic_t_stat": 0.21,
"ic_positive_pct": 56.4,
"mean_quintile_spread": 0.0087,
"reliable": true
},
{
"signal": "mom_3_1",
"weeks": 42,
"avg_cross_section": 498.5,
"mean_ic": -0.0064,
"ic_t_stat": -0.25,
"ic_positive_pct": 50.0,
"mean_quintile_spread": 0.0046,
"reliable": true
},
{
"signal": "high_52w",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": -0.0086,
"ic_t_stat": -0.26,
"ic_positive_pct": 54.3,
"mean_quintile_spread": -0.0088,
"reliable": true
},
{
"signal": "fip_id",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": -0.045,
"ic_t_stat": -2.91,
"ic_positive_pct": 25.7,
"mean_quintile_spread": -0.0168,
"reliable": true
}
],
"ic_grades": {
"mom_12_1_sector_resid": {
"promote_to_ab": true,
"checks": {
"sign_ok": true,
"abs_mean_ic_ge_0_03": true,
"reliable": true,
"t_ge_resid": true,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"resid_ic_t_stat": 1.98,
"weeks": 35
},
"reason": "clears iron rule and t \u2265 mom_12_1_resid \u2014 authorized for A/B only",
"row": {
"signal": "mom_12_1_sector_resid",
"weeks": 35,
"avg_cross_section": 497.7,
"mean_ic": 0.0578,
"ic_t_stat": 2.34,
"ic_positive_pct": 65.7,
"mean_quintile_spread": 0.0245,
"reliable": true
}
},
"mom_12_1_sector_demeaned": {
"promote_to_ab": false,
"checks": {
"sign_ok": true,
"abs_mean_ic_ge_0_03": true,
"reliable": true,
"t_ge_resid": false,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"resid_ic_t_stat": 1.98,
"weeks": 35
},
"reason": "does not clear pre-registered IC promotion bar",
"row": {
"signal": "mom_12_1_sector_demeaned",
"weeks": 35,
"avg_cross_section": 496.7,
"mean_ic": 0.034,
"ic_t_stat": 1.32,
"ic_positive_pct": 62.9,
"mean_quintile_spread": 0.0154,
"reliable": true
}
}
},
"portfolio_ab": {
"signal": "mom_12_1_sector_resid",
"ranking_key": "residual_high_vol_blend_80_20_score",
"fill_mode": "close",
"validation_split": "2024-07-01",
"control": {
"label": "control_mom_12_1_resid",
"n_qualified_longs": 1086,
"windows": {
"train": {
"sharpe": 1.3,
"sharpe_se": 0.685,
"cagr_pct": 29.2,
"max_drawdown_pct": 21.4,
"total_return_pct": 70.9,
"trades": 176,
"win_rate_pct": null,
"avg_r": null,
"n_returns": 525,
"return_skew": 0.3722,
"return_kurtosis": 4.6208,
"psr": 0.971
},
"validation": {
"sharpe": 2.92,
"sharpe_se": 0.709,
"cagr_pct": 76.3,
"max_drawdown_pct": 11.7,
"total_return_pct": 210.7,
"trades": 150,
"win_rate_pct": null,
"avg_r": null,
"n_returns": 501,
"return_skew": 0.1734,
"return_kurtosis": 4.4625,
"psr": 1.0
},
"full": {
"sharpe": 2.09,
"sharpe_se": 0.497,
"cagr_pct": 51.6,
"max_drawdown_pct": 21.4,
"total_return_pct": 424.6,
"trades": 322,
"win_rate_pct": null,
"avg_r": null,
"n_returns": 1000,
"return_skew": 0.2686,
"return_kurtosis": 4.5653,
"psr": 1.0
}
}
},
"treatment": {
"label": "treatment_mom_12_1_sector_resid",
"n_qualified_longs": 1210,
"windows": {
"train": {
"sharpe": 1.57,
"sharpe_se": 0.677,
"cagr_pct": 35.5,
"max_drawdown_pct": 19.8,
"total_return_pct": 90.0,
"trades": 176,
"win_rate_pct": null,
"avg_r": null,
"n_returns": 530,
"return_skew": 0.466,
"return_kurtosis": 4.4413,
"psr": 0.99
},
"validation": {
"sharpe": 2.57,
"sharpe_se": 0.701,
"cagr_pct": 66.3,
"max_drawdown_pct": 14.8,
"total_return_pct": 176.4,
"trades": 163,
"win_rate_pct": null,
"avg_r": null,
"n_returns": 501,
"return_skew": 0.3003,
"return_kurtosis": 4.4305,
"psr": 0.9999
},
"full": {
"sharpe": 2.09,
"sharpe_se": 0.491,
"cagr_pct": 51.0,
"max_drawdown_pct": 19.8,
"total_return_pct": 421.3,
"trades": 337,
"win_rate_pct": null,
"avg_r": null,
"n_returns": 1005,
"return_skew": 0.4066,
"return_kurtosis": 4.3627,
"psr": 1.0
}
}
},
"promotion": {
"promote": true,
"checks": {
"validation_sharpe_ge_control_minus_half_se": true,
"full_sharpe_not_worse": true,
"full_maxdd_not_worse": true,
"control_validation_sharpe": 2.92,
"treatment_validation_sharpe": 2.57,
"se_used": 0.701,
"control_full_sharpe": 2.09,
"treatment_full_sharpe": 2.09,
"control_full_maxdd": 21.4,
"treatment_full_maxdd": 19.8
},
"reason": "clears pre-registered A/B bar \u2014 human decides wire-in"
}
},
"verdict": "PROMOTE",
"verdict_detail": "mom_12_1_sector_resid cleared IC + A/B bars. Human must design wire-in; do not ship from this branch.",
"human_next": "- Approve or reject production residual swap vs dual-signal design.\n- If sector-cap arm ran, review tail-trim diagnostics before any cap.",
"report_path": "reports/sector-residual-20260719-083356.json",
"pre_registration": {
"iron_ic_bar": 0.03,
"validation_split": "2024-07-01",
"fill_mode": "close",
"cost_per_side": 0.001,
"ab_rule": "val Sharpe >= control - 0.5*SE; full Sharpe & maxDD not worse"
}
}
+237
View File
@@ -0,0 +1,237 @@
# Sector-residual momentum (Tier-1 alpha research)
**Status:** **PROMOTE (to human design decision only)** — IC + A/B bars cleared; **do not ship**.
**Branch:** `research/sector-residual-momentum`
**Production impact:** none. Local research only. No scheduler / gate / prod-config changes.
**Artifacts:** `reports/sector-residual-20260719-083356.json` (+ companion `.md`)
---
## Pre-registration (locked before first research run)
### Hypothesis
Residualizing 121 momentum against the sector, not only the market, reduces
factor volatility at similar return (Blitz / Huij / Martens-style) → higher
Sharpe on the production book when the residual replaces market-only residual
as the momentum leg.
### Signals (candidates)
| signal | construction |
|---|---|
| `mom_12_1_sector_resid` | Two-factor residual vs SPY + tickers sector ETF. Same window as `mom_12_1_resid`: ≥100 daily obs, 252-bar lookback, 21-bar skip; two-factor OLS betas **without intercept**; cumulate residual returns over the formation window. |
| `mom_12_1_sector_demeaned` | Plain `mom_12_1` minus the **cross-sectional** mean of `mom_12_1` within the same GICS sector that week (≥2 names in sector). No regression. |
### Baselines (same run, same cross-sections — iron rule)
Always report side-by-side with:
- `mom_12_1`
- `mom_12_1_resid`
Computed on the **identical** weekly non-overlapping cross-sections in this run.
Never compare against IC numbers from another report.
### Iron rule (IC harness)
Source of truth: `_signal_evaluation` in `app/services/backtest_service.py`.
- Mean weekly Spearman IC on **non-overlapping** weekly windows
- Bar: \|mean IC\| ≥ ~0.03, **consistent positive sign**, `reliable: true` (≥ 12 windows)
### Promotion to portfolio A/B (candidate → book)
A candidate promotes to A/B **only if**:
1. It clears the iron-rule bar **and**
2. Its IC **t-stat ≥** that of `mom_12_1_resid` on the same cross-sections.
### Portfolio A/B grading (if and only if IC promotion fires)
- Swap candidate in as the **momentum leg** of the production 80/20 momentum/vol
rank **and** as the gate-percentile signal.
- `fill_mode=close`, `COST_PER_SIDE = 0.001`, full config otherwise unchanged.
- Validation window = entries ≥ **2024-07-01** (call it **validation**, not
holdout — contaminated by prior experiments).
- Pre-registered promotion bar:
- validation Sharpe ≥ control 0.5·SE
- full-period Sharpe and max-DD **not worse** than control
- Report Lo / Mertens-adjusted SEs.
### Optional sector-cap sub-experiment
Only if labels are in **and** A/B ran: max **3** positions per sector in the
10-slot book. Same A/B grading. **Tail-trim presumption of guilt** (rule 4):
report entry counts and both tails of the R distribution. Rising win rate with
falling Sharpe/CAGR = red flag → do not promote.
**This run:** sector-cap arm **not executed** (optional; A/B unconstrained book
only). Can be a human-approved follow-up.
### Verdict labels
| label | meaning |
|---|---|
| **PROMOTE** | Clears pre-registered bar; human decides next (wire design separate) |
| **PARK** | Inconclusive / weak; keep machinery, no book change |
| **DEAD** | Failed iron rule or worse than residual baseline with clear sign |
### Explicit non-goals
- No production deploy from this doc
- Do not resurrect: take-profit exits, EV gate, regime entry-blocking,
inverse-vol sizing, gap-caps, unconditional FIP filter
---
## Data provenance
### Snapshot race guard
| check | result |
|---|---|
| Snapshot path | `backtest_snapshots/prod.sqlite` |
| Manifest | none (expected for prod snapshot); bar-count sanity applied |
| Tickers / OHLCV | **506** / **629,263** |
| Bars min / avg / max | 14 / 1246.1 / 1261 |
| OHLCV range | 2021-06-24 → 2026-07-02 |
| Partial-build red flags | none (avg bars healthy) |
Integrity fingerprint on same run: `fip_id` mean IC **0.045** / t **2.91**
(35 weeks, N≈498) — matches the established prod fingerprint.
### Sector labels
| source | count |
|---|---:|
| Public S&P 500 GICS CSV | 496 newly filled |
| FMP profile requests | 10 (all missing after CSV) |
| Mapped / universe | **505 / 506 (99.8%)** |
| With mappable ETF | 505 |
| Still missing | **RHM** only |
Persist path: `data/research/ticker_sector_map.json`.
FMP aliases (`Technology`, `Consumer Defensive`, `Financial Services`) map to
SPDRs via the alias table in `app/services/sector_map.py`.
### Sector ETFs in `benchmark_prices` (auxiliary only — not tradable)
| symbol | bars | min date | max date |
|---|---:|---|---|
| SPY | 1516 | 2020-07-06 | 2026-07-17 |
| XLB…XLY (11) | 1512 each | 2020-07-10 | 2026-07-17 |
Fetched via Alpaca `Adjustment.SPLIT` into **`benchmark_prices`** (same table as
SPY) so they never enter the ticker universe or candidate replay.
---
## Results
Generated: `2026-07-19T08:33:56`
### IC harness (identical cross-sections, production 506-name universe)
| signal | mean_ic | ic_t_stat | weeks | avg_N | reliable | ic+_pct | quintile spread |
|---|---:|---:|---:|---:|---|---:|---:|
| **mom_12_1_sector_resid** | **0.0578** | **2.34** | 35 | 497.7 | true | 65.7 | 0.0245 |
| mom_12_1_resid | 0.0552 | 1.98 | 35 | 497.7 | true | 60.0 | 0.0207 |
| mom_12_1 | 0.0531 | 1.61 | 35 | 497.7 | true | 65.7 | 0.0206 |
| mom_12_1_sector_demeaned | 0.0340 | 1.32 | 35 | 496.7 | true | 62.9 | 0.0154 |
### IC promotion grades
| candidate | iron rule | t ≥ resid | promote_to_ab |
|---|---|---|---|
| `mom_12_1_sector_resid` | pass (IC 0.058, +sign, reliable) | **yes** (2.34 ≥ 1.98) | **yes** |
| `mom_12_1_sector_demeaned` | pass (IC 0.034, +sign, reliable) | **no** (1.32 < 1.98) | **no** |
### Portfolio A/B — `mom_12_1_sector_resid` as residual leg
Config: production 80/20 residual/high-vol rank + gate percentile, `fill_mode=close`,
cost 10 bps/side, ATR trail / gate-reset re-entry as live. Validation split
2024-07-01.
| window | arm | Sharpe | Sharpe SE (Mertens) | CAGR % | max DD % | trades | n_days |
|---|---|---:|---:|---:|---:|---:|---:|
| train | control (resid) | 1.30 | 0.685 | 29.2 | 21.4 | 176 | 525 |
| train | treatment (sector resid) | **1.57** | 0.677 | **35.5** | **19.8** | 176 | 530 |
| validation | control | **2.92** | 0.709 | **76.3** | **11.7** | 150 | 501 |
| validation | treatment | 2.57 | 0.701 | 66.3 | 14.8 | 163 | 501 |
| full | control | 2.09 | 0.497 | 51.6 | 21.4 | 322 | 1000 |
| full | treatment | 2.09 | 0.491 | 51.0 | **19.8** | 337 | 1005 |
**Pre-registered A/B checks**
| check | result |
|---|---|
| val Sharpe ≥ control 0.5·SE | **pass** (2.57 ≥ 2.92 0.5×0.701 = 2.5695) — **knife-edge** |
| full Sharpe not worse | **pass** (2.09 = 2.09) |
| full max DD not worse | **pass** (19.8 < 21.4) |
Qualified long candidates: control 1086 vs treatment 1210 (sector residual
gates a slightly larger set).
---
## Verdict
| signal | verdict | note |
|---|---|---|
| **`mom_12_1_sector_resid`** | **PROMOTE → human wire-in decision** | IC modestly beats market residual; A/B clears pre-reg bar narrowly. **Do not ship from this branch.** |
| **`mom_12_1_sector_demeaned`** | **DEAD** (for promotion) | Iron-rule IC magnitude ok, but t-stat loses to `mom_12_1_resid`. Cheap variant not competitive. |
### Read carefully (for the human)
1. **IC edge is real but small.** Sector residual IC 0.0578 / t 2.34 vs market
residual 0.0552 / t 1.98 on the **same** 35 windows — better consistency
(ic+ 65.7% vs 60%) and slightly higher mean, not a different factor class.
2. **A/B is not a clear Sharpe win.** Full-period Sharpe is flat (2.09).
Validation Sharpe is **lower** than control (2.57 vs 2.92) and only clears
the pre-registered “within 0.5 SE” cushion by ~0.001. Train improves;
validation worsens — classic regime-split noise on ~2 years.
3. **Risk side is friendly.** Full max DD improves (19.8% vs 21.4%); train DD
also better. Matches the “lower factor vol” half of the hypothesis more than
the “higher Sharpe” half on this window.
4. **Survivorship / short history.** Same caveats as all current research:
todays constituents, ~35 independent weekly windows, one post-2021 regime
dominant. Task 3 (history depth) should re-check IC stability before any
wire-in.
5. **Not shipped.** Machinery lives on the research branch; production residual
path is untouched.
---
## What a human must decide next
1. **Accept or reject** replacing `mom_12_1_resid` with `mom_12_1_sector_resid`
as the production residual (gate + 80/20 mom leg), **or** keep market residual
and treat sector residual as research-only.
2. If leaning accept: require **Task 3 history-depth** confirmation (IC era split
pre/post-2021) before any production PR.
3. Optional: run **sector-cap ≤3** A/B with full tail diagnostics (not run here).
4. **Do not** merge this verdict into main strategy docs without review.
5. Wire-in design (live sector map refresh, ETF series ops, fallback when sector
missing) is a **separate** approved engineering step.
---
## Implementation notes (research machinery)
| piece | role |
|---|---|
| `app/services/sector_map.py` | GICS→ETF map, symbol normalise, JSON load/save |
| `app/services/backtest_service.py` | multi-factor residual; `mom_12_1_sector_resid` in `_signal_values`; demean inject |
| `scripts/build_ticker_sector_map.py` | SP500 CSV + FMP gap fill |
| `scripts/fetch_sector_etfs_to_snapshot.py` | Alpaca → snapshot `benchmark_prices` |
| `scripts/run_sector_residual_research.py` | race guard, IC, optional A/B, reports |
| `data/research/ticker_sector_map.json` | persisted labels (research only) |
---
## Artifacts
- JSON: `reports/sector-residual-20260719-083356.json`
- MD copy: `reports/sector-residual-20260719-083356.md`
+532
View File
@@ -0,0 +1,532 @@
"""Backfill historical earnings into a snapshot ``earnings_events`` table.
Prefers FMP bulk date-range ``earnings-calendar`` (one request per window).
On free-tier 402/403, falls back to per-symbol ``/stable/earnings`` with
resume support and request counting (250 req/day free tier).
Research only writes to the local snapshot SQLite, never production Postgres.
Example
-------
python scripts/backfill_earnings_events.py \\
--snapshot backtest_snapshots/prod.sqlite --limit 250
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
import time
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
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"
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)
)
"""
# Side table tracks which symbols have been fully pulled (resume).
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
)
"""
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--snapshot", default="backtest_snapshots/prod.sqlite")
p.add_argument(
"--from-date",
default="2020-01-01",
help="Bulk calendar window start (also filters per-symbol rows).",
)
p.add_argument(
"--to-date",
default=None,
help="Bulk calendar window end (default: today).",
)
p.add_argument(
"--limit",
type=int,
default=250,
help="Max FMP requests this run (free-tier cushion).",
)
p.add_argument("--sleep", type=float, default=0.35)
p.add_argument(
"--force-symbol",
action="store_true",
help="Skip bulk attempt; go straight to per-symbol.",
)
p.add_argument(
"--refetch-done",
action="store_true",
help="Re-fetch symbols already marked done.",
)
p.add_argument(
"--provider",
choices=("fmp", "alpha_vantage", "auto"),
default="auto",
help="Earnings provider. auto tries FMP bulk then FMP/AV per-symbol.",
)
return p.parse_args()
def _ensure_tables(engine) -> None:
with engine.begin() as conn:
conn.execute(text(DDL))
conn.execute(text(META_DDL))
def _upsert_events(conn, rows: list[dict], source: str) -> int:
if not rows:
return 0
now = datetime.now(timezone.utc).isoformat()
written = 0
for r in rows:
conn.execute(
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,
:source, :fetched_at
)
ON CONFLICT(symbol, announce_date) DO UPDATE SET
announce_time=excluded.announce_time,
eps_estimate=excluded.eps_estimate,
eps_actual=excluded.eps_actual,
revenue_estimate=excluded.revenue_estimate,
revenue_actual=excluded.revenue_actual,
source=excluded.source,
fetched_at=excluded.fetched_at
"""
),
{
"symbol": r["symbol"],
"announce_date": r["announce_date"],
"announce_time": r.get("announce_time"),
"eps_estimate": r.get("eps_estimate"),
"eps_actual": r.get("eps_actual"),
"revenue_estimate": r.get("revenue_estimate"),
"revenue_actual": r.get("revenue_actual"),
"source": source,
"fetched_at": now,
},
)
written += 1
return written
def _parse_bulk_item(item: dict) -> dict | None:
sym = (item.get("symbol") or "").strip().upper()
d = item.get("date") or item.get("earningsDate")
if not sym or not d:
return None
return {
"symbol": sym.replace(".", "-"),
"announce_date": str(d)[:10],
"announce_time": item.get("time") or item.get("announceTime"),
"eps_estimate": _f(item.get("epsEstimated") or item.get("estimatedEarning")),
"eps_actual": _f(item.get("epsActual") or item.get("eps")),
"revenue_estimate": _f(item.get("revenueEstimated")),
"revenue_actual": _f(item.get("revenueActual")),
}
def _parse_symbol_item(item: dict, symbol: str) -> dict | None:
d = item.get("date")
if not d:
return None
return {
"symbol": symbol.replace(".", "-").upper(),
"announce_date": str(d)[:10],
"announce_time": item.get("time"),
"eps_estimate": _f(item.get("epsEstimated")),
"eps_actual": _f(item.get("epsActual")),
"revenue_estimate": _f(item.get("revenueEstimated")),
"revenue_actual": _f(item.get("revenueActual")),
}
def _f(v) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
async def _try_bulk(
client: httpx.AsyncClient,
api_key: str,
start: date,
end: date,
*,
window_days: int = 30,
) -> tuple[list[dict], int, str | None]:
"""Return (rows, requests_used, error_note)."""
rows: list[dict] = []
reqs = 0
cur = start
while cur <= end:
win_end = min(end, cur + timedelta(days=window_days - 1))
resp = await client.get(
f"{FMP_STABLE}/earnings-calendar",
params={
"from": cur.isoformat(),
"to": win_end.isoformat(),
"apikey": api_key,
},
)
reqs += 1
if resp.status_code in (402, 403):
return [], reqs, f"bulk_unavailable status={resp.status_code}"
if resp.status_code == 429:
return rows, reqs, "rate_limited"
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list):
return [], reqs, f"unexpected bulk payload type={type(data)}"
for item in data:
if isinstance(item, dict):
parsed = _parse_bulk_item(item)
if parsed:
rows.append(parsed)
cur = win_end + timedelta(days=1)
return rows, reqs, None
async def _fetch_symbol(
client: httpx.AsyncClient, api_key: str, symbol: str
) -> list[dict]:
resp = await client.get(
f"{FMP_STABLE}/earnings",
params={"symbol": symbol, "apikey": api_key},
)
if resp.status_code == 429:
raise RuntimeError("rate_limited")
if resp.status_code == 402:
return []
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list):
return []
out: list[dict] = []
for item in data:
if isinstance(item, dict):
parsed = _parse_symbol_item(item, symbol)
if parsed:
out.append(parsed)
return out
async def _fetch_symbol_alpha_vantage(
client: httpx.AsyncClient, api_key: str, symbol: str
) -> list[dict]:
"""Alpha Vantage EARNINGS — includes reportedDate (announce) + estimate/actual."""
resp = await client.get(
"https://www.alphavantage.co/query",
params={"function": "EARNINGS", "symbol": symbol, "apikey": api_key},
)
if resp.status_code == 429:
raise RuntimeError("rate_limited")
resp.raise_for_status()
data = resp.json()
if not isinstance(data, dict):
return []
note = str(data.get("Note") or data.get("Information") or "")
if "rate limit" in note.lower() or "Thank you for using Alpha Vantage" in note:
raise RuntimeError("rate_limited")
if data.get("Error Message"):
return []
quarterly = data.get("quarterlyEarnings") or []
out: list[dict] = []
for item in quarterly:
if not isinstance(item, dict):
continue
# Prefer announce (reportedDate); fall back to fiscal end (worse PIT).
ad = item.get("reportedDate") or item.get("fiscalDateEnding")
if not ad:
continue
out.append({
"symbol": symbol.replace(".", "-").upper(),
"announce_date": str(ad)[:10],
"announce_time": item.get("reportTime"),
"eps_estimate": _f(item.get("estimatedEPS")),
"eps_actual": _f(item.get("reportedEPS")),
"revenue_estimate": None,
"revenue_actual": None,
})
return out
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()
engine = create_engine(
f"sqlite:///{snapshot.resolve().as_posix()}",
future=True,
)
_ensure_tables(engine)
with engine.connect() as conn:
symbols = [
str(r[0]).upper().replace(".", "-")
for r in conn.execute(text("SELECT symbol FROM tickers ORDER BY symbol"))
]
done = set()
if not args.refetch_done:
done = {
str(r[0])
for r in conn.execute(
text(
"SELECT symbol FROM earnings_backfill_meta "
"WHERE status='done' AND n_events > 0"
)
)
}
pending = [s for s in symbols if s not in done]
print(f"Snapshot: {snapshot}")
print(f"Universe: {len(symbols)}; pending: {len(pending)}; done: {len(done)}")
print(f"Window filter: {start}{end}")
print(f"Provider: {args.provider}")
req_budget = int(args.limit)
reqs_used = 0
events_written = 0
mode = "per_symbol"
use_av = args.provider in ("alpha_vantage", "auto") and bool(
getattr(settings, "alpha_vantage_api_key", "")
)
use_fmp = args.provider in ("fmp", "auto") and bool(settings.fmp_api_key)
async with httpx.AsyncClient(timeout=60.0) as client:
if (
not args.force_symbol
and req_budget > 0
and use_fmp
and args.provider != "alpha_vantage"
):
print("Attempting bulk earnings-calendar…")
bulk_rows, bulk_reqs, err = await _try_bulk(
client, settings.fmp_api_key, start, end
)
reqs_used += bulk_reqs
if err:
print(f" Bulk unavailable: {err} (requests={bulk_reqs})")
else:
# Filter to universe.
uni = set(symbols)
bulk_rows = [r for r in bulk_rows if r["symbol"] in uni]
with engine.begin() as conn:
events_written += _upsert_events(conn, bulk_rows, "fmp_earnings_calendar")
for sym in symbols:
n = conn.execute(
text(
"SELECT COUNT(*) FROM earnings_events WHERE symbol=:s"
),
{"s": sym},
).scalar_one()
conn.execute(
text(
"""
INSERT INTO earnings_backfill_meta(symbol, status, n_events, updated_at, note)
VALUES (:s, 'done', :n, :t, 'bulk')
ON CONFLICT(symbol) DO UPDATE SET
status='done', n_events=excluded.n_events,
updated_at=excluded.updated_at, note=excluded.note
"""
),
{
"s": sym,
"n": int(n),
"t": datetime.now(timezone.utc).isoformat(),
},
)
mode = "bulk"
print(f" Bulk wrote {events_written} events; requests={bulk_reqs}")
pending = []
# Per-symbol fallback / completion.
fmp_limited = False
for sym in pending:
if reqs_used >= req_budget:
print(f"Request budget exhausted ({req_budget}). Resume later.")
break
items: list[dict] = []
source = "fmp_earnings"
note = "per_symbol"
try:
if use_fmp and not fmp_limited and args.provider != "alpha_vantage":
items = await _fetch_symbol(client, settings.fmp_api_key, sym)
source = "fmp_earnings"
note = "fmp_per_symbol"
# Empty list may mean soft-limit or no data — try AV if available.
if not items and use_av:
items = await _fetch_symbol_alpha_vantage(
client, settings.alpha_vantage_api_key, sym
)
source = "alpha_vantage_earnings"
note = "av_after_fmp_empty"
reqs_used += 1 # count AV call separately below too
elif use_av:
items = await _fetch_symbol_alpha_vantage(
client, settings.alpha_vantage_api_key, sym
)
source = "alpha_vantage_earnings"
note = "av_per_symbol"
else:
raise RuntimeError("no provider available")
except Exception as exc:
msg = str(exc)
print(f" FAIL {sym}: {msg}")
reqs_used += 1
if "rate_limited" in msg and note.startswith("fmp"):
fmp_limited = True
with engine.begin() as conn:
conn.execute(
text(
"""
INSERT INTO earnings_backfill_meta(symbol, status, n_events, updated_at, note)
VALUES (:s, 'error', 0, :t, :n)
ON CONFLICT(symbol) DO UPDATE SET
status='error', updated_at=excluded.updated_at, note=excluded.note
"""
),
{
"s": sym,
"t": datetime.now(timezone.utc).isoformat(),
"n": msg[:200],
},
)
if args.sleep > 0:
await asyncio.sleep(args.sleep)
continue
reqs_used += 1
# Keep all rows with dates on/before end — SUE needs trailing history.
filtered = [
r for r in items if r["announce_date"] <= end.isoformat()
]
# Do NOT mark empty as done — leave pending for another provider/day.
status = "done" if filtered else "empty"
with engine.begin() as conn:
n_w = _upsert_events(conn, filtered, source) if filtered else 0
events_written += n_w
conn.execute(
text(
"""
INSERT INTO earnings_backfill_meta(symbol, status, n_events, updated_at, note)
VALUES (:s, :st, :n, :t, :note)
ON CONFLICT(symbol) DO UPDATE SET
status=excluded.status, n_events=excluded.n_events,
updated_at=excluded.updated_at, note=excluded.note
"""
),
{
"s": sym,
"st": status,
"n": len(filtered),
"t": datetime.now(timezone.utc).isoformat(),
"note": note,
},
)
if reqs_used % 10 == 0 or reqs_used == 1:
print(
f" progress reqs={reqs_used}/{req_budget} last={sym} "
f"events_batch={len(filtered)} src={source}"
)
# AV free tier is ~5/min or 25/day — be polite when using it.
sleep_s = float(args.sleep)
if source.startswith("alpha_vantage"):
sleep_s = max(sleep_s, 12.0)
if sleep_s > 0:
await asyncio.sleep(sleep_s)
with engine.connect() as conn:
total_events = int(
conn.execute(text("SELECT COUNT(*) FROM earnings_events")).scalar_one()
)
done_n = int(
conn.execute(
text("SELECT COUNT(*) FROM earnings_backfill_meta WHERE status='done'")
).scalar_one()
)
d_range = conn.execute(
text("SELECT MIN(announce_date), MAX(announce_date) FROM earnings_events")
).fetchone()
with_actual = int(
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_events "
"WHERE eps_actual IS NOT NULL AND eps_estimate IS NOT NULL"
)
).scalar_one()
)
summary = {
"mode": mode,
"fmp_requests": reqs_used,
"events_written_this_run": events_written,
"total_events": total_events,
"symbols_done": done_n,
"symbols_universe": len(symbols),
"announce_date_range": {"min": d_range[0], "max": d_range[1]},
"events_with_actual_and_estimate": with_actual,
"budget": req_budget,
"complete": done_n >= len(symbols),
}
print(json.dumps(summary, indent=2))
out = Path("reports") / "earnings-backfill-status.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
print(f"Wrote {out}")
if __name__ == "__main__":
asyncio.run(_main())
+4
View File
@@ -45,6 +45,10 @@ ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from app.ssl_bootstrap import bootstrap_ssl # noqa: E402
bootstrap_ssl()
def _parse_args() -> argparse.Namespace: def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__) p = argparse.ArgumentParser(description=__doc__)
+905
View File
@@ -0,0 +1,905 @@
"""Earnings gap diagnostic (2a) + SUE IC (2b). Local research only.
Requires ``earnings_events`` on the snapshot (see backfill_earnings_events.py).
Example
-------
python scripts/run_earnings_research.py \\
--snapshot backtest_snapshots/prod.sqlite --workers 6 --allow-spawn
"""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import os
import sys
from collections import defaultdict
from datetime import date, datetime, timedelta
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()
IRON_IC_BAR = 0.03
MIN_RELIABLE = 12
SUE_CARRY_DAYS = 63
SUE_TRAIL = 8
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("--snapshot", default="backtest_snapshots/prod.sqlite")
p.add_argument("--workers", type=int, default=6)
p.add_argument("--allow-spawn", action="store_true")
p.add_argument("--skip-2a", action="store_true")
p.add_argument("--skip-2b", action="store_true")
p.add_argument("--quiet", action="store_true")
p.add_argument("--out", default=None)
return p.parse_args()
def _load_earnings(snapshot: Path) -> list[dict]:
engine = create_engine(
f"sqlite:///{snapshot.resolve().as_posix()}",
future=True,
)
try:
with engine.connect() as conn:
# Table must exist.
tables = {
r[0]
for r in conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
)
}
if "earnings_events" not in tables:
raise SystemExit(
"earnings_events table missing — run scripts/backfill_earnings_events.py"
)
rows = conn.execute(
text(
"""
SELECT symbol, announce_date, announce_time,
eps_estimate, eps_actual, revenue_estimate, revenue_actual
FROM earnings_events
ORDER BY symbol, announce_date
"""
)
).fetchall()
meta = {}
if "earnings_backfill_meta" in tables:
meta = {
"done": int(
conn.execute(
text(
"SELECT COUNT(*) FROM earnings_backfill_meta "
"WHERE status='done'"
)
).scalar_one()
),
"universe_tickers": int(
conn.execute(text("SELECT COUNT(*) FROM tickers")).scalar_one()
),
}
finally:
engine.dispose()
events = [
{
"symbol": str(r[0]).upper(),
"announce_date": date.fromisoformat(str(r[1])[:10]),
"announce_time": r[2],
"eps_estimate": r[3],
"eps_actual": r[4],
"revenue_estimate": r[5],
"revenue_actual": r[6],
}
for r in rows
]
return events, meta
def _percentile(xs: list[float], q: float) -> float | None:
if not xs:
return None
s = sorted(xs)
if len(s) == 1:
return s[0]
idx = q * (len(s) - 1)
lo = int(math.floor(idx))
hi = int(math.ceil(idx))
if lo == hi:
return s[lo]
w = idx - lo
return s[lo] * (1 - w) + s[hi] * w
def _r_dist(rs: list[float]) -> dict[str, Any]:
if not rs:
return {"n": 0}
return {
"n": len(rs),
"mean": round(sum(rs) / len(rs), 4),
"win_rate": round(sum(1 for r in rs if r > 0) / len(rs), 4),
"p05": round(_percentile(rs, 0.05), 4),
"p25": round(_percentile(rs, 0.25), 4),
"p50": round(_percentile(rs, 0.50), 4),
"p75": round(_percentile(rs, 0.75), 4),
"p95": round(_percentile(rs, 0.95), 4),
"min": round(min(rs), 4),
"max": round(max(rs), 4),
}
def _trading_days_between(
entry: date, exit_: date, calendar: set[date]
) -> list[date]:
"""Inclusive trading dates in [entry, exit_] present on the union calendar."""
out = []
d = entry
while d <= exit_:
if d in calendar:
out.append(d)
d += timedelta(days=1)
return out
def _nth_trading_day_after(
start: date, n: int, ordered_calendar: list[date]
) -> date | None:
"""First calendar date strictly after ``start``, then + (n-1) more sessions.
announce+1 trading day: n=1 first session after announce date
(if announce is a trading day, still use the *next* session for PIT).
"""
# Sessions strictly after start.
after = [d for d in ordered_calendar if d > start]
if len(after) < n:
return None
return after[n - 1]
def _build_sue_series(
events_by_symbol: dict[str, list[dict]],
prices: dict[str, tuple],
) -> dict[str, dict[date, float]]:
"""symbol → {asof_date: sue_value} for days when SUE is live (announce+1 .. +63)."""
out: dict[str, dict[date, float]] = {}
for sym, cols in prices.items():
ords = cols[0]
closes = cols[4]
dates = [date.fromordinal(int(o)) for o in ords]
if not dates:
continue
ordered = dates # already chronological
cal_set = set(ordered)
events = events_by_symbol.get(sym.upper(), [])
# Chronological surprises with actual+estimate.
surprises: list[tuple[date, float, float]] = [] # announce, surprise, close_for_scale
for ev in events:
act, est = ev.get("eps_actual"), ev.get("eps_estimate")
if act is None or est is None:
continue
ad = ev["announce_date"]
# Close on/before announce for price fallback scale.
close_px = None
for d, c in zip(reversed(dates), reversed(closes)):
if d <= ad and float(c) > 0:
close_px = float(c)
break
surprises.append((ad, float(act) - float(est), close_px or 1.0))
surprises.sort(key=lambda x: x[0])
sue_on_day: dict[date, float] = {}
for i, (ad, surprise, px) in enumerate(surprises):
trail = [surprises[j][1] for j in range(max(0, i - SUE_TRAIL), i)]
# Need history of surprises; include current only for value, stdev from prior 8.
if len(trail) >= 3:
mean_t = sum(trail) / len(trail)
var = sum((x - mean_t) ** 2 for x in trail) / (len(trail) - 1)
sd = math.sqrt(var) if var > 0 else None
else:
sd = None
if sd is not None and sd > 1e-9:
sue = surprise / sd
else:
# Fallback: scale by price (EPS surprise / price).
sue = surprise / px if px > 0 else None
if sue is None or not math.isfinite(sue):
continue
usable_from = _nth_trading_day_after(ad, 1, ordered)
if usable_from is None:
continue
# Carry for SUE_CARRY_DAYS trading sessions starting at usable_from.
try:
start_idx = ordered.index(usable_from)
except ValueError:
# usable_from not in this symbol's calendar (halted etc.)
start_idx = next(
(k for k, d in enumerate(ordered) if d >= usable_from), None
)
if start_idx is None:
continue
end_idx = min(len(ordered) - 1, start_idx + SUE_CARRY_DAYS - 1)
for k in range(start_idx, end_idx + 1):
# Later announcements overwrite earlier carry (latest SUE wins).
sue_on_day[ordered[k]] = sue
if sue_on_day:
out[sym.upper()] = sue_on_day
return out
async def _run_2a(
snapshot: Path,
events: list[dict],
*,
quiet: bool,
workers: int,
) -> dict[str, Any]:
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 = workers
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
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)
tickers = list(
(await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars()
)
spy = await load_benchmark_closes(db, "SPY")
prices: dict[str, tuple] = {}
candidates: list[dict] = []
for idx, t in enumerate(tickers):
if not quiet and idx % 50 == 0:
print(f" 2a fetch {idx}/{len(tickers)}", end="\r", flush=True)
cols = await bt._fetch_columns(db, t.symbol)
if cols is None:
continue
prices[t.symbol] = cols
cands, _ = bt._replay_and_signals(
t.symbol,
cols,
config,
activation,
spy,
bt.PRODUCTION_GTL_TARGET_MODEL,
"weekly",
False,
)
candidates.extend(cands)
finally:
await engine.dispose()
if not quiet:
print()
# Production ranks + qualify.
bt._assign_momentum_percentiles(candidates)
bt._assign_residual_momentum_percentiles(candidates)
bt._assign_low_volatility_percentiles(candidates)
bt._assign_activation_momentum_percentiles(candidates)
bt._assign_residual_high_vol_blend(candidates)
for c in candidates:
c["qualified"] = bt._momentum_qualifies(c, 80.0)
longs = [
c for c in candidates 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))
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=int(entry_cfg["max_positions"]),
risk_per_trade=float(entry_cfg["risk_per_trade"]),
atr_trail_multiplier=trail,
post_stop_reentry_fn=reentry,
fill_mode=bt.FILL_MODE_CLOSE,
include_trades=True,
)
if sim is None:
return {"error": "no_trades"}
details = sim.get("trade_details") or []
# Build per-symbol earnings announce dates.
earns_by_sym: dict[str, list[date]] = defaultdict(list)
for ev in events:
earns_by_sym[ev["symbol"]].append(ev["announce_date"])
for sym in earns_by_sym:
earns_by_sym[sym].sort()
# Union trading calendar from prices.
cal: set[date] = set()
for cols in prices.values():
for o in cols[0]:
cal.add(date.fromordinal(int(o)))
ordered_cal = sorted(cal)
# Map entry date → list of announce dates for symbol (for pre-entry lookback).
trades_parsed: list[dict] = []
for t in details:
sym = str(t.get("symbol") or "").upper()
# Field names from simulator.
entry_s = t.get("entry_date") or t.get("open_date") or t.get("date")
exit_s = t.get("exit_date") or t.get("close_date")
r = t.get("realized_r")
if r is None:
r = t.get("r")
if entry_s is None or exit_s is None or r is None:
continue
entry_d = date.fromisoformat(str(entry_s)[:10])
exit_d = date.fromisoformat(str(exit_s)[:10])
announces = earns_by_sym.get(sym, [])
# Earnings between entry and exit (exclusive of entry day? inclusive hold).
# "between entry and exit" — any announce with entry < announce <= exit
# (gap often overnight after entry). Also count announce on entry day.
in_hold = [
a for a in announces if entry_d <= a <= exit_d
]
# Entries within 3 trading days BEFORE an announcement:
# exists announce such that entry is in the 3 sessions immediately before announce.
pre_earn = False
for a in announces:
# trading sessions in (a-lookback, a)
sessions_before = [d for d in ordered_cal if d < a]
last3 = sessions_before[-3:] if len(sessions_before) >= 3 else sessions_before
if entry_d in last3:
pre_earn = True
break
trades_parsed.append({
"symbol": sym,
"entry": entry_d.isoformat(),
"exit": exit_d.isoformat(),
"r": float(r),
"earnings_in_hold": len(in_hold) > 0,
"n_earnings_in_hold": len(in_hold),
"entry_within_3d_before_earn": pre_earn,
})
all_r = [t["r"] for t in trades_parsed]
loss_lt_1r = [t for t in trades_parsed if t["r"] < -1.0]
loss_with_earn = [t for t in loss_lt_1r if t["earnings_in_hold"]]
pre = [t["r"] for t in trades_parsed if t["entry_within_3d_before_earn"]]
other = [t["r"] for t in trades_parsed if not t["entry_within_3d_before_earn"]]
return {
"sim_summary": {
k: sim.get(k)
for k in (
"sharpe",
"sharpe_se",
"cagr_pct",
"max_drawdown_pct",
"trades",
"total_return_pct",
)
},
"n_trades_parsed": len(trades_parsed),
"q1_losses_worse_than_minus_1r": {
"n_losses_lt_minus_1r": len(loss_lt_1r),
"n_with_earnings_in_hold": len(loss_with_earn),
"fraction_with_earnings": (
round(len(loss_with_earn) / len(loss_lt_1r), 4) if loss_lt_1r else None
),
"all_trades_with_earnings_in_hold": sum(
1 for t in trades_parsed if t["earnings_in_hold"]
),
"fraction_all_trades_with_earnings": (
round(
sum(1 for t in trades_parsed if t["earnings_in_hold"])
/ len(trades_parsed),
4,
)
if trades_parsed
else None
),
},
"q2_entry_within_3d_before_announce": {
"pre_earn_entries": _r_dist(pre),
"other_entries": _r_dist(other),
"all_entries": _r_dist(all_r),
"tail_trim_note": (
"Compare p95/max and mean of pre_earn vs other. "
"Rising win_rate with falling mean/p95 = right-tail trim red flag."
),
},
"note": "REPORT-ONLY — no filter shipped.",
}
async def _run_2b_ic(
snapshot: Path,
events: list[dict],
*,
quiet: bool,
workers: int,
) -> dict[str, Any]:
"""SUE IC via harness on identical cross-sections as momentum baselines."""
from app.config import settings
from app.services import backtest_service as bt
from app.services.benchmark_service import load_benchmark_closes
from app.models.ticker import Ticker
from sqlalchemy import select
from collections import defaultdict as dd
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
os.environ["BACKTEST_SIGNAL_EVAL_ONLY"] = "1"
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)
# Collect base signals + attach SUE.
collected: dict = dd(lambda: dd(list))
try:
async with Session() as db:
tickers = list(
(await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars()
)
spy = await load_benchmark_closes(db, "SPY")
prices: dict[str, tuple] = {}
for idx, t in enumerate(tickers):
if not quiet and idx % 50 == 0:
print(f" 2b fetch {idx}/{len(tickers)}", end="\r", flush=True)
cols = await bt._fetch_columns(db, t.symbol)
if cols is None:
continue
prices[t.symbol] = cols
series = bt._signal_series(
[
type(
"R",
(),
{
"date": date.fromordinal(int(cols[0][i])),
"close": cols[4][i],
"high": cols[2][i],
"volume": cols[5][i] if len(cols) > 5 else 0,
},
)()
for i in range(len(cols[0]))
],
spy,
symbol=t.symbol,
)
for name, weeks in series.items():
for wk, pairs in weeks.items():
collected[name][wk].extend(pairs)
finally:
await engine.dispose()
if not quiet:
print()
# SUE series.
events_by_sym: dict[str, list[dict]] = defaultdict(list)
for ev in events:
events_by_sym[ev["symbol"]].append(ev)
sue_map = _build_sue_series(events_by_sym, prices)
# Inject sue_latest into collected using mom_12_1 observations as the
# weekly as-of skeleton (same weeks / symbols).
sue_collected: dict = dd(list)
mom_weeks = collected.get("mom_12_1") or {}
for week_key, recs in mom_weeks.items():
for rec in recs:
pair = bt._obs_val_fwd(rec)
if pair is None:
continue
_val, fwd = pair
sym = None
if isinstance(rec, dict):
sym = rec.get("symbol")
if not sym:
continue
# Need as-of date: recover from week — use Friday of ISO week as proxy
# is weak. Better: re-derive from prices weekly indices.
# Store asof on rich recs? Current rich rows lack asof date.
# Fall back: compute SUE observations directly from prices weekly as-ofs.
pass
# Direct weekly as-of SUE + forward return (authoritative).
for sym, cols in prices.items():
ords, _o, highs, _l, closes, _v = cols
dates = [date.fromordinal(int(o)) for o in ords]
sue_days = sue_map.get(sym.upper()) or {}
if not sue_days:
continue
n = len(dates)
# weekly as-of indices: reuse harness helper via fake records.
records = [
type("R", (), {"date": dates[i], "close": closes[i], "high": highs[i]})()
for i in range(n)
]
for i in bt._weekly_asof_indices(records):
j = i + bt.HORIZON
if j >= n or closes[i] <= 0:
continue
asof = dates[i]
sue = sue_days.get(asof)
if sue is None:
continue
fwd = float(closes[j]) / float(closes[i]) - 1.0
iso = asof.isocalendar()
week_key = (iso[0], iso[1])
# Also grab mom for conditional.
mom = None
if i >= 252 and closes[i - 252] > 0:
mom = float(closes[i - 21]) / float(closes[i - 252]) - 1.0
sue_collected[week_key].append({
"val": float(sue),
"fwd": fwd,
"symbol": sym,
"mom_12_1": mom,
})
collected["sue_latest"] = sue_collected
signal_eval = bt._signal_evaluation(collected)
# Fair side-by-side: re-evaluate mom baselines on the *same* (symbol, week)
# observations where SUE is present (incomplete backfill otherwise inflates
# mom N relative to SUE).
sue_pairs_by_week = sue_collected
restricted: dict = dd(lambda: dd(list))
for week_key, recs in sue_pairs_by_week.items():
syms = {str(r.get("symbol")).upper() for r in recs if r.get("symbol")}
for base_name in ("mom_12_1", "mom_12_1_resid"):
base_recs = (collected.get(base_name) or {}).get(week_key) or []
for rec in base_recs:
pair = bt._obs_val_fwd(rec)
if pair is None:
continue
sym = None
if isinstance(rec, dict):
sym = rec.get("symbol")
if not sym or str(sym).upper() not in syms:
continue
restricted[base_name][week_key].append(rec)
restricted["sue_latest"][week_key].extend(recs)
restricted_eval = bt._signal_evaluation(restricted)
# Momentum-conditional: IC of SUE within top mom quintile each week.
cond_ics: list[float] = []
stride = max(1, round(bt.HORIZON / 5))
usable = [wk for wk, recs in sue_collected.items() if len(recs) >= bt.MIN_CROSS_SECTION]
kept = bt._nonoverlapping_weeks(usable, stride)
for wk in kept:
recs = sue_collected[wk]
with_mom = [r for r in recs if r.get("mom_12_1") is not None]
if len(with_mom) < bt.MIN_CROSS_SECTION:
continue
ordered = sorted(with_mom, key=lambda r: float(r["mom_12_1"]))
k = max(1, len(ordered) // 5)
top = ordered[-k:]
if len(top) < 5:
continue
ic = bt._spearman(
[float(r["val"]) for r in top],
[float(r["fwd"]) for r in top],
)
if ic is not None:
cond_ics.append(ic)
if cond_ics:
mean_c = sum(cond_ics) / len(cond_ics)
if len(cond_ics) > 1:
std = math.sqrt(
sum((x - mean_c) ** 2 for x in cond_ics) / (len(cond_ics) - 1)
)
t_c = mean_c / std * math.sqrt(len(cond_ics)) if std > 0 else None
else:
t_c = None
mom_cond = {
"mean_ic": round(mean_c, 4),
"ic_t_stat": round(t_c, 2) if t_c is not None else None,
"weeks": len(cond_ics),
"note": "IC of sue_latest within top mom_12_1 quintile (non-overlapping weeks)",
}
else:
mom_cond = {"mean_ic": None, "weeks": 0}
def _find(name: str) -> dict | None:
for row in signal_eval:
if row.get("signal") == name:
return row
return None
sue = _find("sue_latest")
grade = {
"green": False,
"reason": "sue_latest missing",
}
if sue:
mean_ic = sue.get("mean_ic")
t = sue.get("ic_t_stat")
reliable = bool(sue.get("reliable"))
sign_ok = mean_ic is not None and float(mean_ic) > 0
mag_ok = mean_ic is not None and abs(float(mean_ic)) >= IRON_IC_BAR
grade = {
"green": bool(sign_ok and mag_ok and reliable),
"checks": {
"mean_ic": mean_ic,
"sign_positive": sign_ok,
"abs_ge_0_03": mag_ok,
"reliable": reliable,
"ic_t_stat": t,
"weeks": sue.get("weeks"),
},
"reason": (
"iron rule cleared — STOP; book-integration is a separate human step"
if (sign_ok and mag_ok and reliable)
else "iron rule not met"
),
"row": sue,
}
def _find_r(name: str) -> dict | None:
for row in restricted_eval:
if row.get("signal") == name:
return row
return None
# Side-by-side baselines from same evaluation.
side = {
name: _find(name)
for name in (
"mom_12_1",
"mom_12_1_resid",
"sue_latest",
"fip_id",
)
}
side_restricted = {
name: _find_r(name)
for name in ("mom_12_1", "mom_12_1_resid", "sue_latest")
}
return {
"signal_eval_side_by_side": side,
"signal_eval_identical_sue_subset": side_restricted,
"identical_subset_note": (
"Mom baselines re-scored only on (week, symbol) cells where SUE exists. "
"Use this table when backfill is incomplete — full-universe mom N is not comparable."
),
"full_signal_eval": signal_eval,
"sue_grade": grade,
"momentum_conditional_sue": mom_cond,
"sue_coverage": {
"symbols_with_sue": len(sue_map),
"avg_weeks_with_sue": (
round(
sum(len(v) for v in sue_collected.values())
/ max(1, len(sue_collected)),
1,
)
if sue_collected
else 0
),
"weeks_with_min_cross_section": len(usable),
},
}
def _write_md(path: Path, payload: dict) -> None:
pre = path.read_text(encoding="utf-8") if path.exists() else ""
marker = "## Results"
idx = pre.find(marker)
header = pre[:idx] if idx >= 0 else pre.split("## Verdict")[0]
lines = [
header.rstrip(),
"",
"## Results",
"",
f"Generated: `{payload.get('generated_at')}`",
"",
"### Data provenance",
"",
f"```json\n{json.dumps(payload.get('data_provenance') or {}, indent=2, default=str)}\n```",
"",
"### 2a — Earnings-gap risk (report-only)",
"",
]
a = payload.get("experiment_2a")
if not a:
lines.append("_Skipped or unavailable._")
else:
lines.append(f"```json\n{json.dumps(a, indent=2, default=str)}\n```")
lines.extend(["", "### 2b — SUE / PEAD IC", ""])
b = payload.get("experiment_2b")
if not b:
lines.append("_Skipped or unavailable._")
else:
side = b.get("signal_eval_side_by_side") or {}
lines.extend([
"| signal | mean_ic | ic_t_stat | weeks | avg_N | reliable |",
"|---|---:|---:|---:|---:|---|",
])
for name in (
"mom_12_1",
"mom_12_1_resid",
"sue_latest",
"fip_id",
):
r = side.get(name) or {}
lines.append(
f"| {name} | {r.get('mean_ic', '')} | {r.get('ic_t_stat', '')} | "
f"{r.get('weeks', '')} | {r.get('avg_cross_section', '')} | "
f"{r.get('reliable', '')} |"
)
lines.extend([
"",
f"**SUE grade:** `{json.dumps(b.get('sue_grade') or {}, default=str)}`",
"",
f"**Momentum-conditional SUE:** `{json.dumps(b.get('momentum_conditional_sue') or {}, default=str)}`",
"",
])
lines.extend([
"",
"## Verdict",
"",
f"**{payload.get('verdict')}**",
"",
payload.get("verdict_detail") or "",
"",
"## What a human must decide next",
"",
payload.get("human_next") or "- Review; no auto-ship.",
"",
f"Artifacts: `{payload.get('report_path')}`",
"",
])
path.write_text("\n".join(lines) + "\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"
events, meta = _load_earnings(snapshot)
# Race guard lite on earnings completeness.
provenance = {
"snapshot": str(snapshot.resolve()),
"n_earnings_events": len(events),
"backfill_meta": meta,
"announce_range": {
"min": min((e["announce_date"] for e in events), default=None),
"max": max((e["announce_date"] for e in events), default=None),
},
"with_actual_and_estimate": sum(
1
for e in events
if e.get("eps_actual") is not None and e.get("eps_estimate") is not None
),
}
print(
f"Earnings events: {provenance['n_earnings_events']} "
f"(with act+est={provenance['with_actual_and_estimate']}) meta={meta}"
)
if meta and meta.get("done", 0) < 0.9 * (meta.get("universe_tickers") or 1):
print(
"WARNING: earnings backfill incomplete "
f"({meta.get('done')}/{meta.get('universe_tickers')}). "
"Results may be biased; resume backfill."
)
exp_2a = None
exp_2b = None
if not args.skip_2a:
print("Running 2a earnings-gap diagnostic…")
exp_2a = await _run_2a(
snapshot, events, quiet=args.quiet, workers=args.workers
)
print(
" 2a losses<-1R with earnings:",
(exp_2a.get("q1_losses_worse_than_minus_1r") or {}),
)
if not args.skip_2b:
print("Running 2b SUE IC harness…")
exp_2b = await _run_2b_ic(
snapshot, events, quiet=args.quiet, workers=args.workers
)
g = exp_2b.get("sue_grade") or {}
print(f" 2b SUE green={g.get('green')} {g.get('reason')}")
# Verdict
if exp_2b and (exp_2b.get("sue_grade") or {}).get("green"):
verdict = "PROMOTE (2b SUE) — STOP for human wire design"
detail = (
"SUE cleared iron rule. No book integration without human approval. "
"2a remains report-only."
)
human = (
"- Design tilt vs second gate if desired.\n"
"- Do not auto-filter from 2a without separate approval + tail review."
)
else:
sue_ic = None
if exp_2b:
sue_ic = ((exp_2b.get("sue_grade") or {}).get("row") or {}).get("mean_ic")
if sue_ic is not None and abs(float(sue_ic)) >= 0.015:
verdict = "PARK"
detail = f"SUE IC={sue_ic} below iron bar or unreliable; keep data, no wire."
else:
verdict = "DEAD (2b) / REPORT-ONLY (2a)"
detail = (
"SUE does not clear iron rule on this window. "
"2a distributions for human risk review only — no filter."
)
human = (
"- No SUE book change.\n"
"- Read 2a tails before considering any earnings-avoid filter."
)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = Path(args.out) if args.out else Path("reports") / f"earnings-gap-sue-{stamp}.json"
payload = {
"generated_at": datetime.now().isoformat(),
"data_provenance": provenance,
"experiment_2a": exp_2a,
"experiment_2b": exp_2b,
"verdict": verdict,
"verdict_detail": detail,
"human_next": human,
"report_path": str(out.as_posix()),
"fmp_note": (
"Bulk earnings-calendar is paid (402 on free tier). "
"Backfill used per-symbol /stable/earnings; see earnings-backfill-status.json."
),
}
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(payload, indent=2, default=str) + "\n", encoding="utf-8")
md = Path("docs/research/earnings-gap-and-sue.md")
_write_md(md, payload)
out.with_suffix(".md").write_text(md.read_text(encoding="utf-8"), encoding="utf-8")
print(f"Verdict: {verdict}")
print(f"Wrote {out}")
if __name__ == "__main__":
asyncio.run(_main())
+706
View File
@@ -0,0 +1,706 @@
#!/usr/bin/env python3
"""Production book × universe × horizon matrix (research only).
Four pre-registered arms same live strategy knobs; only entry start date and
tradable universe change. See docs/research/prod-book-universe-horizon.md.
A 2022-07-01 prod ~505
B 2022-07-01 prod liquid top-1500
C 2016-07-01 prod ~505
D 2016-07-01 prod liquid top-1500
Example (MacBook, deep research.sqlite)
---------------------------------------
python scripts/run_prod_book_universe_matrix.py \\
--snapshot backtest_snapshots/research.sqlite \\
--workers 8 --allow-spawn \\
--candidate-cache reports/.cache/prod-book-univ-cands.pkl
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import pickle
import sys
import time
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor
from datetime import date, datetime
from pathlib import Path
from typing import Any
from sqlalchemy import create_engine, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.ssl_bootstrap import bootstrap_ssl # noqa: E402
bootstrap_ssl()
SHORT_START = date(2022, 7, 1)
LONG_START = date(2016, 7, 1)
LIQUID_TOP_N = 1500
LIQUID_MIN_PRICE = 5.0
CACHE_VERSION = "prod-book-universe-horizon-v1"
ARMS: tuple[dict[str, Any], ...] = (
{
"id": "A_prod_4y_505",
"label": "Prod book · ~4y · 505 only",
"start": SHORT_START,
"universe": "prod_505",
},
{
"id": "B_prod_4y_505_liquid",
"label": "Prod book · ~4y · 505 + liquid top-1500",
"start": SHORT_START,
"universe": "prod_plus_liquid",
},
{
"id": "C_prod_2016_505",
"label": "Prod book · since 2016-07 · 505 only",
"start": LONG_START,
"universe": "prod_505",
},
{
"id": "D_prod_2016_505_liquid",
"label": "Prod book · since 2016-07 · 505 + liquid top-1500",
"start": LONG_START,
"universe": "prod_plus_liquid",
},
)
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--snapshot", default="backtest_snapshots/research.sqlite")
p.add_argument("--workers", type=int, default=8)
p.add_argument("--allow-spawn", action="store_true")
p.add_argument("--quiet", action="store_true")
p.add_argument(
"--candidate-cache",
default="reports/.cache/prod-book-universe-cands.pkl",
help="Pickle cache for full GTL candidate pass (expensive).",
)
p.add_argument(
"--rebuild-cache",
action="store_true",
help="Ignore existing candidate cache.",
)
p.add_argument("--out", default=None)
p.add_argument(
"--skip-race-guard",
action="store_true",
help="Allow run without completion manifest (not recommended).",
)
return p.parse_args()
def _sqlite_url(path: Path) -> str:
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
def _load_prod_and_all_symbols(snapshot: Path) -> tuple[set[str], list[str]]:
engine = create_engine(
f"sqlite:///{snapshot.resolve().as_posix()}",
future=True,
)
try:
with engine.connect() as conn:
all_syms = [
str(r[0]).upper()
for r in conn.execute(text("SELECT symbol FROM tickers ORDER BY 1"))
]
try:
rank_only = {
str(r[0]).upper()
for r in conn.execute(text("SELECT symbol FROM research_rank_only"))
}
except Exception:
rank_only = set()
finally:
engine.dispose()
prod = {s for s in all_syms if s not in rank_only}
return prod, all_syms
def _median(xs: list[float]) -> float | None:
if len(xs) < 20:
return None
s = sorted(xs)
mid = len(s) // 2
if len(s) % 2:
return s[mid]
return 0.5 * (s[mid - 1] + s[mid])
def _build_liquid_membership(
prices: dict[str, tuple],
*,
top_n: int,
min_price: float,
) -> dict[date, set[str]]:
"""For each calendar date present in any series, top-N by 63d median $vol."""
# Collect per-symbol (date -> (close, dvol63))
per_sym: dict[str, dict[date, tuple[float, float | None]]] = {}
all_dates: set[date] = set()
for sym, cols in prices.items():
ords, _o, _h, _l, closes, vols = cols
dates = [date.fromordinal(int(o)) for o in ords]
n = len(dates)
series: dict[date, tuple[float, float | None]] = {}
for i in range(n):
d = dates[i]
c = float(closes[i])
dvol = None
if i + 1 >= 63:
dvs = []
for k in range(i - 62, i + 1):
ck = float(closes[k])
vk = float(vols[k] or 0)
if ck > 0 and vk >= 0:
dvs.append(ck * vk)
dvol = _median(dvs)
series[d] = (c, dvol)
all_dates.add(d)
per_sym[sym] = series
membership: dict[date, set[str]] = {}
for d in sorted(all_dates):
eligible: list[tuple[float, str]] = []
for sym, series in per_sym.items():
row = series.get(d)
if row is None:
continue
c, dvol = row
if c < min_price or dvol is None or dvol <= 0:
continue
eligible.append((-dvol, sym)) # highest dvol first
eligible.sort()
membership[d] = {sym for _, sym in eligible[:top_n]}
return membership
def _worker_replay(
symbol: str,
columns: tuple,
config: dict,
activation: dict,
spy: dict,
cadence: str,
) -> list[dict]:
"""Picklable full GTL+signals candidate replay (no signal-only)."""
from app.services import backtest_service as bt
cands, _series = bt._replay_and_signals(
symbol,
columns,
config,
activation,
spy,
bt.PRODUCTION_GTL_TARGET_MODEL,
cadence,
False, # always full replay for book matrix
None,
None,
)
return cands
async def _load_or_build_candidates(
snapshot: Path,
*,
cache_path: Path | None,
rebuild: bool,
workers: int,
quiet: bool,
) -> tuple[list[dict], dict[str, tuple], dict, set[str], dict]:
from app.config import settings
from app.services import backtest_service as bt
from app.services.admin_service import get_activation_config
from app.services.recommendation_service import get_recommendation_config
from app.services.paper_trade_service import get_exit_policy
from app.services.benchmark_service import load_benchmark_closes
from app.models.ticker import Ticker
from sqlalchemy import select
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
settings.backtest_workers = max(1, workers)
prod_set, all_syms = _load_prod_and_all_symbols(snapshot)
print(f"Symbols: all={len(all_syms)} prod_505={len(prod_set)}")
cache_key = {
"version": CACHE_VERSION,
"snapshot": str(snapshot.resolve()),
"prod_n": len(prod_set),
"all_n": len(all_syms),
}
if cache_path and cache_path.exists() and not rebuild:
with cache_path.open("rb") as fh:
blob = pickle.load(fh)
if blob.get("key") == cache_key and blob.get("candidates"):
print(f"Loaded candidate cache: {cache_path} ({len(blob['candidates'])} rows)")
return (
blob["candidates"],
blob["prices"],
blob["spy"],
set(blob["prod_set"]),
blob["exit_config"],
)
print("Cache key mismatch — rebuilding candidates")
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
candidates: list[dict] = []
prices: dict[str, tuple] = {}
try:
async with Session() as db:
config = await get_recommendation_config(db)
activation = await get_activation_config(db)
exit_config = await get_exit_policy(db)
spy = await load_benchmark_closes(db, "SPY")
tickers = list(
(await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars()
)
# Fetch all price columns first (I/O).
for idx, t in enumerate(tickers):
if not quiet and idx % 100 == 0:
print(f" fetch prices {idx}/{len(tickers)}", end="\r", flush=True)
cols = await bt._fetch_columns(db, t.symbol)
if cols is not None:
prices[t.symbol.upper()] = cols
if not quiet:
print()
# Parallel GTL replay for every symbol with prices.
syms = sorted(prices)
print(f"GTL replay on {len(syms)} symbols (workers={workers})…")
t0 = time.monotonic()
if workers <= 1:
for i, sym in enumerate(syms):
if not quiet and i % 50 == 0:
print(f" replay {i}/{len(syms)}", end="\r", flush=True)
candidates.extend(
_worker_replay(
sym, prices[sym], config, activation, spy, "weekly"
)
)
else:
# Process pool: pass column batches.
import multiprocessing as mp
ctx = mp.get_context("spawn")
chunk = max(1, workers * 2)
with ProcessPoolExecutor(max_workers=workers, mp_context=ctx) as pool:
for start in range(0, len(syms), chunk):
batch = syms[start : start + chunk]
futs = [
pool.submit(
_worker_replay,
sym,
prices[sym],
config,
activation,
spy,
"weekly",
)
for sym in batch
]
for fut in futs:
try:
candidates.extend(fut.result())
except Exception as exc:
print(f" worker error: {exc}")
if not quiet:
print(
f" replay {min(start+chunk, len(syms))}/{len(syms)} "
f"cands={len(candidates)} "
f"elapsed={(time.monotonic()-t0)/60:.1f}m",
end="\r",
flush=True,
)
if not quiet:
print()
finally:
await engine.dispose()
print(f"Total raw candidates: {len(candidates)}")
if cache_path:
cache_path.parent.mkdir(parents=True, exist_ok=True)
with cache_path.open("wb") as fh:
pickle.dump(
{
"key": cache_key,
"candidates": candidates,
"prices": prices,
"spy": spy,
"prod_set": sorted(prod_set),
"exit_config": exit_config,
},
fh,
protocol=pickle.HIGHEST_PROTOCOL,
)
print(f"Wrote cache {cache_path}")
return candidates, prices, spy, prod_set, exit_config
def _candidate_eligible(
cand: dict,
*,
prod_set: set[str],
universe: str,
liquid_by_date: dict[date, set[str]],
) -> bool:
if cand.get("direction") != "long":
return False
sym = str(cand.get("symbol") or "").upper()
if not sym:
return False
if universe == "prod_505":
return sym in prod_set
# prod_plus_liquid
if sym in prod_set:
return True
try:
d = date.fromisoformat(str(cand["date"])[:10])
except Exception:
return False
return sym in (liquid_by_date.get(d) or set())
def _run_arm(
arm: dict[str, Any],
*,
all_candidates: list[dict],
prices: dict[str, tuple],
spy: dict,
prod_set: set[str],
liquid_by_date: dict[date, set[str]],
exit_config: dict,
) -> dict[str, Any]:
from app.services import backtest_service as bt
start: date = arm["start"]
universe: str = arm["universe"]
filtered: list[dict] = []
for c in all_candidates:
try:
d = date.fromisoformat(str(c["date"])[:10])
except Exception:
continue
if d < start:
continue
if not _candidate_eligible(
c, prod_set=prod_set, universe=universe, liquid_by_date=liquid_by_date
):
continue
filtered.append(dict(c))
# Re-rank inside this arm's universe (production percentile logic).
bt._assign_momentum_percentiles(filtered)
bt._assign_residual_momentum_percentiles(filtered)
bt._assign_low_volatility_percentiles(filtered)
bt._assign_activation_momentum_percentiles(filtered)
bt._assign_residual_high_vol_blend(filtered)
for c in filtered:
c["qualified"] = bt._momentum_qualifies(c, 80.0)
longs = [
c for c in filtered if c.get("qualified") and c.get("direction") == "long"
]
strategy = next(s for s in bt.PORTFOLIO_MONITOR_STRATEGIES if s.get("is_production"))
entry_cfg = bt._entry_variant_config(str(strategy["entry_variant"]))
assert entry_cfg is not None
ranking_key = str(
entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]
)
exit_policy = bt.LIVE_EXIT_MODE_TO_SIM.get(
str(exit_config.get("mode", "atr_trailing")), "atr_trail3"
)
hold_days = int(exit_config.get("hold_days", 30))
trail = float(exit_config.get("atr_multiplier", bt.ATR_TRAIL_MULTIPLIER))
risk = float(entry_cfg["risk_per_trade"])
max_pos = int(entry_cfg["max_positions"])
reentry = bt._make_gate_reset_reentry_fn(
longs, prices, cadence="weekly", ranking_key=ranking_key
)
sim = bt._simulate_portfolio(
longs,
prices,
spy,
exit_policy,
hold_days,
ranking_key=ranking_key,
max_positions=max_pos,
risk_per_trade=risk,
atr_trail_multiplier=trail,
post_stop_reentry_fn=reentry,
start_date=start,
end_date=None,
fill_mode=bt.FILL_MODE_CLOSE,
include_trades=False,
)
if sim is None:
return {
"id": arm["id"],
"label": arm["label"],
"start": start.isoformat(),
"universe": universe,
"n_candidates": len(filtered),
"n_qualified_longs": 0,
"error": "no_trades",
}
keep = {
k: sim.get(k)
for k in (
"sharpe",
"sharpe_se",
"cagr_pct",
"max_drawdown_pct",
"total_return_pct",
"calmar",
"trades",
"win_rate",
"n_returns",
"psr",
"start_date",
"end_date",
"spy_return_pct",
"final_equity",
)
}
return {
"id": arm["id"],
"label": arm["label"],
"start": start.isoformat(),
"universe": universe,
"n_candidates": len(filtered),
"n_qualified_longs": len(longs),
"fill_mode": "close",
"ranking_key": ranking_key,
"exit_policy": exit_policy,
"hold_days": hold_days,
**keep,
}
def _write_outputs(payload: dict, out_json: Path, doc_path: Path) -> None:
out_json.parent.mkdir(parents=True, exist_ok=True)
out_json.write_text(
json.dumps(payload, indent=2, default=str) + "\n", encoding="utf-8"
)
lines = [
"# Production book × universe × horizon — results",
"",
f"Generated: `{payload.get('generated_at')}`",
"",
"> Survivorship: today's constituents backfilled. Compare arms relatively; "
"do not treat deep CAGR/Sharpe levels as deployable forecasts.",
"",
"## Arms",
"",
"| arm | universe | entry start | Sharpe | SE | CAGR % | max DD % | ret % | trades | qual longs | span |",
"|---|---|---|---:|---:|---:|---:|---:|---:|---:|---|",
]
for row in payload.get("arms") or []:
if row.get("error"):
lines.append(
f"| {row.get('id')} | {row.get('universe')} | {row.get('start')} | "
f"ERR | | | | | | {row.get('n_qualified_longs')} | {row.get('error')} |"
)
continue
lines.append(
f"| {row.get('id')} | {row.get('universe')} | {row.get('start')} | "
f"{row.get('sharpe')} | {row.get('sharpe_se')} | {row.get('cagr_pct')} | "
f"{row.get('max_drawdown_pct')} | {row.get('total_return_pct')} | "
f"{row.get('trades')} | {row.get('n_qualified_longs')} | "
f"{row.get('start_date')}{row.get('end_date')} |"
)
lines.extend([
"",
"## Config (production, unchanged)",
"",
f"```json\n{json.dumps(payload.get('strategy') or {}, indent=2)}\n```",
"",
"## Snapshot",
"",
f"```json\n{json.dumps(payload.get('snapshot_meta') or {}, indent=2, default=str)}\n```",
"",
"PENDING_HUMAN — descriptive matrix only; no auto promotion.",
"",
f"JSON: `{out_json.as_posix()}`",
"",
])
out_json.with_suffix(".md").write_text("\n".join(lines) + "\n", encoding="utf-8")
# Fill results section of the research doc.
if doc_path.exists():
text = doc_path.read_text(encoding="utf-8")
marker = "## Results"
idx = text.find(marker)
header = text[:idx] if idx >= 0 else text
# Drop old results/verdict tail
for m in ("## Results", "## Verdict"):
pass
body = [
header.rstrip(),
"",
"## Results",
"",
f"Generated: `{payload.get('generated_at')}`",
"",
"| arm | universe | entry start | Sharpe | SE | CAGR % | max DD % | trades |",
"|---|---|---|---:|---:|---:|---:|---:|",
]
for row in payload.get("arms") or []:
body.append(
f"| {row.get('id')} | {row.get('universe')} | {row.get('start')} | "
f"{row.get('sharpe', '')} | {row.get('sharpe_se', '')} | "
f"{row.get('cagr_pct', '')} | {row.get('max_drawdown_pct', '')} | "
f"{row.get('trades', '')} |"
)
body.extend([
"",
f"Full report: `{out_json.as_posix()}`",
"",
"## Verdict",
"",
"**PENDING_HUMAN** — descriptive only; production knobs unchanged.",
"",
])
doc_path.write_text("\n".join(body) + "\n", encoding="utf-8")
async def _main() -> None:
args = _parse_args()
snapshot = Path(args.snapshot)
if not snapshot.exists():
raise SystemExit(f"Missing snapshot: {snapshot}")
if args.allow_spawn:
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
if not args.skip_race_guard:
try:
from scripts.research_snapshot_manifest import (
assert_research_snapshot_complete,
)
manifest = assert_research_snapshot_complete(snapshot)
print(
f"Race guard OK: tickers={manifest.get('ticker_count')} "
f"ohlcv={manifest.get('ohlcv_row_count')}"
)
except SystemExit as exc:
# Prod-only snapshot without manifest: allow with warning if ~505.
engine = create_engine(
f"sqlite:///{snapshot.resolve().as_posix()}",
future=True,
)
try:
with engine.connect() as conn:
n = int(conn.execute(text("SELECT COUNT(*) FROM tickers")).scalar_one())
finally:
engine.dispose()
if n < 400:
raise
print(f"WARNING: no research manifest ({exc}); proceeding n_tickers={n}")
cache = Path(args.candidate_cache) if args.candidate_cache else None
candidates, prices, spy, prod_set, exit_config = await _load_or_build_candidates(
snapshot,
cache_path=cache,
rebuild=args.rebuild_cache,
workers=args.workers,
quiet=args.quiet,
)
print("Building PIT liquid membership (top-1500, price≥5)…")
t0 = time.monotonic()
liquid_by_date = _build_liquid_membership(
prices, top_n=LIQUID_TOP_N, min_price=LIQUID_MIN_PRICE
)
print(
f" liquid dates={len(liquid_by_date)} "
f"elapsed={(time.monotonic()-t0)/60:.1f}m"
)
arms_out = []
for arm in ARMS:
print(f"Running arm {arm['id']}")
row = _run_arm(
arm,
all_candidates=candidates,
prices=prices,
spy=spy,
prod_set=prod_set,
liquid_by_date=liquid_by_date,
exit_config=exit_config,
)
arms_out.append(row)
print(
f" Sharpe={row.get('sharpe')} CAGR={row.get('cagr_pct')} "
f"DD={row.get('max_drawdown_pct')} trades={row.get('trades')} "
f"qual={row.get('n_qualified_longs')}"
)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = (
Path(args.out)
if args.out
else Path("reports") / f"prod-book-universe-horizon-{stamp}.json"
)
payload = {
"generated_at": datetime.now().isoformat(),
"snapshot": str(snapshot.resolve()),
"snapshot_meta": {
"prod_universe_n": len(prod_set),
"price_symbols_n": len(prices),
"raw_candidates": len(candidates),
"liquid_top_n": LIQUID_TOP_N,
"liquid_min_price": LIQUID_MIN_PRICE,
"short_start": SHORT_START.isoformat(),
"long_start": LONG_START.isoformat(),
},
"strategy": {
"note": "Live production knobs — no modifications",
"momentum": "residual_12_1 gate 80",
"rank": "residual_high_vol_blend_80_20",
"fill_mode": "close",
"cost_per_side": 0.001,
"exit": exit_config,
"max_positions": 10,
"risk_per_trade": 0.01,
"reentry": "gate_reset",
},
"arms": arms_out,
"survivorship_banner": (
"Today's constituents backfilled. Relative arm comparison only."
),
"pending_human": True,
}
_write_outputs(
payload,
out,
Path("docs/research/prod-book-universe-horizon.md"),
)
print(f"Wrote {out}")
print(f"Wrote {out.with_suffix('.md')}")
if __name__ == "__main__":
asyncio.run(_main())
+139
View File
@@ -0,0 +1,139 @@
#!/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"
log "Earnings backfill + research (parked experiment)"
"$PYTHON" scripts/backfill_earnings_events.py \
--snapshot "$PROD_SNAP" --provider fmp --force-symbol \
--limit "$FMP_LIMIT" --sleep "$FMP_SLEEP"
"$PYTHON" scripts/run_earnings_research.py \
--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."