1047 lines
50 KiB
Python
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.routing.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)
|