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.
455 lines
16 KiB
Python
455 lines
16 KiB
Python
"""Unit tests for the LowVolAnomaly engine.
|
|
|
|
Mirror of test_cross_sectional_momentum.py: same lookahead defense pattern,
|
|
same rebalance-day semantics, same provider Protocol. The signal differs —
|
|
ASCENDING ranking by realized volatility (lowest vol = best).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from libs.backtest.domain import LookaheadViolationError, StrategyEngineConfig
|
|
from libs.backtest.low_vol_anomaly import (
|
|
LOW_VOL_ANOMALY_EVENT_TYPE,
|
|
_SnapshotStoreBarAdapter,
|
|
build_candidates,
|
|
compute_avg_dollar_volume_20d,
|
|
compute_realized_volatility,
|
|
is_rebalance_day,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_engine(**overrides: Any) -> StrategyEngineConfig:
|
|
base: dict[str, Any] = dict(
|
|
engine_id="low_vol_silo",
|
|
event_types=[LOW_VOL_ANOMALY_EVENT_TYPE],
|
|
direction="long_only",
|
|
timing_class="after_close",
|
|
entry_timing_policy="next_open",
|
|
max_holding_days=21,
|
|
lowvol_enabled=True,
|
|
lowvol_lookback_days=30, # smaller for tests
|
|
lowvol_top_n=3,
|
|
lowvol_holding_days=21,
|
|
lowvol_min_avg_dollar_volume=1_000_000.0,
|
|
lowvol_min_price=5.0,
|
|
lowvol_volatility_min=0.0,
|
|
lowvol_stop_pct=0.10,
|
|
lowvol_target_pct=0.20,
|
|
)
|
|
base.update(overrides)
|
|
return StrategyEngineConfig(**base)
|
|
|
|
|
|
def _business_days(start: dt.date, count: int) -> list[dt.date]:
|
|
out: list[dt.date] = []
|
|
cursor = start
|
|
while len(out) < count:
|
|
if cursor.weekday() < 5:
|
|
out.append(cursor)
|
|
cursor = cursor + dt.timedelta(days=1)
|
|
return out
|
|
|
|
|
|
def _linear_bars(
|
|
days: list[dt.date],
|
|
*,
|
|
start_close: float = 100.0,
|
|
end_close: float = 130.0,
|
|
volume: float = 5_000_000.0,
|
|
) -> dict[dt.date, dict[str, Any]]:
|
|
"""Linear close path — yields VERY low realized vol."""
|
|
n = len(days)
|
|
out: dict[dt.date, dict[str, Any]] = {}
|
|
for i, d in enumerate(days):
|
|
c = start_close + (end_close - start_close) * (i / max(1, n - 1))
|
|
out[d] = {
|
|
"open": c,
|
|
"high": c + 0.5,
|
|
"low": c - 0.5,
|
|
"close": c,
|
|
"volume": volume,
|
|
}
|
|
return out
|
|
|
|
|
|
def _choppy_bars(
|
|
days: list[dt.date],
|
|
*,
|
|
base: float = 100.0,
|
|
amp_pct: float = 0.05,
|
|
volume: float = 5_000_000.0,
|
|
) -> dict[dt.date, dict[str, Any]]:
|
|
"""Alternating up/down by amp_pct — yields HIGH realized vol."""
|
|
out: dict[dt.date, dict[str, Any]] = {}
|
|
for i, d in enumerate(days):
|
|
c = base * (1.0 + amp_pct if i % 2 == 0 else 1.0 - amp_pct)
|
|
out[d] = {
|
|
"open": c,
|
|
"high": c + 0.5,
|
|
"low": c - 0.5,
|
|
"close": c,
|
|
"volume": volume,
|
|
}
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pure trigger computations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_realized_volatility_basic() -> None:
|
|
days = _business_days(dt.date(2024, 1, 2), 80)
|
|
bars_dict = _linear_bars(days, start_close=100.0, end_close=130.0)
|
|
bars = sorted(bars_dict.items(), key=lambda kv: kv[0])
|
|
vol, used_dates = compute_realized_volatility(bars, lookback_days=30)
|
|
assert vol is not None
|
|
assert vol >= 0
|
|
assert len(used_dates) == 31 # 30 returns require 31 closes
|
|
|
|
|
|
def test_realized_volatility_insufficient_history() -> None:
|
|
days = _business_days(dt.date(2024, 1, 2), 10)
|
|
bars_dict = _linear_bars(days)
|
|
bars = sorted(bars_dict.items(), key=lambda kv: kv[0])
|
|
vol, used_dates = compute_realized_volatility(bars, lookback_days=30)
|
|
assert vol is None
|
|
assert used_dates == []
|
|
|
|
|
|
def test_realized_volatility_choppy_higher_than_linear() -> None:
|
|
"""Choppy bars should have higher realized vol than smooth linear bars."""
|
|
days = _business_days(dt.date(2024, 1, 2), 80)
|
|
smooth_dict = _linear_bars(days, start_close=100.0, end_close=130.0)
|
|
choppy_dict = _choppy_bars(days, base=100.0, amp_pct=0.05)
|
|
smooth_bars = sorted(smooth_dict.items(), key=lambda kv: kv[0])
|
|
choppy_bars = sorted(choppy_dict.items(), key=lambda kv: kv[0])
|
|
smooth_vol, _ = compute_realized_volatility(smooth_bars, lookback_days=30)
|
|
choppy_vol, _ = compute_realized_volatility(choppy_bars, lookback_days=30)
|
|
assert smooth_vol is not None and choppy_vol is not None
|
|
assert choppy_vol > smooth_vol
|
|
|
|
|
|
def test_avg_dollar_volume_20d_basic() -> None:
|
|
days = _business_days(dt.date(2024, 1, 2), 30)
|
|
bars_dict = _linear_bars(days, start_close=100, end_close=100, volume=1_000_000.0)
|
|
bars = sorted(bars_dict.items(), key=lambda kv: kv[0])
|
|
adv = compute_avg_dollar_volume_20d(bars)
|
|
# close ≈ 100, volume = 1M, so dollar volume ≈ 100M
|
|
assert abs(adv - 100_000_000.0) < 1_000_000.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rebalance-day semantics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_is_rebalance_day_first_trading_day_of_new_month() -> None:
|
|
bars = [(dt.date(2024, 1, 31), {"close": 100.0})]
|
|
assert is_rebalance_day(bars, dt.date(2024, 2, 1)) is True
|
|
|
|
|
|
def test_is_rebalance_day_mid_month() -> None:
|
|
bars = [(dt.date(2024, 1, 15), {"close": 100.0})]
|
|
assert is_rebalance_day(bars, dt.date(2024, 1, 16)) is False
|
|
|
|
|
|
def test_is_rebalance_day_empty_bars_returns_false() -> None:
|
|
assert is_rebalance_day([], dt.date(2024, 1, 2)) is False
|
|
|
|
|
|
def test_is_rebalance_day_lookahead_raises() -> None:
|
|
bars = [(dt.date(2024, 2, 1), {"close": 100.0})]
|
|
with pytest.raises(LookaheadViolationError):
|
|
is_rebalance_day(bars, dt.date(2024, 2, 1))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lookahead defense: build_candidates rejects T+0 bars
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _LeakyProvider:
|
|
"""Returns bars INCLUDING the decision_date — must be caught by build_candidates."""
|
|
|
|
def __init__(self, bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]]) -> None:
|
|
self.bars_by_symbol = bars_by_symbol
|
|
|
|
def get_bars_before(
|
|
self, symbol: str, as_of_date: dt.date, lookback_days: int
|
|
) -> list[tuple[dt.date, dict[str, Any]]]:
|
|
sym_bars = self.bars_by_symbol.get(symbol.upper(), {})
|
|
ordered = sorted(sym_bars.items(), key=lambda kv: kv[0])
|
|
return [(d, b) for d, b in ordered if d <= as_of_date][-lookback_days:]
|
|
|
|
|
|
def test_build_raises_lookahead_when_provider_returns_t0_bar() -> None:
|
|
"""Proof-by-contradiction: a leaky provider that returns T+0 must trip the assertion."""
|
|
days = _business_days(dt.date(2024, 1, 2), 80)
|
|
decision_date = days[-1]
|
|
next_date = decision_date + dt.timedelta(days=1)
|
|
bars_dict = _linear_bars(days)
|
|
leaky = _LeakyProvider({"FOO": bars_dict})
|
|
engine = _make_engine()
|
|
with pytest.raises(LookaheadViolationError):
|
|
build_candidates(
|
|
decision_date=decision_date,
|
|
next_trading_date=next_date,
|
|
universe_symbols=["FOO"],
|
|
engine=engine,
|
|
bar_provider=leaky,
|
|
)
|
|
|
|
|
|
def test_build_returns_empty_on_non_rebalance_day() -> None:
|
|
"""Mid-month decision_date must yield 0 candidates (no rebalance)."""
|
|
days = _business_days(dt.date(2024, 1, 2), 80)
|
|
bars_dict = _linear_bars(days)
|
|
bars_by_symbol = {f"SYM{i}": bars_dict for i in range(5)}
|
|
adapter = _SnapshotStoreBarAdapter(bars_by_symbol=bars_by_symbol)
|
|
engine = _make_engine()
|
|
|
|
# Use a mid-month date (where last_bar.month == decision_date.month).
|
|
decision_date = days[40]
|
|
next_date = decision_date + dt.timedelta(days=1)
|
|
candidates = build_candidates(
|
|
decision_date=decision_date,
|
|
next_trading_date=next_date,
|
|
universe_symbols=list(bars_by_symbol.keys()),
|
|
engine=engine,
|
|
bar_provider=adapter,
|
|
)
|
|
assert candidates == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Build candidates — happy path with multiple symbols + top-N selection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_build_emits_top_n_lowest_vol_on_rebalance_day() -> None:
|
|
"""Construct symbols with distinct vol; the LOWEST-vol ones must be top-3."""
|
|
days = _business_days(dt.date(2024, 1, 2), 200)
|
|
rebalance_idx = None
|
|
for i in range(40, len(days)):
|
|
if days[i].month != days[i - 1].month:
|
|
rebalance_idx = i
|
|
break
|
|
assert rebalance_idx is not None
|
|
decision_date = days[rebalance_idx]
|
|
next_date = decision_date + dt.timedelta(days=1)
|
|
used_days = days[: rebalance_idx + 1]
|
|
|
|
bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]] = {}
|
|
# SMOOTH symbols → very low vol; CHOPPY → high vol
|
|
for i in range(3):
|
|
bars_by_symbol[f"SMOOTH{i}"] = _linear_bars(
|
|
used_days, start_close=100.0, end_close=100.0 + i
|
|
)
|
|
for i in range(3):
|
|
bars_by_symbol[f"CHOPPY{i}"] = _choppy_bars(
|
|
used_days, base=100.0, amp_pct=0.03 + 0.02 * i
|
|
)
|
|
|
|
adapter = _SnapshotStoreBarAdapter(bars_by_symbol=bars_by_symbol)
|
|
engine = _make_engine(lowvol_top_n=3)
|
|
candidates = build_candidates(
|
|
decision_date=decision_date,
|
|
next_trading_date=next_date,
|
|
universe_symbols=list(bars_by_symbol.keys()),
|
|
engine=engine,
|
|
bar_provider=adapter,
|
|
)
|
|
assert len(candidates) == 3
|
|
symbols_emitted = {c.symbol for c in candidates}
|
|
# All SMOOTH (low-vol) must win over all CHOPPY (high-vol)
|
|
assert symbols_emitted == {"SMOOTH0", "SMOOTH1", "SMOOTH2"}
|
|
|
|
|
|
def test_build_ascending_ranking_order() -> None:
|
|
"""Rank-1 must have the LOWEST realized volatility."""
|
|
days = _business_days(dt.date(2024, 1, 2), 200)
|
|
rebalance_idx = None
|
|
for i in range(40, len(days)):
|
|
if days[i].month != days[i - 1].month:
|
|
rebalance_idx = i
|
|
break
|
|
assert rebalance_idx is not None
|
|
decision_date = days[rebalance_idx]
|
|
next_date = decision_date + dt.timedelta(days=1)
|
|
used_days = days[: rebalance_idx + 1]
|
|
|
|
bars_by_symbol = {
|
|
"VERY_SMOOTH": _linear_bars(used_days, start_close=100.0, end_close=100.5),
|
|
"SMOOTH": _linear_bars(used_days, start_close=100.0, end_close=110.0),
|
|
"CHOPPY": _choppy_bars(used_days, base=100.0, amp_pct=0.05),
|
|
}
|
|
adapter = _SnapshotStoreBarAdapter(bars_by_symbol=bars_by_symbol)
|
|
engine = _make_engine(lowvol_top_n=3)
|
|
candidates = build_candidates(
|
|
decision_date=decision_date,
|
|
next_trading_date=next_date,
|
|
universe_symbols=list(bars_by_symbol.keys()),
|
|
engine=engine,
|
|
bar_provider=adapter,
|
|
)
|
|
assert len(candidates) >= 2
|
|
# Rank 1 should be VERY_SMOOTH
|
|
assert candidates[0].symbol == "VERY_SMOOTH"
|
|
|
|
|
|
def test_build_filters_volatility_min() -> None:
|
|
"""Symbols with vol < min must be dropped (filter zero-vol)."""
|
|
days = _business_days(dt.date(2024, 1, 2), 200)
|
|
rebalance_idx = None
|
|
for i in range(40, len(days)):
|
|
if days[i].month != days[i - 1].month:
|
|
rebalance_idx = i
|
|
break
|
|
assert rebalance_idx is not None
|
|
decision_date = days[rebalance_idx]
|
|
next_date = decision_date + dt.timedelta(days=1)
|
|
used_days = days[: rebalance_idx + 1]
|
|
|
|
# FLAT: zero realized vol (all closes identical)
|
|
flat_bars: dict[dt.date, dict[str, Any]] = {
|
|
d: {"open": 100.0, "high": 100.0, "low": 100.0, "close": 100.0, "volume": 5_000_000.0}
|
|
for d in used_days
|
|
}
|
|
moving_bars = _linear_bars(used_days, start_close=100.0, end_close=120.0)
|
|
bars_by_symbol = {"FLAT": flat_bars, "MOVING": moving_bars}
|
|
adapter = _SnapshotStoreBarAdapter(bars_by_symbol=bars_by_symbol)
|
|
engine = _make_engine(lowvol_top_n=5, lowvol_volatility_min=0.0001)
|
|
candidates = build_candidates(
|
|
decision_date=decision_date,
|
|
next_trading_date=next_date,
|
|
universe_symbols=list(bars_by_symbol.keys()),
|
|
engine=engine,
|
|
bar_provider=adapter,
|
|
)
|
|
symbols_emitted = {c.symbol for c in candidates}
|
|
assert "FLAT" not in symbols_emitted
|
|
|
|
|
|
def test_build_filters_min_price() -> None:
|
|
"""Symbols below min_price must be dropped."""
|
|
days = _business_days(dt.date(2024, 1, 2), 200)
|
|
rebalance_idx = None
|
|
for i in range(40, len(days)):
|
|
if days[i].month != days[i - 1].month:
|
|
rebalance_idx = i
|
|
break
|
|
assert rebalance_idx is not None
|
|
decision_date = days[rebalance_idx]
|
|
next_date = decision_date + dt.timedelta(days=1)
|
|
used_days = days[: rebalance_idx + 1]
|
|
|
|
bars_by_symbol = {
|
|
"PENNY": _linear_bars(used_days, start_close=2.0, end_close=2.5),
|
|
"NORMAL": _linear_bars(used_days, start_close=100.0, end_close=105.0),
|
|
}
|
|
adapter = _SnapshotStoreBarAdapter(bars_by_symbol=bars_by_symbol)
|
|
engine = _make_engine(lowvol_top_n=5, lowvol_min_price=10.0)
|
|
candidates = build_candidates(
|
|
decision_date=decision_date,
|
|
next_trading_date=next_date,
|
|
universe_symbols=list(bars_by_symbol.keys()),
|
|
engine=engine,
|
|
bar_provider=adapter,
|
|
)
|
|
symbols_emitted = {c.symbol for c in candidates}
|
|
assert "PENNY" not in symbols_emitted
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Engine disabled / no universe / etc — defensive
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_build_returns_empty_when_engine_disabled() -> None:
|
|
engine = _make_engine(lowvol_enabled=False)
|
|
adapter = _SnapshotStoreBarAdapter(bars_by_symbol={})
|
|
candidates = build_candidates(
|
|
decision_date=dt.date(2024, 2, 1),
|
|
next_trading_date=dt.date(2024, 2, 2),
|
|
universe_symbols=["FOO"],
|
|
engine=engine,
|
|
bar_provider=adapter,
|
|
)
|
|
assert candidates == []
|
|
|
|
|
|
def test_build_raises_when_next_date_not_after_decision() -> None:
|
|
engine = _make_engine()
|
|
adapter = _SnapshotStoreBarAdapter(bars_by_symbol={})
|
|
with pytest.raises(LookaheadViolationError):
|
|
build_candidates(
|
|
decision_date=dt.date(2024, 2, 2),
|
|
next_trading_date=dt.date(2024, 2, 1),
|
|
universe_symbols=["FOO"],
|
|
engine=engine,
|
|
bar_provider=adapter,
|
|
)
|
|
|
|
|
|
def test_snapshot_store_adapter_returns_only_strictly_before() -> None:
|
|
"""The cached adapter must NEVER return bars where date >= as_of_date."""
|
|
days = [dt.date(2024, 1, d) for d in (2, 3, 4, 5, 8)]
|
|
bars = {d: {"close": 100.0 + i, "high": 101.0, "low": 99.0, "volume": 1e6} for i, d in enumerate(days)}
|
|
adapter = _SnapshotStoreBarAdapter(bars_by_symbol={"FOO": bars})
|
|
|
|
out = adapter.get_bars_before("FOO", days[3], lookback_days=10)
|
|
out_dates = [d for d, _ in out]
|
|
assert all(d < days[3] for d in out_dates)
|
|
assert out_dates == days[:3]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Candidate output shape
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_candidate_event_type_and_features() -> None:
|
|
"""Built candidate carries the correct event_type and feature dict."""
|
|
days = _business_days(dt.date(2024, 1, 2), 200)
|
|
rebalance_idx = None
|
|
for i in range(40, len(days)):
|
|
if days[i].month != days[i - 1].month:
|
|
rebalance_idx = i
|
|
break
|
|
assert rebalance_idx is not None
|
|
decision_date = days[rebalance_idx]
|
|
next_date = decision_date + dt.timedelta(days=1)
|
|
used_days = days[: rebalance_idx + 1]
|
|
|
|
bars_by_symbol = {
|
|
"STABLE": _linear_bars(used_days, start_close=100.0, end_close=100.3),
|
|
}
|
|
adapter = _SnapshotStoreBarAdapter(bars_by_symbol=bars_by_symbol)
|
|
engine = _make_engine(lowvol_top_n=1)
|
|
candidates = build_candidates(
|
|
decision_date=decision_date,
|
|
next_trading_date=next_date,
|
|
universe_symbols=list(bars_by_symbol.keys()),
|
|
engine=engine,
|
|
bar_provider=adapter,
|
|
)
|
|
assert len(candidates) == 1
|
|
c = candidates[0]
|
|
assert c.event_type == LOW_VOL_ANOMALY_EVENT_TYPE
|
|
assert c.symbol == "STABLE"
|
|
assert c.trade_direction == "long"
|
|
assert "lowvol_realized_volatility" in c.features
|
|
assert c.features["lowvol_rank"] == 1
|
|
# Decision date must strictly precede next_trading_date.
|
|
assert c.execution_date > c.event_date
|