research: sector residual, earnings gap/SUE, history-depth scaffolding
Tier-1 alpha research (local only, no production deploy): Sector residual momentum: two-factor SPY+sector residual and sector demean signals, IC harness + A/B. Sector resid clears pre-registered bars narrowly (PROMOTE for human wire design only). Sector demean fails t vs market resid. Earnings: earnings_events backfill (FMP bulk paid; FMP/AV per-symbol), 2a gap diagnostic report-only, 2b SUE IC (PARK; incomplete 48/506 coverage). History-depth: pre-registered doc + runner for MacBook deep rebuild/harness. Do not ship production residual or filters from this branch.
This commit is contained in:
@@ -791,31 +791,86 @@ def _residual_momentum_12_1(
|
||||
with an intercept estimated over the same window, the arithmetic residuals
|
||||
sum to ~zero by construction, which would destroy the signal.
|
||||
"""
|
||||
if not benchmark_closes or i - 252 < 0:
|
||||
return _multi_factor_residual_momentum_12_1(
|
||||
dates, closes, i, [benchmark_closes] if benchmark_closes else None
|
||||
)
|
||||
|
||||
|
||||
def _multi_factor_residual_momentum_12_1(
|
||||
dates: list[date],
|
||||
closes: list[float],
|
||||
i: int,
|
||||
factor_closes: list[dict[date, float]] | None,
|
||||
) -> float | None:
|
||||
"""12-1 residual momentum vs one or more factors (OLS, no intercept).
|
||||
|
||||
Same formation window as raw / single-factor residual momentum:
|
||||
daily returns from close[i-252] → close[i-21], require ≥100 paired obs.
|
||||
Factors are stacked as columns; betas are OLS without intercept so the
|
||||
cumulative residual is not forced to zero.
|
||||
"""
|
||||
if not factor_closes or i - 252 < 0:
|
||||
return None
|
||||
n_factors = len(factor_closes)
|
||||
if n_factors < 1:
|
||||
return None
|
||||
|
||||
stock_rets: list[float] = []
|
||||
market_rets: list[float] = []
|
||||
# Same daily intervals as mom_12_1: close[i-252] -> close[i-21].
|
||||
factor_rets: list[list[float]] = [[] for _ in range(n_factors)]
|
||||
for k in range(i - 251, i - 20):
|
||||
prev_close = closes[k - 1]
|
||||
bench_prev = benchmark_closes.get(dates[k - 1])
|
||||
bench_cur = benchmark_closes.get(dates[k])
|
||||
if prev_close <= 0 or bench_prev is None or bench_cur is None or bench_prev <= 0:
|
||||
if prev_close <= 0:
|
||||
continue
|
||||
f_day: list[float] = []
|
||||
ok = True
|
||||
for fc in factor_closes:
|
||||
f_prev = fc.get(dates[k - 1])
|
||||
f_cur = fc.get(dates[k])
|
||||
if f_prev is None or f_cur is None or f_prev <= 0:
|
||||
ok = False
|
||||
break
|
||||
f_day.append(f_cur / f_prev - 1.0)
|
||||
if not ok:
|
||||
continue
|
||||
stock_rets.append(closes[k] / prev_close - 1.0)
|
||||
market_rets.append(bench_cur / bench_prev - 1.0)
|
||||
for j, r in enumerate(f_day):
|
||||
factor_rets[j].append(r)
|
||||
|
||||
if len(stock_rets) < 100:
|
||||
n = len(stock_rets)
|
||||
if n < 100:
|
||||
return None
|
||||
mean_market = sum(market_rets) / len(market_rets)
|
||||
mean_stock = sum(stock_rets) / len(stock_rets)
|
||||
var_market = sum((x - mean_market) ** 2 for x in market_rets)
|
||||
if var_market <= 0:
|
||||
|
||||
if n_factors == 1:
|
||||
# Fast path: identical algebra to the historical single-factor form.
|
||||
market_rets = factor_rets[0]
|
||||
mean_market = sum(market_rets) / n
|
||||
mean_stock = sum(stock_rets) / n
|
||||
var_market = sum((x - mean_market) ** 2 for x in market_rets)
|
||||
if var_market <= 0:
|
||||
return None
|
||||
cov = sum(
|
||||
(stock_rets[k] - mean_stock) * (market_rets[k] - mean_market)
|
||||
for k in range(n)
|
||||
)
|
||||
beta = cov / var_market
|
||||
return sum(stock_rets[k] - beta * market_rets[k] for k in range(n))
|
||||
|
||||
# OLS without intercept: β = (X'X)^{-1} X'y for X columns = factor returns.
|
||||
# Implemented for exactly two factors (market + sector); refuse larger.
|
||||
if n_factors != 2:
|
||||
return None
|
||||
cov = sum((stock_rets[k] - mean_stock) * (market_rets[k] - mean_market) for k in range(len(stock_rets)))
|
||||
beta = cov / var_market
|
||||
return sum(stock_rets[k] - beta * market_rets[k] for k in range(len(stock_rets)))
|
||||
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:
|
||||
@@ -840,6 +895,7 @@ def _signal_values(
|
||||
highs: list[float],
|
||||
i: int,
|
||||
benchmark_closes: dict[date, float] | None = None,
|
||||
sector_etf_closes: dict[date, float] | None = None,
|
||||
) -> dict[str, float]:
|
||||
"""Point-in-time candidate signals at as-of index ``i`` (price-only).
|
||||
|
||||
@@ -851,6 +907,11 @@ def _signal_values(
|
||||
higher = nearer the high, expect positive IC). ``vol_6m`` is 126-day realized
|
||||
volatility (expect negative IC if the low-volatility anomaly holds).
|
||||
``fip_id`` is Da/Gurun/Warachka information discreteness (expect negative IC).
|
||||
|
||||
When ``sector_etf_closes`` is supplied (research path), also emit
|
||||
``mom_12_1_sector_resid``: two-factor residual vs SPY + sector ETF.
|
||||
Cross-sectional ``mom_12_1_sector_demeaned`` is injected later from the
|
||||
full weekly cross-section (cannot be computed per-ticker alone).
|
||||
"""
|
||||
out: dict[str, float] = {}
|
||||
if i - 252 >= 0 and closes[i - 252] > 0:
|
||||
@@ -858,6 +919,12 @@ def _signal_values(
|
||||
residual = _residual_momentum_12_1(dates, closes, i, benchmark_closes)
|
||||
if residual is not None:
|
||||
out["mom_12_1_resid"] = residual
|
||||
if benchmark_closes and sector_etf_closes:
|
||||
sector_resid = _multi_factor_residual_momentum_12_1(
|
||||
dates, closes, i, [benchmark_closes, sector_etf_closes]
|
||||
)
|
||||
if sector_resid is not None:
|
||||
out["mom_12_1_sector_resid"] = sector_resid
|
||||
fip = _fip_id(closes, i)
|
||||
if fip is not None:
|
||||
out["fip_id"] = fip
|
||||
@@ -944,14 +1011,16 @@ def _accumulate_signal_series(
|
||||
benchmark_closes: dict[date, float] | None = None,
|
||||
*,
|
||||
symbol: str | None = None,
|
||||
sector_etf_closes: dict[date, float] | None = None,
|
||||
) -> None:
|
||||
"""For each weekly as-of bar, emit (signal, forward-return) pairs keyed by ISO
|
||||
week into ``collected[name][week_key]``. Forward return is close-to-close over
|
||||
HORIZON trading days. Mutates ``collected`` (a dict of dict of list).
|
||||
|
||||
When ``BACKTEST_LIQUID_BREADTH`` is set, observations are dicts with PIT
|
||||
liquidity fields for the mask; otherwise plain ``(val, fwd)`` tuples so the
|
||||
production signal path stays unchanged.
|
||||
liquidity fields for the mask. When ``symbol`` is provided, observations are
|
||||
also dicts (so sector demeaning can group by name); otherwise plain
|
||||
``(val, fwd)`` tuples keep the production path unchanged.
|
||||
"""
|
||||
n = len(records)
|
||||
if n < HORIZON + 21:
|
||||
@@ -961,6 +1030,7 @@ def _accumulate_signal_series(
|
||||
volumes = [float(getattr(r, "volume", 0) or 0) for r in records]
|
||||
dates = [r.date for r in records]
|
||||
liquid_mode = _liquid_breadth_top_n() > 0
|
||||
rich = liquid_mode or symbol is not None
|
||||
for i in _weekly_asof_indices(records):
|
||||
j = i + HORIZON
|
||||
if j >= n or closes[i] <= 0:
|
||||
@@ -969,19 +1039,79 @@ def _accumulate_signal_series(
|
||||
iso = records[i].date.isocalendar()
|
||||
week_key = (iso[0], iso[1])
|
||||
dvol = _median_dollar_vol_63(closes, volumes, i) if liquid_mode else None
|
||||
for name, val in _signal_values(dates, closes, highs, i, benchmark_closes).items():
|
||||
if liquid_mode:
|
||||
collected[name][week_key].append({
|
||||
for name, val in _signal_values(
|
||||
dates, closes, highs, i, benchmark_closes, sector_etf_closes
|
||||
).items():
|
||||
if rich:
|
||||
row = {
|
||||
"val": val,
|
||||
"fwd": fwd,
|
||||
"close": closes[i],
|
||||
"median_dvol_63": dvol,
|
||||
"symbol": symbol,
|
||||
})
|
||||
}
|
||||
if liquid_mode:
|
||||
row["close"] = closes[i]
|
||||
row["median_dvol_63"] = dvol
|
||||
collected[name][week_key].append(row)
|
||||
else:
|
||||
collected[name][week_key].append((val, fwd))
|
||||
|
||||
|
||||
def _inject_sector_demeaned_momentum(
|
||||
collected: dict,
|
||||
symbol_to_sector: dict[str, str],
|
||||
*,
|
||||
min_sector_names: int = 2,
|
||||
) -> None:
|
||||
"""Cross-sectional demean of ``mom_12_1`` within GICS sector per week.
|
||||
|
||||
``mom_12_1_sector_demeaned[i] = mom_12_1[i] − mean(mom_12_1 | sector_i)``.
|
||||
Requires rich observations with a ``symbol`` field (research path). Names
|
||||
without a sector label, or sectors with fewer than ``min_sector_names``
|
||||
members that week, are dropped from the demeaned series.
|
||||
"""
|
||||
if not symbol_to_sector or "mom_12_1" not in collected:
|
||||
return
|
||||
from app.services.sector_map import normalise_symbol
|
||||
|
||||
demeaned: dict = defaultdict(list)
|
||||
for week_key, recs in collected["mom_12_1"].items():
|
||||
parsed: list[tuple[str, float, float, object]] = []
|
||||
by_sector: dict[str, list[float]] = defaultdict(list)
|
||||
for rec in recs:
|
||||
pair = _obs_val_fwd(rec)
|
||||
if pair is None:
|
||||
continue
|
||||
val, fwd = pair
|
||||
if isinstance(rec, dict):
|
||||
sym = rec.get("symbol")
|
||||
else:
|
||||
sym = None
|
||||
if not sym:
|
||||
continue
|
||||
sector = symbol_to_sector.get(normalise_symbol(str(sym)))
|
||||
if not sector:
|
||||
continue
|
||||
parsed.append((sector, val, fwd, rec))
|
||||
by_sector[sector].append(val)
|
||||
means = {
|
||||
sec: sum(vs) / len(vs)
|
||||
for sec, vs in by_sector.items()
|
||||
if len(vs) >= min_sector_names
|
||||
}
|
||||
for sector, val, fwd, rec in parsed:
|
||||
if sector not in means:
|
||||
continue
|
||||
dval = val - means[sector]
|
||||
if isinstance(rec, dict):
|
||||
row = dict(rec)
|
||||
row["val"] = dval
|
||||
demeaned[week_key].append(row)
|
||||
else:
|
||||
demeaned[week_key].append((dval, fwd))
|
||||
if demeaned:
|
||||
collected["mom_12_1_sector_demeaned"] = demeaned
|
||||
|
||||
|
||||
def _rank(xs: list[float]) -> list[float]:
|
||||
"""Average (tie-corrected) ranks, 1-based."""
|
||||
order = sorted(range(len(xs)), key=lambda k: xs[k])
|
||||
@@ -1258,14 +1388,38 @@ def _signal_series(
|
||||
benchmark_closes: dict[date, float] | None = None,
|
||||
*,
|
||||
symbol: str | None = None,
|
||||
sector_etf_closes: dict[date, float] | None = None,
|
||||
) -> dict:
|
||||
"""Per-ticker signal/forward-return series as a PLAIN (picklable) nested dict
|
||||
— no defaultdict/lambda — so it can cross a process boundary."""
|
||||
tmp: dict = defaultdict(lambda: defaultdict(list))
|
||||
_accumulate_signal_series(records, tmp, benchmark_closes, symbol=symbol)
|
||||
_accumulate_signal_series(
|
||||
records,
|
||||
tmp,
|
||||
benchmark_closes,
|
||||
symbol=symbol,
|
||||
sector_etf_closes=sector_etf_closes,
|
||||
)
|
||||
return {name: dict(weeks) for name, weeks in tmp.items()}
|
||||
|
||||
|
||||
def _sector_etf_closes_for_symbol(
|
||||
symbol: str,
|
||||
symbol_to_sector: dict[str, str] | None,
|
||||
sector_etf_closes: dict[str, dict[date, float]] | None,
|
||||
) -> dict[date, float] | None:
|
||||
"""Resolve the sector-ETF close series for one ticker, or None."""
|
||||
if not symbol_to_sector or not sector_etf_closes:
|
||||
return None
|
||||
from app.services.sector_map import etf_for_symbol
|
||||
|
||||
etf = etf_for_symbol(symbol, symbol_to_sector)
|
||||
if not etf:
|
||||
return None
|
||||
series = sector_etf_closes.get(etf)
|
||||
return series or None
|
||||
|
||||
|
||||
def _replay_and_signals(
|
||||
symbol: str,
|
||||
columns: tuple,
|
||||
@@ -1275,6 +1429,8 @@ def _replay_and_signals(
|
||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||
signal_only: bool = False,
|
||||
sector_etf_closes: dict[str, dict[date, float]] | None = None,
|
||||
symbol_to_sector: dict[str, str] | None = None,
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""The CPU-bound per-ticker work, as a top-level (picklable) function so it can
|
||||
run in a worker process. Takes primitive column arrays (cheap to pickle),
|
||||
@@ -1301,9 +1457,17 @@ def _replay_and_signals(
|
||||
target_model,
|
||||
cadence,
|
||||
)
|
||||
etf_closes = _sector_etf_closes_for_symbol(
|
||||
symbol, symbol_to_sector, sector_etf_closes
|
||||
)
|
||||
return (
|
||||
candidates,
|
||||
_signal_series(bars, benchmark_closes, symbol=symbol),
|
||||
_signal_series(
|
||||
bars,
|
||||
benchmark_closes,
|
||||
symbol=symbol,
|
||||
sector_etf_closes=etf_closes,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -4054,6 +4218,41 @@ async def run_backtest(
|
||||
except Exception:
|
||||
logger.exception("Benchmark load for residual momentum failed")
|
||||
|
||||
# Optional sector residualisation (research): local ticker→sector map + sector
|
||||
# ETF closes stored in benchmark_prices. Absent map/series → no sector signals.
|
||||
symbol_to_sector: dict[str, str] = {}
|
||||
sector_etf_closes: dict[str, dict[date, float]] = {}
|
||||
try:
|
||||
from app.services.sector_map import (
|
||||
SECTOR_ETFS,
|
||||
load_ticker_sector_map,
|
||||
normalise_symbol,
|
||||
)
|
||||
from app.services.benchmark_service import load_benchmark_closes
|
||||
|
||||
map_path = os.getenv("BACKTEST_SECTOR_MAP_PATH", "").strip() or None
|
||||
symbol_to_sector = {
|
||||
normalise_symbol(k): v
|
||||
for k, v in load_ticker_sector_map(map_path).items()
|
||||
}
|
||||
if symbol_to_sector:
|
||||
for etf in SECTOR_ETFS:
|
||||
try:
|
||||
series = await load_benchmark_closes(db, etf)
|
||||
except Exception:
|
||||
series = {}
|
||||
if series:
|
||||
sector_etf_closes[etf] = series
|
||||
logger.info(json.dumps({
|
||||
"event": "backtest_sector_context_loaded",
|
||||
"sector_map_size": len(symbol_to_sector),
|
||||
"sector_etfs_loaded": sorted(sector_etf_closes),
|
||||
}))
|
||||
except Exception:
|
||||
logger.exception("Sector residual context load failed; continuing without")
|
||||
symbol_to_sector = {}
|
||||
sector_etf_closes = {}
|
||||
|
||||
def _merge(result: tuple[list[dict], dict]) -> None:
|
||||
cands, series = result
|
||||
candidates.extend(cands)
|
||||
@@ -4104,6 +4303,8 @@ async def run_backtest(
|
||||
target_model,
|
||||
cadence,
|
||||
ticker.symbol in rank_only_symbols,
|
||||
sector_etf_closes or None,
|
||||
symbol_to_sector or None,
|
||||
))
|
||||
for result in await asyncio.gather(*futures, return_exceptions=True):
|
||||
if isinstance(result, Exception):
|
||||
@@ -4132,6 +4333,8 @@ async def run_backtest(
|
||||
target_model,
|
||||
cadence,
|
||||
ticker.symbol in rank_only_symbols,
|
||||
sector_etf_closes or None,
|
||||
symbol_to_sector or None,
|
||||
))
|
||||
except Exception:
|
||||
logger.exception("Backtest replay failed for %s", ticker.symbol)
|
||||
@@ -4139,6 +4342,13 @@ async def run_backtest(
|
||||
if progress_cb is not None and total:
|
||||
progress_cb(total, total, "")
|
||||
|
||||
# Cross-sectional sector demean needs the full weekly universe.
|
||||
if symbol_to_sector:
|
||||
try:
|
||||
_inject_sector_demeaned_momentum(collected, symbol_to_sector)
|
||||
except Exception:
|
||||
logger.exception("Sector demeaned momentum injection failed")
|
||||
|
||||
# Cross-sectional momentum: rank every week's universe, then "qualified" means
|
||||
# floors + top ``min_momentum_percentile`` by promoted residual 12-1 momentum
|
||||
# (raw 12-1 fallback only when benchmark data is unavailable).
|
||||
|
||||
Reference in New Issue
Block a user