Files
strategy32/backtest/simulator.py

1047 lines
50 KiB
Python

from __future__ import annotations
from collections import defaultdict
from math import sqrt
import pandas as pd
from strategy29.backtest.metrics import cagr, max_drawdown, profit_factor, sharpe_ratio, win_rate
from strategy29.backtest.simulator import Strategy29Backtester
from strategy29.common.constants import BTC_SYMBOL, ENGINE_CARRY, ENGINE_MOMENTUM
from strategy29.common.models import AllocationDecision, BacktestResult, CarryCandidate, EquityPoint, MarketDataBundle, MomentumCandidate, PositionState, Regime
from strategy29.data.universe import select_tradeable_universe
from strategy29.portfolio.manager import PortfolioManager
from strategy29.portfolio.risk_limits import carry_stop_triggered, momentum_stop_triggered
from strategy29.signal.btc_regime import BTCRegimeEngine
from strategy32.config import Strategy32Config, build_engine_config
from strategy32.signal.router import Strategy32Router
from strategy32.universe import (
filter_momentum_frame,
limit_correlated_symbols,
score_carry_universe,
score_momentum_universe,
select_dynamic_universe,
select_strategic_universe,
)
ENGINE_SIDEWAYS = "sideways"
class Strategy32MomentumCarryBacktester(Strategy29Backtester):
def __init__(
self,
strategy_config: Strategy32Config,
data: MarketDataBundle,
*,
trade_start: pd.Timestamp | None = None,
execution_prices: dict[str, pd.DataFrame] | None = None,
):
self.strategy_config = strategy_config
self.trade_start = trade_start
self.execution_prices = execution_prices or data.prices
self.rejection_log: list[dict[str, object]] = []
self.rejection_summary: dict[str, int] = {}
self._hard_filter_cache: dict[pd.Timestamp, set[str]] = {}
super().__init__(build_engine_config(), data, router=Strategy32Router(strategy_config.budgets))
def run(self, *, close_final_positions: bool = True) -> BacktestResult:
self.rejection_log = []
self.rejection_summary = defaultdict(int)
self._hard_filter_cache = {}
btc_prices = self.data.prices[BTC_SYMBOL]
prepared_btc = BTCRegimeEngine(self.config.regime).prepare(btc_prices)
timestamps = list(prepared_btc["timestamp"])
portfolio = PortfolioManager(self.config.initial_capital)
allocations: list[AllocationDecision] = []
trades = []
equity_points: list[EquityPoint] = []
exposure_rows: list[dict[str, object]] = []
engine_pnl = defaultdict(float)
warmup = max(self.config.momentum.min_history_bars, self.config.spread.min_history_bars, 60)
equity_history = [self.config.initial_capital]
for i in range(1, len(timestamps)):
signal_timestamp = timestamps[i - 1]
execution_timestamp = timestamps[i]
signal_index = i - 1
scheduled_stops = self._scheduled_stop_closures(portfolio, signal_timestamp)
breadth = self._breadth_at(signal_timestamp)
row = prepared_btc.iloc[signal_index]
regime = self.btc_engine.classify_row(row, breadth) if signal_index >= warmup else Regime.SIDEWAYS
momentum_due = signal_index >= warmup and signal_index % self.config.momentum.rebalance_bars == 0
carry_due = signal_index >= warmup and signal_index % self.config.carry.rebalance_bars == 0
can_trade = self.trade_start is None or execution_timestamp >= self.trade_start
governed_decision = None
if can_trade and (momentum_due or carry_due):
base_decision = self.router.decide(regime)
governed_decision = self._govern_decision(
base_decision,
signal_timestamp=signal_timestamp,
current_equity=portfolio.total_equity(),
equity_history=equity_history,
)
allocations.append(governed_decision)
self._apply_bar_returns(portfolio, signal_timestamp, execution_timestamp)
trades.extend(self._close_positions(portfolio, execution_timestamp, scheduled_stops))
if governed_decision is not None:
trades.extend(
self._rebalance(
portfolio=portfolio,
signal_timestamp=signal_timestamp,
execution_timestamp=execution_timestamp,
decision=governed_decision,
rebalance_momentum=momentum_due,
rebalance_carry=carry_due,
rebalance_spread=False,
)
)
equity = portfolio.total_equity()
equity_history.append(equity)
momentum_value = sum(pos.value for pos in portfolio.positions_for_engine(ENGINE_MOMENTUM))
sideways_value = sum(pos.value for pos in portfolio.positions_for_engine(ENGINE_SIDEWAYS))
carry_value = sum(pos.value for pos in portfolio.positions_for_engine(ENGINE_CARRY))
cash_value = portfolio.cash
total_value = max(equity, 1e-12)
exposure_rows.append(
{
"timestamp": execution_timestamp,
"equity": equity,
"cash_value": cash_value,
"momentum_value": momentum_value,
"sideways_value": sideways_value,
"carry_value": carry_value,
"cash_pct": cash_value / total_value,
"momentum_pct": momentum_value / total_value,
"sideways_pct": sideways_value / total_value,
"carry_pct": carry_value / total_value,
"regime": regime.value,
}
)
equity_points.append(
EquityPoint(
timestamp=execution_timestamp,
equity=equity,
regime=regime,
momentum_positions=len(portfolio.positions_for_engine(ENGINE_MOMENTUM))
+ len(portfolio.positions_for_engine(ENGINE_SIDEWAYS)),
carry_positions=len(portfolio.positions_for_engine(ENGINE_CARRY)),
spread_positions=0,
)
)
final_timestamp = timestamps[-1]
final_positions = [
PositionState(
engine=position.engine,
symbol=position.symbol,
entry_time=position.entry_time,
entry_reference=position.entry_reference,
gross_capital=position.gross_capital,
value=position.value,
max_value=position.max_value,
meta=dict(position.meta),
)
for position in portfolio.positions.values()
]
if close_final_positions:
trades.extend(self._close_all(portfolio, final_timestamp, "end_of_backtest"))
final_equity = portfolio.total_equity()
if equity_points:
equity_points[-1] = EquityPoint(
timestamp=final_timestamp,
equity=final_equity,
regime=equity_points[-1].regime,
momentum_positions=0 if close_final_positions else len(portfolio.positions_for_engine(ENGINE_MOMENTUM)) + len(portfolio.positions_for_engine(ENGINE_SIDEWAYS)),
carry_positions=0 if close_final_positions else len(portfolio.positions_for_engine(ENGINE_CARRY)),
spread_positions=0,
)
for trade in trades:
engine_pnl[trade.engine] += trade.pnl_usd
equity_series = pd.Series(
[point.equity for point in equity_points],
index=pd.Index([point.timestamp for point in equity_points], name="timestamp"),
dtype=float,
)
trade_pnls = [trade.pnl_usd for trade in trades]
return BacktestResult(
initial_capital=self.config.initial_capital,
final_equity=final_equity,
total_return=(final_equity / self.config.initial_capital - 1.0) if self.config.initial_capital else 0.0,
cagr=cagr(equity_series) if not equity_series.empty else 0.0,
sharpe=sharpe_ratio(equity_series, self.config.bars_per_day) if not equity_series.empty else 0.0,
max_drawdown=max_drawdown(equity_series) if not equity_series.empty else 0.0,
win_rate=win_rate(trade_pnls),
profit_factor=profit_factor(trade_pnls),
total_trades=len(trades),
engine_pnl=dict(engine_pnl),
equity_curve=equity_series,
trades=trades,
allocations=allocations,
metadata={
"exposure_rows": exposure_rows,
"rejection_log": self.rejection_log,
"rejection_summary": dict(self.rejection_summary),
"final_positions": [
{
"engine": pos.engine,
"symbol": pos.symbol,
"entry_time": pos.entry_time.isoformat(),
"entry_reference": pos.entry_reference,
"gross_capital": pos.gross_capital,
"value": pos.value,
"max_value": pos.max_value,
"meta": pos.meta,
}
for pos in final_positions
],
"final_cash": portfolio.cash,
},
)
@staticmethod
def _recent_drawdown(equity_history: list[float], window_bars: int, current_equity: float, initial_capital: float) -> float:
recent_peak = max(equity_history[-window_bars:] or [current_equity, initial_capital])
if recent_peak <= 0:
return 0.0
return max(0.0, 1.0 - current_equity / recent_peak)
def _govern_decision(
self,
decision: AllocationDecision,
*,
signal_timestamp: pd.Timestamp,
current_equity: float,
equity_history: list[float],
) -> AllocationDecision:
short_window = self.strategy_config.drawdown_window_days * self.config.bars_per_day
short_drawdown = self._recent_drawdown(equity_history, short_window, current_equity, self.config.initial_capital)
scale = 1.0
if self.strategy_config.enable_strong_kill_switch:
long_window = self.strategy_config.strong_kill_drawdown_window_days * self.config.bars_per_day
long_drawdown = self._recent_drawdown(equity_history, long_window, current_equity, self.config.initial_capital)
effective_drawdown = max(short_drawdown, long_drawdown)
if effective_drawdown >= self.strategy_config.strong_kill_stop_trigger:
scale = 0.0
elif effective_drawdown >= self.strategy_config.strong_kill_scale_2_trigger:
scale *= self.strategy_config.strong_kill_scale_2
elif effective_drawdown >= self.strategy_config.strong_kill_scale_1_trigger:
scale *= self.strategy_config.strong_kill_scale_1
else:
if short_drawdown >= self.strategy_config.drawdown_stop_trigger:
scale = 0.0
elif short_drawdown >= self.strategy_config.drawdown_scale_2_trigger:
scale *= self.strategy_config.drawdown_scale_2
elif short_drawdown >= self.strategy_config.drawdown_scale_1_trigger:
scale *= self.strategy_config.drawdown_scale_1
realized_vol = self._portfolio_annualized_vol(equity_history)
if realized_vol > self.strategy_config.target_annualized_vol:
scale *= max(
self.strategy_config.vol_scale_floor,
self.strategy_config.target_annualized_vol / realized_vol,
)
momentum_budget = decision.momentum_budget_pct * scale
carry_budget = decision.carry_budget_pct * scale
sideways_budget = decision.spread_budget_pct * scale
if not self.strategy_config.enable_sideways_engine:
sideways_budget = 0.0
if self.strategy_config.enable_daily_trend_filter and not self._daily_trend_ok(signal_timestamp):
momentum_budget = 0.0
sideways_budget = 0.0
cash_budget = max(0.0, 1.0 - momentum_budget - carry_budget - sideways_budget)
return AllocationDecision(
regime=decision.regime,
momentum_budget_pct=momentum_budget,
carry_budget_pct=carry_budget,
spread_budget_pct=sideways_budget,
cash_budget_pct=cash_budget,
)
def _portfolio_annualized_vol(self, equity_history: list[float]) -> float:
lookback = self.strategy_config.governor_vol_lookback_bars
if len(equity_history) <= lookback:
return 0.0
series = pd.Series(equity_history[-lookback:], dtype=float)
returns = series.pct_change().dropna()
if len(returns) < 10 or returns.std(ddof=0) <= 0:
return 0.0
return float(returns.std(ddof=0) * sqrt(365 * self.config.bars_per_day))
def _apply_bar_returns(self, portfolio: PortfolioManager, prev_ts: pd.Timestamp, ts: pd.Timestamp) -> None:
btc_ret = self._price_return(BTC_SYMBOL, prev_ts, ts)
for position in list(portfolio.positions.values()):
if position.engine in {ENGINE_MOMENTUM, ENGINE_SIDEWAYS}:
alt_ret = self._price_return(position.symbol, prev_ts, ts)
hedge_ratio = float(position.meta.get("hedge_ratio", 0.0))
ret = alt_ret - hedge_ratio * btc_ret
else:
ret = self._carry_return(position.symbol, prev_ts, ts)
portfolio.apply_return(position.engine, position.symbol, ret)
def _scheduled_stop_closures(
self,
portfolio: PortfolioManager,
signal_timestamp: pd.Timestamp,
) -> list[tuple[str, str, str]]:
scheduled: list[tuple[str, str, str]] = []
for position in list(portfolio.positions.values()):
if position.engine in {ENGINE_MOMENTUM, ENGINE_SIDEWAYS} and momentum_stop_triggered(position, self.config.momentum):
scheduled.append((position.engine, position.symbol, "risk_stop"))
elif position.engine in {ENGINE_MOMENTUM, ENGINE_SIDEWAYS} and self._holding_exit_triggered(position, signal_timestamp):
scheduled.append((position.engine, position.symbol, "time_exit"))
elif position.engine == ENGINE_CARRY and carry_stop_triggered(position, self.config.carry):
scheduled.append((ENGINE_CARRY, position.symbol, "risk_stop"))
return scheduled
def _exit_cost(self, engine: str) -> float:
if engine in {ENGINE_MOMENTUM, ENGINE_SIDEWAYS}:
return self.config.momentum.exit_cost_pct
return super()._exit_cost(engine)
def _hard_filter_cache_key(self, timestamp: pd.Timestamp) -> pd.Timestamp:
cadence = str(self.strategy_config.hard_filter_refresh_cadence or "4h").strip().lower()
ts = pd.Timestamp(timestamp)
if ts.tzinfo is None:
ts = ts.tz_localize("UTC")
else:
ts = ts.tz_convert("UTC")
if cadence == "1d":
return ts.floor("1D")
if cadence == "1w":
return ts.floor("7D")
return ts.floor(cadence)
def _hard_filter_symbols(self, timestamp: pd.Timestamp, *, min_history_bars: int) -> set[str]:
key = self._hard_filter_cache_key(timestamp)
cached = self._hard_filter_cache.get(key)
if cached is not None:
return set(cached)
required_bars = max(min_history_bars, self.strategy_config.hard_filter_min_history_bars)
selected = select_dynamic_universe(
self.data.prices,
timestamp=timestamp,
min_history_bars=required_bars,
lookback_bars=self.strategy_config.hard_filter_lookback_bars,
max_symbols=0,
min_avg_dollar_volume=self.strategy_config.hard_filter_min_avg_dollar_volume,
base_symbol=BTC_SYMBOL,
)
symbols = set(selected)
if BTC_SYMBOL in self.data.prices:
symbols.add(BTC_SYMBOL)
self._hard_filter_cache[key] = set(symbols)
return symbols
def _rebalance(
self,
portfolio: PortfolioManager,
signal_timestamp: pd.Timestamp,
execution_timestamp: pd.Timestamp,
decision: AllocationDecision,
rebalance_momentum: bool,
rebalance_carry: bool,
rebalance_spread: bool,
) -> list:
del rebalance_spread
trades = []
event_timestamp = execution_timestamp
if decision.momentum_budget_pct <= 0:
trades.extend(self._close_engine(portfolio, ENGINE_MOMENTUM, execution_timestamp, "regime_off"))
if decision.spread_budget_pct <= 0:
trades.extend(self._close_engine(portfolio, ENGINE_SIDEWAYS, execution_timestamp, "regime_off"))
if decision.carry_budget_pct <= 0:
trades.extend(self._close_engine(portfolio, ENGINE_CARRY, execution_timestamp, "regime_off"))
min_history_bars = max(
self.config.momentum.min_history_bars,
self.config.spread.min_history_bars,
self.strategy_config.momentum_min_history_bars,
)
hard_filter_symbols = self._hard_filter_symbols(signal_timestamp, min_history_bars=min_history_bars)
tradeable = set(
select_tradeable_universe(
self.data.prices,
signal_timestamp,
min_history_bars=min_history_bars,
min_avg_dollar_volume=self.strategy_config.universe_min_avg_dollar_volume,
)
)
if hard_filter_symbols:
tradeable &= hard_filter_symbols
liquid_universe = select_dynamic_universe(
self.data.prices,
timestamp=signal_timestamp,
min_history_bars=min_history_bars,
lookback_bars=self.strategy_config.universe_lookback_bars,
max_symbols=0,
min_avg_dollar_volume=self.strategy_config.universe_fallback_min_avg_dollar_volume,
base_symbol=BTC_SYMBOL,
)
liquid_universe = [symbol for symbol in liquid_universe if symbol in hard_filter_symbols]
if decision.momentum_budget_pct > 0 and not tradeable:
self._record_rejection(event_timestamp, ENGINE_MOMENTUM, "tradeable_universe_empty", regime=decision.regime.value)
if decision.spread_budget_pct > 0 and not tradeable:
self._record_rejection(event_timestamp, ENGINE_SIDEWAYS, "tradeable_universe_empty", regime=decision.regime.value)
if decision.carry_budget_pct > 0 and not tradeable:
self._record_rejection(event_timestamp, ENGINE_CARRY, "tradeable_universe_empty", regime=decision.regime.value)
dynamic_universe = set(
select_strategic_universe(
self.data.prices,
self.data.funding,
btc_symbol=BTC_SYMBOL,
timestamp=signal_timestamp,
min_history_bars=min_history_bars,
lookback_bars=self.strategy_config.universe_lookback_bars,
min_avg_dollar_volume=self.strategy_config.universe_min_avg_dollar_volume,
short_lookback_bars=max(12, self.config.momentum.momentum_lookback_bars // 2),
long_lookback_bars=self.config.momentum.relative_strength_lookback_bars,
overheat_funding_rate=self.config.momentum.overheat_funding_rate,
carry_lookback_bars=self.config.carry.lookback_bars,
carry_expected_horizon_bars=self.config.carry.expected_horizon_bars,
carry_roundtrip_cost_pct=self.config.carry.roundtrip_cost_pct,
carry_basis_risk_multiplier=self.config.carry.basis_risk_multiplier,
momentum_min_score=self.strategy_config.momentum_min_score,
momentum_min_relative_strength=self.strategy_config.momentum_min_relative_strength,
momentum_min_7d_return=self.strategy_config.momentum_min_7d_return,
momentum_max_7d_return=self.strategy_config.momentum_max_7d_return,
momentum_min_positive_bar_ratio=self.strategy_config.momentum_min_positive_bar_ratio,
momentum_max_short_volatility=self.strategy_config.momentum_max_short_volatility,
momentum_max_latest_funding_rate=self.strategy_config.momentum_max_latest_funding_rate,
momentum_max_beta=self.strategy_config.momentum_max_beta,
carry_min_expected_edge=self.strategy_config.carry_min_expected_edge,
max_symbols=self.strategy_config.universe_size,
)
)
dynamic_universe &= tradeable
dynamic_universe &= hard_filter_symbols
if (
not dynamic_universe
and liquid_universe
and self.strategy_config.enable_liquidity_universe_fallback
):
fallback = list(liquid_universe)
if self.strategy_config.universe_fallback_top_n > 0:
fallback = fallback[: self.strategy_config.universe_fallback_top_n]
dynamic_universe = set(fallback)
if dynamic_universe:
self._record_rejection(
event_timestamp,
"universe",
"dynamic_universe_fallback_used",
regime=decision.regime.value,
dynamic_universe_size=len(dynamic_universe),
)
if decision.momentum_budget_pct > 0 and not dynamic_universe:
self._record_rejection(event_timestamp, ENGINE_MOMENTUM, "dynamic_universe_empty", regime=decision.regime.value)
if decision.spread_budget_pct > 0 and not dynamic_universe:
self._record_rejection(event_timestamp, ENGINE_SIDEWAYS, "dynamic_universe_empty", regime=decision.regime.value)
if decision.carry_budget_pct > 0 and not dynamic_universe:
self._record_rejection(event_timestamp, ENGINE_CARRY, "dynamic_universe_empty", regime=decision.regime.value)
momentum_frame = score_momentum_universe(
self.data.prices,
self.data.funding,
btc_symbol=BTC_SYMBOL,
timestamp=signal_timestamp,
candidate_symbols=sorted(dynamic_universe),
min_history_bars=min_history_bars,
liquidity_lookback_bars=self.strategy_config.universe_lookback_bars,
short_lookback_bars=max(12, self.config.momentum.momentum_lookback_bars // 2),
long_lookback_bars=self.config.momentum.relative_strength_lookback_bars,
overheat_funding_rate=self.config.momentum.overheat_funding_rate,
)
if decision.momentum_budget_pct > 0 and momentum_frame.empty:
self._record_rejection(
event_timestamp,
ENGINE_MOMENTUM,
"momentum_scored_empty",
regime=decision.regime.value,
dynamic_universe_size=len(dynamic_universe),
)
momentum_frame = filter_momentum_frame(
momentum_frame,
min_score=self.strategy_config.momentum_min_score,
min_relative_strength=self.strategy_config.momentum_min_relative_strength,
min_7d_return=self.strategy_config.momentum_min_7d_return,
max_7d_return=self.strategy_config.momentum_max_7d_return,
min_positive_bar_ratio=self.strategy_config.momentum_min_positive_bar_ratio,
max_short_volatility=self.strategy_config.momentum_max_short_volatility,
max_latest_funding_rate=self.strategy_config.momentum_max_latest_funding_rate,
max_beta=self.strategy_config.momentum_max_beta,
)
if (
decision.momentum_budget_pct > 0
and momentum_frame.empty
and self.strategy_config.enable_momentum_filter_fallback
):
fallback_frame = score_momentum_universe(
self.data.prices,
self.data.funding,
btc_symbol=BTC_SYMBOL,
timestamp=signal_timestamp,
candidate_symbols=sorted(dynamic_universe),
min_history_bars=min_history_bars,
liquidity_lookback_bars=self.strategy_config.universe_lookback_bars,
short_lookback_bars=max(12, self.config.momentum.momentum_lookback_bars // 2),
long_lookback_bars=self.config.momentum.relative_strength_lookback_bars,
overheat_funding_rate=self.config.momentum.overheat_funding_rate,
)
fallback_frame = filter_momentum_frame(
fallback_frame,
min_score=self.strategy_config.momentum_fallback_min_score,
min_relative_strength=self.strategy_config.momentum_fallback_min_relative_strength,
min_7d_return=self.strategy_config.momentum_fallback_min_7d_return,
max_7d_return=self.strategy_config.momentum_max_7d_return,
min_positive_bar_ratio=self.strategy_config.momentum_min_positive_bar_ratio,
max_short_volatility=self.strategy_config.momentum_max_short_volatility,
max_latest_funding_rate=self.strategy_config.momentum_max_latest_funding_rate,
max_beta=self.strategy_config.momentum_max_beta,
)
if self.strategy_config.momentum_fallback_top_n > 0:
fallback_frame = fallback_frame.head(self.strategy_config.momentum_fallback_top_n).reset_index(drop=True)
if not fallback_frame.empty:
momentum_frame = fallback_frame
self._record_rejection(
event_timestamp,
ENGINE_MOMENTUM,
"momentum_filter_fallback_used",
regime=decision.regime.value,
candidates=int(len(momentum_frame)),
)
if decision.momentum_budget_pct > 0 and momentum_frame.empty:
self._record_rejection(
event_timestamp,
ENGINE_MOMENTUM,
"momentum_filtered_empty",
regime=decision.regime.value,
dynamic_universe_size=len(dynamic_universe),
)
selected_momentum = limit_correlated_symbols(
self.data.prices,
timestamp=signal_timestamp,
candidate_symbols=[str(symbol) for symbol in momentum_frame["symbol"].tolist()],
lookback_bars=self.strategy_config.correlation_lookback_bars,
max_pairwise_correlation=self.strategy_config.max_pairwise_correlation,
max_symbols=len(momentum_frame),
)
if decision.momentum_budget_pct > 0 and momentum_frame.shape[0] > 0 and not selected_momentum:
self._record_rejection(
event_timestamp,
ENGINE_MOMENTUM,
"momentum_correlation_filtered_empty",
regime=decision.regime.value,
)
selected_set = set(selected_momentum)
momentum_candidates = [
MomentumCandidate(
symbol=str(row.symbol),
score=float(row.score),
beta=float(row.beta),
momentum_7d=float(row.short_return),
relative_strength=float(row.relative_strength_long),
avg_dollar_volume=float(row.avg_dollar_volume),
stability=float(row.volume_stability),
latest_funding_rate=float(row.latest_funding_rate),
)
for row in momentum_frame.itertuples(index=False)
if str(row.symbol) in selected_set
]
refinement_states = self._execution_refinement_states(
execution_timestamp,
[candidate.symbol for candidate in momentum_candidates],
)
momentum_candidates, momentum_refinement = self._apply_execution_refinement(
event_timestamp,
ENGINE_MOMENTUM,
momentum_candidates,
refinement_states,
regime=decision.regime.value,
)
carry_candidates = [
candidate
for candidate in self.carry_scanner.scan(self.data.funding, signal_timestamp)
if candidate.symbol in dynamic_universe
]
carry_fallback_reason: str | None = None
if not carry_candidates and decision.carry_budget_pct > 0:
carry_candidates = self._scan_relaxed_carry(signal_timestamp, dynamic_universe)
if carry_candidates:
carry_fallback_reason = "carry_relaxed_fallback_used"
if not carry_candidates and decision.carry_budget_pct > 0:
carry_candidates = self._scan_deep_relaxed_carry(signal_timestamp, dynamic_universe)
if carry_candidates:
carry_fallback_reason = "carry_deep_relaxed_fallback_used"
if (
not carry_candidates
and decision.carry_budget_pct > 0
and self.strategy_config.enable_carry_score_fallback
):
carry_candidates = self._score_fallback_carry(signal_timestamp, dynamic_universe)
if carry_candidates:
carry_fallback_reason = "carry_score_fallback_used"
pre_edge_count = len(carry_candidates)
carry_candidates = [candidate for candidate in carry_candidates if candidate.expected_net_edge > self.strategy_config.carry_min_expected_edge]
if carry_fallback_reason and carry_candidates:
self._record_rejection(
event_timestamp,
ENGINE_CARRY,
carry_fallback_reason,
regime=decision.regime.value,
candidates=len(carry_candidates),
)
elif carry_fallback_reason and pre_edge_count > 0 and not carry_candidates:
self._record_rejection(
event_timestamp,
ENGINE_CARRY,
"carry_fallback_rejected_by_edge",
regime=decision.regime.value,
rejected_candidates=pre_edge_count,
)
if decision.carry_budget_pct > 0 and not carry_candidates:
self._record_rejection(
event_timestamp,
ENGINE_CARRY,
"carry_candidates_empty",
regime=decision.regime.value,
dynamic_universe_size=len(dynamic_universe),
)
if rebalance_momentum:
trades.extend(self._close_engine(portfolio, ENGINE_MOMENTUM, execution_timestamp, "rebalance"))
trades.extend(self._close_engine(portfolio, ENGINE_SIDEWAYS, execution_timestamp, "rebalance"))
if rebalance_carry:
trades.extend(self._close_engine(portfolio, ENGINE_CARRY, execution_timestamp, "rebalance"))
total_equity = portfolio.total_equity()
opened_momentum = 0
if rebalance_momentum and decision.momentum_budget_pct > 0 and momentum_candidates:
budget = self._budget_notional(total_equity, decision.momentum_budget_pct)
weights = self._inverse_vol_weights([candidate.symbol for candidate in momentum_candidates], signal_timestamp)
hedge_ratio = self._momentum_hedge_ratio(decision.regime)
for candidate in momentum_candidates:
scale = momentum_refinement.get(candidate.symbol, {}).get("scale", 1.0)
capital = budget * weights.get(candidate.symbol, 0.0) * float(scale)
if capital <= 0:
continue
portfolio.open_position(
ENGINE_MOMENTUM,
candidate.symbol,
execution_timestamp,
self._close_price(candidate.symbol, execution_timestamp),
capital,
self.config.momentum.entry_cost_pct,
meta={"score": candidate.score, "beta": candidate.beta, "hedge_ratio": hedge_ratio},
)
opened_momentum += 1
elif rebalance_momentum and decision.momentum_budget_pct > 0:
self._record_rejection(event_timestamp, ENGINE_MOMENTUM, "momentum_candidates_empty", regime=decision.regime.value)
if rebalance_momentum and decision.momentum_budget_pct > 0 and opened_momentum == 0:
self._record_rejection(event_timestamp, ENGINE_MOMENTUM, "momentum_no_open_positions", regime=decision.regime.value)
opened_sideways = 0
if rebalance_momentum and decision.spread_budget_pct > 0:
sideways_candidates = self._sideways_candidates(momentum_candidates, signal_timestamp)
if not sideways_candidates:
self._record_rejection(event_timestamp, ENGINE_SIDEWAYS, "sideways_candidates_empty", regime=decision.regime.value)
if sideways_candidates:
budget = self._budget_notional(total_equity, decision.spread_budget_pct)
weights = self._inverse_vol_weights([candidate.symbol for candidate in sideways_candidates], signal_timestamp)
for candidate in sideways_candidates:
scale = momentum_refinement.get(candidate.symbol, {}).get("scale", 1.0)
capital = budget * weights.get(candidate.symbol, 0.0) * float(scale)
if capital <= 0:
continue
portfolio.open_position(
ENGINE_SIDEWAYS,
candidate.symbol,
execution_timestamp,
self._close_price(candidate.symbol, execution_timestamp),
capital,
self.config.momentum.entry_cost_pct,
meta={"score": candidate.score, "beta": candidate.beta, "hedge_ratio": self._sideways_hedge_ratio()},
)
opened_sideways += 1
if rebalance_momentum and decision.spread_budget_pct > 0 and opened_sideways == 0:
self._record_rejection(event_timestamp, ENGINE_SIDEWAYS, "sideways_no_open_positions", regime=decision.regime.value)
opened_carry = 0
if rebalance_carry and decision.carry_budget_pct > 0 and carry_candidates:
budget = self._budget_notional(total_equity, decision.carry_budget_pct)
weights = self._inverse_basis_vol_weights(carry_candidates)
for candidate in carry_candidates:
capital = budget * weights.get(candidate.symbol, 0.0)
if capital <= 0:
continue
portfolio.open_position(
ENGINE_CARRY,
candidate.symbol,
execution_timestamp,
self._basis_at(candidate.symbol, execution_timestamp),
capital,
self.config.carry.entry_cost_pct,
meta={"score": candidate.score, "expected_edge": candidate.expected_net_edge},
)
opened_carry += 1
if rebalance_carry and decision.carry_budget_pct > 0 and opened_carry == 0:
self._record_rejection(event_timestamp, ENGINE_CARRY, "carry_no_open_positions", regime=decision.regime.value)
return trades
def _budget_notional(self, total_equity: float, budget_pct: float) -> float:
if total_equity <= 0 or budget_pct <= 0:
return 0.0
deployable = total_equity * max(0.0, 1.0 - self.config.risk.min_cash_floor_pct)
return min(total_equity * budget_pct, deployable)
def _inverse_vol_weights(self, symbols: list[str], timestamp: pd.Timestamp) -> dict[str, float]:
raw: dict[str, float] = {}
for symbol in symbols:
hist = self.data.prices[symbol].loc[self.data.prices[symbol]["timestamp"] <= timestamp].tail(
self.strategy_config.position_vol_lookback_bars + 1
)
returns = hist["close"].pct_change().dropna()
vol = float(returns.std(ddof=0)) if len(returns) >= 10 else 0.0
raw[symbol] = 1.0 / max(vol, 0.005)
total = sum(raw.values())
if total <= 0:
return {symbol: 1.0 / len(symbols) for symbol in symbols} if symbols else {}
return {symbol: value / total for symbol, value in raw.items()}
@staticmethod
def _inverse_basis_vol_weights(carry_candidates: list[CarryCandidate]) -> dict[str, float]:
raw = {candidate.symbol: 1.0 / max(float(candidate.basis_volatility), 0.001) for candidate in carry_candidates}
total = sum(raw.values())
if total <= 0:
return {candidate.symbol: 1.0 / len(carry_candidates) for candidate in carry_candidates} if carry_candidates else {}
return {symbol: value / total for symbol, value in raw.items()}
def _execution_refinement_states(
self,
timestamp: pd.Timestamp,
symbols: list[str],
) -> dict[str, dict[str, float | str]]:
if not self.strategy_config.enable_execution_refinement:
return {symbol: {"action": "allow", "scale": 1.0, "reason": "disabled"} for symbol in symbols}
min_bars = max(
self.strategy_config.execution_refinement_lookback_bars,
self.strategy_config.execution_refinement_slow_ema + 5,
8,
)
states: dict[str, dict[str, float | str]] = {}
for symbol in symbols:
df = self.execution_prices.get(symbol)
if df is None or df.empty:
states[symbol] = {"action": "allow", "scale": 1.0, "reason": "missing_execution_prices"}
continue
hist = df.loc[df["timestamp"] <= timestamp].tail(min_bars)
if len(hist) < min_bars:
states[symbol] = {"action": "allow", "scale": 1.0, "reason": "insufficient_history"}
continue
closes = hist["close"].astype(float)
close = float(closes.iloc[-1])
ema_fast = float(closes.ewm(span=self.strategy_config.execution_refinement_fast_ema, adjust=False).mean().iloc[-1])
ema_slow = float(closes.ewm(span=self.strategy_config.execution_refinement_slow_ema, adjust=False).mean().iloc[-1])
recent_return = float(close / float(closes.iloc[-4]) - 1.0) if len(closes) >= 4 else 0.0
chase_gap = float(close / max(ema_fast, 1e-9) - 1.0)
action = "allow"
scale = 1.0
reason = "trend_confirmed"
if close <= ema_slow or ema_fast <= ema_slow:
action = "block"
scale = 0.0
reason = "below_1h_trend"
elif (
chase_gap >= self.strategy_config.execution_refinement_max_chase_gap
or recent_return >= self.strategy_config.execution_refinement_max_recent_return
):
action = "block"
scale = 0.0
reason = "too_extended"
elif chase_gap >= self.strategy_config.execution_refinement_scale_down_gap:
action = "scale_down"
scale = self.strategy_config.execution_refinement_scale_down_factor
reason = "slightly_extended"
states[symbol] = {
"action": action,
"scale": float(scale),
"reason": reason,
"close": close,
"ema_fast": ema_fast,
"ema_slow": ema_slow,
"chase_gap": chase_gap,
"recent_return": recent_return,
}
return states
def _apply_execution_refinement(
self,
timestamp: pd.Timestamp,
engine: str,
candidates: list[MomentumCandidate],
refinement_states: dict[str, dict[str, float | str]],
*,
regime: str,
) -> tuple[list[MomentumCandidate], dict[str, dict[str, float | str]]]:
if not candidates:
return [], {}
kept: list[MomentumCandidate] = []
kept_states: dict[str, dict[str, float | str]] = {}
for candidate in candidates:
state = refinement_states.get(candidate.symbol, {"action": "allow", "scale": 1.0, "reason": "missing"})
action = str(state.get("action", "allow"))
if action == "block":
self._record_rejection(
timestamp,
engine,
"execution_refinement_blocked",
regime=regime,
symbol=candidate.symbol,
refinement_reason=str(state.get("reason", "unknown")),
)
continue
if action == "scale_down":
self._record_rejection(
timestamp,
engine,
"execution_refinement_scaled",
regime=regime,
symbol=candidate.symbol,
refinement_reason=str(state.get("reason", "unknown")),
scale=float(state.get("scale", 1.0) or 1.0),
)
kept.append(candidate)
kept_states[candidate.symbol] = state
return kept, kept_states
def _record_rejection(self, timestamp: pd.Timestamp, engine: str, reason: str, **details: object) -> None:
self.rejection_summary[reason] += 1
self.rejection_log.append(
{
"timestamp": str(timestamp),
"engine": engine,
"reason": reason,
**details,
}
)
def _momentum_hedge_ratio(self, regime: Regime) -> float:
if self.strategy_config.enable_expanded_hedge:
if regime == Regime.STRONG_UP:
return self.strategy_config.expanded_strong_up_btc_hedge_ratio
if regime == Regime.UP:
return self.strategy_config.expanded_up_btc_hedge_ratio
return 0.0
if regime == Regime.UP:
return self.strategy_config.up_btc_hedge_ratio
return 0.0
def _sideways_hedge_ratio(self) -> float:
if self.strategy_config.enable_expanded_hedge:
return self.strategy_config.expanded_sideways_btc_hedge_ratio
return self.strategy_config.sideways_btc_hedge_ratio
def _sideways_candidates(self, momentum_candidates: list, timestamp: pd.Timestamp) -> list:
if not momentum_candidates:
return []
selected = []
for candidate in momentum_candidates:
if float(candidate.latest_funding_rate) > self.config.momentum.overheat_funding_rate:
continue
hist = self.data.prices[candidate.symbol].loc[self.data.prices[candidate.symbol]["timestamp"] <= timestamp].tail(
self.strategy_config.position_vol_lookback_bars + 1
)
returns = hist["close"].pct_change().dropna()
if len(returns) < 10 or returns.std(ddof=0) > 0.08:
continue
selected.append(candidate)
if len(selected) >= self.strategy_config.sideways_top_n:
break
return selected
def _scan_relaxed_carry(self, timestamp: pd.Timestamp, dynamic_universe: set[str]) -> list[CarryCandidate]:
return self._scan_carry_with_relaxation(
timestamp,
dynamic_universe,
min_positive_ratio=self.strategy_config.carry_relaxed_min_positive_ratio,
min_mean_funding_rate=self.strategy_config.carry_relaxed_min_mean_funding_rate,
max_basis_volatility=self.strategy_config.carry_relaxed_max_basis_volatility,
roundtrip_cost_pct=self.strategy_config.carry_relaxed_roundtrip_cost_pct,
basis_risk_multiplier=self.strategy_config.carry_relaxed_basis_risk_multiplier,
)
def _scan_deep_relaxed_carry(self, timestamp: pd.Timestamp, dynamic_universe: set[str]) -> list[CarryCandidate]:
return self._scan_carry_with_relaxation(
timestamp,
dynamic_universe,
min_positive_ratio=self.strategy_config.carry_deep_relaxed_min_positive_ratio,
min_mean_funding_rate=self.strategy_config.carry_deep_relaxed_min_mean_funding_rate,
max_basis_volatility=self.strategy_config.carry_deep_relaxed_max_basis_volatility,
roundtrip_cost_pct=self.strategy_config.carry_deep_relaxed_roundtrip_cost_pct,
basis_risk_multiplier=self.strategy_config.carry_deep_relaxed_basis_risk_multiplier,
)
def _scan_carry_with_relaxation(
self,
timestamp: pd.Timestamp,
dynamic_universe: set[str],
*,
min_positive_ratio: float,
min_mean_funding_rate: float,
max_basis_volatility: float,
roundtrip_cost_pct: float,
basis_risk_multiplier: float,
) -> list[CarryCandidate]:
candidates: list[CarryCandidate] = []
for symbol, df in self.data.funding.items():
if symbol not in dynamic_universe:
continue
hist = df.loc[df["timestamp"] <= timestamp].tail(self.config.carry.lookback_bars)
if len(hist) < self.config.carry.lookback_bars:
continue
positive_ratio = float((hist["funding_rate"] > 0).mean())
mean_funding = float(hist["funding_rate"].mean())
basis_vol = float(hist["basis"].std(ddof=0))
latest_basis = float(hist["basis"].iloc[-1])
expected_net_edge = (
mean_funding * self.config.carry.expected_horizon_bars
+ max(latest_basis, 0.0) * 0.45
- roundtrip_cost_pct
- basis_vol * basis_risk_multiplier
)
if positive_ratio < min_positive_ratio:
continue
if mean_funding < min_mean_funding_rate:
continue
if basis_vol > max_basis_volatility:
continue
if expected_net_edge <= 0:
continue
score = expected_net_edge + positive_ratio * 0.05
candidates.append(
CarryCandidate(
symbol=symbol,
score=score,
expected_net_edge=expected_net_edge,
positive_ratio=positive_ratio,
mean_funding_rate=mean_funding,
basis_volatility=basis_vol,
latest_basis=latest_basis,
)
)
candidates.sort(key=lambda candidate: candidate.score, reverse=True)
return candidates[: self.strategy_config.carry_relaxed_top_n]
def _score_fallback_carry(self, timestamp: pd.Timestamp, dynamic_universe: set[str]) -> list[CarryCandidate]:
if not dynamic_universe:
return []
frame = score_carry_universe(
self.data.prices,
self.data.funding,
timestamp=timestamp,
candidate_symbols=sorted(dynamic_universe),
lookback_bars=self.config.carry.lookback_bars,
expected_horizon_bars=self.config.carry.expected_horizon_bars,
roundtrip_cost_pct=self.strategy_config.carry_deep_relaxed_roundtrip_cost_pct,
basis_risk_multiplier=self.strategy_config.carry_deep_relaxed_basis_risk_multiplier,
)
if frame.empty:
return []
frame = frame.loc[
(frame["expected_edge"] >= self.strategy_config.carry_score_fallback_min_expected_edge)
& (frame["positive_ratio"] >= self.strategy_config.carry_score_fallback_min_positive_ratio)
].sort_values("score", ascending=False)
if frame.empty:
return []
if self.strategy_config.carry_score_fallback_top_n > 0:
frame = frame.head(self.strategy_config.carry_score_fallback_top_n)
candidates: list[CarryCandidate] = []
for row in frame.itertuples(index=False):
symbol = str(row.symbol)
hist = self.data.funding.get(symbol)
basis_volatility = 0.0
latest_basis = 0.0
if hist is not None and not hist.empty:
recent = hist.loc[hist["timestamp"] <= timestamp].tail(self.config.carry.lookback_bars)
if not recent.empty:
basis_volatility = float(recent["basis"].std(ddof=0))
latest_basis = float(recent["basis"].iloc[-1])
candidates.append(
CarryCandidate(
symbol=symbol,
score=float(row.score),
expected_net_edge=float(row.expected_edge),
positive_ratio=float(row.positive_ratio),
mean_funding_rate=float(row.mean_funding),
basis_volatility=basis_volatility,
latest_basis=latest_basis,
)
)
return candidates
def _holding_exit_triggered(self, position, timestamp: pd.Timestamp) -> bool:
if not self.strategy_config.enable_max_holding_exit:
return False
bars_held = int((timestamp - position.entry_time).total_seconds() // (4 * 60 * 60))
if bars_held >= self.strategy_config.max_holding_bars:
return True
if bars_held < self.strategy_config.min_hold_bars_for_trend_fail:
return False
hist = self.data.prices[position.symbol].loc[self.data.prices[position.symbol]["timestamp"] <= timestamp].tail(
self.strategy_config.trend_fail_ema_span + 5
)
if hist.empty:
return False
ema = hist["close"].ewm(span=self.strategy_config.trend_fail_ema_span, adjust=False).mean().iloc[-1]
close = float(hist["close"].iloc[-1])
return close < float(ema)
def _daily_trend_ok(self, timestamp: pd.Timestamp) -> bool:
hist = self.data.prices[BTC_SYMBOL].loc[self.data.prices[BTC_SYMBOL]["timestamp"] <= timestamp][["timestamp", "close"]].copy()
if hist.empty:
return True
daily = hist.set_index("timestamp")["close"].resample("1D").last().dropna()
if len(daily) < self.strategy_config.long_trend_slow_ema_days:
return True
fast = daily.ewm(span=self.strategy_config.long_trend_fast_ema_days, adjust=False).mean().iloc[-1]
slow = daily.ewm(span=self.strategy_config.long_trend_slow_ema_days, adjust=False).mean().iloc[-1]
close = float(daily.iloc[-1])
return close > slow and fast > slow
class Strategy32Backtester:
def __init__(
self,
config: Strategy32Config,
data: MarketDataBundle,
*,
trade_start: pd.Timestamp | None = None,
execution_prices: dict[str, pd.DataFrame] | None = None,
):
self.config = config
self.data = data
self.trade_start = trade_start
self.execution_prices = execution_prices or data.prices
self.engine_config = build_engine_config()
def run(self, *, close_final_positions: bool = True) -> BacktestResult:
backtester = Strategy32MomentumCarryBacktester(
self.config,
self.data,
trade_start=self.trade_start,
execution_prices=self.execution_prices,
)
backtester.config.initial_capital = self.engine_config.initial_capital
return backtester.run(close_final_positions=close_final_positions)