Files
strategy32/research/adverse_regime.py

694 lines
32 KiB
Python

from __future__ import annotations
from dataclasses import asdict, dataclass
import pickle
from pathlib import Path
import pandas as pd
from strategy29.backtest.metrics import cagr, max_drawdown, sharpe_ratio
from strategy29.common.constants import BTC_SYMBOL
from strategy29.common.models import MarketDataBundle
from strategy29.data.universe import select_tradeable_universe
from strategy32.scripts.run_regime_filter_analysis import build_strategic_regime_frame
@dataclass(slots=True)
class AdverseRegimeEngineSpec:
name: str
target_regime: str
family: str
min_avg_dollar_volume: float = 50_000_000.0
rebalance_bars: int = 6
top_n: int = 2
transaction_cost_pct: float = 0.0015
@dataclass(slots=True)
class AdverseRegimeEngineResult:
name: str
target_regime: str
family: str
total_return: float
cagr: float
sharpe: float
max_drawdown: float
active_bar_ratio: float
rebalance_count: int
equity_curve: pd.Series
def to_payload(self) -> dict[str, object]:
return {
"name": self.name,
"target_regime": self.target_regime,
"family": self.family,
"total_return": self.total_return,
"cagr": self.cagr,
"sharpe": self.sharpe,
"max_drawdown": self.max_drawdown,
"active_bar_ratio": self.active_bar_ratio,
"rebalance_count": self.rebalance_count,
}
def default_engine_specs() -> list[AdverseRegimeEngineSpec]:
return [
AdverseRegimeEngineSpec("cap_cash", "CAPITULATION_STRESS", "cash", rebalance_bars=1),
AdverseRegimeEngineSpec("cap_btc_rebound", "CAPITULATION_STRESS", "btc_rebound", rebalance_bars=1),
AdverseRegimeEngineSpec("cap_alt_panic_rebound", "CAPITULATION_STRESS", "alt_panic_rebound", rebalance_bars=1),
AdverseRegimeEngineSpec("cap_funding_snapback_hedged", "CAPITULATION_STRESS", "funding_snapback_hedged", rebalance_bars=1),
AdverseRegimeEngineSpec("chop_cash", "CHOPPY_ROTATION", "cash", rebalance_bars=1),
AdverseRegimeEngineSpec("chop_pairs_mean_revert", "CHOPPY_ROTATION", "pairs_mean_revert", rebalance_bars=3),
AdverseRegimeEngineSpec("chop_quality_rotation", "CHOPPY_ROTATION", "quality_rotation", rebalance_bars=3),
AdverseRegimeEngineSpec("chop_carry_only", "CHOPPY_ROTATION", "carry_only", rebalance_bars=6),
AdverseRegimeEngineSpec("chop_rs_spread", "CHOPPY_ROTATION", "rs_spread", rebalance_bars=6),
AdverseRegimeEngineSpec("chop_btc_hedged_leader", "CHOPPY_ROTATION", "btc_hedged_leader", rebalance_bars=6, top_n=1),
AdverseRegimeEngineSpec("chop_carry_strict", "CHOPPY_ROTATION", "carry_only_strict", rebalance_bars=6, top_n=1),
AdverseRegimeEngineSpec("chop_inverse_carry", "CHOPPY_ROTATION", "inverse_carry", rebalance_bars=6, top_n=1),
AdverseRegimeEngineSpec("chop_inverse_carry_strict", "CHOPPY_ROTATION", "inverse_carry_strict", rebalance_bars=6, top_n=1),
AdverseRegimeEngineSpec("dist_cash", "DISTRIBUTION_DRIFT", "cash", rebalance_bars=1),
AdverseRegimeEngineSpec("dist_btc_vs_weak_alt", "DISTRIBUTION_DRIFT", "btc_vs_weak_alt", rebalance_bars=3),
AdverseRegimeEngineSpec("dist_short_rally", "DISTRIBUTION_DRIFT", "short_rally", rebalance_bars=1),
AdverseRegimeEngineSpec("dist_weak_basket_short", "DISTRIBUTION_DRIFT", "weak_basket_short", rebalance_bars=3),
AdverseRegimeEngineSpec("dist_relative_weakness_spread", "DISTRIBUTION_DRIFT", "relative_weakness_spread", rebalance_bars=6),
AdverseRegimeEngineSpec("dist_btc_rally_short", "DISTRIBUTION_DRIFT", "btc_rally_short", rebalance_bars=1, top_n=1),
AdverseRegimeEngineSpec("dist_btc_rally_short_strict", "DISTRIBUTION_DRIFT", "btc_rally_short_strict", rebalance_bars=1, top_n=1),
AdverseRegimeEngineSpec("dist_weak_rally_spread", "DISTRIBUTION_DRIFT", "weak_rally_spread", rebalance_bars=3),
AdverseRegimeEngineSpec("dist_inverse_carry", "DISTRIBUTION_DRIFT", "inverse_carry", rebalance_bars=6, top_n=1),
AdverseRegimeEngineSpec("dist_inverse_carry_strict", "DISTRIBUTION_DRIFT", "inverse_carry_strict", rebalance_bars=6, top_n=1),
]
def load_fixed66_cache(path: str | Path) -> tuple[MarketDataBundle, pd.Timestamp, list[str]]:
payload = pickle.loads(Path(path).read_bytes())
return payload["bundle"], payload["latest_bar"], list(payload["accepted_symbols"])
class AdverseRegimeResearchHarness:
def __init__(self, bundle: MarketDataBundle, latest_bar: pd.Timestamp):
self.bundle = bundle
self.latest_bar = pd.Timestamp(latest_bar)
self.timestamps = sorted(bundle.prices[BTC_SYMBOL]["timestamp"].tolist())
self._regime_frame_cache: dict[pd.Timestamp, pd.DataFrame] = {}
self.price_frames = {
symbol: df.set_index("timestamp")[["close", "volume"]].sort_index()
for symbol, df in bundle.prices.items()
}
self.funding_frames = {
symbol: df.set_index("timestamp")[["funding_rate", "basis"]].sort_index()
for symbol, df in bundle.funding.items()
}
def build_regime_frame(self, eval_start: pd.Timestamp) -> pd.DataFrame:
eval_start = pd.Timestamp(eval_start)
cached = self._regime_frame_cache.get(eval_start)
if cached is not None:
return cached
raw_start = eval_start - pd.Timedelta(days=90)
sliced = MarketDataBundle(
prices={symbol: df.loc[df["timestamp"] >= raw_start].copy() for symbol, df in self.bundle.prices.items()},
funding={symbol: df.loc[df["timestamp"] >= raw_start].copy() for symbol, df in self.bundle.funding.items()},
)
frame = build_strategic_regime_frame(sliced, eval_start, self.latest_bar)
self._regime_frame_cache[eval_start] = frame
return frame
def run_engine(
self,
spec: AdverseRegimeEngineSpec,
*,
eval_start: pd.Timestamp,
initial_capital: float = 1000.0,
regime_frame: pd.DataFrame | None = None,
) -> AdverseRegimeEngineResult:
regime_frame = self.build_regime_frame(eval_start) if regime_frame is None else regime_frame
regime_map = dict(zip(pd.to_datetime(regime_frame["timestamp"]), regime_frame["strategic_regime"]))
timestamps = [ts for ts in self.timestamps if ts >= eval_start]
if len(timestamps) < 3:
raise ValueError("not enough timestamps for adverse regime simulation")
equity = initial_capital
equity_points = [pd.Timestamp(timestamps[0])]
equity_values = [equity]
current_weights: dict[str, float] = {}
rebalance_count = 0
active_bars = 0
for i in range(1, len(timestamps)):
signal_ts = timestamps[i - 1]
execution_ts = timestamps[i]
if current_weights:
bar_ret = self._portfolio_return(current_weights, signal_ts, execution_ts)
equity *= max(0.0, 1.0 + bar_ret)
target_weights = current_weights
regime_name = regime_map.get(signal_ts, "")
if regime_name != spec.target_regime:
target_weights = {}
elif (i - 1) % spec.rebalance_bars == 0:
target_weights = self._target_weights(spec, signal_ts)
if target_weights:
active_bars += 1
turnover = self._turnover(current_weights, target_weights)
if turnover > 0:
rebalance_count += 1
equity *= max(0.0, 1.0 - turnover * spec.transaction_cost_pct)
current_weights = target_weights
equity_points.append(pd.Timestamp(execution_ts))
equity_values.append(equity)
equity_curve = pd.Series(equity_values, index=pd.Index(equity_points, name="timestamp"), dtype=float)
return AdverseRegimeEngineResult(
name=spec.name,
target_regime=spec.target_regime,
family=spec.family,
total_return=equity_curve.iloc[-1] / equity_curve.iloc[0] - 1.0,
cagr=cagr(equity_curve),
sharpe=sharpe_ratio(equity_curve, 6),
max_drawdown=max_drawdown(equity_curve),
active_bar_ratio=active_bars / max(len(timestamps) - 1, 1),
rebalance_count=rebalance_count,
equity_curve=equity_curve,
)
def target_weights(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
return self._target_weights(spec, pd.Timestamp(timestamp))
def _target_weights(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
if spec.family == "cash":
return {}
if spec.family == "btc_rebound":
return self._cap_btc_rebound(timestamp)
if spec.family == "alt_panic_rebound":
return self._cap_alt_panic_rebound(spec, timestamp)
if spec.family == "funding_snapback_hedged":
return self._cap_funding_snapback_hedged(spec, timestamp)
if spec.family == "pairs_mean_revert":
return self._chop_pairs_mean_revert(spec, timestamp)
if spec.family == "quality_rotation":
return self._chop_quality_rotation(spec, timestamp)
if spec.family == "carry_only":
return self._carry_only(spec, timestamp)
if spec.family == "rs_spread":
return self._chop_rs_spread(spec, timestamp)
if spec.family == "btc_hedged_leader":
return self._chop_btc_hedged_leader(spec, timestamp)
if spec.family == "carry_only_strict":
return self._carry_only_strict(spec, timestamp)
if spec.family == "inverse_carry":
return self._inverse_carry(spec, timestamp, strict=False)
if spec.family == "inverse_carry_strict":
return self._inverse_carry(spec, timestamp, strict=True)
if spec.family == "btc_vs_weak_alt":
return self._dist_btc_vs_weak_alt(spec, timestamp)
if spec.family == "short_rally":
return self._dist_short_rally(spec, timestamp)
if spec.family == "weak_basket_short":
return self._dist_weak_basket_short(spec, timestamp)
if spec.family == "relative_weakness_spread":
return self._dist_relative_weakness_spread(spec, timestamp)
if spec.family == "btc_rally_short":
return self._dist_btc_rally_short(timestamp)
if spec.family == "btc_rally_short_strict":
return self._dist_btc_rally_short_strict(timestamp)
if spec.family == "weak_rally_spread":
return self._dist_weak_rally_spread(spec, timestamp)
raise ValueError(f"unsupported family: {spec.family}")
def _cap_btc_rebound(self, timestamp: pd.Timestamp) -> dict[str, float]:
hist = self._price_hist(BTC_SYMBOL, timestamp, 24)
if len(hist) < 19:
return {}
ret_3d = self._return_from_hist(hist, 18)
ret_1b = self._return_from_hist(hist, 1)
if ret_3d > -0.10 or ret_1b <= 0.0:
return {}
return {BTC_SYMBOL: 1.0}
def _cap_alt_panic_rebound(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
candidates: list[tuple[float, str]] = []
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
hist = self._price_hist(symbol, timestamp, 24)
if len(hist) < 19:
continue
ret_3d = self._return_from_hist(hist, 18)
ret_1b = self._return_from_hist(hist, 1)
funding = self._latest_funding(symbol, timestamp)
if ret_3d > -0.12 or ret_1b <= 0.0:
continue
score = (-ret_3d) + max(-funding, 0.0) * 200.0 + ret_1b * 2.0
candidates.append((score, symbol))
candidates.sort(reverse=True)
symbols = [symbol for _, symbol in candidates[: spec.top_n]]
return self._equal_weight(symbols, 1.0)
def _cap_funding_snapback_hedged(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
candidates: list[tuple[float, str]] = []
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
hist = self._price_hist(symbol, timestamp, 24)
if len(hist) < 19:
continue
ret_3d = self._return_from_hist(hist, 18)
ret_1b = self._return_from_hist(hist, 1)
funding = self._latest_funding(symbol, timestamp)
if funding >= 0.0 or ret_3d > -0.08 or ret_1b <= 0.0:
continue
score = max(-funding, 0.0) * 260.0 + (-ret_3d) * 0.6 + ret_1b
candidates.append((score, symbol))
candidates.sort(reverse=True)
symbols = [symbol for _, symbol in candidates[: spec.top_n]]
if not symbols:
return {}
weights = self._equal_weight(symbols, 0.70)
weights[BTC_SYMBOL] = weights.get(BTC_SYMBOL, 0.0) - 0.30
return weights
def _chop_pairs_mean_revert(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
rows: list[tuple[float, float, str]] = []
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
hist = self._price_hist(symbol, timestamp, 18)
if len(hist) < 7:
continue
ret_1d = self._return_from_hist(hist, 6)
vol = float(hist["close"].pct_change().dropna().tail(12).std(ddof=0))
if vol <= 0 or vol > 0.08:
continue
rows.append((ret_1d, vol, symbol))
if len(rows) < spec.top_n * 2:
return {}
rows.sort(key=lambda row: row[0])
longs = [symbol for _, _, symbol in rows[: spec.top_n]]
shorts = [symbol for _, _, symbol in rows[-spec.top_n :]]
return self._long_short_weights(longs, shorts)
def _chop_quality_rotation(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
long_rows: list[tuple[float, str]] = []
short_rows: list[tuple[float, str]] = []
btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48)
if len(btc_hist) < 43:
return {}
btc_ret_7d = self._return_from_hist(btc_hist, 42)
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
hist = self._price_hist(symbol, timestamp, 48)
if len(hist) < 43:
continue
ret_7d = self._return_from_hist(hist, 42)
ret_1d = self._return_from_hist(hist, 6)
rs_7d = ret_7d - btc_ret_7d
long_rows.append((rs_7d - ret_1d, symbol))
short_rows.append((-rs_7d + ret_1d, symbol))
long_rows.sort(reverse=True)
short_rows.sort(reverse=True)
longs = [symbol for _, symbol in long_rows[: spec.top_n]]
shorts = [symbol for _, symbol in short_rows[: spec.top_n]]
shorts = [symbol for symbol in shorts if symbol not in longs]
return self._long_short_weights(longs, shorts[: spec.top_n])
def _carry_only(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
candidates: list[tuple[float, str]] = []
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
if symbol not in self.funding_frames:
continue
f_hist = self.funding_frames[symbol].loc[:timestamp].tail(21)
if len(f_hist) < 21:
continue
mean_funding = float(f_hist["funding_rate"].mean())
basis_vol = float(f_hist["basis"].std(ddof=0))
latest_basis = float(f_hist["basis"].iloc[-1])
expected_edge = mean_funding * 18 + max(latest_basis, 0.0) * 0.35 - 0.0030 - basis_vol * 1.5
if expected_edge <= 0:
continue
candidates.append((expected_edge, symbol))
candidates.sort(reverse=True)
symbols = [symbol for _, symbol in candidates[: spec.top_n]]
if not symbols:
return {}
weight = 1.0 / len(symbols)
return {f"carry:{symbol}": weight for symbol in symbols}
def _carry_only_strict(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
candidates: list[tuple[float, str]] = []
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
if symbol not in self.funding_frames:
continue
f_hist = self.funding_frames[symbol].loc[:timestamp].tail(21)
if len(f_hist) < 21:
continue
mean_funding = float(f_hist["funding_rate"].mean())
basis_vol = float(f_hist["basis"].std(ddof=0))
latest_basis = float(f_hist["basis"].iloc[-1])
positive_ratio = float((f_hist["funding_rate"] > 0).mean())
expected_edge = mean_funding * 18 + max(latest_basis, 0.0) * 0.35 - 0.0030 - basis_vol * 1.5
if expected_edge <= 0.004 or positive_ratio < 0.75:
continue
candidates.append((expected_edge, symbol))
candidates.sort(reverse=True)
symbols = [symbol for _, symbol in candidates[: spec.top_n]]
if not symbols:
return {}
return {f"carry:{symbol}": 1.0 / len(symbols) for symbol in symbols}
def _inverse_carry(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp, *, strict: bool) -> dict[str, float]:
candidates: list[tuple[float, str]] = []
min_edge = 0.004 if strict else 0.001
min_negative_ratio = 0.75 if strict else 0.60
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
if symbol not in self.funding_frames:
continue
f_hist = self.funding_frames[symbol].loc[:timestamp].tail(21)
if len(f_hist) < 21:
continue
mean_funding = float(f_hist["funding_rate"].mean())
negative_ratio = float((f_hist["funding_rate"] < 0).mean())
basis_vol = float(f_hist["basis"].std(ddof=0))
latest_basis = float(f_hist["basis"].iloc[-1])
expected_edge = (-mean_funding) * 18 + max(-latest_basis, 0.0) * 0.35 - 0.0030 - basis_vol * 1.5
if mean_funding >= 0 or negative_ratio < min_negative_ratio or expected_edge <= min_edge:
continue
candidates.append((expected_edge, symbol))
candidates.sort(reverse=True)
symbols = [symbol for _, symbol in candidates[: spec.top_n]]
if not symbols:
return {}
return {f"inverse_carry:{symbol}": 1.0 / len(symbols) for symbol in symbols}
def _chop_rs_spread(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
rows: list[tuple[float, str]] = []
btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48)
if len(btc_hist) < 43:
return {}
btc_ret_7d = self._return_from_hist(btc_hist, 42)
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
hist = self._price_hist(symbol, timestamp, 48)
if len(hist) < 43:
continue
ret_7d = self._return_from_hist(hist, 42)
ret_1d = self._return_from_hist(hist, 6)
vol = float(hist["close"].pct_change().dropna().tail(12).std(ddof=0))
if vol <= 0 or vol > 0.10:
continue
rows.append((ret_7d - btc_ret_7d - abs(ret_1d) * 0.25, symbol))
if len(rows) < spec.top_n * 2:
return {}
rows.sort(reverse=True)
longs = [symbol for _, symbol in rows[: spec.top_n]]
shorts = [symbol for _, symbol in rows[-spec.top_n :]]
return self._long_short_weights(longs, shorts)
def _chop_btc_hedged_leader(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48)
if len(btc_hist) < 43:
return {}
btc_ret_7d = self._return_from_hist(btc_hist, 42)
rows: list[tuple[float, str]] = []
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
hist = self._price_hist(symbol, timestamp, 48)
if len(hist) < 43:
continue
ret_7d = self._return_from_hist(hist, 42)
ret_1d = self._return_from_hist(hist, 6)
funding = self._latest_funding(symbol, timestamp)
rows.append((ret_7d - btc_ret_7d - max(funding, 0.0) * 80.0 - abs(ret_1d) * 0.15, symbol))
rows.sort(reverse=True)
if not rows or rows[0][0] <= 0:
return {}
leader = rows[0][1]
return {leader: 0.70, BTC_SYMBOL: -0.30}
def _dist_btc_vs_weak_alt(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48)
if len(btc_hist) < 43:
return {}
btc_ret_7d = self._return_from_hist(btc_hist, 42)
weak_rows: list[tuple[float, str]] = []
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
hist = self._price_hist(symbol, timestamp, 48)
if len(hist) < 43:
continue
ret_7d = self._return_from_hist(hist, 42)
ret_3d = self._return_from_hist(hist, 18)
weak_rows.append((ret_7d - btc_ret_7d + ret_3d, symbol))
weak_rows.sort()
shorts = [symbol for _, symbol in weak_rows[: spec.top_n]]
if not shorts:
return {}
weights = {BTC_SYMBOL: 0.40}
short_weight = -0.60 / len(shorts)
for symbol in shorts:
weights[symbol] = short_weight
return weights
def _dist_short_rally(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
rows: list[tuple[float, str]] = []
btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48)
if len(btc_hist) < 43:
return {}
btc_ret_7d = self._return_from_hist(btc_hist, 42)
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
hist = self._price_hist(symbol, timestamp, 36)
if len(hist) < 25:
continue
ret_7d = self._return_from_hist(hist, 42 if len(hist) > 42 else len(hist) - 1)
ret_2b = self._return_from_hist(hist, 2)
ema20 = hist["close"].ewm(span=20, adjust=False).mean().iloc[-1]
close = float(hist["close"].iloc[-1])
rs = ret_7d - btc_ret_7d
if rs >= -0.03 or ret_2b <= 0.0 or close >= float(ema20):
continue
score = -rs + ret_2b
rows.append((score, symbol))
rows.sort(reverse=True)
shorts = [symbol for _, symbol in rows[: spec.top_n]]
return {symbol: -1.0 / len(shorts) for symbol in shorts} if shorts else {}
def _dist_weak_basket_short(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48)
if len(btc_hist) < 43:
return {}
btc_ret_7d = self._return_from_hist(btc_hist, 42)
rows: list[tuple[float, str]] = []
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
hist = self._price_hist(symbol, timestamp, 48)
if len(hist) < 43:
continue
ret_7d = self._return_from_hist(hist, 42)
ret_3d = self._return_from_hist(hist, 18)
rs = ret_7d - btc_ret_7d
rows.append((rs + ret_3d, symbol))
rows.sort()
shorts = [symbol for _, symbol in rows[: spec.top_n]]
return {symbol: -1.0 / len(shorts) for symbol in shorts} if shorts else {}
def _dist_relative_weakness_spread(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48)
if len(btc_hist) < 43:
return {}
btc_ret_7d = self._return_from_hist(btc_hist, 42)
rows: list[tuple[float, str]] = []
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
hist = self._price_hist(symbol, timestamp, 48)
if len(hist) < 43:
continue
ret_7d = self._return_from_hist(hist, 42)
ret_3d = self._return_from_hist(hist, 18)
rows.append((ret_7d - btc_ret_7d + ret_3d * 0.25, symbol))
if len(rows) < spec.top_n * 2:
return {}
rows.sort(reverse=True)
longs = [symbol for _, symbol in rows[: spec.top_n]]
shorts = [symbol for _, symbol in rows[-spec.top_n :]]
return self._long_short_weights(longs, shorts)
def _dist_btc_rally_short(self, timestamp: pd.Timestamp) -> dict[str, float]:
hist = self._price_hist(BTC_SYMBOL, timestamp, 36)
if len(hist) < 21:
return {}
ret_2b = self._return_from_hist(hist, 2)
ema20 = hist["close"].ewm(span=20, adjust=False).mean().iloc[-1]
close = float(hist["close"].iloc[-1])
if ret_2b <= 0.0 or close >= float(ema20):
return {}
return {BTC_SYMBOL: -1.0}
def _dist_btc_rally_short_strict(self, timestamp: pd.Timestamp) -> dict[str, float]:
hist = self._price_hist(BTC_SYMBOL, timestamp, 72)
if len(hist) < 43:
return {}
ret_2b = self._return_from_hist(hist, 2)
ret_7d = self._return_from_hist(hist, 42)
ema20 = hist["close"].ewm(span=20, adjust=False).mean().iloc[-1]
ema50 = hist["close"].ewm(span=50, adjust=False).mean().iloc[-1]
close = float(hist["close"].iloc[-1])
if ret_2b < 0.035 or ret_7d > -0.02:
return {}
if close >= float(ema20) or close >= float(ema50):
return {}
return {BTC_SYMBOL: -1.0}
def _dist_weak_rally_spread(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]:
btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48)
if len(btc_hist) < 43:
return {}
btc_ret_7d = self._return_from_hist(btc_hist, 42)
strong_rows: list[tuple[float, str]] = []
weak_rows: list[tuple[float, str]] = []
for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume):
hist = self._price_hist(symbol, timestamp, 48)
if len(hist) < 43:
continue
ret_7d = self._return_from_hist(hist, 42)
ret_2b = self._return_from_hist(hist, 2)
rs = ret_7d - btc_ret_7d
strong_rows.append((rs - abs(ret_2b) * 0.1, symbol))
if ret_2b > 0:
weak_rows.append((-rs + ret_2b, symbol))
strong_rows.sort(reverse=True)
weak_rows.sort(reverse=True)
longs = [symbol for _, symbol in strong_rows[: spec.top_n]]
shorts = [symbol for _, symbol in weak_rows[: spec.top_n] if symbol not in longs]
return self._long_short_weights(longs, shorts[: spec.top_n])
def _liquid_symbols(self, timestamp: pd.Timestamp, min_avg_dollar_volume: float) -> list[str]:
return [
symbol
for symbol in select_tradeable_universe(
self.bundle.prices,
timestamp,
min_history_bars=120,
min_avg_dollar_volume=min_avg_dollar_volume,
)
if symbol != BTC_SYMBOL
]
def _price_hist(self, symbol: str, timestamp: pd.Timestamp, bars: int) -> pd.DataFrame:
return self.price_frames[symbol].loc[:timestamp].tail(bars).reset_index()
def _return_from_hist(self, hist: pd.DataFrame, bars_back: int) -> float:
if hist.empty:
return 0.0
back = min(bars_back, len(hist) - 1)
if back <= 0:
return 0.0
prev_close = float(hist["close"].iloc[-(back + 1)])
close = float(hist["close"].iloc[-1])
if prev_close <= 0:
return 0.0
return close / prev_close - 1.0
def _latest_funding(self, symbol: str, timestamp: pd.Timestamp) -> float:
if symbol not in self.funding_frames:
return 0.0
hist = self.funding_frames[symbol].loc[:timestamp].tail(1)
if hist.empty:
return 0.0
return float(hist["funding_rate"].iloc[-1])
def _portfolio_return(self, weights: dict[str, float], prev_ts: pd.Timestamp, ts: pd.Timestamp) -> float:
total = 0.0
for symbol, weight in weights.items():
if symbol.startswith("carry:"):
total += weight * self._carry_return(symbol.split(":", 1)[1], prev_ts, ts)
elif symbol.startswith("inverse_carry:"):
total += weight * self._inverse_carry_return(symbol.split(":", 1)[1], prev_ts, ts)
else:
total += weight * self._price_return(symbol, prev_ts, ts)
return total
def _price_return(self, symbol: str, prev_ts: pd.Timestamp, ts: pd.Timestamp) -> float:
frame = self.price_frames[symbol]
if prev_ts not in frame.index or ts not in frame.index:
return 0.0
prev_close = float(frame.loc[prev_ts, "close"])
close = float(frame.loc[ts, "close"])
if prev_close <= 0:
return 0.0
return close / prev_close - 1.0
def _carry_return(self, symbol: str, prev_ts: pd.Timestamp, ts: pd.Timestamp) -> float:
if symbol not in self.funding_frames:
return 0.0
frame = self.funding_frames[symbol]
if prev_ts not in frame.index or ts not in frame.index:
return 0.0
funding_rate = float(frame.loc[ts, "funding_rate"])
basis_change = float(frame.loc[ts, "basis"] - frame.loc[prev_ts, "basis"])
return funding_rate - basis_change
def _inverse_carry_return(self, symbol: str, prev_ts: pd.Timestamp, ts: pd.Timestamp) -> float:
if symbol not in self.funding_frames:
return 0.0
frame = self.funding_frames[symbol]
if prev_ts not in frame.index or ts not in frame.index:
return 0.0
funding_rate = float(frame.loc[ts, "funding_rate"])
basis_change = float(frame.loc[ts, "basis"] - frame.loc[prev_ts, "basis"])
return -funding_rate + basis_change
@staticmethod
def _turnover(current: dict[str, float], target: dict[str, float]) -> float:
symbols = set(current) | set(target)
return sum(abs(target.get(symbol, 0.0) - current.get(symbol, 0.0)) for symbol in symbols)
@staticmethod
def _equal_weight(symbols: list[str], gross: float) -> dict[str, float]:
if not symbols:
return {}
weight = gross / len(symbols)
return {symbol: weight for symbol in symbols}
@staticmethod
def _long_short_weights(longs: list[str], shorts: list[str]) -> dict[str, float]:
weights: dict[str, float] = {}
if longs:
long_weight = 0.50 / len(longs)
for symbol in longs:
weights[symbol] = weights.get(symbol, 0.0) + long_weight
if shorts:
short_weight = -0.50 / len(shorts)
for symbol in shorts:
weights[symbol] = weights.get(symbol, 0.0) + short_weight
return {symbol: weight for symbol, weight in weights.items() if abs(weight) > 1e-9}
def run_adverse_regime_search(
*,
cache_path: str | Path,
eval_days: int = 1825,
initial_capital: float = 1000.0,
) -> dict[str, object]:
bundle, latest_bar, accepted_symbols = load_fixed66_cache(cache_path)
harness = AdverseRegimeResearchHarness(bundle, latest_bar)
eval_start = pd.Timestamp(latest_bar) - pd.Timedelta(days=eval_days)
rows: list[dict[str, object]] = []
by_regime: dict[str, list[dict[str, object]]] = {}
for spec in default_engine_specs():
result = harness.run_engine(spec, eval_start=eval_start, initial_capital=initial_capital)
payload = result.to_payload()
print(
spec.target_regime,
spec.name,
f"ret={float(payload['total_return']) * 100:.2f}%",
f"sharpe={float(payload['sharpe']):.2f}",
f"mdd={float(payload['max_drawdown']) * 100:.2f}%",
flush=True,
)
rows.append(payload)
by_regime.setdefault(spec.target_regime, []).append(payload)
for regime_rows in by_regime.values():
regime_rows.sort(key=lambda row: (float(row["total_return"]), float(row["sharpe"]), -abs(float(row["max_drawdown"]))), reverse=True)
return {
"analysis": "adverse_regime_engine_search",
"latest_completed_bar": str(latest_bar),
"accepted_symbols": accepted_symbols,
"eval_days": eval_days,
"initial_capital": initial_capital,
"results": rows,
"by_regime": by_regime,
}