""" Exposure overlays: series in [0, 1] read on signal dates by engine.simulate (``exposure=``). The un-invested part earns the panel's cash return. """ from __future__ import annotations import numpy as np import pandas as pd from research.lab.engine import simulate def ew_index(p) -> pd.Series: """Equal-weight index of PIT members (daily rebalanced).""" r = p.close.pct_change(fill_method=None).where(p.member.shift(1, fill_value=False)) return (1 + r.mean(axis=1).fillna(0)).cumprod() def trend(level: pd.Series, ma: int = 200, floor: float = 0.0) -> pd.Series: """1 when the index is above its moving average, else ``floor``.""" above = level > level.rolling(ma, min_periods=ma).mean() return above.astype(float).where(above, floor) def breadth(p, ma: int = 200, lo: float = 0.4, hi: float = 0.6) -> pd.Series: """Share of members above their own MA mapped linearly from lo→0 to hi→1.""" c = p.close.ffill() above = (c > c.rolling(ma, min_periods=ma).mean()).where(p.member) share = above.mean(axis=1) return ((share - lo) / (hi - lo)).clip(0, 1) def drawdown_brake(level: pd.Series, dd: float = 0.15, ma: int = 50) -> pd.Series: """Cut to zero when the index is > dd below its 1y high AND below its MA.""" high = level.rolling(252, min_periods=60).max() bad = (level / high - 1 < -dd) & (level < level.rolling(ma, min_periods=ma).mean()) return (~bad).astype(float) def vol_target(p, score, target: float, lookback: int = 60, cap: float = 1.0, base_kwargs: dict | None = None, exposure: pd.Series | None = None) -> pd.Series: """Scale exposure by target / realized vol of the unscaled strategy. Two passes: run the strategy fully invested, measure its trailing daily-return vol, then size the next rebalance; any given ``exposure`` (e.g. a trend filter) multiplies the result. """ kw = dict(base_kwargs or {}) base = simulate(p, score, **kw) rv = base.returns.rolling(lookback, min_periods=lookback // 2).std() * np.sqrt(252) scale = (target / rv).clip(upper=cap).fillna(cap) if exposure is not None: scale = scale * exposure.reindex(scale.index).ffill().fillna(1.0) return scale