"""LowVolAnomaly — Frazzini-Pedersen (2014) "Betting Against Beta" / Low-Vol Anomaly. On the first trading day of each calendar month, rank universe by ASCENDING realized volatility over `lookback_days` (default 60). Buy the top-N LOWEST-vol symbols at next_open, hold for `holding_days` trading days, then force-flat. Long-only, large-cap-friendly. The lowest-vol slice of a liquid universe is expected to be dominated by stable mega-caps — explicitly orthogonal to the small-cap moonshot tail of the form4 silo (HYMC etc). 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. Mirrors libs.backtest.cross_sectional_momentum but ranks on inverse-vol. """ 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.low_vol_cache import LowVolRankCache from libs.backtest.domain import ( Candidate, LookaheadViolationError, StrategyEngineConfig, ) from libs.common.logging import get_logger logger = get_logger(__name__) LOW_VOL_ANOMALY_EVENT_TYPE = "low_vol_anomaly" _ET_MARKET_OPEN = dt.time(9, 30) _ET_OFFSET = dt.timedelta(hours=-5) # --------------------------------------------------------------------------- # Provider Protocol (shared shape with xsmom / 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 _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"LowVolAnomaly {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.""" 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_realized_volatility( bars: list[tuple[dt.date, dict[str, Any]]], *, lookback_days: int = 60, ) -> tuple[float | None, list[dt.date]]: """Stdev of daily simple returns over the trailing ``lookback_days`` bars. Returns (volatility, used_dates). None when insufficient history. """ if len(bars) < lookback_days + 1: return None, [] window = bars[-(lookback_days + 1):] closes = [float(b.get("close", 0.0)) for _, b in window] if any(c <= 0 for c in closes): return None, [] rets: list[float] = [] for i in range(1, len(closes)): rets.append((closes[i] / closes[i - 1]) - 1.0) if len(rets) < 2: return None, [] vol = float(statistics.stdev(rets)) used_dates = [d for d, _ in window] return vol, used_dates 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 LowVolAnomalyInputs: symbol: str decision_date: dt.date next_trading_date: dt.date last_bar_date: dt.date last_bar_timestamp: dt.datetime last_close: float realized_volatility: float avg_dollar_volume_20d: float def evaluate_universe_gates( inputs: LowVolAnomalyInputs, 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"LowVolAnomaly {inputs.symbol}: last_bar_date " f"{inputs.last_bar_date.isoformat()} not strictly before decision_date" ) price_min = float(getattr(engine, "lowvol_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, "lowvol_min_avg_dollar_volume", 5_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_min = float(getattr(engine, "lowvol_volatility_min", 0.0) or 0.0) if vol_min > 0 and inputs.realized_volatility < vol_min: return False, f"realized_volatility {inputs.realized_volatility:.6f} < min {vol_min:.6f}" return True, None # --------------------------------------------------------------------------- # Build candidates (top-N ascending vol 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). Sorts ASCENDING by realized_volatility (lowest vol first). """ rows_sorted = sorted(rows, key=lambda r: float(r["realized_volatility"])) 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 = LowVolAnomalyInputs( 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"]), realized_volatility=float(row["realized_volatility"]), 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( "low_vol_anomaly_rebalance", decision_date=decision_date.isoformat(), universe_scanned=total_ranked, top_n_emitted=len(candidates), top_score=float(top[0]["realized_volatility"]) if top else None, bottom_score=float(top[-1]["realized_volatility"]) 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: "LowVolRankCache | None" = None, ) -> list[Candidate]: """Emit top-N synthetic LOW-VOL candidates on rebalance days only. On non-rebalance days, returns []. The universe is scanned once per rebalance day, ranked ASCENDING by realized_volatility, and the top-N (lowest vol) 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, "lowvol_enabled", False): return [] if next_trading_date <= decision_date: raise LookaheadViolationError( f"LowVolAnomaly next_trading_date {next_trading_date.isoformat()} " f"must be strictly after decision_date {decision_date.isoformat()}" ) lookback = int(getattr(engine, "lowvol_lookback_days", 60) or 60) top_n = int(getattr(engine, "lowvol_top_n", 20) or 20) vol_min = float(getattr(engine, "lowvol_volatility_min", 0.0) or 0.0) max_mcap = getattr(engine, "lowvol_max_market_cap_proxy", None) # max_market_cap_proxy is None by default — low-vol = large-cap-friendly. # The runner-level filter (engine.max_market_cap_proxy) still applies. # +5 day buffer for off-by-one and weekend gaps fetch_lookback = lookback + 25 scored: list[tuple[float, LowVolAnomalyInputs]] = [] 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"LowVolAnomaly 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 vol, vol_used_dates = compute_realized_volatility(bars, lookback_days=lookback) if vol is None: continue if vol_min > 0 and vol < vol_min: continue _assert_strictly_before(symbol, decision_date, vol_used_dates) adv_20d = compute_avg_dollar_volume_20d(bars) inputs = LowVolAnomalyInputs( 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, realized_volatility=vol, avg_dollar_volume_20d=adv_20d, ) passes, _reason = evaluate_universe_gates(inputs, engine) if not passes: continue scored.append((vol, inputs)) # Sort by volatility ASCENDING (lowest vol = best), take top N. scored.sort(key=lambda t: t[0]) 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, "realized_volatility": inp.realized_volatility, "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( "low_vol_anomaly_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: LowVolAnomalyInputs, engine: StrategyEngineConfig, *, rank: int, total_ranked: int, ) -> Candidate: holding_days = int(getattr(engine, "lowvol_holding_days", 21) or 21) stop_pct = float(getattr(engine, "lowvol_stop_pct", 0.10) or 0.10) target_pct = float(getattr(engine, "lowvol_target_pct", 0.20) or 0.20) 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 2.0 # Score: lower vol → higher score, monotonic, clipped to [0.5, 0.99]. # Map vol [0%, 4%] → score [0.99, 0.5] (linear, inverted). vol_ref = 0.04 inv = 1.0 - min(1.0, max(0.0, inputs.realized_volatility / vol_ref)) score = 0.5 + 0.49 * inv score_bucket = ( "high" if score >= 0.8 else "medium_high" if score >= 0.6 else "medium" ) event_id = ( f"synth_lowvol_{inputs.symbol.lower()}_" f"{inputs.decision_date.isoformat()}" ) features = { "lowvol_decision_date": inputs.decision_date.isoformat(), "lowvol_last_close": inputs.last_close, "lowvol_realized_volatility": round(inputs.realized_volatility, 6), "lowvol_avg_dollar_volume_20d": inputs.avg_dollar_volume_20d, "lowvol_rank": rank, "lowvol_total_ranked": total_ranked, "lowvol_stop_pct": stop_pct, "lowvol_target_pct": target_pct, "lowvol_holding_days": holding_days, } return Candidate( event_id=event_id, symbol=inputs.symbol, source_symbol=inputs.symbol, score=score, sector="UNKNOWN", event_type=LOW_VOL_ANOMALY_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, # low-vol needs full holding period — explicitly disable NO_PROGRESS / trailing # so v9.x base config's tight gates don't fire on synthetic candidates. 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 xsmom'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__ = [ "LOW_VOL_ANOMALY_EVENT_TYPE", "BarHistoryProvider", "LowVolAnomalyInputs", "_SnapshotStoreBarAdapter", "build_candidates", "compute_avg_dollar_volume_20d", "compute_realized_volatility", "evaluate_universe_gates", "is_rebalance_day", ]