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:
@@ -46,3 +46,4 @@ backtest_snapshots/
|
||||
# Rebuildable pickle caches are local accelerators, not decision evidence.
|
||||
reports/*.pkl
|
||||
reports/*.pk1
|
||||
reports/.cache/
|
||||
|
||||
+2
-49
@@ -3,56 +3,9 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSL + proxy injection — MUST happen before any HTTP client imports
|
||||
# ---------------------------------------------------------------------------
|
||||
import os as _os
|
||||
import ssl as _ssl
|
||||
from pathlib import Path as _Path
|
||||
from app.ssl_bootstrap import bootstrap_ssl
|
||||
|
||||
_COMBINED_CERT = _Path(__file__).resolve().parent.parent / "combined-ca-bundle.pem"
|
||||
|
||||
if _COMBINED_CERT.exists():
|
||||
_cert_path = str(_COMBINED_CERT)
|
||||
# Env vars for libraries that respect them (requests, urllib3)
|
||||
_os.environ["SSL_CERT_FILE"] = _cert_path
|
||||
_os.environ["REQUESTS_CA_BUNDLE"] = _cert_path
|
||||
_os.environ["CURL_CA_BUNDLE"] = _cert_path
|
||||
|
||||
# Monkey-patch ssl.create_default_context so that ALL libraries
|
||||
# (aiohttp, httpx, google-genai, alpaca-py, etc.) automatically
|
||||
# use our combined CA bundle that includes the corporate root cert.
|
||||
_original_create_default_context = _ssl.create_default_context
|
||||
|
||||
def _patched_create_default_context(
|
||||
purpose=_ssl.Purpose.SERVER_AUTH, *, cafile=None, capath=None, cadata=None
|
||||
):
|
||||
ctx = _original_create_default_context(
|
||||
purpose, cafile=cafile, capath=capath, cadata=cadata
|
||||
)
|
||||
# Always load our combined bundle on top of whatever was loaded
|
||||
ctx.load_verify_locations(cafile=_cert_path)
|
||||
return ctx
|
||||
|
||||
_ssl.create_default_context = _patched_create_default_context
|
||||
|
||||
# Also patch aiohttp's cached SSL context objects directly, since
|
||||
# aiohttp creates them at import time and may have already cached
|
||||
# a context without our corporate CA bundle.
|
||||
try:
|
||||
import aiohttp.connector as _aio_conn
|
||||
if hasattr(_aio_conn, '_SSL_CONTEXT_VERIFIED') and _aio_conn._SSL_CONTEXT_VERIFIED is not None:
|
||||
_aio_conn._SSL_CONTEXT_VERIFIED.load_verify_locations(cafile=_cert_path)
|
||||
if hasattr(_aio_conn, '_SSL_CONTEXT_UNVERIFIED') and _aio_conn._SSL_CONTEXT_UNVERIFIED is not None:
|
||||
_aio_conn._SSL_CONTEXT_UNVERIFIED.load_verify_locations(cafile=_cert_path)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Corporate proxy — needed when Kiro spawns the process (no .zshrc sourced)
|
||||
# Only enable this if explicitly requested via environment variable.
|
||||
if _os.environ.get("USE_CORP_PROXY", "0") == "1":
|
||||
_PROXY = "http://aproxy.corproot.net:8080"
|
||||
_NO_PROXY = "corproot.net,sharedtcs.net,127.0.0.1,localhost,bix.swisscom.com,swisscom.com"
|
||||
_os.environ.setdefault("HTTP_PROXY", _PROXY)
|
||||
_os.environ.setdefault("HTTPS_PROXY", _PROXY)
|
||||
_os.environ.setdefault("NO_PROXY", _NO_PROXY)
|
||||
bootstrap_ssl()
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
@@ -813,7 +813,10 @@ def _residual_momentum_12_1(
|
||||
var_market = sum((x - mean_market) ** 2 for x in market_rets)
|
||||
if var_market <= 0:
|
||||
return None
|
||||
cov = sum((stock_rets[k] - mean_stock) * (market_rets[k] - mean_market) for k in range(len(stock_rets)))
|
||||
cov = sum(
|
||||
(stock_rets[k] - mean_stock) * (market_rets[k] - mean_market)
|
||||
for k in range(len(stock_rets))
|
||||
)
|
||||
beta = cov / var_market
|
||||
return sum(stock_rets[k] - beta * market_rets[k] for k in range(len(stock_rets)))
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""TLS / corporate-proxy bootstrap for CLI scripts and the API.
|
||||
|
||||
Must run **before** httpx / alpaca / aiohttp open connections.
|
||||
|
||||
Resolution order for the CA bundle:
|
||||
1. ``combined-ca-bundle.pem`` in the repo root (gitignored corporate bundle)
|
||||
2. ``$HOME/combined-ca-bundle.pem`` (MacBook path used by existing tooling)
|
||||
3. ``SSL_CERT_FILE`` / ``REQUESTS_CA_BUNDLE`` if already set and present
|
||||
4. ``certifi.where()`` when the package is installed
|
||||
5. System defaults (no patch)
|
||||
|
||||
Optional corporate proxy (Swisscom-style) when ``USE_CORP_PROXY=1``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
_BOOTSTRAPPED = False
|
||||
|
||||
|
||||
def _candidate_ca_paths() -> list[Path]:
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
home = Path.home()
|
||||
env_paths = [
|
||||
os.environ.get("SSL_CERT_FILE", ""),
|
||||
os.environ.get("REQUESTS_CA_BUNDLE", ""),
|
||||
os.environ.get("CURL_CA_BUNDLE", ""),
|
||||
]
|
||||
paths = [
|
||||
root / "combined-ca-bundle.pem",
|
||||
home / "combined-ca-bundle.pem",
|
||||
*[Path(p) for p in env_paths if p],
|
||||
]
|
||||
try:
|
||||
import certifi
|
||||
|
||||
paths.append(Path(certifi.where()))
|
||||
except Exception:
|
||||
pass
|
||||
return paths
|
||||
|
||||
|
||||
def resolve_ca_bundle() -> str | None:
|
||||
for path in _candidate_ca_paths():
|
||||
try:
|
||||
if path.is_file() and path.stat().st_size > 0:
|
||||
return str(path.resolve())
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def apply_corp_proxy_if_requested() -> None:
|
||||
if os.environ.get("USE_CORP_PROXY", "0") != "1":
|
||||
return
|
||||
proxy = os.environ.get("CORP_HTTP_PROXY", "http://aproxy.corproot.net:8080")
|
||||
no_proxy = os.environ.get(
|
||||
"CORP_NO_PROXY",
|
||||
"corproot.net,sharedtcs.net,127.0.0.1,localhost,bix.swisscom.com,swisscom.com",
|
||||
)
|
||||
os.environ.setdefault("HTTP_PROXY", proxy)
|
||||
os.environ.setdefault("HTTPS_PROXY", proxy)
|
||||
os.environ.setdefault("NO_PROXY", no_proxy)
|
||||
os.environ.setdefault("http_proxy", proxy)
|
||||
os.environ.setdefault("https_proxy", proxy)
|
||||
os.environ.setdefault("no_proxy", no_proxy)
|
||||
|
||||
|
||||
def bootstrap_ssl(*, force: bool = False) -> str | None:
|
||||
"""Install CA env vars + patch ``ssl.create_default_context``.
|
||||
|
||||
Returns the CA path used, or None if nothing was applied.
|
||||
Safe to call multiple times.
|
||||
"""
|
||||
global _BOOTSTRAPPED
|
||||
if _BOOTSTRAPPED and not force:
|
||||
return os.environ.get("SSL_CERT_FILE") or None
|
||||
|
||||
apply_corp_proxy_if_requested()
|
||||
|
||||
cert_path = resolve_ca_bundle()
|
||||
if not cert_path:
|
||||
_BOOTSTRAPPED = True
|
||||
return None
|
||||
|
||||
os.environ["SSL_CERT_FILE"] = cert_path
|
||||
os.environ["REQUESTS_CA_BUNDLE"] = cert_path
|
||||
os.environ["CURL_CA_BUNDLE"] = cert_path
|
||||
|
||||
original = ssl.create_default_context
|
||||
|
||||
def _patched(
|
||||
purpose=ssl.Purpose.SERVER_AUTH, *, cafile=None, capath=None, cadata=None
|
||||
):
|
||||
ctx = original(purpose, cafile=cafile, capath=capath, cadata=cadata)
|
||||
try:
|
||||
ctx.load_verify_locations(cafile=cert_path)
|
||||
except Exception:
|
||||
pass
|
||||
return ctx
|
||||
|
||||
ssl.create_default_context = _patched # type: ignore[assignment]
|
||||
|
||||
# aiohttp may cache SSL contexts at import time.
|
||||
try:
|
||||
import aiohttp.connector as aio_conn
|
||||
|
||||
for attr in ("_SSL_CONTEXT_VERIFIED", "_SSL_CONTEXT_UNVERIFIED"):
|
||||
ctx = getattr(aio_conn, attr, None)
|
||||
if ctx is not None:
|
||||
try:
|
||||
ctx.load_verify_locations(cafile=cert_path)
|
||||
except Exception:
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
_BOOTSTRAPPED = True
|
||||
return cert_path
|
||||
|
||||
|
||||
def ssl_status() -> dict:
|
||||
"""Diagnostic blob for research scripts / MacBook troubleshooting."""
|
||||
ca = resolve_ca_bundle()
|
||||
return {
|
||||
"ca_bundle": ca,
|
||||
"ssl_cert_file_env": os.environ.get("SSL_CERT_FILE"),
|
||||
"use_corp_proxy": os.environ.get("USE_CORP_PROXY", "0"),
|
||||
"http_proxy": os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY"),
|
||||
"candidates_exist": {
|
||||
str(p): p.is_file() for p in _candidate_ca_paths()[:4]
|
||||
},
|
||||
}
|
||||
@@ -47,6 +47,7 @@ score, Structural S/R, the Gate Target Ladder, sentiment, fundamentals) is
|
||||
| 10 | **Inverse-vol position sizing** | The apparent "win" was **mis-attributed**: the 20% notional cap bound on 95% of entries, so it measured concentration, not vol-sizing. Genuine inverse-vol cuts DD to −18.2% but costs ~58pp return at flat Sharpe | **Rejected** as edge; it's a risk-preference trade | `backtest-20260709-position-sizing*.json` |
|
||||
| 11 | **FIP path-smoothness** as tie-breaker/filter | Non-monotonic within the qualified set; thinning the entry stream costs more compounding than the tilt returns | **Rejected as a filter** — but see §4, it's the strongest raw signal we've measured | — |
|
||||
| 12 | **Fixed take-profit sweep** (R-multiples) | No interior optimum ever found — the best TP is "no TP" | **Rejected.** Momentum's edge lives in the right tail | `backtest_service.py:450` |
|
||||
| 13 | **Sector-residual 12-1** (`mom_12_1_sector_resid` / sector demean) as replacement for market residual | Short-window IC/A/B looked knife-edge green; deep repaired + **liquid-1500** retest: weeks 83, mild +IC **0.027** / t 1.69, **below iron bar 0.03** (FAIL). Demean already weaker | **Rejected / closed.** Keep production market residual. Do not resurrect without a new pre-registered protocol | [sector-residual-momentum.md](sector-residual-momentum.md) · `sector-resid-deep-20260719-113319.json` · history-depth supersession note |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 < −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 book’s 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 < 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 > 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) |
|
||||
@@ -0,0 +1,206 @@
|
||||
# History-depth extension (Tier-1 alpha research)
|
||||
|
||||
**Status:** **CLOSED.** Sector-residual deep test **FAIL** — Task 1 archived as rejected (see supersession).
|
||||
**Branch:** `research/earnings-gap-and-sue`
|
||||
**Superseded artifact (do not cite):** `reports/history-depth-20260719-103315.json` — **UNMASKED, TWO-TIER SNAPSHOT**
|
||||
**Authoritative sector grade:** `reports/sector-resid-deep-20260719-113319.json`
|
||||
**Production impact:** none. **Do not retune any production knob on deep history.**
|
||||
|
||||
---
|
||||
|
||||
## Pre-registration (locked before rebuild)
|
||||
|
||||
### Motivation
|
||||
|
||||
All current conclusions rest on ~35 non-overlapping weekly windows in essentially
|
||||
one post-2021 regime. Extending history toward max Alpaca daily-bar depth adds
|
||||
the 2018 vol shock and full 2020 crash (where the feed allows).
|
||||
|
||||
### Protocol
|
||||
|
||||
1. **Empirical coverage first** — bars per calendar year per symbol; document
|
||||
where the feed thins out. Do **not** assume a uniform start date.
|
||||
2. **Rebuild the research snapshot completely** from prod source + max history
|
||||
per symbol (`Adjustment.SPLIT`, ~200 req/min pacing via existing extender).
|
||||
3. **Race guard (rule 6)** — refuse analysis until completion manifest is
|
||||
`complete=true` and live counts match.
|
||||
4. **Re-run full signal harness** (all existing signals incl. sector residual /
|
||||
SUE if present) on the extended window.
|
||||
5. **Report per signal:** mean IC, t, window count, and **era split**
|
||||
(pre-/post-2021) — diagnostic only, **not a tuning input**.
|
||||
6. **Log prominently:** survivorship bias grows with depth (today’s constituents
|
||||
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
|
||||
**relative** signal comparisons and IC stability, not levels.
|
||||
7. **Do not retune** production knobs. If a knob’s confirmation looks
|
||||
overturned on deep history → report only; human decides.
|
||||
|
||||
### Success / interpretation (not promotion of a new signal)
|
||||
|
||||
| outcome | meaning |
|
||||
|---|---|
|
||||
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
|
||||
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
|
||||
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
|
||||
| Any production knob looks worse deep | report; no auto-retune |
|
||||
|
||||
---
|
||||
|
||||
## Data provenance
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| Snapshot | MacBook `research.sqlite` |
|
||||
| Manifest `complete` | **true** (finished 2026-07-19T08:19Z) |
|
||||
| Live counts match | yes — 4655 tickers / 6,609,926 OHLCV / 4149 rank_only |
|
||||
| `history_days` | 5000 |
|
||||
| fetch_ok / fail | 4152 / 0 |
|
||||
| Race guard | **pass** |
|
||||
|
||||
> **SURVIVORSHIP BIAS:** today’s constituents backfilled historically. Absolute
|
||||
> Sharpe/CAGR levels on deep history are optimistic. Use **relative** signal IC
|
||||
> comparisons and era stability only — not levels.
|
||||
|
||||
**Coverage JSON was empty in the auto-written doc** (harness-only phase after
|
||||
rebuild). Manifest is the race-guard source of truth for this run.
|
||||
|
||||
Earlier MacBook files `history-depth-20260719-093853` … `095156` are intermediate
|
||||
/ incomplete passes — **do not cite**. Only **103315** is authoritative.
|
||||
|
||||
---
|
||||
|
||||
## Results (authoritative: 103315)
|
||||
|
||||
### Full-window signal IC (broad research universe, deep bars)
|
||||
|
||||
| signal | mean_ic | t | weeks | avg_N | notes |
|
||||
|---|---:|---:|---:|---:|---|
|
||||
| high_52w | **0.111** | **6.41** | 84 | 2280 | strong on deep breadth |
|
||||
| mom_12_1 | **0.066** | **5.11** | 83 | 2278 | raw momentum strong |
|
||||
| trend_200 | 0.055 | 4.30 | 85 | 2308 | |
|
||||
| mom_6_1 | 0.049 | 4.91 | 88 | 2376 | |
|
||||
| mom_3_1 | 0.036 | 3.58 | 90 | 2426 | |
|
||||
| fip_id | **+0.027** | **3.25** | 83 | 2278 | **sign flip vs prod fingerprint** |
|
||||
| mom_12_1_resid | 0.026 | 2.21 | 83 | 2278 | market residual still + but weaker than raw |
|
||||
| reversal_1m | ~0 | 0.31 | 91 | 2443 | dead |
|
||||
| vol_6m | **−0.123** | **−6.34** | 88 | 2376 | low-vol anomaly strong |
|
||||
| mom_12_1_sector_resid | 0.058 | 2.34 | **35** | **498** | **not deep-sample — see caveats** |
|
||||
| mom_12_1_sector_demeaned | 0.034 | 1.32 | **35** | **497** | same short fingerprint |
|
||||
|
||||
### Era split (diagnostic only — not a tuning input)
|
||||
|
||||
| signal | pre-2021 IC / t / w / N | post-2021 IC / t / w / N |
|
||||
|---|---|---|
|
||||
| mom_12_1 | +0.041 / 2.94 / 36 / 1425 | +0.079 / 3.64 / 48 / 2913 |
|
||||
| mom_12_1_resid | +0.023 / 1.68 / 36 / 1425 | +0.027 / 1.37 / 48 / 2913 |
|
||||
| fip_id | +0.012 / 1.35 / 36 / 1425 | +0.037 / 2.91 / 48 / 2913 |
|
||||
| vol_6m | −0.056 / −2.16 / 40 / 1461 | −0.162 / −4.94 / 48 / 3123 |
|
||||
| high_52w | +0.049 / 2.0 / 36 / 1422 | +0.138 / 4.34 / 48 / 2915 |
|
||||
| **sector_resid** | **absent** | 0.058 / 2.34 / 35 / 498 (short only) |
|
||||
| **sector_demeaned** | **absent** | 0.034 / 1.32 / 35 / 497 (short only) |
|
||||
|
||||
---
|
||||
|
||||
## Critical caveats (must read)
|
||||
|
||||
### 1. Sector residual did **not** get a deep-history stress test
|
||||
|
||||
`mom_12_1_sector_resid` / `_demeaned` still show **exactly** the Task‑1 short-window
|
||||
fingerprint: **35 weeks, N≈498, IC 0.0578, t 2.34**.
|
||||
|
||||
On the same run, raw `mom_12_1` has **83 weeks, N≈2278**. So depth worked for
|
||||
price-only signals, but sector residual is still limited to the **~505 labeled
|
||||
prod names × short factor calendar** (sector map only covers prod; and/or sector
|
||||
ETF / two-factor path did not extend usable residual weeks).
|
||||
|
||||
**Pre-registered rule:** “Sector residual collapses pre-2021 → PARK Task 1
|
||||
wire-in.” Pre-2021 sector residual is **absent** from the era table. That is a
|
||||
**PARK**, not a confirmation of the short-window PROMOTE.
|
||||
|
||||
Do **not** claim “sector residual beats market residual on deep history” from
|
||||
this table — the two rows are **not the same cross-section or window count**.
|
||||
|
||||
### 2. `fip_id` sign flips vs production fingerprint
|
||||
|
||||
| sample | fip mean IC | t |
|
||||
|---|---:|---:|
|
||||
| Prod 505, ~5y (fingerprint) | **−0.045** | −2.91 |
|
||||
| Research breadth, deep (this run) | **+0.027** | +3.25 |
|
||||
|
||||
This does **not** authorize resurrecting unconditional FIP as a book filter. It
|
||||
confirms earlier Phase‑B caution: FIP edge is **universe- and sample-dependent**.
|
||||
Production display card can stay context-only. Nested lookbacks still not OOS.
|
||||
|
||||
### 3. Market residual vs raw momentum on deep breadth
|
||||
|
||||
On deep broad IC, **raw 12‑1 (0.066 / t 5.1) ≫ market residual (0.026 / t 2.2)**.
|
||||
That does **not** by itself overturn production residual ranking (book A/B was
|
||||
on 505 + GTL gate, not pure factor IC), but it is a yellow flag for “residual is
|
||||
always the better rank key” stories on broad history. **No auto-retune.**
|
||||
|
||||
### 4. Low-vol anomaly is the cleanest deep-history result
|
||||
|
||||
`vol_6m` IC −0.12 / t −6.3 full; stronger post-2021. Consistent sign across eras.
|
||||
Production already blends **high**-vol (not low-vol) into the 80/20 rank — this
|
||||
report does not change that without a separate A/B. Flag for human awareness only.
|
||||
|
||||
---
|
||||
|
||||
## Verdicts (vs pre-registration)
|
||||
|
||||
| question | verdict |
|
||||
|---|---|
|
||||
| Task 1 sector residual wire-in | **PARK** — no pre-2021 sector residual; deep-sample IC not established; short-window PROMOTE stays “human design only,” **not strengthened** by this run |
|
||||
| Sector demean | still **DEAD** for promotion (t 1.32, short only) |
|
||||
| SUE | **not re-scored here** (no `sue_latest` in harness table) — leave Task 2 **PARK** until full earnings backfill |
|
||||
| fip unconditional book filter | remains **rejected / parked** despite sign flip on broad deep sample |
|
||||
| Production residual / 80/20 / trail knobs | **no retune** from this report |
|
||||
| Overall Task 3 | **COMPLETE as diagnostic** — payload is relative IC + caveats above |
|
||||
|
||||
---
|
||||
|
||||
## What a human must decide next
|
||||
|
||||
1. **Sector residual — decided:** CLOSED / REJECTED (archive complete). No wire-in.
|
||||
2. **Do not** retune residual vs raw, FIP, or vol blend from deep IC tables
|
||||
without a pre-registered book A/B on the intended universe.
|
||||
3. Optional (separate threads only): finish earnings backfill and re-run SUE;
|
||||
snapshot per-symbol depth guard as tooling.
|
||||
|
||||
---
|
||||
|
||||
## Artifacts
|
||||
|
||||
| file | role |
|
||||
|---|---|
|
||||
| `reports/history-depth-20260719-103315.json` | Superseded unmasked/two-tier IC dump (do not cite for sector residual) |
|
||||
| `reports/sector-resid-deep-20260719-113319.json` | Authoritative sector-resid deep grade |
|
||||
| `reports/prod-book-universe-horizon-20260719-140737.json` | 505 vs liquid × horizon book matrix |
|
||||
|
||||
Intermediate history-depth partials (093853–095156) and SANITY-FAIL noise were
|
||||
removed in branch cleanup.
|
||||
|
||||
---
|
||||
|
||||
## Supersession notice (2026-07-19 sector-resid deep test)
|
||||
|
||||
The table and interpretation from **`history-depth-20260719-103315`** are **UNMASKED, TWO-TIER SNAPSHOT — superseded, directional only, do not cite**. Prod-universe names (and sector residual coverage) were left shallow while breadth names were deepened; sector residual weeks=35 was a data gap.
|
||||
|
||||
### Sector-residual deep test outcome: **FAIL** (archived)
|
||||
|
||||
**Task 1 CLOSED / REJECTED** — sector residual dead on deep evidence. Archived in
|
||||
the research log rejected table (#13). Do not resurrect without a new
|
||||
pre-registered protocol.
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| weeks | **83** (data fix worked) |
|
||||
| mean IC | **0.0268** (below 0.03 bar) → FAIL |
|
||||
| t vs resid same CS | 1.69 ≥ 1.30 pass |
|
||||
| era signs | both + pass |
|
||||
|
||||
- Artifact: `reports/sector-resid-deep-20260719-113319.json`
|
||||
- Summary write-up: [sector-residual-momentum.md](sector-residual-momentum.md)
|
||||
|
||||
**Future snapshot rebuilds must verify per-symbol depth** (earliest-bar
|
||||
uniformity across the intended universe) — guard is a to-do, not part of this
|
||||
order.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Production book × universe × horizon matrix
|
||||
|
||||
**Status:** PRE-REGISTERED — prepare / MacBook run; no production changes.
|
||||
**Branch:** `research/earnings-gap-and-sue`
|
||||
**Runner:** `scripts/run_prod_book_universe_matrix.py`
|
||||
|
||||
---
|
||||
|
||||
## Question
|
||||
|
||||
How does the **live production book** (unchanged knobs) behave when we only vary:
|
||||
|
||||
1. **History length** used for entries (≈4y vs since 2016-07)
|
||||
2. **Tradable universe** (prod ~505 vs 505 + PIT liquid Nasdaq/breadth)
|
||||
|
||||
No strategy modifications: same residual gate, 80/20 high-vol rank, GTL entry
|
||||
machinery, 3× ATR trail, 30d max hold, gate-reset re-entry, `fill_mode=close`,
|
||||
cost 10 bps/side, max 10, 1% risk.
|
||||
|
||||
---
|
||||
|
||||
## Pre-registered arms (locked)
|
||||
|
||||
| id | label | Entry start | Tradable universe |
|
||||
|---|---|---|---|
|
||||
| **A** | prod_4y_505 | **2022-07-01** | Prod ~505 only |
|
||||
| **B** | prod_4y_505_liquid | **2022-07-01** | Prod ∪ liquid top-1500 |
|
||||
| **C** | prod_2016_505 | **2016-07-01** | Prod ~505 only |
|
||||
| **D** | prod_2016_505_liquid | **2016-07-01** | Prod ∪ liquid top-1500 |
|
||||
|
||||
- **End:** last available bar in snapshot (no artificial end).
|
||||
- **4y start** chosen to align with recent Phase‑A / book baselines (~mid‑2022 → mid‑2026).
|
||||
- **2016-07-01** = first full month after typical Alpaca floor (~2016-01); residual 12‑1 needs ~1y bars so first residual ranks appear mid‑2017 where feed allows.
|
||||
|
||||
### Universe definitions
|
||||
|
||||
| set | definition |
|
||||
|---|---|
|
||||
| **Prod ~505** | Symbols **not** in `research_rank_only` on the research snapshot (the original prod-universe copy). |
|
||||
| **Liquid top-1500** | Point-in-time: among names with as-of close ≥ **$5** and valid 63d median $vol, keep top **1500** by that $vol. Same definition as breadth IC research. |
|
||||
| **Prod ∪ liquid** | A name may enter the book on date *t* if it is prod **or** in the liquid top-1500 at *t*. |
|
||||
|
||||
Cross-sectional residual / vol / 80/20 ranks are **recomputed inside each arm’s
|
||||
eligible candidate set** that period (so breadth arms are not ranked against
|
||||
non-eligible thin names).
|
||||
|
||||
### Explicit non-goals
|
||||
|
||||
- No sector residual, SUE, FIP filter, gap-cap, take-profit, vol-target, corr-cap
|
||||
- No retune of trail / cutoff / min_rr
|
||||
- Survivorship: report levels with the standard caveat; **compare arms relatively**
|
||||
|
||||
### Reporting (required table)
|
||||
|
||||
Per arm: Sharpe, Sharpe SE (Mertens), CAGR %, max DD %, total return %, trades,
|
||||
win rate if available, start/end, n qualified longs. One markdown table + JSON.
|
||||
|
||||
**No promotion rule** — descriptive matrix only. Human decides whether breadth
|
||||
or depth changes the risk story.
|
||||
|
||||
---
|
||||
|
||||
## Snapshot requirements
|
||||
|
||||
- Prefer MacBook **deep** `research.sqlite` after sector-resid deepen (prod names
|
||||
from ~2016, breadth deep, completion manifest `complete=true`).
|
||||
- Race-guard before run.
|
||||
- Sector map / sector ETFs optional (not used for ranking).
|
||||
|
||||
---
|
||||
|
||||
## Results
|
||||
|
||||
Generated: `2026-07-19T14:07:37` · artifact
|
||||
`reports/prod-book-universe-horizon-20260719-140737.json`
|
||||
Snapshot: MacBook deep `research.sqlite` (506 prod + breadth prices; 2.39M raw
|
||||
GTL candidates). Strategy knobs = live production (residual 80, 80/20 high-vol
|
||||
rank, ATR trail 3×, hold 30, gate-reset, `fill_mode=close`).
|
||||
|
||||
> Survivorship: today's constituents backfilled. **Compare arms relatively.**
|
||||
> Absolute deep CAGR/Sharpe are not deployable forecasts.
|
||||
|
||||
| arm | universe | entries from | Sharpe | SE | CAGR % | max DD % | total ret % | trades | win % | vs SPY |
|
||||
|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| **A** | 505 only | 2022-07-01 | **1.32** | 0.49 | **31.8** | **18.9** | +205 | 374 | 35.6 | +96.5 |
|
||||
| **B** | 505 + liquid 1500 | 2022-07-01 | 0.14 | 0.50 | −1.8 | 55.3 | −7 | 706 | 29.3 | +95.0 |
|
||||
| **C** | 505 only | 2016-07-01 | **0.88** | 0.31 | **16.7** | **24.4** | +369 | 763 | 36.7 | +257 |
|
||||
| **D** | 505 + liquid 1500 | 2016-07-01 | −0.06 | 0.32 | −7.0 | 73.9 | −52 | 1567 | 28.0 | +254 |
|
||||
|
||||
Qualified longs: A 1 448 · B 6 587 · C 2 450 · D 11 551.
|
||||
|
||||
### Read (relative only)
|
||||
|
||||
1. **Same strategy, broader liquid universe kills the book** (A→B and C→D).
|
||||
Sharpe collapses; DD roughly triples; win rate drops ~6–8pp; trade count
|
||||
~doubles. This matches earlier breadth IC work: the production residual +
|
||||
high-vol package is a **large-cap / prod-universe** edge, not a
|
||||
“more names = better” edge.
|
||||
|
||||
2. **Longer history on 505 stays positive but softer** (A→C). Sharpe 1.32 → 0.88,
|
||||
CAGR 32% → 17%, DD 19% → 24%. Still well above the liquid-breadth arms.
|
||||
Levels are optimistic (survivorship); the useful message is “edge does not
|
||||
vanish when 2018/2020 are included,” not “expect 17% CAGR forever.”
|
||||
|
||||
3. **Arm A vs older Phase‑A / short-window controls** (~Sharpe 1.7–2.1): this
|
||||
matrix re-ranked on deep research.sqlite with a fixed entry start; numbers
|
||||
need not match prior reports row-for-row. Use **this table for A–D
|
||||
comparisons**, not for rewriting the production baseline number.
|
||||
|
||||
4. **No production change implied.** Keep the live ~505 universe. Do not broaden
|
||||
the tradable set to liquid Nasdaq under current knobs without a new
|
||||
pre-registered design (and almost certainly a different rank/tilt package).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Descriptive matrix complete.**
|
||||
|
||||
| question | answer from this matrix |
|
||||
|---|---|
|
||||
| Prod book @ ~4y / 505 | Positive (arm A) |
|
||||
| Same + liquid Nasdaq | **No** — large degradation (arm B) |
|
||||
| Prod book since 2016 / 505 | Still positive, milder (arm C) |
|
||||
| Same + liquid Nasdaq deep | **No** — worst arm (arm D) |
|
||||
|
||||
**PENDING_HUMAN** only for whether to log “universe broaden under current knobs”
|
||||
as rejected in the main research index. Strategy knobs unchanged either way.
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# Sector-residual momentum (Tier-1 alpha research)
|
||||
|
||||
**Status:** **CLOSED / REJECTED** — do not resurrect without a new pre-registered protocol.
|
||||
**Branch:** `research/earnings-gap-and-sue` (final grade) · earlier short-window work on `research/sector-residual-momentum`
|
||||
**Production impact:** none. Market residual 12-1 remains the production momentum leg.
|
||||
**Authoritative deep grade:** `reports/sector-resid-deep-20260719-113319.json` (**FAIL**)
|
||||
**Short-window A/B (superseded for promotion):** `reports/sector-residual-20260719-083356.json` — knife-edge only; not decisive after deep masked retest.
|
||||
|
||||
### Closure (2026-07-19)
|
||||
|
||||
Pre-registered deep test on repaired snapshot + liquid-1500 mask:
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| weeks extended (≫ 35) | pass (83) |
|
||||
| sign +, reliable, eras both + | pass |
|
||||
| t ≥ `mom_12_1_resid` same CS | pass (1.69 ≥ 1.30) |
|
||||
| \|mean IC\| ≥ 0.03 | **fail (0.0268)** |
|
||||
|
||||
**Verdict:** Task 1 CLOSED — sector residual dead on deep evidence.
|
||||
`mom_12_1_sector_demeaned` remains DEAD for promotion. No further sector-residual variants from this thread.
|
||||
|
||||
---
|
||||
|
||||
## Pre-registration (locked before first research run)
|
||||
|
||||
### Hypothesis
|
||||
|
||||
Residualizing 12–1 momentum against the sector, not only the market, reduces
|
||||
factor volatility at similar return (Blitz / Huij / Martens-style) → higher
|
||||
Sharpe on the production book when the residual replaces market-only residual
|
||||
as the momentum leg.
|
||||
|
||||
### Signals (candidates)
|
||||
|
||||
| signal | construction |
|
||||
|---|---|
|
||||
| `mom_12_1_sector_resid` | Two-factor residual vs SPY + ticker’s sector ETF. Same window as `mom_12_1_resid`: ≥100 daily obs, 252-bar lookback, 21-bar skip; two-factor OLS betas **without intercept**; cumulate residual returns over the formation window. |
|
||||
| `mom_12_1_sector_demeaned` | Plain `mom_12_1` minus the **cross-sectional** mean of `mom_12_1` within the same GICS sector that week (≥2 names in sector). No regression. |
|
||||
|
||||
### Baselines (same run, same cross-sections — iron rule)
|
||||
|
||||
Always report side-by-side with:
|
||||
|
||||
- `mom_12_1`
|
||||
- `mom_12_1_resid`
|
||||
|
||||
Computed on the **identical** weekly non-overlapping cross-sections in this run.
|
||||
Never compare against IC numbers from another report.
|
||||
|
||||
### Iron rule (IC harness)
|
||||
|
||||
Source of truth: `_signal_evaluation` in `app/services/backtest_service.py`.
|
||||
|
||||
- Mean weekly Spearman IC on **non-overlapping** weekly windows
|
||||
- Bar: \|mean IC\| ≥ ~0.03, **consistent positive sign**, `reliable: true` (≥ 12 windows)
|
||||
|
||||
### Promotion to portfolio A/B (candidate → book)
|
||||
|
||||
A candidate promotes to A/B **only if**:
|
||||
|
||||
1. It clears the iron-rule bar **and**
|
||||
2. Its IC **t-stat ≥** that of `mom_12_1_resid` on the same cross-sections.
|
||||
|
||||
### Portfolio A/B grading (if and only if IC promotion fires)
|
||||
|
||||
- Swap candidate in as the **momentum leg** of the production 80/20 momentum/vol
|
||||
rank **and** as the gate-percentile signal.
|
||||
- `fill_mode=close`, `COST_PER_SIDE = 0.001`, full config otherwise unchanged.
|
||||
- Validation window = entries ≥ **2024-07-01** (call it **validation**, not
|
||||
holdout — contaminated by prior experiments).
|
||||
- Pre-registered promotion bar:
|
||||
- validation Sharpe ≥ control − 0.5·SE
|
||||
- full-period Sharpe and max-DD **not worse** than control
|
||||
- Report Lo / Mertens-adjusted SEs.
|
||||
|
||||
### Optional sector-cap sub-experiment
|
||||
|
||||
Only if labels are in **and** A/B ran: max **3** positions per sector in the
|
||||
10-slot book. Same A/B grading. **Tail-trim presumption of guilt** (rule 4):
|
||||
report entry counts and both tails of the R distribution. Rising win rate with
|
||||
falling Sharpe/CAGR = red flag → do not promote.
|
||||
|
||||
**This run:** sector-cap arm **not executed** (optional; A/B unconstrained book
|
||||
only). Can be a human-approved follow-up.
|
||||
|
||||
### Verdict labels
|
||||
|
||||
| label | meaning |
|
||||
|---|---|
|
||||
| **PROMOTE** | Clears pre-registered bar; human decides next (wire design separate) |
|
||||
| **PARK** | Inconclusive / weak; keep machinery, no book change |
|
||||
| **DEAD** | Failed iron rule or worse than residual baseline with clear sign |
|
||||
|
||||
### Explicit non-goals
|
||||
|
||||
- No production deploy from this doc
|
||||
- Do not resurrect: take-profit exits, EV gate, regime entry-blocking,
|
||||
inverse-vol sizing, gap-caps, unconditional FIP filter
|
||||
|
||||
---
|
||||
|
||||
## Data provenance
|
||||
|
||||
### Snapshot race guard
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| Snapshot path | `backtest_snapshots/prod.sqlite` |
|
||||
| Manifest | none (expected for prod snapshot); bar-count sanity applied |
|
||||
| Tickers / OHLCV | **506** / **629,263** |
|
||||
| Bars min / avg / max | 14 / 1246.1 / 1261 |
|
||||
| OHLCV range | 2021-06-24 → 2026-07-02 |
|
||||
| Partial-build red flags | none (avg bars healthy) |
|
||||
|
||||
Integrity fingerprint on same run: `fip_id` mean IC **−0.045** / t **−2.91**
|
||||
(35 weeks, N≈498) — matches the established prod fingerprint.
|
||||
|
||||
### Sector labels
|
||||
|
||||
| source | count |
|
||||
|---|---:|
|
||||
| Public S&P 500 GICS CSV | 496 newly filled |
|
||||
| FMP profile requests | 10 (all missing after CSV) |
|
||||
| Mapped / universe | **505 / 506 (99.8%)** |
|
||||
| With mappable ETF | 505 |
|
||||
| Still missing | **RHM** only |
|
||||
|
||||
Persist path: `data/research/ticker_sector_map.json`.
|
||||
|
||||
FMP aliases (`Technology`, `Consumer Defensive`, `Financial Services`) map to
|
||||
SPDRs via the alias table in `app/services/sector_map.py`.
|
||||
|
||||
### Sector ETFs in `benchmark_prices` (auxiliary only — not tradable)
|
||||
|
||||
| symbol | bars | min date | max date |
|
||||
|---|---:|---|---|
|
||||
| SPY | 1516 | 2020-07-06 | 2026-07-17 |
|
||||
| XLB…XLY (11) | 1512 each | 2020-07-10 | 2026-07-17 |
|
||||
|
||||
Fetched via Alpaca `Adjustment.SPLIT` into **`benchmark_prices`** (same table as
|
||||
SPY) so they never enter the ticker universe or candidate replay.
|
||||
|
||||
---
|
||||
|
||||
## Results
|
||||
|
||||
Generated: `2026-07-19T08:33:56`
|
||||
|
||||
### IC harness (identical cross-sections, production 506-name universe)
|
||||
|
||||
| signal | mean_ic | ic_t_stat | weeks | avg_N | reliable | ic+_pct | quintile spread |
|
||||
|---|---:|---:|---:|---:|---|---:|---:|
|
||||
| **mom_12_1_sector_resid** | **0.0578** | **2.34** | 35 | 497.7 | true | 65.7 | 0.0245 |
|
||||
| mom_12_1_resid | 0.0552 | 1.98 | 35 | 497.7 | true | 60.0 | 0.0207 |
|
||||
| mom_12_1 | 0.0531 | 1.61 | 35 | 497.7 | true | 65.7 | 0.0206 |
|
||||
| mom_12_1_sector_demeaned | 0.0340 | 1.32 | 35 | 496.7 | true | 62.9 | 0.0154 |
|
||||
|
||||
### IC promotion grades
|
||||
|
||||
| candidate | iron rule | t ≥ resid | promote_to_ab |
|
||||
|---|---|---|---|
|
||||
| `mom_12_1_sector_resid` | pass (IC 0.058, +sign, reliable) | **yes** (2.34 ≥ 1.98) | **yes** |
|
||||
| `mom_12_1_sector_demeaned` | pass (IC 0.034, +sign, reliable) | **no** (1.32 < 1.98) | **no** |
|
||||
|
||||
### Portfolio A/B — `mom_12_1_sector_resid` as residual leg
|
||||
|
||||
Config: production 80/20 residual/high-vol rank + gate percentile, `fill_mode=close`,
|
||||
cost 10 bps/side, ATR trail / gate-reset re-entry as live. Validation split
|
||||
2024-07-01.
|
||||
|
||||
| window | arm | Sharpe | Sharpe SE (Mertens) | CAGR % | max DD % | trades | n_days |
|
||||
|---|---|---:|---:|---:|---:|---:|---:|
|
||||
| train | control (resid) | 1.30 | 0.685 | 29.2 | 21.4 | 176 | 525 |
|
||||
| train | treatment (sector resid) | **1.57** | 0.677 | **35.5** | **19.8** | 176 | 530 |
|
||||
| validation | control | **2.92** | 0.709 | **76.3** | **11.7** | 150 | 501 |
|
||||
| validation | treatment | 2.57 | 0.701 | 66.3 | 14.8 | 163 | 501 |
|
||||
| full | control | 2.09 | 0.497 | 51.6 | 21.4 | 322 | 1000 |
|
||||
| full | treatment | 2.09 | 0.491 | 51.0 | **19.8** | 337 | 1005 |
|
||||
|
||||
**Pre-registered A/B checks**
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| val Sharpe ≥ control − 0.5·SE | **pass** (2.57 ≥ 2.92 − 0.5×0.701 = 2.5695) — **knife-edge** |
|
||||
| full Sharpe not worse | **pass** (2.09 = 2.09) |
|
||||
| full max DD not worse | **pass** (19.8 < 21.4) |
|
||||
|
||||
Qualified long candidates: control 1086 vs treatment 1210 (sector residual
|
||||
gates a slightly larger set).
|
||||
|
||||
---
|
||||
|
||||
## Verdict (final — archived)
|
||||
|
||||
| signal | verdict | note |
|
||||
|---|---|---|
|
||||
| **`mom_12_1_sector_resid`** | **CLOSED / REJECTED** | Deep masked IC 0.0268 < 0.03 bar (`sector-resid-deep-20260719-113319`). Short-window PROMOTE superseded. |
|
||||
| **`mom_12_1_sector_demeaned`** | **DEAD** | Never cleared t vs market residual; stays dead. |
|
||||
|
||||
Short-window evidence below is **historical only** (pre-deep retest). Do not use it
|
||||
to reopen promotion.
|
||||
|
||||
### Read carefully (archived context)
|
||||
|
||||
1. Short-window IC (0.058 / t 2.34 vs resid 0.055 / t 1.98 on 35 weeks) and knife-edge
|
||||
A/B looked openable — that was the data gap era (shallow prod bars).
|
||||
2. Deep repaired + liquid-1500 retest closed the case: weeks 83, mild +IC, **below bar**.
|
||||
3. Production keeps **market** residual 12-1. Research harness may still *emit*
|
||||
sector residual for diagnostics; it is not a promotion candidate.
|
||||
4. **Do not resurrect** without a new pre-registered protocol and new data.
|
||||
|
||||
---
|
||||
|
||||
## What a human must decide next
|
||||
|
||||
**Nothing on Task 1** — archived. Optional: leave research machinery in tree
|
||||
(harmless) or delete later as cleanup; not a strategy decision.
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes
|
||||
|
||||
Research runners and sector-residual harness hooks were **removed after close**
|
||||
(2026-07-19 cleanup). Evidence remains in the report artifacts below. Do not
|
||||
re-add without a new pre-registered protocol.
|
||||
|
||||
---
|
||||
|
||||
## Artifacts
|
||||
|
||||
| file | role |
|
||||
|---|---|
|
||||
| `reports/sector-resid-deep-20260719-113319.json` | **Authoritative deep FAIL** |
|
||||
| `reports/sector-residual-20260719-083356.json` | Short-window IC/A/B (superseded for promotion) |
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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 < −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 book’s 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 < 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 > 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) |
|
||||
@@ -0,0 +1,494 @@
|
||||
{
|
||||
"generated_at": "2026-07-19T10:33:15.322673",
|
||||
"survivorship_banner": "SURVIVORSHIP BIAS: today's constituents backfilled historically. Absolute Sharpe/CAGR levels on deep history are optimistic. Use RELATIVE signal IC comparisons and era stability only \u2014 not levels.",
|
||||
"coverage": null,
|
||||
"race_guard": {
|
||||
"manifest": {
|
||||
"schema_version": 1,
|
||||
"snapshot": "research.sqlite",
|
||||
"snapshot_resolved": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/research.sqlite",
|
||||
"complete": true,
|
||||
"finished_at": "2026-07-19T08:19:02.992206+00:00",
|
||||
"ticker_count": 4655,
|
||||
"ohlcv_row_count": 6609926,
|
||||
"rank_only_count": 4149,
|
||||
"sources": {
|
||||
"nasdaq_all": "nasdaq_trader",
|
||||
"sp500": "wikipedia_sp500"
|
||||
},
|
||||
"history_days": 5000,
|
||||
"min_bars": 260,
|
||||
"fetch_ok": 4152,
|
||||
"fetch_fail": 0,
|
||||
"limit": null,
|
||||
"extra": {
|
||||
"prod_symbols_at_start": 506,
|
||||
"pool_size": 4648,
|
||||
"to_fetch": 4152
|
||||
},
|
||||
"live_counts": {
|
||||
"ticker_count": 4655,
|
||||
"ohlcv_row_count": 6609926,
|
||||
"rank_only_count": 4149
|
||||
}
|
||||
},
|
||||
"ok": true
|
||||
},
|
||||
"harness": {
|
||||
"survivorship_banner": "SURVIVORSHIP BIAS: today's constituents backfilled historically. Absolute Sharpe/CAGR levels on deep history are optimistic. Use RELATIVE signal IC comparisons and era stability only \u2014 not levels.",
|
||||
"signal_eval": [
|
||||
{
|
||||
"signal": "high_52w",
|
||||
"weeks": 84,
|
||||
"avg_cross_section": 2280.3,
|
||||
"mean_ic": 0.1111,
|
||||
"ic_t_stat": 6.41,
|
||||
"ic_positive_pct": 76.2,
|
||||
"mean_quintile_spread": -13.5104,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "mom_12_1",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 2277.5,
|
||||
"mean_ic": 0.0663,
|
||||
"ic_t_stat": 5.11,
|
||||
"ic_positive_pct": 74.7,
|
||||
"mean_quintile_spread": -10.1818,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 497.7,
|
||||
"mean_ic": 0.0578,
|
||||
"ic_t_stat": 2.34,
|
||||
"ic_positive_pct": 65.7,
|
||||
"mean_quintile_spread": 0.0245,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "trend_200",
|
||||
"weeks": 85,
|
||||
"avg_cross_section": 2308.1,
|
||||
"mean_ic": 0.0546,
|
||||
"ic_t_stat": 4.3,
|
||||
"ic_positive_pct": 70.6,
|
||||
"mean_quintile_spread": -12.4094,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "mom_6_1",
|
||||
"weeks": 88,
|
||||
"avg_cross_section": 2375.7,
|
||||
"mean_ic": 0.0493,
|
||||
"ic_t_stat": 4.91,
|
||||
"ic_positive_pct": 70.5,
|
||||
"mean_quintile_spread": 3.4224,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "mom_3_1",
|
||||
"weeks": 90,
|
||||
"avg_cross_section": 2425.6,
|
||||
"mean_ic": 0.0363,
|
||||
"ic_t_stat": 3.58,
|
||||
"ic_positive_pct": 72.2,
|
||||
"mean_quintile_spread": 0.9289,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "mom_12_1_sector_demeaned",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 496.7,
|
||||
"mean_ic": 0.034,
|
||||
"ic_t_stat": 1.32,
|
||||
"ic_positive_pct": 62.9,
|
||||
"mean_quintile_spread": 0.0154,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "fip_id",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 2277.5,
|
||||
"mean_ic": 0.0267,
|
||||
"ic_t_stat": 3.25,
|
||||
"ic_positive_pct": 67.5,
|
||||
"mean_quintile_spread": -0.0017,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "mom_12_1_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 2277.5,
|
||||
"mean_ic": 0.0256,
|
||||
"ic_t_stat": 2.21,
|
||||
"ic_positive_pct": 65.1,
|
||||
"mean_quintile_spread": 10.1373,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "reversal_1m",
|
||||
"weeks": 91,
|
||||
"avg_cross_section": 2443.2,
|
||||
"mean_ic": 0.003,
|
||||
"ic_t_stat": 0.31,
|
||||
"ic_positive_pct": 48.4,
|
||||
"mean_quintile_spread": -7.0153,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "vol_6m",
|
||||
"weeks": 88,
|
||||
"avg_cross_section": 2375.7,
|
||||
"mean_ic": -0.1226,
|
||||
"ic_t_stat": -6.34,
|
||||
"ic_positive_pct": 21.6,
|
||||
"mean_quintile_spread": 1.4646,
|
||||
"reliable": true
|
||||
}
|
||||
],
|
||||
"era_split": {
|
||||
"era_split_date": "2021-01-01",
|
||||
"note": "Diagnostic only \u2014 not a tuning input. Nested lookbacks are not OOS.",
|
||||
"full": {
|
||||
"high_52w": {
|
||||
"signal": "high_52w",
|
||||
"weeks": 84,
|
||||
"avg_cross_section": 2280.3,
|
||||
"mean_ic": 0.1111,
|
||||
"ic_t_stat": 6.41,
|
||||
"ic_positive_pct": 76.2,
|
||||
"mean_quintile_spread": -13.5104,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_12_1": {
|
||||
"signal": "mom_12_1",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 2277.5,
|
||||
"mean_ic": 0.0663,
|
||||
"ic_t_stat": 5.11,
|
||||
"ic_positive_pct": 74.7,
|
||||
"mean_quintile_spread": -10.1818,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_12_1_sector_resid": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 497.7,
|
||||
"mean_ic": 0.0578,
|
||||
"ic_t_stat": 2.34,
|
||||
"ic_positive_pct": 65.7,
|
||||
"mean_quintile_spread": 0.0245,
|
||||
"reliable": true
|
||||
},
|
||||
"trend_200": {
|
||||
"signal": "trend_200",
|
||||
"weeks": 85,
|
||||
"avg_cross_section": 2308.1,
|
||||
"mean_ic": 0.0546,
|
||||
"ic_t_stat": 4.3,
|
||||
"ic_positive_pct": 70.6,
|
||||
"mean_quintile_spread": -12.4094,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_6_1": {
|
||||
"signal": "mom_6_1",
|
||||
"weeks": 88,
|
||||
"avg_cross_section": 2375.7,
|
||||
"mean_ic": 0.0493,
|
||||
"ic_t_stat": 4.91,
|
||||
"ic_positive_pct": 70.5,
|
||||
"mean_quintile_spread": 3.4224,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_3_1": {
|
||||
"signal": "mom_3_1",
|
||||
"weeks": 90,
|
||||
"avg_cross_section": 2425.6,
|
||||
"mean_ic": 0.0363,
|
||||
"ic_t_stat": 3.58,
|
||||
"ic_positive_pct": 72.2,
|
||||
"mean_quintile_spread": 0.9289,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_12_1_sector_demeaned": {
|
||||
"signal": "mom_12_1_sector_demeaned",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 496.7,
|
||||
"mean_ic": 0.034,
|
||||
"ic_t_stat": 1.32,
|
||||
"ic_positive_pct": 62.9,
|
||||
"mean_quintile_spread": 0.0154,
|
||||
"reliable": true
|
||||
},
|
||||
"fip_id": {
|
||||
"signal": "fip_id",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 2277.5,
|
||||
"mean_ic": 0.0267,
|
||||
"ic_t_stat": 3.25,
|
||||
"ic_positive_pct": 67.5,
|
||||
"mean_quintile_spread": -0.0017,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_12_1_resid": {
|
||||
"signal": "mom_12_1_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 2277.5,
|
||||
"mean_ic": 0.0256,
|
||||
"ic_t_stat": 2.21,
|
||||
"ic_positive_pct": 65.1,
|
||||
"mean_quintile_spread": 10.1373,
|
||||
"reliable": true
|
||||
},
|
||||
"reversal_1m": {
|
||||
"signal": "reversal_1m",
|
||||
"weeks": 91,
|
||||
"avg_cross_section": 2443.2,
|
||||
"mean_ic": 0.003,
|
||||
"ic_t_stat": 0.31,
|
||||
"ic_positive_pct": 48.4,
|
||||
"mean_quintile_spread": -7.0153,
|
||||
"reliable": true
|
||||
},
|
||||
"vol_6m": {
|
||||
"signal": "vol_6m",
|
||||
"weeks": 88,
|
||||
"avg_cross_section": 2375.7,
|
||||
"mean_ic": -0.1226,
|
||||
"ic_t_stat": -6.34,
|
||||
"ic_positive_pct": 21.6,
|
||||
"mean_quintile_spread": 1.4646,
|
||||
"reliable": true
|
||||
}
|
||||
},
|
||||
"pre_2021": {
|
||||
"high_52w": {
|
||||
"signal": "high_52w",
|
||||
"weeks": 36,
|
||||
"avg_cross_section": 1422.2,
|
||||
"mean_ic": 0.0494,
|
||||
"ic_t_stat": 2.0,
|
||||
"ic_positive_pct": 63.9,
|
||||
"mean_quintile_spread": -31.5146,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_12_1": {
|
||||
"signal": "mom_12_1",
|
||||
"weeks": 36,
|
||||
"avg_cross_section": 1424.8,
|
||||
"mean_ic": 0.0413,
|
||||
"ic_t_stat": 2.94,
|
||||
"ic_positive_pct": 66.7,
|
||||
"mean_quintile_spread": -23.3935,
|
||||
"reliable": true
|
||||
},
|
||||
"trend_200": {
|
||||
"signal": "trend_200",
|
||||
"weeks": 38,
|
||||
"avg_cross_section": 1440.9,
|
||||
"mean_ic": 0.0322,
|
||||
"ic_t_stat": 2.23,
|
||||
"ic_positive_pct": 68.4,
|
||||
"mean_quintile_spread": -27.5982,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_12_1_resid": {
|
||||
"signal": "mom_12_1_resid",
|
||||
"weeks": 36,
|
||||
"avg_cross_section": 1424.8,
|
||||
"mean_ic": 0.0226,
|
||||
"ic_t_stat": 1.68,
|
||||
"ic_positive_pct": 69.4,
|
||||
"mean_quintile_spread": 23.4097,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_3_1": {
|
||||
"signal": "mom_3_1",
|
||||
"weeks": 42,
|
||||
"avg_cross_section": 1480.0,
|
||||
"mean_ic": 0.0217,
|
||||
"ic_t_stat": 1.84,
|
||||
"ic_positive_pct": 73.8,
|
||||
"mean_quintile_spread": 2.0077,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_6_1": {
|
||||
"signal": "mom_6_1",
|
||||
"weeks": 40,
|
||||
"avg_cross_section": 1461.2,
|
||||
"mean_ic": 0.0205,
|
||||
"ic_t_stat": 1.63,
|
||||
"ic_positive_pct": 65.0,
|
||||
"mean_quintile_spread": 7.4512,
|
||||
"reliable": true
|
||||
},
|
||||
"fip_id": {
|
||||
"signal": "fip_id",
|
||||
"weeks": 36,
|
||||
"avg_cross_section": 1424.8,
|
||||
"mean_ic": 0.0116,
|
||||
"ic_t_stat": 1.35,
|
||||
"ic_positive_pct": 58.3,
|
||||
"mean_quintile_spread": -0.0118,
|
||||
"reliable": true
|
||||
},
|
||||
"reversal_1m": {
|
||||
"signal": "reversal_1m",
|
||||
"weeks": 44,
|
||||
"avg_cross_section": 1501.4,
|
||||
"mean_ic": 0.0019,
|
||||
"ic_t_stat": 0.15,
|
||||
"ic_positive_pct": 50.0,
|
||||
"mean_quintile_spread": -14.4299,
|
||||
"reliable": true
|
||||
},
|
||||
"vol_6m": {
|
||||
"signal": "vol_6m",
|
||||
"weeks": 40,
|
||||
"avg_cross_section": 1461.2,
|
||||
"mean_ic": -0.056,
|
||||
"ic_t_stat": -2.16,
|
||||
"ic_positive_pct": 35.0,
|
||||
"mean_quintile_spread": 3.1335,
|
||||
"reliable": true
|
||||
}
|
||||
},
|
||||
"post_2021": {
|
||||
"high_52w": {
|
||||
"signal": "high_52w",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 2914.5,
|
||||
"mean_ic": 0.1375,
|
||||
"ic_t_stat": 4.34,
|
||||
"ic_positive_pct": 79.2,
|
||||
"mean_quintile_spread": -0.0856,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_12_1": {
|
||||
"signal": "mom_12_1",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 2913.0,
|
||||
"mean_ic": 0.0791,
|
||||
"ic_t_stat": 3.64,
|
||||
"ic_positive_pct": 77.1,
|
||||
"mean_quintile_spread": -0.0858,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_6_1": {
|
||||
"signal": "mom_6_1",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 3122.6,
|
||||
"mean_ic": 0.0779,
|
||||
"ic_t_stat": 4.36,
|
||||
"ic_positive_pct": 75.0,
|
||||
"mean_quintile_spread": -0.0781,
|
||||
"reliable": true
|
||||
},
|
||||
"trend_200": {
|
||||
"signal": "trend_200",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 3001.5,
|
||||
"mean_ic": 0.0585,
|
||||
"ic_t_stat": 2.68,
|
||||
"ic_positive_pct": 72.9,
|
||||
"mean_quintile_spread": -0.1129,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_12_1_sector_resid": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 497.7,
|
||||
"mean_ic": 0.0578,
|
||||
"ic_t_stat": 2.34,
|
||||
"ic_positive_pct": 65.7,
|
||||
"mean_quintile_spread": 0.0245,
|
||||
"reliable": true
|
||||
},
|
||||
"fip_id": {
|
||||
"signal": "fip_id",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 2913.0,
|
||||
"mean_ic": 0.0366,
|
||||
"ic_t_stat": 2.91,
|
||||
"ic_positive_pct": 70.8,
|
||||
"mean_quintile_spread": -0.0151,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_12_1_sector_demeaned": {
|
||||
"signal": "mom_12_1_sector_demeaned",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 496.7,
|
||||
"mean_ic": 0.034,
|
||||
"ic_t_stat": 1.32,
|
||||
"ic_positive_pct": 62.9,
|
||||
"mean_quintile_spread": 0.0154,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_3_1": {
|
||||
"signal": "mom_3_1",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 3234.2,
|
||||
"mean_ic": 0.0291,
|
||||
"ic_t_stat": 1.54,
|
||||
"ic_positive_pct": 66.7,
|
||||
"mean_quintile_spread": -0.0356,
|
||||
"reliable": true
|
||||
},
|
||||
"mom_12_1_resid": {
|
||||
"signal": "mom_12_1_resid",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 2913.0,
|
||||
"mean_ic": 0.0265,
|
||||
"ic_t_stat": 1.37,
|
||||
"ic_positive_pct": 66.7,
|
||||
"mean_quintile_spread": -0.0405,
|
||||
"reliable": true
|
||||
},
|
||||
"reversal_1m": {
|
||||
"signal": "reversal_1m",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 3315.8,
|
||||
"mean_ic": -0.0126,
|
||||
"ic_t_stat": -0.72,
|
||||
"ic_positive_pct": 45.8,
|
||||
"mean_quintile_spread": -0.0387,
|
||||
"reliable": true
|
||||
},
|
||||
"vol_6m": {
|
||||
"signal": "vol_6m",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 3122.6,
|
||||
"mean_ic": -0.1623,
|
||||
"ic_t_stat": -4.94,
|
||||
"ic_positive_pct": 22.9,
|
||||
"mean_quintile_spread": 0.0059,
|
||||
"reliable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"params": {
|
||||
"step_days": 5,
|
||||
"step_sessions": 5,
|
||||
"entry_cadence": "weekly",
|
||||
"signal_eval_cadence": "weekly",
|
||||
"horizon_days": 30,
|
||||
"min_lookback": 60,
|
||||
"cost_per_side_pct": 0.1,
|
||||
"target_model": "production_gtl",
|
||||
"target_model_label": "Live GTL (production)",
|
||||
"is_production_target_model": true,
|
||||
"production_reentry_policy": "gate_reset",
|
||||
"liquid_breadth_top_n": null,
|
||||
"liquid_min_price": null,
|
||||
"signal_eval_only": true
|
||||
},
|
||||
"tickers": 4655,
|
||||
"generated_at_run": "2026-07-19T08:27:27.206022+00:00"
|
||||
},
|
||||
"verdict": "PENDING_HUMAN",
|
||||
"verdict_detail": "Harness complete \u2014 human interprets relative IC / era stability. No production retune from this artifact.",
|
||||
"human_next": "- Compare sector residual vs market residual across eras.\n- If pre-2021 IC collapses, park Task 1 wire-in.\n- Do not retune production knobs on deep history levels.",
|
||||
"report_path": "reports/history-depth-20260719-103315.json"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
# History-depth extension (Tier-1 alpha research)
|
||||
|
||||
**Status:** **RUN COMPLETE — human interpretation below.**
|
||||
**Branch:** `research/earnings-gap-and-sue` (MacBook commit `f6e0ca7`)
|
||||
**Authoritative artifact:** `reports/history-depth-20260719-103315.json`
|
||||
**Production impact:** none. **Do not retune any production knob on deep history.**
|
||||
|
||||
---
|
||||
|
||||
## Pre-registration (locked before rebuild)
|
||||
|
||||
### Motivation
|
||||
|
||||
All current conclusions rest on ~35 non-overlapping weekly windows in essentially
|
||||
one post-2021 regime. Extending history toward max Alpaca daily-bar depth adds
|
||||
the 2018 vol shock and full 2020 crash (where the feed allows).
|
||||
|
||||
### Protocol
|
||||
|
||||
1. **Empirical coverage first** — bars per calendar year per symbol; document
|
||||
where the feed thins out. Do **not** assume a uniform start date.
|
||||
2. **Rebuild the research snapshot completely** from prod source + max history
|
||||
per symbol (`Adjustment.SPLIT`, ~200 req/min pacing via existing extender).
|
||||
3. **Race guard (rule 6)** — refuse analysis until completion manifest is
|
||||
`complete=true` and live counts match.
|
||||
4. **Re-run full signal harness** (all existing signals incl. sector residual /
|
||||
SUE if present) on the extended window.
|
||||
5. **Report per signal:** mean IC, t, window count, and **era split**
|
||||
(pre-/post-2021) — diagnostic only, **not a tuning input**.
|
||||
6. **Log prominently:** survivorship bias grows with depth (today’s constituents
|
||||
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
|
||||
**relative** signal comparisons and IC stability, not levels.
|
||||
7. **Do not retune** production knobs. If a knob’s confirmation looks
|
||||
overturned on deep history → report only; human decides.
|
||||
|
||||
### Success / interpretation (not promotion of a new signal)
|
||||
|
||||
| outcome | meaning |
|
||||
|---|---|
|
||||
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
|
||||
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
|
||||
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
|
||||
| Any production knob looks worse deep | report; no auto-retune |
|
||||
|
||||
---
|
||||
|
||||
## Data provenance
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| Snapshot | MacBook `research.sqlite` |
|
||||
| Manifest `complete` | **true** (finished 2026-07-19T08:19Z) |
|
||||
| Live counts match | yes — 4655 tickers / 6,609,926 OHLCV / 4149 rank_only |
|
||||
| `history_days` | 5000 |
|
||||
| fetch_ok / fail | 4152 / 0 |
|
||||
| Race guard | **pass** |
|
||||
|
||||
> **SURVIVORSHIP BIAS:** today’s constituents backfilled historically. Absolute
|
||||
> Sharpe/CAGR levels on deep history are optimistic. Use **relative** signal IC
|
||||
> comparisons and era stability only — not levels.
|
||||
|
||||
**Coverage JSON was empty in the auto-written doc** (harness-only phase after
|
||||
rebuild). Manifest is the race-guard source of truth for this run.
|
||||
|
||||
Earlier MacBook files `history-depth-20260719-093853` … `095156` are intermediate
|
||||
/ incomplete passes — **do not cite**. Only **103315** is authoritative.
|
||||
|
||||
---
|
||||
|
||||
## Results (authoritative: 103315)
|
||||
|
||||
### Full-window signal IC (broad research universe, deep bars)
|
||||
|
||||
| signal | mean_ic | t | weeks | avg_N | notes |
|
||||
|---|---:|---:|---:|---:|---|
|
||||
| high_52w | **0.111** | **6.41** | 84 | 2280 | strong on deep breadth |
|
||||
| mom_12_1 | **0.066** | **5.11** | 83 | 2278 | raw momentum strong |
|
||||
| trend_200 | 0.055 | 4.30 | 85 | 2308 | |
|
||||
| mom_6_1 | 0.049 | 4.91 | 88 | 2376 | |
|
||||
| mom_3_1 | 0.036 | 3.58 | 90 | 2426 | |
|
||||
| fip_id | **+0.027** | **3.25** | 83 | 2278 | **sign flip vs prod fingerprint** |
|
||||
| mom_12_1_resid | 0.026 | 2.21 | 83 | 2278 | market residual still + but weaker than raw |
|
||||
| reversal_1m | ~0 | 0.31 | 91 | 2443 | dead |
|
||||
| vol_6m | **−0.123** | **−6.34** | 88 | 2376 | low-vol anomaly strong |
|
||||
| mom_12_1_sector_resid | 0.058 | 2.34 | **35** | **498** | **not deep-sample — see caveats** |
|
||||
| mom_12_1_sector_demeaned | 0.034 | 1.32 | **35** | **497** | same short fingerprint |
|
||||
|
||||
### Era split (diagnostic only — not a tuning input)
|
||||
|
||||
| signal | pre-2021 IC / t / w / N | post-2021 IC / t / w / N |
|
||||
|---|---|---|
|
||||
| mom_12_1 | +0.041 / 2.94 / 36 / 1425 | +0.079 / 3.64 / 48 / 2913 |
|
||||
| mom_12_1_resid | +0.023 / 1.68 / 36 / 1425 | +0.027 / 1.37 / 48 / 2913 |
|
||||
| fip_id | +0.012 / 1.35 / 36 / 1425 | +0.037 / 2.91 / 48 / 2913 |
|
||||
| vol_6m | −0.056 / −2.16 / 40 / 1461 | −0.162 / −4.94 / 48 / 3123 |
|
||||
| high_52w | +0.049 / 2.0 / 36 / 1422 | +0.138 / 4.34 / 48 / 2915 |
|
||||
| **sector_resid** | **absent** | 0.058 / 2.34 / 35 / 498 (short only) |
|
||||
| **sector_demeaned** | **absent** | 0.034 / 1.32 / 35 / 497 (short only) |
|
||||
|
||||
---
|
||||
|
||||
## Critical caveats (must read)
|
||||
|
||||
### 1. Sector residual did **not** get a deep-history stress test
|
||||
|
||||
`mom_12_1_sector_resid` / `_demeaned` still show **exactly** the Task‑1 short-window
|
||||
fingerprint: **35 weeks, N≈498, IC 0.0578, t 2.34**.
|
||||
|
||||
On the same run, raw `mom_12_1` has **83 weeks, N≈2278**. So depth worked for
|
||||
price-only signals, but sector residual is still limited to the **~505 labeled
|
||||
prod names × short factor calendar** (sector map only covers prod; and/or sector
|
||||
ETF / two-factor path did not extend usable residual weeks).
|
||||
|
||||
**Pre-registered rule:** “Sector residual collapses pre-2021 → PARK Task 1
|
||||
wire-in.” Pre-2021 sector residual is **absent** from the era table. That is a
|
||||
**PARK**, not a confirmation of the short-window PROMOTE.
|
||||
|
||||
Do **not** claim “sector residual beats market residual on deep history” from
|
||||
this table — the two rows are **not the same cross-section or window count**.
|
||||
|
||||
### 2. `fip_id` sign flips vs production fingerprint
|
||||
|
||||
| sample | fip mean IC | t |
|
||||
|---|---:|---:|
|
||||
| Prod 505, ~5y (fingerprint) | **−0.045** | −2.91 |
|
||||
| Research breadth, deep (this run) | **+0.027** | +3.25 |
|
||||
|
||||
This does **not** authorize resurrecting unconditional FIP as a book filter. It
|
||||
confirms earlier Phase‑B caution: FIP edge is **universe- and sample-dependent**.
|
||||
Production display card can stay context-only. Nested lookbacks still not OOS.
|
||||
|
||||
### 3. Market residual vs raw momentum on deep breadth
|
||||
|
||||
On deep broad IC, **raw 12‑1 (0.066 / t 5.1) ≫ market residual (0.026 / t 2.2)**.
|
||||
That does **not** by itself overturn production residual ranking (book A/B was
|
||||
on 505 + GTL gate, not pure factor IC), but it is a yellow flag for “residual is
|
||||
always the better rank key” stories on broad history. **No auto-retune.**
|
||||
|
||||
### 4. Low-vol anomaly is the cleanest deep-history result
|
||||
|
||||
`vol_6m` IC −0.12 / t −6.3 full; stronger post-2021. Consistent sign across eras.
|
||||
Production already blends **high**-vol (not low-vol) into the 80/20 rank — this
|
||||
report does not change that without a separate A/B. Flag for human awareness only.
|
||||
|
||||
---
|
||||
|
||||
## Verdicts (vs pre-registration)
|
||||
|
||||
| question | verdict |
|
||||
|---|---|
|
||||
| Task 1 sector residual wire-in | **PARK** — no pre-2021 sector residual; deep-sample IC not established; short-window PROMOTE stays “human design only,” **not strengthened** by this run |
|
||||
| Sector demean | still **DEAD** for promotion (t 1.32, short only) |
|
||||
| SUE | **not re-scored here** (no `sue_latest` in harness table) — leave Task 2 **PARK** until full earnings backfill |
|
||||
| fip unconditional book filter | remains **rejected / parked** despite sign flip on broad deep sample |
|
||||
| Production residual / 80/20 / trail knobs | **no retune** from this report |
|
||||
| Overall Task 3 | **COMPLETE as diagnostic** — payload is relative IC + caveats above |
|
||||
|
||||
---
|
||||
|
||||
## What a human must decide next
|
||||
|
||||
1. **Sector residual:** keep research-only until either
|
||||
(a) sector ETF + sector map cover the full deep window **and** IC is re-run
|
||||
with weeks ≫ 35 on a documented universe, or
|
||||
(b) explicitly accept short-window-only evidence (weaker case).
|
||||
2. **Do not** merge sector residual into production from this depth run.
|
||||
3. **Do not** retune residual vs raw, FIP, or vol blend from these IC tables
|
||||
without a pre-registered book A/B on the intended universe.
|
||||
4. Optional follow-up: extend sector ETF history + sector labels to nasdaq_all,
|
||||
re-run **only** sector residual IC on deep research.sqlite with race guard.
|
||||
5. Optional: finish earnings backfill (48→506) and re-run SUE; depth alone did
|
||||
not include SUE.
|
||||
|
||||
---
|
||||
|
||||
## Artifacts
|
||||
|
||||
| file | role |
|
||||
|---|---|
|
||||
| `reports/history-depth-20260719-103315.json` | **authoritative** |
|
||||
| `reports/history-depth-20260719-103315.md` | companion dump |
|
||||
| `reports/history-depth-20260719-093853` … `095156` | **ignore** (partial) |
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"generated_at": "2026-07-19T14:07:37.458454",
|
||||
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/research.sqlite",
|
||||
"snapshot_meta": {
|
||||
"prod_universe_n": 506,
|
||||
"price_symbols_n": 4654,
|
||||
"raw_candidates": 2389258,
|
||||
"liquid_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"short_start": "2022-07-01",
|
||||
"long_start": "2016-07-01"
|
||||
},
|
||||
"strategy": {
|
||||
"note": "Live production knobs \u2014 no modifications",
|
||||
"momentum": "residual_12_1 gate 80",
|
||||
"rank": "residual_high_vol_blend_80_20",
|
||||
"fill_mode": "close",
|
||||
"cost_per_side": 0.001,
|
||||
"exit": {
|
||||
"mode": "atr_trailing",
|
||||
"trailing_pct": 12.0,
|
||||
"atr_multiplier": 3.0,
|
||||
"hold_days": 30
|
||||
},
|
||||
"max_positions": 10,
|
||||
"risk_per_trade": 0.01,
|
||||
"reentry": "gate_reset"
|
||||
},
|
||||
"arms": [
|
||||
{
|
||||
"id": "A_prod_4y_505",
|
||||
"label": "Prod book \u00b7 ~4y \u00b7 505 only",
|
||||
"start": "2022-07-01",
|
||||
"universe": "prod_505",
|
||||
"n_candidates": 81626,
|
||||
"n_qualified_longs": 1448,
|
||||
"fill_mode": "close",
|
||||
"ranking_key": "residual_high_vol_blend_80_20_score",
|
||||
"exit_policy": "atr_trail3",
|
||||
"hold_days": 30,
|
||||
"sharpe": 1.32,
|
||||
"sharpe_se": 0.49,
|
||||
"cagr_pct": 31.8,
|
||||
"max_drawdown_pct": 18.9,
|
||||
"total_return_pct": 204.7,
|
||||
"calmar": 1.68,
|
||||
"trades": 374,
|
||||
"win_rate": 35.6,
|
||||
"n_returns": 1009,
|
||||
"psr": 0.9965,
|
||||
"start_date": "2022-07-01",
|
||||
"end_date": "2026-07-13",
|
||||
"spy_return_pct": 96.5,
|
||||
"final_equity": 30467.86
|
||||
},
|
||||
{
|
||||
"id": "B_prod_4y_505_liquid",
|
||||
"label": "Prod book \u00b7 ~4y \u00b7 505 + liquid top-1500",
|
||||
"start": "2022-07-01",
|
||||
"universe": "prod_plus_liquid",
|
||||
"n_candidates": 267579,
|
||||
"n_qualified_longs": 6587,
|
||||
"fill_mode": "close",
|
||||
"ranking_key": "residual_high_vol_blend_80_20_score",
|
||||
"exit_policy": "atr_trail3",
|
||||
"hold_days": 30,
|
||||
"sharpe": 0.14,
|
||||
"sharpe_se": 0.499,
|
||||
"cagr_pct": -1.8,
|
||||
"max_drawdown_pct": 55.3,
|
||||
"total_return_pct": -7.1,
|
||||
"calmar": -0.03,
|
||||
"trades": 706,
|
||||
"win_rate": 29.3,
|
||||
"n_returns": 1013,
|
||||
"psr": 0.6107,
|
||||
"start_date": "2022-07-01",
|
||||
"end_date": "2026-07-17",
|
||||
"spy_return_pct": 95.0,
|
||||
"final_equity": 9287.52
|
||||
},
|
||||
{
|
||||
"id": "C_prod_2016_505",
|
||||
"label": "Prod book \u00b7 since 2016-07 \u00b7 505 only",
|
||||
"start": "2016-07-01",
|
||||
"universe": "prod_505",
|
||||
"n_candidates": 190179,
|
||||
"n_qualified_longs": 2450,
|
||||
"fill_mode": "close",
|
||||
"ranking_key": "residual_high_vol_blend_80_20_score",
|
||||
"exit_policy": "atr_trail3",
|
||||
"hold_days": 30,
|
||||
"sharpe": 0.88,
|
||||
"sharpe_se": 0.314,
|
||||
"cagr_pct": 16.7,
|
||||
"max_drawdown_pct": 24.4,
|
||||
"total_return_pct": 369.4,
|
||||
"calmar": 0.68,
|
||||
"trades": 763,
|
||||
"win_rate": 36.7,
|
||||
"n_returns": 2519,
|
||||
"psr": 0.9975,
|
||||
"start_date": "2016-07-01",
|
||||
"end_date": "2026-07-13",
|
||||
"spy_return_pct": 256.9,
|
||||
"final_equity": 46938.66
|
||||
},
|
||||
{
|
||||
"id": "D_prod_2016_505_liquid",
|
||||
"label": "Prod book \u00b7 since 2016-07 \u00b7 505 + liquid top-1500",
|
||||
"start": "2016-07-01",
|
||||
"universe": "prod_plus_liquid",
|
||||
"n_candidates": 649305,
|
||||
"n_qualified_longs": 11551,
|
||||
"fill_mode": "close",
|
||||
"ranking_key": "residual_high_vol_blend_80_20_score",
|
||||
"exit_policy": "atr_trail3",
|
||||
"hold_days": 30,
|
||||
"sharpe": -0.06,
|
||||
"sharpe_se": 0.316,
|
||||
"cagr_pct": -7.0,
|
||||
"max_drawdown_pct": 73.9,
|
||||
"total_return_pct": -52.0,
|
||||
"calmar": -0.1,
|
||||
"trades": 1567,
|
||||
"win_rate": 28.0,
|
||||
"n_returns": 2523,
|
||||
"psr": 0.4287,
|
||||
"start_date": "2016-07-01",
|
||||
"end_date": "2026-07-17",
|
||||
"spy_return_pct": 254.1,
|
||||
"final_equity": 4800.5
|
||||
}
|
||||
],
|
||||
"survivorship_banner": "Today's constituents backfilled. Relative arm comparison only.",
|
||||
"pending_human": true
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Production book × universe × horizon — results
|
||||
|
||||
Generated: `2026-07-19T14:07:37.458454`
|
||||
|
||||
> Survivorship: today's constituents backfilled. Compare arms relatively; do not treat deep CAGR/Sharpe levels as deployable forecasts.
|
||||
|
||||
## Arms
|
||||
|
||||
| arm | universe | entry start | Sharpe | SE | CAGR % | max DD % | ret % | trades | qual longs | span |
|
||||
|---|---|---|---:|---:|---:|---:|---:|---:|---:|---|
|
||||
| A_prod_4y_505 | prod_505 | 2022-07-01 | 1.32 | 0.49 | 31.8 | 18.9 | 204.7 | 374 | 1448 | 2022-07-01→2026-07-13 |
|
||||
| B_prod_4y_505_liquid | prod_plus_liquid | 2022-07-01 | 0.14 | 0.499 | -1.8 | 55.3 | -7.1 | 706 | 6587 | 2022-07-01→2026-07-17 |
|
||||
| C_prod_2016_505 | prod_505 | 2016-07-01 | 0.88 | 0.314 | 16.7 | 24.4 | 369.4 | 763 | 2450 | 2016-07-01→2026-07-13 |
|
||||
| D_prod_2016_505_liquid | prod_plus_liquid | 2016-07-01 | -0.06 | 0.316 | -7.0 | 73.9 | -52.0 | 1567 | 11551 | 2016-07-01→2026-07-17 |
|
||||
|
||||
## Config (production, unchanged)
|
||||
|
||||
```json
|
||||
{
|
||||
"note": "Live production knobs \u2014 no modifications",
|
||||
"momentum": "residual_12_1 gate 80",
|
||||
"rank": "residual_high_vol_blend_80_20",
|
||||
"fill_mode": "close",
|
||||
"cost_per_side": 0.001,
|
||||
"exit": {
|
||||
"mode": "atr_trailing",
|
||||
"trailing_pct": 12.0,
|
||||
"atr_multiplier": 3.0,
|
||||
"hold_days": 30
|
||||
},
|
||||
"max_positions": 10,
|
||||
"risk_per_trade": 0.01,
|
||||
"reentry": "gate_reset"
|
||||
}
|
||||
```
|
||||
|
||||
## Snapshot
|
||||
|
||||
```json
|
||||
{
|
||||
"prod_universe_n": 506,
|
||||
"price_symbols_n": 4654,
|
||||
"raw_candidates": 2389258,
|
||||
"liquid_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"short_start": "2022-07-01",
|
||||
"long_start": "2016-07-01"
|
||||
}
|
||||
```
|
||||
|
||||
PENDING_HUMAN — descriptive matrix only; no auto promotion.
|
||||
|
||||
JSON: `reports/prod-book-universe-horizon-20260719-140737.json`
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
{
|
||||
"generated_at": "2026-07-19T11:33:19.102779",
|
||||
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/research.sqlite",
|
||||
"pre_registration": {
|
||||
"iron_ic": 0.03,
|
||||
"min_weeks_deep": 50,
|
||||
"liquid_breadth": 1500,
|
||||
"min_price": 5.0,
|
||||
"rule": "PASS = |IC|>=0.03, +sign, reliable, weeks>=50, t>=resid on same CS, era signs both +"
|
||||
},
|
||||
"step1": {
|
||||
"skipped": true,
|
||||
"sanity": {
|
||||
"passed": true,
|
||||
"megacap": {
|
||||
"AAPL": {
|
||||
"symbol": "AAPL",
|
||||
"bars": 2649,
|
||||
"min_date": "2016-01-04",
|
||||
"max_date": "2026-07-17"
|
||||
},
|
||||
"MSFT": {
|
||||
"symbol": "MSFT",
|
||||
"bars": 2649,
|
||||
"min_date": "2016-01-04",
|
||||
"max_date": "2026-07-17"
|
||||
},
|
||||
"JPM": {
|
||||
"symbol": "JPM",
|
||||
"bars": 2649,
|
||||
"min_date": "2016-01-04",
|
||||
"max_date": "2026-07-17"
|
||||
},
|
||||
"XOM": {
|
||||
"symbol": "XOM",
|
||||
"bars": 2649,
|
||||
"min_date": "2016-01-04",
|
||||
"max_date": "2026-07-17"
|
||||
},
|
||||
"JNJ": {
|
||||
"symbol": "JNJ",
|
||||
"bars": 2649,
|
||||
"min_date": "2016-01-04",
|
||||
"max_date": "2026-07-17"
|
||||
}
|
||||
},
|
||||
"megacap_ok": true,
|
||||
"megacap_reasons": [],
|
||||
"feed_floor": "2016-01-04",
|
||||
"spy_benchmark": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"old_shallow_floor": "2020-01-01",
|
||||
"sector_etfs": {
|
||||
"XLB": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLC": {
|
||||
"n": 2030,
|
||||
"min": "2018-06-19",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLE": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLF": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLI": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLK": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLP": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLRE": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLU": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLV": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLY": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
}
|
||||
},
|
||||
"sector_etfs_deep_count": 11,
|
||||
"sector_etfs_ok": true,
|
||||
"sector_etf_reasons": [],
|
||||
"still_shallow_count": 2882,
|
||||
"still_shallow_sample": [
|
||||
"AACB",
|
||||
"AACBR",
|
||||
"AACBU",
|
||||
"AACI",
|
||||
"AACIU",
|
||||
"AACIW",
|
||||
"AACO",
|
||||
"AACOU",
|
||||
"AACOW",
|
||||
"AACP",
|
||||
"AACPR",
|
||||
"AACPU",
|
||||
"AACPW",
|
||||
"AAPG",
|
||||
"AARD",
|
||||
"ABAT",
|
||||
"ABCL",
|
||||
"ABLV",
|
||||
"ABLVW",
|
||||
"ABNB"
|
||||
],
|
||||
"still_shallow_note": "Remaining 'shallow' names are mostly post-2017 IPOs/listings \u2014 expected, not a two-tier defect.",
|
||||
"xlc_note": "XLC lists mid-2018 \u2192 Communication Services residual coverage from ~mid-2019.",
|
||||
"feed_note": "Empirical Alpaca floor observed via SPY: 2016-01-04 (n=2649). Calendar history_days=5000 is a request cap, not a guarantee \u2014 sanity grades against the feed floor, not 5000 calendar days.",
|
||||
"target_history_days": 5000
|
||||
}
|
||||
},
|
||||
"harness": {
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"survivorship_banner": "SURVIVORSHIP BIAS: today's constituents backfilled. Relative IC only \u2014 not levels.",
|
||||
"signal_eval": [
|
||||
{
|
||||
"signal": "high_52w",
|
||||
"weeks": 84,
|
||||
"avg_cross_section": 1499.2,
|
||||
"mean_ic": 0.0761,
|
||||
"ic_t_stat": 4.46,
|
||||
"ic_positive_pct": 71.4,
|
||||
"mean_quintile_spread": 0.0118,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2564.4,
|
||||
"avg_eligible_pre_mask": 2047.0,
|
||||
"mask_binds_pct": 94.0
|
||||
},
|
||||
{
|
||||
"signal": "trend_200",
|
||||
"weeks": 85,
|
||||
"avg_cross_section": 1499.6,
|
||||
"mean_ic": 0.0371,
|
||||
"ic_t_stat": 2.63,
|
||||
"ic_positive_pct": 62.4,
|
||||
"mean_quintile_spread": -0.7839,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2583.0,
|
||||
"avg_eligible_pre_mask": 2065.8,
|
||||
"mask_binds_pct": 96.5
|
||||
},
|
||||
{
|
||||
"signal": "mom_12_1",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 1499.4,
|
||||
"mean_ic": 0.0355,
|
||||
"ic_t_stat": 2.41,
|
||||
"ic_positive_pct": 62.7,
|
||||
"mean_quintile_spread": 0.0141,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2553.0,
|
||||
"avg_eligible_pre_mask": 2045.4,
|
||||
"mask_binds_pct": 95.2
|
||||
},
|
||||
{
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 480.0,
|
||||
"mean_ic": 0.0268,
|
||||
"ic_t_stat": 1.69,
|
||||
"ic_positive_pct": 60.2,
|
||||
"mean_quintile_spread": 0.0126,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 481.2,
|
||||
"avg_eligible_pre_mask": 480.0,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
{
|
||||
"signal": "mom_3_1",
|
||||
"weeks": 90,
|
||||
"avg_cross_section": 1498.6,
|
||||
"mean_ic": 0.0256,
|
||||
"ic_t_stat": 2.24,
|
||||
"ic_positive_pct": 61.1,
|
||||
"mean_quintile_spread": -0.0048,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2679.7,
|
||||
"avg_eligible_pre_mask": 2128.6,
|
||||
"mask_binds_pct": 95.6
|
||||
},
|
||||
{
|
||||
"signal": "reversal_1m",
|
||||
"weeks": 89,
|
||||
"avg_cross_section": 1499.0,
|
||||
"mean_ic": 0.0156,
|
||||
"ic_t_stat": 1.37,
|
||||
"ic_positive_pct": 59.6,
|
||||
"mean_quintile_spread": -0.7882,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2700.0,
|
||||
"avg_eligible_pre_mask": 2085.8,
|
||||
"mask_binds_pct": 94.5
|
||||
},
|
||||
{
|
||||
"signal": "mom_12_1_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 1499.4,
|
||||
"mean_ic": 0.0148,
|
||||
"ic_t_stat": 1.02,
|
||||
"ic_positive_pct": 57.8,
|
||||
"mean_quintile_spread": 0.0181,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2553.0,
|
||||
"avg_eligible_pre_mask": 2045.4,
|
||||
"mask_binds_pct": 95.2
|
||||
},
|
||||
{
|
||||
"signal": "mom_6_1",
|
||||
"weeks": 88,
|
||||
"avg_cross_section": 1499.3,
|
||||
"mean_ic": 0.0101,
|
||||
"ic_t_stat": 0.91,
|
||||
"ic_positive_pct": 58.0,
|
||||
"mean_quintile_spread": 0.0108,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2641.3,
|
||||
"avg_eligible_pre_mask": 2098.8,
|
||||
"mask_binds_pct": 95.5
|
||||
},
|
||||
{
|
||||
"signal": "mom_12_1_sector_demeaned",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 483.2,
|
||||
"mean_ic": 0.0076,
|
||||
"ic_t_stat": 0.46,
|
||||
"ic_positive_pct": 55.4,
|
||||
"mean_quintile_spread": 0.0054,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 484.4,
|
||||
"avg_eligible_pre_mask": 483.2,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
{
|
||||
"signal": "fip_id",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 1499.4,
|
||||
"mean_ic": -0.0184,
|
||||
"ic_t_stat": -2.11,
|
||||
"ic_positive_pct": 45.8,
|
||||
"mean_quintile_spread": -0.0059,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2553.0,
|
||||
"avg_eligible_pre_mask": 2045.4,
|
||||
"mask_binds_pct": 95.2
|
||||
},
|
||||
{
|
||||
"signal": "vol_6m",
|
||||
"weeks": 88,
|
||||
"avg_cross_section": 1499.3,
|
||||
"mean_ic": -0.0704,
|
||||
"ic_t_stat": -3.14,
|
||||
"ic_positive_pct": 35.2,
|
||||
"mean_quintile_spread": 0.0051,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2641.3,
|
||||
"avg_eligible_pre_mask": 2098.8,
|
||||
"mask_binds_pct": 95.5
|
||||
}
|
||||
],
|
||||
"signal_eval_by_name": {
|
||||
"high_52w": {
|
||||
"signal": "high_52w",
|
||||
"weeks": 84,
|
||||
"avg_cross_section": 1499.2,
|
||||
"mean_ic": 0.0761,
|
||||
"ic_t_stat": 4.46,
|
||||
"ic_positive_pct": 71.4,
|
||||
"mean_quintile_spread": 0.0118,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2564.4,
|
||||
"avg_eligible_pre_mask": 2047.0,
|
||||
"mask_binds_pct": 94.0
|
||||
},
|
||||
"trend_200": {
|
||||
"signal": "trend_200",
|
||||
"weeks": 85,
|
||||
"avg_cross_section": 1499.6,
|
||||
"mean_ic": 0.0371,
|
||||
"ic_t_stat": 2.63,
|
||||
"ic_positive_pct": 62.4,
|
||||
"mean_quintile_spread": -0.7839,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2583.0,
|
||||
"avg_eligible_pre_mask": 2065.8,
|
||||
"mask_binds_pct": 96.5
|
||||
},
|
||||
"mom_12_1": {
|
||||
"signal": "mom_12_1",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 1499.4,
|
||||
"mean_ic": 0.0355,
|
||||
"ic_t_stat": 2.41,
|
||||
"ic_positive_pct": 62.7,
|
||||
"mean_quintile_spread": 0.0141,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2553.0,
|
||||
"avg_eligible_pre_mask": 2045.4,
|
||||
"mask_binds_pct": 95.2
|
||||
},
|
||||
"mom_12_1_sector_resid": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 480.0,
|
||||
"mean_ic": 0.0268,
|
||||
"ic_t_stat": 1.69,
|
||||
"ic_positive_pct": 60.2,
|
||||
"mean_quintile_spread": 0.0126,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 481.2,
|
||||
"avg_eligible_pre_mask": 480.0,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"mom_3_1": {
|
||||
"signal": "mom_3_1",
|
||||
"weeks": 90,
|
||||
"avg_cross_section": 1498.6,
|
||||
"mean_ic": 0.0256,
|
||||
"ic_t_stat": 2.24,
|
||||
"ic_positive_pct": 61.1,
|
||||
"mean_quintile_spread": -0.0048,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2679.7,
|
||||
"avg_eligible_pre_mask": 2128.6,
|
||||
"mask_binds_pct": 95.6
|
||||
},
|
||||
"reversal_1m": {
|
||||
"signal": "reversal_1m",
|
||||
"weeks": 89,
|
||||
"avg_cross_section": 1499.0,
|
||||
"mean_ic": 0.0156,
|
||||
"ic_t_stat": 1.37,
|
||||
"ic_positive_pct": 59.6,
|
||||
"mean_quintile_spread": -0.7882,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2700.0,
|
||||
"avg_eligible_pre_mask": 2085.8,
|
||||
"mask_binds_pct": 94.5
|
||||
},
|
||||
"mom_12_1_resid": {
|
||||
"signal": "mom_12_1_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 1499.4,
|
||||
"mean_ic": 0.0148,
|
||||
"ic_t_stat": 1.02,
|
||||
"ic_positive_pct": 57.8,
|
||||
"mean_quintile_spread": 0.0181,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2553.0,
|
||||
"avg_eligible_pre_mask": 2045.4,
|
||||
"mask_binds_pct": 95.2
|
||||
},
|
||||
"mom_6_1": {
|
||||
"signal": "mom_6_1",
|
||||
"weeks": 88,
|
||||
"avg_cross_section": 1499.3,
|
||||
"mean_ic": 0.0101,
|
||||
"ic_t_stat": 0.91,
|
||||
"ic_positive_pct": 58.0,
|
||||
"mean_quintile_spread": 0.0108,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2641.3,
|
||||
"avg_eligible_pre_mask": 2098.8,
|
||||
"mask_binds_pct": 95.5
|
||||
},
|
||||
"mom_12_1_sector_demeaned": {
|
||||
"signal": "mom_12_1_sector_demeaned",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 483.2,
|
||||
"mean_ic": 0.0076,
|
||||
"ic_t_stat": 0.46,
|
||||
"ic_positive_pct": 55.4,
|
||||
"mean_quintile_spread": 0.0054,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 484.4,
|
||||
"avg_eligible_pre_mask": 483.2,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"fip_id": {
|
||||
"signal": "fip_id",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 1499.4,
|
||||
"mean_ic": -0.0184,
|
||||
"ic_t_stat": -2.11,
|
||||
"ic_positive_pct": 45.8,
|
||||
"mean_quintile_spread": -0.0059,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2553.0,
|
||||
"avg_eligible_pre_mask": 2045.4,
|
||||
"mask_binds_pct": 95.2
|
||||
},
|
||||
"vol_6m": {
|
||||
"signal": "vol_6m",
|
||||
"weeks": 88,
|
||||
"avg_cross_section": 1499.3,
|
||||
"mean_ic": -0.0704,
|
||||
"ic_t_stat": -3.14,
|
||||
"ic_positive_pct": 35.2,
|
||||
"mean_quintile_spread": 0.0051,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 2641.3,
|
||||
"avg_eligible_pre_mask": 2098.8,
|
||||
"mask_binds_pct": 95.5
|
||||
}
|
||||
},
|
||||
"era_split": {
|
||||
"era_split_date": "2021-01-01",
|
||||
"note": "Diagnostic only \u2014 not a tuning input.",
|
||||
"pre_2021": {
|
||||
"high_52w": {
|
||||
"signal": "high_52w",
|
||||
"weeks": 36,
|
||||
"avg_cross_section": 1498.1,
|
||||
"mean_ic": 0.0535,
|
||||
"ic_t_stat": 2.26,
|
||||
"ic_positive_pct": 69.4,
|
||||
"mean_quintile_spread": -0.0027,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 1894.7,
|
||||
"avg_eligible_pre_mask": 1670.6,
|
||||
"mask_binds_pct": 86.1
|
||||
},
|
||||
"trend_200": {
|
||||
"signal": "trend_200",
|
||||
"weeks": 38,
|
||||
"avg_cross_section": 1499.1,
|
||||
"mean_ic": 0.0373,
|
||||
"ic_t_stat": 1.99,
|
||||
"ic_positive_pct": 65.8,
|
||||
"mean_quintile_spread": -1.7748,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 1913.9,
|
||||
"avg_eligible_pre_mask": 1687.4,
|
||||
"mask_binds_pct": 92.1
|
||||
},
|
||||
"mom_12_1": {
|
||||
"signal": "mom_12_1",
|
||||
"weeks": 36,
|
||||
"avg_cross_section": 1498.6,
|
||||
"mean_ic": 0.037,
|
||||
"ic_t_stat": 1.92,
|
||||
"ic_positive_pct": 63.9,
|
||||
"mean_quintile_spread": 0.0096,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 1897.3,
|
||||
"avg_eligible_pre_mask": 1673.1,
|
||||
"mask_binds_pct": 88.9
|
||||
},
|
||||
"mom_3_1": {
|
||||
"signal": "mom_3_1",
|
||||
"weeks": 42,
|
||||
"avg_cross_section": 1497.0,
|
||||
"mean_ic": 0.0273,
|
||||
"ic_t_stat": 2.03,
|
||||
"ic_positive_pct": 64.3,
|
||||
"mean_quintile_spread": -0.027,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 1954.5,
|
||||
"avg_eligible_pre_mask": 1717.3,
|
||||
"mask_binds_pct": 90.5
|
||||
},
|
||||
"mom_12_1_resid": {
|
||||
"signal": "mom_12_1_resid",
|
||||
"weeks": 36,
|
||||
"avg_cross_section": 1498.6,
|
||||
"mean_ic": 0.0216,
|
||||
"ic_t_stat": 1.09,
|
||||
"ic_positive_pct": 63.9,
|
||||
"mean_quintile_spread": 0.0284,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 1897.3,
|
||||
"avg_eligible_pre_mask": 1673.1,
|
||||
"mask_binds_pct": 88.9
|
||||
},
|
||||
"reversal_1m": {
|
||||
"signal": "reversal_1m",
|
||||
"weeks": 42,
|
||||
"avg_cross_section": 1497.8,
|
||||
"mean_ic": 0.0208,
|
||||
"ic_t_stat": 1.45,
|
||||
"ic_positive_pct": 64.3,
|
||||
"mean_quintile_spread": -1.6753,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 1976.6,
|
||||
"avg_eligible_pre_mask": 1650.9,
|
||||
"mask_binds_pct": 88.6
|
||||
},
|
||||
"mom_12_1_sector_resid": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 36,
|
||||
"avg_cross_section": 460.7,
|
||||
"mean_ic": 0.0149,
|
||||
"ic_t_stat": 0.64,
|
||||
"ic_positive_pct": 58.3,
|
||||
"mean_quintile_spread": 0.0063,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 462.8,
|
||||
"avg_eligible_pre_mask": 460.7,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"mom_6_1": {
|
||||
"signal": "mom_6_1",
|
||||
"weeks": 40,
|
||||
"avg_cross_section": 1498.5,
|
||||
"mean_ic": 0.0082,
|
||||
"ic_t_stat": 0.58,
|
||||
"ic_positive_pct": 57.5,
|
||||
"mean_quintile_spread": 0.0163,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 1935.1,
|
||||
"avg_eligible_pre_mask": 1702.2,
|
||||
"mask_binds_pct": 90.0
|
||||
},
|
||||
"mom_12_1_sector_demeaned": {
|
||||
"signal": "mom_12_1_sector_demeaned",
|
||||
"weeks": 36,
|
||||
"avg_cross_section": 469.4,
|
||||
"mean_ic": 0.0031,
|
||||
"ic_t_stat": 0.13,
|
||||
"ic_positive_pct": 55.6,
|
||||
"mean_quintile_spread": 0.0009,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 471.5,
|
||||
"avg_eligible_pre_mask": 469.4,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"fip_id": {
|
||||
"signal": "fip_id",
|
||||
"weeks": 36,
|
||||
"avg_cross_section": 1498.6,
|
||||
"mean_ic": -0.0116,
|
||||
"ic_t_stat": -0.93,
|
||||
"ic_positive_pct": 52.8,
|
||||
"mean_quintile_spread": -0.0037,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 1897.3,
|
||||
"avg_eligible_pre_mask": 1673.1,
|
||||
"mask_binds_pct": 88.9
|
||||
},
|
||||
"vol_6m": {
|
||||
"signal": "vol_6m",
|
||||
"weeks": 40,
|
||||
"avg_cross_section": 1498.5,
|
||||
"mean_ic": -0.0219,
|
||||
"ic_t_stat": -0.77,
|
||||
"ic_positive_pct": 40.0,
|
||||
"mean_quintile_spread": 0.0381,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 1935.1,
|
||||
"avg_eligible_pre_mask": 1702.2,
|
||||
"mask_binds_pct": 90.0
|
||||
}
|
||||
},
|
||||
"post_2021": {
|
||||
"high_52w": {
|
||||
"signal": "high_52w",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 1500.0,
|
||||
"mean_ic": 0.0831,
|
||||
"ic_t_stat": 2.36,
|
||||
"ic_positive_pct": 68.8,
|
||||
"mean_quintile_spread": 0.0102,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 3046.7,
|
||||
"avg_eligible_pre_mask": 2325.3,
|
||||
"mask_binds_pct": 100.0
|
||||
},
|
||||
"mom_12_1_sector_resid": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 494.5,
|
||||
"mean_ic": 0.033,
|
||||
"ic_t_stat": 1.39,
|
||||
"ic_positive_pct": 60.4,
|
||||
"mean_quintile_spread": 0.0158,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 495.1,
|
||||
"avg_eligible_pre_mask": 494.5,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"mom_6_1": {
|
||||
"signal": "mom_6_1",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 1500.0,
|
||||
"mean_ic": 0.0314,
|
||||
"ic_t_stat": 1.51,
|
||||
"ic_positive_pct": 58.3,
|
||||
"mean_quintile_spread": 0.0109,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 3214.4,
|
||||
"avg_eligible_pre_mask": 2425.9,
|
||||
"mask_binds_pct": 100.0
|
||||
},
|
||||
"mom_12_1": {
|
||||
"signal": "mom_12_1",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 1500.0,
|
||||
"mean_ic": 0.0279,
|
||||
"ic_t_stat": 1.14,
|
||||
"ic_positive_pct": 62.5,
|
||||
"mean_quintile_spread": 0.0206,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 3045.2,
|
||||
"avg_eligible_pre_mask": 2324.2,
|
||||
"mask_binds_pct": 100.0
|
||||
},
|
||||
"trend_200": {
|
||||
"signal": "trend_200",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 1500.0,
|
||||
"mean_ic": 0.0254,
|
||||
"ic_t_stat": 1.03,
|
||||
"ic_positive_pct": 60.4,
|
||||
"mean_quintile_spread": 0.0064,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 3113.5,
|
||||
"avg_eligible_pre_mask": 2365.0,
|
||||
"mask_binds_pct": 100.0
|
||||
},
|
||||
"mom_12_1_sector_demeaned": {
|
||||
"signal": "mom_12_1_sector_demeaned",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 493.5,
|
||||
"mean_ic": 0.0071,
|
||||
"ic_t_stat": 0.3,
|
||||
"ic_positive_pct": 52.1,
|
||||
"mean_quintile_spread": 0.0078,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 494.1,
|
||||
"avg_eligible_pre_mask": 493.5,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"mom_12_1_resid": {
|
||||
"signal": "mom_12_1_resid",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 1500.0,
|
||||
"mean_ic": 0.0067,
|
||||
"ic_t_stat": 0.29,
|
||||
"ic_positive_pct": 54.2,
|
||||
"mean_quintile_spread": 0.0149,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 3045.2,
|
||||
"avg_eligible_pre_mask": 2324.2,
|
||||
"mask_binds_pct": 100.0
|
||||
},
|
||||
"mom_3_1": {
|
||||
"signal": "mom_3_1",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 1500.0,
|
||||
"mean_ic": -0.0014,
|
||||
"ic_t_stat": -0.06,
|
||||
"ic_positive_pct": 52.1,
|
||||
"mean_quintile_spread": -0.0028,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 3305.8,
|
||||
"avg_eligible_pre_mask": 2486.0,
|
||||
"mask_binds_pct": 100.0
|
||||
},
|
||||
"reversal_1m": {
|
||||
"signal": "reversal_1m",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 1500.0,
|
||||
"mean_ic": -0.013,
|
||||
"ic_t_stat": -0.69,
|
||||
"ic_positive_pct": 41.7,
|
||||
"mean_quintile_spread": -0.0099,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 3367.0,
|
||||
"avg_eligible_pre_mask": 2486.9,
|
||||
"mask_binds_pct": 100.0
|
||||
},
|
||||
"fip_id": {
|
||||
"signal": "fip_id",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 1500.0,
|
||||
"mean_ic": -0.019,
|
||||
"ic_t_stat": -1.56,
|
||||
"ic_positive_pct": 41.7,
|
||||
"mean_quintile_spread": -0.015,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 3045.2,
|
||||
"avg_eligible_pre_mask": 2324.2,
|
||||
"mask_binds_pct": 100.0
|
||||
},
|
||||
"vol_6m": {
|
||||
"signal": "vol_6m",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 1500.0,
|
||||
"mean_ic": -0.0943,
|
||||
"ic_t_stat": -2.41,
|
||||
"ic_positive_pct": 31.2,
|
||||
"mean_quintile_spread": -0.0031,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 3214.4,
|
||||
"avg_eligible_pre_mask": 2425.9,
|
||||
"mask_binds_pct": 100.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"identical_subset_sector_cs": {
|
||||
"mom_12_1_sector_resid": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 480.0,
|
||||
"mean_ic": 0.0268,
|
||||
"ic_t_stat": 1.69,
|
||||
"ic_positive_pct": 60.2,
|
||||
"mean_quintile_spread": 0.0126,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 481.2,
|
||||
"avg_eligible_pre_mask": 480.0,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"mom_12_1_resid": {
|
||||
"signal": "mom_12_1_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 480.0,
|
||||
"mean_ic": 0.0251,
|
||||
"ic_t_stat": 1.3,
|
||||
"ic_positive_pct": 57.8,
|
||||
"mean_quintile_spread": 0.011,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 481.2,
|
||||
"avg_eligible_pre_mask": 480.0,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"mom_12_1": {
|
||||
"signal": "mom_12_1",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 480.0,
|
||||
"mean_ic": 0.0192,
|
||||
"ic_t_stat": 0.92,
|
||||
"ic_positive_pct": 56.6,
|
||||
"mean_quintile_spread": 0.0099,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 481.2,
|
||||
"avg_eligible_pre_mask": 480.0,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"mom_12_1_sector_demeaned": {
|
||||
"signal": "mom_12_1_sector_demeaned",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 479.0,
|
||||
"mean_ic": 0.0063,
|
||||
"ic_t_stat": 0.39,
|
||||
"ic_positive_pct": 55.4,
|
||||
"mean_quintile_spread": 0.0048,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 480.2,
|
||||
"avg_eligible_pre_mask": 479.0,
|
||||
"mask_binds_pct": 0.0
|
||||
}
|
||||
},
|
||||
"mask_diagnostics": {
|
||||
"reference_signal": "vol_6m",
|
||||
"avg_cross_section": 1499.3,
|
||||
"avg_raw_pool": 2641.3,
|
||||
"avg_eligible_pre_mask": 2098.8,
|
||||
"mask_binds_pct": 95.5,
|
||||
"weeks": 88
|
||||
},
|
||||
"sector_map_size": 505,
|
||||
"sector_etfs_loaded": [
|
||||
"XLB",
|
||||
"XLC",
|
||||
"XLE",
|
||||
"XLF",
|
||||
"XLI",
|
||||
"XLK",
|
||||
"XLP",
|
||||
"XLRE",
|
||||
"XLU",
|
||||
"XLV",
|
||||
"XLY"
|
||||
],
|
||||
"spy_bars": 2649
|
||||
},
|
||||
"grade": {
|
||||
"verdict": "FAIL",
|
||||
"reason": "failed one or more pre-registered checks (see checks)",
|
||||
"checks": {
|
||||
"sector_row": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 480.0,
|
||||
"mean_ic": 0.0268,
|
||||
"ic_t_stat": 1.69,
|
||||
"ic_positive_pct": 60.2,
|
||||
"mean_quintile_spread": 0.0126,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 481.2,
|
||||
"avg_eligible_pre_mask": 480.0,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"resid_row_for_t": {
|
||||
"signal": "mom_12_1_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 480.0,
|
||||
"mean_ic": 0.0251,
|
||||
"ic_t_stat": 1.3,
|
||||
"ic_positive_pct": 57.8,
|
||||
"mean_quintile_spread": 0.011,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 481.2,
|
||||
"avg_eligible_pre_mask": 480.0,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"resid_t_source": "identical_subset",
|
||||
"pre_2021": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 36,
|
||||
"avg_cross_section": 460.7,
|
||||
"mean_ic": 0.0149,
|
||||
"ic_t_stat": 0.64,
|
||||
"ic_positive_pct": 58.3,
|
||||
"mean_quintile_spread": 0.0063,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 462.8,
|
||||
"avg_eligible_pre_mask": 460.7,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"post_2021": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 494.5,
|
||||
"mean_ic": 0.033,
|
||||
"ic_t_stat": 1.39,
|
||||
"ic_positive_pct": 60.4,
|
||||
"mean_quintile_spread": 0.0158,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 495.1,
|
||||
"avg_eligible_pre_mask": 494.5,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"abs_mean_ic_ge_0_03": false,
|
||||
"sign_positive": true,
|
||||
"reliable": true,
|
||||
"weeks_ge_50": true,
|
||||
"weeks": 83,
|
||||
"t_ge_resid_same_cs": true,
|
||||
"sector_t": 1.69,
|
||||
"resid_t": 1.3,
|
||||
"era_both_present": true,
|
||||
"era_sign_consistent_positive": true,
|
||||
"pre_ic": 0.0149,
|
||||
"post_ic": 0.033,
|
||||
"avg_cross_section": 480.0
|
||||
},
|
||||
"headline": "Task 1 CLOSED \u2014 sector residual dead on deep evidence."
|
||||
},
|
||||
"pending_human": true,
|
||||
"note": "Nothing merged into production. Thread ends at PASS/FAIL."
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
# Sector-residual deep test (masked, repaired snapshot)
|
||||
|
||||
Generated: `2026-07-19T11:33:19.102779`
|
||||
|
||||
> **SURVIVORSHIP BIAS: today's constituents backfilled. Relative IC only — not levels.**
|
||||
|
||||
## Pre-registered grade (mechanical)
|
||||
|
||||
**Verdict: FAIL**
|
||||
|
||||
Task 1 CLOSED — sector residual dead on deep evidence.
|
||||
|
||||
Reason: failed one or more pre-registered checks (see checks)
|
||||
|
||||
```json
|
||||
{
|
||||
"sector_row": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 480.0,
|
||||
"mean_ic": 0.0268,
|
||||
"ic_t_stat": 1.69,
|
||||
"ic_positive_pct": 60.2,
|
||||
"mean_quintile_spread": 0.0126,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 481.2,
|
||||
"avg_eligible_pre_mask": 480.0,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"resid_row_for_t": {
|
||||
"signal": "mom_12_1_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 480.0,
|
||||
"mean_ic": 0.0251,
|
||||
"ic_t_stat": 1.3,
|
||||
"ic_positive_pct": 57.8,
|
||||
"mean_quintile_spread": 0.011,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 481.2,
|
||||
"avg_eligible_pre_mask": 480.0,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"resid_t_source": "identical_subset",
|
||||
"pre_2021": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 36,
|
||||
"avg_cross_section": 460.7,
|
||||
"mean_ic": 0.0149,
|
||||
"ic_t_stat": 0.64,
|
||||
"ic_positive_pct": 58.3,
|
||||
"mean_quintile_spread": 0.0063,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 462.8,
|
||||
"avg_eligible_pre_mask": 460.7,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"post_2021": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 48,
|
||||
"avg_cross_section": 494.5,
|
||||
"mean_ic": 0.033,
|
||||
"ic_t_stat": 1.39,
|
||||
"ic_positive_pct": 60.4,
|
||||
"mean_quintile_spread": 0.0158,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 495.1,
|
||||
"avg_eligible_pre_mask": 494.5,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"abs_mean_ic_ge_0_03": false,
|
||||
"sign_positive": true,
|
||||
"reliable": true,
|
||||
"weeks_ge_50": true,
|
||||
"weeks": 83,
|
||||
"t_ge_resid_same_cs": true,
|
||||
"sector_t": 1.69,
|
||||
"resid_t": 1.3,
|
||||
"era_both_present": true,
|
||||
"era_sign_consistent_positive": true,
|
||||
"pre_ic": 0.0149,
|
||||
"post_ic": 0.033,
|
||||
"avg_cross_section": 480.0
|
||||
}
|
||||
```
|
||||
|
||||
## Step-1 sanity
|
||||
|
||||
```json
|
||||
{
|
||||
"skipped": true,
|
||||
"sanity": {
|
||||
"passed": true,
|
||||
"megacap": {
|
||||
"AAPL": {
|
||||
"symbol": "AAPL",
|
||||
"bars": 2649,
|
||||
"min_date": "2016-01-04",
|
||||
"max_date": "2026-07-17"
|
||||
},
|
||||
"MSFT": {
|
||||
"symbol": "MSFT",
|
||||
"bars": 2649,
|
||||
"min_date": "2016-01-04",
|
||||
"max_date": "2026-07-17"
|
||||
},
|
||||
"JPM": {
|
||||
"symbol": "JPM",
|
||||
"bars": 2649,
|
||||
"min_date": "2016-01-04",
|
||||
"max_date": "2026-07-17"
|
||||
},
|
||||
"XOM": {
|
||||
"symbol": "XOM",
|
||||
"bars": 2649,
|
||||
"min_date": "2016-01-04",
|
||||
"max_date": "2026-07-17"
|
||||
},
|
||||
"JNJ": {
|
||||
"symbol": "JNJ",
|
||||
"bars": 2649,
|
||||
"min_date": "2016-01-04",
|
||||
"max_date": "2026-07-17"
|
||||
}
|
||||
},
|
||||
"megacap_ok": true,
|
||||
"megacap_reasons": [],
|
||||
"feed_floor": "2016-01-04",
|
||||
"spy_benchmark": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"old_shallow_floor": "2020-01-01",
|
||||
"sector_etfs": {
|
||||
"XLB": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLC": {
|
||||
"n": 2030,
|
||||
"min": "2018-06-19",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLE": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLF": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLI": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLK": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLP": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLRE": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLU": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLV": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
"XLY": {
|
||||
"n": 2649,
|
||||
"min": "2016-01-04",
|
||||
"max": "2026-07-17"
|
||||
}
|
||||
},
|
||||
"sector_etfs_deep_count": 11,
|
||||
"sector_etfs_ok": true,
|
||||
"sector_etf_reasons": [],
|
||||
"still_shallow_count": 2882,
|
||||
"still_shallow_sample": [
|
||||
"AACB",
|
||||
"AACBR",
|
||||
"AACBU",
|
||||
"AACI",
|
||||
"AACIU",
|
||||
"AACIW",
|
||||
"AACO",
|
||||
"AACOU",
|
||||
"AACOW",
|
||||
"AACP",
|
||||
"AACPR",
|
||||
"AACPU",
|
||||
"AACPW",
|
||||
"AAPG",
|
||||
"AARD",
|
||||
"ABAT",
|
||||
"ABCL",
|
||||
"ABLV",
|
||||
"ABLVW",
|
||||
"ABNB"
|
||||
],
|
||||
"still_shallow_note": "Remaining 'shallow' names are mostly post-2017 IPOs/listings \u2014 expected, not a two-tier defect.",
|
||||
"xlc_note": "XLC lists mid-2018 \u2192 Communication Services residual coverage from ~mid-2019.",
|
||||
"feed_note": "Empirical Alpaca floor observed via SPY: 2016-01-04 (n=2649). Calendar history_days=5000 is a request cap, not a guarantee \u2014 sanity grades against the feed floor, not 5000 calendar days.",
|
||||
"target_history_days": 5000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Mask diagnostics
|
||||
|
||||
```json
|
||||
{
|
||||
"reference_signal": "vol_6m",
|
||||
"avg_cross_section": 1499.3,
|
||||
"avg_raw_pool": 2641.3,
|
||||
"avg_eligible_pre_mask": 2098.8,
|
||||
"mask_binds_pct": 95.5,
|
||||
"weeks": 88
|
||||
}
|
||||
```
|
||||
|
||||
## Signal table (rows only — no narrative for non-sector signals)
|
||||
|
||||
| signal | mean_ic | t | weeks | avg_N | reliable |
|
||||
|---|---:|---:|---:|---:|---|
|
||||
| fip_id | -0.0184 | -2.11 | 83 | 1499.4 | True |
|
||||
| high_52w | 0.0761 | 4.46 | 84 | 1499.2 | True |
|
||||
| mom_12_1 | 0.0355 | 2.41 | 83 | 1499.4 | True |
|
||||
| mom_12_1_resid | 0.0148 | 1.02 | 83 | 1499.4 | True |
|
||||
| mom_12_1_sector_demeaned | 0.0076 | 0.46 | 83 | 483.2 | True |
|
||||
| mom_12_1_sector_resid | 0.0268 | 1.69 | 83 | 480.0 | True |
|
||||
| mom_3_1 | 0.0256 | 2.24 | 90 | 1498.6 | True |
|
||||
| mom_6_1 | 0.0101 | 0.91 | 88 | 1499.3 | True |
|
||||
| reversal_1m | 0.0156 | 1.37 | 89 | 1499.0 | True |
|
||||
| trend_200 | 0.0371 | 2.63 | 85 | 1499.6 | True |
|
||||
| vol_6m | -0.0704 | -3.14 | 88 | 1499.3 | True |
|
||||
|
||||
### Era split — mom_12_1_sector_resid only (for grade)
|
||||
|
||||
| era | IC | t | weeks | N |
|
||||
|---|---:|---:|---:|---:|
|
||||
| pre_2021 | 0.0149 | 0.64 | 36 | 460.7 |
|
||||
| post_2021 | 0.033 | 1.39 | 48 | 494.5 |
|
||||
|
||||
### Identical-subset baselines (sector CS)
|
||||
|
||||
```json
|
||||
{
|
||||
"mom_12_1_sector_resid": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 480.0,
|
||||
"mean_ic": 0.0268,
|
||||
"ic_t_stat": 1.69,
|
||||
"ic_positive_pct": 60.2,
|
||||
"mean_quintile_spread": 0.0126,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 481.2,
|
||||
"avg_eligible_pre_mask": 480.0,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"mom_12_1_resid": {
|
||||
"signal": "mom_12_1_resid",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 480.0,
|
||||
"mean_ic": 0.0251,
|
||||
"ic_t_stat": 1.3,
|
||||
"ic_positive_pct": 57.8,
|
||||
"mean_quintile_spread": 0.011,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 481.2,
|
||||
"avg_eligible_pre_mask": 480.0,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"mom_12_1": {
|
||||
"signal": "mom_12_1",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 480.0,
|
||||
"mean_ic": 0.0192,
|
||||
"ic_t_stat": 0.92,
|
||||
"ic_positive_pct": 56.6,
|
||||
"mean_quintile_spread": 0.0099,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 481.2,
|
||||
"avg_eligible_pre_mask": 480.0,
|
||||
"mask_binds_pct": 0.0
|
||||
},
|
||||
"mom_12_1_sector_demeaned": {
|
||||
"signal": "mom_12_1_sector_demeaned",
|
||||
"weeks": 83,
|
||||
"avg_cross_section": 479.0,
|
||||
"mean_ic": 0.0063,
|
||||
"ic_t_stat": 0.39,
|
||||
"ic_positive_pct": 55.4,
|
||||
"mean_quintile_spread": 0.0048,
|
||||
"reliable": true,
|
||||
"liquid_breadth_top_n": 1500,
|
||||
"liquid_min_price": 5.0,
|
||||
"avg_raw_pool": 480.2,
|
||||
"avg_eligible_pre_mask": 479.0,
|
||||
"mask_binds_pct": 0.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
PENDING_HUMAN beyond the mechanical PASS/FAIL above. Nothing merged into production docs or prod code.
|
||||
|
||||
JSON: `reports/sector-resid-deep-20260719-113319.json`
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
{
|
||||
"generated_at": "2026-07-19T08:33:56.651229",
|
||||
"snapshot_guard": {
|
||||
"snapshot": "C:\\Workspace\\signal-platform\\backtest_snapshots\\prod.sqlite",
|
||||
"manifest": null,
|
||||
"manifest_ok": null,
|
||||
"note": "No completion manifest (prod.sqlite is expected without one). Bar-count sanity still applied.",
|
||||
"ticker_count": 506,
|
||||
"ohlcv_row_count": 629263,
|
||||
"bars_min_avg_max": {
|
||||
"min": 14,
|
||||
"avg": 1246.1,
|
||||
"max": 1261
|
||||
},
|
||||
"ohlcv_date_range": {
|
||||
"min": "2021-06-24",
|
||||
"max": "2026-07-02"
|
||||
},
|
||||
"benchmark_prices": [
|
||||
{
|
||||
"symbol": "SPY",
|
||||
"n": 1516,
|
||||
"min": "2020-07-06",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
{
|
||||
"symbol": "XLB",
|
||||
"n": 1512,
|
||||
"min": "2020-07-10",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
{
|
||||
"symbol": "XLC",
|
||||
"n": 1512,
|
||||
"min": "2020-07-10",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
{
|
||||
"symbol": "XLE",
|
||||
"n": 1512,
|
||||
"min": "2020-07-10",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
{
|
||||
"symbol": "XLF",
|
||||
"n": 1512,
|
||||
"min": "2020-07-10",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
{
|
||||
"symbol": "XLI",
|
||||
"n": 1512,
|
||||
"min": "2020-07-10",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
{
|
||||
"symbol": "XLK",
|
||||
"n": 1512,
|
||||
"min": "2020-07-10",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
{
|
||||
"symbol": "XLP",
|
||||
"n": 1512,
|
||||
"min": "2020-07-10",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
{
|
||||
"symbol": "XLRE",
|
||||
"n": 1512,
|
||||
"min": "2020-07-10",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
{
|
||||
"symbol": "XLU",
|
||||
"n": 1512,
|
||||
"min": "2020-07-10",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
{
|
||||
"symbol": "XLV",
|
||||
"n": 1512,
|
||||
"min": "2020-07-10",
|
||||
"max": "2026-07-17"
|
||||
},
|
||||
{
|
||||
"symbol": "XLY",
|
||||
"n": 1512,
|
||||
"min": "2020-07-10",
|
||||
"max": "2026-07-17"
|
||||
}
|
||||
],
|
||||
"missing_sector_etfs": []
|
||||
},
|
||||
"sector_coverage": {
|
||||
"universe": 506,
|
||||
"mapped": 505,
|
||||
"mapped_pct": 99.8,
|
||||
"with_etf": 505,
|
||||
"missing": [
|
||||
"RHM"
|
||||
],
|
||||
"by_sector": {
|
||||
"Industrials": 81,
|
||||
"Financials": 75,
|
||||
"Information Technology": 72,
|
||||
"Health Care": 58,
|
||||
"Consumer Discretionary": 47,
|
||||
"Consumer Staples": 34,
|
||||
"Real Estate": 31,
|
||||
"Utilities": 31,
|
||||
"Materials": 26,
|
||||
"Communication Services": 23,
|
||||
"Energy": 22,
|
||||
"Consumer Defensive": 2,
|
||||
"Technology": 2,
|
||||
"Financial Services": 1
|
||||
}
|
||||
},
|
||||
"sector_map_path": "C:\\Workspace\\signal-platform\\data\\research\\ticker_sector_map.json",
|
||||
"signal_eval": [
|
||||
{
|
||||
"signal": "vol_6m",
|
||||
"weeks": 39,
|
||||
"avg_cross_section": 498.2,
|
||||
"mean_ic": 0.0609,
|
||||
"ic_t_stat": 1.48,
|
||||
"ic_positive_pct": 64.1,
|
||||
"mean_quintile_spread": 0.0337,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 497.7,
|
||||
"mean_ic": 0.0578,
|
||||
"ic_t_stat": 2.34,
|
||||
"ic_positive_pct": 65.7,
|
||||
"mean_quintile_spread": 0.0245,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "mom_12_1_resid",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 497.7,
|
||||
"mean_ic": 0.0552,
|
||||
"ic_t_stat": 1.98,
|
||||
"ic_positive_pct": 60.0,
|
||||
"mean_quintile_spread": 0.0207,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "mom_12_1",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 497.7,
|
||||
"mean_ic": 0.0531,
|
||||
"ic_t_stat": 1.61,
|
||||
"ic_positive_pct": 65.7,
|
||||
"mean_quintile_spread": 0.0206,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "mom_12_1_sector_demeaned",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 496.7,
|
||||
"mean_ic": 0.034,
|
||||
"ic_t_stat": 1.32,
|
||||
"ic_positive_pct": 62.9,
|
||||
"mean_quintile_spread": 0.0154,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "trend_200",
|
||||
"weeks": 37,
|
||||
"avg_cross_section": 497.9,
|
||||
"mean_ic": 0.0161,
|
||||
"ic_t_stat": 0.44,
|
||||
"ic_positive_pct": 59.5,
|
||||
"mean_quintile_spread": 0.006,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "reversal_1m",
|
||||
"weeks": 43,
|
||||
"avg_cross_section": 498.7,
|
||||
"mean_ic": 0.0059,
|
||||
"ic_t_stat": 0.22,
|
||||
"ic_positive_pct": 53.5,
|
||||
"mean_quintile_spread": 0.0053,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "mom_6_1",
|
||||
"weeks": 39,
|
||||
"avg_cross_section": 498.2,
|
||||
"mean_ic": 0.0051,
|
||||
"ic_t_stat": 0.21,
|
||||
"ic_positive_pct": 56.4,
|
||||
"mean_quintile_spread": 0.0087,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "mom_3_1",
|
||||
"weeks": 42,
|
||||
"avg_cross_section": 498.5,
|
||||
"mean_ic": -0.0064,
|
||||
"ic_t_stat": -0.25,
|
||||
"ic_positive_pct": 50.0,
|
||||
"mean_quintile_spread": 0.0046,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "high_52w",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 497.7,
|
||||
"mean_ic": -0.0086,
|
||||
"ic_t_stat": -0.26,
|
||||
"ic_positive_pct": 54.3,
|
||||
"mean_quintile_spread": -0.0088,
|
||||
"reliable": true
|
||||
},
|
||||
{
|
||||
"signal": "fip_id",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 497.7,
|
||||
"mean_ic": -0.045,
|
||||
"ic_t_stat": -2.91,
|
||||
"ic_positive_pct": 25.7,
|
||||
"mean_quintile_spread": -0.0168,
|
||||
"reliable": true
|
||||
}
|
||||
],
|
||||
"ic_grades": {
|
||||
"mom_12_1_sector_resid": {
|
||||
"promote_to_ab": true,
|
||||
"checks": {
|
||||
"sign_ok": true,
|
||||
"abs_mean_ic_ge_0_03": true,
|
||||
"reliable": true,
|
||||
"t_ge_resid": true,
|
||||
"mean_ic": 0.0578,
|
||||
"ic_t_stat": 2.34,
|
||||
"resid_ic_t_stat": 1.98,
|
||||
"weeks": 35
|
||||
},
|
||||
"reason": "clears iron rule and t \u2265 mom_12_1_resid \u2014 authorized for A/B only",
|
||||
"row": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 497.7,
|
||||
"mean_ic": 0.0578,
|
||||
"ic_t_stat": 2.34,
|
||||
"ic_positive_pct": 65.7,
|
||||
"mean_quintile_spread": 0.0245,
|
||||
"reliable": true
|
||||
}
|
||||
},
|
||||
"mom_12_1_sector_demeaned": {
|
||||
"promote_to_ab": false,
|
||||
"checks": {
|
||||
"sign_ok": true,
|
||||
"abs_mean_ic_ge_0_03": true,
|
||||
"reliable": true,
|
||||
"t_ge_resid": false,
|
||||
"mean_ic": 0.034,
|
||||
"ic_t_stat": 1.32,
|
||||
"resid_ic_t_stat": 1.98,
|
||||
"weeks": 35
|
||||
},
|
||||
"reason": "does not clear pre-registered IC promotion bar",
|
||||
"row": {
|
||||
"signal": "mom_12_1_sector_demeaned",
|
||||
"weeks": 35,
|
||||
"avg_cross_section": 496.7,
|
||||
"mean_ic": 0.034,
|
||||
"ic_t_stat": 1.32,
|
||||
"ic_positive_pct": 62.9,
|
||||
"mean_quintile_spread": 0.0154,
|
||||
"reliable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"portfolio_ab": {
|
||||
"signal": "mom_12_1_sector_resid",
|
||||
"ranking_key": "residual_high_vol_blend_80_20_score",
|
||||
"fill_mode": "close",
|
||||
"validation_split": "2024-07-01",
|
||||
"control": {
|
||||
"label": "control_mom_12_1_resid",
|
||||
"n_qualified_longs": 1086,
|
||||
"windows": {
|
||||
"train": {
|
||||
"sharpe": 1.3,
|
||||
"sharpe_se": 0.685,
|
||||
"cagr_pct": 29.2,
|
||||
"max_drawdown_pct": 21.4,
|
||||
"total_return_pct": 70.9,
|
||||
"trades": 176,
|
||||
"win_rate_pct": null,
|
||||
"avg_r": null,
|
||||
"n_returns": 525,
|
||||
"return_skew": 0.3722,
|
||||
"return_kurtosis": 4.6208,
|
||||
"psr": 0.971
|
||||
},
|
||||
"validation": {
|
||||
"sharpe": 2.92,
|
||||
"sharpe_se": 0.709,
|
||||
"cagr_pct": 76.3,
|
||||
"max_drawdown_pct": 11.7,
|
||||
"total_return_pct": 210.7,
|
||||
"trades": 150,
|
||||
"win_rate_pct": null,
|
||||
"avg_r": null,
|
||||
"n_returns": 501,
|
||||
"return_skew": 0.1734,
|
||||
"return_kurtosis": 4.4625,
|
||||
"psr": 1.0
|
||||
},
|
||||
"full": {
|
||||
"sharpe": 2.09,
|
||||
"sharpe_se": 0.497,
|
||||
"cagr_pct": 51.6,
|
||||
"max_drawdown_pct": 21.4,
|
||||
"total_return_pct": 424.6,
|
||||
"trades": 322,
|
||||
"win_rate_pct": null,
|
||||
"avg_r": null,
|
||||
"n_returns": 1000,
|
||||
"return_skew": 0.2686,
|
||||
"return_kurtosis": 4.5653,
|
||||
"psr": 1.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"treatment": {
|
||||
"label": "treatment_mom_12_1_sector_resid",
|
||||
"n_qualified_longs": 1210,
|
||||
"windows": {
|
||||
"train": {
|
||||
"sharpe": 1.57,
|
||||
"sharpe_se": 0.677,
|
||||
"cagr_pct": 35.5,
|
||||
"max_drawdown_pct": 19.8,
|
||||
"total_return_pct": 90.0,
|
||||
"trades": 176,
|
||||
"win_rate_pct": null,
|
||||
"avg_r": null,
|
||||
"n_returns": 530,
|
||||
"return_skew": 0.466,
|
||||
"return_kurtosis": 4.4413,
|
||||
"psr": 0.99
|
||||
},
|
||||
"validation": {
|
||||
"sharpe": 2.57,
|
||||
"sharpe_se": 0.701,
|
||||
"cagr_pct": 66.3,
|
||||
"max_drawdown_pct": 14.8,
|
||||
"total_return_pct": 176.4,
|
||||
"trades": 163,
|
||||
"win_rate_pct": null,
|
||||
"avg_r": null,
|
||||
"n_returns": 501,
|
||||
"return_skew": 0.3003,
|
||||
"return_kurtosis": 4.4305,
|
||||
"psr": 0.9999
|
||||
},
|
||||
"full": {
|
||||
"sharpe": 2.09,
|
||||
"sharpe_se": 0.491,
|
||||
"cagr_pct": 51.0,
|
||||
"max_drawdown_pct": 19.8,
|
||||
"total_return_pct": 421.3,
|
||||
"trades": 337,
|
||||
"win_rate_pct": null,
|
||||
"avg_r": null,
|
||||
"n_returns": 1005,
|
||||
"return_skew": 0.4066,
|
||||
"return_kurtosis": 4.3627,
|
||||
"psr": 1.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"promotion": {
|
||||
"promote": true,
|
||||
"checks": {
|
||||
"validation_sharpe_ge_control_minus_half_se": true,
|
||||
"full_sharpe_not_worse": true,
|
||||
"full_maxdd_not_worse": true,
|
||||
"control_validation_sharpe": 2.92,
|
||||
"treatment_validation_sharpe": 2.57,
|
||||
"se_used": 0.701,
|
||||
"control_full_sharpe": 2.09,
|
||||
"treatment_full_sharpe": 2.09,
|
||||
"control_full_maxdd": 21.4,
|
||||
"treatment_full_maxdd": 19.8
|
||||
},
|
||||
"reason": "clears pre-registered A/B bar \u2014 human decides wire-in"
|
||||
}
|
||||
},
|
||||
"verdict": "PROMOTE",
|
||||
"verdict_detail": "mom_12_1_sector_resid cleared IC + A/B bars. Human must design wire-in; do not ship from this branch.",
|
||||
"human_next": "- Approve or reject production residual swap vs dual-signal design.\n- If sector-cap arm ran, review tail-trim diagnostics before any cap.",
|
||||
"report_path": "reports/sector-residual-20260719-083356.json",
|
||||
"pre_registration": {
|
||||
"iron_ic_bar": 0.03,
|
||||
"validation_split": "2024-07-01",
|
||||
"fill_mode": "close",
|
||||
"cost_per_side": 0.001,
|
||||
"ab_rule": "val Sharpe >= control - 0.5*SE; full Sharpe & maxDD not worse"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
# Sector-residual momentum (Tier-1 alpha research)
|
||||
|
||||
**Status:** **PROMOTE (to human design decision only)** — IC + A/B bars cleared; **do not ship**.
|
||||
**Branch:** `research/sector-residual-momentum`
|
||||
**Production impact:** none. Local research only. No scheduler / gate / prod-config changes.
|
||||
**Artifacts:** `reports/sector-residual-20260719-083356.json` (+ companion `.md`)
|
||||
|
||||
---
|
||||
|
||||
## Pre-registration (locked before first research run)
|
||||
|
||||
### Hypothesis
|
||||
|
||||
Residualizing 12–1 momentum against the sector, not only the market, reduces
|
||||
factor volatility at similar return (Blitz / Huij / Martens-style) → higher
|
||||
Sharpe on the production book when the residual replaces market-only residual
|
||||
as the momentum leg.
|
||||
|
||||
### Signals (candidates)
|
||||
|
||||
| signal | construction |
|
||||
|---|---|
|
||||
| `mom_12_1_sector_resid` | Two-factor residual vs SPY + ticker’s sector ETF. Same window as `mom_12_1_resid`: ≥100 daily obs, 252-bar lookback, 21-bar skip; two-factor OLS betas **without intercept**; cumulate residual returns over the formation window. |
|
||||
| `mom_12_1_sector_demeaned` | Plain `mom_12_1` minus the **cross-sectional** mean of `mom_12_1` within the same GICS sector that week (≥2 names in sector). No regression. |
|
||||
|
||||
### Baselines (same run, same cross-sections — iron rule)
|
||||
|
||||
Always report side-by-side with:
|
||||
|
||||
- `mom_12_1`
|
||||
- `mom_12_1_resid`
|
||||
|
||||
Computed on the **identical** weekly non-overlapping cross-sections in this run.
|
||||
Never compare against IC numbers from another report.
|
||||
|
||||
### Iron rule (IC harness)
|
||||
|
||||
Source of truth: `_signal_evaluation` in `app/services/backtest_service.py`.
|
||||
|
||||
- Mean weekly Spearman IC on **non-overlapping** weekly windows
|
||||
- Bar: \|mean IC\| ≥ ~0.03, **consistent positive sign**, `reliable: true` (≥ 12 windows)
|
||||
|
||||
### Promotion to portfolio A/B (candidate → book)
|
||||
|
||||
A candidate promotes to A/B **only if**:
|
||||
|
||||
1. It clears the iron-rule bar **and**
|
||||
2. Its IC **t-stat ≥** that of `mom_12_1_resid` on the same cross-sections.
|
||||
|
||||
### Portfolio A/B grading (if and only if IC promotion fires)
|
||||
|
||||
- Swap candidate in as the **momentum leg** of the production 80/20 momentum/vol
|
||||
rank **and** as the gate-percentile signal.
|
||||
- `fill_mode=close`, `COST_PER_SIDE = 0.001`, full config otherwise unchanged.
|
||||
- Validation window = entries ≥ **2024-07-01** (call it **validation**, not
|
||||
holdout — contaminated by prior experiments).
|
||||
- Pre-registered promotion bar:
|
||||
- validation Sharpe ≥ control − 0.5·SE
|
||||
- full-period Sharpe and max-DD **not worse** than control
|
||||
- Report Lo / Mertens-adjusted SEs.
|
||||
|
||||
### Optional sector-cap sub-experiment
|
||||
|
||||
Only if labels are in **and** A/B ran: max **3** positions per sector in the
|
||||
10-slot book. Same A/B grading. **Tail-trim presumption of guilt** (rule 4):
|
||||
report entry counts and both tails of the R distribution. Rising win rate with
|
||||
falling Sharpe/CAGR = red flag → do not promote.
|
||||
|
||||
**This run:** sector-cap arm **not executed** (optional; A/B unconstrained book
|
||||
only). Can be a human-approved follow-up.
|
||||
|
||||
### Verdict labels
|
||||
|
||||
| label | meaning |
|
||||
|---|---|
|
||||
| **PROMOTE** | Clears pre-registered bar; human decides next (wire design separate) |
|
||||
| **PARK** | Inconclusive / weak; keep machinery, no book change |
|
||||
| **DEAD** | Failed iron rule or worse than residual baseline with clear sign |
|
||||
|
||||
### Explicit non-goals
|
||||
|
||||
- No production deploy from this doc
|
||||
- Do not resurrect: take-profit exits, EV gate, regime entry-blocking,
|
||||
inverse-vol sizing, gap-caps, unconditional FIP filter
|
||||
|
||||
---
|
||||
|
||||
## Data provenance
|
||||
|
||||
### Snapshot race guard
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| Snapshot path | `backtest_snapshots/prod.sqlite` |
|
||||
| Manifest | none (expected for prod snapshot); bar-count sanity applied |
|
||||
| Tickers / OHLCV | **506** / **629,263** |
|
||||
| Bars min / avg / max | 14 / 1246.1 / 1261 |
|
||||
| OHLCV range | 2021-06-24 → 2026-07-02 |
|
||||
| Partial-build red flags | none (avg bars healthy) |
|
||||
|
||||
Integrity fingerprint on same run: `fip_id` mean IC **−0.045** / t **−2.91**
|
||||
(35 weeks, N≈498) — matches the established prod fingerprint.
|
||||
|
||||
### Sector labels
|
||||
|
||||
| source | count |
|
||||
|---|---:|
|
||||
| Public S&P 500 GICS CSV | 496 newly filled |
|
||||
| FMP profile requests | 10 (all missing after CSV) |
|
||||
| Mapped / universe | **505 / 506 (99.8%)** |
|
||||
| With mappable ETF | 505 |
|
||||
| Still missing | **RHM** only |
|
||||
|
||||
Persist path: `data/research/ticker_sector_map.json`.
|
||||
|
||||
FMP aliases (`Technology`, `Consumer Defensive`, `Financial Services`) map to
|
||||
SPDRs via the alias table in `app/services/sector_map.py`.
|
||||
|
||||
### Sector ETFs in `benchmark_prices` (auxiliary only — not tradable)
|
||||
|
||||
| symbol | bars | min date | max date |
|
||||
|---|---:|---|---|
|
||||
| SPY | 1516 | 2020-07-06 | 2026-07-17 |
|
||||
| XLB…XLY (11) | 1512 each | 2020-07-10 | 2026-07-17 |
|
||||
|
||||
Fetched via Alpaca `Adjustment.SPLIT` into **`benchmark_prices`** (same table as
|
||||
SPY) so they never enter the ticker universe or candidate replay.
|
||||
|
||||
---
|
||||
|
||||
## Results
|
||||
|
||||
Generated: `2026-07-19T08:33:56`
|
||||
|
||||
### IC harness (identical cross-sections, production 506-name universe)
|
||||
|
||||
| signal | mean_ic | ic_t_stat | weeks | avg_N | reliable | ic+_pct | quintile spread |
|
||||
|---|---:|---:|---:|---:|---|---:|---:|
|
||||
| **mom_12_1_sector_resid** | **0.0578** | **2.34** | 35 | 497.7 | true | 65.7 | 0.0245 |
|
||||
| mom_12_1_resid | 0.0552 | 1.98 | 35 | 497.7 | true | 60.0 | 0.0207 |
|
||||
| mom_12_1 | 0.0531 | 1.61 | 35 | 497.7 | true | 65.7 | 0.0206 |
|
||||
| mom_12_1_sector_demeaned | 0.0340 | 1.32 | 35 | 496.7 | true | 62.9 | 0.0154 |
|
||||
|
||||
### IC promotion grades
|
||||
|
||||
| candidate | iron rule | t ≥ resid | promote_to_ab |
|
||||
|---|---|---|---|
|
||||
| `mom_12_1_sector_resid` | pass (IC 0.058, +sign, reliable) | **yes** (2.34 ≥ 1.98) | **yes** |
|
||||
| `mom_12_1_sector_demeaned` | pass (IC 0.034, +sign, reliable) | **no** (1.32 < 1.98) | **no** |
|
||||
|
||||
### Portfolio A/B — `mom_12_1_sector_resid` as residual leg
|
||||
|
||||
Config: production 80/20 residual/high-vol rank + gate percentile, `fill_mode=close`,
|
||||
cost 10 bps/side, ATR trail / gate-reset re-entry as live. Validation split
|
||||
2024-07-01.
|
||||
|
||||
| window | arm | Sharpe | Sharpe SE (Mertens) | CAGR % | max DD % | trades | n_days |
|
||||
|---|---|---:|---:|---:|---:|---:|---:|
|
||||
| train | control (resid) | 1.30 | 0.685 | 29.2 | 21.4 | 176 | 525 |
|
||||
| train | treatment (sector resid) | **1.57** | 0.677 | **35.5** | **19.8** | 176 | 530 |
|
||||
| validation | control | **2.92** | 0.709 | **76.3** | **11.7** | 150 | 501 |
|
||||
| validation | treatment | 2.57 | 0.701 | 66.3 | 14.8 | 163 | 501 |
|
||||
| full | control | 2.09 | 0.497 | 51.6 | 21.4 | 322 | 1000 |
|
||||
| full | treatment | 2.09 | 0.491 | 51.0 | **19.8** | 337 | 1005 |
|
||||
|
||||
**Pre-registered A/B checks**
|
||||
|
||||
| check | result |
|
||||
|---|---|
|
||||
| val Sharpe ≥ control − 0.5·SE | **pass** (2.57 ≥ 2.92 − 0.5×0.701 = 2.5695) — **knife-edge** |
|
||||
| full Sharpe not worse | **pass** (2.09 = 2.09) |
|
||||
| full max DD not worse | **pass** (19.8 < 21.4) |
|
||||
|
||||
Qualified long candidates: control 1086 vs treatment 1210 (sector residual
|
||||
gates a slightly larger set).
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
| signal | verdict | note |
|
||||
|---|---|---|
|
||||
| **`mom_12_1_sector_resid`** | **PROMOTE → human wire-in decision** | IC modestly beats market residual; A/B clears pre-reg bar narrowly. **Do not ship from this branch.** |
|
||||
| **`mom_12_1_sector_demeaned`** | **DEAD** (for promotion) | Iron-rule IC magnitude ok, but t-stat loses to `mom_12_1_resid`. Cheap variant not competitive. |
|
||||
|
||||
### Read carefully (for the human)
|
||||
|
||||
1. **IC edge is real but small.** Sector residual IC 0.0578 / t 2.34 vs market
|
||||
residual 0.0552 / t 1.98 on the **same** 35 windows — better consistency
|
||||
(ic+ 65.7% vs 60%) and slightly higher mean, not a different factor class.
|
||||
2. **A/B is not a clear Sharpe win.** Full-period Sharpe is flat (2.09).
|
||||
Validation Sharpe is **lower** than control (2.57 vs 2.92) and only clears
|
||||
the pre-registered “within 0.5 SE” cushion by ~0.001. Train improves;
|
||||
validation worsens — classic regime-split noise on ~2 years.
|
||||
3. **Risk side is friendly.** Full max DD improves (19.8% vs 21.4%); train DD
|
||||
also better. Matches the “lower factor vol” half of the hypothesis more than
|
||||
the “higher Sharpe” half on this window.
|
||||
4. **Survivorship / short history.** Same caveats as all current research:
|
||||
today’s constituents, ~35 independent weekly windows, one post-2021 regime
|
||||
dominant. Task 3 (history depth) should re-check IC stability before any
|
||||
wire-in.
|
||||
5. **Not shipped.** Machinery lives on the research branch; production residual
|
||||
path is untouched.
|
||||
|
||||
---
|
||||
|
||||
## What a human must decide next
|
||||
|
||||
1. **Accept or reject** replacing `mom_12_1_resid` with `mom_12_1_sector_resid`
|
||||
as the production residual (gate + 80/20 mom leg), **or** keep market residual
|
||||
and treat sector residual as research-only.
|
||||
2. If leaning accept: require **Task 3 history-depth** confirmation (IC era split
|
||||
pre/post-2021) before any production PR.
|
||||
3. Optional: run **sector-cap ≤3** A/B with full tail diagnostics (not run here).
|
||||
4. **Do not** merge this verdict into main strategy docs without review.
|
||||
5. Wire-in design (live sector map refresh, ETF series ops, fallback when sector
|
||||
missing) is a **separate** approved engineering step.
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes (research machinery)
|
||||
|
||||
| piece | role |
|
||||
|---|---|
|
||||
| `app/services/sector_map.py` | GICS→ETF map, symbol normalise, JSON load/save |
|
||||
| `app/services/backtest_service.py` | multi-factor residual; `mom_12_1_sector_resid` in `_signal_values`; demean inject |
|
||||
| `scripts/build_ticker_sector_map.py` | SP500 CSV + FMP gap fill |
|
||||
| `scripts/fetch_sector_etfs_to_snapshot.py` | Alpaca → snapshot `benchmark_prices` |
|
||||
| `scripts/run_sector_residual_research.py` | race guard, IC, optional A/B, reports |
|
||||
| `data/research/ticker_sector_map.json` | persisted labels (research only) |
|
||||
|
||||
---
|
||||
|
||||
## Artifacts
|
||||
|
||||
- JSON: `reports/sector-residual-20260719-083356.json`
|
||||
- MD copy: `reports/sector-residual-20260719-083356.md`
|
||||
@@ -0,0 +1,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())
|
||||
@@ -45,6 +45,10 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.ssl_bootstrap import bootstrap_ssl # noqa: E402
|
||||
|
||||
bootstrap_ssl()
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,706 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Production book × universe × horizon matrix (research only).
|
||||
|
||||
Four pre-registered arms — same live strategy knobs; only entry start date and
|
||||
tradable universe change. See docs/research/prod-book-universe-horizon.md.
|
||||
|
||||
A 2022-07-01 prod ~505
|
||||
B 2022-07-01 prod ∪ liquid top-1500
|
||||
C 2016-07-01 prod ~505
|
||||
D 2016-07-01 prod ∪ liquid top-1500
|
||||
|
||||
Example (MacBook, deep research.sqlite)
|
||||
---------------------------------------
|
||||
python scripts/run_prod_book_universe_matrix.py \\
|
||||
--snapshot backtest_snapshots/research.sqlite \\
|
||||
--workers 8 --allow-spawn \\
|
||||
--candidate-cache reports/.cache/prod-book-univ-cands.pkl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.ssl_bootstrap import bootstrap_ssl # noqa: E402
|
||||
|
||||
bootstrap_ssl()
|
||||
|
||||
SHORT_START = date(2022, 7, 1)
|
||||
LONG_START = date(2016, 7, 1)
|
||||
LIQUID_TOP_N = 1500
|
||||
LIQUID_MIN_PRICE = 5.0
|
||||
CACHE_VERSION = "prod-book-universe-horizon-v1"
|
||||
|
||||
ARMS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"id": "A_prod_4y_505",
|
||||
"label": "Prod book · ~4y · 505 only",
|
||||
"start": SHORT_START,
|
||||
"universe": "prod_505",
|
||||
},
|
||||
{
|
||||
"id": "B_prod_4y_505_liquid",
|
||||
"label": "Prod book · ~4y · 505 + liquid top-1500",
|
||||
"start": SHORT_START,
|
||||
"universe": "prod_plus_liquid",
|
||||
},
|
||||
{
|
||||
"id": "C_prod_2016_505",
|
||||
"label": "Prod book · since 2016-07 · 505 only",
|
||||
"start": LONG_START,
|
||||
"universe": "prod_505",
|
||||
},
|
||||
{
|
||||
"id": "D_prod_2016_505_liquid",
|
||||
"label": "Prod book · since 2016-07 · 505 + liquid top-1500",
|
||||
"start": LONG_START,
|
||||
"universe": "prod_plus_liquid",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--snapshot", default="backtest_snapshots/research.sqlite")
|
||||
p.add_argument("--workers", type=int, default=8)
|
||||
p.add_argument("--allow-spawn", action="store_true")
|
||||
p.add_argument("--quiet", action="store_true")
|
||||
p.add_argument(
|
||||
"--candidate-cache",
|
||||
default="reports/.cache/prod-book-universe-cands.pkl",
|
||||
help="Pickle cache for full GTL candidate pass (expensive).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--rebuild-cache",
|
||||
action="store_true",
|
||||
help="Ignore existing candidate cache.",
|
||||
)
|
||||
p.add_argument("--out", default=None)
|
||||
p.add_argument(
|
||||
"--skip-race-guard",
|
||||
action="store_true",
|
||||
help="Allow run without completion manifest (not recommended).",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def _sqlite_url(path: Path) -> str:
|
||||
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
|
||||
|
||||
|
||||
def _load_prod_and_all_symbols(snapshot: Path) -> tuple[set[str], list[str]]:
|
||||
engine = create_engine(
|
||||
f"sqlite:///{snapshot.resolve().as_posix()}",
|
||||
future=True,
|
||||
)
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
all_syms = [
|
||||
str(r[0]).upper()
|
||||
for r in conn.execute(text("SELECT symbol FROM tickers ORDER BY 1"))
|
||||
]
|
||||
try:
|
||||
rank_only = {
|
||||
str(r[0]).upper()
|
||||
for r in conn.execute(text("SELECT symbol FROM research_rank_only"))
|
||||
}
|
||||
except Exception:
|
||||
rank_only = set()
|
||||
finally:
|
||||
engine.dispose()
|
||||
prod = {s for s in all_syms if s not in rank_only}
|
||||
return prod, all_syms
|
||||
|
||||
|
||||
def _median(xs: list[float]) -> float | None:
|
||||
if len(xs) < 20:
|
||||
return None
|
||||
s = sorted(xs)
|
||||
mid = len(s) // 2
|
||||
if len(s) % 2:
|
||||
return s[mid]
|
||||
return 0.5 * (s[mid - 1] + s[mid])
|
||||
|
||||
|
||||
def _build_liquid_membership(
|
||||
prices: dict[str, tuple],
|
||||
*,
|
||||
top_n: int,
|
||||
min_price: float,
|
||||
) -> dict[date, set[str]]:
|
||||
"""For each calendar date present in any series, top-N by 63d median $vol."""
|
||||
# Collect per-symbol (date -> (close, dvol63))
|
||||
per_sym: dict[str, dict[date, tuple[float, float | None]]] = {}
|
||||
all_dates: set[date] = set()
|
||||
for sym, cols in prices.items():
|
||||
ords, _o, _h, _l, closes, vols = cols
|
||||
dates = [date.fromordinal(int(o)) for o in ords]
|
||||
n = len(dates)
|
||||
series: dict[date, tuple[float, float | None]] = {}
|
||||
for i in range(n):
|
||||
d = dates[i]
|
||||
c = float(closes[i])
|
||||
dvol = None
|
||||
if i + 1 >= 63:
|
||||
dvs = []
|
||||
for k in range(i - 62, i + 1):
|
||||
ck = float(closes[k])
|
||||
vk = float(vols[k] or 0)
|
||||
if ck > 0 and vk >= 0:
|
||||
dvs.append(ck * vk)
|
||||
dvol = _median(dvs)
|
||||
series[d] = (c, dvol)
|
||||
all_dates.add(d)
|
||||
per_sym[sym] = series
|
||||
|
||||
membership: dict[date, set[str]] = {}
|
||||
for d in sorted(all_dates):
|
||||
eligible: list[tuple[float, str]] = []
|
||||
for sym, series in per_sym.items():
|
||||
row = series.get(d)
|
||||
if row is None:
|
||||
continue
|
||||
c, dvol = row
|
||||
if c < min_price or dvol is None or dvol <= 0:
|
||||
continue
|
||||
eligible.append((-dvol, sym)) # highest dvol first
|
||||
eligible.sort()
|
||||
membership[d] = {sym for _, sym in eligible[:top_n]}
|
||||
return membership
|
||||
|
||||
|
||||
def _worker_replay(
|
||||
symbol: str,
|
||||
columns: tuple,
|
||||
config: dict,
|
||||
activation: dict,
|
||||
spy: dict,
|
||||
cadence: str,
|
||||
) -> list[dict]:
|
||||
"""Picklable full GTL+signals candidate replay (no signal-only)."""
|
||||
from app.services import backtest_service as bt
|
||||
|
||||
cands, _series = bt._replay_and_signals(
|
||||
symbol,
|
||||
columns,
|
||||
config,
|
||||
activation,
|
||||
spy,
|
||||
bt.PRODUCTION_GTL_TARGET_MODEL,
|
||||
cadence,
|
||||
False, # always full replay for book matrix
|
||||
None,
|
||||
None,
|
||||
)
|
||||
return cands
|
||||
|
||||
|
||||
async def _load_or_build_candidates(
|
||||
snapshot: Path,
|
||||
*,
|
||||
cache_path: Path | None,
|
||||
rebuild: bool,
|
||||
workers: int,
|
||||
quiet: bool,
|
||||
) -> tuple[list[dict], dict[str, tuple], dict, set[str], dict]:
|
||||
from app.config import settings
|
||||
from app.services import backtest_service as bt
|
||||
from app.services.admin_service import get_activation_config
|
||||
from app.services.recommendation_service import get_recommendation_config
|
||||
from app.services.paper_trade_service import get_exit_policy
|
||||
from app.services.benchmark_service import load_benchmark_closes
|
||||
from app.models.ticker import Ticker
|
||||
from sqlalchemy import select
|
||||
|
||||
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
||||
settings.backtest_workers = max(1, workers)
|
||||
|
||||
prod_set, all_syms = _load_prod_and_all_symbols(snapshot)
|
||||
print(f"Symbols: all={len(all_syms)} prod_505={len(prod_set)}")
|
||||
|
||||
cache_key = {
|
||||
"version": CACHE_VERSION,
|
||||
"snapshot": str(snapshot.resolve()),
|
||||
"prod_n": len(prod_set),
|
||||
"all_n": len(all_syms),
|
||||
}
|
||||
if cache_path and cache_path.exists() and not rebuild:
|
||||
with cache_path.open("rb") as fh:
|
||||
blob = pickle.load(fh)
|
||||
if blob.get("key") == cache_key and blob.get("candidates"):
|
||||
print(f"Loaded candidate cache: {cache_path} ({len(blob['candidates'])} rows)")
|
||||
return (
|
||||
blob["candidates"],
|
||||
blob["prices"],
|
||||
blob["spy"],
|
||||
set(blob["prod_set"]),
|
||||
blob["exit_config"],
|
||||
)
|
||||
print("Cache key mismatch — rebuilding candidates")
|
||||
|
||||
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
||||
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
candidates: list[dict] = []
|
||||
prices: dict[str, tuple] = {}
|
||||
try:
|
||||
async with Session() as db:
|
||||
config = await get_recommendation_config(db)
|
||||
activation = await get_activation_config(db)
|
||||
exit_config = await get_exit_policy(db)
|
||||
spy = await load_benchmark_closes(db, "SPY")
|
||||
tickers = list(
|
||||
(await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars()
|
||||
)
|
||||
|
||||
# Fetch all price columns first (I/O).
|
||||
for idx, t in enumerate(tickers):
|
||||
if not quiet and idx % 100 == 0:
|
||||
print(f" fetch prices {idx}/{len(tickers)}", end="\r", flush=True)
|
||||
cols = await bt._fetch_columns(db, t.symbol)
|
||||
if cols is not None:
|
||||
prices[t.symbol.upper()] = cols
|
||||
if not quiet:
|
||||
print()
|
||||
|
||||
# Parallel GTL replay for every symbol with prices.
|
||||
syms = sorted(prices)
|
||||
print(f"GTL replay on {len(syms)} symbols (workers={workers})…")
|
||||
t0 = time.monotonic()
|
||||
if workers <= 1:
|
||||
for i, sym in enumerate(syms):
|
||||
if not quiet and i % 50 == 0:
|
||||
print(f" replay {i}/{len(syms)}", end="\r", flush=True)
|
||||
candidates.extend(
|
||||
_worker_replay(
|
||||
sym, prices[sym], config, activation, spy, "weekly"
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Process pool: pass column batches.
|
||||
import multiprocessing as mp
|
||||
|
||||
ctx = mp.get_context("spawn")
|
||||
chunk = max(1, workers * 2)
|
||||
with ProcessPoolExecutor(max_workers=workers, mp_context=ctx) as pool:
|
||||
for start in range(0, len(syms), chunk):
|
||||
batch = syms[start : start + chunk]
|
||||
futs = [
|
||||
pool.submit(
|
||||
_worker_replay,
|
||||
sym,
|
||||
prices[sym],
|
||||
config,
|
||||
activation,
|
||||
spy,
|
||||
"weekly",
|
||||
)
|
||||
for sym in batch
|
||||
]
|
||||
for fut in futs:
|
||||
try:
|
||||
candidates.extend(fut.result())
|
||||
except Exception as exc:
|
||||
print(f" worker error: {exc}")
|
||||
if not quiet:
|
||||
print(
|
||||
f" replay {min(start+chunk, len(syms))}/{len(syms)} "
|
||||
f"cands={len(candidates)} "
|
||||
f"elapsed={(time.monotonic()-t0)/60:.1f}m",
|
||||
end="\r",
|
||||
flush=True,
|
||||
)
|
||||
if not quiet:
|
||||
print()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
print(f"Total raw candidates: {len(candidates)}")
|
||||
if cache_path:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with cache_path.open("wb") as fh:
|
||||
pickle.dump(
|
||||
{
|
||||
"key": cache_key,
|
||||
"candidates": candidates,
|
||||
"prices": prices,
|
||||
"spy": spy,
|
||||
"prod_set": sorted(prod_set),
|
||||
"exit_config": exit_config,
|
||||
},
|
||||
fh,
|
||||
protocol=pickle.HIGHEST_PROTOCOL,
|
||||
)
|
||||
print(f"Wrote cache {cache_path}")
|
||||
|
||||
return candidates, prices, spy, prod_set, exit_config
|
||||
|
||||
|
||||
def _candidate_eligible(
|
||||
cand: dict,
|
||||
*,
|
||||
prod_set: set[str],
|
||||
universe: str,
|
||||
liquid_by_date: dict[date, set[str]],
|
||||
) -> bool:
|
||||
if cand.get("direction") != "long":
|
||||
return False
|
||||
sym = str(cand.get("symbol") or "").upper()
|
||||
if not sym:
|
||||
return False
|
||||
if universe == "prod_505":
|
||||
return sym in prod_set
|
||||
# prod_plus_liquid
|
||||
if sym in prod_set:
|
||||
return True
|
||||
try:
|
||||
d = date.fromisoformat(str(cand["date"])[:10])
|
||||
except Exception:
|
||||
return False
|
||||
return sym in (liquid_by_date.get(d) or set())
|
||||
|
||||
|
||||
def _run_arm(
|
||||
arm: dict[str, Any],
|
||||
*,
|
||||
all_candidates: list[dict],
|
||||
prices: dict[str, tuple],
|
||||
spy: dict,
|
||||
prod_set: set[str],
|
||||
liquid_by_date: dict[date, set[str]],
|
||||
exit_config: dict,
|
||||
) -> dict[str, Any]:
|
||||
from app.services import backtest_service as bt
|
||||
|
||||
start: date = arm["start"]
|
||||
universe: str = arm["universe"]
|
||||
|
||||
filtered: list[dict] = []
|
||||
for c in all_candidates:
|
||||
try:
|
||||
d = date.fromisoformat(str(c["date"])[:10])
|
||||
except Exception:
|
||||
continue
|
||||
if d < start:
|
||||
continue
|
||||
if not _candidate_eligible(
|
||||
c, prod_set=prod_set, universe=universe, liquid_by_date=liquid_by_date
|
||||
):
|
||||
continue
|
||||
filtered.append(dict(c))
|
||||
|
||||
# Re-rank inside this arm's universe (production percentile logic).
|
||||
bt._assign_momentum_percentiles(filtered)
|
||||
bt._assign_residual_momentum_percentiles(filtered)
|
||||
bt._assign_low_volatility_percentiles(filtered)
|
||||
bt._assign_activation_momentum_percentiles(filtered)
|
||||
bt._assign_residual_high_vol_blend(filtered)
|
||||
for c in filtered:
|
||||
c["qualified"] = bt._momentum_qualifies(c, 80.0)
|
||||
|
||||
longs = [
|
||||
c for c in filtered if c.get("qualified") and c.get("direction") == "long"
|
||||
]
|
||||
|
||||
strategy = next(s for s in bt.PORTFOLIO_MONITOR_STRATEGIES if s.get("is_production"))
|
||||
entry_cfg = bt._entry_variant_config(str(strategy["entry_variant"]))
|
||||
assert entry_cfg is not None
|
||||
ranking_key = str(
|
||||
entry_cfg.get("ranking_key") or entry_cfg["percentile_key"]
|
||||
)
|
||||
exit_policy = bt.LIVE_EXIT_MODE_TO_SIM.get(
|
||||
str(exit_config.get("mode", "atr_trailing")), "atr_trail3"
|
||||
)
|
||||
hold_days = int(exit_config.get("hold_days", 30))
|
||||
trail = float(exit_config.get("atr_multiplier", bt.ATR_TRAIL_MULTIPLIER))
|
||||
risk = float(entry_cfg["risk_per_trade"])
|
||||
max_pos = int(entry_cfg["max_positions"])
|
||||
|
||||
reentry = bt._make_gate_reset_reentry_fn(
|
||||
longs, prices, cadence="weekly", ranking_key=ranking_key
|
||||
)
|
||||
sim = bt._simulate_portfolio(
|
||||
longs,
|
||||
prices,
|
||||
spy,
|
||||
exit_policy,
|
||||
hold_days,
|
||||
ranking_key=ranking_key,
|
||||
max_positions=max_pos,
|
||||
risk_per_trade=risk,
|
||||
atr_trail_multiplier=trail,
|
||||
post_stop_reentry_fn=reentry,
|
||||
start_date=start,
|
||||
end_date=None,
|
||||
fill_mode=bt.FILL_MODE_CLOSE,
|
||||
include_trades=False,
|
||||
)
|
||||
if sim is None:
|
||||
return {
|
||||
"id": arm["id"],
|
||||
"label": arm["label"],
|
||||
"start": start.isoformat(),
|
||||
"universe": universe,
|
||||
"n_candidates": len(filtered),
|
||||
"n_qualified_longs": 0,
|
||||
"error": "no_trades",
|
||||
}
|
||||
|
||||
keep = {
|
||||
k: sim.get(k)
|
||||
for k in (
|
||||
"sharpe",
|
||||
"sharpe_se",
|
||||
"cagr_pct",
|
||||
"max_drawdown_pct",
|
||||
"total_return_pct",
|
||||
"calmar",
|
||||
"trades",
|
||||
"win_rate",
|
||||
"n_returns",
|
||||
"psr",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"spy_return_pct",
|
||||
"final_equity",
|
||||
)
|
||||
}
|
||||
return {
|
||||
"id": arm["id"],
|
||||
"label": arm["label"],
|
||||
"start": start.isoformat(),
|
||||
"universe": universe,
|
||||
"n_candidates": len(filtered),
|
||||
"n_qualified_longs": len(longs),
|
||||
"fill_mode": "close",
|
||||
"ranking_key": ranking_key,
|
||||
"exit_policy": exit_policy,
|
||||
"hold_days": hold_days,
|
||||
**keep,
|
||||
}
|
||||
|
||||
|
||||
def _write_outputs(payload: dict, out_json: Path, doc_path: Path) -> None:
|
||||
out_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_json.write_text(
|
||||
json.dumps(payload, indent=2, default=str) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
lines = [
|
||||
"# Production book × universe × horizon — results",
|
||||
"",
|
||||
f"Generated: `{payload.get('generated_at')}`",
|
||||
"",
|
||||
"> Survivorship: today's constituents backfilled. Compare arms relatively; "
|
||||
"do not treat deep CAGR/Sharpe levels as deployable forecasts.",
|
||||
"",
|
||||
"## Arms",
|
||||
"",
|
||||
"| arm | universe | entry start | Sharpe | SE | CAGR % | max DD % | ret % | trades | qual longs | span |",
|
||||
"|---|---|---|---:|---:|---:|---:|---:|---:|---:|---|",
|
||||
]
|
||||
for row in payload.get("arms") or []:
|
||||
if row.get("error"):
|
||||
lines.append(
|
||||
f"| {row.get('id')} | {row.get('universe')} | {row.get('start')} | "
|
||||
f"ERR | | | | | | {row.get('n_qualified_longs')} | {row.get('error')} |"
|
||||
)
|
||||
continue
|
||||
lines.append(
|
||||
f"| {row.get('id')} | {row.get('universe')} | {row.get('start')} | "
|
||||
f"{row.get('sharpe')} | {row.get('sharpe_se')} | {row.get('cagr_pct')} | "
|
||||
f"{row.get('max_drawdown_pct')} | {row.get('total_return_pct')} | "
|
||||
f"{row.get('trades')} | {row.get('n_qualified_longs')} | "
|
||||
f"{row.get('start_date')}→{row.get('end_date')} |"
|
||||
)
|
||||
lines.extend([
|
||||
"",
|
||||
"## Config (production, unchanged)",
|
||||
"",
|
||||
f"```json\n{json.dumps(payload.get('strategy') or {}, indent=2)}\n```",
|
||||
"",
|
||||
"## Snapshot",
|
||||
"",
|
||||
f"```json\n{json.dumps(payload.get('snapshot_meta') or {}, indent=2, default=str)}\n```",
|
||||
"",
|
||||
"PENDING_HUMAN — descriptive matrix only; no auto promotion.",
|
||||
"",
|
||||
f"JSON: `{out_json.as_posix()}`",
|
||||
"",
|
||||
])
|
||||
out_json.with_suffix(".md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
# Fill results section of the research doc.
|
||||
if doc_path.exists():
|
||||
text = doc_path.read_text(encoding="utf-8")
|
||||
marker = "## Results"
|
||||
idx = text.find(marker)
|
||||
header = text[:idx] if idx >= 0 else text
|
||||
# Drop old results/verdict tail
|
||||
for m in ("## Results", "## Verdict"):
|
||||
pass
|
||||
body = [
|
||||
header.rstrip(),
|
||||
"",
|
||||
"## Results",
|
||||
"",
|
||||
f"Generated: `{payload.get('generated_at')}`",
|
||||
"",
|
||||
"| arm | universe | entry start | Sharpe | SE | CAGR % | max DD % | trades |",
|
||||
"|---|---|---|---:|---:|---:|---:|---:|",
|
||||
]
|
||||
for row in payload.get("arms") or []:
|
||||
body.append(
|
||||
f"| {row.get('id')} | {row.get('universe')} | {row.get('start')} | "
|
||||
f"{row.get('sharpe', '')} | {row.get('sharpe_se', '')} | "
|
||||
f"{row.get('cagr_pct', '')} | {row.get('max_drawdown_pct', '')} | "
|
||||
f"{row.get('trades', '')} |"
|
||||
)
|
||||
body.extend([
|
||||
"",
|
||||
f"Full report: `{out_json.as_posix()}`",
|
||||
"",
|
||||
"## Verdict",
|
||||
"",
|
||||
"**PENDING_HUMAN** — descriptive only; production knobs unchanged.",
|
||||
"",
|
||||
])
|
||||
doc_path.write_text("\n".join(body) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
async def _main() -> None:
|
||||
args = _parse_args()
|
||||
snapshot = Path(args.snapshot)
|
||||
if not snapshot.exists():
|
||||
raise SystemExit(f"Missing snapshot: {snapshot}")
|
||||
if args.allow_spawn:
|
||||
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
|
||||
|
||||
if not args.skip_race_guard:
|
||||
try:
|
||||
from scripts.research_snapshot_manifest import (
|
||||
assert_research_snapshot_complete,
|
||||
)
|
||||
|
||||
manifest = assert_research_snapshot_complete(snapshot)
|
||||
print(
|
||||
f"Race guard OK: tickers={manifest.get('ticker_count')} "
|
||||
f"ohlcv={manifest.get('ohlcv_row_count')}"
|
||||
)
|
||||
except SystemExit as exc:
|
||||
# Prod-only snapshot without manifest: allow with warning if ~505.
|
||||
engine = create_engine(
|
||||
f"sqlite:///{snapshot.resolve().as_posix()}",
|
||||
future=True,
|
||||
)
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
n = int(conn.execute(text("SELECT COUNT(*) FROM tickers")).scalar_one())
|
||||
finally:
|
||||
engine.dispose()
|
||||
if n < 400:
|
||||
raise
|
||||
print(f"WARNING: no research manifest ({exc}); proceeding n_tickers={n}")
|
||||
|
||||
cache = Path(args.candidate_cache) if args.candidate_cache else None
|
||||
candidates, prices, spy, prod_set, exit_config = await _load_or_build_candidates(
|
||||
snapshot,
|
||||
cache_path=cache,
|
||||
rebuild=args.rebuild_cache,
|
||||
workers=args.workers,
|
||||
quiet=args.quiet,
|
||||
)
|
||||
|
||||
print("Building PIT liquid membership (top-1500, price≥5)…")
|
||||
t0 = time.monotonic()
|
||||
liquid_by_date = _build_liquid_membership(
|
||||
prices, top_n=LIQUID_TOP_N, min_price=LIQUID_MIN_PRICE
|
||||
)
|
||||
print(
|
||||
f" liquid dates={len(liquid_by_date)} "
|
||||
f"elapsed={(time.monotonic()-t0)/60:.1f}m"
|
||||
)
|
||||
|
||||
arms_out = []
|
||||
for arm in ARMS:
|
||||
print(f"Running arm {arm['id']}…")
|
||||
row = _run_arm(
|
||||
arm,
|
||||
all_candidates=candidates,
|
||||
prices=prices,
|
||||
spy=spy,
|
||||
prod_set=prod_set,
|
||||
liquid_by_date=liquid_by_date,
|
||||
exit_config=exit_config,
|
||||
)
|
||||
arms_out.append(row)
|
||||
print(
|
||||
f" Sharpe={row.get('sharpe')} CAGR={row.get('cagr_pct')} "
|
||||
f"DD={row.get('max_drawdown_pct')} trades={row.get('trades')} "
|
||||
f"qual={row.get('n_qualified_longs')}"
|
||||
)
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
out = (
|
||||
Path(args.out)
|
||||
if args.out
|
||||
else Path("reports") / f"prod-book-universe-horizon-{stamp}.json"
|
||||
)
|
||||
payload = {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"snapshot": str(snapshot.resolve()),
|
||||
"snapshot_meta": {
|
||||
"prod_universe_n": len(prod_set),
|
||||
"price_symbols_n": len(prices),
|
||||
"raw_candidates": len(candidates),
|
||||
"liquid_top_n": LIQUID_TOP_N,
|
||||
"liquid_min_price": LIQUID_MIN_PRICE,
|
||||
"short_start": SHORT_START.isoformat(),
|
||||
"long_start": LONG_START.isoformat(),
|
||||
},
|
||||
"strategy": {
|
||||
"note": "Live production knobs — no modifications",
|
||||
"momentum": "residual_12_1 gate 80",
|
||||
"rank": "residual_high_vol_blend_80_20",
|
||||
"fill_mode": "close",
|
||||
"cost_per_side": 0.001,
|
||||
"exit": exit_config,
|
||||
"max_positions": 10,
|
||||
"risk_per_trade": 0.01,
|
||||
"reentry": "gate_reset",
|
||||
},
|
||||
"arms": arms_out,
|
||||
"survivorship_banner": (
|
||||
"Today's constituents backfilled. Relative arm comparison only."
|
||||
),
|
||||
"pending_human": True,
|
||||
}
|
||||
_write_outputs(
|
||||
payload,
|
||||
out,
|
||||
Path("docs/research/prod-book-universe-horizon.md"),
|
||||
)
|
||||
print(f"Wrote {out}")
|
||||
print(f"Wrote {out.with_suffix('.md')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(_main())
|
||||
Executable
+139
@@ -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."
|
||||
Reference in New Issue
Block a user