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)