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:
@@ -791,86 +791,34 @@ def _residual_momentum_12_1(
|
|||||||
with an intercept estimated over the same window, the arithmetic residuals
|
with an intercept estimated over the same window, the arithmetic residuals
|
||||||
sum to ~zero by construction, which would destroy the signal.
|
sum to ~zero by construction, which would destroy the signal.
|
||||||
"""
|
"""
|
||||||
return _multi_factor_residual_momentum_12_1(
|
if not benchmark_closes or i - 252 < 0:
|
||||||
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:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
stock_rets: list[float] = []
|
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):
|
for k in range(i - 251, i - 20):
|
||||||
prev_close = closes[k - 1]
|
prev_close = closes[k - 1]
|
||||||
if prev_close <= 0:
|
bench_prev = benchmark_closes.get(dates[k - 1])
|
||||||
continue
|
bench_cur = benchmark_closes.get(dates[k])
|
||||||
f_day: list[float] = []
|
if prev_close <= 0 or bench_prev is None or bench_cur is None or bench_prev <= 0:
|
||||||
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:
|
|
||||||
continue
|
continue
|
||||||
stock_rets.append(closes[k] / prev_close - 1.0)
|
stock_rets.append(closes[k] / prev_close - 1.0)
|
||||||
for j, r in enumerate(f_day):
|
market_rets.append(bench_cur / bench_prev - 1.0)
|
||||||
factor_rets[j].append(r)
|
|
||||||
|
|
||||||
n = len(stock_rets)
|
if len(stock_rets) < 100:
|
||||||
if n < 100:
|
|
||||||
return None
|
return None
|
||||||
|
mean_market = sum(market_rets) / len(market_rets)
|
||||||
if n_factors == 1:
|
mean_stock = sum(stock_rets) / len(stock_rets)
|
||||||
# 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)
|
var_market = sum((x - mean_market) ** 2 for x in market_rets)
|
||||||
if var_market <= 0:
|
if var_market <= 0:
|
||||||
return None
|
return None
|
||||||
cov = sum(
|
cov = sum(
|
||||||
(stock_rets[k] - mean_stock) * (market_rets[k] - mean_market)
|
(stock_rets[k] - mean_stock) * (market_rets[k] - mean_market)
|
||||||
for k in range(n)
|
for k in range(len(stock_rets))
|
||||||
)
|
)
|
||||||
beta = cov / var_market
|
beta = cov / var_market
|
||||||
return sum(stock_rets[k] - beta * market_rets[k] for k in range(n))
|
return sum(stock_rets[k] - beta * market_rets[k] for k in range(len(stock_rets)))
|
||||||
|
|
||||||
# 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:
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
def _realized_vol_6m(closes: list[float], i: int) -> float | None:
|
def _realized_vol_6m(closes: list[float], i: int) -> float | None:
|
||||||
@@ -895,7 +843,6 @@ def _signal_values(
|
|||||||
highs: list[float],
|
highs: list[float],
|
||||||
i: int,
|
i: int,
|
||||||
benchmark_closes: dict[date, float] | None = None,
|
benchmark_closes: dict[date, float] | None = None,
|
||||||
sector_etf_closes: dict[date, float] | None = None,
|
|
||||||
) -> dict[str, float]:
|
) -> dict[str, float]:
|
||||||
"""Point-in-time candidate signals at as-of index ``i`` (price-only).
|
"""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
|
higher = nearer the high, expect positive IC). ``vol_6m`` is 126-day realized
|
||||||
volatility (expect negative IC if the low-volatility anomaly holds).
|
volatility (expect negative IC if the low-volatility anomaly holds).
|
||||||
``fip_id`` is Da/Gurun/Warachka information discreteness (expect negative IC).
|
``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] = {}
|
out: dict[str, float] = {}
|
||||||
if i - 252 >= 0 and closes[i - 252] > 0:
|
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)
|
residual = _residual_momentum_12_1(dates, closes, i, benchmark_closes)
|
||||||
if residual is not None:
|
if residual is not None:
|
||||||
out["mom_12_1_resid"] = residual
|
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)
|
fip = _fip_id(closes, i)
|
||||||
if fip is not None:
|
if fip is not None:
|
||||||
out["fip_id"] = fip
|
out["fip_id"] = fip
|
||||||
@@ -1011,16 +947,14 @@ def _accumulate_signal_series(
|
|||||||
benchmark_closes: dict[date, float] | None = None,
|
benchmark_closes: dict[date, float] | None = None,
|
||||||
*,
|
*,
|
||||||
symbol: str | None = None,
|
symbol: str | None = None,
|
||||||
sector_etf_closes: dict[date, float] | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""For each weekly as-of bar, emit (signal, forward-return) pairs keyed by ISO
|
"""For each weekly as-of bar, emit (signal, forward-return) pairs keyed by ISO
|
||||||
week into ``collected[name][week_key]``. Forward return is close-to-close over
|
week into ``collected[name][week_key]``. Forward return is close-to-close over
|
||||||
HORIZON trading days. Mutates ``collected`` (a dict of dict of list).
|
HORIZON trading days. Mutates ``collected`` (a dict of dict of list).
|
||||||
|
|
||||||
When ``BACKTEST_LIQUID_BREADTH`` is set, observations are dicts with PIT
|
When ``BACKTEST_LIQUID_BREADTH`` is set, observations are dicts with PIT
|
||||||
liquidity fields for the mask. When ``symbol`` is provided, observations are
|
liquidity fields for the mask; otherwise plain ``(val, fwd)`` tuples so the
|
||||||
also dicts (so sector demeaning can group by name); otherwise plain
|
production signal path stays unchanged.
|
||||||
``(val, fwd)`` tuples keep the production path unchanged.
|
|
||||||
"""
|
"""
|
||||||
n = len(records)
|
n = len(records)
|
||||||
if n < HORIZON + 21:
|
if n < HORIZON + 21:
|
||||||
@@ -1030,7 +964,6 @@ def _accumulate_signal_series(
|
|||||||
volumes = [float(getattr(r, "volume", 0) or 0) for r in records]
|
volumes = [float(getattr(r, "volume", 0) or 0) for r in records]
|
||||||
dates = [r.date for r in records]
|
dates = [r.date for r in records]
|
||||||
liquid_mode = _liquid_breadth_top_n() > 0
|
liquid_mode = _liquid_breadth_top_n() > 0
|
||||||
rich = liquid_mode or symbol is not None
|
|
||||||
for i in _weekly_asof_indices(records):
|
for i in _weekly_asof_indices(records):
|
||||||
j = i + HORIZON
|
j = i + HORIZON
|
||||||
if j >= n or closes[i] <= 0:
|
if j >= n or closes[i] <= 0:
|
||||||
@@ -1039,79 +972,19 @@ def _accumulate_signal_series(
|
|||||||
iso = records[i].date.isocalendar()
|
iso = records[i].date.isocalendar()
|
||||||
week_key = (iso[0], iso[1])
|
week_key = (iso[0], iso[1])
|
||||||
dvol = _median_dollar_vol_63(closes, volumes, i) if liquid_mode else None
|
dvol = _median_dollar_vol_63(closes, volumes, i) if liquid_mode else None
|
||||||
for name, val in _signal_values(
|
for name, val in _signal_values(dates, closes, highs, i, benchmark_closes).items():
|
||||||
dates, closes, highs, i, benchmark_closes, sector_etf_closes
|
if liquid_mode:
|
||||||
).items():
|
collected[name][week_key].append({
|
||||||
if rich:
|
|
||||||
row = {
|
|
||||||
"val": val,
|
"val": val,
|
||||||
"fwd": fwd,
|
"fwd": fwd,
|
||||||
|
"close": closes[i],
|
||||||
|
"median_dvol_63": dvol,
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
}
|
})
|
||||||
if liquid_mode:
|
|
||||||
row["close"] = closes[i]
|
|
||||||
row["median_dvol_63"] = dvol
|
|
||||||
collected[name][week_key].append(row)
|
|
||||||
else:
|
else:
|
||||||
collected[name][week_key].append((val, fwd))
|
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]:
|
def _rank(xs: list[float]) -> list[float]:
|
||||||
"""Average (tie-corrected) ranks, 1-based."""
|
"""Average (tie-corrected) ranks, 1-based."""
|
||||||
order = sorted(range(len(xs)), key=lambda k: xs[k])
|
order = sorted(range(len(xs)), key=lambda k: xs[k])
|
||||||
@@ -1388,38 +1261,14 @@ def _signal_series(
|
|||||||
benchmark_closes: dict[date, float] | None = None,
|
benchmark_closes: dict[date, float] | None = None,
|
||||||
*,
|
*,
|
||||||
symbol: str | None = None,
|
symbol: str | None = None,
|
||||||
sector_etf_closes: dict[date, float] | None = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Per-ticker signal/forward-return series as a PLAIN (picklable) nested dict
|
"""Per-ticker signal/forward-return series as a PLAIN (picklable) nested dict
|
||||||
— no defaultdict/lambda — so it can cross a process boundary."""
|
— no defaultdict/lambda — so it can cross a process boundary."""
|
||||||
tmp: dict = defaultdict(lambda: defaultdict(list))
|
tmp: dict = defaultdict(lambda: defaultdict(list))
|
||||||
_accumulate_signal_series(
|
_accumulate_signal_series(records, tmp, benchmark_closes, symbol=symbol)
|
||||||
records,
|
|
||||||
tmp,
|
|
||||||
benchmark_closes,
|
|
||||||
symbol=symbol,
|
|
||||||
sector_etf_closes=sector_etf_closes,
|
|
||||||
)
|
|
||||||
return {name: dict(weeks) for name, weeks in tmp.items()}
|
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(
|
def _replay_and_signals(
|
||||||
symbol: str,
|
symbol: str,
|
||||||
columns: tuple,
|
columns: tuple,
|
||||||
@@ -1429,8 +1278,6 @@ def _replay_and_signals(
|
|||||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||||
signal_only: bool = False,
|
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]:
|
) -> tuple[list[dict], dict]:
|
||||||
"""The CPU-bound per-ticker work, as a top-level (picklable) function so it can
|
"""The CPU-bound per-ticker work, as a top-level (picklable) function so it can
|
||||||
run in a worker process. Takes primitive column arrays (cheap to pickle),
|
run in a worker process. Takes primitive column arrays (cheap to pickle),
|
||||||
@@ -1457,17 +1304,9 @@ def _replay_and_signals(
|
|||||||
target_model,
|
target_model,
|
||||||
cadence,
|
cadence,
|
||||||
)
|
)
|
||||||
etf_closes = _sector_etf_closes_for_symbol(
|
|
||||||
symbol, symbol_to_sector, sector_etf_closes
|
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
candidates,
|
candidates,
|
||||||
_signal_series(
|
_signal_series(bars, benchmark_closes, symbol=symbol),
|
||||||
bars,
|
|
||||||
benchmark_closes,
|
|
||||||
symbol=symbol,
|
|
||||||
sector_etf_closes=etf_closes,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4218,41 +4057,6 @@ async def run_backtest(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Benchmark load for residual momentum failed")
|
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:
|
def _merge(result: tuple[list[dict], dict]) -> None:
|
||||||
cands, series = result
|
cands, series = result
|
||||||
candidates.extend(cands)
|
candidates.extend(cands)
|
||||||
@@ -4303,8 +4107,6 @@ async def run_backtest(
|
|||||||
target_model,
|
target_model,
|
||||||
cadence,
|
cadence,
|
||||||
ticker.symbol in rank_only_symbols,
|
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):
|
for result in await asyncio.gather(*futures, return_exceptions=True):
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
@@ -4333,8 +4135,6 @@ async def run_backtest(
|
|||||||
target_model,
|
target_model,
|
||||||
cadence,
|
cadence,
|
||||||
ticker.symbol in rank_only_symbols,
|
ticker.symbol in rank_only_symbols,
|
||||||
sector_etf_closes or None,
|
|
||||||
symbol_to_sector or None,
|
|
||||||
))
|
))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Backtest replay failed for %s", ticker.symbol)
|
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:
|
if progress_cb is not None and total:
|
||||||
progress_cb(total, 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
|
# Cross-sectional momentum: rank every week's universe, then "qualified" means
|
||||||
# floors + top ``min_momentum_percentile`` by promoted residual 12-1 momentum
|
# floors + top ``min_momentum_percentile`` by promoted residual 12-1 momentum
|
||||||
# (raw 12-1 fallback only when benchmark data is unavailable).
|
# (raw 12-1 fallback only when benchmark data is unavailable).
|
||||||
|
|||||||
@@ -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]))),
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -172,9 +172,12 @@ report does not change that without a separate A/B. Flag for human awareness onl
|
|||||||
|
|
||||||
| file | role |
|
| file | role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `reports/history-depth-20260719-103315.json` | **authoritative** |
|
| `reports/history-depth-20260719-103315.json` | Superseded unmasked/two-tier IC dump (do not cite for sector residual) |
|
||||||
| `reports/history-depth-20260719-103315.md` | companion dump |
|
| `reports/sector-resid-deep-20260719-113319.json` | Authoritative sector-resid deep grade |
|
||||||
| `reports/history-depth-20260719-093853` … `095156` | **ignore** (partial) |
|
| `reports/prod-book-universe-horizon-20260719-140737.json` | 505 vs liquid × horizon book matrix |
|
||||||
|
|
||||||
|
Intermediate history-depth partials (093853–095156) and SANITY-FAIL noise were
|
||||||
|
removed in branch cleanup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -219,20 +219,17 @@ to reopen promotion.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation notes (research machinery)
|
## Implementation notes
|
||||||
|
|
||||||
| piece | role |
|
Research runners and sector-residual harness hooks were **removed after close**
|
||||||
|---|---|
|
(2026-07-19 cleanup). Evidence remains in the report artifacts below. Do not
|
||||||
| `app/services/sector_map.py` | GICS→ETF map, symbol normalise, JSON load/save |
|
re-add without a new pre-registered protocol.
|
||||||
| `app/services/backtest_service.py` | multi-factor residual; `mom_12_1_sector_resid` in `_signal_values`; demean inject |
|
|
||||||
| `scripts/build_ticker_sector_map.py` | SP500 CSV + FMP gap fill |
|
|
||||||
| `scripts/fetch_sector_etfs_to_snapshot.py` | Alpaca → snapshot `benchmark_prices` |
|
|
||||||
| `scripts/run_sector_residual_research.py` | race guard, IC, optional A/B, reports |
|
|
||||||
| `data/research/ticker_sector_map.json` | persisted labels (research only) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Artifacts
|
## Artifacts
|
||||||
|
|
||||||
- JSON: `reports/sector-residual-20260719-083356.json`
|
| file | role |
|
||||||
- MD copy: `reports/sector-residual-20260719-083356.md`
|
|---|---|
|
||||||
|
| `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"
|
|
||||||
}
|
|
||||||
@@ -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 (today’s constituents
|
|
||||||
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
|
|
||||||
**relative** signal comparisons and IC stability, not levels.
|
|
||||||
7. **Do not retune** production knobs. If a knob’s confirmation looks
|
|
||||||
overturned on deep history → report only; human decides.
|
|
||||||
|
|
||||||
### Success / interpretation (not promotion of a new signal)
|
|
||||||
|
|
||||||
| outcome | meaning |
|
|
||||||
|---|---|
|
|
||||||
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
|
|
||||||
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
|
|
||||||
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
|
|
||||||
| Any production knob looks worse deep | report; no auto-retune |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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"
|
|
||||||
}
|
|
||||||
@@ -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 (today’s constituents
|
|
||||||
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
|
|
||||||
**relative** signal comparisons and IC stability, not levels.
|
|
||||||
7. **Do not retune** production knobs. If a knob’s confirmation looks
|
|
||||||
overturned on deep history → report only; human decides.
|
|
||||||
|
|
||||||
### Success / interpretation (not promotion of a new signal)
|
|
||||||
|
|
||||||
| outcome | meaning |
|
|
||||||
|---|---|
|
|
||||||
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
|
|
||||||
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
|
|
||||||
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
|
|
||||||
| Any production knob looks worse deep | report; no auto-retune |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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"
|
|
||||||
}
|
|
||||||
@@ -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 (today’s constituents
|
|
||||||
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
|
|
||||||
**relative** signal comparisons and IC stability, not levels.
|
|
||||||
7. **Do not retune** production knobs. If a knob’s confirmation looks
|
|
||||||
overturned on deep history → report only; human decides.
|
|
||||||
|
|
||||||
### Success / interpretation (not promotion of a new signal)
|
|
||||||
|
|
||||||
| outcome | meaning |
|
|
||||||
|---|---|
|
|
||||||
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
|
|
||||||
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
|
|
||||||
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
|
|
||||||
| Any production knob looks worse deep | report; no auto-retune |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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"
|
|
||||||
}
|
|
||||||
@@ -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 (today’s constituents
|
|
||||||
backfilled). Absolute Sharpe/CAGR on deep history is optimistic; payload is
|
|
||||||
**relative** signal comparisons and IC stability, not levels.
|
|
||||||
7. **Do not retune** production knobs. If a knob’s confirmation looks
|
|
||||||
overturned on deep history → report only; human decides.
|
|
||||||
|
|
||||||
### Success / interpretation (not promotion of a new signal)
|
|
||||||
|
|
||||||
| outcome | meaning |
|
|
||||||
|---|---|
|
|
||||||
| Sector residual still ≥ market residual on deep IC + stable sign | strengthens Task 1 PROMOTE case |
|
|
||||||
| Sector residual collapses pre-2021 | **PARK** Task 1 wire-in |
|
|
||||||
| SUE remains weak after full earnings + depth | **DEAD** SUE for this stack |
|
|
||||||
| Any production knob looks worse deep | report; no auto-retune |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
@@ -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())
|
|
||||||
@@ -466,11 +466,6 @@ async def _run_2b_ic(
|
|||||||
|
|
||||||
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
||||||
os.environ["BACKTEST_SIGNAL_EVAL_ONLY"] = "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
|
settings.backtest_workers = workers
|
||||||
|
|
||||||
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
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()
|
(await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars()
|
||||||
)
|
)
|
||||||
spy = await load_benchmark_closes(db, "SPY")
|
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] = {}
|
prices: dict[str, tuple] = {}
|
||||||
for idx, t in enumerate(tickers):
|
for idx, t in enumerate(tickers):
|
||||||
@@ -521,9 +504,6 @@ async def _run_2b_ic(
|
|||||||
],
|
],
|
||||||
spy,
|
spy,
|
||||||
symbol=t.symbol,
|
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 name, weeks in series.items():
|
||||||
for wk, pairs in weeks.items():
|
for wk, pairs in weeks.items():
|
||||||
@@ -533,9 +513,6 @@ async def _run_2b_ic(
|
|||||||
if not quiet:
|
if not quiet:
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if symbol_to_sector:
|
|
||||||
bt._inject_sector_demeaned_momentum(collected, symbol_to_sector)
|
|
||||||
|
|
||||||
# SUE series.
|
# SUE series.
|
||||||
events_by_sym: dict[str, list[dict]] = defaultdict(list)
|
events_by_sym: dict[str, list[dict]] = defaultdict(list)
|
||||||
for ev in events:
|
for ev in events:
|
||||||
@@ -709,8 +686,6 @@ async def _run_2b_ic(
|
|||||||
for name in (
|
for name in (
|
||||||
"mom_12_1",
|
"mom_12_1",
|
||||||
"mom_12_1_resid",
|
"mom_12_1_resid",
|
||||||
"mom_12_1_sector_resid",
|
|
||||||
"mom_12_1_sector_demeaned",
|
|
||||||
"sue_latest",
|
"sue_latest",
|
||||||
"fip_id",
|
"fip_id",
|
||||||
)
|
)
|
||||||
@@ -784,7 +759,6 @@ def _write_md(path: Path, payload: dict) -> None:
|
|||||||
"mom_12_1",
|
"mom_12_1",
|
||||||
"mom_12_1_resid",
|
"mom_12_1_resid",
|
||||||
"sue_latest",
|
"sue_latest",
|
||||||
"mom_12_1_sector_resid",
|
|
||||||
"fip_id",
|
"fip_id",
|
||||||
):
|
):
|
||||||
r = side.get(name) or {}
|
r = side.get(name) or {}
|
||||||
|
|||||||
@@ -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
@@ -1,99 +1,70 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
# Kept after Tier-1 cleanup:
|
||||||
# - git checkout research/earnings-gap-and-sue (or later research branch)
|
# --ssl-check diagnose corporate CA / proxy
|
||||||
# - .env with ALPACA_* (required for OHLCV/ETFs); FMP_* for earnings resume;
|
# --earnings-only resume FMP earnings backfill + 2a/2b (parked)
|
||||||
# optional ALPHA_VANTAGE_* as earnings fallback
|
# --prod-book-matrix re-run 505 vs liquid universe × horizon book matrix
|
||||||
# - Python venv with project deps installed
|
#
|
||||||
# - backtest_snapshots/prod.sqlite present (gitignored — copy or rebuild)
|
# 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
|
# chmod +x scripts/run_tier1_macbook.sh
|
||||||
# ./scripts/run_tier1_macbook.sh # coverage + deep rebuild + harness
|
# ./scripts/run_tier1_macbook.sh --ssl-check
|
||||||
# ./scripts/run_tier1_macbook.sh --all # earnings resume + full depth pipeline
|
# ./scripts/run_tier1_macbook.sh --prod-book-matrix
|
||||||
# ./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.
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
# --- defaults (override via flags or env) ---
|
|
||||||
PROD_SNAP="${PROD_SNAP:-backtest_snapshots/prod.sqlite}"
|
|
||||||
RESEARCH_SNAP="${RESEARCH_SNAP:-backtest_snapshots/research.sqlite}"
|
RESEARCH_SNAP="${RESEARCH_SNAP:-backtest_snapshots/research.sqlite}"
|
||||||
HISTORY_DAYS="${HISTORY_DAYS:-5000}"
|
PROD_SNAP="${PROD_SNAP:-backtest_snapshots/prod.sqlite}"
|
||||||
MIN_BARS="${MIN_BARS:-260}"
|
|
||||||
WORKERS="${WORKERS:-8}"
|
WORKERS="${WORKERS:-8}"
|
||||||
ALPACA_SLEEP="${ALPACA_SLEEP:-0.15}"
|
|
||||||
FMP_LIMIT="${FMP_LIMIT:-250}"
|
FMP_LIMIT="${FMP_LIMIT:-250}"
|
||||||
FMP_SLEEP="${FMP_SLEEP:-0.35}"
|
FMP_SLEEP="${FMP_SLEEP:-0.35}"
|
||||||
PYTHON="${PYTHON:-python3}"
|
PYTHON="${PYTHON:-python3}"
|
||||||
USE_CORP_PROXY="${USE_CORP_PROXY:-0}"
|
USE_CORP_PROXY="${USE_CORP_PROXY:-0}"
|
||||||
|
PHASE=""
|
||||||
PHASE="depth" # depth | all | earnings | harness | coverage | ssl | sector-resid-deep | prod-book
|
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
sed -n '2,25p' "$0" | sed 's/^# \?//'
|
sed -n '2,16p' "$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
|
|
||||||
exit "${1:-0}"
|
exit "${1:-0}"
|
||||||
}
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
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 ;;
|
--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 ;;
|
--prod-book-matrix) PHASE=prod_book; shift ;;
|
||||||
--corp-proxy) USE_CORP_PROXY=1; 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 ;;
|
--workers) WORKERS="$2"; shift 2 ;;
|
||||||
--fmp-limit) FMP_LIMIT="$2"; shift 2 ;;
|
|
||||||
--python) PYTHON="$2"; shift 2 ;;
|
--python) PYTHON="$2"; shift 2 ;;
|
||||||
-h|--help) usage 0 ;;
|
-h|--help) usage 0 ;;
|
||||||
*) echo "Unknown flag: $1" >&2; usage 1 ;;
|
*) echo "Unknown flag: $1" >&2; usage 1 ;;
|
||||||
esac
|
esac
|
||||||
done
|
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
|
if [[ -x .venv/bin/python ]]; then
|
||||||
PYTHON=".venv/bin/python"
|
PYTHON=".venv/bin/python"
|
||||||
elif command -v "$PYTHON" >/dev/null 2>&1; then
|
elif command -v "$PYTHON" >/dev/null 2>&1; then
|
||||||
:
|
:
|
||||||
else
|
else
|
||||||
echo "ERROR: no Python found (tried .venv/bin/python and $PYTHON)" >&2
|
echo "ERROR: no Python found" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log() { printf '\n==> %s\n' "$*"; }
|
log() { printf '\n==> %s\n' "$*"; }
|
||||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
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() {
|
setup_ssl() {
|
||||||
export USE_CORP_PROXY
|
export USE_CORP_PROXY
|
||||||
|
|
||||||
# Prefer explicit env, then repo / home corporate bundle, then certifi.
|
|
||||||
if [[ -z "${SSL_CERT_FILE:-}" ]]; then
|
if [[ -z "${SSL_CERT_FILE:-}" ]]; then
|
||||||
if [[ -f "$ROOT/combined-ca-bundle.pem" ]]; then
|
if [[ -f "$ROOT/combined-ca-bundle.pem" ]]; then
|
||||||
export SSL_CERT_FILE="$ROOT/combined-ca-bundle.pem"
|
export SSL_CERT_FILE="$ROOT/combined-ca-bundle.pem"
|
||||||
@@ -101,191 +72,68 @@ setup_ssl() {
|
|||||||
export SSL_CERT_FILE="$HOME/combined-ca-bundle.pem"
|
export SSL_CERT_FILE="$HOME/combined-ca-bundle.pem"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${SSL_CERT_FILE:-}" && -f "$SSL_CERT_FILE" ]]; then
|
if [[ -n "${SSL_CERT_FILE:-}" && -f "$SSL_CERT_FILE" ]]; then
|
||||||
export REQUESTS_CA_BUNDLE="$SSL_CERT_FILE"
|
export REQUESTS_CA_BUNDLE="$SSL_CERT_FILE" CURL_CA_BUNDLE="$SSL_CERT_FILE"
|
||||||
export CURL_CA_BUNDLE="$SSL_CERT_FILE"
|
|
||||||
log "SSL CA bundle: $SSL_CERT_FILE"
|
log "SSL CA bundle: $SSL_CERT_FILE"
|
||||||
else
|
else
|
||||||
# Fall back to certifi if installed
|
|
||||||
local certifi_path
|
local certifi_path
|
||||||
certifi_path="$("$PYTHON" -c 'import certifi; print(certifi.where())' 2>/dev/null || true)"
|
certifi_path="$("$PYTHON" -c 'import certifi; print(certifi.where())' 2>/dev/null || true)"
|
||||||
if [[ -n "$certifi_path" && -f "$certifi_path" ]]; then
|
if [[ -n "$certifi_path" && -f "$certifi_path" ]]; then
|
||||||
export SSL_CERT_FILE="$certifi_path"
|
export SSL_CERT_FILE="$certifi_path" REQUESTS_CA_BUNDLE="$certifi_path" CURL_CA_BUNDLE="$certifi_path"
|
||||||
export REQUESTS_CA_BUNDLE="$certifi_path"
|
|
||||||
export CURL_CA_BUNDLE="$certifi_path"
|
|
||||||
log "SSL CA bundle (certifi): $SSL_CERT_FILE"
|
log "SSL CA bundle (certifi): $SSL_CERT_FILE"
|
||||||
else
|
else
|
||||||
log "WARNING: no CA bundle found — SSL may fail on corp networks"
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$USE_CORP_PROXY" == "1" ]]; then
|
if [[ "$USE_CORP_PROXY" == "1" ]]; then
|
||||||
export HTTP_PROXY="${HTTP_PROXY:-http://aproxy.corproot.net:8080}"
|
export HTTP_PROXY="${HTTP_PROXY:-http://aproxy.corproot.net:8080}"
|
||||||
export HTTPS_PROXY="${HTTPS_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"
|
export http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY" no_proxy="$NO_PROXY"
|
||||||
log "Corp proxy enabled: $HTTPS_PROXY"
|
log "Corp proxy enabled: $HTTPS_PROXY"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure Python process sees the same bootstrap (patches ssl for alpaca-py).
|
|
||||||
export PYTHONPATH="${ROOT}${PYTHONPATH:+:$PYTHONPATH}"
|
export PYTHONPATH="${ROOT}${PYTHONPATH:+:$PYTHONPATH}"
|
||||||
}
|
}
|
||||||
|
|
||||||
ssl_check() {
|
ssl_check() {
|
||||||
setup_ssl
|
setup_ssl
|
||||||
log "SSL diagnostic"
|
|
||||||
"$PYTHON" - <<'PY'
|
"$PYTHON" - <<'PY'
|
||||||
from app.ssl_bootstrap import bootstrap_ssl, ssl_status
|
from app.ssl_bootstrap import bootstrap_ssl, ssl_status
|
||||||
import json
|
import json, urllib.request
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
ca = bootstrap_ssl()
|
|
||||||
print(json.dumps(ssl_status(), indent=2))
|
print(json.dumps(ssl_status(), indent=2))
|
||||||
print("bootstrap_ssl ->", ca)
|
print("bootstrap ->", bootstrap_ssl())
|
||||||
urls = [
|
for url in (
|
||||||
"https://data.alpaca.markets/v2/stocks/SPY/bars?timeframe=1Day&limit=1",
|
"https://data.alpaca.markets/v2/stocks/SPY/bars?timeframe=1Day&limit=1",
|
||||||
"https://financialmodelingprep.com/stable/profile?symbol=AAPL",
|
"https://financialmodelingprep.com/stable/profile?symbol=AAPL",
|
||||||
"https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol=IBM",
|
):
|
||||||
]
|
|
||||||
for url in urls:
|
|
||||||
try:
|
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:
|
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:
|
except Exception as exc:
|
||||||
print(f"FAIL {type(exc).__name__}: {exc}")
|
print(f"FAIL {type(exc).__name__}: {exc}")
|
||||||
print(f" {url[:80]}")
|
|
||||||
PY
|
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
|
setup_ssl
|
||||||
|
|
||||||
case "$PHASE" in
|
case "$PHASE" in
|
||||||
ssl)
|
ssl) ssl_check ;;
|
||||||
ssl_check
|
earnings)
|
||||||
;;
|
need_file "$PROD_SNAP"
|
||||||
sector_resid_deep)
|
log "Earnings backfill + research (parked experiment)"
|
||||||
run_sector_resid_deep
|
"$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)
|
prod_book)
|
||||||
run_prod_book_matrix
|
need_file "$RESEARCH_SNAP"
|
||||||
;;
|
log "Production book universe × horizon matrix"
|
||||||
coverage)
|
"$PYTHON" scripts/run_prod_book_universe_matrix.py \
|
||||||
run_coverage
|
--snapshot "$RESEARCH_SNAP" --workers "$WORKERS" --allow-spawn \
|
||||||
;;
|
--candidate-cache reports/.cache/prod-book-universe-cands.pkl
|
||||||
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"
|
|
||||||
;;
|
;;
|
||||||
|
*) die "unknown phase $PHASE" ;;
|
||||||
esac
|
esac
|
||||||
|
log "Done."
|
||||||
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."
|
|
||||||
|
|||||||
@@ -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
|
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():
|
def test_assigns_raw_and_residual_percentiles_independently():
|
||||||
cands = [
|
cands = [
|
||||||
{"iso_week": (2026, 1), "momentum": 0.10, "residual_momentum": 0.30},
|
{"iso_week": (2026, 1), "momentum": 0.10, "residual_momentum": 0.30},
|
||||||
|
|||||||
Reference in New Issue
Block a user