You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
512 lines
18 KiB
Python
512 lines
18 KiB
Python
"""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",
|
|
]
|