research: clean up closed Tier-1 scaffolding from branch

Drop intermediate history-depth reports, sector-residual runners/map/code hooks
(evidence stays in final reports + docs), and slim MacBook helper to ssl/earnings/
prod-book-matrix only. SSL bootstrap and archived research conclusions retained.
This commit is contained in:
2026-07-19 14:41:52 +02:00
parent 1c38a94dd0
commit bb8aa655a1
22 changed files with 88 additions and 5450 deletions
+28 -235
View File
@@ -791,86 +791,34 @@ def _residual_momentum_12_1(
with an intercept estimated over the same window, the arithmetic residuals
sum to ~zero by construction, which would destroy the signal.
"""
return _multi_factor_residual_momentum_12_1(
dates, closes, i, [benchmark_closes] if benchmark_closes else None
)
def _multi_factor_residual_momentum_12_1(
dates: list[date],
closes: list[float],
i: int,
factor_closes: list[dict[date, float]] | None,
) -> float | None:
"""12-1 residual momentum vs one or more factors (OLS, no intercept).
Same formation window as raw / single-factor residual momentum:
daily returns from close[i-252] → close[i-21], require ≥100 paired obs.
Factors are stacked as columns; betas are OLS without intercept so the
cumulative residual is not forced to zero.
"""
if not factor_closes or i - 252 < 0:
return None
n_factors = len(factor_closes)
if n_factors < 1:
if not benchmark_closes or i - 252 < 0:
return None
stock_rets: list[float] = []
factor_rets: list[list[float]] = [[] for _ in range(n_factors)]
market_rets: list[float] = []
# Same daily intervals as mom_12_1: close[i-252] -> close[i-21].
for k in range(i - 251, i - 20):
prev_close = closes[k - 1]
if prev_close <= 0:
continue
f_day: list[float] = []
ok = True
for fc in factor_closes:
f_prev = fc.get(dates[k - 1])
f_cur = fc.get(dates[k])
if f_prev is None or f_cur is None or f_prev <= 0:
ok = False
break
f_day.append(f_cur / f_prev - 1.0)
if not ok:
bench_prev = benchmark_closes.get(dates[k - 1])
bench_cur = benchmark_closes.get(dates[k])
if prev_close <= 0 or bench_prev is None or bench_cur is None or bench_prev <= 0:
continue
stock_rets.append(closes[k] / prev_close - 1.0)
for j, r in enumerate(f_day):
factor_rets[j].append(r)
market_rets.append(bench_cur / bench_prev - 1.0)
n = len(stock_rets)
if n < 100:
if len(stock_rets) < 100:
return None
if n_factors == 1:
# Fast path: identical algebra to the historical single-factor form.
market_rets = factor_rets[0]
mean_market = sum(market_rets) / n
mean_stock = sum(stock_rets) / n
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(n)
)
beta = cov / var_market
return sum(stock_rets[k] - beta * market_rets[k] for k in range(n))
# OLS without intercept: β = (X'X)^{-1} X'y for X columns = factor returns.
# Implemented for exactly two factors (market + sector); refuse larger.
if n_factors != 2:
mean_market = sum(market_rets) / len(market_rets)
mean_stock = sum(stock_rets) / len(stock_rets)
var_market = sum((x - mean_market) ** 2 for x in market_rets)
if var_market <= 0:
return None
f1, f2 = factor_rets[0], factor_rets[1]
s11 = sum(a * a for a in f1)
s22 = sum(a * a for a in f2)
s12 = sum(f1[k] * f2[k] for k in range(n))
sy1 = sum(stock_rets[k] * f1[k] for k in range(n))
sy2 = sum(stock_rets[k] * f2[k] for k in range(n))
det = s11 * s22 - s12 * s12
if abs(det) < 1e-18:
return None
b1 = (s22 * sy1 - s12 * sy2) / det
b2 = (s11 * sy2 - s12 * sy1) / det
return sum(stock_rets[k] - b1 * f1[k] - b2 * f2[k] for k in range(n))
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)))
def _realized_vol_6m(closes: list[float], i: int) -> float | None:
@@ -895,7 +843,6 @@ def _signal_values(
highs: list[float],
i: int,
benchmark_closes: dict[date, float] | None = None,
sector_etf_closes: dict[date, float] | None = None,
) -> dict[str, float]:
"""Point-in-time candidate signals at as-of index ``i`` (price-only).
@@ -907,11 +854,6 @@ def _signal_values(
higher = nearer the high, expect positive IC). ``vol_6m`` is 126-day realized
volatility (expect negative IC if the low-volatility anomaly holds).
``fip_id`` is Da/Gurun/Warachka information discreteness (expect negative IC).
When ``sector_etf_closes`` is supplied (research path), also emit
``mom_12_1_sector_resid``: two-factor residual vs SPY + sector ETF.
Cross-sectional ``mom_12_1_sector_demeaned`` is injected later from the
full weekly cross-section (cannot be computed per-ticker alone).
"""
out: dict[str, float] = {}
if i - 252 >= 0 and closes[i - 252] > 0:
@@ -919,12 +861,6 @@ def _signal_values(
residual = _residual_momentum_12_1(dates, closes, i, benchmark_closes)
if residual is not None:
out["mom_12_1_resid"] = residual
if benchmark_closes and sector_etf_closes:
sector_resid = _multi_factor_residual_momentum_12_1(
dates, closes, i, [benchmark_closes, sector_etf_closes]
)
if sector_resid is not None:
out["mom_12_1_sector_resid"] = sector_resid
fip = _fip_id(closes, i)
if fip is not None:
out["fip_id"] = fip
@@ -1011,16 +947,14 @@ def _accumulate_signal_series(
benchmark_closes: dict[date, float] | None = None,
*,
symbol: str | None = None,
sector_etf_closes: dict[date, float] | None = None,
) -> None:
"""For each weekly as-of bar, emit (signal, forward-return) pairs keyed by ISO
week into ``collected[name][week_key]``. Forward return is close-to-close over
HORIZON trading days. Mutates ``collected`` (a dict of dict of list).
When ``BACKTEST_LIQUID_BREADTH`` is set, observations are dicts with PIT
liquidity fields for the mask. When ``symbol`` is provided, observations are
also dicts (so sector demeaning can group by name); otherwise plain
``(val, fwd)`` tuples keep the production path unchanged.
liquidity fields for the mask; otherwise plain ``(val, fwd)`` tuples so the
production signal path stays unchanged.
"""
n = len(records)
if n < HORIZON + 21:
@@ -1030,7 +964,6 @@ def _accumulate_signal_series(
volumes = [float(getattr(r, "volume", 0) or 0) for r in records]
dates = [r.date for r in records]
liquid_mode = _liquid_breadth_top_n() > 0
rich = liquid_mode or symbol is not None
for i in _weekly_asof_indices(records):
j = i + HORIZON
if j >= n or closes[i] <= 0:
@@ -1039,79 +972,19 @@ def _accumulate_signal_series(
iso = records[i].date.isocalendar()
week_key = (iso[0], iso[1])
dvol = _median_dollar_vol_63(closes, volumes, i) if liquid_mode else None
for name, val in _signal_values(
dates, closes, highs, i, benchmark_closes, sector_etf_closes
).items():
if rich:
row = {
for name, val in _signal_values(dates, closes, highs, i, benchmark_closes).items():
if liquid_mode:
collected[name][week_key].append({
"val": val,
"fwd": fwd,
"close": closes[i],
"median_dvol_63": dvol,
"symbol": symbol,
}
if liquid_mode:
row["close"] = closes[i]
row["median_dvol_63"] = dvol
collected[name][week_key].append(row)
})
else:
collected[name][week_key].append((val, fwd))
def _inject_sector_demeaned_momentum(
collected: dict,
symbol_to_sector: dict[str, str],
*,
min_sector_names: int = 2,
) -> None:
"""Cross-sectional demean of ``mom_12_1`` within GICS sector per week.
``mom_12_1_sector_demeaned[i] = mom_12_1[i] mean(mom_12_1 | sector_i)``.
Requires rich observations with a ``symbol`` field (research path). Names
without a sector label, or sectors with fewer than ``min_sector_names``
members that week, are dropped from the demeaned series.
"""
if not symbol_to_sector or "mom_12_1" not in collected:
return
from app.services.sector_map import normalise_symbol
demeaned: dict = defaultdict(list)
for week_key, recs in collected["mom_12_1"].items():
parsed: list[tuple[str, float, float, object]] = []
by_sector: dict[str, list[float]] = defaultdict(list)
for rec in recs:
pair = _obs_val_fwd(rec)
if pair is None:
continue
val, fwd = pair
if isinstance(rec, dict):
sym = rec.get("symbol")
else:
sym = None
if not sym:
continue
sector = symbol_to_sector.get(normalise_symbol(str(sym)))
if not sector:
continue
parsed.append((sector, val, fwd, rec))
by_sector[sector].append(val)
means = {
sec: sum(vs) / len(vs)
for sec, vs in by_sector.items()
if len(vs) >= min_sector_names
}
for sector, val, fwd, rec in parsed:
if sector not in means:
continue
dval = val - means[sector]
if isinstance(rec, dict):
row = dict(rec)
row["val"] = dval
demeaned[week_key].append(row)
else:
demeaned[week_key].append((dval, fwd))
if demeaned:
collected["mom_12_1_sector_demeaned"] = demeaned
def _rank(xs: list[float]) -> list[float]:
"""Average (tie-corrected) ranks, 1-based."""
order = sorted(range(len(xs)), key=lambda k: xs[k])
@@ -1388,38 +1261,14 @@ def _signal_series(
benchmark_closes: dict[date, float] | None = None,
*,
symbol: str | None = None,
sector_etf_closes: dict[date, float] | None = None,
) -> dict:
"""Per-ticker signal/forward-return series as a PLAIN (picklable) nested dict
— no defaultdict/lambda — so it can cross a process boundary."""
tmp: dict = defaultdict(lambda: defaultdict(list))
_accumulate_signal_series(
records,
tmp,
benchmark_closes,
symbol=symbol,
sector_etf_closes=sector_etf_closes,
)
_accumulate_signal_series(records, tmp, benchmark_closes, symbol=symbol)
return {name: dict(weeks) for name, weeks in tmp.items()}
def _sector_etf_closes_for_symbol(
symbol: str,
symbol_to_sector: dict[str, str] | None,
sector_etf_closes: dict[str, dict[date, float]] | None,
) -> dict[date, float] | None:
"""Resolve the sector-ETF close series for one ticker, or None."""
if not symbol_to_sector or not sector_etf_closes:
return None
from app.services.sector_map import etf_for_symbol
etf = etf_for_symbol(symbol, symbol_to_sector)
if not etf:
return None
series = sector_etf_closes.get(etf)
return series or None
def _replay_and_signals(
symbol: str,
columns: tuple,
@@ -1429,8 +1278,6 @@ def _replay_and_signals(
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
cadence: str = DEFAULT_BACKTEST_CADENCE,
signal_only: bool = False,
sector_etf_closes: dict[str, dict[date, float]] | None = None,
symbol_to_sector: dict[str, str] | None = None,
) -> tuple[list[dict], dict]:
"""The CPU-bound per-ticker work, as a top-level (picklable) function so it can
run in a worker process. Takes primitive column arrays (cheap to pickle),
@@ -1457,17 +1304,9 @@ def _replay_and_signals(
target_model,
cadence,
)
etf_closes = _sector_etf_closes_for_symbol(
symbol, symbol_to_sector, sector_etf_closes
)
return (
candidates,
_signal_series(
bars,
benchmark_closes,
symbol=symbol,
sector_etf_closes=etf_closes,
),
_signal_series(bars, benchmark_closes, symbol=symbol),
)
@@ -4218,41 +4057,6 @@ async def run_backtest(
except Exception:
logger.exception("Benchmark load for residual momentum failed")
# Optional sector residualisation (research): local ticker→sector map + sector
# ETF closes stored in benchmark_prices. Absent map/series → no sector signals.
symbol_to_sector: dict[str, str] = {}
sector_etf_closes: dict[str, dict[date, float]] = {}
try:
from app.services.sector_map import (
SECTOR_ETFS,
load_ticker_sector_map,
normalise_symbol,
)
from app.services.benchmark_service import load_benchmark_closes
map_path = os.getenv("BACKTEST_SECTOR_MAP_PATH", "").strip() or None
symbol_to_sector = {
normalise_symbol(k): v
for k, v in load_ticker_sector_map(map_path).items()
}
if symbol_to_sector:
for etf in SECTOR_ETFS:
try:
series = await load_benchmark_closes(db, etf)
except Exception:
series = {}
if series:
sector_etf_closes[etf] = series
logger.info(json.dumps({
"event": "backtest_sector_context_loaded",
"sector_map_size": len(symbol_to_sector),
"sector_etfs_loaded": sorted(sector_etf_closes),
}))
except Exception:
logger.exception("Sector residual context load failed; continuing without")
symbol_to_sector = {}
sector_etf_closes = {}
def _merge(result: tuple[list[dict], dict]) -> None:
cands, series = result
candidates.extend(cands)
@@ -4303,8 +4107,6 @@ async def run_backtest(
target_model,
cadence,
ticker.symbol in rank_only_symbols,
sector_etf_closes or None,
symbol_to_sector or None,
))
for result in await asyncio.gather(*futures, return_exceptions=True):
if isinstance(result, Exception):
@@ -4333,8 +4135,6 @@ async def run_backtest(
target_model,
cadence,
ticker.symbol in rank_only_symbols,
sector_etf_closes or None,
symbol_to_sector or None,
))
except Exception:
logger.exception("Backtest replay failed for %s", ticker.symbol)
@@ -4342,13 +4142,6 @@ async def run_backtest(
if progress_cb is not None and total:
progress_cb(total, total, "")
# Cross-sectional sector demean needs the full weekly universe.
if symbol_to_sector:
try:
_inject_sector_demeaned_momentum(collected, symbol_to_sector)
except Exception:
logger.exception("Sector demeaned momentum injection failed")
# Cross-sectional momentum: rank every week's universe, then "qualified" means
# floors + top ``min_momentum_percentile`` by promoted residual 12-1 momentum
# (raw 12-1 fallback only when benchmark data is unavailable).
-145
View File
@@ -1,145 +0,0 @@
"""Ticker → GICS sector → SPDR sector ETF mapping (research only).
Sector residual momentum residualizes 12-1 momentum against SPY and the name's
sector ETF. Labels are persisted under ``data/research/ticker_sector_map.json``
so research runs do not depend on live FMP calls.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
# Eleven SPDR sector ETFs. Auxiliary series only — never tradable book members.
SECTOR_ETFS: tuple[str, ...] = (
"XLB",
"XLC",
"XLE",
"XLF",
"XLI",
"XLK",
"XLP",
"XLRE",
"XLU",
"XLV",
"XLY",
)
# GICS sector name (and common aliases) → SPDR ETF.
# Keys are lower-case for matching.
GICS_SECTOR_TO_ETF: dict[str, str] = {
"materials": "XLB",
"basic materials": "XLB",
"communication services": "XLC",
"communications": "XLC",
"telecommunication services": "XLC",
"energy": "XLE",
"financials": "XLF",
"financial services": "XLF",
"financial": "XLF",
"industrials": "XLI",
"industrial goods": "XLI",
"information technology": "XLK",
"technology": "XLK",
"consumer staples": "XLP",
"consumer defensive": "XLP",
"real estate": "XLRE",
"utilities": "XLU",
"health care": "XLV",
"healthcare": "XLV",
"consumer discretionary": "XLY",
"consumer cyclical": "XLY",
}
DEFAULT_SECTOR_MAP_PATH = Path("data/research/ticker_sector_map.json")
def normalise_symbol(symbol: str) -> str:
"""Alpaca-style symbols: BRK.B / BRK/B → BRK-B."""
s = str(symbol or "").strip().upper()
s = s.replace(".", "-").replace("/", "-")
return s
def sector_to_etf(sector: str | None) -> str | None:
if not sector:
return None
return GICS_SECTOR_TO_ETF.get(str(sector).strip().lower())
def etf_for_symbol(symbol: str, symbol_to_sector: dict[str, str]) -> str | None:
sector = symbol_to_sector.get(normalise_symbol(symbol))
return sector_to_etf(sector)
def load_ticker_sector_map(path: Path | str | None = None) -> dict[str, str]:
"""Load ``{symbol: gics_sector}`` from JSON. Empty dict if missing."""
p = Path(path) if path is not None else DEFAULT_SECTOR_MAP_PATH
if not p.exists():
return {}
raw = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
return {}
out: dict[str, str] = {}
# Accept either flat map or {"map": {...}, "meta": ...}
payload = raw.get("map") if "map" in raw and isinstance(raw.get("map"), dict) else raw
if not isinstance(payload, dict):
return {}
for sym, sector in payload.items():
if sym in ("meta", "schema_version", "map"):
continue
if sector is None:
continue
ns = normalise_symbol(str(sym))
if ns:
out[ns] = str(sector).strip()
return out
def save_ticker_sector_map(
mapping: dict[str, str],
path: Path | str | None = None,
*,
meta: dict[str, Any] | None = None,
) -> Path:
p = Path(path) if path is not None else DEFAULT_SECTOR_MAP_PATH
p.parent.mkdir(parents=True, exist_ok=True)
# Normalise keys on write.
clean = {
normalise_symbol(k): str(v).strip()
for k, v in mapping.items()
if k and v and normalise_symbol(k)
}
payload: dict[str, Any] = {
"schema_version": 1,
"map": clean,
"meta": meta or {},
}
p.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return p
def coverage_stats(
symbols: list[str], mapping: dict[str, str]
) -> dict[str, Any]:
total = len(symbols)
mapped = [s for s in symbols if normalise_symbol(s) in mapping]
with_etf = [
s
for s in mapped
if sector_to_etf(mapping[normalise_symbol(s)]) is not None
]
missing = [s for s in symbols if normalise_symbol(s) not in mapping]
by_sector: dict[str, int] = {}
for s in mapped:
sec = mapping[normalise_symbol(s)]
by_sector[sec] = by_sector.get(sec, 0) + 1
return {
"universe": total,
"mapped": len(mapped),
"mapped_pct": round(100.0 * len(mapped) / total, 1) if total else 0.0,
"with_etf": len(with_etf),
"missing": missing,
"by_sector": dict(sorted(by_sector.items(), key=lambda kv: (-kv[1], kv[0]))),
}
-543
View File
@@ -1,543 +0,0 @@
{
"map": {
"A": "Health Care",
"AAPL": "Information Technology",
"ABBV": "Health Care",
"ABNB": "Consumer Discretionary",
"ABT": "Health Care",
"ACGL": "Financials",
"ACN": "Information Technology",
"ADBE": "Information Technology",
"ADI": "Information Technology",
"ADM": "Consumer Staples",
"ADP": "Industrials",
"ADSK": "Information Technology",
"AEE": "Utilities",
"AEP": "Utilities",
"AES": "Utilities",
"AFL": "Financials",
"AIG": "Financials",
"AIZ": "Financials",
"AJG": "Financials",
"AKAM": "Information Technology",
"ALB": "Materials",
"ALGN": "Health Care",
"ALL": "Financials",
"ALLE": "Industrials",
"AMAT": "Information Technology",
"AMCR": "Materials",
"AMD": "Information Technology",
"AME": "Industrials",
"AMGN": "Health Care",
"AMP": "Financials",
"AMT": "Real Estate",
"AMZN": "Consumer Discretionary",
"ANET": "Information Technology",
"AON": "Financials",
"AOS": "Industrials",
"APA": "Energy",
"APD": "Materials",
"APH": "Information Technology",
"APO": "Financials",
"APP": "Information Technology",
"APTV": "Consumer Discretionary",
"ARE": "Real Estate",
"ARES": "Financials",
"ATO": "Utilities",
"AVB": "Real Estate",
"AVGO": "Information Technology",
"AVY": "Materials",
"AWK": "Utilities",
"AXON": "Industrials",
"AXP": "Financials",
"AZO": "Consumer Discretionary",
"BA": "Industrials",
"BAC": "Financials",
"BALL": "Materials",
"BAX": "Health Care",
"BBY": "Consumer Discretionary",
"BDX": "Health Care",
"BEN": "Financials",
"BF-B": "Consumer Staples",
"BG": "Consumer Staples",
"BIIB": "Health Care",
"BK": "Financial Services",
"BKNG": "Consumer Discretionary",
"BKR": "Energy",
"BLDR": "Industrials",
"BLK": "Financials",
"BMY": "Health Care",
"BR": "Industrials",
"BRK-B": "Financials",
"BRO": "Financials",
"BSX": "Health Care",
"BX": "Financials",
"BXP": "Real Estate",
"C": "Financials",
"CAG": "Consumer Defensive",
"CAH": "Health Care",
"CARR": "Industrials",
"CASY": "Consumer Staples",
"CAT": "Industrials",
"CB": "Financials",
"CBOE": "Financials",
"CBRE": "Real Estate",
"CCI": "Real Estate",
"CCL": "Consumer Discretionary",
"CDNS": "Information Technology",
"CDW": "Information Technology",
"CEG": "Utilities",
"CF": "Materials",
"CFG": "Financials",
"CHD": "Consumer Staples",
"CHRW": "Industrials",
"CHTR": "Communication Services",
"CI": "Health Care",
"CIEN": "Information Technology",
"CINF": "Financials",
"CL": "Consumer Staples",
"CLX": "Consumer Staples",
"CMCSA": "Communication Services",
"CME": "Financials",
"CMG": "Consumer Discretionary",
"CMI": "Industrials",
"CMS": "Utilities",
"CNC": "Health Care",
"CNP": "Utilities",
"COF": "Financials",
"COHR": "Information Technology",
"COIN": "Financials",
"COO": "Health Care",
"COP": "Energy",
"COR": "Health Care",
"COST": "Consumer Staples",
"CPAY": "Financials",
"CPB": "Consumer Defensive",
"CPRT": "Industrials",
"CPT": "Real Estate",
"CRH": "Materials",
"CRL": "Health Care",
"CRM": "Information Technology",
"CRWD": "Information Technology",
"CSCO": "Information Technology",
"CSGP": "Real Estate",
"CSX": "Industrials",
"CTAS": "Industrials",
"CTRA": "Energy",
"CTSH": "Information Technology",
"CTVA": "Materials",
"CVNA": "Consumer Discretionary",
"CVS": "Health Care",
"CVX": "Energy",
"D": "Utilities",
"DAL": "Industrials",
"DASH": "Consumer Discretionary",
"DD": "Materials",
"DDOG": "Information Technology",
"DE": "Industrials",
"DECK": "Consumer Discretionary",
"DELL": "Information Technology",
"DG": "Consumer Staples",
"DGX": "Health Care",
"DHI": "Consumer Discretionary",
"DHR": "Health Care",
"DIS": "Communication Services",
"DLR": "Real Estate",
"DLTR": "Consumer Staples",
"DOC": "Real Estate",
"DOV": "Industrials",
"DOW": "Materials",
"DPZ": "Consumer Discretionary",
"DRI": "Consumer Discretionary",
"DTE": "Utilities",
"DUK": "Utilities",
"DVA": "Health Care",
"DVN": "Energy",
"DXCM": "Health Care",
"EA": "Communication Services",
"EBAY": "Consumer Discretionary",
"ECL": "Materials",
"ED": "Utilities",
"EFX": "Industrials",
"EG": "Financials",
"EIX": "Utilities",
"EL": "Consumer Staples",
"ELV": "Health Care",
"EME": "Industrials",
"EMR": "Industrials",
"EOG": "Energy",
"EPAM": "Technology",
"EQIX": "Real Estate",
"EQR": "Real Estate",
"EQT": "Energy",
"ERIE": "Financials",
"ES": "Utilities",
"ESS": "Real Estate",
"ETN": "Industrials",
"ETR": "Utilities",
"EVRG": "Utilities",
"EW": "Health Care",
"EXC": "Utilities",
"EXE": "Energy",
"EXPD": "Industrials",
"EXPE": "Consumer Discretionary",
"EXR": "Real Estate",
"F": "Consumer Discretionary",
"FANG": "Energy",
"FAST": "Industrials",
"FCX": "Materials",
"FDS": "Financials",
"FDX": "Industrials",
"FE": "Utilities",
"FFIV": "Information Technology",
"FICO": "Information Technology",
"FIS": "Financials",
"FISV": "Financials",
"FITB": "Financials",
"FIX": "Industrials",
"FOX": "Communication Services",
"FOXA": "Communication Services",
"FRT": "Real Estate",
"FSLR": "Information Technology",
"FTNT": "Information Technology",
"FTV": "Industrials",
"GD": "Industrials",
"GDDY": "Information Technology",
"GE": "Industrials",
"GEHC": "Health Care",
"GEN": "Information Technology",
"GEV": "Industrials",
"GILD": "Health Care",
"GIS": "Consumer Staples",
"GL": "Financials",
"GLW": "Information Technology",
"GM": "Consumer Discretionary",
"GNRC": "Industrials",
"GOOG": "Communication Services",
"GOOGL": "Communication Services",
"GPC": "Consumer Discretionary",
"GPN": "Financials",
"GRMN": "Consumer Discretionary",
"GS": "Financials",
"GWW": "Industrials",
"HAL": "Energy",
"HAS": "Consumer Discretionary",
"HBAN": "Financials",
"HCA": "Health Care",
"HD": "Consumer Discretionary",
"HIG": "Financials",
"HII": "Industrials",
"HLT": "Consumer Discretionary",
"HON": "Industrials",
"HOOD": "Financials",
"HPE": "Information Technology",
"HPQ": "Information Technology",
"HRL": "Consumer Staples",
"HSIC": "Health Care",
"HST": "Real Estate",
"HSY": "Consumer Staples",
"HUBB": "Industrials",
"HUM": "Health Care",
"HWM": "Industrials",
"IBKR": "Financials",
"IBM": "Information Technology",
"ICE": "Financials",
"IDXX": "Health Care",
"IEX": "Industrials",
"IFF": "Materials",
"INCY": "Health Care",
"INTC": "Information Technology",
"INTU": "Information Technology",
"INVH": "Real Estate",
"IP": "Materials",
"IQV": "Health Care",
"IR": "Industrials",
"IRM": "Real Estate",
"ISRG": "Health Care",
"IT": "Information Technology",
"ITW": "Industrials",
"IVZ": "Financials",
"J": "Industrials",
"JBHT": "Industrials",
"JBL": "Information Technology",
"JCI": "Industrials",
"JKHY": "Financials",
"JNJ": "Health Care",
"JPM": "Financials",
"KDP": "Consumer Staples",
"KEY": "Financials",
"KEYS": "Information Technology",
"KHC": "Consumer Staples",
"KIM": "Real Estate",
"KKR": "Financials",
"KLAC": "Information Technology",
"KMB": "Consumer Staples",
"KMI": "Energy",
"KO": "Consumer Staples",
"KR": "Consumer Staples",
"KVUE": "Consumer Staples",
"L": "Financials",
"LDOS": "Industrials",
"LEN": "Consumer Discretionary",
"LH": "Health Care",
"LHX": "Industrials",
"LII": "Industrials",
"LIN": "Materials",
"LITE": "Information Technology",
"LLY": "Health Care",
"LMT": "Industrials",
"LNT": "Utilities",
"LOW": "Consumer Discretionary",
"LRCX": "Information Technology",
"LULU": "Consumer Discretionary",
"LUV": "Industrials",
"LVS": "Consumer Discretionary",
"LYB": "Materials",
"LYV": "Communication Services",
"MA": "Financials",
"MAA": "Real Estate",
"MAR": "Consumer Discretionary",
"MAS": "Industrials",
"MCD": "Consumer Discretionary",
"MCHP": "Information Technology",
"MCK": "Health Care",
"MCO": "Financials",
"MDLZ": "Consumer Staples",
"MDT": "Health Care",
"MET": "Financials",
"META": "Communication Services",
"MGM": "Consumer Discretionary",
"MKC": "Consumer Staples",
"MLM": "Materials",
"MMM": "Industrials",
"MNST": "Consumer Staples",
"MO": "Consumer Staples",
"MOS": "Materials",
"MPC": "Energy",
"MPWR": "Information Technology",
"MRK": "Health Care",
"MRNA": "Health Care",
"MRSH": "Financials",
"MS": "Financials",
"MSCI": "Financials",
"MSFT": "Information Technology",
"MSI": "Information Technology",
"MSTR": "Technology",
"MTB": "Financials",
"MTD": "Health Care",
"MU": "Information Technology",
"NCLH": "Consumer Discretionary",
"NDAQ": "Financials",
"NDSN": "Industrials",
"NEE": "Utilities",
"NEM": "Materials",
"NFLX": "Communication Services",
"NI": "Utilities",
"NKE": "Consumer Discretionary",
"NOC": "Industrials",
"NOW": "Information Technology",
"NRG": "Utilities",
"NSC": "Industrials",
"NTAP": "Information Technology",
"NTRS": "Financials",
"NUE": "Materials",
"NVDA": "Information Technology",
"NVR": "Consumer Discretionary",
"NWS": "Communication Services",
"NWSA": "Communication Services",
"NXPI": "Information Technology",
"O": "Real Estate",
"ODFL": "Industrials",
"OKE": "Energy",
"OMC": "Communication Services",
"ON": "Information Technology",
"ORCL": "Information Technology",
"ORLY": "Consumer Discretionary",
"OTIS": "Industrials",
"OXY": "Energy",
"PANW": "Information Technology",
"PAYX": "Industrials",
"PCAR": "Industrials",
"PCG": "Utilities",
"PEG": "Utilities",
"PEP": "Consumer Staples",
"PFE": "Health Care",
"PFG": "Financials",
"PG": "Consumer Staples",
"PGR": "Financials",
"PH": "Industrials",
"PHM": "Consumer Discretionary",
"PKG": "Materials",
"PLD": "Real Estate",
"PLTR": "Information Technology",
"PM": "Consumer Staples",
"PNC": "Financials",
"PNR": "Industrials",
"PNW": "Utilities",
"PODD": "Health Care",
"POOL": "Industrials",
"PPG": "Materials",
"PPL": "Utilities",
"PRU": "Financials",
"PSA": "Real Estate",
"PSKY": "Communication Services",
"PSX": "Energy",
"PTC": "Information Technology",
"PWR": "Industrials",
"PYPL": "Financials",
"Q": "Information Technology",
"QCOM": "Information Technology",
"RCL": "Consumer Discretionary",
"REG": "Real Estate",
"REGN": "Health Care",
"RF": "Financials",
"RJF": "Financials",
"RL": "Consumer Discretionary",
"RMD": "Health Care",
"ROK": "Industrials",
"ROL": "Industrials",
"ROP": "Information Technology",
"ROST": "Consumer Discretionary",
"RSG": "Industrials",
"RTX": "Industrials",
"RVTY": "Health Care",
"SATS": "Communication Services",
"SBAC": "Real Estate",
"SBUX": "Consumer Discretionary",
"SCHW": "Financials",
"SHW": "Materials",
"SJM": "Consumer Staples",
"SLB": "Energy",
"SMCI": "Information Technology",
"SNA": "Industrials",
"SNDK": "Information Technology",
"SNPS": "Information Technology",
"SO": "Utilities",
"SOLV": "Health Care",
"SPCX": "Industrials",
"SPG": "Real Estate",
"SPGI": "Financials",
"SRE": "Utilities",
"STE": "Health Care",
"STLD": "Materials",
"STT": "Financials",
"STX": "Information Technology",
"STZ": "Consumer Staples",
"SW": "Materials",
"SWK": "Industrials",
"SWKS": "Information Technology",
"SYF": "Financials",
"SYK": "Health Care",
"SYY": "Consumer Staples",
"T": "Communication Services",
"TAP": "Consumer Staples",
"TDG": "Industrials",
"TDY": "Information Technology",
"TECH": "Health Care",
"TEL": "Information Technology",
"TER": "Information Technology",
"TFC": "Financials",
"TGT": "Consumer Staples",
"TJX": "Consumer Discretionary",
"TKO": "Communication Services",
"TMO": "Health Care",
"TMUS": "Communication Services",
"TPL": "Energy",
"TPR": "Consumer Discretionary",
"TRGP": "Energy",
"TRMB": "Information Technology",
"TROW": "Financials",
"TRV": "Financials",
"TSCO": "Consumer Discretionary",
"TSLA": "Consumer Discretionary",
"TSN": "Consumer Staples",
"TT": "Industrials",
"TTD": "Communication Services",
"TTWO": "Communication Services",
"TXN": "Information Technology",
"TXT": "Industrials",
"TYL": "Information Technology",
"UAL": "Industrials",
"UBER": "Industrials",
"UDR": "Real Estate",
"UHS": "Health Care",
"ULTA": "Consumer Discretionary",
"UNH": "Health Care",
"UNP": "Industrials",
"UPS": "Industrials",
"URI": "Industrials",
"USB": "Financials",
"V": "Financials",
"VICI": "Real Estate",
"VLO": "Energy",
"VLTO": "Industrials",
"VMC": "Materials",
"VRSK": "Industrials",
"VRSN": "Information Technology",
"VRT": "Industrials",
"VRTX": "Health Care",
"VST": "Utilities",
"VTR": "Real Estate",
"VTRS": "Health Care",
"VZ": "Communication Services",
"WAB": "Industrials",
"WAT": "Health Care",
"WBD": "Communication Services",
"WDAY": "Information Technology",
"WDC": "Information Technology",
"WEC": "Utilities",
"WELL": "Real Estate",
"WFC": "Financials",
"WM": "Industrials",
"WMB": "Energy",
"WMT": "Consumer Staples",
"WRB": "Financials",
"WSM": "Consumer Discretionary",
"WST": "Health Care",
"WTW": "Financials",
"WY": "Real Estate",
"WYNN": "Consumer Discretionary",
"XEL": "Utilities",
"XOM": "Energy",
"XYL": "Industrials",
"XYZ": "Financials",
"YUM": "Consumer Discretionary",
"ZBH": "Health Care",
"ZBRA": "Information Technology",
"ZTS": "Health Care"
},
"meta": {
"built_at": "2026-07-19T05:35:41.184460+00:00",
"coverage": {
"by_sector": {
"Communication Services": 23,
"Consumer Defensive": 2,
"Consumer Discretionary": 47,
"Consumer Staples": 34,
"Energy": 22,
"Financial Services": 1,
"Financials": 75,
"Health Care": 58,
"Industrials": 81,
"Information Technology": 72,
"Materials": 26,
"Real Estate": 31,
"Technology": 2,
"Utilities": 31
},
"mapped": 505,
"mapped_pct": 99.8,
"universe": 506,
"with_etf": 505
},
"fmp_requests": 10,
"from_existing": 0,
"from_fmp": 9,
"from_sp500_csv": 496,
"snapshot": "C:\\Workspace\\signal-platform\\backtest_snapshots\\prod.sqlite",
"still_missing": [
"RHM"
]
},
"schema_version": 1
}
+6 -3
View File
@@ -172,9 +172,12 @@ report does not change that without a separate A/B. Flag for human awareness onl
| 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) |
| `reports/history-depth-20260719-103315.json` | Superseded unmasked/two-tier IC dump (do not cite for sector residual) |
| `reports/sector-resid-deep-20260719-113319.json` | Authoritative sector-resid deep grade |
| `reports/prod-book-universe-horizon-20260719-140737.json` | 505 vs liquid × horizon book matrix |
Intermediate history-depth partials (093853095156) and SANITY-FAIL noise were
removed in branch cleanup.
---
+8 -11
View File
@@ -219,20 +219,17 @@ to reopen promotion.
---
## Implementation notes (research machinery)
## Implementation notes
| 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) |
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
- JSON: `reports/sector-residual-20260719-083356.json`
- MD copy: `reports/sector-residual-20260719-083356.md`
| 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) |
@@ -1,68 +0,0 @@
{
"generated_at": "2026-07-19T09:38:53.936226",
"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": {
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/prod.sqlite",
"ticker_count": 506,
"ohlcv_row_count": 629263,
"date_range": {
"min": "2021-06-24",
"max": "2026-07-02"
},
"bars_per_year": [
{
"year": "2021",
"bars": 65678,
"tickers_with_bars": 494
},
{
"year": "2022",
"bars": 124423,
"tickers_with_bars": 497
},
{
"year": "2023",
"bars": 124479,
"tickers_with_bars": 499
},
{
"year": "2024",
"bars": 126129,
"tickers_with_bars": 501
},
{
"year": "2025",
"bars": 125615,
"tickers_with_bars": 504
},
{
"year": "2026",
"bars": 62939,
"tickers_with_bars": 505
}
],
"bars_per_symbol": {
"min": 14,
"p10": 1261,
"p50": 1261,
"p90": 1261,
"max": 1261
},
"symbols_by_start_year": {
"2021": 494,
"2022": 3,
"2023": 2,
"2024": 2,
"2025": 3,
"2026": 1
},
"note": "Where ticker counts drop in early years, the feed (or listing history) thins \u2014 do not treat those years as a full 505-name cross-section.",
"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."
},
"race_guard": null,
"harness": null,
"verdict": "COVERAGE_ONLY",
"verdict_detail": "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": "reports/history-depth-20260719-093853.json"
}
-177
View File
@@ -1,177 +0,0 @@
# History-depth extension (Tier-1 alpha research)
**Status:** PRE-REGISTERED — run on MacBook (heavy I/O + full harness).
**Branch:** `research/history-depth-extension` (create from latest research stack).
**Production impact:** none. **Do not retune any production knob on deep history.**
---
## Pre-registration (locked before rebuild)
### Motivation
All current conclusions rest on ~35 non-overlapping weekly windows in essentially
one post-2021 regime. Extending history toward max Alpaca daily-bar depth adds
the 2018 vol shock and full 2020 crash (where the feed allows).
### Protocol
1. **Empirical coverage first** — bars per calendar year per symbol; document
where the feed thins out. Do **not** assume a uniform start date.
2. **Rebuild the research snapshot completely** from prod source + max history
per symbol (`Adjustment.SPLIT`, ~200 req/min pacing via existing extender).
3. **Race guard (rule 6)** — refuse analysis until completion manifest is
`complete=true` and live counts match.
4. **Re-run full signal harness** (all existing signals incl. sector residual /
SUE if present) on the extended window.
5. **Report per signal:** mean IC, t, window count, and **era split**
(pre-/post-2021) — diagnostic only, **not a tuning input**.
6. **Log prominently:** survivorship bias grows with depth (todays constituents
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
**relative** signal comparisons and IC stability, not levels.
7. **Do not retune** production knobs. If a knobs confirmation looks
overturned on deep history → report only; human decides.
### Success / interpretation (not promotion of a new signal)
| outcome | meaning |
|---|---|
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
| Any production knob looks worse deep | report; no auto-retune |
---
## MacBook runbook
Prefer the bundled script (one entry point):
```bash
git fetch origin && git checkout research/earnings-gap-and-sue
# .env: ALPACA_* required; FMP_* if resuming earnings
# copy backtest_snapshots/prod.sqlite if not already local
chmod +x scripts/run_tier1_macbook.sh
# Default: coverage → deep rebuild → harness (+ era split)
./scripts/run_tier1_macbook.sh
# Optional variants
./scripts/run_tier1_macbook.sh --all # + earnings resume first
./scripts/run_tier1_macbook.sh --earnings-only # multi-day FMP + 2a/2b only
./scripts/run_tier1_macbook.sh --harness-only # skip rebuild
./scripts/run_tier1_macbook.sh --coverage-only
# Tunables
WORKERS=12 HISTORY_DAYS=5000 ./scripts/run_tier1_macbook.sh
./scripts/run_tier1_macbook.sh --workers 12 --fmp-limit 250
```
Then commit `reports/` + updated research docs, or copy them back to Windows.
---
## Data provenance
*(filled at run time)*
---
## Results
Generated: `2026-07-19T09:38:53.936226`
> **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
{
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/prod.sqlite",
"ticker_count": 506,
"ohlcv_row_count": 629263,
"date_range": {
"min": "2021-06-24",
"max": "2026-07-02"
},
"bars_per_year": [
{
"year": "2021",
"bars": 65678,
"tickers_with_bars": 494
},
{
"year": "2022",
"bars": 124423,
"tickers_with_bars": 497
},
{
"year": "2023",
"bars": 124479,
"tickers_with_bars": 499
},
{
"year": "2024",
"bars": 126129,
"tickers_with_bars": 501
},
{
"year": "2025",
"bars": 125615,
"tickers_with_bars": 504
},
{
"year": "2026",
"bars": 62939,
"tickers_with_bars": 505
}
],
"bars_per_symbol": {
"min": 14,
"p10": 1261,
"p50": 1261,
"p90": 1261,
"max": 1261
},
"symbols_by_start_year": {
"2021": 494,
"2022": 3,
"2023": 2,
"2024": 2,
"2025": 3,
"2026": 1
},
"note": "Where ticker counts drop in early years, the feed (or listing history) thins \u2014 do not treat those years as a full 505-name cross-section.",
"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."
}
```
### Race guard
```json
{}
```
### Signal IC (full extended window)
_Harness not run this pass._
### Era split (diagnostic only)
_No era split._
## Verdict
**COVERAGE_ONLY**
Coverage probe only; run --phase harness after deep rebuild.
## What a human must decide next
- Compare sector residual vs market residual across eras.
- If pre-2021 IC collapses, park Task 1 wire-in.
- Do not retune production knobs on deep history levels.
Artifacts: `reports/history-depth-20260719-093853.json`
@@ -1,68 +0,0 @@
{
"generated_at": "2026-07-19T09:41:34.109378",
"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": {
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/prod.sqlite",
"ticker_count": 506,
"ohlcv_row_count": 629263,
"date_range": {
"min": "2021-06-24",
"max": "2026-07-02"
},
"bars_per_year": [
{
"year": "2021",
"bars": 65678,
"tickers_with_bars": 494
},
{
"year": "2022",
"bars": 124423,
"tickers_with_bars": 497
},
{
"year": "2023",
"bars": 124479,
"tickers_with_bars": 499
},
{
"year": "2024",
"bars": 126129,
"tickers_with_bars": 501
},
{
"year": "2025",
"bars": 125615,
"tickers_with_bars": 504
},
{
"year": "2026",
"bars": 62939,
"tickers_with_bars": 505
}
],
"bars_per_symbol": {
"min": 14,
"p10": 1261,
"p50": 1261,
"p90": 1261,
"max": 1261
},
"symbols_by_start_year": {
"2021": 494,
"2022": 3,
"2023": 2,
"2024": 2,
"2025": 3,
"2026": 1
},
"note": "Where ticker counts drop in early years, the feed (or listing history) thins \u2014 do not treat those years as a full 505-name cross-section.",
"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."
},
"race_guard": null,
"harness": null,
"verdict": "COVERAGE_ONLY",
"verdict_detail": "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": "reports/history-depth-20260719-094134.json"
}
-177
View File
@@ -1,177 +0,0 @@
# History-depth extension (Tier-1 alpha research)
**Status:** PRE-REGISTERED — run on MacBook (heavy I/O + full harness).
**Branch:** `research/history-depth-extension` (create from latest research stack).
**Production impact:** none. **Do not retune any production knob on deep history.**
---
## Pre-registration (locked before rebuild)
### Motivation
All current conclusions rest on ~35 non-overlapping weekly windows in essentially
one post-2021 regime. Extending history toward max Alpaca daily-bar depth adds
the 2018 vol shock and full 2020 crash (where the feed allows).
### Protocol
1. **Empirical coverage first** — bars per calendar year per symbol; document
where the feed thins out. Do **not** assume a uniform start date.
2. **Rebuild the research snapshot completely** from prod source + max history
per symbol (`Adjustment.SPLIT`, ~200 req/min pacing via existing extender).
3. **Race guard (rule 6)** — refuse analysis until completion manifest is
`complete=true` and live counts match.
4. **Re-run full signal harness** (all existing signals incl. sector residual /
SUE if present) on the extended window.
5. **Report per signal:** mean IC, t, window count, and **era split**
(pre-/post-2021) — diagnostic only, **not a tuning input**.
6. **Log prominently:** survivorship bias grows with depth (todays constituents
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
**relative** signal comparisons and IC stability, not levels.
7. **Do not retune** production knobs. If a knobs confirmation looks
overturned on deep history → report only; human decides.
### Success / interpretation (not promotion of a new signal)
| outcome | meaning |
|---|---|
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
| Any production knob looks worse deep | report; no auto-retune |
---
## MacBook runbook
Prefer the bundled script (one entry point):
```bash
git fetch origin && git checkout research/earnings-gap-and-sue
# .env: ALPACA_* required; FMP_* if resuming earnings
# copy backtest_snapshots/prod.sqlite if not already local
chmod +x scripts/run_tier1_macbook.sh
# Default: coverage → deep rebuild → harness (+ era split)
./scripts/run_tier1_macbook.sh
# Optional variants
./scripts/run_tier1_macbook.sh --all # + earnings resume first
./scripts/run_tier1_macbook.sh --earnings-only # multi-day FMP + 2a/2b only
./scripts/run_tier1_macbook.sh --harness-only # skip rebuild
./scripts/run_tier1_macbook.sh --coverage-only
# Tunables
WORKERS=12 HISTORY_DAYS=5000 ./scripts/run_tier1_macbook.sh
./scripts/run_tier1_macbook.sh --workers 12 --fmp-limit 250
```
Then commit `reports/` + updated research docs, or copy them back to Windows.
---
## Data provenance
*(filled at run time)*
---
## Results
Generated: `2026-07-19T09:41:34.109378`
> **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
{
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/prod.sqlite",
"ticker_count": 506,
"ohlcv_row_count": 629263,
"date_range": {
"min": "2021-06-24",
"max": "2026-07-02"
},
"bars_per_year": [
{
"year": "2021",
"bars": 65678,
"tickers_with_bars": 494
},
{
"year": "2022",
"bars": 124423,
"tickers_with_bars": 497
},
{
"year": "2023",
"bars": 124479,
"tickers_with_bars": 499
},
{
"year": "2024",
"bars": 126129,
"tickers_with_bars": 501
},
{
"year": "2025",
"bars": 125615,
"tickers_with_bars": 504
},
{
"year": "2026",
"bars": 62939,
"tickers_with_bars": 505
}
],
"bars_per_symbol": {
"min": 14,
"p10": 1261,
"p50": 1261,
"p90": 1261,
"max": 1261
},
"symbols_by_start_year": {
"2021": 494,
"2022": 3,
"2023": 2,
"2024": 2,
"2025": 3,
"2026": 1
},
"note": "Where ticker counts drop in early years, the feed (or listing history) thins \u2014 do not treat those years as a full 505-name cross-section.",
"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."
}
```
### Race guard
```json
{}
```
### Signal IC (full extended window)
_Harness not run this pass._
### Era split (diagnostic only)
_No era split._
## Verdict
**COVERAGE_ONLY**
Coverage probe only; run --phase harness after deep rebuild.
## What a human must decide next
- Compare sector residual vs market residual across eras.
- If pre-2021 IC collapses, park Task 1 wire-in.
- Do not retune production knobs on deep history levels.
Artifacts: `reports/history-depth-20260719-094134.json`
@@ -1,68 +0,0 @@
{
"generated_at": "2026-07-19T09:43:44.520126",
"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": {
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/prod.sqlite",
"ticker_count": 506,
"ohlcv_row_count": 629263,
"date_range": {
"min": "2021-06-24",
"max": "2026-07-02"
},
"bars_per_year": [
{
"year": "2021",
"bars": 65678,
"tickers_with_bars": 494
},
{
"year": "2022",
"bars": 124423,
"tickers_with_bars": 497
},
{
"year": "2023",
"bars": 124479,
"tickers_with_bars": 499
},
{
"year": "2024",
"bars": 126129,
"tickers_with_bars": 501
},
{
"year": "2025",
"bars": 125615,
"tickers_with_bars": 504
},
{
"year": "2026",
"bars": 62939,
"tickers_with_bars": 505
}
],
"bars_per_symbol": {
"min": 14,
"p10": 1261,
"p50": 1261,
"p90": 1261,
"max": 1261
},
"symbols_by_start_year": {
"2021": 494,
"2022": 3,
"2023": 2,
"2024": 2,
"2025": 3,
"2026": 1
},
"note": "Where ticker counts drop in early years, the feed (or listing history) thins \u2014 do not treat those years as a full 505-name cross-section.",
"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."
},
"race_guard": null,
"harness": null,
"verdict": "COVERAGE_ONLY",
"verdict_detail": "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": "reports/history-depth-20260719-094344.json"
}
-177
View File
@@ -1,177 +0,0 @@
# History-depth extension (Tier-1 alpha research)
**Status:** PRE-REGISTERED — run on MacBook (heavy I/O + full harness).
**Branch:** `research/history-depth-extension` (create from latest research stack).
**Production impact:** none. **Do not retune any production knob on deep history.**
---
## Pre-registration (locked before rebuild)
### Motivation
All current conclusions rest on ~35 non-overlapping weekly windows in essentially
one post-2021 regime. Extending history toward max Alpaca daily-bar depth adds
the 2018 vol shock and full 2020 crash (where the feed allows).
### Protocol
1. **Empirical coverage first** — bars per calendar year per symbol; document
where the feed thins out. Do **not** assume a uniform start date.
2. **Rebuild the research snapshot completely** from prod source + max history
per symbol (`Adjustment.SPLIT`, ~200 req/min pacing via existing extender).
3. **Race guard (rule 6)** — refuse analysis until completion manifest is
`complete=true` and live counts match.
4. **Re-run full signal harness** (all existing signals incl. sector residual /
SUE if present) on the extended window.
5. **Report per signal:** mean IC, t, window count, and **era split**
(pre-/post-2021) — diagnostic only, **not a tuning input**.
6. **Log prominently:** survivorship bias grows with depth (todays constituents
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
**relative** signal comparisons and IC stability, not levels.
7. **Do not retune** production knobs. If a knobs confirmation looks
overturned on deep history → report only; human decides.
### Success / interpretation (not promotion of a new signal)
| outcome | meaning |
|---|---|
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
| Any production knob looks worse deep | report; no auto-retune |
---
## MacBook runbook
Prefer the bundled script (one entry point):
```bash
git fetch origin && git checkout research/earnings-gap-and-sue
# .env: ALPACA_* required; FMP_* if resuming earnings
# copy backtest_snapshots/prod.sqlite if not already local
chmod +x scripts/run_tier1_macbook.sh
# Default: coverage → deep rebuild → harness (+ era split)
./scripts/run_tier1_macbook.sh
# Optional variants
./scripts/run_tier1_macbook.sh --all # + earnings resume first
./scripts/run_tier1_macbook.sh --earnings-only # multi-day FMP + 2a/2b only
./scripts/run_tier1_macbook.sh --harness-only # skip rebuild
./scripts/run_tier1_macbook.sh --coverage-only
# Tunables
WORKERS=12 HISTORY_DAYS=5000 ./scripts/run_tier1_macbook.sh
./scripts/run_tier1_macbook.sh --workers 12 --fmp-limit 250
```
Then commit `reports/` + updated research docs, or copy them back to Windows.
---
## Data provenance
*(filled at run time)*
---
## Results
Generated: `2026-07-19T09:43:44.520126`
> **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
{
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/prod.sqlite",
"ticker_count": 506,
"ohlcv_row_count": 629263,
"date_range": {
"min": "2021-06-24",
"max": "2026-07-02"
},
"bars_per_year": [
{
"year": "2021",
"bars": 65678,
"tickers_with_bars": 494
},
{
"year": "2022",
"bars": 124423,
"tickers_with_bars": 497
},
{
"year": "2023",
"bars": 124479,
"tickers_with_bars": 499
},
{
"year": "2024",
"bars": 126129,
"tickers_with_bars": 501
},
{
"year": "2025",
"bars": 125615,
"tickers_with_bars": 504
},
{
"year": "2026",
"bars": 62939,
"tickers_with_bars": 505
}
],
"bars_per_symbol": {
"min": 14,
"p10": 1261,
"p50": 1261,
"p90": 1261,
"max": 1261
},
"symbols_by_start_year": {
"2021": 494,
"2022": 3,
"2023": 2,
"2024": 2,
"2025": 3,
"2026": 1
},
"note": "Where ticker counts drop in early years, the feed (or listing history) thins \u2014 do not treat those years as a full 505-name cross-section.",
"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."
}
```
### Race guard
```json
{}
```
### Signal IC (full extended window)
_Harness not run this pass._
### Era split (diagnostic only)
_No era split._
## Verdict
**COVERAGE_ONLY**
Coverage probe only; run --phase harness after deep rebuild.
## What a human must decide next
- Compare sector residual vs market residual across eras.
- If pre-2021 IC collapses, park Task 1 wire-in.
- Do not retune production knobs on deep history levels.
Artifacts: `reports/history-depth-20260719-094344.json`
@@ -1,68 +0,0 @@
{
"generated_at": "2026-07-19T09:51:56.525634",
"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": {
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/prod.sqlite",
"ticker_count": 506,
"ohlcv_row_count": 629263,
"date_range": {
"min": "2021-06-24",
"max": "2026-07-02"
},
"bars_per_year": [
{
"year": "2021",
"bars": 65678,
"tickers_with_bars": 494
},
{
"year": "2022",
"bars": 124423,
"tickers_with_bars": 497
},
{
"year": "2023",
"bars": 124479,
"tickers_with_bars": 499
},
{
"year": "2024",
"bars": 126129,
"tickers_with_bars": 501
},
{
"year": "2025",
"bars": 125615,
"tickers_with_bars": 504
},
{
"year": "2026",
"bars": 62939,
"tickers_with_bars": 505
}
],
"bars_per_symbol": {
"min": 14,
"p10": 1261,
"p50": 1261,
"p90": 1261,
"max": 1261
},
"symbols_by_start_year": {
"2021": 494,
"2022": 3,
"2023": 2,
"2024": 2,
"2025": 3,
"2026": 1
},
"note": "Where ticker counts drop in early years, the feed (or listing history) thins \u2014 do not treat those years as a full 505-name cross-section.",
"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."
},
"race_guard": null,
"harness": null,
"verdict": "COVERAGE_ONLY",
"verdict_detail": "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": "reports/history-depth-20260719-095156.json"
}
-177
View File
@@ -1,177 +0,0 @@
# History-depth extension (Tier-1 alpha research)
**Status:** PRE-REGISTERED — run on MacBook (heavy I/O + full harness).
**Branch:** `research/history-depth-extension` (create from latest research stack).
**Production impact:** none. **Do not retune any production knob on deep history.**
---
## Pre-registration (locked before rebuild)
### Motivation
All current conclusions rest on ~35 non-overlapping weekly windows in essentially
one post-2021 regime. Extending history toward max Alpaca daily-bar depth adds
the 2018 vol shock and full 2020 crash (where the feed allows).
### Protocol
1. **Empirical coverage first** — bars per calendar year per symbol; document
where the feed thins out. Do **not** assume a uniform start date.
2. **Rebuild the research snapshot completely** from prod source + max history
per symbol (`Adjustment.SPLIT`, ~200 req/min pacing via existing extender).
3. **Race guard (rule 6)** — refuse analysis until completion manifest is
`complete=true` and live counts match.
4. **Re-run full signal harness** (all existing signals incl. sector residual /
SUE if present) on the extended window.
5. **Report per signal:** mean IC, t, window count, and **era split**
(pre-/post-2021) — diagnostic only, **not a tuning input**.
6. **Log prominently:** survivorship bias grows with depth (todays constituents
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
**relative** signal comparisons and IC stability, not levels.
7. **Do not retune** production knobs. If a knobs confirmation looks
overturned on deep history → report only; human decides.
### Success / interpretation (not promotion of a new signal)
| outcome | meaning |
|---|---|
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
| Any production knob looks worse deep | report; no auto-retune |
---
## MacBook runbook
Prefer the bundled script (one entry point):
```bash
git fetch origin && git checkout research/earnings-gap-and-sue
# .env: ALPACA_* required; FMP_* if resuming earnings
# copy backtest_snapshots/prod.sqlite if not already local
chmod +x scripts/run_tier1_macbook.sh
# Default: coverage → deep rebuild → harness (+ era split)
./scripts/run_tier1_macbook.sh
# Optional variants
./scripts/run_tier1_macbook.sh --all # + earnings resume first
./scripts/run_tier1_macbook.sh --earnings-only # multi-day FMP + 2a/2b only
./scripts/run_tier1_macbook.sh --harness-only # skip rebuild
./scripts/run_tier1_macbook.sh --coverage-only
# Tunables
WORKERS=12 HISTORY_DAYS=5000 ./scripts/run_tier1_macbook.sh
./scripts/run_tier1_macbook.sh --workers 12 --fmp-limit 250
```
Then commit `reports/` + updated research docs, or copy them back to Windows.
---
## Data provenance
*(filled at run time)*
---
## Results
Generated: `2026-07-19T09:51:56.525634`
> **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
{
"snapshot": "/Users/taathde3/git/lab/signal_platform/backtest_snapshots/prod.sqlite",
"ticker_count": 506,
"ohlcv_row_count": 629263,
"date_range": {
"min": "2021-06-24",
"max": "2026-07-02"
},
"bars_per_year": [
{
"year": "2021",
"bars": 65678,
"tickers_with_bars": 494
},
{
"year": "2022",
"bars": 124423,
"tickers_with_bars": 497
},
{
"year": "2023",
"bars": 124479,
"tickers_with_bars": 499
},
{
"year": "2024",
"bars": 126129,
"tickers_with_bars": 501
},
{
"year": "2025",
"bars": 125615,
"tickers_with_bars": 504
},
{
"year": "2026",
"bars": 62939,
"tickers_with_bars": 505
}
],
"bars_per_symbol": {
"min": 14,
"p10": 1261,
"p50": 1261,
"p90": 1261,
"max": 1261
},
"symbols_by_start_year": {
"2021": 494,
"2022": 3,
"2023": 2,
"2024": 2,
"2025": 3,
"2026": 1
},
"note": "Where ticker counts drop in early years, the feed (or listing history) thins \u2014 do not treat those years as a full 505-name cross-section.",
"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."
}
```
### Race guard
```json
{}
```
### Signal IC (full extended window)
_Harness not run this pass._
### Era split (diagnostic only)
_No era split._
## Verdict
**COVERAGE_ONLY**
Coverage probe only; run --phase harness after deep rebuild.
## What a human must decide next
- Compare sector residual vs market residual across eras.
- If pre-2021 IC collapses, park Task 1 wire-in.
- Do not retune production knobs on deep history levels.
Artifacts: `reports/history-depth-20260719-095156.json`
@@ -1,275 +0,0 @@
{
"step1": {
"shallow_meta": {
"n_symbols": 4654,
"deep_cohort_p10_start": "2016-01-04",
"shallow_cutoff": "2017-02-07",
"lag_days": 400,
"n_shallow": 3349,
"shallow_start_histogram": {
"2017": 117,
"2018": 162,
"2019": 163,
"2020": 269,
"2021": 1060,
"2022": 211,
"2023": 176,
"2024": 281,
"2025": 543,
"2026": 367
},
"deep_start_histogram": {
"2016": 1296,
"2017": 9
},
"shallow_sample": [
"A",
"AACB",
"AACBR",
"AACBU",
"AACI",
"AACIU",
"AACIW",
"AACO",
"AACOU",
"AACOW",
"AACP",
"AACPR",
"AACPU",
"AACPW",
"AAPG",
"AAPL",
"AARD",
"ABAT",
"ABBV",
"ABCL",
"ABLV",
"ABLVW",
"ABNB",
"ABOS",
"ABSI",
"ABT",
"ABTC",
"ABVC",
"ABVX",
"ACAA"
]
},
"shallow_list_n": 3349,
"fetch_ok": 3349,
"fetch_fail": 0,
"etf_refresh": {
"written": {
"SPY": 0,
"XLB": 0,
"XLC": 0,
"XLE": 0,
"XLF": 0,
"XLI": 0,
"XLK": 0,
"XLP": 0,
"XLRE": 0,
"XLU": 0,
"XLV": 0,
"XLY": 0
},
"benchmark_summary": [
{
"symbol": "SPY",
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
{
"symbol": "XLB",
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
{
"symbol": "XLC",
"n": 2030,
"min": "2018-06-19",
"max": "2026-07-17"
},
{
"symbol": "XLE",
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
{
"symbol": "XLF",
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
{
"symbol": "XLI",
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
{
"symbol": "XLK",
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
{
"symbol": "XLP",
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
{
"symbol": "XLRE",
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
{
"symbol": "XLU",
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
{
"symbol": "XLV",
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
},
{
"symbol": "XLY",
"n": 2649,
"min": "2016-01-04",
"max": "2026-07-17"
}
]
},
"sanity": {
"passed": false,
"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": false,
"megacap_deadline": "2013-12-14",
"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,
"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"
],
"xlc_note": "XLC lists mid-2018 \u2192 Communication Services residual coverage from ~mid-2019.",
"target_history_days": 5000
},
"manifest_path": "backtest_snapshots/research.sqlite.manifest.json"
},
"harness": null
}
-233
View File
@@ -1,233 +0,0 @@
"""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.ssl_bootstrap import bootstrap_ssl # noqa: E402
bootstrap_ssl()
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())
-187
View File
@@ -1,187 +0,0 @@
"""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.ssl_bootstrap import bootstrap_ssl # noqa: E402
bootstrap_ssl()
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())
-26
View File
@@ -466,11 +466,6 @@ async def _run_2b_ic(
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)
@@ -484,18 +479,6 @@ async def _run_2b_ic(
(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):
@@ -521,9 +504,6 @@ async def _run_2b_ic(
],
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():
@@ -533,9 +513,6 @@ async def _run_2b_ic(
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:
@@ -709,8 +686,6 @@ async def _run_2b_ic(
for name in (
"mom_12_1",
"mom_12_1_resid",
"mom_12_1_sector_resid",
"mom_12_1_sector_demeaned",
"sue_latest",
"fip_id",
)
@@ -784,7 +759,6 @@ def _write_md(path: Path, payload: dict) -> None:
"mom_12_1",
"mom_12_1_resid",
"sue_latest",
"mom_12_1_sector_resid",
"fip_id",
):
r = side.get(name) or {}
-480
View File
@@ -1,480 +0,0 @@
"""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))
from app.ssl_bootstrap import bootstrap_ssl # noqa: E402
bootstrap_ssl()
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
File diff suppressed because it is too large Load Diff
+46 -198
View File
@@ -1,99 +1,70 @@
#!/usr/bin/env bash
# Tier-1 alpha research runner for a high-CPU MacBook (local only).
# Research helpers for a high-CPU MacBook (local only).
#
# Prerequisites
# - git checkout research/earnings-gap-and-sue (or later research branch)
# - .env with ALPACA_* (required for OHLCV/ETFs); FMP_* for earnings resume;
# optional ALPHA_VANTAGE_* as earnings fallback
# - Python venv with project deps installed
# - backtest_snapshots/prod.sqlite present (gitignored — copy or rebuild)
# 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.
#
# Usage
# chmod +x scripts/run_tier1_macbook.sh
# ./scripts/run_tier1_macbook.sh # coverage + deep rebuild + harness
# ./scripts/run_tier1_macbook.sh --all # earnings resume + full depth pipeline
# ./scripts/run_tier1_macbook.sh --earnings-only # multi-day FMP backfill + re-run 2a/2b
# ./scripts/run_tier1_macbook.sh --harness-only # skip rebuild; race-guard + IC only
# ./scripts/run_tier1_macbook.sh --coverage-only # bars-per-year probe only
# ./scripts/run_tier1_macbook.sh --sector-resid-deep # deepen shallow + ONE masked grade
# ./scripts/run_tier1_macbook.sh --prod-book-matrix # 4-arm universe×horizon book matrix
#
# Does NOT touch production Postgres, scheduler, gates, or prod config.
# ./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"
# --- defaults (override via flags or env) ---
PROD_SNAP="${PROD_SNAP:-backtest_snapshots/prod.sqlite}"
RESEARCH_SNAP="${RESEARCH_SNAP:-backtest_snapshots/research.sqlite}"
HISTORY_DAYS="${HISTORY_DAYS:-5000}"
MIN_BARS="${MIN_BARS:-260}"
PROD_SNAP="${PROD_SNAP:-backtest_snapshots/prod.sqlite}"
WORKERS="${WORKERS:-8}"
ALPACA_SLEEP="${ALPACA_SLEEP:-0.15}"
FMP_LIMIT="${FMP_LIMIT:-250}"
FMP_SLEEP="${FMP_SLEEP:-0.35}"
PYTHON="${PYTHON:-python3}"
USE_CORP_PROXY="${USE_CORP_PROXY:-0}"
PHASE="depth" # depth | all | earnings | harness | coverage | ssl | sector-resid-deep | prod-book
PHASE=""
usage() {
sed -n '2,25p' "$0" | sed 's/^# \?//'
cat <<'EOF'
SSL / network (corporate MacBook)
SSL errors usually mean the corp root CA is missing from Python.
1) Put combined-ca-bundle.pem in the repo root OR $HOME
2) Or: export SSL_CERT_FILE=/path/to/combined-ca-bundle.pem
3) Behind corp proxy: USE_CORP_PROXY=1 ./scripts/run_tier1_macbook.sh
4) Diagnose: ./scripts/run_tier1_macbook.sh --ssl-check
EOF
sed -n '2,16p' "$0" | sed 's/^# \?//'
exit "${1:-0}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--all) PHASE=all; shift ;;
--earnings-only) PHASE=earnings; shift ;;
--harness-only) PHASE=harness; shift ;;
--coverage-only) PHASE=coverage; shift ;;
--depth) PHASE=depth; shift ;;
--ssl-check) PHASE=ssl; shift ;;
--sector-resid-deep) PHASE=sector_resid_deep; shift ;;
--earnings-only) PHASE=earnings; shift ;;
--prod-book-matrix) PHASE=prod_book; shift ;;
--corp-proxy) USE_CORP_PROXY=1; shift ;;
--prod-snap) PROD_SNAP="$2"; shift 2 ;;
--research-snap) RESEARCH_SNAP="$2"; shift 2 ;;
--history-days) HISTORY_DAYS="$2"; shift 2 ;;
--workers) WORKERS="$2"; shift 2 ;;
--fmp-limit) FMP_LIMIT="$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 (tried .venv/bin/python and $PYTHON)" >&2
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"; }
# ---------------------------------------------------------------------------
# TLS bootstrap — same corp CA path the FastAPI app uses
# ---------------------------------------------------------------------------
setup_ssl() {
export USE_CORP_PROXY
# Prefer explicit env, then repo / home corporate bundle, then certifi.
if [[ -z "${SSL_CERT_FILE:-}" ]]; then
if [[ -f "$ROOT/combined-ca-bundle.pem" ]]; then
export SSL_CERT_FILE="$ROOT/combined-ca-bundle.pem"
@@ -101,191 +72,68 @@ setup_ssl() {
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"
export CURL_CA_BUNDLE="$SSL_CERT_FILE"
export REQUESTS_CA_BUNDLE="$SSL_CERT_FILE" CURL_CA_BUNDLE="$SSL_CERT_FILE"
log "SSL CA bundle: $SSL_CERT_FILE"
else
# Fall back to certifi if installed
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"
export REQUESTS_CA_BUNDLE="$certifi_path"
export CURL_CA_BUNDLE="$certifi_path"
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"
log " Copy combined-ca-bundle.pem to $ROOT/ or \$HOME/"
log " Or: export SSL_CERT_FILE=/path/to/combined-ca-bundle.pem"
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,bix.swisscom.com,swisscom.com}"
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
# Ensure Python process sees the same bootstrap (patches ssl for alpaca-py).
export PYTHONPATH="${ROOT}${PYTHONPATH:+:$PYTHONPATH}"
}
ssl_check() {
setup_ssl
log "SSL diagnostic"
"$PYTHON" - <<'PY'
from app.ssl_bootstrap import bootstrap_ssl, ssl_status
import json
import urllib.request
ca = bootstrap_ssl()
import json, urllib.request
print(json.dumps(ssl_status(), indent=2))
print("bootstrap_ssl ->", ca)
urls = [
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",
"https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol=IBM",
]
for url in urls:
):
try:
req = urllib.request.Request(url, headers={"User-Agent": "signal-platform-ssl-check"})
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]}...")
print(f"OK {resp.status} {url[:60]}")
except Exception as exc:
print(f"FAIL {type(exc).__name__}: {exc}")
print(f" {url[:80]}")
PY
}
need_file() {
[[ -f "$1" ]] || die "missing $1"
}
require_prod() {
need_file "$PROD_SNAP"
}
run_earnings() {
require_prod
log "Earnings backfill (FMP free tier ~${FMP_LIMIT}/day; resume-safe)"
"$PYTHON" scripts/backfill_earnings_events.py \
--snapshot "$PROD_SNAP" \
--provider fmp \
--force-symbol \
--limit "$FMP_LIMIT" \
--sleep "$FMP_SLEEP"
log "Earnings research 2a+2b (report only; no filters shipped)"
"$PYTHON" scripts/run_earnings_research.py \
--snapshot "$PROD_SNAP" \
--workers "$WORKERS" \
--allow-spawn
}
run_coverage() {
require_prod
log "Coverage probe (bars per year) on $PROD_SNAP"
"$PYTHON" scripts/run_history_depth_research.py \
--phase coverage \
--snapshot "$PROD_SNAP"
}
run_rebuild() {
require_prod
log "Deep rebuild $PROD_SNAP$RESEARCH_SNAP (history-days=$HISTORY_DAYS)"
log "SURVIVORSHIP: today's constituents backfilled — relative IC only, not levels"
"$PYTHON" scripts/extend_snapshot_universe.py \
--source "$PROD_SNAP" \
--output "$RESEARCH_SNAP" \
--force-copy \
--history-days "$HISTORY_DAYS" \
--min-bars "$MIN_BARS" \
--sleep "$ALPACA_SLEEP"
log "Refresh SPY + 11 sector ETFs on research snapshot"
"$PYTHON" scripts/fetch_sector_etfs_to_snapshot.py \
--snapshot "$RESEARCH_SNAP" \
--history-days "$HISTORY_DAYS"
log "Also deepen sector ETFs on prod snapshot (for local A/B parity)"
"$PYTHON" scripts/fetch_sector_etfs_to_snapshot.py \
--snapshot "$PROD_SNAP" \
--history-days "$HISTORY_DAYS"
}
run_harness() {
need_file "$RESEARCH_SNAP"
log "Race-guard + full signal harness + era split on $RESEARCH_SNAP"
"$PYTHON" scripts/run_history_depth_research.py \
--phase harness \
--snapshot "$RESEARCH_SNAP" \
--workers "$WORKERS" \
--allow-spawn
}
run_sector_resid_deep() {
need_file "$RESEARCH_SNAP"
log "Sector-resid deep test: deepen shallow symbols + ONE liquid-1500 masked grade"
log "Pre-registered PASS/FAIL only — thread ends after this run"
"$PYTHON" scripts/run_sector_resid_deep_test.py \
--snapshot "$RESEARCH_SNAP" \
--history-days "$HISTORY_DAYS" \
--sleep "$ALPACA_SLEEP" \
--workers "$WORKERS" \
--allow-spawn
}
run_prod_book_matrix() {
need_file "$RESEARCH_SNAP"
log "Production book × universe × horizon (4 arms, strategy unchanged)"
"$PYTHON" scripts/run_prod_book_universe_matrix.py \
--snapshot "$RESEARCH_SNAP" \
--workers "$WORKERS" \
--allow-spawn \
--candidate-cache reports/.cache/prod-book-universe-cands.pkl
}
log "cwd=$ROOT python=$PYTHON phase=$PHASE workers=$WORKERS"
setup_ssl
case "$PHASE" in
ssl)
ssl_check
;;
sector_resid_deep)
run_sector_resid_deep
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)
run_prod_book_matrix
;;
coverage)
run_coverage
;;
earnings)
run_earnings
;;
harness)
run_harness
;;
depth)
run_coverage
run_rebuild
run_harness
;;
all)
run_earnings
run_coverage
run_rebuild
run_harness
;;
*)
die "unknown phase $PHASE"
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. Check reports/ and docs/research/history-depth-extension.md"
log "Commit reports on this machine if they look good, or copy them back to Windows."
log "Done."
-69
View File
@@ -100,75 +100,6 @@ def test_residual_momentum_removes_market_beta_but_keeps_specific_drift():
assert drift["mom_12_1_resid"] > pure["mom_12_1_resid"] + 0.12
def test_sector_residual_momentum_two_factor():
"""Pure market+sector beta stock → sector resid ~0; idiosyncratic drift kept."""
dates, pure_beta, highs, benchmark = _signal_test_series(extra_return=0.0)
# Sector ETF = leveraged market (collinear-ish but not identical).
sector = {d: benchmark[d] * 1.02 + 0.5 for d in dates}
# Stock with pure exposure to market + sector, no alpha.
closes = [100.0]
for i in range(1, len(dates)):
m_prev = benchmark[dates[i - 1]]
m_cur = benchmark[dates[i]]
s_prev = sector[dates[i - 1]]
s_cur = sector[dates[i]]
m_ret = m_cur / m_prev - 1.0
s_ret = s_cur / s_prev - 1.0
closes.append(closes[-1] * (1.0 + 0.7 * m_ret + 0.5 * s_ret))
highs_p = [c * 1.01 for c in closes]
pure = bt._signal_values(
dates, closes, highs_p, 260, benchmark, sector_etf_closes=sector
)
assert "mom_12_1_sector_resid" in pure
assert pure["mom_12_1_sector_resid"] == pytest.approx(0.0, abs=0.05)
# Add idiosyncratic drift — sector residual should keep it.
drift_closes = [100.0]
for i in range(1, len(dates)):
m_prev = benchmark[dates[i - 1]]
m_cur = benchmark[dates[i]]
s_prev = sector[dates[i - 1]]
s_cur = sector[dates[i]]
m_ret = m_cur / m_prev - 1.0
s_ret = s_cur / s_prev - 1.0
drift_closes.append(
drift_closes[-1] * (1.0 + 0.7 * m_ret + 0.5 * s_ret + 0.0008)
)
drift_highs = [c * 1.01 for c in drift_closes]
drift = bt._signal_values(
dates, drift_closes, drift_highs, 260, benchmark, sector_etf_closes=sector
)
assert drift["mom_12_1_sector_resid"] > pure["mom_12_1_sector_resid"] + 0.10
def test_inject_sector_demeaned_momentum():
collected = {
"mom_12_1": {
(2024, 1): [
{"val": 0.20, "fwd": 0.01, "symbol": "AAA"},
{"val": 0.10, "fwd": 0.02, "symbol": "BBB"},
{"val": 0.40, "fwd": -0.01, "symbol": "CCC"},
{"val": 0.00, "fwd": 0.03, "symbol": "DDD"},
]
}
}
symbol_to_sector = {
"AAA": "Information Technology",
"BBB": "Information Technology",
"CCC": "Energy",
"DDD": "Energy",
}
bt._inject_sector_demeaned_momentum(collected, symbol_to_sector)
dem = collected["mom_12_1_sector_demeaned"][(2024, 1)]
by_sym = {r["symbol"]: r["val"] for r in dem}
# IT mean = 0.15 → AAA +0.05, BBB -0.05; Energy mean = 0.20 → CCC +0.20, DDD -0.20
assert by_sym["AAA"] == pytest.approx(0.05)
assert by_sym["BBB"] == pytest.approx(-0.05)
assert by_sym["CCC"] == pytest.approx(0.20)
assert by_sym["DDD"] == pytest.approx(-0.20)
def test_assigns_raw_and_residual_percentiles_independently():
cands = [
{"iso_week": (2026, 1), "momentum": 0.10, "residual_momentum": 0.30},