research: sector residual, earnings gap/SUE, history-depth scaffolding

Tier-1 alpha research (local only, no production deploy):

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

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

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

Do not ship production residual or filters from this branch.
This commit is contained in:
2026-07-19 09:33:34 +02:00
parent 8f285acb00
commit fa25b6ee68
18 changed files with 6093 additions and 25 deletions
+528
View File
@@ -0,0 +1,528 @@
"""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))
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())
+229
View File
@@ -0,0 +1,229 @@
"""Build a local ticker → GICS sector map for research residualization.
Sources (in order):
1. Public S&P 500 constituents CSV (datasets/s-and-p-500-companies) — bulk, free.
2. Existing map file (resume).
3. FMP stable ``profile`` for still-missing symbols (budget ~250 req/day).
Writes ``data/research/ticker_sector_map.json``. Never touches production Postgres.
Example
-------
python scripts/build_ticker_sector_map.py \\
--snapshot backtest_snapshots/prod.sqlite
python scripts/build_ticker_sector_map.py --fmp-limit 50
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import io
import json
import sys
import time
from datetime import datetime, 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.services.sector_map import ( # noqa: E402
DEFAULT_SECTOR_MAP_PATH,
coverage_stats,
load_ticker_sector_map,
normalise_symbol,
save_ticker_sector_map,
sector_to_etf,
)
SP500_CSV_URL = (
"https://raw.githubusercontent.com/datasets/s-and-p-500-companies/"
"master/data/constituents.csv"
)
FMP_STABLE = "https://financialmodelingprep.com/stable"
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument(
"--snapshot",
default="backtest_snapshots/prod.sqlite",
help="Snapshot whose tickers define the universe.",
)
p.add_argument(
"--out",
default=str(DEFAULT_SECTOR_MAP_PATH),
help="Output JSON path.",
)
p.add_argument(
"--fmp-limit",
type=int,
default=200,
help="Max FMP profile requests this run (free-tier cushion).",
)
p.add_argument(
"--skip-fmp",
action="store_true",
help="Only use public SP500 CSV + existing map.",
)
p.add_argument("--sleep", type=float, default=0.35, help="Pause between FMP calls.")
return p.parse_args()
def _snapshot_symbols(snapshot: Path) -> list[str]:
engine = create_engine(f"sqlite:///{snapshot.resolve().as_posix()}", future=True)
try:
with engine.connect() as conn:
rows = conn.execute(text("SELECT symbol FROM tickers ORDER BY symbol")).fetchall()
finally:
engine.dispose()
return [normalise_symbol(r[0]) for r in rows if r[0]]
def _fetch_sp500_map() -> dict[str, str]:
with httpx.Client(timeout=60.0, follow_redirects=True) as client:
resp = client.get(SP500_CSV_URL)
resp.raise_for_status()
reader = csv.DictReader(io.StringIO(resp.text))
out: dict[str, str] = {}
for row in reader:
sym = normalise_symbol(row.get("Symbol") or "")
sector = (row.get("GICS Sector") or "").strip()
if sym and sector:
out[sym] = sector
return out
async def _fmp_profile_sector(client: httpx.AsyncClient, api_key: str, symbol: str) -> str | None:
resp = await client.get(
f"{FMP_STABLE}/profile",
params={"symbol": symbol, "apikey": api_key},
)
if resp.status_code == 429:
raise RuntimeError(f"FMP rate limited on {symbol}")
if resp.status_code == 402:
return None
resp.raise_for_status()
data = resp.json()
if isinstance(data, list):
data = data[0] if data else {}
if not isinstance(data, dict):
return None
sector = (data.get("sector") or data.get("industry") or "").strip()
# industry alone is not a GICS sector — only accept if we can map to an ETF
if sector and sector_to_etf(sector):
return sector
# FMP sometimes returns industry under sector when sector missing; try sector field only
sec = (data.get("sector") or "").strip()
return sec or None
async def _fill_from_fmp(
missing: list[str],
*,
api_key: str,
limit: int,
sleep_s: float,
) -> tuple[dict[str, str], int]:
filled: dict[str, str] = {}
used = 0
async with httpx.AsyncClient(timeout=30.0) as client:
for sym in missing:
if used >= limit:
break
try:
sector = await _fmp_profile_sector(client, api_key, sym)
except Exception as exc:
print(f" FMP fail {sym}: {exc}")
used += 1
await asyncio.sleep(sleep_s)
continue
used += 1
if sector:
filled[sym] = sector
print(f" FMP {sym}{sector}")
else:
print(f" FMP {sym} → (no sector)")
if sleep_s > 0:
await asyncio.sleep(sleep_s)
return filled, used
async def _main() -> None:
args = _parse_args()
snapshot = Path(args.snapshot)
if not snapshot.exists():
raise SystemExit(f"Snapshot not found: {snapshot}")
symbols = _snapshot_symbols(snapshot)
print(f"Universe: {len(symbols)} symbols from {snapshot}")
existing = load_ticker_sector_map(args.out)
print(f"Existing map entries: {len(existing)}")
print("Fetching public S&P 500 sector CSV…")
sp500 = _fetch_sp500_map()
print(f" SP500 CSV rows: {len(sp500)}")
mapping = dict(existing)
from_sp500 = 0
for sym in symbols:
if sym in mapping:
continue
if sym in sp500:
mapping[sym] = sp500[sym]
from_sp500 += 1
print(f" Newly filled from SP500 CSV: {from_sp500}")
missing = [s for s in symbols if s not in mapping]
fmp_used = 0
from_fmp = 0
if missing and not args.skip_fmp:
from app.config import settings
if not settings.fmp_api_key:
print("WARNING: FMP key missing; leaving gaps unfilled")
else:
print(f"FMP fill for {len(missing)} missing (limit={args.fmp_limit})…")
filled, fmp_used = await _fill_from_fmp(
missing,
api_key=settings.fmp_api_key,
limit=int(args.fmp_limit),
sleep_s=float(args.sleep),
)
mapping.update(filled)
from_fmp = len(filled)
still_missing = [s for s in symbols if s not in mapping]
stats = coverage_stats(symbols, mapping)
meta = {
"built_at": datetime.now(timezone.utc).isoformat(),
"snapshot": str(snapshot.resolve()),
"from_existing": len(existing),
"from_sp500_csv": from_sp500,
"from_fmp": from_fmp,
"fmp_requests": fmp_used,
"still_missing": still_missing,
"coverage": {
k: stats[k]
for k in ("universe", "mapped", "mapped_pct", "with_etf", "by_sector")
},
}
out_path = save_ticker_sector_map(mapping, args.out, meta=meta)
print(f"Wrote {out_path}")
print(json.dumps(meta["coverage"], indent=2))
if still_missing:
print(f"Still missing ({len(still_missing)}): {still_missing[:40]}")
if len(still_missing) > 40:
print(f" … +{len(still_missing) - 40} more")
if __name__ == "__main__":
asyncio.run(_main())
+183
View File
@@ -0,0 +1,183 @@
"""Fetch the 11 SPDR sector ETFs into a snapshot's ``benchmark_prices``.
Research-only. Sector ETFs are auxiliary series (like SPY) — they must not
enter the tradable ticker universe or candidate replay. Storing them in
``benchmark_prices`` keeps that invariant.
Also refreshes SPY on the same window so residual factors share a calendar.
Example
-------
python scripts/fetch_sector_etfs_to_snapshot.py \\
--snapshot backtest_snapshots/prod.sqlite --history-days 2200
"""
from __future__ import annotations
import argparse
import asyncio
import sys
import time
from datetime import date, timedelta
from pathlib import Path
from sqlalchemy import create_engine, text
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.services.sector_map import SECTOR_ETFS # noqa: E402
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--snapshot", default="backtest_snapshots/prod.sqlite")
p.add_argument(
"--history-days",
type=int,
default=2200,
help="Lookback calendar days (default ~6y; covers 5y snapshot + cushion).",
)
p.add_argument("--sleep", type=float, default=0.25)
p.add_argument(
"--symbols",
default=None,
help="Comma-separated override (default: SPY + 11 sector ETFs).",
)
return p.parse_args()
async def _fetch_and_upsert(
engine,
provider,
symbol: str,
start: date,
end: date,
*,
sleep_s: float,
) -> int:
from app.exceptions import ProviderError, RateLimitError
for attempt in range(5):
try:
bars = await provider.fetch_ohlcv(symbol, start, end)
break
except RateLimitError:
wait = min(60.0, 2.0 ** attempt)
print(f" rate limited {symbol}; sleep {wait:.0f}s")
await asyncio.sleep(wait)
bars = []
except ProviderError as exc:
if attempt + 1 >= 5:
raise
await asyncio.sleep(1.0)
print(f" retry {symbol}: {exc}")
bars = []
else:
bars = []
if sleep_s > 0:
await asyncio.sleep(sleep_s)
if not bars:
print(f" {symbol}: empty")
return 0
written = 0
with engine.begin() as conn:
for bar in bars:
d = bar.date.isoformat() if hasattr(bar.date, "isoformat") else str(bar.date)
close = float(bar.close)
existing = conn.execute(
text(
"SELECT id, close FROM benchmark_prices "
"WHERE symbol = :sym AND date = :d"
),
{"sym": symbol, "d": d},
).fetchone()
if existing is None:
# id is INTEGER PK — let sqlite autoincrement if possible
conn.execute(
text(
"INSERT INTO benchmark_prices (symbol, date, close) "
"VALUES (:sym, :d, :c)"
),
{"sym": symbol, "d": d, "c": close},
)
written += 1
elif abs(float(existing[1]) - close) > 1e-9:
conn.execute(
text(
"UPDATE benchmark_prices SET close = :c WHERE id = :id"
),
{"c": close, "id": int(existing[0])},
)
written += 1
print(f" {symbol}: {len(bars)} bars, {written} rows written/updated")
return written
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
from app.providers.alpaca import AlpacaOHLCVProvider
if not settings.alpaca_api_key or not settings.alpaca_api_secret:
raise SystemExit("ALPACA_API_KEY / ALPACA_API_SECRET required")
if args.symbols:
symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()]
else:
symbols = ["SPY", *SECTOR_ETFS]
end = date.today()
start = end - timedelta(days=int(args.history_days))
provider = AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret)
engine = create_engine(
f"sqlite:///{snapshot.resolve().as_posix()}",
future=True,
)
print(f"Snapshot: {snapshot}")
print(f"Window: {start}{end}")
print(f"Symbols: {symbols}")
t0 = time.monotonic()
total = 0
try:
for sym in symbols:
n = await _fetch_and_upsert(
engine, provider, sym, start, end, sleep_s=float(args.sleep)
)
total += n
finally:
engine.dispose()
# Summary counts
engine = create_engine(
f"sqlite:///{snapshot.resolve().as_posix()}",
future=True,
)
try:
with engine.connect() as conn:
rows = conn.execute(
text(
"SELECT symbol, COUNT(*), MIN(date), MAX(date) "
"FROM benchmark_prices GROUP BY symbol ORDER BY symbol"
)
).fetchall()
finally:
engine.dispose()
print(f"Done in {(time.monotonic() - t0) / 60:.1f}m; rows touched={total}")
for sym, n, d0, d1 in rows:
print(f" {sym}: n={n} {d0}{d1}")
if __name__ == "__main__":
asyncio.run(_main())
+927
View File
@@ -0,0 +1,927 @@
"""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))
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"
# Load sector map if present so sector signals also appear (side-by-side optional).
if Path("data/research/ticker_sector_map.json").exists():
os.environ["BACKTEST_SECTOR_MAP_PATH"] = str(
Path("data/research/ticker_sector_map.json").resolve()
)
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")
sector_etf: dict[str, dict] = {}
try:
from app.services.sector_map import SECTOR_ETFS, load_ticker_sector_map
symbol_to_sector = load_ticker_sector_map()
for etf in SECTOR_ETFS:
series = await load_benchmark_closes(db, etf)
if series:
sector_etf[etf] = series
except Exception:
symbol_to_sector = {}
sector_etf = {}
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,
sector_etf_closes=bt._sector_etf_closes_for_symbol(
t.symbol, symbol_to_sector, sector_etf
),
)
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()
if symbol_to_sector:
bt._inject_sector_demeaned_momentum(collected, symbol_to_sector)
# 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",
"mom_12_1_sector_resid",
"mom_12_1_sector_demeaned",
"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",
"mom_12_1_sector_resid",
"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())
+476
View File
@@ -0,0 +1,476 @@
"""History-depth extension research (local / MacBook).
Phases
------
coverage — bars per calendar year; no rebuild
harness — race-guard snapshot, full signal_eval, era split pre/post-2021
Does not retune production knobs. Does not modify scheduler/gates.
Example
-------
python scripts/run_history_depth_research.py --phase coverage \\
--snapshot backtest_snapshots/prod.sqlite
python scripts/run_history_depth_research.py --phase harness \\
--snapshot backtest_snapshots/research.sqlite --workers 8 --allow-spawn
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from collections import defaultdict
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))
ERA_SPLIT = date(2021, 1, 1)
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 — not levels."
)
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("--phase", choices=("coverage", "harness", "all"), default="all")
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("--out", default=None)
return p.parse_args()
def _coverage_report(snapshot: Path) -> dict[str, Any]:
engine = create_engine(
f"sqlite:///{snapshot.resolve().as_posix()}",
future=True,
)
try:
with engine.connect() as conn:
ticker_n = int(conn.execute(text("SELECT COUNT(*) FROM tickers")).scalar_one())
ohlcv_n = int(
conn.execute(text("SELECT COUNT(*) FROM ohlcv_records")).scalar_one()
)
d_range = conn.execute(
text("SELECT MIN(date), MAX(date) FROM ohlcv_records")
).fetchone()
# Bars per calendar year (global).
by_year = conn.execute(
text(
"""
SELECT substr(date, 1, 4) AS y, COUNT(*) AS n,
COUNT(DISTINCT ticker_id) AS tickers
FROM ohlcv_records
GROUP BY substr(date, 1, 4)
ORDER BY y
"""
)
).fetchall()
# Per-symbol min/max date + bar count (summary percentiles).
per_sym = conn.execute(
text(
"""
SELECT t.symbol, COUNT(*) AS n, MIN(o.date), MAX(o.date)
FROM ohlcv_records o
JOIN tickers t ON t.id = o.ticker_id
GROUP BY t.symbol
"""
)
).fetchall()
finally:
engine.dispose()
ns = sorted(int(r[1]) for r in per_sym)
def pct(p: float) -> int | None:
if not ns:
return None
i = int(round(p * (len(ns) - 1)))
return ns[i]
starts = sorted(str(r[2]) for r in per_sym if r[2])
start_hist: dict[str, int] = defaultdict(int)
for s in starts:
start_hist[s[:4]] += 1
return {
"snapshot": str(snapshot.resolve()),
"ticker_count": ticker_n,
"ohlcv_row_count": ohlcv_n,
"date_range": {"min": d_range[0], "max": d_range[1]},
"bars_per_year": [
{"year": y, "bars": n, "tickers_with_bars": t} for y, n, t in by_year
],
"bars_per_symbol": {
"min": ns[0] if ns else None,
"p10": pct(0.10),
"p50": pct(0.50),
"p90": pct(0.90),
"max": ns[-1] if ns else None,
},
"symbols_by_start_year": dict(sorted(start_hist.items())),
"note": (
"Where ticker counts drop in early years, the feed (or listing history) "
"thins — do not treat those years as a full 505-name cross-section."
),
"survivorship_banner": SURVIVORSHIP_BANNER,
}
def _assert_complete(snapshot: Path) -> dict[str, Any]:
from scripts.research_snapshot_manifest import ( # type: ignore
assert_research_snapshot_complete,
load_manifest,
)
m = load_manifest(snapshot)
if m is None:
# Prod snapshot may lack manifest; still require healthy bar depth.
eng = create_engine(
f"sqlite:///{snapshot.resolve().as_posix()}",
future=True,
)
try:
with eng.connect() as conn:
avg = conn.execute(
text(
"""
SELECT AVG(c) FROM (
SELECT COUNT(*) AS c FROM ohlcv_records GROUP BY ticker_id
)
"""
)
).scalar_one()
finally:
eng.dispose()
if avg is None or float(avg) < 400:
raise SystemExit(
f"No completion manifest and avg bars={avg} look short. "
"Rebuild research.sqlite via extend_snapshot_universe.py"
)
return {"manifest": None, "avg_bars": float(avg), "ok": True}
return {"manifest": assert_research_snapshot_complete(snapshot), "ok": True}
async def _harness(snapshot: Path, *, workers: int, quiet: bool) -> dict[str, Any]:
from app.config import settings
from app.services.backtest_service import run_backtest
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
os.environ["BACKTEST_SIGNAL_EVAL_ONLY"] = "1"
if Path("data/research/ticker_sector_map.json").exists():
os.environ["BACKTEST_SECTOR_MAP_PATH"] = str(
Path("data/research/ticker_sector_map.json").resolve()
)
settings.backtest_workers = workers
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
def progress(done: int, total: int, symbol: str) -> None:
if quiet:
return
print(f" progress {done}/{total} {symbol}", end="\r", flush=True)
try:
async with Session() as db:
report = await run_backtest(db, progress_cb=progress, cadence="weekly")
finally:
await engine.dispose()
if not quiet:
print()
signal_eval = report.get("signal_eval") or []
# Era-split IC: recompute from collected is not available post-run.
# Approximate via second pass is expensive; instead document that era split
# requires collecting weekly ICs. We re-run evaluation if the report embeds
# nothing — for v1, call internal collection is too heavy to duplicate.
# Lightweight approach: mark era_split as requiring BACKTEST with custom
# filter — implemented below by re-scoring from a dedicated collection pass.
era = await _era_split_ics(snapshot, workers=workers, quiet=quiet)
return {
"survivorship_banner": SURVIVORSHIP_BANNER,
"signal_eval": signal_eval,
"era_split": era,
"params": report.get("params"),
"tickers": report.get("tickers"),
"generated_at_run": report.get("generated_at"),
}
async def _era_split_ics(
snapshot: Path, *, workers: int, quiet: bool
) -> dict[str, Any]:
"""Collect weekly signal series and evaluate pre/post ERA_SPLIT separately."""
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"
settings.backtest_workers = max(1, workers)
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
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")
symbol_to_sector = {}
sector_etf: dict = {}
try:
from app.services.sector_map import (
SECTOR_ETFS,
load_ticker_sector_map,
)
symbol_to_sector = load_ticker_sector_map()
for etf in SECTOR_ETFS:
series = await load_benchmark_closes(db, etf)
if series:
sector_etf[etf] = series
except Exception:
pass
for idx, t in enumerate(tickers):
if not quiet and idx % 100 == 0:
print(f" era-collect {idx}/{len(tickers)}", end="\r", flush=True)
cols = await bt._fetch_columns(db, t.symbol)
if cols is None:
continue
records = [
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]))
]
series = bt._signal_series(
records,
spy,
symbol=t.symbol,
sector_etf_closes=bt._sector_etf_closes_for_symbol(
t.symbol, symbol_to_sector, sector_etf
),
)
for name, weeks in series.items():
for wk, pairs in weeks.items():
collected[name][wk].extend(pairs)
if symbol_to_sector:
bt._inject_sector_demeaned_momentum(collected, symbol_to_sector)
finally:
await engine.dispose()
if not quiet:
print()
def _filter_era(coll: dict, *, pre: bool) -> dict:
out: dict = dd(lambda: dd(list))
for name, weeks in coll.items():
for wk, recs in weeks.items():
# ISO week key (year, week) — approximate era by ISO year.
year = int(wk[0]) if isinstance(wk, tuple) else int(str(wk)[:4])
if pre and year >= ERA_SPLIT.year:
continue
if not pre and year < ERA_SPLIT.year:
continue
out[name][wk].extend(recs)
return out
pre_eval = bt._signal_evaluation(_filter_era(collected, pre=True))
post_eval = bt._signal_evaluation(_filter_era(collected, pre=False))
full_eval = bt._signal_evaluation(collected)
def _index(rows: list[dict]) -> dict[str, dict]:
return {r["signal"]: r for r in rows}
return {
"era_split_date": ERA_SPLIT.isoformat(),
"note": "Diagnostic only — not a tuning input. Nested lookbacks are not OOS.",
"full": _index(full_eval),
"pre_2021": _index(pre_eval),
"post_2021": _index(post_eval),
}
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')}`",
"",
f"> **{SURVIVORSHIP_BANNER}**",
"",
"### Coverage",
"",
f"```json\n{json.dumps(payload.get('coverage') or {}, indent=2, default=str)}\n```",
"",
"### Race guard",
"",
f"```json\n{json.dumps(payload.get('race_guard') or {}, indent=2, default=str)}\n```",
"",
"### Signal IC (full extended window)",
"",
]
harness = payload.get("harness") or {}
rows = harness.get("signal_eval") or []
if rows:
lines.extend([
"| signal | mean_ic | ic_t_stat | weeks | avg_N | reliable |",
"|---|---:|---:|---:|---:|---|",
])
for r in rows:
lines.append(
f"| {r.get('signal')} | {r.get('mean_ic')} | {r.get('ic_t_stat')} | "
f"{r.get('weeks')} | {r.get('avg_cross_section')} | {r.get('reliable')} |"
)
else:
lines.append("_Harness not run this pass._")
era = (harness.get("era_split") or {})
lines.extend(["", "### Era split (diagnostic only)", ""])
if era:
for label in ("full", "pre_2021", "post_2021"):
block = era.get(label) or {}
lines.append(f"#### {label}")
lines.append("")
lines.append("| signal | mean_ic | t | weeks | N |")
lines.append("|---|---:|---:|---:|---:|")
for name in sorted(block):
r = block[name]
lines.append(
f"| {name} | {r.get('mean_ic')} | {r.get('ic_t_stat')} | "
f"{r.get('weeks')} | {r.get('avg_cross_section')} |"
)
lines.append("")
else:
lines.append("_No era split._")
lines.extend([
"",
"## Verdict",
"",
f"**{payload.get('verdict')}**",
"",
payload.get("verdict_detail") or "",
"",
"## What a human must decide next",
"",
payload.get("human_next")
or "- Do not retune production knobs from this report without review.",
"",
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"
coverage = None
race = None
harness = None
if args.phase in ("coverage", "all"):
print("Coverage probe…")
coverage = _coverage_report(snapshot)
print(
f" tickers={coverage['ticker_count']} ohlcv={coverage['ohlcv_row_count']} "
f"range={coverage['date_range']}"
)
for row in coverage["bars_per_year"]:
print(
f" year {row['year']}: bars={row['bars']} "
f"tickers={row['tickers_with_bars']}"
)
if args.phase in ("harness", "all"):
print("Race guard…")
race = _assert_complete(snapshot)
print(f" ok={race.get('ok')}")
print("Full harness + era split (LONG)…")
print(f" {SURVIVORSHIP_BANNER}")
harness = await _harness(
snapshot, workers=args.workers, quiet=args.quiet
)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = (
Path(args.out)
if args.out
else Path("reports") / f"history-depth-{stamp}.json"
)
payload = {
"generated_at": datetime.now().isoformat(),
"survivorship_banner": SURVIVORSHIP_BANNER,
"coverage": coverage,
"race_guard": race,
"harness": harness,
"verdict": "PENDING_HUMAN" if harness else "COVERAGE_ONLY",
"verdict_detail": (
"Harness complete — human interprets relative IC / era stability. "
"No production retune from this artifact."
if harness
else "Coverage probe only; run --phase harness after deep rebuild."
),
"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": str(out.as_posix()),
}
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/history-depth-extension.md")
_write_md(md, payload)
out.with_suffix(".md").write_text(md.read_text(encoding="utf-8"), encoding="utf-8")
print(f"Wrote {out}")
print(f"Wrote {md}")
if __name__ == "__main__":
asyncio.run(_main())
File diff suppressed because it is too large Load Diff