"""CrossSectionalMomentum — classic 12-1 academic momentum factor, look-ahead-safe. On the first trading day of each calendar month, rank universe by the cumulative return from t-252 to t-21 (i.e. 12 months back, skipping the most recent month per Jegadeesh-Titman 1993). Buy the top-N highest-momentum symbols at next_open, hold for `holding_days` trading days, then force-flat. This is the canonical academic momentum factor. The 1-month skip removes short-term reversal contamination. Top-decile long-only is the cleanest specification compatible with the existing long-only execution stack. Look-ahead defenses (NON-NEGOTIABLE): * BarHistoryProvider returns bars STRICTLY before decision_date. * `_assert_strictly_before` re-checks every used bar date. * Rebalance gate uses last_bar_date.month vs decision_date.month — purely derived from already-strict bars; no calendar lookup needed. Per advisor pre-commit (Phase 19): STANDALONE POC only — no hybrid scaffolding. """ from __future__ import annotations import datetime as dt import statistics from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Iterable, Protocol if TYPE_CHECKING: from libs.backtest.xsmom_cache import XsmomRankCache from libs.backtest.domain import ( Candidate, LookaheadViolationError, StrategyEngineConfig, ) from libs.common.logging import get_logger logger = get_logger(__name__) CROSS_SECTIONAL_MOMENTUM_EVENT_TYPE = "xsmom_12_1" _ET_MARKET_OPEN = dt.time(9, 30) _ET_OFFSET = dt.timedelta(hours=-5) # --------------------------------------------------------------------------- # Provider Protocol (shared shape with VolBreakout52w) # --------------------------------------------------------------------------- class BarHistoryProvider(Protocol): def get_bars_before( self, symbol: str, as_of_date: dt.date, lookback_days: int, ) -> list[tuple[dt.date, dict[str, Any]]]: ... # --------------------------------------------------------------------------- # Lookahead defense # --------------------------------------------------------------------------- def _decision_cutoff_utc(decision_date: dt.date) -> dt.datetime: et_naive = dt.datetime.combine(decision_date, _ET_MARKET_OPEN) utc_naive = et_naive - _ET_OFFSET return utc_naive.replace(tzinfo=dt.timezone.utc) def _assert_strictly_before( symbol: str, decision_date: dt.date, used_dates: Iterable[dt.date], ) -> None: for d in used_dates: if d >= decision_date: raise LookaheadViolationError( f"CrossSectionalMomentum {symbol}: bar date {d.isoformat()} " f"is not strictly before decision_date {decision_date.isoformat()}" ) def _bar_close_timestamp(bar_date: dt.date) -> dt.datetime: """16:00 ET close on bar_date, tz-aware UTC.""" et_naive = dt.datetime.combine(bar_date, dt.time(16, 0)) utc_naive = et_naive - _ET_OFFSET return utc_naive.replace(tzinfo=dt.timezone.utc) # --------------------------------------------------------------------------- # Pure trigger computations # --------------------------------------------------------------------------- def is_rebalance_day( bars: list[tuple[dt.date, dict[str, Any]]], decision_date: dt.date, ) -> bool: """First trading day of the calendar month iff last_bar's month != decision_date.month. Pure function: derives the rebalance flag from already-strict bar history. Returns False on empty bars. """ if not bars: return False last_bar_date = bars[-1][0] if last_bar_date >= decision_date: raise LookaheadViolationError( f"is_rebalance_day: last_bar_date {last_bar_date.isoformat()} " f"is not strictly before decision_date {decision_date.isoformat()}" ) return last_bar_date.month != decision_date.month def compute_12_1_momentum( bars: list[tuple[dt.date, dict[str, Any]]], *, lookback_days: int = 252, skip_days: int = 21, ) -> tuple[float | None, list[dt.date]]: """Cumulative return from t-(lookback+skip) to t-skip. Skips most recent `skip_days`. Returns (momentum_score, used_dates). None when insufficient history. The window is `bars[-(lookback+skip):-skip]` — last bar is t-skip-1, first bar is t-(lookback+skip). """ needed = lookback_days + skip_days if len(bars) < needed: return None, [] window = bars[-needed:-skip_days] if skip_days > 0 else bars[-needed:] if len(window) < 2: return None, [] start_close = float(window[0][1].get("close", 0.0)) end_close = float(window[-1][1].get("close", 0.0)) if start_close <= 0 or end_close <= 0: return None, [] momentum = (end_close / start_close) - 1.0 used_dates = [d for d, _ in window] return momentum, used_dates def compute_20d_volatility( bars: list[tuple[dt.date, dict[str, Any]]], ) -> float | None: """Stdev of daily log returns over the last 20 bars. Returns None on insufficient data.""" if len(bars) < 21: return None closes = [float(b.get("close", 0.0)) for _, b in bars[-21:]] if any(c <= 0 for c in closes): return None rets = [] for i in range(1, len(closes)): rets.append((closes[i] / closes[i - 1]) - 1.0) if len(rets) < 2: return None return float(statistics.stdev(rets)) def compute_avg_dollar_volume_20d( bars: list[tuple[dt.date, dict[str, Any]]], ) -> float: if not bars: return 0.0 tail = bars[-20:] return statistics.fmean( float(b.get("close", 0.0)) * float(b.get("volume", 0.0)) for _, b in tail ) # --------------------------------------------------------------------------- # Per-symbol trigger inputs + evaluation # --------------------------------------------------------------------------- @dataclass(frozen=True) class CrossSectionalMomentumInputs: symbol: str decision_date: dt.date next_trading_date: dt.date last_bar_date: dt.date last_bar_timestamp: dt.datetime last_close: float momentum_12_1: float volatility_20d: float avg_dollar_volume_20d: float def evaluate_universe_gates( inputs: CrossSectionalMomentumInputs, engine: StrategyEngineConfig, ) -> tuple[bool, str | None]: """Reject low-quality candidates BEFORE ranking. Pure function.""" if inputs.last_bar_date >= inputs.decision_date: raise LookaheadViolationError( f"CrossSectionalMomentum {inputs.symbol}: last_bar_date " f"{inputs.last_bar_date.isoformat()} not strictly before decision_date" ) price_min = float(getattr(engine, "xsmom_min_price", 5.0) or 0.0) if price_min > 0 and inputs.last_close < price_min: return False, f"last_close {inputs.last_close:.2f} < min {price_min:.2f}" adv_min = float(getattr(engine, "xsmom_min_avg_dollar_volume", 10_000_000.0) or 0.0) if adv_min > 0 and inputs.avg_dollar_volume_20d < adv_min: return False, ( f"avg_dollar_volume_20d {inputs.avg_dollar_volume_20d:,.0f} < min {adv_min:,.0f}" ) vol_max = float(getattr(engine, "xsmom_volatility_20d_max", 0.08) or 0.0) if vol_max > 0 and inputs.volatility_20d > vol_max: return False, f"volatility_20d {inputs.volatility_20d:.4f} > max {vol_max:.4f}" return True, None # --------------------------------------------------------------------------- # Build candidates (top-N selection across universe) # --------------------------------------------------------------------------- def _build_candidates_from_cache( rows: list[dict[str, Any]], decision_date: dt.date, next_trading_date: dt.date, engine: StrategyEngineConfig, top_n: int, ) -> list[Candidate]: """Build top-N candidates from cached ranked rows (already post-quality-gate).""" rows_sorted = sorted(rows, key=lambda r: float(r["momentum_12_1"]), reverse=True) top = rows_sorted[:top_n] total_ranked = len(rows_sorted) candidates: list[Candidate] = [] for rank, row in enumerate(top, start=1): last_bar_date = dt.date.fromisoformat(str(row["last_bar_date"])) _assert_strictly_before(str(row["symbol"]), decision_date, [last_bar_date]) inputs = CrossSectionalMomentumInputs( symbol=str(row["symbol"]), decision_date=decision_date, next_trading_date=next_trading_date, last_bar_date=last_bar_date, last_bar_timestamp=dt.datetime.fromisoformat(str(row["last_bar_timestamp_iso"])), last_close=float(row["last_close"]), momentum_12_1=float(row["momentum_12_1"]), volatility_20d=float(row["volatility_20d"]), avg_dollar_volume_20d=float(row["avg_dollar_volume_20d"]), ) candidates.append(_build_candidate(inputs, engine, rank=rank, total_ranked=total_ranked)) if candidates: logger.info( "cross_sectional_momentum_rebalance", decision_date=decision_date.isoformat(), universe_scanned=total_ranked, top_n_emitted=len(candidates), top_score=float(top[0]["momentum_12_1"]) if top else None, bottom_score=float(top[-1]["momentum_12_1"]) if top else None, cache_hit=True, ) return candidates def build_candidates( decision_date: dt.date, next_trading_date: dt.date, universe_symbols: Iterable[str], engine: StrategyEngineConfig, bar_provider: BarHistoryProvider, *, cache: "XsmomRankCache | None" = None, ) -> list[Candidate]: """Emit top-N synthetic momentum candidates on rebalance days only. On non-rebalance days, returns []. The universe is scanned once per rebalance day, ranked, and the top-N pass through. If ``cache`` is provided: on rebalance days, tries to serve from the disk cache (populated on prior runs). Cache miss triggers the full universe scan and saves the ranked universe for future runs. """ if not getattr(engine, "xsmom_enabled", False): return [] if next_trading_date <= decision_date: raise LookaheadViolationError( f"CrossSectionalMomentum next_trading_date {next_trading_date.isoformat()} " f"must be strictly after decision_date {decision_date.isoformat()}" ) lookback = int(getattr(engine, "xsmom_lookback_days", 252) or 252) skip = int(getattr(engine, "xsmom_skip_days", 21) or 21) top_n = int(getattr(engine, "xsmom_top_n", 20) or 20) momentum_min = float(getattr(engine, "xsmom_momentum_min", 0.0) or 0.0) fetch_lookback = lookback + skip + 5 scored: list[tuple[float, CrossSectionalMomentumInputs]] = [] seen: set[str] = set() rebalance_checked = False for raw_symbol in universe_symbols: symbol = str(raw_symbol).strip().upper() if not symbol or symbol in seen: continue seen.add(symbol) bars = bar_provider.get_bars_before(symbol, decision_date, lookback_days=fetch_lookback) if not bars: continue last_bar_date, last_bar = bars[-1] if last_bar_date >= decision_date: raise LookaheadViolationError( f"CrossSectionalMomentum bar for {symbol} on {last_bar_date.isoformat()} " f"is not strictly before decision_date {decision_date.isoformat()}" ) # Rebalance gate — check once per call, derived from bars (no calendar). if not rebalance_checked: is_rebalance = is_rebalance_day(bars, decision_date) rebalance_checked = True if not is_rebalance: return [] # Rebalance confirmed: try cache before scanning remaining universe. if cache is not None: cached_rows = cache.get_date(decision_date) if cached_rows is not None: return _build_candidates_from_cache( cached_rows, decision_date, next_trading_date, engine, top_n ) last_close = float(last_bar.get("close", 0.0)) if last_close <= 0: continue momentum, mom_used_dates = compute_12_1_momentum( bars, lookback_days=lookback, skip_days=skip ) if momentum is None: continue if momentum < momentum_min: continue _assert_strictly_before(symbol, decision_date, mom_used_dates) vol_20d = compute_20d_volatility(bars) if vol_20d is None: continue adv_20d = compute_avg_dollar_volume_20d(bars) inputs = CrossSectionalMomentumInputs( symbol=symbol, decision_date=decision_date, next_trading_date=next_trading_date, last_bar_date=last_bar_date, last_bar_timestamp=_bar_close_timestamp(last_bar_date), last_close=last_close, momentum_12_1=momentum, volatility_20d=vol_20d, avg_dollar_volume_20d=adv_20d, ) passes, reason = evaluate_universe_gates(inputs, engine) if not passes: continue scored.append((momentum, inputs)) # Sort by momentum descending, take top N. scored.sort(key=lambda t: t[0], reverse=True) top = scored[:top_n] # Save full ranked universe to cache (pre-top_n) for future runs. if cache is not None and scored: cache.save_date(decision_date, [ { "decision_date": decision_date.isoformat(), "symbol": inp.symbol, "momentum_12_1": inp.momentum_12_1, "volatility_20d": inp.volatility_20d, "avg_dollar_volume_20d": inp.avg_dollar_volume_20d, "last_close": inp.last_close, "last_bar_date": inp.last_bar_date.isoformat(), "last_bar_timestamp_iso": inp.last_bar_timestamp.isoformat(), } for _, inp in scored ]) candidates: list[Candidate] = [] for rank, (_, inputs) in enumerate(top, start=1): candidates.append(_build_candidate(inputs, engine, rank=rank, total_ranked=len(scored))) if candidates: logger.info( "cross_sectional_momentum_rebalance", decision_date=decision_date.isoformat(), universe_scanned=len(scored), top_n_emitted=len(candidates), top_score=top[0][0] if top else None, bottom_score=top[-1][0] if top else None, ) return candidates def _build_candidate( inputs: CrossSectionalMomentumInputs, engine: StrategyEngineConfig, *, rank: int, total_ranked: int, ) -> Candidate: holding_days = int(getattr(engine, "xsmom_holding_days", 21) or 21) stop_pct = float(getattr(engine, "xsmom_stop_pct", 0.10) or 0.10) target_pct = float(getattr(engine, "xsmom_target_pct", 0.30) or 0.30) synthetic_atr = max(inputs.last_close * 0.02, 0.01) stop_mult = stop_pct / 0.02 if stop_pct > 0 else 5.0 target_r = target_pct / stop_pct if stop_pct > 0 else 3.0 # Score: higher momentum → higher score, monotonic, clipped to [0, 0.99]. # Map momentum [0%, +60%] → score [0.5, 0.99] (linear). score = 0.5 + 0.49 * min(1.0, max(0.0, inputs.momentum_12_1 / 0.60)) score_bucket = ( "high" if score >= 0.8 else "medium_high" if score >= 0.6 else "medium" ) event_id = ( f"synth_xsmom_{inputs.symbol.lower()}_" f"{inputs.decision_date.isoformat()}" ) features = { "xsmom_decision_date": inputs.decision_date.isoformat(), "xsmom_last_close": inputs.last_close, "xsmom_momentum_12_1": round(inputs.momentum_12_1, 6), "xsmom_volatility_20d": round(inputs.volatility_20d, 6), "xsmom_avg_dollar_volume_20d": inputs.avg_dollar_volume_20d, "xsmom_rank": rank, "xsmom_total_ranked": total_ranked, "xsmom_stop_pct": stop_pct, "xsmom_target_pct": target_pct, "xsmom_holding_days": holding_days, } return Candidate( event_id=event_id, symbol=inputs.symbol, source_symbol=inputs.symbol, score=score, sector="UNKNOWN", event_type=CROSS_SECTIONAL_MOMENTUM_EVENT_TYPE, event_timestamp=inputs.last_bar_timestamp, event_date=inputs.decision_date, filing_time_bucket="post_market", timing_class="after_close", reaction_date=inputs.decision_date, execution_date=inputs.next_trading_date, entry_price_est=inputs.last_close, avg_dollar_volume=inputs.avg_dollar_volume_20d, atr_14=synthetic_atr, score_bucket=score_bucket, engine_id=engine.engine_id, entry_timing_policy="next_open", trade_direction="long", engine_max_holding_days=holding_days, engine_risk_budget_pct=engine.engine_risk_budget_pct, engine_capital_bucket_id=( (engine.capital_bucket_id or engine.engine_id) if engine.capital_bucket_allocation_pct is not None else None ), engine_capital_bucket_allocation_pct=engine.capital_bucket_allocation_pct, engine_per_trade_risk_pct=engine.per_trade_risk_pct_override, engine_target_1_r=target_r, engine_target_1_fraction=1.0, engine_stop_atr_multiplier=stop_mult, engine_next_open_gap_cap_pct=engine.next_open_gap_cap_pct, engine_use_reaction_day_low_stop=False, engine_early_failure_close_below_entry_and_reaction_close=False, # xsmom needs full holding period — explicitly disable NO_PROGRESS / trailing # at the candidate level so v7.356 base config's tight gates don't fire on # synthetic candidates in hybrid runs. engine_early_failure_no_progress_days=0, engine_early_failure_no_progress_r=0.0, engine_early_failure_no_progress_fraction=0.0, engine_trailing_model="none", engine_trailing_warmup_days=999, shadow_only=engine.shadow_only, features=features, ) # --------------------------------------------------------------------------- # Adapter (reuse VolBreakout52w's cached-bars pattern) # --------------------------------------------------------------------------- @dataclass class _SnapshotStoreBarAdapter: bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]] _sorted_cache: dict[str, list[tuple[dt.date, dict[str, Any]]]] = field(default_factory=dict) def _sorted_for(self, symbol: str) -> list[tuple[dt.date, dict[str, Any]]]: sym_upper = symbol.upper() cached = self._sorted_cache.get(sym_upper) if cached is not None: return cached sym_bars = self.bars_by_symbol.get(sym_upper) if not sym_bars: self._sorted_cache[sym_upper] = [] return self._sorted_cache[sym_upper] ordered = sorted(sym_bars.items(), key=lambda kv: kv[0]) self._sorted_cache[sym_upper] = ordered return ordered def get_bars_before( self, symbol: str, as_of_date: dt.date, lookback_days: int, ) -> list[tuple[dt.date, dict[str, Any]]]: ordered = self._sorted_for(symbol) if not ordered: return [] eligible: list[tuple[dt.date, dict[str, Any]]] = [] for d, b in ordered: if d >= as_of_date: break eligible.append((d, b)) return eligible[-lookback_days:] __all__ = [ "CROSS_SECTIONAL_MOMENTUM_EVENT_TYPE", "BarHistoryProvider", "CrossSectionalMomentumInputs", "_SnapshotStoreBarAdapter", "build_candidates", "compute_12_1_momentum", "compute_20d_volatility", "compute_avg_dollar_volume_20d", "evaluate_universe_gates", "is_rebalance_day", ]