From c165a9add739f4041b924108d08360ff4b2152fb Mon Sep 17 00:00:00 2001 From: tara Date: Mon, 16 Mar 2026 20:18:41 -0700 Subject: [PATCH] Initial strategy32 research and live runtime --- .env.example | 48 + .gitignore | 6 + Dockerfile | 20 + __init__.py | 5 + backtest/__init__.py | 1 + backtest/simulator.py | 1046 ++++++++++++++++ config.py | 185 +++ data.py | 185 +++ docker-compose.yml | 52 + live/__init__.py | 2 + live/binance_account.py | 111 ++ live/env.py | 27 + live/executor.py | 275 +++++ live/notifier.py | 129 ++ live/runtime.py | 1071 +++++++++++++++++ research/adverse_regime.py | 693 +++++++++++ research/hybrid_regime.py | 326 +++++ research/soft_router.py | 848 +++++++++++++ scripts/__init__.py | 1 + scripts/run_ablation_analysis.py | 104 ++ scripts/run_adverse_regime_engine_search.py | 39 + scripts/run_backtest.py | 139 +++ scripts/run_cash_overlay_search.py | 436 +++++++ scripts/run_current_relaxed_hybrid_exact.py | 169 +++ .../run_current_relaxed_hybrid_experiment.py | 376 ++++++ scripts/run_exhaustive_combo_analysis.py | 164 +++ scripts/run_filter_search.py | 283 +++++ scripts/run_filter_search_extended.py | 447 +++++++ scripts/run_hybrid_regime_backtest.py | 30 + scripts/run_hybrid_strategy_search.py | 458 +++++++ scripts/run_hybrid_strategy_search_fast.py | 281 +++++ scripts/run_live_combo_backtest.py | 54 + scripts/run_live_monitor.py | 62 + scripts/run_recent_core_filter_comparison.py | 166 +++ scripts/run_regime_filter_analysis.py | 498 ++++++++ scripts/run_relaxed_macro_scaling_search.py | 296 +++++ scripts/run_soft_router_search.py | 319 +++++ scripts/run_v7_branch_validation.py | 144 +++ signal/__init__.py | 1 + signal/router.py | 22 + tests/test_strategy32.py | 794 ++++++++++++ universe.py | 437 +++++++ 42 files changed, 10750 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 __init__.py create mode 100644 backtest/__init__.py create mode 100644 backtest/simulator.py create mode 100644 config.py create mode 100644 data.py create mode 100644 docker-compose.yml create mode 100644 live/__init__.py create mode 100644 live/binance_account.py create mode 100644 live/env.py create mode 100644 live/executor.py create mode 100644 live/notifier.py create mode 100644 live/runtime.py create mode 100644 research/adverse_regime.py create mode 100644 research/hybrid_regime.py create mode 100644 research/soft_router.py create mode 100644 scripts/__init__.py create mode 100644 scripts/run_ablation_analysis.py create mode 100644 scripts/run_adverse_regime_engine_search.py create mode 100644 scripts/run_backtest.py create mode 100644 scripts/run_cash_overlay_search.py create mode 100644 scripts/run_current_relaxed_hybrid_exact.py create mode 100644 scripts/run_current_relaxed_hybrid_experiment.py create mode 100644 scripts/run_exhaustive_combo_analysis.py create mode 100644 scripts/run_filter_search.py create mode 100644 scripts/run_filter_search_extended.py create mode 100644 scripts/run_hybrid_regime_backtest.py create mode 100644 scripts/run_hybrid_strategy_search.py create mode 100644 scripts/run_hybrid_strategy_search_fast.py create mode 100644 scripts/run_live_combo_backtest.py create mode 100644 scripts/run_live_monitor.py create mode 100644 scripts/run_recent_core_filter_comparison.py create mode 100644 scripts/run_regime_filter_analysis.py create mode 100644 scripts/run_relaxed_macro_scaling_search.py create mode 100644 scripts/run_soft_router_search.py create mode 100644 scripts/run_v7_branch_validation.py create mode 100644 signal/__init__.py create mode 100644 signal/router.py create mode 100644 tests/test_strategy32.py create mode 100644 universe.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d82c7f0 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# Telegram +GAMMA_TELEGRAM_TOKEN= +GAMMA_TELEGRAM_CHAT_ID= +GAMMA_TELEGRAM_MIN_LEVEL=INFO +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +TELEGRAM_MIN_LEVEL=INFO + +# Binance USD-M +GAMMA_BOT_API_KEY= +GAMMA_BOT_API_SECRET= +BN_API_KEY= +BN_API_SECRET= +STRATEGY32_BINANCE_TESTNET=true +STRATEGY32_INCLUDE_ACCOUNT_SNAPSHOT=true +STRATEGY32_ENABLE_LIVE_ORDERS=true +STRATEGY32_EXECUTION_LEVERAGE=5 +STRATEGY32_MIN_TARGET_NOTIONAL_USD=25 +STRATEGY32_MIN_REBALANCE_NOTIONAL_USD=10 +STRATEGY32_CLOSE_ORPHAN_POSITIONS=true + +# Monitor runtime +STRATEGY32_LOG_LEVEL=INFO +STRATEGY32_TIMEFRAME=4h +STRATEGY32_MACRO_FILTER_TIMEFRAME=1w +STRATEGY32_MACRO_FILTER_FAST_WEEKS=10 +STRATEGY32_MACRO_FILTER_SLOW_WEEKS=30 +STRATEGY32_HARD_FILTER_TIMEFRAME=1d +STRATEGY32_EXECUTION_REFINEMENT_TIMEFRAME=1h +STRATEGY32_LOOKBACK_DAYS=365 +STRATEGY32_WARMUP_DAYS=90 +STRATEGY32_POLL_SECONDS=60 +STRATEGY32_LIVE_MIN_QUOTE_VOLUME_24H=100000000 +STRATEGY32_HARD_FILTER_MIN_HISTORY_BARS=120 +STRATEGY32_HARD_FILTER_LOOKBACK_BARS=30 +STRATEGY32_HARD_FILTER_MIN_AVG_DOLLAR_VOLUME=50000000 +STRATEGY32_EXECUTION_REFINEMENT_LOOKBACK_BARS=48 +STRATEGY32_EXECUTION_REFINEMENT_FAST_EMA=8 +STRATEGY32_EXECUTION_REFINEMENT_SLOW_EMA=21 +STRATEGY32_EXECUTION_REFINEMENT_SCALE_DOWN_GAP=0.008 +STRATEGY32_EXECUTION_REFINEMENT_MAX_CHASE_GAP=0.018 +STRATEGY32_EXECUTION_REFINEMENT_MAX_RECENT_RETURN=0.03 +STRATEGY32_EXECUTION_REFINEMENT_SCALE_DOWN_FACTOR=0.5 +STRATEGY32_ENTRY_ONLY_REFINEMENT=true +STRATEGY32_MAX_SPECS=0 +STRATEGY32_PAPER_CAPITAL_USD=1000 +STRATEGY32_MAX_STALENESS_DAYS=3 +STRATEGY32_RUNTIME_DIR=runtime diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e470c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.DS_Store +.env +runtime/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4bcdd55 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONPATH=/app + +WORKDIR /app + +RUN pip install --no-cache-dir pandas + +COPY strategy29/ strategy29/ +COPY strategy32/ strategy32/ + +RUN mkdir -p /app/runtime + +ENTRYPOINT ["python", "-m", "strategy32.scripts.run_live_monitor"] diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..3dd0b09 --- /dev/null +++ b/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/backtest/__init__.py b/backtest/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backtest/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backtest/simulator.py b/backtest/simulator.py new file mode 100644 index 0000000..64438a9 --- /dev/null +++ b/backtest/simulator.py @@ -0,0 +1,1046 @@ +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) diff --git a/config.py b/config.py new file mode 100644 index 0000000..c10f645 --- /dev/null +++ b/config.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from strategy29.common.models import Regime +from strategy29.config import Strategy29Config + + +DEFAULT_QUOTE_ASSETS = ("USDT", "USDC") +DEFAULT_EXCLUDED_BASE_ASSETS = ("USDT", "USDC", "BUSD", "FDUSD", "TUSD", "USDP", "DAI", "USDS", "USDD", "EUR", "AEUR") + +PROFILE_V5_BASELINE = "v5_baseline" +PROFILE_V7_DEFAULT = "v7_default" + + +@dataclass(slots=True) +class Strategy32Budgets: + strong_up_momentum: float = 0.85 + strong_up_carry: float = 0.10 + strong_up_sideways: float = 0.00 + up_momentum: float = 0.25 + up_carry: float = 0.15 + up_sideways: float = 0.00 + sideways_momentum: float = 0.00 + sideways_carry: float = 0.20 + sideways_sideways: float = 0.10 + down_momentum: float = 0.00 + down_carry: float = 0.10 + down_sideways: float = 0.03 + + def for_regime(self, regime: Regime) -> tuple[float, float, float]: + if regime == Regime.STRONG_UP: + return self.strong_up_momentum, self.strong_up_carry, self.strong_up_sideways + if regime == Regime.UP: + return self.up_momentum, self.up_carry, self.up_sideways + if regime == Regime.SIDEWAYS: + return self.sideways_momentum, self.sideways_carry, self.sideways_sideways + if regime == Regime.DOWN: + return self.down_momentum, self.down_carry, self.down_sideways + return 0.0, 0.0, 0.0 + + +@dataclass(slots=True) +class Strategy32Config: + symbols: list[str] = field(default_factory=list) + auto_discover_symbols: bool = True + quote_assets: tuple[str, ...] = DEFAULT_QUOTE_ASSETS + excluded_base_assets: tuple[str, ...] = DEFAULT_EXCLUDED_BASE_ASSETS + discovery_min_quote_volume_24h: float = 5_000_000.0 + timeframe: str = "4h" + warmup_days: int = 90 + max_symbol_staleness_days: int = 3 + universe_size: int = 0 + universe_lookback_bars: int = 30 + universe_min_avg_dollar_volume: float = 5_000_000.0 + hard_filter_refresh_cadence: str = "4h" + hard_filter_min_history_bars: int = 120 + hard_filter_lookback_bars: int = 30 + hard_filter_min_avg_dollar_volume: float = 50_000_000.0 + momentum_min_history_bars: int = 120 + momentum_min_score: float = 0.55 + momentum_min_relative_strength: float = 0.0 + momentum_min_7d_return: float = 0.0 + momentum_max_7d_return: float = 1.0 + momentum_min_positive_bar_ratio: float = 0.0 + momentum_max_short_volatility: float = 1.0 + momentum_max_beta: float = 10.0 + momentum_max_latest_funding_rate: float = 1.0 + enable_liquidity_universe_fallback: bool = True + universe_fallback_min_avg_dollar_volume: float = 2_500_000.0 + universe_fallback_top_n: int = 8 + enable_momentum_filter_fallback: bool = True + momentum_fallback_min_score: float = 0.45 + momentum_fallback_min_relative_strength: float = -0.03 + momentum_fallback_min_7d_return: float = -0.02 + momentum_fallback_top_n: int = 3 + carry_min_expected_edge: float = 0.0 + position_vol_lookback_bars: int = 36 + correlation_lookback_bars: int = 36 + max_pairwise_correlation: float = 0.78 + target_annualized_vol: float = 0.55 + governor_vol_lookback_bars: int = 42 + drawdown_window_days: int = 30 + drawdown_scale_1_trigger: float = 0.08 + drawdown_scale_1: float = 0.70 + drawdown_scale_2_trigger: float = 0.12 + drawdown_scale_2: float = 0.40 + drawdown_stop_trigger: float = 0.18 + vol_scale_floor: float = 0.35 + up_btc_hedge_ratio: float = 0.35 + sideways_btc_hedge_ratio: float = 0.65 + sideways_top_n: int = 1 + carry_relaxed_top_n: int = 128 + carry_relaxed_min_positive_ratio: float = 0.52 + carry_relaxed_min_mean_funding_rate: float = 0.000015 + carry_relaxed_max_basis_volatility: float = 0.0075 + carry_relaxed_roundtrip_cost_pct: float = 0.0022 + carry_relaxed_basis_risk_multiplier: float = 1.0 + carry_deep_relaxed_min_positive_ratio: float = 0.48 + carry_deep_relaxed_min_mean_funding_rate: float = 0.0 + carry_deep_relaxed_max_basis_volatility: float = 0.012 + carry_deep_relaxed_roundtrip_cost_pct: float = 0.0018 + carry_deep_relaxed_basis_risk_multiplier: float = 0.75 + enable_carry_score_fallback: bool = True + carry_score_fallback_min_expected_edge: float = -0.0002 + carry_score_fallback_min_positive_ratio: float = 0.48 + carry_score_fallback_top_n: int = 2 + enable_sideways_engine: bool = False + enable_strong_kill_switch: bool = True + enable_daily_trend_filter: bool = True + enable_expanded_hedge: bool = False + enable_max_holding_exit: bool = False + enable_execution_refinement: bool = True + execution_refinement_timeframe: str = "1h" + execution_refinement_lookback_bars: int = 48 + execution_refinement_fast_ema: int = 8 + execution_refinement_slow_ema: int = 21 + execution_refinement_scale_down_gap: float = 0.008 + execution_refinement_max_chase_gap: float = 0.018 + execution_refinement_max_recent_return: float = 0.03 + execution_refinement_scale_down_factor: float = 0.5 + strong_kill_drawdown_window_days: int = 60 + strong_kill_scale_1_trigger: float = 0.05 + strong_kill_scale_1: float = 0.60 + strong_kill_scale_2_trigger: float = 0.08 + strong_kill_scale_2: float = 0.35 + strong_kill_stop_trigger: float = 0.10 + long_trend_fast_ema_days: int = 50 + long_trend_slow_ema_days: int = 200 + expanded_up_btc_hedge_ratio: float = 0.55 + expanded_sideways_btc_hedge_ratio: float = 0.85 + expanded_strong_up_btc_hedge_ratio: float = 0.10 + max_holding_bars: int = 42 + trend_fail_ema_span: int = 18 + min_hold_bars_for_trend_fail: int = 12 + budgets: Strategy32Budgets = field(default_factory=Strategy32Budgets) + + +PROFILE_OVERRIDES: dict[str, dict[str, bool]] = { + PROFILE_V5_BASELINE: { + "enable_sideways_engine": True, + "enable_strong_kill_switch": False, + "enable_daily_trend_filter": False, + "enable_expanded_hedge": False, + "enable_max_holding_exit": False, + }, + PROFILE_V7_DEFAULT: { + "enable_sideways_engine": False, + "enable_strong_kill_switch": True, + "enable_daily_trend_filter": True, + "enable_expanded_hedge": False, + "enable_max_holding_exit": False, + }, +} + + +def apply_strategy32_profile(config: Strategy32Config, profile: str) -> Strategy32Config: + try: + overrides = PROFILE_OVERRIDES[profile] + except KeyError as exc: + supported = ", ".join(sorted(PROFILE_OVERRIDES)) + raise ValueError(f"Unsupported Strategy32 profile: {profile}. Supported: {supported}") from exc + for attr, value in overrides.items(): + setattr(config, attr, value) + return config + + +def build_strategy32_config(profile: str = PROFILE_V7_DEFAULT, **overrides: object) -> Strategy32Config: + config = Strategy32Config() + apply_strategy32_profile(config, profile) + for attr, value in overrides.items(): + setattr(config, attr, value) + return config + + +def build_engine_config() -> Strategy29Config: + config = Strategy29Config() + config.momentum.top_n = 128 + config.momentum.max_positions = 128 + config.momentum.rebalance_bars = 24 + config.momentum.trailing_stop_pct = 0.08 + config.momentum.stop_loss_pct = 0.07 + config.momentum.overheat_funding_rate = 0.00025 + config.carry.top_n = 128 + return config diff --git a/data.py b/data.py new file mode 100644 index 0000000..a4d909b --- /dev/null +++ b/data.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import pandas as pd + +from strategy29.common.models import MarketDataBundle +from strategy29.data.basis import compute_basis_frame +from strategy29.data.binance_history import BinancePairSpec, discover_usd_quote_pair_specs, fetch_funding_paginated, fetch_klines_paginated +from strategy29.data.funding import align_funding_to_price_bars + + +def resolve_strategy32_pair_specs( + *, + symbols: list[str], + auto_discover_symbols: bool, + quote_assets: tuple[str, ...], + excluded_base_assets: tuple[str, ...], + min_quote_volume_24h: float, +) -> list[BinancePairSpec]: + discovered = discover_usd_quote_pair_specs( + quote_assets=quote_assets, + excluded_base_assets=excluded_base_assets, + min_quote_volume_24h=0.0, + ) + by_base = {spec.base_symbol: spec for spec in discovered} + preferred_quote = quote_assets[0] + if auto_discover_symbols: + # Point-in-time liquidity is enforced inside the backtest using historical bars. + # Filtering discovery by current 24h quote volume leaks future information into past windows. + return discovered + if symbols: + specs: list[BinancePairSpec] = [] + for symbol in symbols: + base_symbol = symbol.upper() + if base_symbol in by_base: + spec = by_base[base_symbol] + if min(spec.spot_quote_volume_24h, spec.perp_quote_volume_24h) >= min_quote_volume_24h: + specs.append(spec) + continue + specs.append( + BinancePairSpec( + base_symbol=base_symbol, + quote_asset=preferred_quote, + spot_symbol=f"{base_symbol}{preferred_quote}", + perp_symbol=f"{base_symbol}{preferred_quote}", + spot_quote_volume_24h=0.0, + perp_quote_volume_24h=0.0, + ) + ) + return specs + return [] + + +def build_strategy32_market_bundle( + *, + symbols: list[str], + auto_discover_symbols: bool, + quote_assets: tuple[str, ...], + excluded_base_assets: tuple[str, ...], + min_quote_volume_24h: float, + start: pd.Timestamp, + end: pd.Timestamp, + timeframe: str = "4h", + max_staleness_days: int = 3, +) -> tuple[MarketDataBundle, pd.Timestamp, list[str], list[str], dict[str, str]]: + specs = resolve_strategy32_pair_specs( + symbols=symbols, + auto_discover_symbols=auto_discover_symbols, + quote_assets=quote_assets, + excluded_base_assets=excluded_base_assets, + min_quote_volume_24h=min_quote_volume_24h, + ) + return build_strategy32_market_bundle_from_specs( + specs=specs, + start=start, + end=end, + timeframe=timeframe, + max_staleness_days=max_staleness_days, + ) + + +def build_strategy32_price_frames_from_specs( + *, + specs: list[BinancePairSpec], + start: pd.Timestamp, + end: pd.Timestamp, + timeframe: str = "4h", + max_staleness_days: int = 3, +) -> tuple[dict[str, pd.DataFrame], pd.Timestamp, list[str], list[str], dict[str, str]]: + prices: dict[str, pd.DataFrame] = {} + accepted: list[str] = [] + rejected: list[str] = [] + quote_by_symbol: dict[str, str] = {} + latest_completed_bar: pd.Timestamp | None = None + staleness_cutoff = end - pd.Timedelta(days=max_staleness_days) + + for spec in specs: + symbol = spec.base_symbol + try: + perp = fetch_klines_paginated( + symbol, + timeframe=timeframe, + start=start, + end=end, + market="perp", + market_symbol=spec.perp_symbol, + quote_asset=spec.quote_asset, + ) + except Exception: + rejected.append(symbol) + continue + perp = perp.loc[perp["close_time"] <= end].reset_index(drop=True) + if perp.empty: + rejected.append(symbol) + continue + symbol_end = pd.Timestamp(perp["timestamp"].iloc[-1]) + if symbol_end < staleness_cutoff: + rejected.append(symbol) + continue + prices[symbol] = perp[["timestamp", "open", "high", "low", "close", "volume"]].copy() + latest_completed_bar = symbol_end if latest_completed_bar is None else min(latest_completed_bar, symbol_end) + accepted.append(symbol) + quote_by_symbol[symbol] = spec.quote_asset + + if latest_completed_bar is None: + raise ValueError("no completed bars fetched for strategy32 price frames") + return prices, latest_completed_bar, accepted, rejected, quote_by_symbol + + +def build_strategy32_market_bundle_from_specs( + *, + specs: list[BinancePairSpec], + start: pd.Timestamp, + end: pd.Timestamp, + timeframe: str = "4h", + max_staleness_days: int = 3, +) -> tuple[MarketDataBundle, pd.Timestamp, list[str], list[str], dict[str, str]]: + prices, latest_completed_bar, accepted, rejected, quote_by_symbol = build_strategy32_price_frames_from_specs( + specs=specs, + start=start, + end=end, + timeframe=timeframe, + max_staleness_days=max_staleness_days, + ) + funding: dict[str, pd.DataFrame] = {} + + by_base_symbol = {spec.base_symbol: spec for spec in specs} + for symbol in accepted: + if symbol == "BTC": + continue + spec = by_base_symbol[symbol] + try: + spot = fetch_klines_paginated( + symbol, + timeframe=timeframe, + start=start, + end=end, + market="spot", + market_symbol=spec.spot_symbol, + quote_asset=spec.quote_asset, + ) + except Exception: + continue + spot = spot.loc[spot["close_time"] <= end].reset_index(drop=True) + if spot.empty: + continue + basis = compute_basis_frame(spot[["timestamp", "close"]], prices[symbol][["timestamp", "close"]]) + try: + funding_rates = fetch_funding_paginated( + symbol, + start=start, + end=end, + market_symbol=spec.perp_symbol, + quote_asset=spec.quote_asset, + ) + except Exception: + funding_rates = pd.DataFrame(columns=["timestamp", "funding_rate"]) + if funding_rates.empty: + funding[symbol] = basis.assign(funding_rate=0.0)[["timestamp", "funding_rate", "basis"]] + continue + funding[symbol] = align_funding_to_price_bars( + funding_rates.merge(basis, on="timestamp", how="outer").sort_values("timestamp").ffill(), + prices[symbol]["timestamp"], + )[["timestamp", "funding_rate", "basis"]] + + return MarketDataBundle(prices=prices, funding=funding), latest_completed_bar, accepted, rejected, quote_by_symbol diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f01d775 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +services: + strategy32-live-monitor: + build: + context: .. + dockerfile: strategy32/Dockerfile + container_name: strategy32-live-monitor + restart: unless-stopped + env_file: + - /Volumes/SSD/workspace/money-bot/strategy11/.env + environment: + STRATEGY32_TIMEFRAME: ${STRATEGY32_TIMEFRAME:-4h} + STRATEGY32_MACRO_FILTER_TIMEFRAME: ${STRATEGY32_MACRO_FILTER_TIMEFRAME:-1w} + STRATEGY32_MACRO_FILTER_FAST_WEEKS: ${STRATEGY32_MACRO_FILTER_FAST_WEEKS:-10} + STRATEGY32_MACRO_FILTER_SLOW_WEEKS: ${STRATEGY32_MACRO_FILTER_SLOW_WEEKS:-30} + STRATEGY32_HARD_FILTER_TIMEFRAME: ${STRATEGY32_HARD_FILTER_TIMEFRAME:-1d} + STRATEGY32_EXECUTION_REFINEMENT_TIMEFRAME: ${STRATEGY32_EXECUTION_REFINEMENT_TIMEFRAME:-1h} + STRATEGY32_LOOKBACK_DAYS: ${STRATEGY32_LOOKBACK_DAYS:-365} + STRATEGY32_WARMUP_DAYS: ${STRATEGY32_WARMUP_DAYS:-90} + STRATEGY32_POLL_SECONDS: ${STRATEGY32_POLL_SECONDS:-60} + STRATEGY32_LIVE_MIN_QUOTE_VOLUME_24H: ${STRATEGY32_LIVE_MIN_QUOTE_VOLUME_24H:-100000000} + STRATEGY32_HARD_FILTER_MIN_HISTORY_BARS: ${STRATEGY32_HARD_FILTER_MIN_HISTORY_BARS:-120} + STRATEGY32_HARD_FILTER_LOOKBACK_BARS: ${STRATEGY32_HARD_FILTER_LOOKBACK_BARS:-30} + STRATEGY32_HARD_FILTER_MIN_AVG_DOLLAR_VOLUME: ${STRATEGY32_HARD_FILTER_MIN_AVG_DOLLAR_VOLUME:-50000000} + STRATEGY32_EXECUTION_REFINEMENT_LOOKBACK_BARS: ${STRATEGY32_EXECUTION_REFINEMENT_LOOKBACK_BARS:-48} + STRATEGY32_EXECUTION_REFINEMENT_FAST_EMA: ${STRATEGY32_EXECUTION_REFINEMENT_FAST_EMA:-8} + STRATEGY32_EXECUTION_REFINEMENT_SLOW_EMA: ${STRATEGY32_EXECUTION_REFINEMENT_SLOW_EMA:-21} + STRATEGY32_EXECUTION_REFINEMENT_SCALE_DOWN_GAP: ${STRATEGY32_EXECUTION_REFINEMENT_SCALE_DOWN_GAP:-0.008} + STRATEGY32_EXECUTION_REFINEMENT_MAX_CHASE_GAP: ${STRATEGY32_EXECUTION_REFINEMENT_MAX_CHASE_GAP:-0.018} + STRATEGY32_EXECUTION_REFINEMENT_MAX_RECENT_RETURN: ${STRATEGY32_EXECUTION_REFINEMENT_MAX_RECENT_RETURN:-0.03} + STRATEGY32_EXECUTION_REFINEMENT_SCALE_DOWN_FACTOR: ${STRATEGY32_EXECUTION_REFINEMENT_SCALE_DOWN_FACTOR:-0.5} + STRATEGY32_ENTRY_ONLY_REFINEMENT: ${STRATEGY32_ENTRY_ONLY_REFINEMENT:-true} + STRATEGY32_MAX_SPECS: ${STRATEGY32_MAX_SPECS:-0} + STRATEGY32_PAPER_CAPITAL_USD: ${STRATEGY32_PAPER_CAPITAL_USD:-1000} + STRATEGY32_MAX_STALENESS_DAYS: ${STRATEGY32_MAX_STALENESS_DAYS:-3} + STRATEGY32_INCLUDE_ACCOUNT_SNAPSHOT: ${STRATEGY32_INCLUDE_ACCOUNT_SNAPSHOT:-1} + STRATEGY32_BINANCE_TESTNET: ${STRATEGY32_BINANCE_TESTNET:-true} + STRATEGY32_ENABLE_LIVE_ORDERS: ${STRATEGY32_ENABLE_LIVE_ORDERS:-true} + STRATEGY32_EXECUTION_LEVERAGE: ${STRATEGY32_EXECUTION_LEVERAGE:-5} + STRATEGY32_MIN_TARGET_NOTIONAL_USD: ${STRATEGY32_MIN_TARGET_NOTIONAL_USD:-25} + STRATEGY32_MIN_REBALANCE_NOTIONAL_USD: ${STRATEGY32_MIN_REBALANCE_NOTIONAL_USD:-10} + STRATEGY32_CLOSE_ORPHAN_POSITIONS: ${STRATEGY32_CLOSE_ORPHAN_POSITIONS:-true} + STRATEGY32_RUNTIME_DIR: /app/runtime + STRATEGY32_LOG_LEVEL: ${STRATEGY32_LOG_LEVEL:-INFO} + volumes: + - ./runtime:/app/runtime + command: ["--runtime-dir", "/app/runtime"] + healthcheck: + test: ["CMD", "python", "-c", "from pathlib import Path; raise SystemExit(0 if Path('/app/runtime/strategy32_live_latest.json').exists() else 1)"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s diff --git a/live/__init__.py b/live/__init__.py new file mode 100644 index 0000000..3ce55a6 --- /dev/null +++ b/live/__init__.py @@ -0,0 +1,2 @@ +from __future__ import annotations + diff --git a/live/binance_account.py b/live/binance_account.py new file mode 100644 index 0000000..7e7319d --- /dev/null +++ b/live/binance_account.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import time +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urlencode +from urllib.request import Request, urlopen + + +REST_TESTNET = "https://testnet.binancefuture.com" +REST_MAINNET = "https://fapi.binance.com" + + +@dataclass(slots=True) +class BinanceUsdMAccountClient: + api_key: str + api_secret: str + testnet: bool = False + _response_cache: dict[str, tuple[float, Any]] = field(default_factory=dict, init=False, repr=False) + + @property + def base_url(self) -> str: + return REST_TESTNET if self.testnet else REST_MAINNET + + def _sign_params(self, params: dict[str, Any] | None = None) -> dict[str, Any]: + payload = dict(params or {}) + payload["timestamp"] = int(time.time() * 1000) + query = urlencode(payload) + signature = hmac.new(self.api_secret.encode("utf-8"), query.encode("utf-8"), hashlib.sha256).hexdigest() + payload["signature"] = signature + return payload + + def _get(self, path: str, params: dict[str, Any] | None = None, *, auth: bool = True) -> Any: + query_params = self._sign_params(params) if auth else dict(params or {}) + url = f"{self.base_url}{path}?{urlencode(query_params)}" + request = Request(url, headers={"X-MBX-APIKEY": self.api_key}) + with urlopen(request, timeout=15) as response: + return json.loads(response.read().decode("utf-8")) + + def _post(self, path: str, params: dict[str, Any] | None = None, *, auth: bool = True) -> Any: + payload = self._sign_params(params) if auth else dict(params or {}) + encoded = urlencode(payload).encode("utf-8") + url = f"{self.base_url}{path}" + request = Request(url, data=encoded, method="POST", headers={"X-MBX-APIKEY": self.api_key}) + with urlopen(request, timeout=15) as response: + return json.loads(response.read().decode("utf-8")) + + def _get_cached(self, key: str, ttl_seconds: float, loader) -> Any: + now = time.time() + cached = self._response_cache.get(key) + if cached is not None: + loaded_at, payload = cached + if now - loaded_at <= ttl_seconds: + return payload + payload = loader() + self._response_cache[key] = (now, payload) + return payload + + def get_balance(self) -> list[dict[str, Any]]: + payload = self._get_cached("balance", 10.0, lambda: list(self._get("/fapi/v2/balance"))) + return list(payload) + + def get_position_risk(self) -> list[dict[str, Any]]: + payload = self._get_cached("position_risk", 5.0, lambda: list(self._get("/fapi/v2/positionRisk"))) + return list(payload) + + def get_exchange_info(self) -> dict[str, Any]: + payload = self._get_cached("exchange_info", 3600.0, lambda: dict(self._get("/fapi/v1/exchangeInfo", auth=False))) + return dict(payload) + + def get_ticker_price(self, symbol: str | None = None) -> Any: + params = {"symbol": symbol} if symbol else None + key = f"ticker_price:{symbol or '*'}" + return self._get_cached(key, 5.0, lambda: self._get("/fapi/v1/ticker/price", params=params, auth=False)) + + def set_leverage(self, symbol: str, leverage: int) -> dict[str, Any]: + return dict(self._post("/fapi/v1/leverage", {"symbol": symbol, "leverage": max(1, int(leverage))})) + + def place_market_order( + self, + *, + symbol: str, + side: str, + quantity: float, + reduce_only: bool = False, + client_order_id: str | None = None, + ) -> dict[str, Any]: + params: dict[str, Any] = { + "symbol": symbol, + "side": side.upper(), + "type": "MARKET", + "quantity": self._format_decimal(quantity), + "newOrderRespType": "RESULT", + } + if reduce_only: + params["reduceOnly"] = "true" + if client_order_id: + clean = "".join(ch for ch in str(client_order_id) if ch.isalnum() or ch in "-_.") + if clean: + params["newClientOrderId"] = clean[:36] + response = dict(self._post("/fapi/v1/order", params)) + self._response_cache.pop("balance", None) + self._response_cache.pop("position_risk", None) + return response + + @staticmethod + def _format_decimal(value: float) -> str: + return ("%.12f" % float(value)).rstrip("0").rstrip(".") diff --git a/live/env.py b/live/env.py new file mode 100644 index 0000000..728d51a --- /dev/null +++ b/live/env.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +def load_dotenv(path: str | Path) -> None: + target = Path(path) + if not target.exists(): + return + for raw_line in target.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip("'\"") + if key and key not in os.environ: + os.environ[key] = value + + +def env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "y", "on"} + diff --git a/live/executor.py b/live/executor.py new file mode 100644 index 0000000..2b84c22 --- /dev/null +++ b/live/executor.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Any + +from strategy32.live.binance_account import BinanceUsdMAccountClient + + +KNOWN_QUOTES = ("USDT", "USDC") + + +@dataclass(slots=True) +class SymbolRule: + contract_symbol: str + base_asset: str + quote_asset: str + step_size: float + min_qty: float + min_notional: float + + +@dataclass(slots=True) +class LiveExecutionConfig: + enabled: bool = False + leverage: int = 1 + min_target_notional_usd: float = 25.0 + min_rebalance_notional_usd: float = 10.0 + close_orphan_positions: bool = True + entry_only_refinement: bool = True + + +@dataclass(slots=True) +class ExecutionResult: + executed_at: str + enabled: bool + account_equity_usd: float + target_symbols: list[str] = field(default_factory=list) + orders: list[dict[str, Any]] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +class LiveFuturesExecutor: + def __init__(self, client: BinanceUsdMAccountClient, config: LiveExecutionConfig) -> None: + self.client = client + self.config = config + self._rules: dict[str, SymbolRule] | None = None + self._applied_leverage: set[str] = set() + + def reconcile(self, snapshot: dict[str, Any]) -> ExecutionResult: + result = ExecutionResult( + executed_at=str(snapshot.get("generated_at", "")), + enabled=self.config.enabled, + account_equity_usd=self._account_equity_usd(), + ) + quote_by_symbol = dict(snapshot.get("universe", {}).get("quote_by_symbol", {})) + execution_targets = snapshot.get("execution_targets") + if isinstance(execution_targets, list): + target_rows = list(execution_targets) + else: + target_rows = [ + row + for row in snapshot.get("combined_targets", []) + if bool(row.get("tradeable")) and str(row.get("instrument", "")).startswith("perp:") + ] + result.target_symbols = [str(row["instrument"]).split(":", 1)[1] for row in target_rows] + if not self.config.enabled: + return result + + target_weights = { + str(row["instrument"]).split(":", 1)[1]: float(row.get("weight", 0.0) or 0.0) + for row in target_rows + } + desired_weights = { + str(row["instrument"]).split(":", 1)[1]: float(row.get("desired_weight", row.get("weight", 0.0)) or 0.0) + for row in target_rows + } + current_positions = self._current_positions() + all_symbols = sorted(set(target_weights) | set(current_positions if self.config.close_orphan_positions else target_weights)) + prices = self._prices_for_symbols(all_symbols, quote_by_symbol, current_positions) + + for base_symbol in all_symbols: + quote_asset = quote_by_symbol.get(base_symbol, current_positions.get(base_symbol, {}).get("quote_asset", "USDT")) + rule = self._symbol_rule(base_symbol, quote_asset) + if rule is None: + result.warnings.append(f"missing_symbol_rule:{base_symbol}") + continue + + target_weight = float(target_weights.get(base_symbol, 0.0) or 0.0) + price = float(prices.get(base_symbol, 0.0) or 0.0) + if price <= 0: + result.warnings.append(f"missing_price:{base_symbol}") + continue + + target_notional = target_weight * result.account_equity_usd + if abs(target_notional) < max(self.config.min_target_notional_usd, rule.min_notional): + target_notional = 0.0 + + current_qty = float(current_positions.get(base_symbol, {}).get("qty", 0.0) or 0.0) + current_contract = current_positions.get(base_symbol, {}).get("contract_symbol", rule.contract_symbol) + if self.config.entry_only_refinement: + desired_weight = float(desired_weights.get(base_symbol, target_weight) or 0.0) + desired_notional = desired_weight * result.account_equity_usd + current_notional = current_qty * price + if ( + current_qty != 0.0 + and desired_notional != 0.0 + and math.copysign(1.0, current_notional) == math.copysign(1.0, desired_notional) + and abs(target_notional) < abs(desired_notional) + ): + target_notional = math.copysign( + min(abs(current_notional), abs(desired_notional)), + desired_notional, + ) + + target_qty = self._normalize_qty(rule, target_notional / price) + + if current_qty != 0.0 and target_qty != 0.0 and math.copysign(1.0, current_qty) != math.copysign(1.0, target_qty): + close_order = self._submit_delta( + contract_symbol=current_contract, + delta_qty=-current_qty, + price=price, + reduce_only=True, + ) + if close_order is not None: + result.orders.append(close_order) + current_qty = 0.0 + + delta_qty = target_qty - current_qty + delta_notional = abs(delta_qty) * price + if delta_notional < max(self.config.min_rebalance_notional_usd, rule.min_notional): + continue + + order = self._submit_delta( + contract_symbol=rule.contract_symbol, + delta_qty=delta_qty, + price=price, + reduce_only=False, + ) + if order is not None: + result.orders.append(order) + + return result + + def _submit_delta( + self, + *, + contract_symbol: str, + delta_qty: float, + price: float, + reduce_only: bool, + ) -> dict[str, Any] | None: + qty = abs(float(delta_qty)) + if qty <= 0: + return None + side = "BUY" if delta_qty > 0 else "SELL" + self._ensure_leverage(contract_symbol) + response = self.client.place_market_order( + symbol=contract_symbol, + side=side, + quantity=qty, + reduce_only=reduce_only, + client_order_id=f"s32-{contract_symbol.lower()}-{side.lower()}", + ) + return { + "symbol": contract_symbol, + "side": side, + "quantity": qty, + "price_ref": price, + "reduce_only": reduce_only, + "response": response, + } + + def _ensure_leverage(self, contract_symbol: str) -> None: + if contract_symbol in self._applied_leverage: + return + self.client.set_leverage(contract_symbol, self.config.leverage) + self._applied_leverage.add(contract_symbol) + + def _account_equity_usd(self) -> float: + balances = self.client.get_balance() + total = 0.0 + for row in balances: + asset = str(row.get("asset", "")).upper() + if asset in KNOWN_QUOTES: + total += float(row.get("balance", 0.0) or 0.0) + return total + + def _current_positions(self) -> dict[str, dict[str, Any]]: + rows = self.client.get_position_risk() + positions: dict[str, dict[str, Any]] = {} + for row in rows: + qty = float(row.get("positionAmt", 0.0) or 0.0) + if abs(qty) <= 1e-12: + continue + contract_symbol = str(row.get("symbol", "")).upper() + base_asset, quote_asset = self._split_contract_symbol(contract_symbol) + positions[base_asset] = { + "qty": qty, + "quote_asset": quote_asset, + "contract_symbol": contract_symbol, + "mark_price": float(row.get("markPrice", 0.0) or 0.0), + } + return positions + + def _prices_for_symbols( + self, + symbols: list[str], + quote_by_symbol: dict[str, str], + current_positions: dict[str, dict[str, Any]], + ) -> dict[str, float]: + prices: dict[str, float] = {} + for base_symbol in symbols: + current = current_positions.get(base_symbol) + if current is not None and float(current.get("mark_price", 0.0) or 0.0) > 0: + prices[base_symbol] = float(current["mark_price"]) + continue + quote_asset = quote_by_symbol.get(base_symbol, "USDT") + contract_symbol = f"{base_symbol}{quote_asset}" + ticker = self.client.get_ticker_price(contract_symbol) + if isinstance(ticker, dict): + prices[base_symbol] = float(ticker.get("price", 0.0) or 0.0) + return prices + + def _symbol_rule(self, base_symbol: str, quote_asset: str) -> SymbolRule | None: + rules = self._load_rules() + return rules.get(f"{base_symbol}{quote_asset}") + + def _load_rules(self) -> dict[str, SymbolRule]: + if self._rules is not None: + return self._rules + info = self.client.get_exchange_info() + rules: dict[str, SymbolRule] = {} + for row in info.get("symbols", []): + contract_symbol = str(row.get("symbol", "")).upper() + if not contract_symbol: + continue + step_size = 0.0 + min_qty = 0.0 + min_notional = 5.0 + for flt in row.get("filters", []): + flt_type = str(flt.get("filterType", "")) + if flt_type in {"LOT_SIZE", "MARKET_LOT_SIZE"}: + step_size = max(step_size, float(flt.get("stepSize", 0.0) or 0.0)) + min_qty = max(min_qty, float(flt.get("minQty", 0.0) or 0.0)) + elif flt_type == "MIN_NOTIONAL": + min_notional = max(min_notional, float(flt.get("notional", 0.0) or 0.0)) + rules[contract_symbol] = SymbolRule( + contract_symbol=contract_symbol, + base_asset=str(row.get("baseAsset", "")).upper(), + quote_asset=str(row.get("quoteAsset", "")).upper(), + step_size=step_size or 0.001, + min_qty=min_qty or step_size or 0.001, + min_notional=min_notional, + ) + self._rules = rules + return rules + + @staticmethod + def _normalize_qty(rule: SymbolRule, raw_qty: float) -> float: + if abs(raw_qty) <= 0: + return 0.0 + sign = 1.0 if raw_qty > 0 else -1.0 + step = max(rule.step_size, 1e-12) + qty = math.floor(abs(raw_qty) / step) * step + if qty < max(rule.min_qty, step): + return 0.0 + return sign * qty + + @staticmethod + def _split_contract_symbol(contract_symbol: str) -> tuple[str, str]: + for quote in KNOWN_QUOTES: + if contract_symbol.endswith(quote): + return contract_symbol[: -len(quote)], quote + return contract_symbol, "USDT" diff --git a/live/notifier.py b/live/notifier.py new file mode 100644 index 0000000..85a6e55 --- /dev/null +++ b/live/notifier.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import asyncio +import json +import os +import re +import time +import urllib.parse +import urllib.request +from dataclasses import dataclass + + +@dataclass +class NotifierConfig: + bot_token: str + chat_id: str + min_level: str = "INFO" + rate_limit_per_sec: float = 5.0 + + +class Notifier: + LEVELS = {"INFO": 10, "WARNING": 20, "CRITICAL": 30} + + def __init__(self, config: NotifierConfig | None = None) -> None: + self.config = config + self.enabled = config is not None and bool(config.bot_token) and bool(config.chat_id) + self._queue: asyncio.PriorityQueue[tuple[int, float, str, str]] = asyncio.PriorityQueue(maxsize=1000) + self._worker: asyncio.Task | None = None + self._stopping = False + self._last_send_ts = 0.0 + + @classmethod + def from_env(cls) -> "Notifier": + token = ( + os.getenv("GAMMA_TELEGRAM_TOKEN", "").strip() + or os.getenv("TELEGRAM_BOT_TOKEN", "").strip() + ) + chat_id = ( + os.getenv("GAMMA_TELEGRAM_CHAT_ID", "").strip() + or os.getenv("TELEGRAM_CHAT_ID", "").strip() + ) + min_level = ( + os.getenv("GAMMA_TELEGRAM_MIN_LEVEL", "").strip().upper() + or os.getenv("TELEGRAM_MIN_LEVEL", "INFO").strip().upper() + or "INFO" + ) + if not token or not chat_id: + return cls(config=None) + return cls(config=NotifierConfig(bot_token=token, chat_id=chat_id, min_level=min_level)) + + async def start(self) -> None: + if not self.enabled or self._worker is not None: + return + self._stopping = False + self._worker = asyncio.create_task(self._worker_loop(), name="strategy32-telegram-notifier") + + async def stop(self) -> None: + self._stopping = True + if self._worker is None: + return + self._worker.cancel() + try: + await self._worker + except asyncio.CancelledError: + pass + except Exception: + pass + self._worker = None + + async def send(self, level: str, message: str) -> bool: + level = level.upper().strip() + if not self.enabled: + return False + if self.LEVELS.get(level, 0) < self.LEVELS.get(self.config.min_level.upper(), 10): + return False + try: + priority = 0 if level == "CRITICAL" else 1 + self._queue.put_nowait((priority, time.time(), level, _mask_sensitive(message))) + return True + except asyncio.QueueFull: + return False + + async def _worker_loop(self) -> None: + while not self._stopping: + _priority, _ts, level, message = await self._queue.get() + text = f"[{level}] {message}" + try: + await self._send_telegram(text) + except Exception: + continue + + async def _send_telegram(self, text: str) -> None: + min_interval = 1.0 / max(1e-6, float(self.config.rate_limit_per_sec)) + elapsed = time.time() - self._last_send_ts + if elapsed < min_interval: + await asyncio.sleep(min_interval - elapsed) + + encoded = urllib.parse.urlencode( + { + "chat_id": self.config.chat_id, + "text": text, + "disable_web_page_preview": "true", + } + ).encode("utf-8") + url = f"https://api.telegram.org/bot{self.config.bot_token}/sendMessage" + + def _call() -> None: + req = urllib.request.Request( + url, + data=encoded, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + raw = resp.read().decode("utf-8") + data = json.loads(raw) + if not isinstance(data, dict) or not bool(data.get("ok")): + raise RuntimeError("telegram_send_failed") + + await asyncio.to_thread(_call) + self._last_send_ts = time.time() + + +def _mask_sensitive(text: str) -> str: + out = str(text) + out = re.sub(r"0x[a-fA-F0-9]{64}", "0x***MASKED_PRIVATE_KEY***", out) + out = re.sub(r"0x[a-fA-F0-9]{40}", "0x***MASKED_ADDRESS***", out) + out = re.sub(r"\b\d{7,}:[A-Za-z0-9_-]{20,}\b", "***MASKED_TOKEN***", out) + return out diff --git a/live/runtime.py b/live/runtime.py new file mode 100644 index 0000000..f9fdf1c --- /dev/null +++ b/live/runtime.py @@ -0,0 +1,1071 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +import pandas as pd + +from strategy29.common.constants import BTC_SYMBOL +from strategy32.backtest.simulator import Strategy32Backtester +from strategy32.config import PROFILE_V7_DEFAULT, Strategy32Config, build_strategy32_config +from strategy32.data import ( + build_strategy32_market_bundle_from_specs, + build_strategy32_price_frames_from_specs, + resolve_strategy32_pair_specs, +) +from strategy32.live.binance_account import BinanceUsdMAccountClient +from strategy32.live.env import env_bool +from strategy32.live.executor import LiveExecutionConfig, LiveFuturesExecutor +from strategy32.live.notifier import Notifier +from strategy32.research.adverse_regime import AdverseRegimeResearchHarness, default_engine_specs +from strategy32.research.hybrid_regime import STATIC_FILTERS +from strategy32.research.soft_router import CashOverlayCandidate, build_regime_score_frame +from strategy32.scripts.run_regime_filter_analysis import build_strategic_regime_frame +from strategy32.universe import select_dynamic_universe + + +LOGGER = logging.getLogger("strategy32.live") + +BEST_CASH_OVERLAY = CashOverlayCandidate( + regime_profile="loose_positive", + core_filter="overheat_tolerant", + cap_engine="cap_btc_rebound", + chop_engine="chop_inverse_carry_strict", + dist_engine="dist_inverse_carry_strict", + cap_cash_weight=0.65, + chop_cash_weight=0.40, + dist_cash_weight=0.20, + cap_threshold=0.20, + chop_threshold=0.35, + dist_threshold=0.35, + core_block_threshold=0.60, +) + +LIVE_STATIC_FILTER_NAME = "overheat_tolerant" +LIVE_STRATEGY_OVERRIDES: dict[str, Any] = { + **STATIC_FILTERS[LIVE_STATIC_FILTER_NAME], + # Keep live aligned with the research winner, not the later diagnostics. + "enable_liquidity_universe_fallback": False, + "enable_momentum_filter_fallback": False, + "enable_carry_score_fallback": False, +} + + +@dataclass(slots=True) +class LiveMonitorConfig: + timeframe: str = "4h" + macro_filter_timeframe: str = "1w" + hard_filter_timeframe: str = "1d" + execution_refinement_timeframe: str = "1h" + lookback_days: int = 365 + warmup_days: int = 90 + poll_seconds: int = 60 + runtime_dir: Path = Path("runtime") + live_min_quote_volume_24h: float = 100_000_000.0 + macro_filter_fast_weeks: int = 10 + macro_filter_slow_weeks: int = 30 + hard_filter_min_history_bars: int = 120 + hard_filter_lookback_bars: int = 30 + hard_filter_min_avg_dollar_volume: float = 50_000_000.0 + execution_refinement_lookback_bars: int = 48 + execution_refinement_fast_ema: int = 8 + execution_refinement_slow_ema: int = 21 + execution_refinement_scale_down_gap: float = 0.008 + execution_refinement_max_chase_gap: float = 0.018 + execution_refinement_max_recent_return: float = 0.03 + execution_refinement_scale_down_factor: float = 0.5 + max_specs: int = 0 + paper_capital_usd: float = 1_000.0 + max_staleness_days: int = 3 + include_account_snapshot: bool = True + execution: LiveExecutionConfig = field(default_factory=LiveExecutionConfig) + + +def _clip01(value: float) -> float: + return min(max(float(value), 0.0), 1.0) + + +def _now_utc() -> pd.Timestamp: + return pd.Timestamp.now(tz="UTC") + + +def _completed_bar_time(now: pd.Timestamp, timeframe: str) -> pd.Timestamp: + now = pd.Timestamp(now).tz_convert("UTC") if pd.Timestamp(now).tzinfo else pd.Timestamp(now, tz="UTC") + step = pd.Timedelta(timeframe) + epoch = pd.Timestamp("1970-01-01 00:00:00+00:00") + steps = int((now - epoch) // step) + return epoch + steps * step + + +def _heartbeat_slot(now: pd.Timestamp) -> tuple[int, int, int, int, int]: + ts = pd.Timestamp(now).tz_convert("UTC") if pd.Timestamp(now).tzinfo else pd.Timestamp(now, tz="UTC") + return (ts.year, ts.month, ts.day, ts.hour, 0 if ts.minute < 30 else 30) + + +def _execution_client_from_env() -> BinanceUsdMAccountClient | None: + api_key = os.getenv("GAMMA_BOT_API_KEY", "").strip() or os.getenv("BN_API_KEY", "").strip() + api_secret = os.getenv("GAMMA_BOT_API_SECRET", "").strip() or os.getenv("BN_API_SECRET", "").strip() + if not api_key or not api_secret: + return None + use_testnet = env_bool("STRATEGY32_BINANCE_TESTNET", True) + return BinanceUsdMAccountClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet) + + +def _load_monitor_config(runtime_dir: str | None = None) -> LiveMonitorConfig: + return LiveMonitorConfig( + timeframe=os.getenv("STRATEGY32_TIMEFRAME", "4h").strip() or "4h", + macro_filter_timeframe=os.getenv("STRATEGY32_MACRO_FILTER_TIMEFRAME", "1w").strip() or "1w", + hard_filter_timeframe=os.getenv("STRATEGY32_HARD_FILTER_TIMEFRAME", "1d").strip() or "1d", + execution_refinement_timeframe=os.getenv("STRATEGY32_EXECUTION_REFINEMENT_TIMEFRAME", "1h").strip() or "1h", + lookback_days=int(os.getenv("STRATEGY32_LOOKBACK_DAYS", "365")), + warmup_days=int(os.getenv("STRATEGY32_WARMUP_DAYS", "90")), + poll_seconds=int(os.getenv("STRATEGY32_POLL_SECONDS", "60")), + runtime_dir=Path(runtime_dir or os.getenv("STRATEGY32_RUNTIME_DIR", "runtime")), + live_min_quote_volume_24h=float(os.getenv("STRATEGY32_LIVE_MIN_QUOTE_VOLUME_24H", "100000000")), + macro_filter_fast_weeks=int(os.getenv("STRATEGY32_MACRO_FILTER_FAST_WEEKS", "10")), + macro_filter_slow_weeks=int(os.getenv("STRATEGY32_MACRO_FILTER_SLOW_WEEKS", "30")), + hard_filter_min_history_bars=int(os.getenv("STRATEGY32_HARD_FILTER_MIN_HISTORY_BARS", "120")), + hard_filter_lookback_bars=int(os.getenv("STRATEGY32_HARD_FILTER_LOOKBACK_BARS", "30")), + hard_filter_min_avg_dollar_volume=float(os.getenv("STRATEGY32_HARD_FILTER_MIN_AVG_DOLLAR_VOLUME", "50000000")), + execution_refinement_lookback_bars=int(os.getenv("STRATEGY32_EXECUTION_REFINEMENT_LOOKBACK_BARS", "48")), + execution_refinement_fast_ema=int(os.getenv("STRATEGY32_EXECUTION_REFINEMENT_FAST_EMA", "8")), + execution_refinement_slow_ema=int(os.getenv("STRATEGY32_EXECUTION_REFINEMENT_SLOW_EMA", "21")), + execution_refinement_scale_down_gap=float(os.getenv("STRATEGY32_EXECUTION_REFINEMENT_SCALE_DOWN_GAP", "0.008")), + execution_refinement_max_chase_gap=float(os.getenv("STRATEGY32_EXECUTION_REFINEMENT_MAX_CHASE_GAP", "0.018")), + execution_refinement_max_recent_return=float(os.getenv("STRATEGY32_EXECUTION_REFINEMENT_MAX_RECENT_RETURN", "0.03")), + execution_refinement_scale_down_factor=float(os.getenv("STRATEGY32_EXECUTION_REFINEMENT_SCALE_DOWN_FACTOR", "0.5")), + max_specs=int(os.getenv("STRATEGY32_MAX_SPECS", "0")), + paper_capital_usd=float(os.getenv("STRATEGY32_PAPER_CAPITAL_USD", "1000")), + max_staleness_days=int(os.getenv("STRATEGY32_MAX_STALENESS_DAYS", "3")), + include_account_snapshot=env_bool("STRATEGY32_INCLUDE_ACCOUNT_SNAPSHOT", True), + execution=LiveExecutionConfig( + enabled=env_bool("STRATEGY32_ENABLE_LIVE_ORDERS", False), + leverage=int(os.getenv("STRATEGY32_EXECUTION_LEVERAGE", os.getenv("GAMMA_BOT_LEVERAGE", "1"))), + min_target_notional_usd=float(os.getenv("STRATEGY32_MIN_TARGET_NOTIONAL_USD", "25")), + min_rebalance_notional_usd=float(os.getenv("STRATEGY32_MIN_REBALANCE_NOTIONAL_USD", "10")), + close_orphan_positions=env_bool("STRATEGY32_CLOSE_ORPHAN_POSITIONS", True), + entry_only_refinement=env_bool("STRATEGY32_ENTRY_ONLY_REFINEMENT", True), + ), + ) + + +def _runtime_paths(runtime_dir: Path) -> dict[str, Path]: + runtime_dir.mkdir(parents=True, exist_ok=True) + return { + "runtime_dir": runtime_dir, + "snapshots_jsonl": runtime_dir / "strategy32_live_snapshots.jsonl", + "orders_jsonl": runtime_dir / "strategy32_live_orders.jsonl", + "latest_json": runtime_dir / "strategy32_live_latest.json", + "state_json": runtime_dir / "strategy32_live_state.json", + } + + +def _read_state(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=True), encoding="utf-8") + + +def _append_jsonl(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, ensure_ascii=True) + "\n") + + +def _position_to_serializable(position: dict[str, Any]) -> dict[str, Any]: + payload = dict(position) + if "entry_time" in payload: + payload["entry_time"] = str(payload["entry_time"]) + return payload + + +def _expand_core_targets(final_positions: list[dict[str, Any]], final_equity: float) -> list[dict[str, Any]]: + rows: dict[str, dict[str, Any]] = {} + equity = max(float(final_equity), 1e-9) + + def add_row(instrument: str, *, source: str, weight: float, note: str, tradeable: bool) -> None: + if abs(weight) <= 1e-9: + return + row = rows.setdefault( + instrument, + { + "instrument": instrument, + "source": source, + "weight": 0.0, + "tradeable": tradeable, + "note": note, + }, + ) + row["weight"] = float(row["weight"]) + float(weight) + + for pos in final_positions: + weight = float(pos["value"]) / equity + hedge_ratio = float((pos.get("meta") or {}).get("hedge_ratio", 0.0) or 0.0) + engine = str(pos["engine"]) + symbol = str(pos["symbol"]) + if engine in {"momentum", "sideways"}: + add_row(f"perp:{symbol}", source="core", weight=weight, note=engine, tradeable=True) + if hedge_ratio > 0: + add_row(f"perp:{BTC_SYMBOL}", source="core", weight=-weight * hedge_ratio, note=f"{engine}_hedge", tradeable=True) + elif engine == "carry": + add_row(f"carry:{symbol}", source="core", weight=weight, note="synthetic_carry", tradeable=False) + result = [] + for row in rows.values(): + row["notional_usd"] = float(row["weight"]) * equity + result.append(row) + result.sort(key=lambda row: abs(float(row["notional_usd"])), reverse=True) + return result + + +def _overlay_signal_strengths(candidate: CashOverlayCandidate, score_row: dict[str, Any]) -> dict[str, float]: + core_score = float(score_row.get("core_score", 0.0)) + panic_score = float(score_row.get("panic_score", 0.0)) + choppy_score = float(score_row.get("choppy_score", 0.0)) + distribution_score = float(score_row.get("distribution_score", 0.0)) + + cap_signal = _clip01((panic_score - candidate.cap_threshold) / max(1.0 - candidate.cap_threshold, 1e-9)) + chop_signal = _clip01((choppy_score - candidate.chop_threshold) / max(1.0 - candidate.chop_threshold, 1e-9)) + dist_signal = _clip01((distribution_score - candidate.dist_threshold) / max(1.0 - candidate.dist_threshold, 1e-9)) + if core_score > candidate.core_block_threshold: + chop_signal *= 0.25 + dist_signal *= 0.35 + return { + "core_score": core_score, + "panic_score": panic_score, + "choppy_score": choppy_score, + "distribution_score": distribution_score, + "cap_signal": cap_signal, + "chop_signal": chop_signal, + "dist_signal": dist_signal, + } + + +def _overlay_targets( + *, + bundle, + latest_bar: pd.Timestamp, + score_row: dict[str, Any], + core_cash_pct: float, + equity: float, + candidate: CashOverlayCandidate, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + harness = AdverseRegimeResearchHarness(bundle, latest_bar) + specs = {spec.name: spec for spec in default_engine_specs()} + signals = _overlay_signal_strengths(candidate, score_row) + + budgets = { + candidate.cap_engine: float(core_cash_pct) * candidate.cap_cash_weight * signals["cap_signal"], + candidate.chop_engine: float(core_cash_pct) * candidate.chop_cash_weight * signals["chop_signal"], + candidate.dist_engine: float(core_cash_pct) * candidate.dist_cash_weight * signals["dist_signal"], + } + rows: dict[str, dict[str, Any]] = {} + for engine_name, budget in budgets.items(): + if budget <= 0: + continue + raw_weights = harness.target_weights(specs[engine_name], latest_bar) + if not raw_weights: + continue + for key, raw_weight in raw_weights.items(): + tradeable = ":" not in key + instrument = key if ":" in key else f"perp:{key}" + row = rows.setdefault( + instrument, + { + "instrument": instrument, + "source": "overlay", + "weight": 0.0, + "tradeable": tradeable, + "note": engine_name, + }, + ) + row["weight"] = float(row["weight"]) + float(budget) * float(raw_weight) + + result = [] + for row in rows.values(): + row["notional_usd"] = float(row["weight"]) * float(equity) + result.append(row) + result.sort(key=lambda row: abs(float(row["notional_usd"])), reverse=True) + signals["engine_budgets"] = budgets + return result, signals + + +def _combine_targets(core_targets: list[dict[str, Any]], overlay_targets: list[dict[str, Any]], equity: float) -> list[dict[str, Any]]: + rows: dict[str, dict[str, Any]] = {} + for target in core_targets + overlay_targets: + instrument = str(target["instrument"]) + row = rows.setdefault( + instrument, + { + "instrument": instrument, + "weight": 0.0, + "notional_usd": 0.0, + "tradeable": bool(target["tradeable"]), + "sources": [], + }, + ) + row["weight"] = float(row["weight"]) + float(target["weight"]) + row["sources"].append({"source": target["source"], "note": target["note"], "weight": float(target["weight"])}) + row["tradeable"] = bool(row["tradeable"]) and bool(target["tradeable"]) + combined = [] + for row in rows.values(): + row["notional_usd"] = float(row["weight"]) * float(equity) + combined.append(row) + combined.sort(key=lambda row: abs(float(row["notional_usd"])), reverse=True) + return combined + + +def _current_specs(config: LiveMonitorConfig, strategy_config: Strategy32Config) -> list: + specs = resolve_strategy32_pair_specs( + symbols=strategy_config.symbols, + auto_discover_symbols=True, + quote_assets=strategy_config.quote_assets, + excluded_base_assets=strategy_config.excluded_base_assets, + min_quote_volume_24h=0.0, + ) + specs = [ + spec + for spec in specs + if min(spec.spot_quote_volume_24h, spec.perp_quote_volume_24h) >= config.live_min_quote_volume_24h + ] + specs.sort(key=lambda spec: min(spec.spot_quote_volume_24h, spec.perp_quote_volume_24h), reverse=True) + if config.max_specs > 0: + specs = specs[: config.max_specs] + return specs + + +def _select_live_hard_filter_symbols( + prices: dict[str, pd.DataFrame], + *, + timestamp: pd.Timestamp, + config: LiveMonitorConfig, +) -> list[str]: + selected = select_dynamic_universe( + prices, + timestamp=timestamp, + min_history_bars=config.hard_filter_min_history_bars, + lookback_bars=config.hard_filter_lookback_bars, + max_symbols=0, + min_avg_dollar_volume=config.hard_filter_min_avg_dollar_volume, + base_symbol=BTC_SYMBOL, + ) + result: list[str] = [] + if BTC_SYMBOL in prices: + result.append(BTC_SYMBOL) + result.extend(selected) + return result + + +def _weekly_macro_filter_state( + prices: dict[str, pd.DataFrame], + *, + timestamp: pd.Timestamp, + config: LiveMonitorConfig, +) -> dict[str, Any]: + hist = prices.get(BTC_SYMBOL) + if hist is None or hist.empty: + return { + "timeframe": config.macro_filter_timeframe, + "risk_on": True, + "reason": "missing_btc_prices", + } + frame = hist.loc[hist["timestamp"] <= timestamp, ["timestamp", "close"]].copy() + if frame.empty: + return { + "timeframe": config.macro_filter_timeframe, + "risk_on": True, + "reason": "empty_btc_prices", + } + daily = frame.set_index("timestamp")["close"].resample("1D").last().dropna() + weekly = daily.resample("W-SUN").last().dropna() + if len(weekly) < config.macro_filter_slow_weeks: + return { + "timeframe": config.macro_filter_timeframe, + "risk_on": True, + "reason": "insufficient_weekly_history", + "latest_bar": str(weekly.index[-1]) if len(weekly) else None, + "bars": int(len(weekly)), + } + fast = weekly.ewm(span=config.macro_filter_fast_weeks, adjust=False).mean().iloc[-1] + slow = weekly.ewm(span=config.macro_filter_slow_weeks, adjust=False).mean().iloc[-1] + close = float(weekly.iloc[-1]) + risk_on = bool(close > float(slow) and float(fast) > float(slow)) + return { + "timeframe": config.macro_filter_timeframe, + "risk_on": risk_on, + "latest_bar": str(weekly.index[-1]), + "bars": int(len(weekly)), + "close": close, + "ema_fast": float(fast), + "ema_slow": float(slow), + "trend_gap": float(close / float(slow) - 1.0), + } + + +def _apply_weekly_macro_filter(core_targets: list[dict[str, Any]], *, macro_state: dict[str, Any]) -> list[dict[str, Any]]: + if bool(macro_state.get("risk_on", True)): + return core_targets + filtered: list[dict[str, Any]] = [] + for row in core_targets: + instrument = str(row.get("instrument", "")) + if bool(row.get("tradeable")) and instrument.startswith("perp:"): + continue + filtered.append(row) + return filtered + + +def _execution_refinement_states( + prices: dict[str, pd.DataFrame], + *, + timestamp: pd.Timestamp, + config: LiveMonitorConfig, +) -> dict[str, dict[str, Any]]: + min_bars = max( + config.execution_refinement_lookback_bars, + config.execution_refinement_slow_ema + 5, + 8, + ) + states: dict[str, dict[str, Any]] = {} + for symbol, df in prices.items(): + 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=config.execution_refinement_fast_ema, adjust=False).mean().iloc[-1]) + ema_slow = float(closes.ewm(span=config.execution_refinement_slow_ema, adjust=False).mean().iloc[-1]) + recent_return = 0.0 + if len(closes) >= 4: + recent_return = float(close / float(closes.iloc[-4]) - 1.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 >= config.execution_refinement_max_chase_gap or recent_return >= config.execution_refinement_max_recent_return: + action = "block" + scale = 0.0 + reason = "too_extended" + elif chase_gap >= config.execution_refinement_scale_down_gap: + action = "scale_down" + scale = config.execution_refinement_scale_down_factor + reason = "slightly_extended" + + states[symbol] = { + "action": action, + "scale": scale, + "reason": reason, + "close": close, + "ema_fast": ema_fast, + "ema_slow": ema_slow, + "chase_gap": chase_gap, + "recent_return": recent_return, + "timestamp": str(hist["timestamp"].iloc[-1]), + } + return states + + +def _refine_execution_targets( + combined_targets: list[dict[str, Any]], + *, + refinement_states: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + execution_targets: list[dict[str, Any]] = [] + for row in combined_targets: + if not bool(row.get("tradeable")) or not str(row.get("instrument", "")).startswith("perp:"): + continue + instrument = str(row["instrument"]) + base_symbol = instrument.split(":", 1)[1] + desired_weight = float(row.get("weight", 0.0) or 0.0) + state = refinement_states.get(base_symbol, {"action": "allow", "scale": 1.0, "reason": "no_state"}) + adjusted_weight = desired_weight + if desired_weight > 0.0 and base_symbol != BTC_SYMBOL: + adjusted_weight = desired_weight * float(state.get("scale", 1.0) or 0.0) + execution_targets.append( + { + **row, + "desired_weight": desired_weight, + "weight": adjusted_weight, + "refinement_action": state.get("action", "allow"), + "refinement_scale": float(state.get("scale", 1.0) or 1.0), + "refinement_reason": state.get("reason", "unknown"), + } + ) + return execution_targets + + +def _account_snapshot() -> dict[str, Any] | None: + client = _execution_client_from_env() + if client is None: + return None + try: + balances = client.get_balance() + positions = client.get_position_risk() + except Exception as exc: + return {"error": str(exc)} + active_positions = [] + for row in positions: + amt = float(row.get("positionAmt", 0.0) or 0.0) + if abs(amt) <= 1e-9: + continue + active_positions.append( + { + "symbol": str(row.get("symbol", "")), + "position_amt": amt, + "entry_price": float(row.get("entryPrice", 0.0) or 0.0), + "notional": float(row.get("notional", 0.0) or 0.0), + "unrealized_pnl": float(row.get("unRealizedProfit", 0.0) or 0.0), + } + ) + return { + "balances": balances, + "active_positions": active_positions, + } + + +def _capital_summary(account: dict[str, Any] | None) -> dict[str, float]: + if not isinstance(account, dict): + return {"usdt": 0.0, "usdc": 0.0, "total_quote": 0.0} + balances = account.get("balances", []) + if not isinstance(balances, list): + return {"usdt": 0.0, "usdc": 0.0, "total_quote": 0.0} + by_asset: dict[str, float] = {} + for row in balances: + asset = str(row.get("asset", "")).upper() + if asset not in {"USDT", "USDC"}: + continue + by_asset[asset] = float(row.get("balance", 0.0) or 0.0) + usdt = float(by_asset.get("USDT", 0.0)) + usdc = float(by_asset.get("USDC", 0.0)) + return { + "usdt": usdt, + "usdc": usdc, + "total_quote": usdt + usdc, + } + + +def _snapshot_hash(payload: dict[str, Any]) -> str: + compact = { + "latest_bar": payload["latest_bar"], + "strategic_regime": payload["regime"]["strategic_regime"], + "macro_risk_on": payload["regime"].get("macro_risk_on"), + "execution_targets": [ + { + "instrument": row["instrument"], + "weight": round(float(row["weight"]), 6), + "tradeable": bool(row["tradeable"]), + } + for row in payload.get("execution_targets", [])[:20] + ], + } + return hashlib.sha1(json.dumps(compact, sort_keys=True).encode("utf-8")).hexdigest() + + +def _build_live_strategy_config() -> Strategy32Config: + return build_strategy32_config(PROFILE_V7_DEFAULT, **LIVE_STRATEGY_OVERRIDES) + + +def _live_strategy_signature() -> str: + payload = { + "profile": PROFILE_V7_DEFAULT, + "core_filter": LIVE_STATIC_FILTER_NAME, + "core_filter_overrides": {key: LIVE_STRATEGY_OVERRIDES[key] for key in sorted(LIVE_STRATEGY_OVERRIDES)}, + "cash_overlay_candidate": asdict(BEST_CASH_OVERLAY), + } + return hashlib.sha1(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest() + + +def _resolve_specs_for_symbols(strategy_config: Strategy32Config, symbols: list[str]) -> list: + return resolve_strategy32_pair_specs( + symbols=symbols, + auto_discover_symbols=False, + quote_assets=strategy_config.quote_assets, + excluded_base_assets=strategy_config.excluded_base_assets, + min_quote_volume_24h=0.0, + ) + + +def _build_daily_hard_filter_state( + config: LiveMonitorConfig, + strategy_config: Strategy32Config, + *, + end: pd.Timestamp, +) -> dict[str, Any]: + specs = _current_specs(config, strategy_config) + if not specs: + raise ValueError("no live specs matched current volume filter") + start = end - pd.Timedelta(days=config.lookback_days) + daily_prices, daily_latest_bar, daily_accepted, daily_rejected, _ = build_strategy32_price_frames_from_specs( + specs=specs, + start=start, + end=end, + timeframe=config.hard_filter_timeframe, + max_staleness_days=config.max_staleness_days, + ) + hard_filter_symbols = _select_live_hard_filter_symbols( + daily_prices, + timestamp=daily_latest_bar, + config=config, + ) + selected_symbols = [spec.base_symbol for spec in specs if spec.base_symbol in set(hard_filter_symbols)] + if not selected_symbols: + raise ValueError("daily hard filter removed every live spec") + return { + "generated_at": str(_now_utc()), + "refresh_timeframe": config.hard_filter_timeframe, + "discovered_specs": len(specs), + "discovered_symbols": [spec.base_symbol for spec in specs], + "hard_filter_latest_bar": str(daily_latest_bar), + "hard_filter_candidates": daily_accepted, + "hard_filter_rejected_symbols": daily_rejected, + "hard_filter_symbols": hard_filter_symbols, + "selected_symbols": selected_symbols, + } + + +def _build_cached_macro_state( + config: LiveMonitorConfig, + strategy_config: Strategy32Config, + *, + end: pd.Timestamp, +) -> dict[str, Any]: + start = end - pd.Timedelta(days=config.lookback_days) + btc_specs = _resolve_specs_for_symbols(strategy_config, [BTC_SYMBOL]) + daily_prices, daily_latest_bar, _, _, _ = build_strategy32_price_frames_from_specs( + specs=btc_specs, + start=start, + end=end, + timeframe=config.hard_filter_timeframe, + max_staleness_days=config.max_staleness_days, + ) + macro_state = _weekly_macro_filter_state( + daily_prices, + timestamp=daily_latest_bar, + config=config, + ) + macro_state["generated_at"] = str(_now_utc()) + macro_state["daily_latest_bar"] = str(daily_latest_bar) + return macro_state + + +def build_monitor_snapshot( + config: LiveMonitorConfig, + *, + hard_filter_state: dict[str, Any] | None = None, + macro_state: dict[str, Any] | None = None, +) -> dict[str, Any]: + strategy_config = _build_live_strategy_config() + end = _now_utc() + start = end - pd.Timedelta(days=config.lookback_days) + if hard_filter_state is None: + hard_filter_state = _build_daily_hard_filter_state(config, strategy_config, end=end) + if macro_state is None: + macro_state = _build_cached_macro_state(config, strategy_config, end=end) + selected_symbols = [str(symbol) for symbol in hard_filter_state.get("selected_symbols", [])] + filtered_specs = _resolve_specs_for_symbols(strategy_config, selected_symbols) + if not filtered_specs: + raise ValueError("cached hard filter symbols no longer resolved to live specs") + + bundle, latest_bar, accepted, rejected, quote_by_symbol = build_strategy32_market_bundle_from_specs( + specs=filtered_specs, + start=start, + end=end, + timeframe=config.timeframe, + max_staleness_days=config.max_staleness_days, + ) + + earliest_ts = pd.Timestamp(bundle.prices[BTC_SYMBOL]["timestamp"].iloc[0]) + trade_start = min(latest_bar, earliest_ts + pd.Timedelta(days=config.warmup_days)) + backtester = Strategy32Backtester(strategy_config, bundle, trade_start=trade_start) + backtester.engine_config.initial_capital = config.paper_capital_usd + core_result = backtester.run(close_final_positions=False) + final_positions = list(core_result.metadata.get("final_positions", [])) + exposure_rows = list(core_result.metadata.get("exposure_rows", [])) + latest_exposure = exposure_rows[-1] if exposure_rows else {} + + raw_core_targets = _expand_core_targets(final_positions, core_result.final_equity) + core_targets = _apply_weekly_macro_filter(raw_core_targets, macro_state=macro_state) + strategic_frame = build_strategic_regime_frame(bundle, trade_start, latest_bar, profile=BEST_CASH_OVERLAY.regime_profile) + strategic_row = strategic_frame.iloc[-1].to_dict() + score_frame = build_regime_score_frame(bundle, trade_start, latest_bar, profile_name=BEST_CASH_OVERLAY.regime_profile) + score_row = score_frame.iloc[-1].to_dict() + overlay_targets, overlay_meta = _overlay_targets( + bundle=bundle, + latest_bar=latest_bar, + score_row=score_row, + core_cash_pct=float(latest_exposure.get("cash_pct", 1.0) or 1.0), + equity=core_result.final_equity, + candidate=BEST_CASH_OVERLAY, + ) + combined_targets = _combine_targets(core_targets, overlay_targets, core_result.final_equity) + execution_prices, execution_latest_bar, execution_accepted, execution_rejected, _ = build_strategy32_price_frames_from_specs( + specs=filtered_specs, + start=start, + end=end, + timeframe=config.execution_refinement_timeframe, + max_staleness_days=config.max_staleness_days, + ) + execution_refinement = _execution_refinement_states( + execution_prices, + timestamp=execution_latest_bar, + config=config, + ) + execution_targets = _refine_execution_targets( + combined_targets, + refinement_states=execution_refinement, + ) + + snapshot = { + "generated_at": str(_now_utc()), + "latest_bar": str(latest_bar), + "trade_start": str(trade_start), + "timeframe": config.timeframe, + "lookback_days": config.lookback_days, + "paper_capital_usd": config.paper_capital_usd, + "schedule": { + "macro_filter_refresh": f"on new completed {config.macro_filter_timeframe} bar", + "regime_refresh": f"on new completed {config.timeframe} bar", + "ticker_hard_filter_refresh": f"on new completed {config.hard_filter_timeframe} bar", + "ticker_ranking_refresh": f"on new completed {config.timeframe} bar", + "execution_refinement_refresh": f"on new completed {config.execution_refinement_timeframe} bar", + "execution_reconcile_seconds": config.poll_seconds, + "heartbeat_minutes": [0, 30], + }, + "strategy": { + "profile": PROFILE_V7_DEFAULT, + "core_filter": LIVE_STATIC_FILTER_NAME, + "core_filter_overrides": {key: LIVE_STRATEGY_OVERRIDES[key] for key in sorted(LIVE_STRATEGY_OVERRIDES)}, + "cash_overlay_candidate": asdict(BEST_CASH_OVERLAY), + }, + "regime": { + "strategic_regime": strategic_row.get("strategic_regime"), + "btc_regime": latest_exposure.get("regime"), + "macro_risk_on": bool(macro_state.get("risk_on", True)), + "breadth": float(strategic_row.get("breadth", 0.0) or 0.0), + "mean_alt_funding": float(strategic_row.get("mean_alt_funding", 0.0) or 0.0), + "btc_7d_return": float(strategic_row.get("btc_7d_return", 0.0) or 0.0), + }, + "score_row": { + "core_score": float(score_row.get("core_score", 0.0) or 0.0), + "panic_score": float(score_row.get("panic_score", 0.0) or 0.0), + "choppy_score": float(score_row.get("choppy_score", 0.0) or 0.0), + "distribution_score": float(score_row.get("distribution_score", 0.0) or 0.0), + }, + "universe": { + "discovered_specs": int(hard_filter_state.get("discovered_specs", len(selected_symbols))), + "discovered_symbols": hard_filter_state.get("discovered_symbols", []), + "macro_filter": macro_state, + "hard_filter_timeframe": config.hard_filter_timeframe, + "hard_filter_latest_bar": str(hard_filter_state.get("hard_filter_latest_bar", "")), + "hard_filter_candidates": hard_filter_state.get("hard_filter_candidates", []), + "hard_filter_rejected_symbols": hard_filter_state.get("hard_filter_rejected_symbols", []), + "hard_filter_symbols": hard_filter_state.get("hard_filter_symbols", []), + "hard_filter_spec_count": len(filtered_specs), + "accepted_symbols": accepted, + "rejected_symbols": rejected, + "quote_by_symbol": quote_by_symbol, + }, + "core": { + "filter": LIVE_STATIC_FILTER_NAME, + "final_equity": core_result.final_equity, + "cash_pct": float(latest_exposure.get("cash_pct", 1.0) or 1.0), + "momentum_pct": float(latest_exposure.get("momentum_pct", 0.0) or 0.0), + "carry_pct": float(latest_exposure.get("carry_pct", 0.0) or 0.0), + "sideways_pct": float(latest_exposure.get("sideways_pct", 0.0) or 0.0), + "raw_final_positions": [_position_to_serializable(pos) for pos in final_positions], + "raw_expanded_targets": raw_core_targets, + "expanded_targets": core_targets, + }, + "overlay": { + "candidate": asdict(BEST_CASH_OVERLAY), + "meta": overlay_meta, + "targets": overlay_targets, + }, + "execution_refinement": { + "timeframe": config.execution_refinement_timeframe, + "latest_bar": str(execution_latest_bar), + "accepted_symbols": execution_accepted, + "rejected_symbols": execution_rejected, + "states": execution_refinement, + }, + "combined_targets": combined_targets, + "execution_targets": execution_targets, + } + account = _account_snapshot() if config.include_account_snapshot else None + if account is not None: + snapshot["account"] = account + snapshot["capital"] = _capital_summary(account) + snapshot["snapshot_hash"] = _snapshot_hash(snapshot) + return snapshot + + +def _telegram_summary(snapshot: dict[str, Any]) -> str: + capital = snapshot.get("capital", {}) if isinstance(snapshot.get("capital"), dict) else {} + targets = _format_execution_targets(snapshot, limit=6) + accepted = _format_symbol_list(snapshot.get("universe", {}).get("accepted_symbols", []), limit=8) + return ( + "STRATEGY32 UPDATE\n" + f"Bar: {snapshot['latest_bar']}\n" + f"Regime: {'RISK_ON' if snapshot['regime']['macro_risk_on'] else 'RISK_OFF'}" + f" | strategic={snapshot['regime']['strategic_regime']}" + f" | btc={snapshot['regime']['btc_regime']}\n" + f"Capital: USDT {float(capital.get('usdt', 0.0)):.2f}" + f" | USDC {float(capital.get('usdc', 0.0)):.2f}" + f" | Total {float(capital.get('total_quote', 0.0)):.2f}\n" + f"Universe: {accepted}\n" + f"Core cash: {float(snapshot['core']['cash_pct']) * 100:.2f}%" + f" | panic={float(snapshot['overlay']['meta']['panic_score']):.2f}" + f" | choppy={float(snapshot['overlay']['meta']['choppy_score']):.2f}" + f" | dist={float(snapshot['overlay']['meta']['distribution_score']):.2f}\n" + "Execution targets:\n" + f"{targets}" + ) + + +def _heartbeat_summary(snapshot: dict[str, Any], state: dict[str, Any]) -> str: + account = snapshot.get("account", {}) if isinstance(snapshot.get("account"), dict) else {} + capital = snapshot.get("capital", {}) if isinstance(snapshot.get("capital"), dict) else {} + active_positions = account.get("active_positions", []) if isinstance(account, dict) else [] + available_symbols = _format_symbol_list(snapshot.get("universe", {}).get("accepted_symbols", []), limit=10) + hard_filter_symbols = _format_symbol_list(snapshot.get("universe", {}).get("hard_filter_symbols", []), limit=10) + open_positions = _format_open_positions(active_positions, limit=6) + targets = _format_execution_targets(snapshot, limit=6) + return ( + "STRATEGY32 HEARTBEAT\n" + f"Time: {_now_utc()}\n" + f"Bar: {snapshot['latest_bar']}\n" + f"Regime: {'RISK_ON' if snapshot['regime']['macro_risk_on'] else 'RISK_OFF'}" + f" | strategic={snapshot['regime']['strategic_regime']}" + f" | btc={snapshot['regime']['btc_regime']}\n" + f"Scores: core={float(snapshot['score_row']['core_score']):.2f}" + f" | panic={float(snapshot['score_row']['panic_score']):.2f}" + f" | choppy={float(snapshot['score_row']['choppy_score']):.2f}" + f" | dist={float(snapshot['score_row']['distribution_score']):.2f}\n" + f"Capital: USDT {float(capital.get('usdt', 0.0)):.2f}" + f" | USDC {float(capital.get('usdc', 0.0)):.2f}" + f" | Total {float(capital.get('total_quote', 0.0)):.2f}\n" + f"Schedule: macro={snapshot['schedule']['macro_filter_refresh']}" + f" | hard={snapshot['schedule']['ticker_hard_filter_refresh']}" + f" | rank={snapshot['schedule']['ticker_ranking_refresh']}" + f" | exec={snapshot['schedule']['execution_refinement_refresh']}\n" + f"Universe: hard={hard_filter_symbols}\n" + f"Available: {available_symbols}\n" + "Execution targets:\n" + f"{targets}\n" + f"Open positions:\n{open_positions}\n" + f"Orders last cycle: {len(state.get('last_execution_orders', []))}" + ) + + +def _format_symbol_list(symbols: list[str], *, limit: int) -> str: + values = [str(symbol) for symbol in symbols if str(symbol)] + if not values: + return "none" + head = values[:limit] + suffix = f" (+{len(values) - limit} more)" if len(values) > limit else "" + return ", ".join(head) + suffix + + +def _format_execution_targets(snapshot: dict[str, Any], *, limit: int) -> str: + rows = list(snapshot.get("execution_targets", []) or []) + if not rows: + return "- none" + lines: list[str] = [] + for row in rows[:limit]: + instrument = str(row.get("instrument", "")) + symbol = instrument.split(":", 1)[1] if ":" in instrument else instrument + weight = float(row.get("weight", 0.0) or 0.0) * 100.0 + desired_weight = float(row.get("desired_weight", row.get("weight", 0.0)) or 0.0) * 100.0 + notional = float(row.get("notional_usd", 0.0) or 0.0) + action = str(row.get("refinement_action", "allow")) + reason = str(row.get("refinement_reason", "")).strip() + if abs(weight - desired_weight) > 1e-6: + lines.append( + f"- {symbol}: {weight:+.2f}% (desired {desired_weight:+.2f}%, {notional:+.2f} USD, {action}, {reason})" + ) + else: + lines.append(f"- {symbol}: {weight:+.2f}% ({notional:+.2f} USD, {action})") + if len(rows) > limit: + lines.append(f"- ... +{len(rows) - limit} more") + return "\n".join(lines) + + +def _format_open_positions(active_positions: list[dict[str, Any]], *, limit: int) -> str: + if not active_positions: + return "- none" + lines: list[str] = [] + for row in active_positions[:limit]: + symbol = str(row.get("symbol", "")) + qty = float(row.get("position_amt", 0.0) or 0.0) + notional = float(row.get("notional", 0.0) or 0.0) + unrealized = float(row.get("unrealized_pnl", 0.0) or 0.0) + lines.append(f"- {symbol}: qty={qty:+.6f}, notional={notional:+.2f}, upnl={unrealized:+.2f}") + if len(active_positions) > limit: + lines.append(f"- ... +{len(active_positions) - limit} more") + return "\n".join(lines) + + +def _read_latest_snapshot(path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + + +async def run_monitor(*, once: bool = False, runtime_dir: str | None = None) -> None: + config = _load_monitor_config(runtime_dir) + paths = _runtime_paths(config.runtime_dir) + notifier = Notifier.from_env() + await notifier.start() + state = _read_state(paths["state_json"]) + snapshot = _read_latest_snapshot(paths["latest_json"]) + client = _execution_client_from_env() + executor = LiveFuturesExecutor(client, config.execution) if client is not None else None + strategy_signature = _live_strategy_signature() + try: + while True: + try: + now = _now_utc() + refresh_bar = str(_completed_bar_time(now, config.execution_refinement_timeframe)) + hard_filter_refresh_bar = str(_completed_bar_time(now, config.hard_filter_timeframe)) + macro_refresh_bar = str(_completed_bar_time(now, config.macro_filter_timeframe)) + state_signature = str(state.get("strategy_signature", "")) + hard_filter_state = state.get("hard_filter_state") + macro_state = state.get("macro_state") + state_changed = False + if ( + not isinstance(hard_filter_state, dict) + or hard_filter_refresh_bar != str(state.get("last_hard_filter_refresh_bar", "")) + or strategy_signature != state_signature + ): + hard_filter_state = await asyncio.to_thread( + _build_daily_hard_filter_state, + config, + _build_live_strategy_config(), + end=now, + ) + state["hard_filter_state"] = hard_filter_state + state["last_hard_filter_refresh_bar"] = hard_filter_refresh_bar + state_changed = True + if ( + not isinstance(macro_state, dict) + or macro_refresh_bar != str(state.get("last_macro_refresh_bar", "")) + or strategy_signature != state_signature + ): + macro_state = await asyncio.to_thread( + _build_cached_macro_state, + config, + _build_live_strategy_config(), + end=now, + ) + state["macro_state"] = macro_state + state["last_macro_refresh_bar"] = macro_refresh_bar + state_changed = True + if state_changed: + state["strategy_signature"] = strategy_signature + _write_json(paths["state_json"], state) + if ( + snapshot is None + or refresh_bar != str(state.get("last_snapshot_refresh_bar", "")) + or strategy_signature != state_signature + ): + snapshot = await asyncio.to_thread( + build_monitor_snapshot, + config, + hard_filter_state=hard_filter_state, + macro_state=macro_state, + ) + _write_json(paths["latest_json"], snapshot) + _append_jsonl(paths["snapshots_jsonl"], {"event": "snapshot", **snapshot}) + LOGGER.info( + "snapshot bar=%s strategic=%s targets=%d", + snapshot["latest_bar"], + snapshot["regime"]["strategic_regime"], + len(snapshot["combined_targets"]), + ) + state["last_snapshot_refresh_bar"] = refresh_bar + state["last_completed_bar"] = str(snapshot["latest_bar"]) + state["strategy_signature"] = strategy_signature + if snapshot["snapshot_hash"] != state.get("last_snapshot_hash"): + await notifier.send("INFO", _telegram_summary(snapshot)) + state["last_snapshot_hash"] = snapshot["snapshot_hash"] + state["last_latest_bar"] = snapshot["latest_bar"] + _write_json(paths["state_json"], state) + + if snapshot is not None and executor is not None: + execution_result = await asyncio.to_thread(executor.reconcile, snapshot) + if execution_result.orders: + if config.include_account_snapshot: + live_account = await asyncio.to_thread(_account_snapshot) + if live_account is not None: + snapshot["account"] = live_account + snapshot["capital"] = _capital_summary(live_account) + _write_json(paths["latest_json"], snapshot) + payload = {"event": "execution", **asdict(execution_result)} + _append_jsonl(paths["orders_jsonl"], payload) + state["last_execution_orders"] = execution_result.orders[-10:] + state["last_execution_at"] = execution_result.executed_at + _write_json(paths["state_json"], state) + order_lines = [ + f"{row['symbol']} {row['side']} qty={row['quantity']:.6f}" + for row in execution_result.orders[:8] + ] + capital = snapshot.get("capital", {}) if isinstance(snapshot.get("capital"), dict) else {} + await notifier.send( + "WARNING", + "strategy32 orders\n" + + "\n".join(order_lines) + + "\n" + + ( + f"capital usdt={float(capital.get('usdt', 0.0)):.2f} " + f"usdc={float(capital.get('usdc', 0.0)):.2f} " + f"total={float(capital.get('total_quote', 0.0)):.2f}" + ), + ) + + heartbeat_slot = list(_heartbeat_slot(now)) + if snapshot is not None and heartbeat_slot != state.get("last_heartbeat_slot"): + if config.include_account_snapshot: + live_account = await asyncio.to_thread(_account_snapshot) + if live_account is not None: + snapshot["account"] = live_account + snapshot["capital"] = _capital_summary(live_account) + _write_json(paths["latest_json"], snapshot) + state["last_heartbeat_slot"] = heartbeat_slot + _write_json(paths["state_json"], state) + await notifier.send("INFO", _heartbeat_summary(snapshot, state)) + except Exception as exc: + LOGGER.exception("monitor cycle failed") + error_payload = { + "event": "error", + "generated_at": str(_now_utc()), + "error": str(exc), + } + _append_jsonl(paths["snapshots_jsonl"], error_payload) + await notifier.send("CRITICAL", f"strategy32 monitor failed: {exc}") + if once: + break + await asyncio.sleep(config.poll_seconds) + finally: + await notifier.stop() diff --git a/research/adverse_regime.py b/research/adverse_regime.py new file mode 100644 index 0000000..432b792 --- /dev/null +++ b/research/adverse_regime.py @@ -0,0 +1,693 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +import pickle +from pathlib import Path + +import pandas as pd + +from strategy29.backtest.metrics import cagr, max_drawdown, sharpe_ratio +from strategy29.common.constants import BTC_SYMBOL +from strategy29.common.models import MarketDataBundle +from strategy29.data.universe import select_tradeable_universe +from strategy32.scripts.run_regime_filter_analysis import build_strategic_regime_frame + + +@dataclass(slots=True) +class AdverseRegimeEngineSpec: + name: str + target_regime: str + family: str + min_avg_dollar_volume: float = 50_000_000.0 + rebalance_bars: int = 6 + top_n: int = 2 + transaction_cost_pct: float = 0.0015 + + +@dataclass(slots=True) +class AdverseRegimeEngineResult: + name: str + target_regime: str + family: str + total_return: float + cagr: float + sharpe: float + max_drawdown: float + active_bar_ratio: float + rebalance_count: int + equity_curve: pd.Series + + def to_payload(self) -> dict[str, object]: + return { + "name": self.name, + "target_regime": self.target_regime, + "family": self.family, + "total_return": self.total_return, + "cagr": self.cagr, + "sharpe": self.sharpe, + "max_drawdown": self.max_drawdown, + "active_bar_ratio": self.active_bar_ratio, + "rebalance_count": self.rebalance_count, + } + + +def default_engine_specs() -> list[AdverseRegimeEngineSpec]: + return [ + AdverseRegimeEngineSpec("cap_cash", "CAPITULATION_STRESS", "cash", rebalance_bars=1), + AdverseRegimeEngineSpec("cap_btc_rebound", "CAPITULATION_STRESS", "btc_rebound", rebalance_bars=1), + AdverseRegimeEngineSpec("cap_alt_panic_rebound", "CAPITULATION_STRESS", "alt_panic_rebound", rebalance_bars=1), + AdverseRegimeEngineSpec("cap_funding_snapback_hedged", "CAPITULATION_STRESS", "funding_snapback_hedged", rebalance_bars=1), + AdverseRegimeEngineSpec("chop_cash", "CHOPPY_ROTATION", "cash", rebalance_bars=1), + AdverseRegimeEngineSpec("chop_pairs_mean_revert", "CHOPPY_ROTATION", "pairs_mean_revert", rebalance_bars=3), + AdverseRegimeEngineSpec("chop_quality_rotation", "CHOPPY_ROTATION", "quality_rotation", rebalance_bars=3), + AdverseRegimeEngineSpec("chop_carry_only", "CHOPPY_ROTATION", "carry_only", rebalance_bars=6), + AdverseRegimeEngineSpec("chop_rs_spread", "CHOPPY_ROTATION", "rs_spread", rebalance_bars=6), + AdverseRegimeEngineSpec("chop_btc_hedged_leader", "CHOPPY_ROTATION", "btc_hedged_leader", rebalance_bars=6, top_n=1), + AdverseRegimeEngineSpec("chop_carry_strict", "CHOPPY_ROTATION", "carry_only_strict", rebalance_bars=6, top_n=1), + AdverseRegimeEngineSpec("chop_inverse_carry", "CHOPPY_ROTATION", "inverse_carry", rebalance_bars=6, top_n=1), + AdverseRegimeEngineSpec("chop_inverse_carry_strict", "CHOPPY_ROTATION", "inverse_carry_strict", rebalance_bars=6, top_n=1), + AdverseRegimeEngineSpec("dist_cash", "DISTRIBUTION_DRIFT", "cash", rebalance_bars=1), + AdverseRegimeEngineSpec("dist_btc_vs_weak_alt", "DISTRIBUTION_DRIFT", "btc_vs_weak_alt", rebalance_bars=3), + AdverseRegimeEngineSpec("dist_short_rally", "DISTRIBUTION_DRIFT", "short_rally", rebalance_bars=1), + AdverseRegimeEngineSpec("dist_weak_basket_short", "DISTRIBUTION_DRIFT", "weak_basket_short", rebalance_bars=3), + AdverseRegimeEngineSpec("dist_relative_weakness_spread", "DISTRIBUTION_DRIFT", "relative_weakness_spread", rebalance_bars=6), + AdverseRegimeEngineSpec("dist_btc_rally_short", "DISTRIBUTION_DRIFT", "btc_rally_short", rebalance_bars=1, top_n=1), + AdverseRegimeEngineSpec("dist_btc_rally_short_strict", "DISTRIBUTION_DRIFT", "btc_rally_short_strict", rebalance_bars=1, top_n=1), + AdverseRegimeEngineSpec("dist_weak_rally_spread", "DISTRIBUTION_DRIFT", "weak_rally_spread", rebalance_bars=3), + AdverseRegimeEngineSpec("dist_inverse_carry", "DISTRIBUTION_DRIFT", "inverse_carry", rebalance_bars=6, top_n=1), + AdverseRegimeEngineSpec("dist_inverse_carry_strict", "DISTRIBUTION_DRIFT", "inverse_carry_strict", rebalance_bars=6, top_n=1), + ] + + +def load_fixed66_cache(path: str | Path) -> tuple[MarketDataBundle, pd.Timestamp, list[str]]: + payload = pickle.loads(Path(path).read_bytes()) + return payload["bundle"], payload["latest_bar"], list(payload["accepted_symbols"]) + + +class AdverseRegimeResearchHarness: + def __init__(self, bundle: MarketDataBundle, latest_bar: pd.Timestamp): + self.bundle = bundle + self.latest_bar = pd.Timestamp(latest_bar) + self.timestamps = sorted(bundle.prices[BTC_SYMBOL]["timestamp"].tolist()) + self._regime_frame_cache: dict[pd.Timestamp, pd.DataFrame] = {} + self.price_frames = { + symbol: df.set_index("timestamp")[["close", "volume"]].sort_index() + for symbol, df in bundle.prices.items() + } + self.funding_frames = { + symbol: df.set_index("timestamp")[["funding_rate", "basis"]].sort_index() + for symbol, df in bundle.funding.items() + } + + def build_regime_frame(self, eval_start: pd.Timestamp) -> pd.DataFrame: + eval_start = pd.Timestamp(eval_start) + cached = self._regime_frame_cache.get(eval_start) + if cached is not None: + return cached + raw_start = eval_start - pd.Timedelta(days=90) + sliced = MarketDataBundle( + prices={symbol: df.loc[df["timestamp"] >= raw_start].copy() for symbol, df in self.bundle.prices.items()}, + funding={symbol: df.loc[df["timestamp"] >= raw_start].copy() for symbol, df in self.bundle.funding.items()}, + ) + frame = build_strategic_regime_frame(sliced, eval_start, self.latest_bar) + self._regime_frame_cache[eval_start] = frame + return frame + + def run_engine( + self, + spec: AdverseRegimeEngineSpec, + *, + eval_start: pd.Timestamp, + initial_capital: float = 1000.0, + regime_frame: pd.DataFrame | None = None, + ) -> AdverseRegimeEngineResult: + regime_frame = self.build_regime_frame(eval_start) if regime_frame is None else regime_frame + regime_map = dict(zip(pd.to_datetime(regime_frame["timestamp"]), regime_frame["strategic_regime"])) + timestamps = [ts for ts in self.timestamps if ts >= eval_start] + if len(timestamps) < 3: + raise ValueError("not enough timestamps for adverse regime simulation") + + equity = initial_capital + equity_points = [pd.Timestamp(timestamps[0])] + equity_values = [equity] + current_weights: dict[str, float] = {} + rebalance_count = 0 + active_bars = 0 + + for i in range(1, len(timestamps)): + signal_ts = timestamps[i - 1] + execution_ts = timestamps[i] + + if current_weights: + bar_ret = self._portfolio_return(current_weights, signal_ts, execution_ts) + equity *= max(0.0, 1.0 + bar_ret) + + target_weights = current_weights + regime_name = regime_map.get(signal_ts, "") + if regime_name != spec.target_regime: + target_weights = {} + elif (i - 1) % spec.rebalance_bars == 0: + target_weights = self._target_weights(spec, signal_ts) + if target_weights: + active_bars += 1 + + turnover = self._turnover(current_weights, target_weights) + if turnover > 0: + rebalance_count += 1 + equity *= max(0.0, 1.0 - turnover * spec.transaction_cost_pct) + current_weights = target_weights + + equity_points.append(pd.Timestamp(execution_ts)) + equity_values.append(equity) + + equity_curve = pd.Series(equity_values, index=pd.Index(equity_points, name="timestamp"), dtype=float) + return AdverseRegimeEngineResult( + name=spec.name, + target_regime=spec.target_regime, + family=spec.family, + total_return=equity_curve.iloc[-1] / equity_curve.iloc[0] - 1.0, + cagr=cagr(equity_curve), + sharpe=sharpe_ratio(equity_curve, 6), + max_drawdown=max_drawdown(equity_curve), + active_bar_ratio=active_bars / max(len(timestamps) - 1, 1), + rebalance_count=rebalance_count, + equity_curve=equity_curve, + ) + + def target_weights(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + return self._target_weights(spec, pd.Timestamp(timestamp)) + + def _target_weights(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + if spec.family == "cash": + return {} + if spec.family == "btc_rebound": + return self._cap_btc_rebound(timestamp) + if spec.family == "alt_panic_rebound": + return self._cap_alt_panic_rebound(spec, timestamp) + if spec.family == "funding_snapback_hedged": + return self._cap_funding_snapback_hedged(spec, timestamp) + if spec.family == "pairs_mean_revert": + return self._chop_pairs_mean_revert(spec, timestamp) + if spec.family == "quality_rotation": + return self._chop_quality_rotation(spec, timestamp) + if spec.family == "carry_only": + return self._carry_only(spec, timestamp) + if spec.family == "rs_spread": + return self._chop_rs_spread(spec, timestamp) + if spec.family == "btc_hedged_leader": + return self._chop_btc_hedged_leader(spec, timestamp) + if spec.family == "carry_only_strict": + return self._carry_only_strict(spec, timestamp) + if spec.family == "inverse_carry": + return self._inverse_carry(spec, timestamp, strict=False) + if spec.family == "inverse_carry_strict": + return self._inverse_carry(spec, timestamp, strict=True) + if spec.family == "btc_vs_weak_alt": + return self._dist_btc_vs_weak_alt(spec, timestamp) + if spec.family == "short_rally": + return self._dist_short_rally(spec, timestamp) + if spec.family == "weak_basket_short": + return self._dist_weak_basket_short(spec, timestamp) + if spec.family == "relative_weakness_spread": + return self._dist_relative_weakness_spread(spec, timestamp) + if spec.family == "btc_rally_short": + return self._dist_btc_rally_short(timestamp) + if spec.family == "btc_rally_short_strict": + return self._dist_btc_rally_short_strict(timestamp) + if spec.family == "weak_rally_spread": + return self._dist_weak_rally_spread(spec, timestamp) + raise ValueError(f"unsupported family: {spec.family}") + + def _cap_btc_rebound(self, timestamp: pd.Timestamp) -> dict[str, float]: + hist = self._price_hist(BTC_SYMBOL, timestamp, 24) + if len(hist) < 19: + return {} + ret_3d = self._return_from_hist(hist, 18) + ret_1b = self._return_from_hist(hist, 1) + if ret_3d > -0.10 or ret_1b <= 0.0: + return {} + return {BTC_SYMBOL: 1.0} + + def _cap_alt_panic_rebound(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + candidates: list[tuple[float, str]] = [] + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + hist = self._price_hist(symbol, timestamp, 24) + if len(hist) < 19: + continue + ret_3d = self._return_from_hist(hist, 18) + ret_1b = self._return_from_hist(hist, 1) + funding = self._latest_funding(symbol, timestamp) + if ret_3d > -0.12 or ret_1b <= 0.0: + continue + score = (-ret_3d) + max(-funding, 0.0) * 200.0 + ret_1b * 2.0 + candidates.append((score, symbol)) + candidates.sort(reverse=True) + symbols = [symbol for _, symbol in candidates[: spec.top_n]] + return self._equal_weight(symbols, 1.0) + + def _cap_funding_snapback_hedged(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + candidates: list[tuple[float, str]] = [] + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + hist = self._price_hist(symbol, timestamp, 24) + if len(hist) < 19: + continue + ret_3d = self._return_from_hist(hist, 18) + ret_1b = self._return_from_hist(hist, 1) + funding = self._latest_funding(symbol, timestamp) + if funding >= 0.0 or ret_3d > -0.08 or ret_1b <= 0.0: + continue + score = max(-funding, 0.0) * 260.0 + (-ret_3d) * 0.6 + ret_1b + candidates.append((score, symbol)) + candidates.sort(reverse=True) + symbols = [symbol for _, symbol in candidates[: spec.top_n]] + if not symbols: + return {} + weights = self._equal_weight(symbols, 0.70) + weights[BTC_SYMBOL] = weights.get(BTC_SYMBOL, 0.0) - 0.30 + return weights + + def _chop_pairs_mean_revert(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + rows: list[tuple[float, float, str]] = [] + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + hist = self._price_hist(symbol, timestamp, 18) + if len(hist) < 7: + continue + ret_1d = self._return_from_hist(hist, 6) + vol = float(hist["close"].pct_change().dropna().tail(12).std(ddof=0)) + if vol <= 0 or vol > 0.08: + continue + rows.append((ret_1d, vol, symbol)) + if len(rows) < spec.top_n * 2: + return {} + rows.sort(key=lambda row: row[0]) + longs = [symbol for _, _, symbol in rows[: spec.top_n]] + shorts = [symbol for _, _, symbol in rows[-spec.top_n :]] + return self._long_short_weights(longs, shorts) + + def _chop_quality_rotation(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + long_rows: list[tuple[float, str]] = [] + short_rows: list[tuple[float, str]] = [] + btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48) + if len(btc_hist) < 43: + return {} + btc_ret_7d = self._return_from_hist(btc_hist, 42) + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + hist = self._price_hist(symbol, timestamp, 48) + if len(hist) < 43: + continue + ret_7d = self._return_from_hist(hist, 42) + ret_1d = self._return_from_hist(hist, 6) + rs_7d = ret_7d - btc_ret_7d + long_rows.append((rs_7d - ret_1d, symbol)) + short_rows.append((-rs_7d + ret_1d, symbol)) + long_rows.sort(reverse=True) + short_rows.sort(reverse=True) + longs = [symbol for _, symbol in long_rows[: spec.top_n]] + shorts = [symbol for _, symbol in short_rows[: spec.top_n]] + shorts = [symbol for symbol in shorts if symbol not in longs] + return self._long_short_weights(longs, shorts[: spec.top_n]) + + def _carry_only(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + candidates: list[tuple[float, str]] = [] + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + if symbol not in self.funding_frames: + continue + f_hist = self.funding_frames[symbol].loc[:timestamp].tail(21) + if len(f_hist) < 21: + continue + mean_funding = float(f_hist["funding_rate"].mean()) + basis_vol = float(f_hist["basis"].std(ddof=0)) + latest_basis = float(f_hist["basis"].iloc[-1]) + expected_edge = mean_funding * 18 + max(latest_basis, 0.0) * 0.35 - 0.0030 - basis_vol * 1.5 + if expected_edge <= 0: + continue + candidates.append((expected_edge, symbol)) + candidates.sort(reverse=True) + symbols = [symbol for _, symbol in candidates[: spec.top_n]] + if not symbols: + return {} + weight = 1.0 / len(symbols) + return {f"carry:{symbol}": weight for symbol in symbols} + + def _carry_only_strict(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + candidates: list[tuple[float, str]] = [] + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + if symbol not in self.funding_frames: + continue + f_hist = self.funding_frames[symbol].loc[:timestamp].tail(21) + if len(f_hist) < 21: + continue + mean_funding = float(f_hist["funding_rate"].mean()) + basis_vol = float(f_hist["basis"].std(ddof=0)) + latest_basis = float(f_hist["basis"].iloc[-1]) + positive_ratio = float((f_hist["funding_rate"] > 0).mean()) + expected_edge = mean_funding * 18 + max(latest_basis, 0.0) * 0.35 - 0.0030 - basis_vol * 1.5 + if expected_edge <= 0.004 or positive_ratio < 0.75: + continue + candidates.append((expected_edge, symbol)) + candidates.sort(reverse=True) + symbols = [symbol for _, symbol in candidates[: spec.top_n]] + if not symbols: + return {} + return {f"carry:{symbol}": 1.0 / len(symbols) for symbol in symbols} + + def _inverse_carry(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp, *, strict: bool) -> dict[str, float]: + candidates: list[tuple[float, str]] = [] + min_edge = 0.004 if strict else 0.001 + min_negative_ratio = 0.75 if strict else 0.60 + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + if symbol not in self.funding_frames: + continue + f_hist = self.funding_frames[symbol].loc[:timestamp].tail(21) + if len(f_hist) < 21: + continue + mean_funding = float(f_hist["funding_rate"].mean()) + negative_ratio = float((f_hist["funding_rate"] < 0).mean()) + basis_vol = float(f_hist["basis"].std(ddof=0)) + latest_basis = float(f_hist["basis"].iloc[-1]) + expected_edge = (-mean_funding) * 18 + max(-latest_basis, 0.0) * 0.35 - 0.0030 - basis_vol * 1.5 + if mean_funding >= 0 or negative_ratio < min_negative_ratio or expected_edge <= min_edge: + continue + candidates.append((expected_edge, symbol)) + candidates.sort(reverse=True) + symbols = [symbol for _, symbol in candidates[: spec.top_n]] + if not symbols: + return {} + return {f"inverse_carry:{symbol}": 1.0 / len(symbols) for symbol in symbols} + + def _chop_rs_spread(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + rows: list[tuple[float, str]] = [] + btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48) + if len(btc_hist) < 43: + return {} + btc_ret_7d = self._return_from_hist(btc_hist, 42) + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + hist = self._price_hist(symbol, timestamp, 48) + if len(hist) < 43: + continue + ret_7d = self._return_from_hist(hist, 42) + ret_1d = self._return_from_hist(hist, 6) + vol = float(hist["close"].pct_change().dropna().tail(12).std(ddof=0)) + if vol <= 0 or vol > 0.10: + continue + rows.append((ret_7d - btc_ret_7d - abs(ret_1d) * 0.25, symbol)) + if len(rows) < spec.top_n * 2: + return {} + rows.sort(reverse=True) + longs = [symbol for _, symbol in rows[: spec.top_n]] + shorts = [symbol for _, symbol in rows[-spec.top_n :]] + return self._long_short_weights(longs, shorts) + + def _chop_btc_hedged_leader(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48) + if len(btc_hist) < 43: + return {} + btc_ret_7d = self._return_from_hist(btc_hist, 42) + rows: list[tuple[float, str]] = [] + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + hist = self._price_hist(symbol, timestamp, 48) + if len(hist) < 43: + continue + ret_7d = self._return_from_hist(hist, 42) + ret_1d = self._return_from_hist(hist, 6) + funding = self._latest_funding(symbol, timestamp) + rows.append((ret_7d - btc_ret_7d - max(funding, 0.0) * 80.0 - abs(ret_1d) * 0.15, symbol)) + rows.sort(reverse=True) + if not rows or rows[0][0] <= 0: + return {} + leader = rows[0][1] + return {leader: 0.70, BTC_SYMBOL: -0.30} + + def _dist_btc_vs_weak_alt(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48) + if len(btc_hist) < 43: + return {} + btc_ret_7d = self._return_from_hist(btc_hist, 42) + weak_rows: list[tuple[float, str]] = [] + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + hist = self._price_hist(symbol, timestamp, 48) + if len(hist) < 43: + continue + ret_7d = self._return_from_hist(hist, 42) + ret_3d = self._return_from_hist(hist, 18) + weak_rows.append((ret_7d - btc_ret_7d + ret_3d, symbol)) + weak_rows.sort() + shorts = [symbol for _, symbol in weak_rows[: spec.top_n]] + if not shorts: + return {} + weights = {BTC_SYMBOL: 0.40} + short_weight = -0.60 / len(shorts) + for symbol in shorts: + weights[symbol] = short_weight + return weights + + def _dist_short_rally(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + rows: list[tuple[float, str]] = [] + btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48) + if len(btc_hist) < 43: + return {} + btc_ret_7d = self._return_from_hist(btc_hist, 42) + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + hist = self._price_hist(symbol, timestamp, 36) + if len(hist) < 25: + continue + ret_7d = self._return_from_hist(hist, 42 if len(hist) > 42 else len(hist) - 1) + ret_2b = self._return_from_hist(hist, 2) + ema20 = hist["close"].ewm(span=20, adjust=False).mean().iloc[-1] + close = float(hist["close"].iloc[-1]) + rs = ret_7d - btc_ret_7d + if rs >= -0.03 or ret_2b <= 0.0 or close >= float(ema20): + continue + score = -rs + ret_2b + rows.append((score, symbol)) + rows.sort(reverse=True) + shorts = [symbol for _, symbol in rows[: spec.top_n]] + return {symbol: -1.0 / len(shorts) for symbol in shorts} if shorts else {} + + def _dist_weak_basket_short(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48) + if len(btc_hist) < 43: + return {} + btc_ret_7d = self._return_from_hist(btc_hist, 42) + rows: list[tuple[float, str]] = [] + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + hist = self._price_hist(symbol, timestamp, 48) + if len(hist) < 43: + continue + ret_7d = self._return_from_hist(hist, 42) + ret_3d = self._return_from_hist(hist, 18) + rs = ret_7d - btc_ret_7d + rows.append((rs + ret_3d, symbol)) + rows.sort() + shorts = [symbol for _, symbol in rows[: spec.top_n]] + return {symbol: -1.0 / len(shorts) for symbol in shorts} if shorts else {} + + def _dist_relative_weakness_spread(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48) + if len(btc_hist) < 43: + return {} + btc_ret_7d = self._return_from_hist(btc_hist, 42) + rows: list[tuple[float, str]] = [] + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + hist = self._price_hist(symbol, timestamp, 48) + if len(hist) < 43: + continue + ret_7d = self._return_from_hist(hist, 42) + ret_3d = self._return_from_hist(hist, 18) + rows.append((ret_7d - btc_ret_7d + ret_3d * 0.25, symbol)) + if len(rows) < spec.top_n * 2: + return {} + rows.sort(reverse=True) + longs = [symbol for _, symbol in rows[: spec.top_n]] + shorts = [symbol for _, symbol in rows[-spec.top_n :]] + return self._long_short_weights(longs, shorts) + + def _dist_btc_rally_short(self, timestamp: pd.Timestamp) -> dict[str, float]: + hist = self._price_hist(BTC_SYMBOL, timestamp, 36) + if len(hist) < 21: + return {} + ret_2b = self._return_from_hist(hist, 2) + ema20 = hist["close"].ewm(span=20, adjust=False).mean().iloc[-1] + close = float(hist["close"].iloc[-1]) + if ret_2b <= 0.0 or close >= float(ema20): + return {} + return {BTC_SYMBOL: -1.0} + + def _dist_btc_rally_short_strict(self, timestamp: pd.Timestamp) -> dict[str, float]: + hist = self._price_hist(BTC_SYMBOL, timestamp, 72) + if len(hist) < 43: + return {} + ret_2b = self._return_from_hist(hist, 2) + ret_7d = self._return_from_hist(hist, 42) + ema20 = hist["close"].ewm(span=20, adjust=False).mean().iloc[-1] + ema50 = hist["close"].ewm(span=50, adjust=False).mean().iloc[-1] + close = float(hist["close"].iloc[-1]) + if ret_2b < 0.035 or ret_7d > -0.02: + return {} + if close >= float(ema20) or close >= float(ema50): + return {} + return {BTC_SYMBOL: -1.0} + + def _dist_weak_rally_spread(self, spec: AdverseRegimeEngineSpec, timestamp: pd.Timestamp) -> dict[str, float]: + btc_hist = self._price_hist(BTC_SYMBOL, timestamp, 48) + if len(btc_hist) < 43: + return {} + btc_ret_7d = self._return_from_hist(btc_hist, 42) + strong_rows: list[tuple[float, str]] = [] + weak_rows: list[tuple[float, str]] = [] + for symbol in self._liquid_symbols(timestamp, spec.min_avg_dollar_volume): + hist = self._price_hist(symbol, timestamp, 48) + if len(hist) < 43: + continue + ret_7d = self._return_from_hist(hist, 42) + ret_2b = self._return_from_hist(hist, 2) + rs = ret_7d - btc_ret_7d + strong_rows.append((rs - abs(ret_2b) * 0.1, symbol)) + if ret_2b > 0: + weak_rows.append((-rs + ret_2b, symbol)) + strong_rows.sort(reverse=True) + weak_rows.sort(reverse=True) + longs = [symbol for _, symbol in strong_rows[: spec.top_n]] + shorts = [symbol for _, symbol in weak_rows[: spec.top_n] if symbol not in longs] + return self._long_short_weights(longs, shorts[: spec.top_n]) + + def _liquid_symbols(self, timestamp: pd.Timestamp, min_avg_dollar_volume: float) -> list[str]: + return [ + symbol + for symbol in select_tradeable_universe( + self.bundle.prices, + timestamp, + min_history_bars=120, + min_avg_dollar_volume=min_avg_dollar_volume, + ) + if symbol != BTC_SYMBOL + ] + + def _price_hist(self, symbol: str, timestamp: pd.Timestamp, bars: int) -> pd.DataFrame: + return self.price_frames[symbol].loc[:timestamp].tail(bars).reset_index() + + def _return_from_hist(self, hist: pd.DataFrame, bars_back: int) -> float: + if hist.empty: + return 0.0 + back = min(bars_back, len(hist) - 1) + if back <= 0: + return 0.0 + prev_close = float(hist["close"].iloc[-(back + 1)]) + close = float(hist["close"].iloc[-1]) + if prev_close <= 0: + return 0.0 + return close / prev_close - 1.0 + + def _latest_funding(self, symbol: str, timestamp: pd.Timestamp) -> float: + if symbol not in self.funding_frames: + return 0.0 + hist = self.funding_frames[symbol].loc[:timestamp].tail(1) + if hist.empty: + return 0.0 + return float(hist["funding_rate"].iloc[-1]) + + def _portfolio_return(self, weights: dict[str, float], prev_ts: pd.Timestamp, ts: pd.Timestamp) -> float: + total = 0.0 + for symbol, weight in weights.items(): + if symbol.startswith("carry:"): + total += weight * self._carry_return(symbol.split(":", 1)[1], prev_ts, ts) + elif symbol.startswith("inverse_carry:"): + total += weight * self._inverse_carry_return(symbol.split(":", 1)[1], prev_ts, ts) + else: + total += weight * self._price_return(symbol, prev_ts, ts) + return total + + def _price_return(self, symbol: str, prev_ts: pd.Timestamp, ts: pd.Timestamp) -> float: + frame = self.price_frames[symbol] + if prev_ts not in frame.index or ts not in frame.index: + return 0.0 + prev_close = float(frame.loc[prev_ts, "close"]) + close = float(frame.loc[ts, "close"]) + if prev_close <= 0: + return 0.0 + return close / prev_close - 1.0 + + def _carry_return(self, symbol: str, prev_ts: pd.Timestamp, ts: pd.Timestamp) -> float: + if symbol not in self.funding_frames: + return 0.0 + frame = self.funding_frames[symbol] + if prev_ts not in frame.index or ts not in frame.index: + return 0.0 + funding_rate = float(frame.loc[ts, "funding_rate"]) + basis_change = float(frame.loc[ts, "basis"] - frame.loc[prev_ts, "basis"]) + return funding_rate - basis_change + + def _inverse_carry_return(self, symbol: str, prev_ts: pd.Timestamp, ts: pd.Timestamp) -> float: + if symbol not in self.funding_frames: + return 0.0 + frame = self.funding_frames[symbol] + if prev_ts not in frame.index or ts not in frame.index: + return 0.0 + funding_rate = float(frame.loc[ts, "funding_rate"]) + basis_change = float(frame.loc[ts, "basis"] - frame.loc[prev_ts, "basis"]) + return -funding_rate + basis_change + + @staticmethod + def _turnover(current: dict[str, float], target: dict[str, float]) -> float: + symbols = set(current) | set(target) + return sum(abs(target.get(symbol, 0.0) - current.get(symbol, 0.0)) for symbol in symbols) + + @staticmethod + def _equal_weight(symbols: list[str], gross: float) -> dict[str, float]: + if not symbols: + return {} + weight = gross / len(symbols) + return {symbol: weight for symbol in symbols} + + @staticmethod + def _long_short_weights(longs: list[str], shorts: list[str]) -> dict[str, float]: + weights: dict[str, float] = {} + if longs: + long_weight = 0.50 / len(longs) + for symbol in longs: + weights[symbol] = weights.get(symbol, 0.0) + long_weight + if shorts: + short_weight = -0.50 / len(shorts) + for symbol in shorts: + weights[symbol] = weights.get(symbol, 0.0) + short_weight + return {symbol: weight for symbol, weight in weights.items() if abs(weight) > 1e-9} + + +def run_adverse_regime_search( + *, + cache_path: str | Path, + eval_days: int = 1825, + initial_capital: float = 1000.0, +) -> dict[str, object]: + bundle, latest_bar, accepted_symbols = load_fixed66_cache(cache_path) + harness = AdverseRegimeResearchHarness(bundle, latest_bar) + eval_start = pd.Timestamp(latest_bar) - pd.Timedelta(days=eval_days) + + rows: list[dict[str, object]] = [] + by_regime: dict[str, list[dict[str, object]]] = {} + for spec in default_engine_specs(): + result = harness.run_engine(spec, eval_start=eval_start, initial_capital=initial_capital) + payload = result.to_payload() + print( + spec.target_regime, + spec.name, + f"ret={float(payload['total_return']) * 100:.2f}%", + f"sharpe={float(payload['sharpe']):.2f}", + f"mdd={float(payload['max_drawdown']) * 100:.2f}%", + flush=True, + ) + rows.append(payload) + by_regime.setdefault(spec.target_regime, []).append(payload) + + for regime_rows in by_regime.values(): + regime_rows.sort(key=lambda row: (float(row["total_return"]), float(row["sharpe"]), -abs(float(row["max_drawdown"]))), reverse=True) + + return { + "analysis": "adverse_regime_engine_search", + "latest_completed_bar": str(latest_bar), + "accepted_symbols": accepted_symbols, + "eval_days": eval_days, + "initial_capital": initial_capital, + "results": rows, + "by_regime": by_regime, + } diff --git a/research/hybrid_regime.py b/research/hybrid_regime.py new file mode 100644 index 0000000..fcac045 --- /dev/null +++ b/research/hybrid_regime.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +import pickle +from pathlib import Path + +import pandas as pd + +from strategy29.backtest.metrics import cagr, max_drawdown, sharpe_ratio +from strategy29.backtest.window_analysis import slice_bundle +from strategy29.common.models import AllocationDecision, BacktestResult, MarketDataBundle +from strategy32.backtest.simulator import Strategy32Backtester, Strategy32MomentumCarryBacktester, build_engine_config +from strategy32.config import PROFILE_V7_DEFAULT, build_strategy32_config +from strategy32.research.adverse_regime import AdverseRegimeResearchHarness, default_engine_specs +from strategy32.scripts.run_regime_filter_analysis import build_strategic_regime_frame + + +STATIC_FILTERS: dict[str, dict[str, float]] = { + "prev_balanced": { + "universe_min_avg_dollar_volume": 50_000_000.0, + "momentum_min_score": 0.60, + "momentum_min_relative_strength": 0.00, + "momentum_min_7d_return": 0.00, + "max_pairwise_correlation": 0.70, + "carry_min_expected_edge": 0.0, + }, + "guarded_positive": { + "universe_min_avg_dollar_volume": 50_000_000.0, + "momentum_min_score": 0.60, + "momentum_min_relative_strength": 0.00, + "momentum_min_7d_return": 0.00, + "momentum_max_7d_return": 0.35, + "momentum_min_positive_bar_ratio": 0.52, + "momentum_max_short_volatility": 0.075, + "momentum_max_beta": 2.50, + "momentum_max_latest_funding_rate": 0.00045, + "max_pairwise_correlation": 0.70, + "carry_min_expected_edge": 0.0, + }, + "overheat_tolerant": { + "universe_min_avg_dollar_volume": 100_000_000.0, + "momentum_min_score": 0.60, + "momentum_min_relative_strength": -0.02, + "momentum_min_7d_return": 0.02, + "max_pairwise_correlation": 0.78, + "carry_min_expected_edge": 0.0, + }, + "guarded_euphoria": { + "universe_min_avg_dollar_volume": 100_000_000.0, + "momentum_min_score": 0.62, + "momentum_min_relative_strength": -0.01, + "momentum_min_7d_return": 0.02, + "momentum_max_7d_return": 0.28, + "momentum_min_positive_bar_ratio": 0.55, + "momentum_max_short_volatility": 0.070, + "momentum_max_beta": 2.20, + "momentum_max_latest_funding_rate": 0.00035, + "max_pairwise_correlation": 0.72, + "carry_min_expected_edge": 0.0, + }, +} + +STATIC_FILTER_ATTRS = tuple(sorted({key for overrides in STATIC_FILTERS.values() for key in overrides})) +STATIC_COMPONENT_MAP = { + "MOMENTUM_EXPANSION": "prev_balanced", + "EUPHORIC_BREAKOUT": "overheat_tolerant", +} +ADVERSE_COMPONENT_MAP = { + "CAPITULATION_STRESS": "cap_btc_rebound", + "CHOPPY_ROTATION": "chop_inverse_carry_strict", + "DISTRIBUTION_DRIFT": "dist_inverse_carry_strict", +} +ADVERSE_REGIMES = {"CAPITULATION_STRESS", "CHOPPY_ROTATION", "DISTRIBUTION_DRIFT"} + + +@dataclass(slots=True) +class HybridWindowResult: + label: str + start: pd.Timestamp + end: pd.Timestamp + total_return: float + annualized_return: float + sharpe: float + max_drawdown: float + component_map: dict[str, str] + + def to_payload(self) -> dict[str, object]: + return { + "start": str(self.start), + "end": str(self.end), + "total_return": self.total_return, + "annualized_return": self.annualized_return, + "sharpe": self.sharpe, + "max_drawdown": self.max_drawdown, + "component_map": self.component_map, + } + + +class StrategicRegimeFilterBacktester(Strategy32MomentumCarryBacktester): + def __init__( + self, + strategy_config, + data: MarketDataBundle, + *, + trade_start: pd.Timestamp, + strategic_regime_map: dict[pd.Timestamp, str], + active_regime: str, + default_filter_name: str, + filter_plan: dict[pd.Timestamp, str] | None = None, + ): + self._strategic_regime_map = strategic_regime_map + self._active_regime = active_regime + self._default_filter_name = default_filter_name + self._filter_plan = filter_plan or {} + super().__init__(strategy_config, data, trade_start=trade_start) + + def _govern_decision( + self, + decision: AllocationDecision, + *, + signal_timestamp: pd.Timestamp, + current_equity: float, + equity_history: list[float], + ) -> AllocationDecision: + governed = super()._govern_decision( + decision, + signal_timestamp=signal_timestamp, + current_equity=current_equity, + equity_history=equity_history, + ) + if self._strategic_regime_map.get(signal_timestamp) != self._active_regime: + return AllocationDecision( + regime=governed.regime, + momentum_budget_pct=0.0, + carry_budget_pct=0.0, + spread_budget_pct=0.0, + cash_budget_pct=1.0, + ) + return governed + + def _rebalance( + self, + portfolio, + signal_timestamp: pd.Timestamp, + execution_timestamp: pd.Timestamp, + decision: AllocationDecision, + rebalance_momentum: bool, + rebalance_carry: bool, + rebalance_spread: bool, + ) -> list: + originals = {attr: getattr(self.strategy_config, attr) for attr in STATIC_FILTER_ATTRS} + try: + filter_name = self._filter_plan.get(signal_timestamp, self._default_filter_name) + for attr, value in STATIC_FILTERS[filter_name].items(): + setattr(self.strategy_config, attr, value) + return super()._rebalance( + portfolio, + signal_timestamp, + execution_timestamp, + decision, + rebalance_momentum, + rebalance_carry, + rebalance_spread, + ) + finally: + for attr, value in originals.items(): + setattr(self.strategy_config, attr, value) + + +def load_fixed66_bundle(path: str | Path) -> tuple[MarketDataBundle, pd.Timestamp]: + payload = pickle.loads(Path(path).read_bytes()) + return payload["bundle"], pd.Timestamp(payload["latest_bar"]) + + +def _run_static_component_curve( + *, + sliced: MarketDataBundle, + latest_bar: pd.Timestamp, + eval_start: pd.Timestamp, + regime_map: dict[pd.Timestamp, str], + active_regime: str, + filter_name: str, + filter_plan: dict[pd.Timestamp, str] | None = None, +) -> pd.Series: + cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[filter_name]) + backtester = StrategicRegimeFilterBacktester( + cfg, + sliced, + trade_start=eval_start, + strategic_regime_map=regime_map, + active_regime=active_regime, + default_filter_name=filter_name, + filter_plan=filter_plan, + ) + backtester.config.initial_capital = build_engine_config().initial_capital + result = backtester.run() + return result.equity_curve.loc[result.equity_curve.index >= eval_start] + + +def _run_adverse_component_curve( + *, + eval_start: pd.Timestamp, + engine_name: str, + harness: AdverseRegimeResearchHarness, + regime_frame: pd.DataFrame, +) -> pd.Series: + spec = next(spec for spec in default_engine_specs() if spec.name == engine_name) + result = harness.run_engine(spec, eval_start=eval_start, initial_capital=1000.0, regime_frame=regime_frame) + return result.equity_curve.loc[result.equity_curve.index >= eval_start] + + +def _curve_returns(curve: pd.Series) -> pd.Series: + return curve.pct_change().fillna(0.0) + + +def _annualized_return(total_return: float, days: int) -> float: + if days <= 0: + return 0.0 + return (1.0 + total_return) ** (365.0 / days) - 1.0 + + +def _build_positive_filter_plan(regime_frame: pd.DataFrame, active_regime: str) -> dict[pd.Timestamp, str]: + frame = regime_frame.sort_values("timestamp").copy() + frame["is_adverse"] = frame["strategic_regime"].isin(ADVERSE_REGIMES).astype(float) + frame["recent_adverse_share"] = frame["is_adverse"].rolling(18, min_periods=1).mean() + plan: dict[pd.Timestamp, str] = {} + for row in frame.itertuples(index=False): + ts = pd.Timestamp(row.timestamp) + if active_regime == "MOMENTUM_EXPANSION": + guarded = float(row.recent_adverse_share) >= 0.40 or float(row.breadth_persist) < 0.58 + plan[ts] = "guarded_positive" if guarded else "prev_balanced" + elif active_regime == "EUPHORIC_BREAKOUT": + guarded = float(row.recent_adverse_share) >= 0.25 or float(row.funding_persist) < 0.72 + plan[ts] = "guarded_euphoria" if guarded else "overheat_tolerant" + return plan + + +def run_hybrid_backtest( + *, + cache_path: str | Path = "/tmp/strategy32_fixed66_bundle.pkl", + windows: tuple[tuple[int, str], ...] = ((365, "1y"), (730, "2y"), (1095, "3y"), (1460, "4y"), (1825, "5y")), +) -> dict[str, object]: + bundle, latest_bar = load_fixed66_bundle(cache_path) + payload: dict[str, object] = { + "analysis": "fixed66_hybrid_regime_backtest", + "latest_completed_bar": str(latest_bar), + "static_component_map": STATIC_COMPONENT_MAP, + "adverse_component_map": ADVERSE_COMPONENT_MAP, + "results": {}, + } + + for days, label in windows: + eval_start = latest_bar - pd.Timedelta(days=days) + raw_start = eval_start - pd.Timedelta(days=90) + sliced = slice_bundle(bundle, raw_start, latest_bar) + regime_frame = build_strategic_regime_frame(sliced, eval_start, latest_bar) + regime_map = dict(zip(pd.to_datetime(regime_frame["timestamp"]), regime_frame["strategic_regime"])) + harness = AdverseRegimeResearchHarness(sliced, latest_bar) + + component_curves: dict[str, pd.Series] = {} + for regime_name, filter_name in STATIC_COMPONENT_MAP.items(): + filter_plan = _build_positive_filter_plan(regime_frame, regime_name) + component_curves[regime_name] = _run_static_component_curve( + sliced=sliced, + latest_bar=latest_bar, + eval_start=eval_start, + regime_map=regime_map, + active_regime=regime_name, + filter_name=filter_name, + filter_plan=filter_plan, + ) + for regime_name, engine_name in ADVERSE_COMPONENT_MAP.items(): + component_curves[regime_name] = _run_adverse_component_curve( + eval_start=eval_start, + engine_name=engine_name, + harness=harness, + regime_frame=regime_frame, + ) + + return_frames = {name: _curve_returns(curve) for name, curve in component_curves.items()} + timestamps = sorted(sliced.prices["BTC"]["timestamp"].loc[sliced.prices["BTC"]["timestamp"] >= eval_start].tolist()) + equity = 1000.0 + equity_idx = [timestamps[0]] + equity_values = [equity] + + for i in range(1, len(timestamps)): + signal_ts = timestamps[i - 1] + execution_ts = timestamps[i] + regime_name = regime_map.get(signal_ts, "") + ret = float(return_frames.get(regime_name, pd.Series(dtype=float)).get(execution_ts, 0.0)) + equity *= max(0.0, 1.0 + ret) + equity_idx.append(execution_ts) + equity_values.append(equity) + + equity_curve = pd.Series(equity_values, index=pd.Index(equity_idx, name="timestamp"), dtype=float) + total_return = float(equity_curve.iloc[-1] / equity_curve.iloc[0] - 1.0) + payload["results"][label] = HybridWindowResult( + label=label, + start=pd.Timestamp(eval_start), + end=pd.Timestamp(latest_bar), + total_return=total_return, + annualized_return=_annualized_return(total_return, days), + sharpe=sharpe_ratio(equity_curve, 6), + max_drawdown=max_drawdown(equity_curve), + component_map={ + **{regime: filter_name for regime, filter_name in STATIC_COMPONENT_MAP.items()}, + **{regime: engine_name for regime, engine_name in ADVERSE_COMPONENT_MAP.items()}, + }, + ).to_payload() + print( + label, + f"ret={total_return * 100:.2f}%", + f"ann={payload['results'][label]['annualized_return'] * 100:.2f}%", + f"sharpe={payload['results'][label]['sharpe']:.2f}", + f"mdd={payload['results'][label]['max_drawdown'] * 100:.2f}%", + flush=True, + ) + return payload + + +def write_hybrid_backtest(out_path: str | Path = "/tmp/strategy32_hybrid_regime_backtest.json") -> Path: + payload = run_hybrid_backtest() + out = Path(out_path) + out.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return out diff --git a/research/soft_router.py b/research/soft_router.py new file mode 100644 index 0000000..9d42ca7 --- /dev/null +++ b/research/soft_router.py @@ -0,0 +1,848 @@ +from __future__ import annotations + +import multiprocessing as mp +from concurrent.futures import ProcessPoolExecutor, as_completed +from dataclasses import asdict, dataclass + +import pandas as pd + +from strategy29.backtest.metrics import max_drawdown, sharpe_ratio +from strategy29.backtest.window_analysis import slice_bundle +from strategy32.research.adverse_regime import AdverseRegimeResearchHarness, default_engine_specs +from strategy32.research.hybrid_regime import ( + STATIC_FILTERS, + _curve_returns, + _run_adverse_component_curve, + load_fixed66_bundle, +) +from strategy32.backtest.simulator import Strategy32Backtester +from strategy32.config import PROFILE_V7_DEFAULT, build_strategy32_config +from strategy32.scripts.run_regime_filter_analysis import STRATEGIC_REGIME_PROFILES, build_strategic_regime_frame + + +@dataclass(frozen=True, slots=True) +class SoftRouterCandidate: + regime_profile: str + core_filter: str + cap_engine: str + chop_engine: str + dist_engine: str + core_floor: float + cap_max_weight: float + chop_max_weight: float + dist_max_weight: float + chop_blend_floor: float + + @property + def name(self) -> str: + return ( + f"{self.regime_profile}" + f"|core:{self.core_filter}" + f"|cap:{self.cap_engine}" + f"|chop:{self.chop_engine}" + f"|dist:{self.dist_engine}" + f"|floor:{self.core_floor:.2f}" + f"|capw:{self.cap_max_weight:.2f}" + f"|chopw:{self.chop_max_weight:.2f}" + f"|distw:{self.dist_max_weight:.2f}" + f"|chopf:{self.chop_blend_floor:.2f}" + ) + + +@dataclass(frozen=True, slots=True) +class CashOverlayCandidate: + regime_profile: str + core_filter: str + cap_engine: str + chop_engine: str + dist_engine: str + cap_cash_weight: float + chop_cash_weight: float + dist_cash_weight: float + cap_threshold: float + chop_threshold: float + dist_threshold: float + core_block_threshold: float + + @property + def name(self) -> str: + return ( + f"{self.regime_profile}" + f"|core:{self.core_filter}" + f"|cap:{self.cap_engine}" + f"|chop:{self.chop_engine}" + f"|dist:{self.dist_engine}" + f"|capcw:{self.cap_cash_weight:.2f}" + f"|chopcw:{self.chop_cash_weight:.2f}" + f"|distcw:{self.dist_cash_weight:.2f}" + f"|capth:{self.cap_threshold:.2f}" + f"|chopth:{self.chop_threshold:.2f}" + f"|distth:{self.dist_threshold:.2f}" + f"|block:{self.core_block_threshold:.2f}" + ) + + +@dataclass(frozen=True, slots=True) +class MacroScaleSpec: + floor: float + close_gap_start: float + close_gap_full: float + fast_gap_start: float + fast_gap_full: float + close_weight: float = 0.60 + fast_weeks: int = 10 + slow_weeks: int = 30 + + @property + def name(self) -> str: + return ( + f"floor:{self.floor:.2f}" + f"|close:{self.close_gap_start:.3f}->{self.close_gap_full:.3f}" + f"|fast:{self.fast_gap_start:.3f}->{self.fast_gap_full:.3f}" + f"|w:{self.close_weight:.2f}" + ) + + +WINDOWS = ( + (365, "1y"), + (730, "2y"), + (1095, "3y"), + (1460, "4y"), + (1825, "5y"), +) + +YEAR_PERIODS = ( + ("2021", pd.Timestamp("2021-03-16 04:00:00+00:00"), pd.Timestamp("2022-01-01 00:00:00+00:00")), + ("2022", pd.Timestamp("2022-01-01 00:00:00+00:00"), pd.Timestamp("2023-01-01 00:00:00+00:00")), + ("2023", pd.Timestamp("2023-01-01 00:00:00+00:00"), pd.Timestamp("2024-01-01 00:00:00+00:00")), + ("2024", pd.Timestamp("2024-01-01 00:00:00+00:00"), pd.Timestamp("2025-01-01 00:00:00+00:00")), + ("2025", pd.Timestamp("2025-01-01 00:00:00+00:00"), pd.Timestamp("2026-01-01 00:00:00+00:00")), +) + +YTD_START = pd.Timestamp("2026-01-01 00:00:00+00:00") + + +def _clip01(value: float) -> float: + return min(max(float(value), 0.0), 1.0) + + +def _ramp(value: float, start: float, end: float) -> float: + if end == start: + return 1.0 if value >= end else 0.0 + if value <= start: + return 0.0 + if value >= end: + return 1.0 + return (value - start) / (end - start) + + +def _inverse_ramp(value: float, start: float, end: float) -> float: + if end == start: + return 1.0 if value <= end else 0.0 + if value >= start: + return 0.0 + if value <= end: + return 1.0 + return (start - value) / (start - end) + + +def build_regime_score_frame( + bundle, + eval_start: pd.Timestamp, + eval_end: pd.Timestamp, + *, + profile_name: str, +) -> pd.DataFrame: + profile = STRATEGIC_REGIME_PROFILES[profile_name] + frame = build_strategic_regime_frame(bundle, eval_start, eval_end, profile=profile).copy() + + panic_scores: list[float] = [] + euphoria_scores: list[float] = [] + expansion_scores: list[float] = [] + distribution_scores: list[float] = [] + choppy_scores: list[float] = [] + core_scores: list[float] = [] + + for row in frame.itertuples(index=False): + breadth = float(row.breadth) + breadth_persist = float(row.breadth_persist) if pd.notna(row.breadth_persist) else breadth + atr = float(row.atr_pct) if pd.notna(row.atr_pct) else 0.0 + bar_ret = float(row.bar_return) if pd.notna(row.bar_return) else 0.0 + daily_gap = float(row.daily_trend_gap) if pd.notna(row.daily_trend_gap) else 0.0 + intra_gap = float(row.intraday_trend_gap) if pd.notna(row.intraday_trend_gap) else 0.0 + avg_funding = float(row.mean_alt_funding) + positive_ratio = float(row.positive_funding_ratio) + funding_persist = float(row.funding_persist) if pd.notna(row.funding_persist) else positive_ratio + btc_7d = float(row.btc_7d_return) + + panic_score = max( + _ramp(atr, profile.panic_atr * 0.85, profile.panic_atr * 1.35), + _ramp(-bar_ret, abs(profile.panic_bar_return) * 0.75, abs(profile.panic_bar_return) * 1.35), + min( + _ramp(profile.panic_breadth - breadth, 0.0, max(profile.panic_breadth, 0.15)), + _ramp(profile.panic_funding - avg_funding, 0.0, abs(profile.panic_funding) + 0.00015), + ), + ) + + euphoria_components = [ + _ramp(daily_gap, profile.euphoria_daily_gap * 0.75, profile.euphoria_daily_gap * 1.6), + _ramp(intra_gap, profile.euphoria_intraday_gap * 0.6, profile.euphoria_intraday_gap * 1.8), + _ramp(breadth, profile.euphoria_breadth - 0.05, min(profile.euphoria_breadth + 0.12, 0.95)), + _ramp(breadth_persist, profile.euphoria_breadth_persist - 0.06, min(profile.euphoria_breadth_persist + 0.12, 0.95)), + _ramp(positive_ratio, profile.euphoria_positive_ratio - 0.08, min(profile.euphoria_positive_ratio + 0.12, 0.98)), + _ramp(funding_persist, profile.euphoria_funding_persist - 0.08, min(profile.euphoria_funding_persist + 0.12, 0.98)), + max( + _ramp(avg_funding, profile.euphoria_funding * 0.5, max(profile.euphoria_funding * 2.0, profile.euphoria_funding + 0.00008)), + _ramp(btc_7d, profile.euphoria_btc_7d * 0.6, max(profile.euphoria_btc_7d * 1.8, profile.euphoria_btc_7d + 0.08)), + ), + ] + euphoria_score = sum(euphoria_components) / len(euphoria_components) + + expansion_components = [ + _ramp(daily_gap, max(profile.expansion_daily_gap - 0.02, -0.02), profile.expansion_daily_gap + 0.06), + _ramp(intra_gap, profile.expansion_intraday_gap - 0.01, profile.expansion_intraday_gap + 0.05), + _ramp(breadth, profile.expansion_breadth - 0.06, min(profile.expansion_breadth + 0.14, 0.92)), + _ramp(breadth_persist, profile.expansion_breadth_persist - 0.06, min(profile.expansion_breadth_persist + 0.14, 0.92)), + _inverse_ramp(atr, profile.expansion_atr * 1.10, max(profile.expansion_atr * 0.60, 0.015)), + _ramp(avg_funding, profile.expansion_min_funding - 0.00005, profile.expansion_min_funding + 0.00015), + _ramp(btc_7d, profile.expansion_btc_7d - 0.04, profile.expansion_btc_7d + 0.10), + ] + expansion_score = sum(expansion_components) / len(expansion_components) + expansion_score *= 1.0 - 0.55 * euphoria_score + + distribution_components = [ + max( + _ramp(profile.distribution_daily_gap - daily_gap, 0.0, abs(profile.distribution_daily_gap) + 0.05), + _ramp(profile.distribution_intraday_gap - intra_gap, 0.0, abs(profile.distribution_intraday_gap) + 0.04), + ), + _ramp(profile.distribution_breadth - breadth, 0.0, max(profile.distribution_breadth, 0.18)), + _ramp(profile.distribution_positive_ratio - positive_ratio, 0.0, max(profile.distribution_positive_ratio, 0.18)), + _ramp(-avg_funding, 0.0, 0.00020), + ] + distribution_score = sum(distribution_components) / len(distribution_components) + distribution_score *= 1.0 - 0.35 * panic_score + + trendlessness = 1.0 - max( + _clip01(abs(daily_gap) / max(profile.euphoria_daily_gap, 0.03)), + _clip01(abs(intra_gap) / max(profile.euphoria_intraday_gap, 0.015)), + ) + centered_breadth = 1.0 - min(abs(breadth - 0.5) / 0.30, 1.0) + funding_neutral = 1.0 - min(abs(avg_funding) / 0.00012, 1.0) + choppy_score = (trendlessness + centered_breadth + funding_neutral) / 3.0 + choppy_score *= 1.0 - max(euphoria_score, expansion_score, distribution_score, panic_score) * 0.65 + choppy_score = max(choppy_score, 0.0) + + core_score = max(expansion_score, euphoria_score) + + panic_scores.append(_clip01(panic_score)) + euphoria_scores.append(_clip01(euphoria_score)) + expansion_scores.append(_clip01(expansion_score)) + distribution_scores.append(_clip01(distribution_score)) + choppy_scores.append(_clip01(choppy_score)) + core_scores.append(_clip01(core_score)) + + frame["panic_score"] = panic_scores + frame["euphoria_score"] = euphoria_scores + frame["expansion_score"] = expansion_scores + frame["distribution_score"] = distribution_scores + frame["choppy_score"] = choppy_scores + frame["core_score"] = core_scores + return frame + + +def _annualized_return(total_return: float, days: int) -> float: + if days <= 0: + return 0.0 + return (1.0 + total_return) ** (365.0 / days) - 1.0 + + +def segment_metrics(curve: pd.Series, start: pd.Timestamp, end: pd.Timestamp) -> dict[str, float]: + segment = curve.loc[(curve.index >= start) & (curve.index <= end)].copy() + if len(segment) < 2: + return { + "start": str(start), + "end": str(end), + "total_return": 0.0, + "annualized_return": 0.0, + "sharpe": 0.0, + "max_drawdown": 0.0, + } + base = float(segment.iloc[0]) + if base <= 0: + return { + "start": str(start), + "end": str(end), + "total_return": 0.0, + "annualized_return": 0.0, + "sharpe": 0.0, + "max_drawdown": 0.0, + } + normalized = segment / base * 1000.0 + total_return = float(normalized.iloc[-1] / normalized.iloc[0] - 1.0) + days = max(int((end - start) / pd.Timedelta(days=1)), 1) + return { + "start": str(start), + "end": str(end), + "total_return": total_return, + "annualized_return": _annualized_return(total_return, days), + "sharpe": sharpe_ratio(normalized, 6), + "max_drawdown": max_drawdown(normalized), + } + + +def score_candidate(window_results: dict[str, dict[str, float]], year_results: dict[str, dict[str, float]]) -> tuple[float, int, int]: + year_returns = [float(metrics["total_return"]) for metrics in year_results.values()] + negative_years = sum(ret < 0 for ret in year_returns) + mdd_violations = sum(float(metrics["max_drawdown"]) < -0.20 for metrics in window_results.values()) + + score = 0.0 + score += 4.5 * float(window_results["5y"]["annualized_return"]) + score += 2.0 * float(window_results["1y"]["annualized_return"]) + score += 1.4 * float(window_results["2y"]["annualized_return"]) + score += 1.0 * float(window_results["4y"]["annualized_return"]) + score += 0.6 * float(window_results["3y"]["annualized_return"]) + score += 1.3 * float(window_results["5y"]["sharpe"]) + score += 0.6 * float(window_results["1y"]["sharpe"]) + score += 2.5 * min(year_returns) + score += 0.7 * sum(max(ret, 0.0) for ret in year_returns) + score -= 3.25 * negative_years + score -= 0.9 * mdd_violations + for label in ("1y", "2y", "3y", "4y", "5y"): + score -= max(0.0, abs(float(window_results[label]["max_drawdown"])) - 0.20) * 5.0 + return score, negative_years, mdd_violations + + +def load_component_bundle(cache_path: str | None = None) -> tuple[object, pd.Timestamp]: + return load_fixed66_bundle(cache_path or "/tmp/strategy32_fixed66_bundle.pkl") + + +def compose_soft_router_curve( + *, + timestamps: list[pd.Timestamp], + score_frame: pd.DataFrame, + core_returns: pd.Series, + cap_returns: pd.Series, + chop_returns: pd.Series, + dist_returns: pd.Series, + candidate: SoftRouterCandidate, +) -> tuple[pd.Series, pd.DataFrame]: + score_map = score_frame.set_index("timestamp")[ + ["core_score", "panic_score", "choppy_score", "distribution_score"] + ].sort_index() + + equity = 1000.0 + idx = [timestamps[0]] + vals = [equity] + rows: list[dict[str, float | str]] = [] + for i in range(1, len(timestamps)): + signal_ts = pd.Timestamp(timestamps[i - 1]) + execution_ts = pd.Timestamp(timestamps[i]) + score_row = score_map.loc[signal_ts] if signal_ts in score_map.index else None + if score_row is None: + core_score = panic_score = choppy_score = distribution_score = 0.0 + else: + core_score = float(score_row["core_score"]) + panic_score = float(score_row["panic_score"]) + choppy_score = float(score_row["choppy_score"]) + distribution_score = float(score_row["distribution_score"]) + + cap_weight = candidate.cap_max_weight * panic_score + dist_weight = candidate.dist_max_weight * distribution_score * (1.0 - 0.60 * panic_score) + chop_signal = max(choppy_score, candidate.chop_blend_floor * (1.0 - core_score)) + chop_weight = candidate.chop_max_weight * chop_signal * (1.0 - 0.45 * panic_score) + + overlay_weight = cap_weight + dist_weight + chop_weight + if overlay_weight > 0.90: + scale = 0.90 / overlay_weight + cap_weight *= scale + dist_weight *= scale + chop_weight *= scale + overlay_weight = 0.90 + + core_target = candidate.core_floor + (1.0 - candidate.core_floor) * core_score + core_weight = max(0.0, core_target * (1.0 - overlay_weight)) + total_weight = core_weight + cap_weight + chop_weight + dist_weight + if total_weight > 1.0: + scale = 1.0 / total_weight + core_weight *= scale + cap_weight *= scale + chop_weight *= scale + dist_weight *= scale + + bar_ret = ( + core_weight * float(core_returns.get(execution_ts, 0.0)) + + cap_weight * float(cap_returns.get(execution_ts, 0.0)) + + chop_weight * float(chop_returns.get(execution_ts, 0.0)) + + dist_weight * float(dist_returns.get(execution_ts, 0.0)) + ) + equity *= max(0.0, 1.0 + bar_ret) + idx.append(execution_ts) + vals.append(equity) + rows.append( + { + "timestamp": execution_ts, + "core_weight": core_weight, + "cap_weight": cap_weight, + "chop_weight": chop_weight, + "dist_weight": dist_weight, + "cash_weight": max(0.0, 1.0 - core_weight - cap_weight - chop_weight - dist_weight), + "core_score": core_score, + "panic_score": panic_score, + "choppy_score": choppy_score, + "distribution_score": distribution_score, + "portfolio_return": bar_ret, + } + ) + curve = pd.Series(vals, index=pd.DatetimeIndex(idx, name="timestamp"), dtype=float) + weights = pd.DataFrame(rows) + return curve, weights + + +def build_period_components( + *, + bundle, + eval_start: pd.Timestamp, + eval_end: pd.Timestamp, + profile_name: str, + core_filter: str, + cap_engine: str, + chop_engine: str, + dist_engine: str, +) -> dict[str, object]: + raw_start = eval_start - pd.Timedelta(days=90) + sliced = slice_bundle(bundle, raw_start, eval_end) + score_frame = build_regime_score_frame(sliced, eval_start, eval_end, profile_name=profile_name) + regime_frame = score_frame.copy() + harness = AdverseRegimeResearchHarness(sliced, eval_end) + + cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[core_filter]) + backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start) + backtester.engine_config.initial_capital = 1000.0 + core_curve = backtester.run().equity_curve.loc[lambda s: s.index >= eval_start] + cap_curve = _run_adverse_component_curve( + eval_start=eval_start, + engine_name=cap_engine, + harness=harness, + regime_frame=regime_frame, + ) + chop_curve = _run_adverse_component_curve( + eval_start=eval_start, + engine_name=chop_engine, + harness=harness, + regime_frame=regime_frame, + ) + dist_curve = _run_adverse_component_curve( + eval_start=eval_start, + engine_name=dist_engine, + harness=harness, + regime_frame=regime_frame, + ) + timestamps = sorted(sliced.prices["BTC"]["timestamp"].loc[sliced.prices["BTC"]["timestamp"] >= eval_start].tolist()) + + return { + "score_frame": score_frame, + "timestamps": timestamps, + "core_returns": _curve_returns(core_curve), + "cap_returns": _curve_returns(cap_curve), + "chop_returns": _curve_returns(chop_curve), + "dist_returns": _curve_returns(dist_curve), + } + + +def build_cash_overlay_period_components( + *, + bundle, + eval_start: pd.Timestamp, + eval_end: pd.Timestamp, + profile_name: str, + core_filter: str, + cap_engine: str, + chop_engine: str, + dist_engine: str, + core_config_overrides: dict[str, object] | None = None, + macro_scale_spec: MacroScaleSpec | None = None, +) -> dict[str, object]: + raw_start = eval_start - pd.Timedelta(days=365 if macro_scale_spec is not None else 90) + sliced = slice_bundle(bundle, raw_start, eval_end) + score_frame = build_regime_score_frame(sliced, eval_start, eval_end, profile_name=profile_name) + regime_frame = score_frame.copy() + harness = AdverseRegimeResearchHarness(sliced, eval_end) + + core_config = dict(STATIC_FILTERS[core_filter]) + core_config.update(core_config_overrides or {}) + cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **core_config) + backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start) + backtester.engine_config.initial_capital = 1000.0 + core_result = backtester.run() + core_curve = core_result.equity_curve.loc[lambda s: s.index >= eval_start] + exposure_frame = pd.DataFrame(core_result.metadata.get("exposure_rows", [])) + if not exposure_frame.empty: + exposure_frame = exposure_frame.loc[exposure_frame["timestamp"] >= eval_start].copy() + + cap_curve = _run_adverse_component_curve( + eval_start=eval_start, + engine_name=cap_engine, + harness=harness, + regime_frame=regime_frame, + ) + chop_curve = _run_adverse_component_curve( + eval_start=eval_start, + engine_name=chop_engine, + harness=harness, + regime_frame=regime_frame, + ) + dist_curve = _run_adverse_component_curve( + eval_start=eval_start, + engine_name=dist_engine, + harness=harness, + regime_frame=regime_frame, + ) + timestamps = sorted(sliced.prices["BTC"]["timestamp"].loc[sliced.prices["BTC"]["timestamp"] >= eval_start].tolist()) + macro_scale_map = _build_macro_scale_map( + sliced, + timestamps=timestamps[:-1], + spec=macro_scale_spec, + ) + return { + "score_frame": score_frame, + "timestamps": timestamps, + "core_returns": _curve_returns(core_curve), + "core_exposure_frame": exposure_frame, + "cap_returns": _curve_returns(cap_curve), + "chop_returns": _curve_returns(chop_curve), + "dist_returns": _curve_returns(dist_curve), + "macro_scale_map": macro_scale_map, + } + + +def _build_macro_scale_map( + bundle, + *, + timestamps: list[pd.Timestamp], + spec: MacroScaleSpec | None, +) -> pd.Series | None: + if spec is None or not timestamps: + return None + btc_prices = bundle.prices.get("BTC") + if btc_prices is None or btc_prices.empty: + return None + closes = btc_prices.set_index("timestamp")["close"].astype(float).sort_index() + daily = closes.resample("1D").last().dropna() + weekly = daily.resample("W-SUN").last().dropna() + if weekly.empty: + return None + fast = weekly.ewm(span=spec.fast_weeks, adjust=False).mean() + slow = weekly.ewm(span=spec.slow_weeks, adjust=False).mean() + frame = pd.DataFrame( + { + "close_gap": weekly / slow - 1.0, + "fast_gap": fast / slow - 1.0, + } + ) + close_scale = frame["close_gap"].apply(lambda value: _ramp(float(value), spec.close_gap_start, spec.close_gap_full)) + fast_scale = frame["fast_gap"].apply(lambda value: _ramp(float(value), spec.fast_gap_start, spec.fast_gap_full)) + blended = spec.close_weight * close_scale + (1.0 - spec.close_weight) * fast_scale + macro_scale = spec.floor + (1.0 - spec.floor) * blended.clip(0.0, 1.0) + aligned = macro_scale.reindex(pd.DatetimeIndex(timestamps, name="timestamp"), method="ffill") + aligned = aligned.fillna(1.0).clip(spec.floor, 1.0) + return aligned.astype(float) + + +def compose_cash_overlay_curve( + *, + timestamps: list[pd.Timestamp], + score_frame: pd.DataFrame, + core_returns: pd.Series, + core_exposure_frame: pd.DataFrame, + cap_returns: pd.Series, + chop_returns: pd.Series, + dist_returns: pd.Series, + candidate: CashOverlayCandidate, + macro_scale_map: pd.Series | None = None, +) -> tuple[pd.Series, pd.DataFrame]: + score_map = score_frame.set_index("timestamp")[ + ["core_score", "panic_score", "choppy_score", "distribution_score"] + ].sort_index() + if core_exposure_frame.empty: + cash_map = pd.Series(1.0, index=pd.DatetimeIndex(timestamps[:-1], name="timestamp")) + else: + cash_map = core_exposure_frame.set_index("timestamp")["cash_pct"].sort_index() + + equity = 1000.0 + idx = [timestamps[0]] + vals = [equity] + rows: list[dict[str, float | str]] = [] + for i in range(1, len(timestamps)): + signal_ts = pd.Timestamp(timestamps[i - 1]) + execution_ts = pd.Timestamp(timestamps[i]) + score_row = score_map.loc[signal_ts] if signal_ts in score_map.index else None + if score_row is None: + core_score = panic_score = choppy_score = distribution_score = 0.0 + else: + core_score = float(score_row["core_score"]) + panic_score = float(score_row["panic_score"]) + choppy_score = float(score_row["choppy_score"]) + distribution_score = float(score_row["distribution_score"]) + + macro_scale = float(macro_scale_map.get(signal_ts, 1.0)) if macro_scale_map is not None else 1.0 + raw_cash_pct = float(cash_map.get(signal_ts, cash_map.iloc[-1] if not cash_map.empty else 1.0)) + cash_pct = raw_cash_pct + (1.0 - raw_cash_pct) * (1.0 - macro_scale) + cap_signal = _clip01((panic_score - candidate.cap_threshold) / max(1.0 - candidate.cap_threshold, 1e-9)) + chop_signal = _clip01((choppy_score - candidate.chop_threshold) / max(1.0 - candidate.chop_threshold, 1e-9)) + dist_signal = _clip01((distribution_score - candidate.dist_threshold) / max(1.0 - candidate.dist_threshold, 1e-9)) + + if core_score > candidate.core_block_threshold: + chop_signal *= 0.25 + dist_signal *= 0.35 + + cap_weight = cash_pct * candidate.cap_cash_weight * cap_signal + chop_weight = cash_pct * candidate.chop_cash_weight * chop_signal + dist_weight = cash_pct * candidate.dist_cash_weight * dist_signal + overlay_total = cap_weight + chop_weight + dist_weight + if overlay_total > cash_pct and overlay_total > 0: + scale = cash_pct / overlay_total + cap_weight *= scale + chop_weight *= scale + dist_weight *= scale + overlay_total = cash_pct + + bar_ret = ( + float(core_returns.get(execution_ts, 0.0)) * macro_scale + + cap_weight * float(cap_returns.get(execution_ts, 0.0)) + + chop_weight * float(chop_returns.get(execution_ts, 0.0)) + + dist_weight * float(dist_returns.get(execution_ts, 0.0)) + ) + equity *= max(0.0, 1.0 + bar_ret) + idx.append(execution_ts) + vals.append(equity) + rows.append( + { + "timestamp": execution_ts, + "raw_core_cash_pct": raw_cash_pct, + "core_cash_pct": cash_pct, + "macro_scale": macro_scale, + "cap_weight": cap_weight, + "chop_weight": chop_weight, + "dist_weight": dist_weight, + "overlay_total": overlay_total, + "core_score": core_score, + "panic_score": panic_score, + "choppy_score": choppy_score, + "distribution_score": distribution_score, + "portfolio_return": bar_ret, + } + ) + curve = pd.Series(vals, index=pd.DatetimeIndex(idx, name="timestamp"), dtype=float) + weights = pd.DataFrame(rows) + return curve, weights + + +def evaluate_candidate_exact( + *, + bundle, + latest_bar: pd.Timestamp, + candidate: SoftRouterCandidate, + cache_path: str | None = None, + max_workers: int = 6, +) -> dict[str, object]: + period_specs: list[tuple[str, str, pd.Timestamp, pd.Timestamp]] = [] + for days, label in WINDOWS: + period_specs.append(("window", label, latest_bar - pd.Timedelta(days=days), latest_bar)) + for label, start, end_exclusive in YEAR_PERIODS: + period_specs.append(("year", label, start, min(latest_bar, end_exclusive - pd.Timedelta(seconds=1)))) + period_specs.append(("year", "2026_YTD", YTD_START, latest_bar)) + + ctx = mp.get_context("fork") + cache_path = cache_path or "/tmp/strategy32_fixed66_bundle.pkl" + window_results: dict[str, dict[str, float]] = {} + year_results: dict[str, dict[str, float]] = {} + latest_weights: list[dict[str, object]] = [] + + with ProcessPoolExecutor(max_workers=min(max_workers, len(period_specs)), mp_context=ctx) as executor: + future_map = { + executor.submit( + _exact_period_worker, + cache_path, + asdict(candidate), + kind, + label, + str(start), + str(end), + ): (kind, label) + for kind, label, start, end in period_specs + } + for future in as_completed(future_map): + kind, label, metrics, weight_tail = future.result() + if kind == "window": + window_results[label] = metrics + else: + year_results[label] = metrics + if label == "2026_YTD": + latest_weights = weight_tail + + score, negative_years, mdd_violations = score_candidate( + {label: window_results[label] for _, label in WINDOWS}, + {k: year_results[k] for k, _, _ in YEAR_PERIODS}, + ) + return { + "candidate": asdict(candidate), + "name": candidate.name, + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "windows": {label: window_results[label] for _, label in WINDOWS}, + "years": year_results, + "latest_weights": latest_weights, + "validation": "exact_independent_periods_soft_router", + } + + +def evaluate_cash_overlay_exact( + *, + bundle, + latest_bar: pd.Timestamp, + candidate: CashOverlayCandidate, + cache_path: str | None = None, + max_workers: int = 6, + core_config_overrides: dict[str, object] | None = None, + macro_scale_spec: MacroScaleSpec | None = None, +) -> dict[str, object]: + period_specs: list[tuple[str, str, pd.Timestamp, pd.Timestamp]] = [] + for days, label in WINDOWS: + period_specs.append(("window", label, latest_bar - pd.Timedelta(days=days), latest_bar)) + for label, start, end_exclusive in YEAR_PERIODS: + period_specs.append(("year", label, start, min(latest_bar, end_exclusive - pd.Timedelta(seconds=1)))) + period_specs.append(("year", "2026_YTD", YTD_START, latest_bar)) + + ctx = mp.get_context("fork") + cache_path = cache_path or "/tmp/strategy32_fixed66_bundle.pkl" + window_results: dict[str, dict[str, float]] = {} + year_results: dict[str, dict[str, float]] = {} + latest_weights: list[dict[str, object]] = [] + + with ProcessPoolExecutor(max_workers=min(max_workers, len(period_specs)), mp_context=ctx) as executor: + future_map = { + executor.submit( + _exact_cash_overlay_period_worker, + cache_path, + asdict(candidate), + core_config_overrides or {}, + asdict(macro_scale_spec) if macro_scale_spec is not None else None, + kind, + label, + str(start), + str(end), + ): (kind, label) + for kind, label, start, end in period_specs + } + for future in as_completed(future_map): + kind, label, metrics, weight_tail = future.result() + if kind == "window": + window_results[label] = metrics + else: + year_results[label] = metrics + if label == "2026_YTD": + latest_weights = weight_tail + + score, negative_years, mdd_violations = score_candidate( + {label: window_results[label] for _, label in WINDOWS}, + {k: year_results[k] for k, _, _ in YEAR_PERIODS}, + ) + return { + "candidate": asdict(candidate), + "name": candidate.name, + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "macro_scale_spec": asdict(macro_scale_spec) if macro_scale_spec is not None else None, + "windows": {label: window_results[label] for _, label in WINDOWS}, + "years": year_results, + "latest_weights": latest_weights, + "validation": "exact_independent_periods_cash_overlay", + } + + +def _exact_period_worker( + cache_path: str, + candidate_payload: dict[str, object], + kind: str, + label: str, + start_text: str, + end_text: str, +) -> tuple[str, str, dict[str, float], list[dict[str, object]]]: + bundle, _ = load_component_bundle(cache_path) + candidate = SoftRouterCandidate(**candidate_payload) + eval_start = pd.Timestamp(start_text) + eval_end = pd.Timestamp(end_text) + components = build_period_components( + bundle=bundle, + eval_start=eval_start, + eval_end=eval_end, + profile_name=candidate.regime_profile, + core_filter=candidate.core_filter, + cap_engine=candidate.cap_engine, + chop_engine=candidate.chop_engine, + dist_engine=candidate.dist_engine, + ) + curve, weights = compose_soft_router_curve(candidate=candidate, **components) + weight_tail = weights.tail(1).copy() + if not weight_tail.empty and "timestamp" in weight_tail.columns: + weight_tail["timestamp"] = weight_tail["timestamp"].astype(str) + return kind, label, segment_metrics(curve, eval_start, eval_end), weight_tail.to_dict(orient="records") + + +def _exact_cash_overlay_period_worker( + cache_path: str, + candidate_payload: dict[str, object], + core_config_overrides_payload: dict[str, object], + macro_scale_spec_payload: dict[str, object] | None, + kind: str, + label: str, + start_text: str, + end_text: str, +) -> tuple[str, str, dict[str, float], list[dict[str, object]]]: + bundle, _ = load_component_bundle(cache_path) + candidate = CashOverlayCandidate(**candidate_payload) + macro_scale_spec = MacroScaleSpec(**macro_scale_spec_payload) if macro_scale_spec_payload else None + eval_start = pd.Timestamp(start_text) + eval_end = pd.Timestamp(end_text) + components = build_cash_overlay_period_components( + bundle=bundle, + eval_start=eval_start, + eval_end=eval_end, + profile_name=candidate.regime_profile, + core_filter=candidate.core_filter, + cap_engine=candidate.cap_engine, + chop_engine=candidate.chop_engine, + dist_engine=candidate.dist_engine, + core_config_overrides=core_config_overrides_payload, + macro_scale_spec=macro_scale_spec, + ) + curve, weights = compose_cash_overlay_curve(candidate=candidate, **components) + weight_tail = weights.tail(1).copy() + if not weight_tail.empty and "timestamp" in weight_tail.columns: + weight_tail["timestamp"] = weight_tail["timestamp"].astype(str) + return kind, label, segment_metrics(curve, eval_start, eval_end), weight_tail.to_dict(orient="records") + + +def build_full_period_components( + *, + bundle, + latest_bar: pd.Timestamp, + profile_name: str, + core_filter: str, + cap_engine: str, + chop_engine: str, + dist_engine: str, +) -> dict[str, object]: + eval_start = latest_bar - pd.Timedelta(days=1825) + return build_period_components( + bundle=bundle, + eval_start=eval_start, + eval_end=latest_bar, + profile_name=profile_name, + core_filter=core_filter, + cap_engine=cap_engine, + chop_engine=chop_engine, + dist_engine=dist_engine, + ) diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/scripts/run_ablation_analysis.py b/scripts/run_ablation_analysis.py new file mode 100644 index 0000000..cd235bf --- /dev/null +++ b/scripts/run_ablation_analysis.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import copy +import json +import sys +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy29.backtest.window_analysis import evaluate_window_result, slice_bundle +from strategy32.backtest.simulator import Strategy32Backtester +from strategy32.config import PROFILE_V5_BASELINE, build_strategy32_config +from strategy32.data import build_strategy32_market_bundle + + +WINDOWS = [(30, "1m"), (365, "1y"), (1095, "3y"), (1825, "5y")] + + +def build_variants() -> list[tuple[str, dict[str, bool]]]: + return [ + ("baseline_v5", {}), + ("no_sideways", {"enable_sideways_engine": False}), + ("strong_kill_switch", {"enable_strong_kill_switch": True}), + ("daily_trend_filter", {"enable_daily_trend_filter": True}), + ("expanded_hedge", {"enable_expanded_hedge": True}), + ("max_holding_exit", {"enable_max_holding_exit": True}), + ] + + +def main() -> None: + base = build_strategy32_config(PROFILE_V5_BASELINE) + end = pd.Timestamp("2026-03-15 00:00:00", tz="UTC") + start = end - pd.Timedelta(days=max(days for days, _ in WINDOWS) + base.warmup_days + 14) + + print("fetching bundle...") + bundle, latest_completed_bar, accepted_symbols, rejected_symbols, quote_by_symbol = build_strategy32_market_bundle( + symbols=base.symbols, + auto_discover_symbols=base.auto_discover_symbols, + quote_assets=base.quote_assets, + excluded_base_assets=base.excluded_base_assets, + min_quote_volume_24h=base.discovery_min_quote_volume_24h, + start=start, + end=end, + timeframe=base.timeframe, + max_staleness_days=base.max_symbol_staleness_days, + ) + print("latest", latest_completed_bar) + + results: dict[str, dict[str, dict[str, float | int | str]]] = {} + for name, overrides in build_variants(): + cfg = copy.deepcopy(base) + for key, value in overrides.items(): + setattr(cfg, key, value) + variant_results = {} + print("\nVARIANT", name) + for days, label in WINDOWS: + eval_end = latest_completed_bar + eval_start = eval_end - pd.Timedelta(days=days) + raw_start = eval_start - pd.Timedelta(days=cfg.warmup_days) + sliced = slice_bundle(bundle, raw_start, eval_end) + backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start) + backtester.engine_config.initial_capital = 1000.0 + result = backtester.run() + metrics = evaluate_window_result(result, eval_start=eval_start, bars_per_day=backtester.engine_config.bars_per_day) + metrics["engine_pnl"] = result.engine_pnl + metrics["total_trades"] = result.total_trades + variant_results[label] = metrics + print( + label, + "ret", + round(float(metrics["total_return"]) * 100, 2), + "mdd", + round(float(metrics["max_drawdown"]) * 100, 2), + "sharpe", + round(float(metrics["sharpe"]), 2), + "trades", + metrics["trade_count"], + ) + results[name] = variant_results + + payload = { + "strategy": "strategy32", + "analysis": "v6_single_change_ablation", + "initial_capital": 1000.0, + "auto_discover_symbols": base.auto_discover_symbols, + "latest_completed_bar": str(latest_completed_bar), + "requested_symbols": [] if base.auto_discover_symbols else base.symbols, + "accepted_symbols": accepted_symbols, + "rejected_symbols": rejected_symbols, + "quote_by_symbol": quote_by_symbol, + "timeframe": base.timeframe, + "results": results, + } + out = Path("/tmp/strategy32_v6_ablation.json") + out.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print("\nwrote", out) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_adverse_regime_engine_search.py b/scripts/run_adverse_regime_engine_search.py new file mode 100644 index 0000000..1abd189 --- /dev/null +++ b/scripts/run_adverse_regime_engine_search.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy32.research.adverse_regime import run_adverse_regime_search + + +def main() -> None: + payload = run_adverse_regime_search( + cache_path="/tmp/strategy32_fixed66_bundle.pkl", + eval_days=1825, + initial_capital=1000.0, + ) + out = Path("/tmp/strategy32_adverse_regime_engine_search.json") + out.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + for regime, rows in payload["by_regime"].items(): + print(regime) + for row in rows: + print( + " ", + row["name"], + f"ret={float(row['total_return']) * 100:.2f}%", + f"sharpe={float(row['sharpe']):.2f}", + f"mdd={float(row['max_drawdown']) * 100:.2f}%", + f"active={float(row['active_bar_ratio']) * 100:.2f}%", + f"rebalance={int(row['rebalance_count'])}", + ) + print(f"wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_backtest.py b/scripts/run_backtest.py new file mode 100644 index 0000000..8b636f5 --- /dev/null +++ b/scripts/run_backtest.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy29.backtest.window_analysis import evaluate_window_result, slice_bundle +from strategy32.backtest.simulator import Strategy32Backtester +from strategy32.config import PROFILE_V5_BASELINE, PROFILE_V7_DEFAULT, build_strategy32_config +from strategy32.data import ( + build_strategy32_market_bundle_from_specs, + build_strategy32_price_frames_from_specs, + resolve_strategy32_pair_specs, +) + + +DEFAULT_WINDOWS = [365, 1095, 1825] + + +def _slice_price_frames( + prices: dict[str, pd.DataFrame], + start: pd.Timestamp, + end: pd.Timestamp, +) -> dict[str, pd.DataFrame]: + sliced: dict[str, pd.DataFrame] = {} + for symbol, df in prices.items(): + frame = df.loc[(df["timestamp"] >= start) & (df["timestamp"] <= end)].copy() + if not frame.empty: + sliced[symbol] = frame.reset_index(drop=True) + return sliced + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run Strategy32 backtest on Binance data") + parser.add_argument("--profile", default=PROFILE_V7_DEFAULT, choices=[PROFILE_V5_BASELINE, PROFILE_V7_DEFAULT]) + parser.add_argument("--symbols", default="") + parser.add_argument("--windows", default=",".join(str(days) for days in DEFAULT_WINDOWS)) + parser.add_argument("--warmup-days", type=int, default=90) + parser.add_argument("--timeframe", default="4h") + parser.add_argument("--out", default="/tmp/strategy32_backtest_v0.json") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + strategy_config = build_strategy32_config(args.profile) + if args.symbols: + strategy_config.symbols = [symbol.strip().upper() for symbol in args.symbols.split(",") if symbol.strip()] + strategy_config.auto_discover_symbols = False + strategy_config.timeframe = args.timeframe + strategy_config.warmup_days = args.warmup_days + windows = [int(token.strip()) for token in args.windows.split(",") if token.strip()] + + end = pd.Timestamp.utcnow() + if end.tzinfo is None: + end = end.tz_localize("UTC") + else: + end = end.tz_convert("UTC") + start = end - pd.Timedelta(days=max(windows) + strategy_config.warmup_days + 14) + + specs = resolve_strategy32_pair_specs( + symbols=strategy_config.symbols, + auto_discover_symbols=strategy_config.auto_discover_symbols, + quote_assets=strategy_config.quote_assets, + excluded_base_assets=strategy_config.excluded_base_assets, + min_quote_volume_24h=strategy_config.discovery_min_quote_volume_24h, + ) + bundle, latest_completed_bar, accepted_symbols, rejected_symbols, quote_by_symbol = build_strategy32_market_bundle_from_specs( + specs=specs, + start=start, + end=end, + timeframe=strategy_config.timeframe, + max_staleness_days=strategy_config.max_symbol_staleness_days, + ) + accepted_specs = [spec for spec in specs if spec.base_symbol in set(accepted_symbols)] + execution_prices, _, execution_accepted, execution_rejected, _ = build_strategy32_price_frames_from_specs( + specs=accepted_specs, + start=start, + end=end, + timeframe=strategy_config.execution_refinement_timeframe, + max_staleness_days=strategy_config.max_symbol_staleness_days, + ) + + results = {} + for days in windows: + label = "1y" if days == 365 else "3y" if days == 1095 else "5y" if days == 1825 else f"{days}d" + eval_end = latest_completed_bar + eval_start = eval_end - pd.Timedelta(days=days) + raw_start = eval_start - pd.Timedelta(days=strategy_config.warmup_days) + sliced = slice_bundle(bundle, raw_start, eval_end) + execution_slice = _slice_price_frames( + execution_prices, + raw_start - pd.Timedelta(hours=24), + eval_end, + ) + result = Strategy32Backtester( + strategy_config, + sliced, + trade_start=eval_start, + execution_prices=execution_slice, + ).run() + metrics = evaluate_window_result(result, eval_start=eval_start, bars_per_day=6) + metrics["engine_pnl"] = result.engine_pnl + metrics["total_trades"] = result.total_trades + metrics["rejection_summary"] = result.metadata.get("rejection_summary", {}) + results[label] = metrics + + payload = { + "strategy": "strategy32", + "profile": args.profile, + "auto_discover_symbols": strategy_config.auto_discover_symbols, + "latest_completed_bar": str(latest_completed_bar), + "warmup_days": strategy_config.warmup_days, + "requested_symbols": [] if strategy_config.auto_discover_symbols else strategy_config.symbols, + "accepted_symbols": accepted_symbols, + "rejected_symbols": rejected_symbols, + "execution_refinement_timeframe": strategy_config.execution_refinement_timeframe, + "execution_refinement_symbols": execution_accepted, + "execution_refinement_rejected": execution_rejected, + "quote_by_symbol": quote_by_symbol, + "timeframe": strategy_config.timeframe, + "results": results, + } + target = Path(args.out) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(json.dumps(payload, indent=2)) + print(f"Wrote {target}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_cash_overlay_search.py b/scripts/run_cash_overlay_search.py new file mode 100644 index 0000000..c543d88 --- /dev/null +++ b/scripts/run_cash_overlay_search.py @@ -0,0 +1,436 @@ +from __future__ import annotations + +import itertools +import json +import sys +from dataclasses import asdict +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy29.backtest.window_analysis import slice_bundle +from strategy32.backtest.simulator import Strategy32Backtester +from strategy32.config import PROFILE_V7_DEFAULT, build_strategy32_config +from strategy32.research.hybrid_regime import STATIC_FILTERS +from strategy32.research.soft_router import ( + WINDOWS, + YEAR_PERIODS, + YTD_START, + CashOverlayCandidate, + build_cash_overlay_period_components, + compose_cash_overlay_curve, + evaluate_cash_overlay_exact, + load_component_bundle, + score_candidate, + segment_metrics, +) + + +OUT_JSON = Path("/tmp/strategy32_cash_overlay_search.json") +OUT_MD = Path("/Volumes/SSD/data/nextcloud/data/tara/files/📂HeadOffice/money-bot/strategy32/017_cash_overlay_탐색결과.md") +SOFT_JSON = Path("/tmp/strategy32_best_soft_exact.json") + +PROFILE = "loose_positive" +CORE_FILTER = "overheat_tolerant" +CAP_ENGINE = "cap_btc_rebound" +CHOP_ENGINE = "chop_inverse_carry_strict" +DIST_ENGINE = "dist_inverse_carry_strict" + +STATIC_BASELINE = { + "name": "overheat_tolerant", + "windows": { + "1y": {"total_return": 0.1477, "annualized_return": 0.1477, "max_drawdown": -0.1229}, + "2y": {"total_return": 0.2789, "annualized_return": 0.1309, "max_drawdown": -0.1812}, + "3y": {"total_return": 0.4912, "annualized_return": 0.1425, "max_drawdown": -0.1931}, + "4y": {"total_return": 0.3682, "annualized_return": 0.0815, "max_drawdown": -0.3461}, + "5y": {"total_return": 3.7625, "annualized_return": 0.3664, "max_drawdown": -0.2334}, + }, + "years": { + "2026_YTD": {"total_return": 0.0, "max_drawdown": 0.0}, + "2025": {"total_return": 0.0426, "max_drawdown": -0.1323}, + "2024": {"total_return": 0.1951, "max_drawdown": -0.2194}, + "2023": {"total_return": 0.4670, "max_drawdown": -0.2155}, + "2022": {"total_return": 0.0147, "max_drawdown": -0.0662}, + "2021": {"total_return": 1.9152, "max_drawdown": -0.1258}, + }, +} + +EXPOSURE_SUMMARY = { + "avg_cash_pct": 0.9379, + "median_cash_pct": 1.0, + "cash_gt_50_pct": 0.9469, + "cash_gt_80_pct": 0.9068, + "avg_momentum_pct": 0.0495, + "avg_carry_pct": 0.0126, +} + +CAP_CASH_WEIGHTS = (0.20, 0.35, 0.50, 0.65) +CHOP_CASH_WEIGHTS = (0.10, 0.20, 0.30, 0.40) +DIST_CASH_WEIGHTS = (0.05, 0.10, 0.15, 0.20) +CAP_THRESHOLDS = (0.20, 0.35, 0.50) +CHOP_THRESHOLDS = (0.35, 0.50, 0.65) +DIST_THRESHOLDS = (0.35, 0.50, 0.65) +CORE_BLOCK_THRESHOLDS = (0.45, 0.60, 0.75) + + +def _evaluate_from_curve(curve: pd.Series, latest_bar: pd.Timestamp) -> tuple[dict[str, dict[str, float]], dict[str, dict[str, float]], float, int, int]: + window_results = { + label: segment_metrics(curve, latest_bar - pd.Timedelta(days=days), latest_bar) + for days, label in WINDOWS + } + year_results = { + label: segment_metrics(curve, start, min(latest_bar, end_exclusive - pd.Timedelta(seconds=1))) + for label, start, end_exclusive in YEAR_PERIODS + } + year_results["2026_YTD"] = segment_metrics(curve, YTD_START, latest_bar) + score, negative_years, mdd_violations = score_candidate( + window_results, + {k: v for k, v in year_results.items() if k != "2026_YTD"}, + ) + return window_results, year_results, score, negative_years, mdd_violations + + +def _exact_static_variant(bundle, latest_bar: pd.Timestamp, filter_name: str) -> dict[str, object]: + window_results: dict[str, dict[str, float]] = {} + year_results: dict[str, dict[str, float]] = {} + + for days, label in WINDOWS: + eval_start = latest_bar - pd.Timedelta(days=days) + raw_start = eval_start - pd.Timedelta(days=90) + sliced = slice_bundle(bundle, raw_start, latest_bar) + cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[filter_name]) + backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start) + backtester.engine_config.initial_capital = 1000.0 + curve = backtester.run().equity_curve.loc[lambda s: s.index >= eval_start] + window_results[label] = segment_metrics(curve, eval_start, latest_bar) + + for label, start, end_exclusive in YEAR_PERIODS: + eval_end = min(latest_bar, end_exclusive - pd.Timedelta(seconds=1)) + raw_start = start - pd.Timedelta(days=90) + sliced = slice_bundle(bundle, raw_start, eval_end) + cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[filter_name]) + backtester = Strategy32Backtester(cfg, sliced, trade_start=start) + backtester.engine_config.initial_capital = 1000.0 + curve = backtester.run().equity_curve.loc[lambda s: s.index >= start] + year_results[label] = segment_metrics(curve, start, eval_end) + + raw_start = YTD_START - pd.Timedelta(days=90) + sliced = slice_bundle(bundle, raw_start, latest_bar) + cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[filter_name]) + backtester = Strategy32Backtester(cfg, sliced, trade_start=YTD_START) + backtester.engine_config.initial_capital = 1000.0 + curve = backtester.run().equity_curve.loc[lambda s: s.index >= YTD_START] + year_results["2026_YTD"] = segment_metrics(curve, YTD_START, latest_bar) + + score, negative_years, mdd_violations = score_candidate( + window_results, + {k: v for k, v in year_results.items() if k != "2026_YTD"}, + ) + return { + "name": filter_name, + "windows": window_results, + "years": year_results, + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "validation": "exact_static_variant", + } + + +def _core_exposure_summary(bundle, latest_bar: pd.Timestamp) -> dict[str, float]: + eval_start = latest_bar - pd.Timedelta(days=1825) + raw_start = eval_start - pd.Timedelta(days=90) + sliced = slice_bundle(bundle, raw_start, latest_bar) + cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[CORE_FILTER]) + backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start) + backtester.engine_config.initial_capital = 1000.0 + result = backtester.run() + exposure_frame = pd.DataFrame(result.metadata.get("exposure_rows", [])) + exposure_frame = exposure_frame.loc[exposure_frame["timestamp"] >= eval_start].copy() + return { + "avg_cash_pct": float(exposure_frame["cash_pct"].mean()), + "median_cash_pct": float(exposure_frame["cash_pct"].median()), + "cash_gt_50_pct": float((exposure_frame["cash_pct"] > 0.50).mean()), + "cash_gt_80_pct": float((exposure_frame["cash_pct"] > 0.80).mean()), + "avg_momentum_pct": float(exposure_frame["momentum_pct"].mean()), + "avg_carry_pct": float(exposure_frame["carry_pct"].mean()), + } + + +def _metric_line(metrics: dict[str, float], *, include_ann: bool) -> str: + sharpe = metrics.get("sharpe") + if include_ann: + parts = [ + f"ret `{metrics['total_return'] * 100:.2f}%`", + f"ann `{metrics['annualized_return'] * 100:.2f}%`", + ] + else: + parts = [f"ret `{metrics['total_return'] * 100:.2f}%`"] + if sharpe is not None: + parts.append(f"sharpe `{sharpe:.2f}`") + parts.append(f"mdd `{metrics['max_drawdown'] * 100:.2f}%`") + return ", ".join(parts) + + +def main() -> None: + bundle, latest_bar = load_component_bundle() + eval_start = latest_bar - pd.Timedelta(days=1825) + static_exact = STATIC_BASELINE + exposure_summary = EXPOSURE_SUMMARY + print("[stage] build 5y overlay components", flush=True) + components = build_cash_overlay_period_components( + bundle=bundle, + eval_start=eval_start, + eval_end=latest_bar, + profile_name=PROFILE, + core_filter=CORE_FILTER, + cap_engine=CAP_ENGINE, + chop_engine=CHOP_ENGINE, + dist_engine=DIST_ENGINE, + ) + print("[stage] begin approximate candidate search", flush=True) + + candidates = [ + CashOverlayCandidate( + regime_profile=PROFILE, + core_filter=CORE_FILTER, + cap_engine=CAP_ENGINE, + chop_engine=CHOP_ENGINE, + dist_engine=DIST_ENGINE, + cap_cash_weight=cap_cash_weight, + chop_cash_weight=chop_cash_weight, + dist_cash_weight=dist_cash_weight, + cap_threshold=cap_threshold, + chop_threshold=chop_threshold, + dist_threshold=dist_threshold, + core_block_threshold=core_block_threshold, + ) + for ( + cap_cash_weight, + chop_cash_weight, + dist_cash_weight, + cap_threshold, + chop_threshold, + dist_threshold, + core_block_threshold, + ) in itertools.product( + CAP_CASH_WEIGHTS, + CHOP_CASH_WEIGHTS, + DIST_CASH_WEIGHTS, + CAP_THRESHOLDS, + CHOP_THRESHOLDS, + DIST_THRESHOLDS, + CORE_BLOCK_THRESHOLDS, + ) + ] + + approx_rows: list[dict[str, object]] = [] + static_1y_ann = float(static_exact["windows"]["1y"]["annualized_return"]) + static_5y_ann = float(static_exact["windows"]["5y"]["annualized_return"]) + static_5y_mdd = float(static_exact["windows"]["5y"]["max_drawdown"]) + + for idx, candidate in enumerate(candidates, start=1): + curve, weights = compose_cash_overlay_curve(candidate=candidate, **components) + window_results, year_results, score, negative_years, mdd_violations = _evaluate_from_curve(curve, latest_bar) + beat_static_flags = { + "1y_ann": float(window_results["1y"]["annualized_return"]) > static_1y_ann, + "5y_ann": float(window_results["5y"]["annualized_return"]) > static_5y_ann, + "5y_mdd": float(window_results["5y"]["max_drawdown"]) >= static_5y_mdd, + } + approx_rows.append( + { + "candidate": asdict(candidate), + "name": candidate.name, + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "windows": window_results, + "years": year_results, + "avg_weights": { + "cap": float(weights["cap_weight"].mean()), + "chop": float(weights["chop_weight"].mean()), + "dist": float(weights["dist_weight"].mean()), + "overlay_total": float(weights["overlay_total"].mean()), + "core_cash_pct": float(weights["core_cash_pct"].mean()), + }, + "beat_static": beat_static_flags, + "validation": "approx_full_curve_slice_cash_overlay", + } + ) + if idx % 500 == 0 or idx == len(candidates): + print( + f"[approx {idx:04d}/{len(candidates)}] " + f"1y={window_results['1y']['total_return'] * 100:.2f}% " + f"5y_ann={window_results['5y']['annualized_return'] * 100:.2f}%", + flush=True, + ) + + approx_rows.sort( + key=lambda row: ( + int(not row["beat_static"]["5y_ann"]), + int(not row["beat_static"]["1y_ann"]), + int(row["negative_years"]), + int(row["mdd_violations"]), + -float(row["score"]), + ) + ) + + exact_top: list[dict[str, object]] = [] + print("[stage] begin exact validation for top candidates", flush=True) + for row in approx_rows[:5]: + candidate = CashOverlayCandidate(**row["candidate"]) + print(f"[exact-start] {candidate.name}", flush=True) + result = evaluate_cash_overlay_exact(bundle=bundle, latest_bar=latest_bar, candidate=candidate) + exact_top.append(result) + print( + f"[exact] {candidate.name} 1y={result['windows']['1y']['total_return'] * 100:.2f}% " + f"5y_ann={result['windows']['5y']['annualized_return'] * 100:.2f}% " + f"neg={result['negative_years']} mdd_viol={result['mdd_violations']}", + flush=True, + ) + + exact_top.sort( + key=lambda row: ( + int(float(row["windows"]["5y"]["annualized_return"]) <= static_5y_ann), + int(float(row["windows"]["1y"]["annualized_return"]) <= static_1y_ann), + int(row["negative_years"]), + int(row["mdd_violations"]), + -float(row["score"]), + ) + ) + best_exact = exact_top[0] + + soft_exact = json.loads(SOFT_JSON.read_text(encoding="utf-8")) if SOFT_JSON.exists() else None + + payload = { + "analysis": "strategy32_cash_overlay_search", + "latest_completed_bar": str(latest_bar), + "candidate_count": len(candidates), + "core_filter": CORE_FILTER, + "engines": { + "cap": CAP_ENGINE, + "chop": CHOP_ENGINE, + "dist": DIST_ENGINE, + }, + "exposure_summary": exposure_summary, + "static_exact": static_exact, + "summary": approx_rows[:20], + "exact_top": exact_top, + "best_exact": best_exact, + "best_soft_exact": soft_exact, + } + print("[stage] write outputs", flush=True) + OUT_JSON.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + lines = [ + "# Strategy32 Cash Overlay 탐색결과", + "", + "## 1. 목적", + "", + "정적 core를 줄이던 기존 soft-router를 버리고, `overheat_tolerant` core가 실제로 비워두는 현금 위에만 adverse 엔진을 얹는 cash-overlay 구조를 탐색한다.", + "", + "## 2. 왜 구조를 바꿨는가", + "", + f"- core `overheat_tolerant` 5y 평균 현금 비중: `{exposure_summary['avg_cash_pct'] * 100:.2f}%`", + f"- core 중앙값 현금 비중: `{exposure_summary['median_cash_pct'] * 100:.2f}%`", + f"- 현금 비중 `> 50%` 바 비율: `{exposure_summary['cash_gt_50_pct'] * 100:.2f}%`", + f"- 현금 비중 `> 80%` 바 비율: `{exposure_summary['cash_gt_80_pct'] * 100:.2f}%`", + "", + "즉 기존 soft-router는 이미 대부분 현금인 core를 또 줄이고 있었다. overlay는 core를 대체하는 게 아니라, core가 실제로 안 쓰는 현금에만 들어가야 맞다.", + "", + "## 3. 탐색 범위", + "", + f"- profile: `{PROFILE}`", + f"- core filter: `{CORE_FILTER}`", + f"- cap engine: `{CAP_ENGINE}`", + f"- chop engine: `{CHOP_ENGINE}`", + f"- dist engine: `{DIST_ENGINE}`", + f"- cap cash weights: `{CAP_CASH_WEIGHTS}`", + f"- chop cash weights: `{CHOP_CASH_WEIGHTS}`", + f"- dist cash weights: `{DIST_CASH_WEIGHTS}`", + f"- cap thresholds: `{CAP_THRESHOLDS}`", + f"- chop thresholds: `{CHOP_THRESHOLDS}`", + f"- dist thresholds: `{DIST_THRESHOLDS}`", + f"- core block thresholds: `{CORE_BLOCK_THRESHOLDS}`", + f"- candidate count: `{len(candidates)}`", + "", + "## 4. 정적 core exact 기준선", + "", + f"- 1y: {_metric_line(static_exact['windows']['1y'], include_ann=False)}", + f"- 2y: {_metric_line(static_exact['windows']['2y'], include_ann=True)}", + f"- 3y: {_metric_line(static_exact['windows']['3y'], include_ann=True)}", + f"- 4y: {_metric_line(static_exact['windows']['4y'], include_ann=True)}", + f"- 5y: {_metric_line(static_exact['windows']['5y'], include_ann=True)}", + f"- 2026 YTD: {_metric_line(static_exact['years']['2026_YTD'], include_ann=False)}", + f"- 2025: {_metric_line(static_exact['years']['2025'], include_ann=False)}", + f"- 2024: {_metric_line(static_exact['years']['2024'], include_ann=False)}", + f"- 2023: {_metric_line(static_exact['years']['2023'], include_ann=False)}", + f"- 2022: {_metric_line(static_exact['years']['2022'], include_ann=False)}", + f"- 2021: {_metric_line(static_exact['years']['2021'], include_ann=False)}", + "", + "## 5. cash-overlay exact 상위 후보", + "", + ] + + for idx, row in enumerate(exact_top, start=1): + candidate = row["candidate"] + lines.extend( + [ + f"### {idx}. {row['name']}", + "", + f"- weights: `cap {candidate['cap_cash_weight']:.2f}`, `chop {candidate['chop_cash_weight']:.2f}`, `dist {candidate['dist_cash_weight']:.2f}`", + f"- thresholds: `cap {candidate['cap_threshold']:.2f}`, `chop {candidate['chop_threshold']:.2f}`, `dist {candidate['dist_threshold']:.2f}`, `block {candidate['core_block_threshold']:.2f}`", + f"- 1y: {_metric_line(row['windows']['1y'], include_ann=False)}", + f"- 2y: {_metric_line(row['windows']['2y'], include_ann=True)}", + f"- 3y: {_metric_line(row['windows']['3y'], include_ann=True)}", + f"- 4y: {_metric_line(row['windows']['4y'], include_ann=True)}", + f"- 5y: {_metric_line(row['windows']['5y'], include_ann=True)}", + f"- 2026 YTD: {_metric_line(row['years']['2026_YTD'], include_ann=False)}", + f"- 2025: {_metric_line(row['years']['2025'], include_ann=False)}", + f"- 2024: {_metric_line(row['years']['2024'], include_ann=False)}", + f"- 2023: {_metric_line(row['years']['2023'], include_ann=False)}", + f"- 2022: {_metric_line(row['years']['2022'], include_ann=False)}", + f"- 2021: {_metric_line(row['years']['2021'], include_ann=False)}", + f"- score `{row['score']:.3f}`, negative years `{row['negative_years']}`, mdd violations `{row['mdd_violations']}`", + "", + ] + ) + + lines.extend( + [ + "## 6. 결론", + "", + ( + "cash-overlay가 정적 core보다 나아졌는지는 `best_exact`와 `static_exact` 비교로 판단한다. " + "핵심 비교 포인트는 `1y`, `5y annualized`, `5y MDD`, 그리고 `2025/2024`의 음수 여부다." + ), + "", + f"- best cash-overlay 1y: `{best_exact['windows']['1y']['total_return'] * 100:.2f}%` vs static `{static_exact['windows']['1y']['total_return'] * 100:.2f}%`", + f"- best cash-overlay 5y ann: `{best_exact['windows']['5y']['annualized_return'] * 100:.2f}%` vs static `{static_exact['windows']['5y']['annualized_return'] * 100:.2f}%`", + f"- best cash-overlay 5y MDD: `{best_exact['windows']['5y']['max_drawdown'] * 100:.2f}%` vs static `{static_exact['windows']['5y']['max_drawdown'] * 100:.2f}%`", + "", + ] + ) + + if soft_exact: + lines.extend( + [ + "## 7. 기존 replacement soft-router와 비교", + "", + f"- previous soft 1y: `{soft_exact['windows']['1y']['total_return'] * 100:.2f}%`", + f"- previous soft 5y ann: `{soft_exact['windows']['5y']['annualized_return'] * 100:.2f}%`", + f"- previous soft 5y MDD: `{soft_exact['windows']['5y']['max_drawdown'] * 100:.2f}%`", + "", + ] + ) + + OUT_MD.write_text("\n".join(lines), encoding="utf-8") + print("[done] cash overlay search complete", flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_current_relaxed_hybrid_exact.py b/scripts/run_current_relaxed_hybrid_exact.py new file mode 100644 index 0000000..85d2f60 --- /dev/null +++ b/scripts/run_current_relaxed_hybrid_exact.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import json +import multiprocessing as mp +import sys +from concurrent.futures import ProcessPoolExecutor, as_completed +from dataclasses import asdict +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy32.scripts.run_current_relaxed_hybrid_experiment import ( + BASELINE_PATH, + BEST_CASH_OVERLAY, + CACHE_PATH, + CURRENT_OVERHEAT_OVERRIDES, + OUT_JSON as SEARCH_OUT_JSON, + RELAXED_OVERHEAT_OVERRIDES, + WINDOWS, + YEAR_PERIODS, + YTD_START, + HybridSwitchCandidate, + _compose_hybrid_curve, +) +from strategy32.research.soft_router import build_cash_overlay_period_components, load_component_bundle, score_candidate, segment_metrics + + +OUT_JSON = Path("/tmp/strategy32_current_relaxed_hybrid_exact.json") + +BEST_SEARCH_CANDIDATE = HybridSwitchCandidate( + positive_regimes=("MOMENTUM_EXPANSION", "EUPHORIC_BREAKOUT"), + core_score_min=0.60, + breadth_persist_min=0.50, + funding_persist_min=0.55, + panic_max=0.20, + choppy_max=0.40, + distribution_max=0.30, +) + + +def _baseline_summary() -> dict[str, object]: + payload = json.loads(BASELINE_PATH.read_text(encoding="utf-8")) + variants = payload["variants"] + return {name: variants[name]["results"] for name in ("current_overheat", "relaxed_overheat")} + + +def _period_specs(latest_bar: pd.Timestamp) -> list[tuple[str, str, pd.Timestamp, pd.Timestamp]]: + specs: list[tuple[str, str, pd.Timestamp, pd.Timestamp]] = [] + for days, label in WINDOWS: + specs.append(("window", label, latest_bar - pd.Timedelta(days=days), latest_bar)) + for label, start, end_exclusive in YEAR_PERIODS: + specs.append(("year", label, start, min(latest_bar, end_exclusive - pd.Timedelta(seconds=1)))) + specs.append(("year", "2026_YTD", YTD_START, latest_bar)) + return specs + + +def _period_worker( + cache_path: str, + candidate_payload: dict[str, object], + kind: str, + label: str, + start_text: str, + end_text: str, +) -> tuple[str, str, dict[str, float], list[dict[str, object]]]: + bundle, _ = load_component_bundle(cache_path) + candidate = HybridSwitchCandidate(**candidate_payload) + start = pd.Timestamp(start_text) + end = pd.Timestamp(end_text) + current = build_cash_overlay_period_components( + bundle=bundle, + eval_start=start, + eval_end=end, + profile_name=BEST_CASH_OVERLAY.regime_profile, + core_filter=BEST_CASH_OVERLAY.core_filter, + cap_engine=BEST_CASH_OVERLAY.cap_engine, + chop_engine=BEST_CASH_OVERLAY.chop_engine, + dist_engine=BEST_CASH_OVERLAY.dist_engine, + core_config_overrides=CURRENT_OVERHEAT_OVERRIDES, + ) + relaxed = build_cash_overlay_period_components( + bundle=bundle, + eval_start=start, + eval_end=end, + profile_name=BEST_CASH_OVERLAY.regime_profile, + core_filter=BEST_CASH_OVERLAY.core_filter, + cap_engine=BEST_CASH_OVERLAY.cap_engine, + chop_engine=BEST_CASH_OVERLAY.chop_engine, + dist_engine=BEST_CASH_OVERLAY.dist_engine, + core_config_overrides=RELAXED_OVERHEAT_OVERRIDES, + ) + curve, rows = _compose_hybrid_curve( + current_components=current, + relaxed_components=relaxed, + switch_candidate=candidate, + ) + latest_rows: list[dict[str, object]] = [] + if label == "2026_YTD": + latest_rows = rows.tail(5).assign(timestamp=lambda df: df["timestamp"].astype(str)).to_dict(orient="records") + return kind, label, segment_metrics(curve, start, end), latest_rows + + +def main() -> None: + if SEARCH_OUT_JSON.exists(): + payload = json.loads(SEARCH_OUT_JSON.read_text(encoding="utf-8")) + if payload.get("search_top"): + best_candidate = HybridSwitchCandidate(**payload["search_top"][0]["candidate"]) + else: + best_candidate = BEST_SEARCH_CANDIDATE + else: + best_candidate = BEST_SEARCH_CANDIDATE + + _, latest_bar = load_component_bundle(CACHE_PATH) + window_results: dict[str, dict[str, float]] = {} + year_results: dict[str, dict[str, float]] = {} + latest_rows: list[dict[str, object]] = [] + + specs = _period_specs(latest_bar) + ctx = mp.get_context("fork") + with ProcessPoolExecutor(max_workers=min(6, len(specs)), mp_context=ctx) as executor: + future_map = { + executor.submit( + _period_worker, + CACHE_PATH, + asdict(best_candidate), + kind, + label, + str(start), + str(end), + ): (kind, label) + for kind, label, start, end in specs + } + for future in as_completed(future_map): + kind, label = future_map[future] + kind_result, label_result, metrics, latest = future.result() + if kind_result == "window": + window_results[label_result] = metrics + else: + year_results[label_result] = metrics + if latest: + latest_rows = latest + print(f"[done] {label_result}", flush=True) + + score, negative_years, mdd_violations = score_candidate( + {label: window_results[label] for _, label in WINDOWS}, + {label: year_results[label] for label, _, _ in YEAR_PERIODS}, + ) + payload = { + "analysis": "current_relaxed_hybrid_exact", + "latest_bar": str(latest_bar), + "candidate": asdict(best_candidate), + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "windows": {label: window_results[label] for _, label in WINDOWS}, + "years": year_results, + "latest_rows": latest_rows, + "baselines": _baseline_summary(), + } + OUT_JSON.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(json.dumps(payload, indent=2)) + print(f"[saved] {OUT_JSON}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_current_relaxed_hybrid_experiment.py b/scripts/run_current_relaxed_hybrid_experiment.py new file mode 100644 index 0000000..388ff97 --- /dev/null +++ b/scripts/run_current_relaxed_hybrid_experiment.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +import json +import sys +from dataclasses import asdict, dataclass +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy32.live.runtime import BEST_CASH_OVERLAY, LIVE_STRATEGY_OVERRIDES +from strategy32.research.soft_router import ( + build_cash_overlay_period_components, + load_component_bundle, + score_candidate, + segment_metrics, +) + + +CACHE_PATH = "/tmp/strategy32_fixed66_bundle.pkl" +BASELINE_PATH = Path("/tmp/strategy32_recent_core_filter_comparison.json") +OUT_JSON = Path("/tmp/strategy32_current_relaxed_hybrid_experiment.json") + +WINDOWS = ( + (365, "1y"), + (730, "2y"), + (1095, "3y"), + (1460, "4y"), + (1825, "5y"), +) + +YEAR_PERIODS = ( + ("2021", pd.Timestamp("2021-03-16 04:00:00+00:00"), pd.Timestamp("2022-01-01 00:00:00+00:00")), + ("2022", pd.Timestamp("2022-01-01 00:00:00+00:00"), pd.Timestamp("2023-01-01 00:00:00+00:00")), + ("2023", pd.Timestamp("2023-01-01 00:00:00+00:00"), pd.Timestamp("2024-01-01 00:00:00+00:00")), + ("2024", pd.Timestamp("2024-01-01 00:00:00+00:00"), pd.Timestamp("2025-01-01 00:00:00+00:00")), + ("2025", pd.Timestamp("2025-01-01 00:00:00+00:00"), pd.Timestamp("2026-01-01 00:00:00+00:00")), +) +YTD_START = pd.Timestamp("2026-01-01 00:00:00+00:00") + +CURRENT_OVERHEAT_OVERRIDES = { + **LIVE_STRATEGY_OVERRIDES, + "hard_filter_refresh_cadence": "1d", + "hard_filter_min_history_bars": 120, + "hard_filter_lookback_bars": 30, + "hard_filter_min_avg_dollar_volume": 50_000_000.0, +} + +RELAXED_OVERHEAT_OVERRIDES = { + **LIVE_STRATEGY_OVERRIDES, + "momentum_min_score": 0.58, + "momentum_min_relative_strength": -0.03, + "momentum_min_7d_return": 0.00, + "universe_min_avg_dollar_volume": 75_000_000.0, + "hard_filter_refresh_cadence": "1d", + "hard_filter_min_history_bars": 120, + "hard_filter_lookback_bars": 30, + "hard_filter_min_avg_dollar_volume": 50_000_000.0, +} + + +@dataclass(frozen=True, slots=True) +class HybridSwitchCandidate: + positive_regimes: tuple[str, ...] + core_score_min: float + breadth_persist_min: float + funding_persist_min: float + panic_max: float + choppy_max: float + distribution_max: float + + @property + def name(self) -> str: + regimes = ",".join(self.positive_regimes) + return ( + f"regimes:{regimes}" + f"|core>={self.core_score_min:.2f}" + f"|breadth>={self.breadth_persist_min:.2f}" + f"|funding>={self.funding_persist_min:.2f}" + f"|panic<={self.panic_max:.2f}" + f"|choppy<={self.choppy_max:.2f}" + f"|dist<={self.distribution_max:.2f}" + ) + + +def _clip01(value: float) -> float: + return min(max(float(value), 0.0), 1.0) + + +def _overlay_weights(candidate, score_row: dict[str, float], core_cash_pct: float) -> tuple[float, float, float]: + core_score = float(score_row.get("core_score", 0.0)) + panic_score = float(score_row.get("panic_score", 0.0)) + choppy_score = float(score_row.get("choppy_score", 0.0)) + distribution_score = float(score_row.get("distribution_score", 0.0)) + + cap_signal = _clip01((panic_score - candidate.cap_threshold) / max(1.0 - candidate.cap_threshold, 1e-9)) + chop_signal = _clip01((choppy_score - candidate.chop_threshold) / max(1.0 - candidate.chop_threshold, 1e-9)) + dist_signal = _clip01((distribution_score - candidate.dist_threshold) / max(1.0 - candidate.dist_threshold, 1e-9)) + if core_score > candidate.core_block_threshold: + chop_signal *= 0.25 + dist_signal *= 0.35 + + cap_weight = float(core_cash_pct) * candidate.cap_cash_weight * cap_signal + chop_weight = float(core_cash_pct) * candidate.chop_cash_weight * chop_signal + dist_weight = float(core_cash_pct) * candidate.dist_cash_weight * dist_signal + overlay_total = cap_weight + chop_weight + dist_weight + if overlay_total > core_cash_pct and overlay_total > 0.0: + scale = float(core_cash_pct) / overlay_total + cap_weight *= scale + chop_weight *= scale + dist_weight *= scale + return cap_weight, chop_weight, dist_weight + + +def _pick_relaxed(score_row: dict[str, float], candidate: HybridSwitchCandidate) -> bool: + return ( + str(score_row.get("strategic_regime")) in candidate.positive_regimes + and float(score_row.get("core_score", 0.0)) >= candidate.core_score_min + and float(score_row.get("breadth_persist", 0.0) or 0.0) >= candidate.breadth_persist_min + and float(score_row.get("funding_persist", 0.0) or 0.0) >= candidate.funding_persist_min + and float(score_row.get("panic_score", 0.0)) <= candidate.panic_max + and float(score_row.get("choppy_score", 0.0)) <= candidate.choppy_max + and float(score_row.get("distribution_score", 0.0)) <= candidate.distribution_max + ) + + +def _compose_hybrid_curve( + *, + current_components: dict[str, object], + relaxed_components: dict[str, object], + switch_candidate: HybridSwitchCandidate, +) -> tuple[pd.Series, pd.DataFrame]: + timestamps = list(current_components["timestamps"]) + score_map = current_components["score_frame"].set_index("timestamp").sort_index() + current_cash_map = current_components["core_exposure_frame"].set_index("timestamp")["cash_pct"].sort_index() + relaxed_cash_map = relaxed_components["core_exposure_frame"].set_index("timestamp")["cash_pct"].sort_index() + current_core_returns = current_components["core_returns"] + relaxed_core_returns = relaxed_components["core_returns"] + cap_returns = current_components["cap_returns"] + chop_returns = current_components["chop_returns"] + dist_returns = current_components["dist_returns"] + + equity = 1000.0 + idx = [timestamps[0]] + vals = [equity] + rows: list[dict[str, object]] = [] + for i in range(1, len(timestamps)): + signal_ts = pd.Timestamp(timestamps[i - 1]) + execution_ts = pd.Timestamp(timestamps[i]) + score_row = score_map.loc[signal_ts].to_dict() if signal_ts in score_map.index else {} + use_relaxed = _pick_relaxed(score_row, switch_candidate) + active_name = "relaxed_overheat" if use_relaxed else "current_overheat" + core_returns = relaxed_core_returns if use_relaxed else current_core_returns + cash_map = relaxed_cash_map if use_relaxed else current_cash_map + core_cash_pct = float(cash_map.get(signal_ts, cash_map.iloc[-1] if not cash_map.empty else 1.0)) + cap_weight, chop_weight, dist_weight = _overlay_weights(BEST_CASH_OVERLAY, score_row, core_cash_pct) + bar_ret = ( + float(core_returns.get(execution_ts, 0.0)) + + cap_weight * float(cap_returns.get(execution_ts, 0.0)) + + chop_weight * float(chop_returns.get(execution_ts, 0.0)) + + dist_weight * float(dist_returns.get(execution_ts, 0.0)) + ) + equity *= max(0.0, 1.0 + bar_ret) + idx.append(execution_ts) + vals.append(equity) + rows.append( + { + "timestamp": execution_ts, + "active_core": active_name, + "core_cash_pct": core_cash_pct, + "core_score": float(score_row.get("core_score", 0.0)), + "panic_score": float(score_row.get("panic_score", 0.0)), + "choppy_score": float(score_row.get("choppy_score", 0.0)), + "distribution_score": float(score_row.get("distribution_score", 0.0)), + "breadth_persist": float(score_row.get("breadth_persist", 0.0) or 0.0), + "funding_persist": float(score_row.get("funding_persist", 0.0) or 0.0), + "portfolio_return": bar_ret, + } + ) + curve = pd.Series(vals, index=pd.DatetimeIndex(idx, name="timestamp"), dtype=float) + return curve, pd.DataFrame(rows) + + +def _metrics_for_curve(curve: pd.Series, latest_bar: pd.Timestamp) -> tuple[dict[str, dict[str, float]], dict[str, dict[str, float]], float, int, int]: + windows: dict[str, dict[str, float]] = {} + for days, label in WINDOWS: + start = latest_bar - pd.Timedelta(days=days) + windows[label] = segment_metrics(curve, start, latest_bar) + years: dict[str, dict[str, float]] = {} + for label, start, end_exclusive in YEAR_PERIODS: + years[label] = segment_metrics(curve, start, min(latest_bar, end_exclusive - pd.Timedelta(seconds=1))) + years["2026_YTD"] = segment_metrics(curve, YTD_START, latest_bar) + score, negative_years, mdd_violations = score_candidate( + {label: windows[label] for _, label in WINDOWS}, + {label: years[label] for label, _, _ in YEAR_PERIODS}, + ) + return windows, years, score, negative_years, mdd_violations + + +def _candidate_space() -> list[HybridSwitchCandidate]: + space: list[HybridSwitchCandidate] = [] + positive_sets = ( + ("EUPHORIC_BREAKOUT",), + ("MOMENTUM_EXPANSION", "EUPHORIC_BREAKOUT"), + ) + for positive_regimes in positive_sets: + for core_score_min in (0.50, 0.55, 0.60): + for breadth_persist_min in (0.50, 0.55, 0.60): + for funding_persist_min in (0.55, 0.60, 0.65): + for panic_max in (0.20, 0.30): + for choppy_max in (0.40, 0.50): + for distribution_max in (0.30, 0.40): + space.append( + HybridSwitchCandidate( + positive_regimes=positive_regimes, + core_score_min=core_score_min, + breadth_persist_min=breadth_persist_min, + funding_persist_min=funding_persist_min, + panic_max=panic_max, + choppy_max=choppy_max, + distribution_max=distribution_max, + ) + ) + return space + + +def _baseline_summary() -> dict[str, object]: + payload = json.loads(BASELINE_PATH.read_text(encoding="utf-8")) + variants = payload["variants"] + result: dict[str, object] = {} + for name in ("current_overheat", "relaxed_overheat"): + result[name] = variants[name]["results"] + return result + + +def _evaluate_exact_candidate(bundle, latest_bar: pd.Timestamp, switch_candidate: HybridSwitchCandidate) -> dict[str, object]: + windows: dict[str, dict[str, float]] = {} + years: dict[str, dict[str, float]] = {} + latest_rows: list[dict[str, object]] = [] + periods = [ + *(("window", label, latest_bar - pd.Timedelta(days=days), latest_bar) for days, label in WINDOWS), + *(("year", label, start, min(latest_bar, end_exclusive - pd.Timedelta(seconds=1))) for label, start, end_exclusive in YEAR_PERIODS), + ("year", "2026_YTD", YTD_START, latest_bar), + ] + for kind, label, start, end in periods: + current = build_cash_overlay_period_components( + bundle=bundle, + eval_start=start, + eval_end=end, + profile_name=BEST_CASH_OVERLAY.regime_profile, + core_filter=BEST_CASH_OVERLAY.core_filter, + cap_engine=BEST_CASH_OVERLAY.cap_engine, + chop_engine=BEST_CASH_OVERLAY.chop_engine, + dist_engine=BEST_CASH_OVERLAY.dist_engine, + core_config_overrides=CURRENT_OVERHEAT_OVERRIDES, + ) + relaxed = build_cash_overlay_period_components( + bundle=bundle, + eval_start=start, + eval_end=end, + profile_name=BEST_CASH_OVERLAY.regime_profile, + core_filter=BEST_CASH_OVERLAY.core_filter, + cap_engine=BEST_CASH_OVERLAY.cap_engine, + chop_engine=BEST_CASH_OVERLAY.chop_engine, + dist_engine=BEST_CASH_OVERLAY.dist_engine, + core_config_overrides=RELAXED_OVERHEAT_OVERRIDES, + ) + curve, rows = _compose_hybrid_curve( + current_components=current, + relaxed_components=relaxed, + switch_candidate=switch_candidate, + ) + metrics = segment_metrics(curve, start, end) + if kind == "window": + windows[label] = metrics + else: + years[label] = metrics + if label == "2026_YTD": + latest_rows = rows.tail(3).assign(timestamp=lambda df: df["timestamp"].astype(str)).to_dict(orient="records") + score, negative_years, mdd_violations = score_candidate( + {label: windows[label] for _, label in WINDOWS}, + {label: years[label] for label, _, _ in YEAR_PERIODS}, + ) + return { + "candidate": asdict(switch_candidate), + "name": switch_candidate.name, + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "windows": windows, + "years": years, + "latest_rows": latest_rows, + } + + +def main() -> None: + bundle, latest_bar = load_component_bundle(CACHE_PATH) + eval_start = latest_bar - pd.Timedelta(days=1825) + print("[phase] build current components", flush=True) + current_components = build_cash_overlay_period_components( + bundle=bundle, + eval_start=eval_start, + eval_end=latest_bar, + profile_name=BEST_CASH_OVERLAY.regime_profile, + core_filter=BEST_CASH_OVERLAY.core_filter, + cap_engine=BEST_CASH_OVERLAY.cap_engine, + chop_engine=BEST_CASH_OVERLAY.chop_engine, + dist_engine=BEST_CASH_OVERLAY.dist_engine, + core_config_overrides=CURRENT_OVERHEAT_OVERRIDES, + ) + print("[phase] build relaxed components", flush=True) + relaxed_components = build_cash_overlay_period_components( + bundle=bundle, + eval_start=eval_start, + eval_end=latest_bar, + profile_name=BEST_CASH_OVERLAY.regime_profile, + core_filter=BEST_CASH_OVERLAY.core_filter, + cap_engine=BEST_CASH_OVERLAY.cap_engine, + chop_engine=BEST_CASH_OVERLAY.chop_engine, + dist_engine=BEST_CASH_OVERLAY.dist_engine, + core_config_overrides=RELAXED_OVERHEAT_OVERRIDES, + ) + + search_rows: list[dict[str, object]] = [] + candidates = _candidate_space() + print("[phase] search switch candidates", flush=True) + for idx, candidate in enumerate(candidates, start=1): + curve, rows = _compose_hybrid_curve( + current_components=current_components, + relaxed_components=relaxed_components, + switch_candidate=candidate, + ) + windows, years, score, negative_years, mdd_violations = _metrics_for_curve(curve, latest_bar) + relaxed_share = float((rows["active_core"] == "relaxed_overheat").mean()) if not rows.empty else 0.0 + search_rows.append( + { + "candidate": asdict(candidate), + "name": candidate.name, + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "relaxed_share": relaxed_share, + "windows": windows, + "years": years, + } + ) + if idx % 36 == 0 or idx == len(candidates): + print(f"[search] {idx}/{len(candidates)}", flush=True) + + search_rows.sort(key=lambda row: float(row["score"]), reverse=True) + best_search = search_rows[0] + print(f"[phase] exact best {best_search['name']}", flush=True) + best_exact = _evaluate_exact_candidate( + bundle, + latest_bar, + HybridSwitchCandidate(**best_search["candidate"]), + ) + + payload = { + "analysis": "current_relaxed_hybrid_experiment", + "latest_bar": str(latest_bar), + "candidate": asdict(BEST_CASH_OVERLAY), + "baselines": _baseline_summary(), + "search_top": search_rows[:5], + "best_exact": best_exact, + } + OUT_JSON.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(json.dumps(payload, indent=2)) + print(f"[saved] {OUT_JSON}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_exhaustive_combo_analysis.py b/scripts/run_exhaustive_combo_analysis.py new file mode 100644 index 0000000..fc5a883 --- /dev/null +++ b/scripts/run_exhaustive_combo_analysis.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import copy +import itertools +import json +import sys +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy29.backtest.window_analysis import evaluate_window_result, slice_bundle +from strategy32.backtest.simulator import Strategy32Backtester +from strategy32.config import PROFILE_V5_BASELINE, build_strategy32_config +from strategy32.data import build_strategy32_market_bundle + + +WINDOWS = [(30, "1m"), (365, "1y"), (1095, "3y"), (1825, "5y")] +FEATURES: list[tuple[str, str, bool]] = [ + ("no_sideways", "enable_sideways_engine", False), + ("strong_kill_switch", "enable_strong_kill_switch", True), + ("daily_trend_filter", "enable_daily_trend_filter", True), + ("expanded_hedge", "enable_expanded_hedge", True), + ("max_holding_exit", "enable_max_holding_exit", True), +] + + +def variant_name(enabled: list[str]) -> str: + return "baseline_v5" if not enabled else "+".join(enabled) + + +def balanced_score(results: dict[str, dict[str, float | int | str]]) -> float: + score = 0.0 + for label, weight in (("1y", 1.0), ("3y", 1.0), ("5y", 1.2)): + annualized = float(results[label]["annualized_return"]) + drawdown = abs(float(results[label]["max_drawdown"])) + score += weight * (annualized / max(drawdown, 0.01)) + score += 0.25 * float(results["1m"]["total_return"]) + return score + + +def build_variants() -> list[tuple[str, dict[str, bool], list[str]]]: + variants: list[tuple[str, dict[str, bool], list[str]]] = [("baseline_v5", {}, [])] + feature_names = [feature[0] for feature in FEATURES] + for r in range(1, len(FEATURES) + 1): + for combo in itertools.combinations(range(len(FEATURES)), r): + overrides: dict[str, bool] = {} + enabled: list[str] = [] + for idx in combo: + label, attr, value = FEATURES[idx] + overrides[attr] = value + enabled.append(label) + variants.append((variant_name(enabled), overrides, enabled)) + return variants + + +def main() -> None: + base = build_strategy32_config(PROFILE_V5_BASELINE) + end = pd.Timestamp("2026-03-15 00:00:00", tz="UTC") + start = end - pd.Timedelta(days=max(days for days, _ in WINDOWS) + base.warmup_days + 14) + + print("fetching bundle...") + bundle, latest_completed_bar, accepted_symbols, rejected_symbols, quote_by_symbol = build_strategy32_market_bundle( + symbols=base.symbols, + auto_discover_symbols=base.auto_discover_symbols, + quote_assets=base.quote_assets, + excluded_base_assets=base.excluded_base_assets, + min_quote_volume_24h=base.discovery_min_quote_volume_24h, + start=start, + end=end, + timeframe=base.timeframe, + max_staleness_days=base.max_symbol_staleness_days, + ) + print("latest", latest_completed_bar) + + results: dict[str, dict[str, dict[str, float | int | str]]] = {} + summary_rows: list[dict[str, float | int | str | list[str]]] = [] + + for idx, (name, overrides, enabled) in enumerate(build_variants(), start=1): + cfg = copy.deepcopy(base) + for attr, value in overrides.items(): + setattr(cfg, attr, value) + variant_results = {} + print(f"\n[{idx:02d}/32] {name}") + for days, label in WINDOWS: + eval_end = latest_completed_bar + eval_start = eval_end - pd.Timedelta(days=days) + raw_start = eval_start - pd.Timedelta(days=cfg.warmup_days) + sliced = slice_bundle(bundle, raw_start, eval_end) + backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start) + backtester.engine_config.initial_capital = 1000.0 + result = backtester.run() + metrics = evaluate_window_result(result, eval_start=eval_start, bars_per_day=backtester.engine_config.bars_per_day) + metrics["engine_pnl"] = result.engine_pnl + metrics["total_trades"] = result.total_trades + variant_results[label] = metrics + print( + label, + "ret", + round(float(metrics["total_return"]) * 100, 2), + "mdd", + round(float(metrics["max_drawdown"]) * 100, 2), + "sharpe", + round(float(metrics["sharpe"]), 2), + "trades", + metrics["trade_count"], + ) + score = balanced_score(variant_results) + results[name] = variant_results + summary_rows.append( + { + "name": name, + "enabled": enabled, + "balanced_score": score, + "ret_1m": float(variant_results["1m"]["total_return"]), + "ret_1y": float(variant_results["1y"]["total_return"]), + "ret_3y": float(variant_results["3y"]["total_return"]), + "ret_5y": float(variant_results["5y"]["total_return"]), + "mdd_1y": float(variant_results["1y"]["max_drawdown"]), + "mdd_3y": float(variant_results["3y"]["max_drawdown"]), + "mdd_5y": float(variant_results["5y"]["max_drawdown"]), + } + ) + + summary_rows.sort(key=lambda row: float(row["balanced_score"]), reverse=True) + payload = { + "strategy": "strategy32", + "analysis": "v6_exhaustive_combo", + "initial_capital": 1000.0, + "auto_discover_symbols": base.auto_discover_symbols, + "latest_completed_bar": str(latest_completed_bar), + "requested_symbols": [] if base.auto_discover_symbols else base.symbols, + "accepted_symbols": accepted_symbols, + "rejected_symbols": rejected_symbols, + "quote_by_symbol": quote_by_symbol, + "timeframe": base.timeframe, + "results": results, + "summary": summary_rows, + } + out = Path("/tmp/strategy32_v6_exhaustive_combos.json") + out.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print("\nTop 10 by balanced score") + for row in summary_rows[:10]: + print( + row["name"], + "score", + round(float(row["balanced_score"]), 3), + "1y", + round(float(row["ret_1y"]) * 100, 2), + "3y", + round(float(row["ret_3y"]) * 100, 2), + "5y", + round(float(row["ret_5y"]) * 100, 2), + "mdd5y", + round(float(row["mdd_5y"]) * 100, 2), + ) + print("\nwrote", out) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_filter_search.py b/scripts/run_filter_search.py new file mode 100644 index 0000000..35f2a59 --- /dev/null +++ b/scripts/run_filter_search.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import copy +import itertools +import json +import multiprocessing as mp +import sys +from concurrent.futures import ProcessPoolExecutor, as_completed +from dataclasses import asdict, dataclass +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy29.backtest.window_analysis import evaluate_window_result, slice_bundle +from strategy29.common.models import MarketDataBundle +from strategy32.backtest.simulator import Strategy32Backtester +from strategy32.config import PROFILE_V7_DEFAULT, Strategy32Config, build_strategy32_config +from strategy32.data import build_strategy32_market_bundle + + +COARSE_WINDOWS = [(365, "1y"), (1095, "3y")] +FINAL_WINDOWS = [(365, "1y"), (1095, "3y"), (1825, "5y")] +COARSE_LIQUIDITY_FLOORS = [5_000_000.0, 10_000_000.0, 20_000_000.0, 50_000_000.0] +COARSE_MOMENTUM_SCORES = [0.55, 0.60, 0.65] +COARSE_RELATIVE_STRENGTH = [0.00, 0.02] +COARSE_7D_RETURNS = [0.00, 0.02] +FINE_CORRELATION_CAPS = [0.70, 0.78] +FINE_CARRY_MIN_EDGE = [0.0, 0.002] +TOP_COARSE_FOR_FINE = 8 +TOP_FINE_FOR_FINAL = 5 + + +@dataclass(slots=True) +class FilterVariant: + name: str + liquidity_floor: float + momentum_min_score: float + momentum_min_relative_strength: float + momentum_min_7d_return: float + max_pairwise_correlation: float + carry_min_expected_edge: float + + +GLOBAL_BUNDLE: MarketDataBundle | None = None +GLOBAL_LATEST_BAR: pd.Timestamp | None = None + + +def _subset_bundle(bundle: MarketDataBundle, symbols: set[str]) -> MarketDataBundle: + return MarketDataBundle( + prices={symbol: df for symbol, df in bundle.prices.items() if symbol in symbols}, + funding={symbol: df for symbol, df in bundle.funding.items() if symbol in symbols}, + ) + + +def _score_results(results: dict[str, dict[str, float | int | str]], include_5y: bool) -> float: + score = 0.0 + ret_1y = float(results["1y"]["total_return"]) + ann_1y = float(results["1y"]["annualized_return"]) + mdd_1y = abs(float(results["1y"]["max_drawdown"])) + ret_3y = float(results["3y"]["total_return"]) + ann_3y = float(results["3y"]["annualized_return"]) + mdd_3y = abs(float(results["3y"]["max_drawdown"])) + score += 1.8 * (ann_1y / max(mdd_1y, 0.01)) + score += 1.0 * (ann_3y / max(mdd_3y, 0.01)) + score += 0.25 * ret_1y + 0.15 * ret_3y + if ret_1y <= 0: + score -= 6.0 + if float(results["1y"]["max_drawdown"]) < -0.25: + score -= 1.0 + if include_5y: + ret_5y = float(results["5y"]["total_return"]) + ann_5y = float(results["5y"]["annualized_return"]) + mdd_5y = abs(float(results["5y"]["max_drawdown"])) + score += 0.8 * (ann_5y / max(mdd_5y, 0.01)) + score += 0.10 * ret_5y + if float(results["5y"]["max_drawdown"]) < -0.30: + score -= 1.0 + return score + + +def _evaluate_variant(variant: FilterVariant, windows: list[tuple[int, str]]) -> dict[str, object]: + if GLOBAL_BUNDLE is None or GLOBAL_LATEST_BAR is None: + raise RuntimeError("global bundle not initialized") + cfg: Strategy32Config = build_strategy32_config(PROFILE_V7_DEFAULT) + cfg.discovery_min_quote_volume_24h = variant.liquidity_floor + cfg.universe_min_avg_dollar_volume = variant.liquidity_floor + cfg.momentum_min_score = variant.momentum_min_score + cfg.momentum_min_relative_strength = variant.momentum_min_relative_strength + cfg.momentum_min_7d_return = variant.momentum_min_7d_return + cfg.max_pairwise_correlation = variant.max_pairwise_correlation + cfg.carry_min_expected_edge = variant.carry_min_expected_edge + + bundle = GLOBAL_BUNDLE + results: dict[str, dict[str, float | int | str]] = {} + for days, label in windows: + eval_end = GLOBAL_LATEST_BAR + eval_start = eval_end - pd.Timedelta(days=days) + raw_start = eval_start - pd.Timedelta(days=cfg.warmup_days) + sliced = slice_bundle(bundle, raw_start, eval_end) + backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start) + backtester.engine_config.initial_capital = 1000.0 + result = backtester.run() + metrics = evaluate_window_result(result, eval_start=eval_start, bars_per_day=backtester.engine_config.bars_per_day) + metrics["engine_pnl"] = result.engine_pnl + metrics["total_trades"] = result.total_trades + metrics["universe_size"] = len(bundle.prices) + results[label] = metrics + + score = _score_results(results, include_5y=any(label == "5y" for _, label in windows)) + return { + "variant": asdict(variant), + "score": score, + "results": results, + } + + +def _run_parallel(variants: list[FilterVariant], windows: list[tuple[int, str]], workers: int = 6) -> list[dict[str, object]]: + ctx = mp.get_context("fork") + rows: list[dict[str, object]] = [] + with ProcessPoolExecutor(max_workers=workers, mp_context=ctx) as executor: + future_map = {executor.submit(_evaluate_variant, variant, windows): variant for variant in variants} + total = len(future_map) + done = 0 + for future in as_completed(future_map): + row = future.result() + rows.append(row) + done += 1 + variant = row["variant"] + results = row["results"] + print( + f"[{done:02d}/{total}] {variant['name']} score={row['score']:.3f} " + f"1y={float(results['1y']['total_return'])*100:.2f}% " + f"3y={float(results['3y']['total_return'])*100:.2f}%", + flush=True, + ) + rows.sort(key=lambda row: float(row["score"]), reverse=True) + return rows + + +def _build_coarse_variants() -> list[FilterVariant]: + variants: list[FilterVariant] = [] + for floor, score, rs, ret7 in itertools.product( + COARSE_LIQUIDITY_FLOORS, + COARSE_MOMENTUM_SCORES, + COARSE_RELATIVE_STRENGTH, + COARSE_7D_RETURNS, + ): + name = f"liq{int(floor/1_000_000)}m_s{score:.2f}_rs{rs:.2f}_r7{ret7:.2f}" + variants.append( + FilterVariant( + name=name, + liquidity_floor=floor, + momentum_min_score=score, + momentum_min_relative_strength=rs, + momentum_min_7d_return=ret7, + max_pairwise_correlation=0.78, + carry_min_expected_edge=0.0, + ) + ) + return variants + + +def _build_fine_variants(top_rows: list[dict[str, object]]) -> list[FilterVariant]: + variants: list[FilterVariant] = [] + seen: set[tuple] = set() + for row in top_rows[:TOP_COARSE_FOR_FINE]: + base = row["variant"] + for corr_cap, carry_edge in itertools.product(FINE_CORRELATION_CAPS, FINE_CARRY_MIN_EDGE): + key = ( + base["liquidity_floor"], + base["momentum_min_score"], + base["momentum_min_relative_strength"], + base["momentum_min_7d_return"], + corr_cap, + carry_edge, + ) + if key in seen: + continue + seen.add(key) + variants.append( + FilterVariant( + name=( + f"liq{int(base['liquidity_floor']/1_000_000)}m_" + f"s{base['momentum_min_score']:.2f}_" + f"rs{base['momentum_min_relative_strength']:.2f}_" + f"r7{base['momentum_min_7d_return']:.2f}_" + f"corr{corr_cap:.2f}_carry{carry_edge:.3f}" + ), + liquidity_floor=float(base["liquidity_floor"]), + momentum_min_score=float(base["momentum_min_score"]), + momentum_min_relative_strength=float(base["momentum_min_relative_strength"]), + momentum_min_7d_return=float(base["momentum_min_7d_return"]), + max_pairwise_correlation=float(corr_cap), + carry_min_expected_edge=float(carry_edge), + ) + ) + return variants + + +def main() -> None: + global GLOBAL_BUNDLE, GLOBAL_LATEST_BAR + + base = build_strategy32_config(PROFILE_V7_DEFAULT) + max_days = max(days for days, _ in FINAL_WINDOWS) + end = pd.Timestamp("2026-03-15 00:00:00", tz="UTC") + start_5y = end - pd.Timedelta(days=max_days + base.warmup_days + 14) + start_3y = end - pd.Timedelta(days=max(days for days, _ in COARSE_WINDOWS) + base.warmup_days + 14) + + lowest_floor = min(COARSE_LIQUIDITY_FLOORS) + print("fetching 3y bundle for coarse search...") + GLOBAL_BUNDLE, GLOBAL_LATEST_BAR, accepted_symbols_3y, rejected_symbols_3y, quote_by_symbol_3y = build_strategy32_market_bundle( + symbols=base.symbols, + auto_discover_symbols=True, + quote_assets=base.quote_assets, + excluded_base_assets=base.excluded_base_assets, + min_quote_volume_24h=lowest_floor, + start=start_3y, + end=end, + timeframe=base.timeframe, + max_staleness_days=base.max_symbol_staleness_days, + ) + + coarse_rows = _run_parallel(_build_coarse_variants(), COARSE_WINDOWS) + fine_rows = _run_parallel(_build_fine_variants(coarse_rows), COARSE_WINDOWS) + + print("fetching 5y bundle for final validation...") + GLOBAL_BUNDLE, GLOBAL_LATEST_BAR, accepted_symbols_5y, rejected_symbols_5y, quote_by_symbol_5y = build_strategy32_market_bundle( + symbols=base.symbols, + auto_discover_symbols=True, + quote_assets=base.quote_assets, + excluded_base_assets=base.excluded_base_assets, + min_quote_volume_24h=lowest_floor, + start=start_5y, + end=end, + timeframe=base.timeframe, + max_staleness_days=base.max_symbol_staleness_days, + ) + final_variants = [FilterVariant(**row["variant"]) for row in fine_rows[:TOP_FINE_FOR_FINAL]] + final_rows = _run_parallel(final_variants, FINAL_WINDOWS) + + payload = { + "strategy": "strategy32", + "analysis": "wide_universe_filter_search", + "profile": PROFILE_V7_DEFAULT, + "initial_capital": 1000.0, + "latest_completed_bar": str(GLOBAL_LATEST_BAR), + "accepted_symbols_3y": accepted_symbols_3y, + "rejected_symbols_3y": rejected_symbols_3y, + "quote_by_symbol_3y": quote_by_symbol_3y, + "accepted_symbols_5y": accepted_symbols_5y, + "rejected_symbols_5y": rejected_symbols_5y, + "quote_by_symbol_5y": quote_by_symbol_5y, + "coarse_top10": coarse_rows[:10], + "fine_top10": fine_rows[:10], + "final_ranked": final_rows, + } + out = Path("/tmp/strategy32_filter_search.json") + out.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print("\nTop final variants") + for row in final_rows: + metrics = row["results"] + print( + row["variant"]["name"], + "score", + round(float(row["score"]), 3), + "1y", + round(float(metrics["1y"]["total_return"]) * 100, 2), + "3y", + round(float(metrics["3y"]["total_return"]) * 100, 2), + "5y", + round(float(metrics["5y"]["total_return"]) * 100, 2), + "mdd5y", + round(float(metrics["5y"]["max_drawdown"]) * 100, 2), + ) + print("\nwrote", out) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_filter_search_extended.py b/scripts/run_filter_search_extended.py new file mode 100644 index 0000000..53b67c5 --- /dev/null +++ b/scripts/run_filter_search_extended.py @@ -0,0 +1,447 @@ +from __future__ import annotations + +import copy +import itertools +import json +import multiprocessing as mp +import random +import sys +from concurrent.futures import ProcessPoolExecutor, as_completed +from dataclasses import asdict, dataclass +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy29.backtest.window_analysis import evaluate_window_result, slice_bundle +from strategy29.common.models import MarketDataBundle +from strategy32.backtest.simulator import Strategy32Backtester +from strategy32.config import PROFILE_V7_DEFAULT, Strategy32Config, build_strategy32_config +from strategy32.data import build_strategy32_market_bundle + + +FINAL_WINDOWS = [(365, "1y"), (1095, "3y"), (1825, "5y")] +RANDOM_SEED = 32 +RANDOM_SAMPLE_SIZE = 96 +TOP_BROAD_FOR_LOCAL = 10 +LOCAL_SAMPLE_SIZE = 192 +TOP_FINAL_FOR_EXACT = 8 + +LIQUIDITY_FLOORS = [20_000_000.0, 30_000_000.0, 40_000_000.0, 50_000_000.0, 60_000_000.0, 75_000_000.0, 100_000_000.0] +AVG_DOLLAR_VOLUME_MULTIPLIERS = [0.50, 0.75, 1.00] +MOMENTUM_MIN_SCORES = [0.50, 0.55, 0.60, 0.65, 0.70] +MOMENTUM_MIN_RS = [-0.02, 0.00, 0.02] +MOMENTUM_MIN_7D = [-0.02, 0.00, 0.02] +CORRELATION_CAPS = [0.65, 0.70, 0.74, 0.78, 0.82] +CARRY_MIN_EDGES = [0.0, 0.001, 0.002] + + +@dataclass(slots=True) +class ExtendedFilterVariant: + name: str + liquidity_floor: float + avg_dollar_volume_floor: float + momentum_min_score: float + momentum_min_relative_strength: float + momentum_min_7d_return: float + max_pairwise_correlation: float + carry_min_expected_edge: float + + +GLOBAL_BUNDLE: MarketDataBundle | None = None +GLOBAL_LATEST_BAR: pd.Timestamp | None = None + + +def _subset_bundle(bundle: MarketDataBundle, symbols: set[str]) -> MarketDataBundle: + return MarketDataBundle( + prices={symbol: df for symbol, df in bundle.prices.items() if symbol in symbols}, + funding={symbol: df for symbol, df in bundle.funding.items() if symbol in symbols}, + ) + + +def _window_return(curve: pd.Series, start: pd.Timestamp, end: pd.Timestamp) -> float | None: + window = curve.loc[(curve.index >= start) & (curve.index <= end)] + if len(window) < 2: + return None + start_equity = float(window.iloc[0]) + end_equity = float(window.iloc[-1]) + if start_equity <= 0: + return None + return end_equity / start_equity - 1.0 + + +def _rolling_returns(curve: pd.Series, *, window_days: int, step_days: int) -> list[float]: + if curve.empty: + return [] + end = curve.index[-1] + start = curve.index[0] + returns: list[float] = [] + cursor = start + pd.Timedelta(days=window_days) + while cursor <= end: + ret = _window_return(curve, cursor - pd.Timedelta(days=window_days), cursor) + if ret is not None: + returns.append(ret) + cursor += pd.Timedelta(days=step_days) + return returns + + +def _summarize_rolling(curve: pd.Series) -> dict[str, float]: + rolling_12m = _rolling_returns(curve, window_days=365, step_days=30) + rolling_24m = _rolling_returns(curve, window_days=730, step_days=30) + metrics = { + "rolling_12m_count": float(len(rolling_12m)), + "rolling_12m_positive_ratio": float(sum(ret > 0 for ret in rolling_12m) / len(rolling_12m)) if rolling_12m else 0.0, + "rolling_12m_median": float(pd.Series(rolling_12m).median()) if rolling_12m else 0.0, + "rolling_12m_worst": float(min(rolling_12m)) if rolling_12m else 0.0, + "rolling_24m_count": float(len(rolling_24m)), + "rolling_24m_positive_ratio": float(sum(ret > 0 for ret in rolling_24m) / len(rolling_24m)) if rolling_24m else 0.0, + "rolling_24m_median": float(pd.Series(rolling_24m).median()) if rolling_24m else 0.0, + "rolling_24m_worst": float(min(rolling_24m)) if rolling_24m else 0.0, + } + return metrics + + +def _score_variant(full_metrics: dict[str, float], rolling_metrics: dict[str, float]) -> float: + annualized_return = float(full_metrics["annualized_return"]) + max_dd = abs(float(full_metrics["max_drawdown"])) + sharpe = float(full_metrics["sharpe"]) + calmar = annualized_return / max(max_dd, 0.01) + + score = 0.0 + score += 2.0 * calmar + score += 0.6 * sharpe + score += 1.6 * rolling_metrics["rolling_12m_positive_ratio"] + score += 1.0 * rolling_metrics["rolling_24m_positive_ratio"] + score += 3.0 * rolling_metrics["rolling_12m_median"] + score += 2.2 * rolling_metrics["rolling_24m_median"] + score += 1.8 * rolling_metrics["rolling_12m_worst"] + score += 1.0 * rolling_metrics["rolling_24m_worst"] + score += 0.25 * float(full_metrics["total_return"]) + + if rolling_metrics["rolling_12m_positive_ratio"] < 0.55: + score -= 0.8 + if rolling_metrics["rolling_12m_worst"] < -0.18: + score -= 1.2 + if float(full_metrics["max_drawdown"]) < -0.30: + score -= 1.0 + if annualized_return < 0.08: + score -= 0.6 + return score + + +def _evaluate_variant(variant: ExtendedFilterVariant) -> dict[str, object]: + if GLOBAL_BUNDLE is None or GLOBAL_LATEST_BAR is None: + raise RuntimeError("global bundle not initialized") + + cfg: Strategy32Config = build_strategy32_config(PROFILE_V7_DEFAULT) + cfg.discovery_min_quote_volume_24h = variant.liquidity_floor + cfg.universe_min_avg_dollar_volume = variant.avg_dollar_volume_floor + cfg.momentum_min_score = variant.momentum_min_score + cfg.momentum_min_relative_strength = variant.momentum_min_relative_strength + cfg.momentum_min_7d_return = variant.momentum_min_7d_return + cfg.max_pairwise_correlation = variant.max_pairwise_correlation + cfg.carry_min_expected_edge = variant.carry_min_expected_edge + + bundle = GLOBAL_BUNDLE + eval_end = GLOBAL_LATEST_BAR + eval_start = eval_end - pd.Timedelta(days=1825) + raw_start = eval_start - pd.Timedelta(days=cfg.warmup_days) + sliced = slice_bundle(bundle, raw_start, eval_end) + + backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start) + backtester.engine_config.initial_capital = 1000.0 + result = backtester.run() + + full_metrics = evaluate_window_result( + result, + eval_start=eval_start, + bars_per_day=backtester.engine_config.bars_per_day, + ) + curve = result.equity_curve.loc[result.equity_curve.index >= eval_start] + rolling_metrics = _summarize_rolling(curve) + score = _score_variant(full_metrics, rolling_metrics) + return { + "variant": asdict(variant), + "score": score, + "full_metrics": full_metrics, + "rolling_metrics": rolling_metrics, + "engine_pnl": result.engine_pnl, + "total_trades": result.total_trades, + "universe_size": len(bundle.prices), + } + + +def _evaluate_exact_windows(variant: ExtendedFilterVariant) -> dict[str, object]: + if GLOBAL_BUNDLE is None or GLOBAL_LATEST_BAR is None: + raise RuntimeError("global bundle not initialized") + + cfg: Strategy32Config = build_strategy32_config(PROFILE_V7_DEFAULT) + cfg.discovery_min_quote_volume_24h = variant.liquidity_floor + cfg.universe_min_avg_dollar_volume = variant.avg_dollar_volume_floor + cfg.momentum_min_score = variant.momentum_min_score + cfg.momentum_min_relative_strength = variant.momentum_min_relative_strength + cfg.momentum_min_7d_return = variant.momentum_min_7d_return + cfg.max_pairwise_correlation = variant.max_pairwise_correlation + cfg.carry_min_expected_edge = variant.carry_min_expected_edge + + bundle = GLOBAL_BUNDLE + results: dict[str, dict[str, float | int | str]] = {} + for days, label in FINAL_WINDOWS: + eval_end = GLOBAL_LATEST_BAR + eval_start = eval_end - pd.Timedelta(days=days) + raw_start = eval_start - pd.Timedelta(days=cfg.warmup_days) + sliced = slice_bundle(bundle, raw_start, eval_end) + backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start) + backtester.engine_config.initial_capital = 1000.0 + result = backtester.run() + metrics = evaluate_window_result( + result, + eval_start=eval_start, + bars_per_day=backtester.engine_config.bars_per_day, + ) + metrics["engine_pnl"] = result.engine_pnl + metrics["total_trades"] = result.total_trades + results[label] = metrics + + return { + "variant": asdict(variant), + "results": results, + } + + +def _run_parallel(func, variants: list[ExtendedFilterVariant], workers: int = 6) -> list[dict[str, object]]: + ctx = mp.get_context("fork") + rows: list[dict[str, object]] = [] + with ProcessPoolExecutor(max_workers=workers, mp_context=ctx) as executor: + future_map = {executor.submit(func, variant): variant for variant in variants} + total = len(future_map) + done = 0 + for future in as_completed(future_map): + row = future.result() + rows.append(row) + done += 1 + if "full_metrics" in row: + print( + f"[{done:03d}/{total}] {row['variant']['name']} " + f"score={float(row['score']):.3f} " + f"5y={float(row['full_metrics']['total_return']) * 100:.2f}% " + f"mdd={float(row['full_metrics']['max_drawdown']) * 100:.2f}%", + flush=True, + ) + else: + metrics = row["results"] + print( + f"[{done:02d}/{total}] exact {row['variant']['name']} " + f"1y={float(metrics['1y']['total_return']) * 100:.2f}% " + f"3y={float(metrics['3y']['total_return']) * 100:.2f}% " + f"5y={float(metrics['5y']['total_return']) * 100:.2f}%", + flush=True, + ) + rows.sort(key=lambda row: float(row.get("score", 0.0)), reverse=True) + return rows + + +def _all_combos() -> list[ExtendedFilterVariant]: + variants: list[ExtendedFilterVariant] = [] + for floor, avg_mult, score, rs, ret7, corr, carry in itertools.product( + LIQUIDITY_FLOORS, + AVG_DOLLAR_VOLUME_MULTIPLIERS, + MOMENTUM_MIN_SCORES, + MOMENTUM_MIN_RS, + MOMENTUM_MIN_7D, + CORRELATION_CAPS, + CARRY_MIN_EDGES, + ): + name = ( + f"liq{int(floor/1_000_000)}m" + f"_avg{avg_mult:.2f}" + f"_s{score:.2f}" + f"_rs{rs:.2f}" + f"_r7{ret7:.2f}" + f"_corr{corr:.2f}" + f"_carry{carry:.3f}" + ) + variants.append( + ExtendedFilterVariant( + name=name, + liquidity_floor=floor, + avg_dollar_volume_floor=floor * avg_mult, + momentum_min_score=score, + momentum_min_relative_strength=rs, + momentum_min_7d_return=ret7, + max_pairwise_correlation=corr, + carry_min_expected_edge=carry, + ) + ) + return variants + + +def _seed_variants() -> list[ExtendedFilterVariant]: + return [ + ExtendedFilterVariant("prev_balanced", 50_000_000.0, 50_000_000.0, 0.60, 0.00, 0.00, 0.70, 0.0), + ExtendedFilterVariant("prev_profit", 50_000_000.0, 50_000_000.0, 0.65, 0.00, 0.00, 0.78, 0.0), + ExtendedFilterVariant("prev_profit_carry", 50_000_000.0, 50_000_000.0, 0.65, 0.00, 0.00, 0.78, 0.002), + ] + + +def _build_random_sample(seed: int) -> list[ExtendedFilterVariant]: + rng = random.Random(seed) + combos = _all_combos() + seeded_names = {variant.name for variant in _seed_variants()} + sample = rng.sample(combos, k=min(RANDOM_SAMPLE_SIZE, len(combos))) + unique: dict[str, ExtendedFilterVariant] = {variant.name: variant for variant in _seed_variants()} + for variant in sample: + if variant.name in seeded_names: + continue + unique[variant.name] = variant + return list(unique.values()) + + +def _neighbor_values(value: float, values: list[float]) -> list[float]: + idx = values.index(value) + start = max(0, idx - 1) + end = min(len(values), idx + 2) + return values[start:end] + + +def _build_local_variants(rows: list[dict[str, object]]) -> list[ExtendedFilterVariant]: + seen: dict[str, ExtendedFilterVariant] = {} + for row in rows[:TOP_BROAD_FOR_LOCAL]: + base = row["variant"] + floor_values = _neighbor_values(float(base["liquidity_floor"]), LIQUIDITY_FLOORS) + avg_mult_values = _neighbor_values(float(base["avg_dollar_volume_floor"]) / float(base["liquidity_floor"]), AVG_DOLLAR_VOLUME_MULTIPLIERS) + score_values = _neighbor_values(float(base["momentum_min_score"]), MOMENTUM_MIN_SCORES) + rs_values = _neighbor_values(float(base["momentum_min_relative_strength"]), MOMENTUM_MIN_RS) + ret7_values = _neighbor_values(float(base["momentum_min_7d_return"]), MOMENTUM_MIN_7D) + corr_values = _neighbor_values(float(base["max_pairwise_correlation"]), CORRELATION_CAPS) + carry_values = _neighbor_values(float(base["carry_min_expected_edge"]), CARRY_MIN_EDGES) + + for floor, avg_mult, score, rs, ret7, corr, carry in itertools.product( + floor_values, + avg_mult_values, + score_values, + rs_values, + ret7_values, + corr_values, + carry_values, + ): + name = ( + f"liq{int(floor/1_000_000)}m" + f"_avg{avg_mult:.2f}" + f"_s{score:.2f}" + f"_rs{rs:.2f}" + f"_r7{ret7:.2f}" + f"_corr{corr:.2f}" + f"_carry{carry:.3f}" + ) + seen[name] = ExtendedFilterVariant( + name=name, + liquidity_floor=floor, + avg_dollar_volume_floor=floor * avg_mult, + momentum_min_score=score, + momentum_min_relative_strength=rs, + momentum_min_7d_return=ret7, + max_pairwise_correlation=corr, + carry_min_expected_edge=carry, + ) + variants = list(seen.values()) + if len(variants) <= LOCAL_SAMPLE_SIZE: + return variants + rng = random.Random(RANDOM_SEED + 1) + return rng.sample(variants, k=LOCAL_SAMPLE_SIZE) + + +def main() -> None: + global GLOBAL_BUNDLE, GLOBAL_LATEST_BAR + + base = build_strategy32_config(PROFILE_V7_DEFAULT) + end = pd.Timestamp("2026-03-15 00:00:00", tz="UTC") + max_days = max(days for days, _ in FINAL_WINDOWS) + lowest_floor = min(LIQUIDITY_FLOORS) + start = end - pd.Timedelta(days=max_days + base.warmup_days + 14) + + print(f"building unbiased discovery bundle with current-volume filter disabled") + + GLOBAL_BUNDLE, GLOBAL_LATEST_BAR, accepted, rejected, quote_by_symbol = build_strategy32_market_bundle( + symbols=base.symbols, + auto_discover_symbols=True, + quote_assets=base.quote_assets, + excluded_base_assets=base.excluded_base_assets, + min_quote_volume_24h=lowest_floor, + start=start, + end=end, + timeframe=base.timeframe, + max_staleness_days=base.max_symbol_staleness_days, + ) + + broad_variants = _build_random_sample(RANDOM_SEED) + print(f"running broad search on {len(broad_variants)} variants") + broad_rows = _run_parallel(_evaluate_variant, broad_variants) + + local_variants = _build_local_variants(broad_rows) + already = {row["variant"]["name"] for row in broad_rows} + local_variants = [variant for variant in local_variants if variant.name not in already] + print(f"running local refinement on {len(local_variants)} variants") + local_rows = _run_parallel(_evaluate_variant, local_variants) + + merged: dict[str, dict[str, object]] = {} + for row in broad_rows + local_rows: + merged[row["variant"]["name"]] = row + ranked = sorted(merged.values(), key=lambda row: float(row["score"]), reverse=True) + + exact_variants = [ExtendedFilterVariant(**row["variant"]) for row in ranked[:TOP_FINAL_FOR_EXACT]] + print(f"running exact 1y/3y/5y validation on top {len(exact_variants)} variants") + exact_rows = _run_parallel(_evaluate_exact_windows, exact_variants) + exact_by_name = {row["variant"]["name"]: row for row in exact_rows} + + final_ranked: list[dict[str, object]] = [] + for row in ranked[:TOP_FINAL_FOR_EXACT]: + name = row["variant"]["name"] + final_ranked.append( + { + **row, + "exact_windows": exact_by_name[name]["results"], + } + ) + + payload = { + "strategy": "strategy32", + "analysis": "wide_universe_filter_search_extended", + "profile": PROFILE_V7_DEFAULT, + "initial_capital": 1000.0, + "latest_completed_bar": str(GLOBAL_LATEST_BAR), + "accepted_symbols": accepted, + "rejected_symbols": rejected, + "quote_by_symbol": quote_by_symbol, + "broad_variants": len(broad_rows), + "local_variants": len(local_rows), + "ranked_top20": ranked[:20], + "final_ranked": final_ranked, + } + out = Path("/tmp/strategy32_filter_search_extended.json") + out.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + print("\nTop exact variants") + for row in final_ranked: + metrics = row["exact_windows"] + print( + row["variant"]["name"], + "score", + round(float(row["score"]), 3), + "1y", + round(float(metrics["1y"]["total_return"]) * 100, 2), + "3y", + round(float(metrics["3y"]["total_return"]) * 100, 2), + "5y", + round(float(metrics["5y"]["total_return"]) * 100, 2), + "mdd5y", + round(float(metrics["5y"]["max_drawdown"]) * 100, 2), + ) + print("\nwrote", out) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_hybrid_regime_backtest.py b/scripts/run_hybrid_regime_backtest.py new file mode 100644 index 0000000..971810d --- /dev/null +++ b/scripts/run_hybrid_regime_backtest.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy32.research.hybrid_regime import run_hybrid_backtest + + +def main() -> None: + payload = run_hybrid_backtest() + out = Path("/tmp/strategy32_hybrid_regime_backtest.json") + out.write_text(json.dumps(payload, indent=2), encoding="utf-8") + for label, metrics in payload["results"].items(): + print( + label, + f"ret={float(metrics['total_return']) * 100:.2f}%", + f"ann={float(metrics['annualized_return']) * 100:.2f}%", + f"sharpe={float(metrics['sharpe']):.2f}", + f"mdd={float(metrics['max_drawdown']) * 100:.2f}%", + ) + print(f"wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_hybrid_strategy_search.py b/scripts/run_hybrid_strategy_search.py new file mode 100644 index 0000000..b82f019 --- /dev/null +++ b/scripts/run_hybrid_strategy_search.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +import itertools +import json +import multiprocessing as mp +import sys +from concurrent.futures import ProcessPoolExecutor, as_completed +from dataclasses import asdict, dataclass +from pathlib import Path +from statistics import median + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy29.backtest.metrics import max_drawdown, sharpe_ratio +from strategy29.backtest.window_analysis import slice_bundle +from strategy32.research.adverse_regime import AdverseRegimeResearchHarness +from strategy32.research.hybrid_regime import ( + _build_positive_filter_plan, + _curve_returns, + _run_adverse_component_curve, + _run_static_component_curve, + load_fixed66_bundle, +) +from strategy32.scripts.run_regime_filter_analysis import STRATEGIC_REGIME_PROFILES, build_strategic_regime_frame + + +OUT_JSON = Path("/tmp/strategy32_hybrid_strategy_search.json") +WINDOWS = ( + (365, "1y"), + (730, "2y"), + (1095, "3y"), + (1460, "4y"), + (1825, "5y"), +) +YEAR_PERIODS = ( + ("2021", pd.Timestamp("2021-03-16 04:00:00+00:00"), pd.Timestamp("2022-01-01 00:00:00+00:00")), + ("2022", pd.Timestamp("2022-01-01 00:00:00+00:00"), pd.Timestamp("2023-01-01 00:00:00+00:00")), + ("2023", pd.Timestamp("2023-01-01 00:00:00+00:00"), pd.Timestamp("2024-01-01 00:00:00+00:00")), + ("2024", pd.Timestamp("2024-01-01 00:00:00+00:00"), pd.Timestamp("2025-01-01 00:00:00+00:00")), + ("2025", pd.Timestamp("2025-01-01 00:00:00+00:00"), pd.Timestamp("2026-01-01 00:00:00+00:00")), +) + +EXPANSION_MODES = { + "prev_static": {"filter_name": "prev_balanced", "guarded": False}, + "guarded_static": {"filter_name": "guarded_positive", "guarded": False}, + "guarded_switch": {"filter_name": "prev_balanced", "guarded": True}, + "overheat_static": {"filter_name": "overheat_tolerant", "guarded": False}, +} +EUPHORIA_MODES = { + "overheat_static": {"filter_name": "overheat_tolerant", "guarded": False}, + "guarded_static": {"filter_name": "guarded_euphoria", "guarded": False}, + "guarded_switch": {"filter_name": "overheat_tolerant", "guarded": True}, + "prev_static": {"filter_name": "prev_balanced", "guarded": False}, +} +CAP_ENGINES = ("cap_cash", "cap_btc_rebound") +CHOP_ENGINES = ("chop_cash", "chop_inverse_carry", "chop_inverse_carry_strict") +DIST_ENGINES = ("dist_cash", "dist_inverse_carry_strict") + + +@dataclass(frozen=True, slots=True) +class HybridCandidate: + regime_profile: str + expansion_mode: str + euphoria_mode: str + cap_engine: str + chop_engine: str + dist_engine: str + + @property + def name(self) -> str: + return ( + f"{self.regime_profile}" + f"|exp:{self.expansion_mode}" + f"|eup:{self.euphoria_mode}" + f"|cap:{self.cap_engine}" + f"|chop:{self.chop_engine}" + f"|dist:{self.dist_engine}" + ) + + +def _annualized_return(total_return: float, days: int) -> float: + if days <= 0: + return 0.0 + return (1.0 + total_return) ** (365.0 / days) - 1.0 + + +def _segment_curve(curve: pd.Series, start: pd.Timestamp, end: pd.Timestamp) -> pd.Series: + segment = curve.loc[(curve.index >= start) & (curve.index <= end)].copy() + if segment.empty: + return segment + base = float(segment.iloc[0]) + if base <= 0: + return pd.Series(dtype=float) + return segment / base * 1000.0 + + +def _segment_metrics(curve: pd.Series, start: pd.Timestamp, end: pd.Timestamp) -> dict[str, float]: + segment = _segment_curve(curve, start, end) + if len(segment) < 2: + return { + "start": str(start), + "end": str(end), + "total_return": 0.0, + "annualized_return": 0.0, + "sharpe": 0.0, + "max_drawdown": 0.0, + } + total_return = float(segment.iloc[-1] / segment.iloc[0] - 1.0) + days = max(int((end - start) / pd.Timedelta(days=1)), 1) + return { + "start": str(start), + "end": str(end), + "total_return": total_return, + "annualized_return": _annualized_return(total_return, days), + "sharpe": sharpe_ratio(segment, 6), + "max_drawdown": max_drawdown(segment), + } + + +def _score_candidate(window_results: dict[str, dict[str, float]], year_results: dict[str, dict[str, float]]) -> tuple[float, int, int]: + year_returns = [float(metrics["total_return"]) for metrics in year_results.values()] + negative_years = sum(ret < 0 for ret in year_returns) + mdd_violations = sum(float(metrics["max_drawdown"]) < -0.20 for metrics in window_results.values()) + + score = 0.0 + score += 4.0 * float(window_results["5y"]["annualized_return"]) + score += 2.2 * float(window_results["1y"]["annualized_return"]) + score += 1.5 * float(window_results["2y"]["annualized_return"]) + score += 1.2 * float(window_results["4y"]["annualized_return"]) + score += 0.8 * float(window_results["3y"]["annualized_return"]) + score += 1.5 * float(window_results["5y"]["sharpe"]) + score += 0.8 * float(window_results["1y"]["sharpe"]) + score += 2.0 * min(year_returns) + score += 1.0 * median(year_returns) + score += 0.75 * sum(max(ret, 0.0) for ret in year_returns) + score -= 3.0 * negative_years + score -= 0.75 * mdd_violations + for label in ("1y", "2y", "3y", "4y", "5y"): + dd = abs(float(window_results[label]["max_drawdown"])) + score -= max(0.0, dd - 0.20) * 4.0 + return score, negative_years, mdd_violations + + +def _compose_full_curve( + *, + latest_bar: pd.Timestamp, + timestamps: list[pd.Timestamp], + regime_map: dict[pd.Timestamp, str], + component_returns: dict[str, pd.Series], + candidate: HybridCandidate, +) -> pd.Series: + equity = 1000.0 + idx = [timestamps[0]] + vals = [equity] + for i in range(1, len(timestamps)): + signal_ts = timestamps[i - 1] + execution_ts = timestamps[i] + regime = regime_map.get(signal_ts, "") + if regime == "MOMENTUM_EXPANSION": + key = f"MOMENTUM_EXPANSION::{candidate.expansion_mode}" + elif regime == "EUPHORIC_BREAKOUT": + key = f"EUPHORIC_BREAKOUT::{candidate.euphoria_mode}" + elif regime == "CAPITULATION_STRESS": + key = candidate.cap_engine + elif regime == "CHOPPY_ROTATION": + key = candidate.chop_engine + elif regime == "DISTRIBUTION_DRIFT": + key = candidate.dist_engine + else: + key = "" + ret = float(component_returns.get(key, pd.Series(dtype=float)).get(execution_ts, 0.0)) + equity *= max(0.0, 1.0 + ret) + idx.append(execution_ts) + vals.append(equity) + return pd.Series(vals, index=pd.DatetimeIndex(idx, name="timestamp"), dtype=float) + + +def _exact_validate_candidate( + *, + bundle, + latest_bar: pd.Timestamp, + candidate: HybridCandidate, +) -> dict[str, object]: + def run_period(eval_start: pd.Timestamp, eval_end: pd.Timestamp) -> pd.Series: + raw_start = eval_start - pd.Timedelta(days=90) + sliced = slice_bundle(bundle, raw_start, eval_end) + regime_frame = build_strategic_regime_frame(sliced, eval_start, eval_end, profile=candidate.regime_profile) + regime_map = dict(zip(pd.to_datetime(regime_frame["timestamp"]), regime_frame["strategic_regime"])) + harness = AdverseRegimeResearchHarness(sliced, eval_end) + component_returns: dict[str, pd.Series] = {} + + for mode_name, spec in EXPANSION_MODES.items(): + filter_plan = _build_positive_filter_plan(regime_frame, "MOMENTUM_EXPANSION") if spec["guarded"] else None + curve = _run_static_component_curve( + sliced=sliced, + latest_bar=eval_end, + eval_start=eval_start, + regime_map=regime_map, + active_regime="MOMENTUM_EXPANSION", + filter_name=str(spec["filter_name"]), + filter_plan=filter_plan, + ) + component_returns[f"MOMENTUM_EXPANSION::{mode_name}"] = _curve_returns(curve) + + for mode_name, spec in EUPHORIA_MODES.items(): + filter_plan = _build_positive_filter_plan(regime_frame, "EUPHORIC_BREAKOUT") if spec["guarded"] else None + curve = _run_static_component_curve( + sliced=sliced, + latest_bar=eval_end, + eval_start=eval_start, + regime_map=regime_map, + active_regime="EUPHORIC_BREAKOUT", + filter_name=str(spec["filter_name"]), + filter_plan=filter_plan, + ) + component_returns[f"EUPHORIC_BREAKOUT::{mode_name}"] = _curve_returns(curve) + + for engine_name in {candidate.cap_engine, candidate.chop_engine, candidate.dist_engine}: + curve = _run_adverse_component_curve( + eval_start=eval_start, + engine_name=engine_name, + harness=harness, + regime_frame=regime_frame, + ) + component_returns[engine_name] = _curve_returns(curve) + + timestamps = sorted(sliced.prices["BTC"]["timestamp"].loc[sliced.prices["BTC"]["timestamp"] >= eval_start].tolist()) + return _compose_full_curve( + latest_bar=eval_end, + timestamps=timestamps, + regime_map=regime_map, + component_returns=component_returns, + candidate=candidate, + ) + + window_results: dict[str, dict[str, float]] = {} + for days, label in WINDOWS: + eval_end = latest_bar + eval_start = eval_end - pd.Timedelta(days=days) + curve = run_period(eval_start, eval_end) + window_results[label] = _segment_metrics(curve, eval_start, eval_end) + + year_results: dict[str, dict[str, float]] = {} + for label, start, end_exclusive in YEAR_PERIODS: + eval_end = min(latest_bar, end_exclusive - pd.Timedelta(seconds=1)) + curve = run_period(start, eval_end) + year_results[label] = _segment_metrics(curve, start, eval_end) + ytd_start = pd.Timestamp("2026-01-01 00:00:00+00:00") + year_results["2026_YTD"] = _segment_metrics(run_period(ytd_start, latest_bar), ytd_start, latest_bar) + + score, negative_years, mdd_violations = _score_candidate(window_results, {k: v for k, v in year_results.items() if k != "2026_YTD"}) + return { + "candidate": asdict(candidate), + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "windows": window_results, + "years": year_results, + "validation": "exact_independent_periods", + } + + +def _build_profile_cache(profile_name: str) -> tuple[str, dict[str, object]]: + bundle, latest_bar = load_fixed66_bundle("/tmp/strategy32_fixed66_bundle.pkl") + eval_start = latest_bar - pd.Timedelta(days=1825) + raw_start = eval_start - pd.Timedelta(days=90) + sliced = slice_bundle(bundle, raw_start, latest_bar) + regime_frame = build_strategic_regime_frame(sliced, eval_start, latest_bar, profile=profile_name) + regime_map = dict(zip(pd.to_datetime(regime_frame["timestamp"]), regime_frame["strategic_regime"])) + harness = AdverseRegimeResearchHarness(sliced, latest_bar) + component_returns: dict[str, pd.Series] = {} + + for mode_name, spec in EXPANSION_MODES.items(): + filter_plan = _build_positive_filter_plan(regime_frame, "MOMENTUM_EXPANSION") if spec["guarded"] else None + curve = _run_static_component_curve( + sliced=sliced, + latest_bar=latest_bar, + eval_start=eval_start, + regime_map=regime_map, + active_regime="MOMENTUM_EXPANSION", + filter_name=str(spec["filter_name"]), + filter_plan=filter_plan, + ) + component_returns[f"MOMENTUM_EXPANSION::{mode_name}"] = _curve_returns(curve) + + for mode_name, spec in EUPHORIA_MODES.items(): + filter_plan = _build_positive_filter_plan(regime_frame, "EUPHORIC_BREAKOUT") if spec["guarded"] else None + curve = _run_static_component_curve( + sliced=sliced, + latest_bar=latest_bar, + eval_start=eval_start, + regime_map=regime_map, + active_regime="EUPHORIC_BREAKOUT", + filter_name=str(spec["filter_name"]), + filter_plan=filter_plan, + ) + component_returns[f"EUPHORIC_BREAKOUT::{mode_name}"] = _curve_returns(curve) + + for engine_name in sorted(set(CAP_ENGINES) | set(CHOP_ENGINES) | set(DIST_ENGINES)): + curve = _run_adverse_component_curve( + eval_start=eval_start, + engine_name=engine_name, + harness=harness, + regime_frame=regime_frame, + ) + component_returns[engine_name] = _curve_returns(curve) + + timestamps = sorted(sliced.prices["BTC"]["timestamp"].loc[sliced.prices["BTC"]["timestamp"] >= eval_start].tolist()) + return profile_name, { + "regime_map": regime_map, + "component_returns": component_returns, + "timestamps": timestamps, + } + + +def _exact_validate_candidate_worker(candidate_payload: dict[str, str]) -> dict[str, object]: + bundle, latest_bar = load_fixed66_bundle("/tmp/strategy32_fixed66_bundle.pkl") + return _exact_validate_candidate(bundle=bundle, latest_bar=latest_bar, candidate=HybridCandidate(**candidate_payload)) + + +def main() -> None: + bundle, latest_bar = load_fixed66_bundle("/tmp/strategy32_fixed66_bundle.pkl") + profile_caches: dict[str, dict[str, object]] = {} + ctx = mp.get_context("fork") + with ProcessPoolExecutor(max_workers=min(3, len(STRATEGIC_REGIME_PROFILES)), mp_context=ctx) as executor: + future_map = {executor.submit(_build_profile_cache, profile_name): profile_name for profile_name in STRATEGIC_REGIME_PROFILES} + for future in as_completed(future_map): + profile_name, cache = future.result() + profile_caches[profile_name] = cache + print(f"[cache] built {profile_name}", flush=True) + + candidates = [ + HybridCandidate(*combo) + for combo in itertools.product( + STRATEGIC_REGIME_PROFILES.keys(), + EXPANSION_MODES.keys(), + EUPHORIA_MODES.keys(), + CAP_ENGINES, + CHOP_ENGINES, + DIST_ENGINES, + ) + ] + + rows: list[dict[str, object]] = [] + for idx, candidate in enumerate(candidates, start=1): + cache = profile_caches[candidate.regime_profile] + full_curve = _compose_full_curve( + latest_bar=latest_bar, + timestamps=cache["timestamps"], + regime_map=cache["regime_map"], + component_returns=cache["component_returns"], + candidate=candidate, + ) + window_results = { + label: _segment_metrics(full_curve, latest_bar - pd.Timedelta(days=days), latest_bar) + for days, label in WINDOWS + } + year_results = { + label: _segment_metrics(full_curve, start, min(latest_bar, end_exclusive - pd.Timedelta(seconds=1))) + for label, start, end_exclusive in YEAR_PERIODS + } + year_results["2026_YTD"] = _segment_metrics(full_curve, pd.Timestamp("2026-01-01 00:00:00+00:00"), latest_bar) + score, negative_years, mdd_violations = _score_candidate(window_results, {k: v for k, v in year_results.items() if k != "2026_YTD"}) + row = { + "candidate": asdict(candidate), + "name": candidate.name, + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "windows": window_results, + "years": year_results, + "validation": "approx_full_curve_slice", + } + rows.append(row) + print( + f"[{idx:03d}/{len(candidates)}] {candidate.name} " + f"score={score:.3f} neg_years={negative_years} mdd_viol={mdd_violations} " + f"1y={window_results['1y']['total_return']*100:.2f}% " + f"5y_ann={window_results['5y']['annualized_return']*100:.2f}%", + flush=True, + ) + + rows.sort(key=lambda row: (int(row["negative_years"]), int(row["mdd_violations"]), -float(row["score"]))) + with ProcessPoolExecutor(max_workers=min(3, len(rows[:3])), mp_context=ctx) as executor: + future_map = { + executor.submit(_exact_validate_candidate_worker, row["candidate"]): row["name"] + for row in rows[:3] + } + exact_top = [] + for future in as_completed(future_map): + result = future.result() + exact_top.append(result) + print(f"[exact] validated {future_map[future]}", flush=True) + exact_top.sort(key=lambda row: (int(row["negative_years"]), int(row["mdd_violations"]), -float(row["score"]))) + + payload = { + "analysis": "strategy32_hybrid_strategy_search", + "latest_completed_bar": str(latest_bar), + "candidate_count": len(candidates), + "regime_profiles": list(STRATEGIC_REGIME_PROFILES.keys()), + "expansion_modes": list(EXPANSION_MODES.keys()), + "euphoria_modes": list(EUPHORIA_MODES.keys()), + "cap_engines": list(CAP_ENGINES), + "chop_engines": list(CHOP_ENGINES), + "dist_engines": list(DIST_ENGINES), + "summary": rows[:20], + "exact_top": exact_top, + } + OUT_JSON.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + print("\nTop 5 approximate candidates", flush=True) + for row in rows[:5]: + print( + row["name"], + "score", + round(float(row["score"]), 3), + "neg_years", + row["negative_years"], + "mdd_viol", + row["mdd_violations"], + "2025", + round(float(row["years"]["2025"]["total_return"]) * 100, 2), + "2024", + round(float(row["years"]["2024"]["total_return"]) * 100, 2), + "1y", + round(float(row["windows"]["1y"]["total_return"]) * 100, 2), + "5y_ann", + round(float(row["windows"]["5y"]["annualized_return"]) * 100, 2), + ) + + print("\nExact top candidates", flush=True) + for row in exact_top: + print( + HybridCandidate(**row["candidate"]).name, + "score", + round(float(row["score"]), 3), + "neg_years", + row["negative_years"], + "mdd_viol", + row["mdd_violations"], + "2025", + round(float(row["years"]["2025"]["total_return"]) * 100, 2), + "2024", + round(float(row["years"]["2024"]["total_return"]) * 100, 2), + "1y", + round(float(row["windows"]["1y"]["total_return"]) * 100, 2), + "5y_ann", + round(float(row["windows"]["5y"]["annualized_return"]) * 100, 2), + ) + print("\nwrote", OUT_JSON, flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_hybrid_strategy_search_fast.py b/scripts/run_hybrid_strategy_search_fast.py new file mode 100644 index 0000000..a382ded --- /dev/null +++ b/scripts/run_hybrid_strategy_search_fast.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import argparse +import json +import multiprocessing as mp +import sys +from concurrent.futures import ProcessPoolExecutor, as_completed +from dataclasses import asdict +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy29.backtest.window_analysis import slice_bundle +from strategy32.research.adverse_regime import AdverseRegimeResearchHarness +from strategy32.research.hybrid_regime import ( + _build_positive_filter_plan, + _curve_returns, + _run_adverse_component_curve, + _run_static_component_curve, + load_fixed66_bundle, +) +from strategy32.scripts.run_hybrid_strategy_search import ( + CAP_ENGINES, + CHOP_ENGINES, + DIST_ENGINES, + EUPHORIA_MODES, + EXPANSION_MODES, + OUT_JSON, + WINDOWS, + YEAR_PERIODS, + HybridCandidate, + _build_profile_cache, + _compose_full_curve, + _score_candidate, + _segment_metrics, +) +from strategy32.scripts.run_regime_filter_analysis import STRATEGIC_REGIME_PROFILES, build_strategic_regime_frame + + +FAST_OUT_JSON = Path("/tmp/strategy32_hybrid_strategy_search_fast.json") +YTD_START = pd.Timestamp("2026-01-01 00:00:00+00:00") + + +def _build_candidate_rows(latest_bar: pd.Timestamp, profile_caches: dict[str, dict[str, object]]) -> list[dict[str, object]]: + candidates = [ + HybridCandidate(*combo) + for combo in __import__("itertools").product( + STRATEGIC_REGIME_PROFILES.keys(), + EXPANSION_MODES.keys(), + EUPHORIA_MODES.keys(), + CAP_ENGINES, + CHOP_ENGINES, + DIST_ENGINES, + ) + ] + rows: list[dict[str, object]] = [] + for idx, candidate in enumerate(candidates, start=1): + cache = profile_caches[candidate.regime_profile] + full_curve = _compose_full_curve( + latest_bar=latest_bar, + timestamps=cache["timestamps"], + regime_map=cache["regime_map"], + component_returns=cache["component_returns"], + candidate=candidate, + ) + window_results = { + label: _segment_metrics(full_curve, latest_bar - pd.Timedelta(days=days), latest_bar) + for days, label in WINDOWS + } + year_results = { + label: _segment_metrics(full_curve, start, min(latest_bar, end_exclusive - pd.Timedelta(seconds=1))) + for label, start, end_exclusive in YEAR_PERIODS + } + year_results["2026_YTD"] = _segment_metrics(full_curve, YTD_START, latest_bar) + score, negative_years, mdd_violations = _score_candidate( + window_results, + {k: v for k, v in year_results.items() if k != "2026_YTD"}, + ) + row = { + "candidate": asdict(candidate), + "name": candidate.name, + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "windows": window_results, + "years": year_results, + "validation": "approx_full_curve_slice", + } + rows.append(row) + print( + f"[approx {idx:03d}/{len(candidates)}] {candidate.name} " + f"score={score:.3f} neg_years={negative_years} mdd_viol={mdd_violations} " + f"1y={window_results['1y']['total_return'] * 100:.2f}% " + f"5y_ann={window_results['5y']['annualized_return'] * 100:.2f}%", + flush=True, + ) + rows.sort(key=lambda row: (int(row["negative_years"]), int(row["mdd_violations"]), -float(row["score"]))) + return rows + + +def _period_specs(latest_bar: pd.Timestamp) -> list[tuple[str, str, pd.Timestamp, pd.Timestamp]]: + specs: list[tuple[str, str, pd.Timestamp, pd.Timestamp]] = [] + for days, label in WINDOWS: + specs.append(("window", label, latest_bar - pd.Timedelta(days=days), latest_bar)) + for label, start, end_exclusive in YEAR_PERIODS: + specs.append(("year", label, start, min(latest_bar, end_exclusive - pd.Timedelta(seconds=1)))) + specs.append(("year", "2026_YTD", YTD_START, latest_bar)) + return specs + + +def _exact_period_worker(candidate_payload: dict[str, str], period_spec: tuple[str, str, str, str]) -> tuple[str, str, dict[str, float]]: + kind, label, start_text, end_text = period_spec + eval_start = pd.Timestamp(start_text) + eval_end = pd.Timestamp(end_text) + bundle, _ = load_fixed66_bundle("/tmp/strategy32_fixed66_bundle.pkl") + candidate = HybridCandidate(**candidate_payload) + + raw_start = eval_start - pd.Timedelta(days=90) + sliced = slice_bundle(bundle, raw_start, eval_end) + regime_frame = build_strategic_regime_frame(sliced, eval_start, eval_end, profile=candidate.regime_profile) + regime_map = dict(zip(pd.to_datetime(regime_frame["timestamp"]), regime_frame["strategic_regime"])) + harness = AdverseRegimeResearchHarness(sliced, eval_end) + component_returns: dict[str, pd.Series] = {} + + for mode_name, spec in EXPANSION_MODES.items(): + filter_plan = _build_positive_filter_plan(regime_frame, "MOMENTUM_EXPANSION") if spec["guarded"] else None + curve = _run_static_component_curve( + sliced=sliced, + latest_bar=eval_end, + eval_start=eval_start, + regime_map=regime_map, + active_regime="MOMENTUM_EXPANSION", + filter_name=str(spec["filter_name"]), + filter_plan=filter_plan, + ) + component_returns[f"MOMENTUM_EXPANSION::{mode_name}"] = _curve_returns(curve) + + for mode_name, spec in EUPHORIA_MODES.items(): + filter_plan = _build_positive_filter_plan(regime_frame, "EUPHORIC_BREAKOUT") if spec["guarded"] else None + curve = _run_static_component_curve( + sliced=sliced, + latest_bar=eval_end, + eval_start=eval_start, + regime_map=regime_map, + active_regime="EUPHORIC_BREAKOUT", + filter_name=str(spec["filter_name"]), + filter_plan=filter_plan, + ) + component_returns[f"EUPHORIC_BREAKOUT::{mode_name}"] = _curve_returns(curve) + + for engine_name in sorted({candidate.cap_engine, candidate.chop_engine, candidate.dist_engine}): + curve = _run_adverse_component_curve( + eval_start=eval_start, + engine_name=engine_name, + harness=harness, + regime_frame=regime_frame, + ) + component_returns[engine_name] = _curve_returns(curve) + + timestamps = sorted(sliced.prices["BTC"]["timestamp"].loc[sliced.prices["BTC"]["timestamp"] >= eval_start].tolist()) + curve = _compose_full_curve( + latest_bar=eval_end, + timestamps=timestamps, + regime_map=regime_map, + component_returns=component_returns, + candidate=candidate, + ) + return kind, label, _segment_metrics(curve, eval_start, eval_end) + + +def _exact_validate_candidate_parallel( + candidate: HybridCandidate, + latest_bar: pd.Timestamp, + *, + max_workers: int, +) -> dict[str, object]: + period_specs = [ + (kind, label, str(start), str(end)) + for kind, label, start, end in _period_specs(latest_bar) + ] + window_results: dict[str, dict[str, float]] = {} + year_results: dict[str, dict[str, float]] = {} + ctx = mp.get_context("fork") + with ProcessPoolExecutor(max_workers=min(max_workers, len(period_specs)), mp_context=ctx) as executor: + future_map = { + executor.submit(_exact_period_worker, asdict(candidate), period_spec): period_spec + for period_spec in period_specs + } + for future in as_completed(future_map): + kind, label, metrics = future.result() + if kind == "window": + window_results[label] = metrics + else: + year_results[label] = metrics + print( + f"[exact-period] {candidate.name} {label} " + f"ret={metrics['total_return'] * 100:.2f}% " + f"mdd={metrics['max_drawdown'] * 100:.2f}%", + flush=True, + ) + + ordered_windows = {label: window_results[label] for _, label in WINDOWS} + ordered_years = {label: year_results[label] for label, _, _ in YEAR_PERIODS} + ordered_years["2026_YTD"] = year_results["2026_YTD"] + score, negative_years, mdd_violations = _score_candidate( + ordered_windows, + {k: v for k, v in ordered_years.items() if k != "2026_YTD"}, + ) + return { + "candidate": asdict(candidate), + "name": candidate.name, + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "windows": ordered_windows, + "years": ordered_years, + "validation": "exact_independent_periods_parallel", + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--top-n", type=int, default=3) + parser.add_argument("--exact-workers", type=int, default=6) + parser.add_argument("--out", type=str, default=str(FAST_OUT_JSON)) + args = parser.parse_args() + + _, latest_bar = load_fixed66_bundle("/tmp/strategy32_fixed66_bundle.pkl") + profile_caches: dict[str, dict[str, object]] = {} + ctx = mp.get_context("fork") + with ProcessPoolExecutor(max_workers=min(3, len(STRATEGIC_REGIME_PROFILES)), mp_context=ctx) as executor: + future_map = { + executor.submit(_build_profile_cache, profile_name): profile_name + for profile_name in STRATEGIC_REGIME_PROFILES + } + for future in as_completed(future_map): + profile_name, cache = future.result() + profile_caches[profile_name] = cache + print(f"[cache] built {profile_name}", flush=True) + + rows = _build_candidate_rows(latest_bar, profile_caches) + exact_top: list[dict[str, object]] = [] + for row in rows[: args.top_n]: + candidate = HybridCandidate(**row["candidate"]) + print(f"[exact-start] {candidate.name}", flush=True) + exact_top.append( + _exact_validate_candidate_parallel( + candidate, + latest_bar, + max_workers=args.exact_workers, + ) + ) + exact_top.sort(key=lambda item: (int(item["negative_years"]), int(item["mdd_violations"]), -float(item["score"]))) + payload = { + "analysis": "strategy32_hybrid_strategy_search_fast", + "latest_completed_bar": str(latest_bar), + "candidate_count": len(rows), + "summary": rows[:20], + "exact_top": exact_top, + } + Path(args.out).write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"[exact-done] {candidate.name}", flush=True) + + Path(OUT_JSON).write_text( + json.dumps( + { + "analysis": "strategy32_hybrid_strategy_search_fast_link", + "source": str(Path(args.out)), + }, + indent=2, + ), + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_live_combo_backtest.py b/scripts/run_live_combo_backtest.py new file mode 100644 index 0000000..8955f41 --- /dev/null +++ b/scripts/run_live_combo_backtest.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy32.live.runtime import BEST_CASH_OVERLAY, LIVE_STRATEGY_OVERRIDES +from strategy32.research.soft_router import evaluate_cash_overlay_exact, load_component_bundle + + +OUT_JSON = Path("/tmp/strategy32_live_combo_backtest.json") +CACHE_PATH = "/tmp/strategy32_fixed66_bundle.pkl" + + +def main() -> None: + bundle, latest_bar = load_component_bundle(CACHE_PATH) + payload = evaluate_cash_overlay_exact( + bundle=bundle, + latest_bar=latest_bar, + candidate=BEST_CASH_OVERLAY, + cache_path=CACHE_PATH, + max_workers=6, + core_config_overrides={ + **LIVE_STRATEGY_OVERRIDES, + "hard_filter_refresh_cadence": "1d", + "hard_filter_min_history_bars": 120, + "hard_filter_lookback_bars": 30, + "hard_filter_min_avg_dollar_volume": 50_000_000.0, + }, + ) + payload["backtest_basis"] = { + "universe": "fixed66 cached bundle", + "core_filter": "overheat_tolerant", + "cash_overlay": payload["candidate"], + "core_config_overrides": { + **LIVE_STRATEGY_OVERRIDES, + "hard_filter_refresh_cadence": "1d", + "hard_filter_min_history_bars": 120, + "hard_filter_lookback_bars": 30, + "hard_filter_min_avg_dollar_volume": 50_000_000.0, + }, + "execution_refinement_note": "4h proxy in research bundle; live 1h refinement is not replayed here", + } + OUT_JSON.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(json.dumps(payload, indent=2)) + print(f"[saved] {OUT_JSON}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_live_monitor.py b/scripts/run_live_monitor.py new file mode 100644 index 0000000..5a2b332 --- /dev/null +++ b/scripts/run_live_monitor.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +import sys +from pathlib import Path + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy32.live.env import load_dotenv +from strategy32.live.runtime import run_monitor + + +def _default_env_candidates() -> list[Path]: + return [ + Path(__file__).resolve().parents[1] / ".env", + Path("/Volumes/SSD/workspace/money-bot/strategy11/.env"), + Path("/Volumes/SSD/workspace/money-bot/strategy7/engine_a_mm/.env"), + Path("/Volumes/SSD/workspace/money-bot/strategy7/engine_aa_mm/.env"), + ] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run Strategy32 live paper/advisory monitor") + parser.add_argument("--once", action="store_true", help="Run one cycle and exit") + parser.add_argument("--runtime-dir", type=str, default=os.getenv("STRATEGY32_RUNTIME_DIR", "runtime")) + parser.add_argument("--env-file", type=str, default="") + parser.add_argument("--log-level", type=str, default=os.getenv("STRATEGY32_LOG_LEVEL", "INFO")) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.env_file: + load_dotenv(args.env_file) + else: + for env_path in _default_env_candidates(): + if env_path.exists(): + load_dotenv(env_path) + break + + runtime_dir = Path(args.runtime_dir) + runtime_dir.mkdir(parents=True, exist_ok=True) + handlers: list[logging.Handler] = [ + logging.StreamHandler(), + logging.FileHandler(runtime_dir / "strategy32_live.log", encoding="utf-8"), + ] + logging.basicConfig( + level=getattr(logging, args.log_level.upper(), logging.INFO), + format="%(asctime)s %(levelname)-5s %(name)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=handlers, + ) + asyncio.run(run_monitor(once=args.once, runtime_dir=args.runtime_dir)) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_recent_core_filter_comparison.py b/scripts/run_recent_core_filter_comparison.py new file mode 100644 index 0000000..44a1676 --- /dev/null +++ b/scripts/run_recent_core_filter_comparison.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import json +import multiprocessing as mp +import sys +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy32.live.runtime import BEST_CASH_OVERLAY, LIVE_STRATEGY_OVERRIDES +from strategy32.research.soft_router import ( + build_cash_overlay_period_components, + compose_cash_overlay_curve, + segment_metrics, + load_component_bundle, +) + + +OUT_JSON = Path("/tmp/strategy32_recent_core_filter_comparison.json") +CACHE_PATH = "/tmp/strategy32_fixed66_bundle.pkl" + +PERIODS = { + "1y": lambda latest_bar: (latest_bar - pd.Timedelta(days=365), latest_bar), + "2y": lambda latest_bar: (latest_bar - pd.Timedelta(days=730), latest_bar), + "5y": lambda latest_bar: (latest_bar - pd.Timedelta(days=1825), latest_bar), + "2024": lambda latest_bar: (pd.Timestamp("2024-01-01 00:00:00+00:00"), pd.Timestamp("2024-12-31 23:59:59+00:00")), + "2025": lambda latest_bar: (pd.Timestamp("2025-01-01 00:00:00+00:00"), pd.Timestamp("2025-12-31 23:59:59+00:00")), + "2026_YTD": lambda latest_bar: (pd.Timestamp("2026-01-01 00:00:00+00:00"), latest_bar), +} + +VARIANTS: dict[str, dict[str, object]] = { + "current_overheat": { + "core_filter": "overheat_tolerant", + "overrides": { + **LIVE_STRATEGY_OVERRIDES, + "hard_filter_refresh_cadence": "1d", + "hard_filter_min_history_bars": 120, + "hard_filter_lookback_bars": 30, + "hard_filter_min_avg_dollar_volume": 50_000_000.0, + }, + }, + "prev_balanced": { + "core_filter": "prev_balanced", + "overrides": { + "enable_liquidity_universe_fallback": False, + "enable_momentum_filter_fallback": False, + "enable_carry_score_fallback": False, + "hard_filter_refresh_cadence": "1d", + "hard_filter_min_history_bars": 120, + "hard_filter_lookback_bars": 30, + "hard_filter_min_avg_dollar_volume": 50_000_000.0, + }, + }, + "guarded_positive": { + "core_filter": "guarded_positive", + "overrides": { + "enable_liquidity_universe_fallback": False, + "enable_momentum_filter_fallback": False, + "enable_carry_score_fallback": False, + "hard_filter_refresh_cadence": "1d", + "hard_filter_min_history_bars": 120, + "hard_filter_lookback_bars": 30, + "hard_filter_min_avg_dollar_volume": 50_000_000.0, + }, + }, + "relaxed_overheat": { + "core_filter": "overheat_tolerant", + "overrides": { + **LIVE_STRATEGY_OVERRIDES, + "momentum_min_score": 0.58, + "momentum_min_relative_strength": -0.03, + "momentum_min_7d_return": 0.00, + "universe_min_avg_dollar_volume": 75_000_000.0, + "hard_filter_refresh_cadence": "1d", + "hard_filter_min_history_bars": 120, + "hard_filter_lookback_bars": 30, + "hard_filter_min_avg_dollar_volume": 50_000_000.0, + }, + }, +} + + +def main() -> None: + bundle, latest_bar = load_component_bundle(CACHE_PATH) + output: dict[str, object] = { + "latest_bar": str(latest_bar), + "candidate": { + "regime_profile": BEST_CASH_OVERLAY.regime_profile, + "cap_engine": BEST_CASH_OVERLAY.cap_engine, + "chop_engine": BEST_CASH_OVERLAY.chop_engine, + "dist_engine": BEST_CASH_OVERLAY.dist_engine, + }, + "note": "fixed66 cached bundle, 4h proxy execution, same cash-overlay with different core filters", + "variants": {}, + } + + for name, spec in VARIANTS.items(): + output["variants"][name] = { + "core_filter": spec["core_filter"], + "overrides": spec["overrides"], + "results": {}, + } + + tasks: list[tuple[str, str, pd.Timestamp, pd.Timestamp]] = [] + for variant_name in VARIANTS: + for label, period_fn in PERIODS.items(): + start, end = period_fn(latest_bar) + tasks.append((variant_name, label, start, end)) + + ctx = mp.get_context("fork") + with ProcessPoolExecutor(max_workers=min(6, len(tasks)), mp_context=ctx) as executor: + future_map = { + executor.submit( + _period_worker, + CACHE_PATH, + variant_name, + label, + str(start), + str(end), + ): (variant_name, label) + for variant_name, label, start, end in tasks + } + for future in as_completed(future_map): + variant_name, label = future_map[future] + print(f"[done] {variant_name} {label}", flush=True) + result = future.result() + output["variants"][variant_name]["results"][label] = result + + OUT_JSON.write_text(json.dumps(output, indent=2), encoding="utf-8") + print(json.dumps(output, indent=2)) + print(f"[saved] {OUT_JSON}") + + +def _period_worker( + cache_path: str, + variant_name: str, + label: str, + start_text: str, + end_text: str, +) -> dict[str, float]: + bundle, _latest_bar = load_component_bundle(cache_path) + spec = VARIANTS[variant_name] + start = pd.Timestamp(start_text) + end = pd.Timestamp(end_text) + components = build_cash_overlay_period_components( + bundle=bundle, + eval_start=start, + eval_end=end, + profile_name=BEST_CASH_OVERLAY.regime_profile, + core_filter=str(spec["core_filter"]), + cap_engine=BEST_CASH_OVERLAY.cap_engine, + chop_engine=BEST_CASH_OVERLAY.chop_engine, + dist_engine=BEST_CASH_OVERLAY.dist_engine, + core_config_overrides=dict(spec["overrides"]), + ) + curve, _weights = compose_cash_overlay_curve(candidate=BEST_CASH_OVERLAY, **components) + return segment_metrics(curve, start, end) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_regime_filter_analysis.py b/scripts/run_regime_filter_analysis.py new file mode 100644 index 0000000..0437a83 --- /dev/null +++ b/scripts/run_regime_filter_analysis.py @@ -0,0 +1,498 @@ +from __future__ import annotations + +import json +import sys +from dataclasses import asdict, dataclass +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy29.backtest.metrics import max_drawdown, sharpe_ratio +from strategy29.backtest.window_analysis import slice_bundle +from strategy29.common.constants import BTC_SYMBOL +from strategy29.signal.btc_regime import BTCRegimeEngine +from strategy32.backtest.simulator import Strategy32Backtester +from strategy32.config import PROFILE_V7_DEFAULT, Strategy32Config, build_engine_config, build_strategy32_config +from strategy32.data import build_strategy32_market_bundle + + +OUT_JSON = Path("/tmp/strategy32_regime_filter_analysis.json") +OUT_MD = Path("/Volumes/SSD/data/nextcloud/data/tara/files/📂HeadOffice/money-bot/strategy32/008_레짐별_필터적합도_분석.md") + + +@dataclass(slots=True) +class FilterVariant: + key: str + label: str + liquidity_floor: float + avg_dollar_floor: float + momentum_score: float + relative_strength: float + ret7: float + corr_cap: float + carry_edge: float + + +@dataclass(frozen=True, slots=True) +class StrategicRegimeProfile: + name: str + panic_atr: float + panic_bar_return: float + panic_breadth: float + panic_funding: float + euphoria_daily_gap: float + euphoria_intraday_gap: float + euphoria_breadth: float + euphoria_breadth_persist: float + euphoria_positive_ratio: float + euphoria_funding_persist: float + euphoria_funding: float + euphoria_btc_7d: float + expansion_daily_gap: float + expansion_intraday_gap: float + expansion_breadth: float + expansion_breadth_persist: float + expansion_atr: float + expansion_min_funding: float + expansion_btc_7d: float + distribution_daily_gap: float + distribution_intraday_gap: float + distribution_breadth: float + distribution_positive_ratio: float + + +STRATEGIC_REGIME_PROFILES: dict[str, StrategicRegimeProfile] = { + "base": StrategicRegimeProfile( + name="base", + panic_atr=0.05, + panic_bar_return=-0.05, + panic_breadth=0.22, + panic_funding=-0.00005, + euphoria_daily_gap=0.05, + euphoria_intraday_gap=0.015, + euphoria_breadth=0.68, + euphoria_breadth_persist=0.62, + euphoria_positive_ratio=0.72, + euphoria_funding_persist=0.66, + euphoria_funding=0.00012, + euphoria_btc_7d=0.10, + expansion_daily_gap=0.02, + expansion_intraday_gap=0.00, + expansion_breadth=0.55, + expansion_breadth_persist=0.52, + expansion_atr=0.05, + expansion_min_funding=-0.00003, + expansion_btc_7d=0.0, + distribution_daily_gap=0.00, + distribution_intraday_gap=0.00, + distribution_breadth=0.45, + distribution_positive_ratio=0.45, + ), + "tight_positive": StrategicRegimeProfile( + name="tight_positive", + panic_atr=0.048, + panic_bar_return=-0.048, + panic_breadth=0.24, + panic_funding=-0.00004, + euphoria_daily_gap=0.055, + euphoria_intraday_gap=0.018, + euphoria_breadth=0.72, + euphoria_breadth_persist=0.66, + euphoria_positive_ratio=0.75, + euphoria_funding_persist=0.70, + euphoria_funding=0.00014, + euphoria_btc_7d=0.12, + expansion_daily_gap=0.028, + expansion_intraday_gap=0.004, + expansion_breadth=0.60, + expansion_breadth_persist=0.57, + expansion_atr=0.045, + expansion_min_funding=0.0, + expansion_btc_7d=0.02, + distribution_daily_gap=0.005, + distribution_intraday_gap=0.002, + distribution_breadth=0.48, + distribution_positive_ratio=0.48, + ), + "loose_positive": StrategicRegimeProfile( + name="loose_positive", + panic_atr=0.052, + panic_bar_return=-0.055, + panic_breadth=0.20, + panic_funding=-0.00006, + euphoria_daily_gap=0.045, + euphoria_intraday_gap=0.012, + euphoria_breadth=0.64, + euphoria_breadth_persist=0.58, + euphoria_positive_ratio=0.68, + euphoria_funding_persist=0.62, + euphoria_funding=0.00010, + euphoria_btc_7d=0.08, + expansion_daily_gap=0.015, + expansion_intraday_gap=-0.002, + expansion_breadth=0.50, + expansion_breadth_persist=0.48, + expansion_atr=0.055, + expansion_min_funding=-0.00005, + expansion_btc_7d=-0.01, + distribution_daily_gap=-0.005, + distribution_intraday_gap=-0.004, + distribution_breadth=0.42, + distribution_positive_ratio=0.42, + ), +} + + +FILTER_VARIANTS = [ + FilterVariant("prev_balanced", "Legacy Balanced", 50_000_000.0, 50_000_000.0, 0.60, 0.00, 0.00, 0.70, 0.0), + FilterVariant("prev_profit", "Legacy Profit", 50_000_000.0, 50_000_000.0, 0.65, 0.00, 0.00, 0.78, 0.0), + FilterVariant("new_default", "New Durable", 100_000_000.0, 50_000_000.0, 0.65, 0.00, 0.00, 0.70, 0.0), + FilterVariant("high_liq_breakout", "High-Liq Breakout", 100_000_000.0, 75_000_000.0, 0.65, 0.00, 0.02, 0.78, 0.001), + FilterVariant("overheat_tolerant", "Overheat Tolerant", 100_000_000.0, 100_000_000.0, 0.60, -0.02, 0.02, 0.78, 0.0), + FilterVariant("ultra_selective", "Ultra Selective", 100_000_000.0, 50_000_000.0, 0.70, 0.02, 0.00, 0.78, 0.0), +] + + +def _price_at(df: pd.DataFrame, timestamp: pd.Timestamp) -> float: + row = df.loc[df["timestamp"] == timestamp] + if row.empty: + return 0.0 + return float(row["close"].iloc[-1]) + + +def _funding_row(df: pd.DataFrame, timestamp: pd.Timestamp) -> pd.Series | None: + row = df.loc[df["timestamp"] == timestamp] + if row.empty: + return None + return row.iloc[-1] + + +def _daily_return(series: pd.Series, bars_back: int) -> pd.Series: + return series / series.shift(bars_back) - 1.0 + + +def build_strategic_regime_frame( + bundle, + eval_start: pd.Timestamp, + latest_bar: pd.Timestamp, + profile: StrategicRegimeProfile | str = "base", +) -> pd.DataFrame: + if isinstance(profile, str): + profile = STRATEGIC_REGIME_PROFILES[profile] + btc_prices = bundle.prices[BTC_SYMBOL] + prepared = BTCRegimeEngine(build_engine_config().regime).prepare(btc_prices) + prepared = prepared.loc[prepared["timestamp"] >= eval_start].copy() + timestamps = prepared["timestamp"].tolist() + + breadths: list[float] = [] + mean_funding: list[float] = [] + positive_funding_ratio: list[float] = [] + mean_basis: list[float] = [] + btc_7d_return: list[float] = [] + btc_30d_return: list[float] = [] + + for idx, ts in enumerate(timestamps): + votes = [] + funding_vals = [] + basis_vals = [] + positive_votes = [] + for symbol, df in bundle.prices.items(): + if symbol == BTC_SYMBOL: + continue + hist = df.loc[df["timestamp"] <= ts].tail(30) + if len(hist) >= 10: + ema = hist["close"].ewm(span=20, adjust=False).mean().iloc[-1] + votes.append(float(hist["close"].iloc[-1] > ema)) + f_df = bundle.funding.get(symbol) + if f_df is None: + continue + f_hist = f_df.loc[f_df["timestamp"] <= ts].tail(6) + if f_hist.empty: + continue + funding_vals.append(float(f_hist["funding_rate"].mean())) + basis_vals.append(float(f_hist["basis"].iloc[-1])) + positive_votes.append(float((f_hist["funding_rate"] > 0).mean())) + breadths.append(sum(votes) / len(votes) if votes else 0.5) + mean_funding.append(sum(funding_vals) / len(funding_vals) if funding_vals else 0.0) + mean_basis.append(sum(basis_vals) / len(basis_vals) if basis_vals else 0.0) + positive_funding_ratio.append(sum(positive_votes) / len(positive_votes) if positive_votes else 0.5) + + if idx >= 42: + btc_7d_return.append(float(prepared["close"].iloc[idx] / prepared["close"].iloc[idx - 42] - 1.0)) + else: + btc_7d_return.append(0.0) + if idx >= 180: + btc_30d_return.append(float(prepared["close"].iloc[idx] / prepared["close"].iloc[idx - 180] - 1.0)) + else: + btc_30d_return.append(0.0) + + prepared["breadth"] = breadths + prepared["mean_alt_funding"] = mean_funding + prepared["mean_alt_basis"] = mean_basis + prepared["positive_funding_ratio"] = positive_funding_ratio + prepared["btc_7d_return"] = btc_7d_return + prepared["btc_30d_return"] = btc_30d_return + prepared["daily_trend_gap"] = prepared["daily_close"] / prepared["daily_ema_slow"] - 1.0 + prepared["intraday_trend_gap"] = prepared["close"] / prepared["ema_slow"] - 1.0 + prepared["breadth_persist"] = prepared["breadth"].rolling(18, min_periods=6).mean() + prepared["funding_persist"] = prepared["positive_funding_ratio"].rolling(18, min_periods=6).mean() + + regimes: list[str] = [] + for row in prepared.itertuples(index=False): + breadth = float(row.breadth) + breadth_persist = float(row.breadth_persist) if pd.notna(row.breadth_persist) else breadth + atr = float(row.atr_pct) if pd.notna(row.atr_pct) else 0.0 + bar_ret = float(row.bar_return) if pd.notna(row.bar_return) else 0.0 + daily_gap = float(row.daily_trend_gap) if pd.notna(row.daily_trend_gap) else 0.0 + intra_gap = float(row.intraday_trend_gap) if pd.notna(row.intraday_trend_gap) else 0.0 + avg_funding = float(row.mean_alt_funding) + positive_ratio = float(row.positive_funding_ratio) + funding_persist = float(row.funding_persist) if pd.notna(row.funding_persist) else positive_ratio + btc_7d = float(row.btc_7d_return) + + panic = ( + atr >= profile.panic_atr + or bar_ret <= profile.panic_bar_return + or (breadth <= profile.panic_breadth and avg_funding < profile.panic_funding) + ) + euphoria = ( + daily_gap > profile.euphoria_daily_gap + and intra_gap > profile.euphoria_intraday_gap + and breadth >= profile.euphoria_breadth + and breadth_persist >= profile.euphoria_breadth_persist + and positive_ratio >= profile.euphoria_positive_ratio + and funding_persist >= profile.euphoria_funding_persist + and (avg_funding >= profile.euphoria_funding or btc_7d >= profile.euphoria_btc_7d) + ) + expansion = ( + daily_gap > profile.expansion_daily_gap + and intra_gap > profile.expansion_intraday_gap + and breadth >= profile.expansion_breadth + and breadth_persist >= profile.expansion_breadth_persist + and atr < profile.expansion_atr + and avg_funding > profile.expansion_min_funding + and btc_7d > profile.expansion_btc_7d + and not euphoria + ) + distribution = ( + (daily_gap < profile.distribution_daily_gap and breadth < profile.distribution_breadth) + or (intra_gap < profile.distribution_intraday_gap and breadth < max(profile.distribution_breadth - 0.07, 0.0)) + or (avg_funding < 0.0 and positive_ratio < profile.distribution_positive_ratio and breadth < profile.distribution_breadth) + ) + + if panic: + regimes.append("CAPITULATION_STRESS") + elif euphoria: + regimes.append("EUPHORIC_BREAKOUT") + elif expansion: + regimes.append("MOMENTUM_EXPANSION") + elif distribution: + regimes.append("DISTRIBUTION_DRIFT") + else: + regimes.append("CHOPPY_ROTATION") + + prepared["strategic_regime"] = regimes + return prepared.reset_index(drop=True) + + +def build_variant_config(variant: FilterVariant) -> Strategy32Config: + cfg = build_strategy32_config(PROFILE_V7_DEFAULT) + cfg.discovery_min_quote_volume_24h = variant.liquidity_floor + cfg.universe_min_avg_dollar_volume = variant.avg_dollar_floor + cfg.momentum_min_score = variant.momentum_score + cfg.momentum_min_relative_strength = variant.relative_strength + cfg.momentum_min_7d_return = variant.ret7 + cfg.max_pairwise_correlation = variant.corr_cap + cfg.carry_min_expected_edge = variant.carry_edge + return cfg + + +def regime_metrics_from_equity(curve: pd.Series, regime_frame: pd.DataFrame, bars_per_day: int) -> dict[str, dict[str, float]]: + returns = curve.pct_change().fillna(0.0).rename("equity_bar_return") + frame = regime_frame.merge(returns.rename_axis("timestamp").reset_index(), on="timestamp", how="left").fillna({"equity_bar_return": 0.0}) + results: dict[str, dict[str, float]] = {} + for regime, chunk in frame.groupby("strategic_regime"): + eq = (1.0 + chunk["equity_bar_return"]).cumprod() + eq.index = pd.Index(chunk["timestamp"], name="timestamp") + total_return = float(eq.iloc[-1] - 1.0) if not eq.empty else 0.0 + results[str(regime)] = { + "bars": int(len(chunk)), + "bar_share": float(len(chunk) / len(frame)) if len(frame) else 0.0, + "total_return": total_return, + "sharpe": sharpe_ratio(eq, bars_per_day), + "max_drawdown": max_drawdown(eq), + "positive_bar_ratio": float((chunk["equity_bar_return"] > 0).mean()) if len(chunk) else 0.0, + } + return results + + +def regime_score(metrics: dict[str, float]) -> float: + total_return = float(metrics["total_return"]) + max_dd = abs(float(metrics["max_drawdown"])) + sharpe = float(metrics["sharpe"]) + bar_share = float(metrics["bar_share"]) + return 1.8 * (total_return / max(max_dd, 0.01)) + 0.8 * sharpe + 0.25 * total_return + 0.15 * bar_share + + +def main() -> None: + base = build_strategy32_config(PROFILE_V7_DEFAULT) + end = pd.Timestamp("2026-03-15 00:00:00", tz="UTC") + start = end - pd.Timedelta(days=1825 + base.warmup_days + 14) + bundle, latest_bar, accepted, rejected, quote_by_symbol = build_strategy32_market_bundle( + symbols=base.symbols, + auto_discover_symbols=True, + quote_assets=base.quote_assets, + excluded_base_assets=base.excluded_base_assets, + min_quote_volume_24h=min(variant.liquidity_floor for variant in FILTER_VARIANTS), + start=start, + end=end, + timeframe=base.timeframe, + max_staleness_days=base.max_symbol_staleness_days, + ) + eval_start = latest_bar - pd.Timedelta(days=1825) + raw_start = eval_start - pd.Timedelta(days=base.warmup_days) + sliced = slice_bundle(bundle, raw_start, latest_bar) + regime_frame = build_strategic_regime_frame(sliced, eval_start, latest_bar) + + variant_rows = [] + regime_rankings: dict[str, list[dict[str, object]]] = {} + for variant in FILTER_VARIANTS: + cfg = build_variant_config(variant) + backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start) + backtester.engine_config.initial_capital = 1000.0 + result = backtester.run() + curve = result.equity_curve.loc[result.equity_curve.index >= regime_frame["timestamp"].iloc[0]] + metrics = regime_metrics_from_equity(curve, regime_frame, backtester.engine_config.bars_per_day) + total = { + "total_return": float(result.total_return), + "cagr": float(result.cagr), + "sharpe": float(result.sharpe), + "max_drawdown": float(result.max_drawdown), + "total_trades": int(result.total_trades), + } + variant_rows.append( + { + "variant": asdict(variant), + "total": total, + "regimes": metrics, + } + ) + for regime_name, regime_metrics in metrics.items(): + regime_rankings.setdefault(regime_name, []).append( + { + "variant_key": variant.key, + "variant_label": variant.label, + "score": regime_score(regime_metrics), + "metrics": regime_metrics, + "total": total, + } + ) + + for regime_name, rows in regime_rankings.items(): + rows.sort(key=lambda row: float(row["score"]), reverse=True) + + payload = { + "strategy": "strategy32", + "analysis": "regime_filter_fit", + "profile": PROFILE_V7_DEFAULT, + "latest_completed_bar": str(latest_bar), + "accepted_symbols": accepted, + "rejected_symbols": rejected, + "quote_by_symbol": quote_by_symbol, + "regime_distribution": ( + regime_frame["strategic_regime"] + .value_counts(normalize=False) + .sort_index() + .rename_axis("regime") + .reset_index(name="bars") + .to_dict(orient="records") + ), + "variant_rows": variant_rows, + "regime_rankings": regime_rankings, + } + OUT_JSON.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + lines = [ + "# Strategy32 레짐별 필터 적합도 분석", + "", + "## 1. 목적", + "", + "유니버스 필터를 단일 기본값으로 고정하지 않고, 전략적으로 나눈 `5개 레짐`마다 어떤 필터가 더 잘 작동하는지 본다.", + "", + "## 2. 전략형 5개 레짐", + "", + "- `MOMENTUM_EXPANSION`: 장기/단기 추세가 위이고 breadth가 넓게 살아있는 구간", + "- `EUPHORIC_BREAKOUT`: breadth와 funding이 과열된 추세 확장 구간", + "- `CHOPPY_ROTATION`: 뚜렷한 추세가 없고 자금이 순환하는 박스권", + "- `DISTRIBUTION_DRIFT`: breadth와 funding이 식으며 약세로 기우는 구간", + "- `CAPITULATION_STRESS`: 고변동/급락/광범위 붕괴 구간", + "", + "## 3. 비교한 필터 후보", + "", + ] + for variant in FILTER_VARIANTS: + lines.append( + f"- `{variant.key}`: liq `${variant.liquidity_floor/1_000_000:.0f}M`, " + f"avg `${variant.avg_dollar_floor/1_000_000:.0f}M`, " + f"score `{variant.momentum_score:.2f}`, rs `{variant.relative_strength:.2f}`, " + f"7d `{variant.ret7:.2f}`, corr `{variant.corr_cap:.2f}`" + ) + + lines.extend(["", "## 4. 레짐 분포", ""]) + dist_frame = pd.DataFrame(payload["regime_distribution"]) + if not dist_frame.empty: + total_bars = int(dist_frame["bars"].sum()) + lines.append("| 레짐 | bars | 비중 |") + lines.append("|---|---:|---:|") + for row in dist_frame.itertuples(index=False): + lines.append(f"| `{row.regime}` | `{row.bars}` | `{row.bars / total_bars:.1%}` |") + + lines.extend(["", "## 5. 레짐별 1위 필터", ""]) + for regime_name in sorted(regime_rankings): + best = regime_rankings[regime_name][0] + metrics = best["metrics"] + lines.append(f"### {regime_name}") + lines.append("") + lines.append(f"- Best: `{best['variant_key']}` ({best['variant_label']})") + lines.append(f"- Regime return: `{metrics['total_return'] * 100:.2f}%`") + lines.append(f"- Regime MDD: `{metrics['max_drawdown'] * 100:.2f}%`") + lines.append(f"- Regime Sharpe: `{metrics['sharpe']:.2f}`") + lines.append(f"- Positive bar ratio: `{metrics['positive_bar_ratio']:.2%}`") + lines.append("") + + lines.extend(["## 6. 필터별 전체 5y 결과", "", "| 필터 | 5y 수익률 | CAGR | MDD | Sharpe | 거래수 |", "|---|---:|---:|---:|---:|---:|"]) + for row in sorted(variant_rows, key=lambda item: float(item["total"]["cagr"]), reverse=True): + total = row["total"] + lines.append( + f"| `{row['variant']['key']}` | `{total['total_return'] * 100:.2f}%` | `{total['cagr'] * 100:.2f}%` | " + f"`{total['max_drawdown'] * 100:.2f}%` | `{total['sharpe']:.2f}` | `{total['total_trades']}` |" + ) + + lines.extend(["", "## 7. 해석", ""]) + for regime_name in sorted(regime_rankings): + top_two = regime_rankings[regime_name][:2] + summary = ", ".join( + f"`{row['variant_key']}` ({row['metrics']['total_return'] * 100:.1f}%, MDD {row['metrics']['max_drawdown'] * 100:.1f}%)" + for row in top_two + ) + lines.append(f"- `{regime_name}`: 상위 후보는 {summary}") + + lines.extend( + [ + "", + "## 8. 원본 결과", + "", + f"- JSON: [{OUT_JSON}]({OUT_JSON})", + ] + ) + OUT_MD.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"wrote {OUT_JSON}") + print(f"wrote {OUT_MD}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_relaxed_macro_scaling_search.py b/scripts/run_relaxed_macro_scaling_search.py new file mode 100644 index 0000000..51b8cc0 --- /dev/null +++ b/scripts/run_relaxed_macro_scaling_search.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +import json +import os +import sys +from dataclasses import asdict +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy29.backtest.window_analysis import slice_bundle +from strategy32.live.runtime import BEST_CASH_OVERLAY, LIVE_STRATEGY_OVERRIDES +from strategy32.research.soft_router import ( + MacroScaleSpec, + build_cash_overlay_period_components, + compose_cash_overlay_curve, + load_component_bundle, + score_candidate, + segment_metrics, +) + + +CACHE_PATH = "/tmp/strategy32_fixed66_bundle.pkl" +OUT_JSON = Path("/tmp/strategy32_relaxed_macro_scaling_search.json") + +RELAXED_OVERHEAT_OVERRIDES = { + **LIVE_STRATEGY_OVERRIDES, + "momentum_min_score": 0.58, + "momentum_min_relative_strength": -0.03, + "momentum_min_7d_return": 0.00, + "universe_min_avg_dollar_volume": 75_000_000.0, + "hard_filter_refresh_cadence": "1d", + "hard_filter_min_history_bars": 120, + "hard_filter_lookback_bars": 30, + "hard_filter_min_avg_dollar_volume": 50_000_000.0, +} + +CURRENT_OVERHEAT_OVERRIDES = { + **LIVE_STRATEGY_OVERRIDES, + "hard_filter_refresh_cadence": "1d", + "hard_filter_min_history_bars": 120, + "hard_filter_lookback_bars": 30, + "hard_filter_min_avg_dollar_volume": 50_000_000.0, +} + +WINDOWS = ( + (365, "1y"), + (730, "2y"), + (1095, "3y"), + (1460, "4y"), + (1825, "5y"), +) + +YEAR_PERIODS = ( + ("2021", pd.Timestamp("2021-03-16 04:00:00+00:00"), pd.Timestamp("2022-01-01 00:00:00+00:00")), + ("2022", pd.Timestamp("2022-01-01 00:00:00+00:00"), pd.Timestamp("2023-01-01 00:00:00+00:00")), + ("2023", pd.Timestamp("2023-01-01 00:00:00+00:00"), pd.Timestamp("2024-01-01 00:00:00+00:00")), + ("2024", pd.Timestamp("2024-01-01 00:00:00+00:00"), pd.Timestamp("2025-01-01 00:00:00+00:00")), + ("2025", pd.Timestamp("2025-01-01 00:00:00+00:00"), pd.Timestamp("2026-01-01 00:00:00+00:00")), +) +YTD_START = pd.Timestamp("2026-01-01 00:00:00+00:00") + + +def _clip01(value: float) -> float: + return min(max(float(value), 0.0), 1.0) + + +def _ramp(value: float, start: float, end: float) -> float: + if end == start: + return 1.0 if value >= end else 0.0 + if value <= start: + return 0.0 + if value >= end: + return 1.0 + return (value - start) / (end - start) + + +def _build_macro_scale_map(sliced_bundle, *, timestamps: list[pd.Timestamp], spec: MacroScaleSpec) -> pd.Series: + btc_prices = sliced_bundle.prices["BTC"] + closes = btc_prices.set_index("timestamp")["close"].astype(float).sort_index() + daily = closes.resample("1D").last().dropna() + weekly = daily.resample("W-SUN").last().dropna() + fast = weekly.ewm(span=spec.fast_weeks, adjust=False).mean() + slow = weekly.ewm(span=spec.slow_weeks, adjust=False).mean() + close_scale = (weekly / slow - 1.0).apply(lambda value: _ramp(float(value), spec.close_gap_start, spec.close_gap_full)) + fast_scale = (fast / slow - 1.0).apply(lambda value: _ramp(float(value), spec.fast_gap_start, spec.fast_gap_full)) + blended = spec.close_weight * close_scale + (1.0 - spec.close_weight) * fast_scale + macro_scale = spec.floor + (1.0 - spec.floor) * blended.clip(0.0, 1.0) + aligned = macro_scale.reindex(pd.DatetimeIndex(timestamps, name="timestamp"), method="ffill") + return aligned.fillna(1.0).clip(spec.floor, 1.0).astype(float) + + +def _candidate_specs() -> list[MacroScaleSpec]: + specs: list[MacroScaleSpec] = [] + for floor in (0.25, 0.35, 0.45): + for close_gap_start, close_gap_full in ((-0.08, 0.02), (-0.06, 0.02), (-0.05, 0.04)): + for fast_gap_start, fast_gap_full in ((-0.04, 0.01), (-0.03, 0.02)): + for close_weight in (0.55, 0.65): + specs.append( + MacroScaleSpec( + floor=floor, + close_gap_start=close_gap_start, + close_gap_full=close_gap_full, + fast_gap_start=fast_gap_start, + fast_gap_full=fast_gap_full, + close_weight=close_weight, + ) + ) + return specs + + +def _collect_metrics(curve: pd.Series, latest_bar: pd.Timestamp) -> tuple[dict[str, dict[str, float]], dict[str, dict[str, float]], float, int, int]: + window_results: dict[str, dict[str, float]] = {} + for days, label in WINDOWS: + start = latest_bar - pd.Timedelta(days=days) + window_results[label] = segment_metrics(curve, start, latest_bar) + + year_results: dict[str, dict[str, float]] = {} + for label, start, end_exclusive in YEAR_PERIODS: + year_results[label] = segment_metrics(curve, start, min(latest_bar, end_exclusive - pd.Timedelta(seconds=1))) + year_results["2026_YTD"] = segment_metrics(curve, YTD_START, latest_bar) + + score, negative_years, mdd_violations = score_candidate( + {label: window_results[label] for _, label in WINDOWS}, + {label: year_results[label] for label, _, _ in YEAR_PERIODS}, + ) + return window_results, year_results, score, negative_years, mdd_violations + + +def _evaluate_exact_sequential( + bundle, + latest_bar: pd.Timestamp, + *, + core_overrides: dict[str, object], + macro_scale_spec: MacroScaleSpec | None, +) -> dict[str, object]: + window_results: dict[str, dict[str, float]] = {} + year_results: dict[str, dict[str, float]] = {} + + periods = [ + *(("window", label, latest_bar - pd.Timedelta(days=days), latest_bar) for days, label in WINDOWS), + *(("year", label, start, min(latest_bar, end_exclusive - pd.Timedelta(seconds=1))) for label, start, end_exclusive in YEAR_PERIODS), + ("year", "2026_YTD", YTD_START, latest_bar), + ] + + latest_weights: list[dict[str, object]] = [] + for kind, label, start, end in periods: + components = build_cash_overlay_period_components( + bundle=bundle, + eval_start=start, + eval_end=end, + profile_name=BEST_CASH_OVERLAY.regime_profile, + core_filter=BEST_CASH_OVERLAY.core_filter, + cap_engine=BEST_CASH_OVERLAY.cap_engine, + chop_engine=BEST_CASH_OVERLAY.chop_engine, + dist_engine=BEST_CASH_OVERLAY.dist_engine, + core_config_overrides=core_overrides, + macro_scale_spec=macro_scale_spec, + ) + curve, weights = compose_cash_overlay_curve(candidate=BEST_CASH_OVERLAY, **components) + metrics = segment_metrics(curve, start, end) + if kind == "window": + window_results[label] = metrics + else: + year_results[label] = metrics + if label == "2026_YTD": + latest_weights = weights.tail(1).assign(timestamp=lambda df: df["timestamp"].astype(str)).to_dict(orient="records") + + score, negative_years, mdd_violations = score_candidate( + {label: window_results[label] for _, label in WINDOWS}, + {label: year_results[label] for label, _, _ in YEAR_PERIODS}, + ) + return { + "candidate": asdict(BEST_CASH_OVERLAY), + "core_overrides": core_overrides, + "macro_scale_spec": asdict(macro_scale_spec) if macro_scale_spec is not None else None, + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "windows": window_results, + "years": year_results, + "latest_weights": latest_weights, + "validation": "exact_independent_periods_cash_overlay_sequential", + } + + +def main() -> None: + bundle, latest_bar = load_component_bundle(CACHE_PATH) + eval_start = latest_bar - pd.Timedelta(days=1825) + sliced = slice_bundle(bundle, eval_start - pd.Timedelta(days=365), latest_bar) + print("[phase] build relaxed core components", flush=True) + + relaxed_components = build_cash_overlay_period_components( + bundle=bundle, + eval_start=eval_start, + eval_end=latest_bar, + profile_name=BEST_CASH_OVERLAY.regime_profile, + core_filter=BEST_CASH_OVERLAY.core_filter, + cap_engine=BEST_CASH_OVERLAY.cap_engine, + chop_engine=BEST_CASH_OVERLAY.chop_engine, + dist_engine=BEST_CASH_OVERLAY.dist_engine, + core_config_overrides=RELAXED_OVERHEAT_OVERRIDES, + ) + print("[phase] search macro specs", flush=True) + + search_rows: list[dict[str, object]] = [] + specs = _candidate_specs() + for idx, spec in enumerate(specs, start=1): + macro_scale_map = _build_macro_scale_map(sliced, timestamps=relaxed_components["timestamps"][:-1], spec=spec) + curve, _weights = compose_cash_overlay_curve( + candidate=BEST_CASH_OVERLAY, + timestamps=relaxed_components["timestamps"], + score_frame=relaxed_components["score_frame"], + core_returns=relaxed_components["core_returns"], + core_exposure_frame=relaxed_components["core_exposure_frame"], + cap_returns=relaxed_components["cap_returns"], + chop_returns=relaxed_components["chop_returns"], + dist_returns=relaxed_components["dist_returns"], + macro_scale_map=macro_scale_map, + ) + windows, years, score, negative_years, mdd_violations = _collect_metrics(curve, latest_bar) + search_rows.append( + { + "macro_scale_spec": asdict(spec), + "windows": windows, + "years": years, + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + } + ) + if idx % 6 == 0 or idx == len(specs): + print(f"[search] {idx}/{len(specs)}", flush=True) + + search_rows.sort(key=lambda row: float(row["score"]), reverse=True) + top_search = search_rows[:5] + search_only = os.getenv("STRATEGY32_SEARCH_ONLY", "").strip().lower() in {"1", "true", "yes", "on"} + if search_only: + payload = { + "analysis": "relaxed_overheat_macro_scaling_search", + "mode": "search_only", + "latest_bar": str(latest_bar), + "core_filter": "relaxed_overheat", + "candidate": asdict(BEST_CASH_OVERLAY), + "search_top": top_search, + } + OUT_JSON.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(json.dumps(payload, indent=2)) + print(f"[saved] {OUT_JSON}") + return + print("[phase] exact baselines", flush=True) + + baselines = { + "current_overheat": _evaluate_exact_sequential( + bundle, + latest_bar, + core_overrides=CURRENT_OVERHEAT_OVERRIDES, + macro_scale_spec=None, + ), + "relaxed_overheat": _evaluate_exact_sequential( + bundle, + latest_bar, + core_overrides=RELAXED_OVERHEAT_OVERRIDES, + macro_scale_spec=None, + ), + } + + best_spec = MacroScaleSpec(**top_search[0]["macro_scale_spec"]) + print(f"[phase] exact best spec {best_spec.name}", flush=True) + best_exact = _evaluate_exact_sequential( + bundle, + latest_bar, + core_overrides=RELAXED_OVERHEAT_OVERRIDES, + macro_scale_spec=best_spec, + ) + + payload = { + "analysis": "relaxed_overheat_macro_scaling_search", + "latest_bar": str(latest_bar), + "core_filter": "relaxed_overheat", + "candidate": asdict(BEST_CASH_OVERLAY), + "baselines": baselines, + "search_top": top_search, + "best_exact": best_exact, + } + OUT_JSON.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(json.dumps(payload, indent=2)) + print(f"[saved] {OUT_JSON}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_soft_router_search.py b/scripts/run_soft_router_search.py new file mode 100644 index 0000000..4e0a8ad --- /dev/null +++ b/scripts/run_soft_router_search.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import itertools +import json +import sys +from dataclasses import asdict +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy29.backtest.window_analysis import slice_bundle +from strategy29.backtest.metrics import max_drawdown, sharpe_ratio +from strategy32.backtest.simulator import Strategy32Backtester +from strategy32.config import PROFILE_V7_DEFAULT, build_strategy32_config +from strategy32.research.adverse_regime import AdverseRegimeResearchHarness +from strategy32.research.hybrid_regime import _curve_returns, _run_adverse_component_curve +from strategy32.research.soft_router import ( + WINDOWS, + YEAR_PERIODS, + YTD_START, + SoftRouterCandidate, + build_regime_score_frame, + compose_soft_router_curve, + evaluate_candidate_exact, + load_component_bundle, + score_candidate, + segment_metrics, +) +from strategy32.research.hybrid_regime import STATIC_FILTERS + + +OUT_JSON = Path("/tmp/strategy32_soft_router_search.json") +OUT_MD = Path("/Volumes/SSD/data/nextcloud/data/tara/files/📂HeadOffice/money-bot/strategy32/015_soft_router_탐색결과.md") + +PROFILES = ("loose_positive",) +CORE_FILTERS = ("overheat_tolerant", "prev_balanced") +CAP_ENGINES = ("cap_btc_rebound",) +CHOP_ENGINES = ("chop_inverse_carry", "chop_inverse_carry_strict") +DIST_ENGINES = ("dist_inverse_carry_strict",) +CORE_FLOORS = (0.00, 0.10, 0.20) +CAP_MAX_WEIGHTS = (0.20, 0.35, 0.50) +CHOP_MAX_WEIGHTS = (0.10, 0.20, 0.35) +DIST_MAX_WEIGHTS = (0.10, 0.20, 0.35) +CHOP_BLEND_FLOORS = (0.00, 0.10, 0.20) + + +def _evaluate_from_curve(curve: pd.Series, latest_bar: pd.Timestamp) -> tuple[dict[str, dict[str, float]], dict[str, dict[str, float]], float, int, int]: + window_results = { + label: segment_metrics(curve, latest_bar - pd.Timedelta(days=days), latest_bar) + for days, label in WINDOWS + } + year_results = { + label: segment_metrics(curve, start, min(latest_bar, end_exclusive - pd.Timedelta(seconds=1))) + for label, start, end_exclusive in YEAR_PERIODS + } + year_results["2026_YTD"] = segment_metrics(curve, YTD_START, latest_bar) + score, negative_years, mdd_violations = score_candidate( + window_results, + {k: v for k, v in year_results.items() if k != "2026_YTD"}, + ) + return window_results, year_results, score, negative_years, mdd_violations + + +def _exact_static_variant(bundle, latest_bar: pd.Timestamp, filter_name: str) -> dict[str, object]: + window_results: dict[str, dict[str, float]] = {} + year_results: dict[str, dict[str, float]] = {} + + for days, label in WINDOWS: + eval_start = latest_bar - pd.Timedelta(days=days) + raw_start = eval_start - pd.Timedelta(days=90) + from strategy29.backtest.window_analysis import slice_bundle + + sliced = slice_bundle(bundle, raw_start, latest_bar) + cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[filter_name]) + backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start) + backtester.engine_config.initial_capital = 1000.0 + curve = backtester.run().equity_curve.loc[lambda s: s.index >= eval_start] + window_results[label] = segment_metrics(curve, eval_start, latest_bar) + + for label, start, end_exclusive in YEAR_PERIODS: + eval_end = min(latest_bar, end_exclusive - pd.Timedelta(seconds=1)) + raw_start = start - pd.Timedelta(days=90) + from strategy29.backtest.window_analysis import slice_bundle + + sliced = slice_bundle(bundle, raw_start, eval_end) + cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[filter_name]) + backtester = Strategy32Backtester(cfg, sliced, trade_start=start) + backtester.engine_config.initial_capital = 1000.0 + curve = backtester.run().equity_curve.loc[lambda s: s.index >= start] + year_results[label] = segment_metrics(curve, start, eval_end) + + raw_start = YTD_START - pd.Timedelta(days=90) + from strategy29.backtest.window_analysis import slice_bundle + + sliced = slice_bundle(bundle, raw_start, latest_bar) + cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[filter_name]) + backtester = Strategy32Backtester(cfg, sliced, trade_start=YTD_START) + backtester.engine_config.initial_capital = 1000.0 + curve = backtester.run().equity_curve.loc[lambda s: s.index >= YTD_START] + year_results["2026_YTD"] = segment_metrics(curve, YTD_START, latest_bar) + + score, negative_years, mdd_violations = score_candidate( + window_results, + {k: v for k, v in year_results.items() if k != "2026_YTD"}, + ) + return { + "name": filter_name, + "windows": window_results, + "years": year_results, + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "validation": "exact_static_variant", + } + + +def main() -> None: + bundle, latest_bar = load_component_bundle() + eval_start = latest_bar - pd.Timedelta(days=1825) + raw_start = eval_start - pd.Timedelta(days=90) + sliced = slice_bundle(bundle, raw_start, latest_bar) + precomputed: dict[str, object] = {"profiles": {}} + + for profile_name in PROFILES: + score_frame = build_regime_score_frame(sliced, eval_start, latest_bar, profile_name=profile_name) + harness = AdverseRegimeResearchHarness(sliced, latest_bar) + timestamps = sorted(sliced.prices["BTC"]["timestamp"].loc[sliced.prices["BTC"]["timestamp"] >= eval_start].tolist()) + core_returns: dict[str, pd.Series] = {} + adverse_returns: dict[str, pd.Series] = {} + + for core_filter in CORE_FILTERS: + cfg = build_strategy32_config(PROFILE_V7_DEFAULT, **STATIC_FILTERS[core_filter]) + backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start) + backtester.engine_config.initial_capital = 1000.0 + core_curve = backtester.run().equity_curve.loc[lambda s: s.index >= eval_start] + core_returns[core_filter] = _curve_returns(core_curve) + print(f"[cache core] {profile_name}|{core_filter}", flush=True) + + for engine_name in sorted(set(CAP_ENGINES) | set(CHOP_ENGINES) | set(DIST_ENGINES)): + adverse_curve = _run_adverse_component_curve( + eval_start=eval_start, + engine_name=engine_name, + harness=harness, + regime_frame=score_frame, + ) + adverse_returns[engine_name] = _curve_returns(adverse_curve) + print(f"[cache adverse] {profile_name}|{engine_name}", flush=True) + + precomputed["profiles"][profile_name] = { + "score_frame": score_frame, + "timestamps": timestamps, + "core_returns": core_returns, + "adverse_returns": adverse_returns, + } + + candidates = [ + SoftRouterCandidate(*combo) + for combo in itertools.product( + PROFILES, + CORE_FILTERS, + CAP_ENGINES, + CHOP_ENGINES, + DIST_ENGINES, + CORE_FLOORS, + CAP_MAX_WEIGHTS, + CHOP_MAX_WEIGHTS, + DIST_MAX_WEIGHTS, + CHOP_BLEND_FLOORS, + ) + ] + + approx_rows: list[dict[str, object]] = [] + for idx, candidate in enumerate(candidates, start=1): + profile_cache = precomputed["profiles"][candidate.regime_profile] + components = { + "timestamps": profile_cache["timestamps"], + "score_frame": profile_cache["score_frame"], + "core_returns": profile_cache["core_returns"][candidate.core_filter], + "cap_returns": profile_cache["adverse_returns"][candidate.cap_engine], + "chop_returns": profile_cache["adverse_returns"][candidate.chop_engine], + "dist_returns": profile_cache["adverse_returns"][candidate.dist_engine], + } + curve, weights = compose_soft_router_curve(candidate=candidate, **components) + window_results, year_results, score, negative_years, mdd_violations = _evaluate_from_curve(curve, latest_bar) + approx_rows.append( + { + "candidate": asdict(candidate), + "name": candidate.name, + "score": score, + "negative_years": negative_years, + "mdd_violations": mdd_violations, + "windows": window_results, + "years": year_results, + "avg_weights": { + "core": float(weights["core_weight"].mean()), + "cap": float(weights["cap_weight"].mean()), + "chop": float(weights["chop_weight"].mean()), + "dist": float(weights["dist_weight"].mean()), + "cash": float(weights["cash_weight"].mean()), + }, + "validation": "approx_full_curve_slice", + } + ) + if idx % 100 == 0 or idx == len(candidates): + print( + f"[approx {idx:04d}/{len(candidates)}] top={approx_rows[-1]['name']} " + f"1y={window_results['1y']['total_return'] * 100:.2f}% " + f"5y_ann={window_results['5y']['annualized_return'] * 100:.2f}%", + flush=True, + ) + + approx_rows.sort(key=lambda row: (int(row["negative_years"]), int(row["mdd_violations"]), -float(row["score"]))) + + exact_top = [] + for row in approx_rows[:3]: + candidate = SoftRouterCandidate(**row["candidate"]) + print(f"[exact-start] {candidate.name}", flush=True) + result = evaluate_candidate_exact(bundle=bundle, latest_bar=latest_bar, candidate=candidate) + exact_top.append(result) + exact_top.sort(key=lambda item: (int(item["negative_years"]), int(item["mdd_violations"]), -float(item["score"]))) + print( + f"[exact] {candidate.name} 1y={result['windows']['1y']['total_return'] * 100:.2f}% " + f"5y_ann={result['windows']['5y']['annualized_return'] * 100:.2f}% " + f"neg={result['negative_years']} mdd_viol={result['mdd_violations']}", + flush=True, + ) + + static_exact = [_exact_static_variant(bundle, latest_bar, filter_name) for filter_name in CORE_FILTERS] + + payload = { + "analysis": "strategy32_soft_router_search", + "latest_completed_bar": str(latest_bar), + "candidate_count": len(candidates), + "component_cache_count": sum( + len(profile_cache["core_returns"]) + len(profile_cache["adverse_returns"]) + for profile_cache in precomputed["profiles"].values() + ), + "summary": approx_rows[:20], + "exact_top": exact_top, + "exact_static": static_exact, + } + OUT_JSON.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + lines = [ + "# Strategy32 Soft Router 탐색결과", + "", + "## 1. 목적", + "", + "`5개 하드 레짐 -> 1엔진 선택` 구조를 버리고, `정적 코어 엔진 + adverse overlay` 구조를 연속형 점수 기반으로 탐색한다.", + "", + "## 2. 탐색 범위", + "", + f"- profiles: `{', '.join(PROFILES)}`", + f"- core filters: `{', '.join(CORE_FILTERS)}`", + f"- cap engines: `{', '.join(CAP_ENGINES)}`", + f"- chop engines: `{', '.join(CHOP_ENGINES)}`", + f"- dist engines: `{', '.join(DIST_ENGINES)}`", + f"- total candidates: `{len(candidates)}`", + "", + "## 3. exact 상위 후보", + "", + "| rank | candidate | 1y | 2y ann | 3y ann | 4y ann | 5y ann | 5y MDD | 2025 | 2024 |", + "|---|---|---:|---:|---:|---:|---:|---:|---:|---:|", + ] + for idx, row in enumerate(exact_top, start=1): + lines.append( + f"| `{idx}` | `{row['name']}` | `{row['windows']['1y']['total_return'] * 100:.2f}%` | " + f"`{row['windows']['2y']['annualized_return'] * 100:.2f}%` | " + f"`{row['windows']['3y']['annualized_return'] * 100:.2f}%` | " + f"`{row['windows']['4y']['annualized_return'] * 100:.2f}%` | " + f"`{row['windows']['5y']['annualized_return'] * 100:.2f}%` | " + f"`{row['windows']['5y']['max_drawdown'] * 100:.2f}%` | " + f"`{row['years']['2025']['total_return'] * 100:.2f}%` | " + f"`{row['years']['2024']['total_return'] * 100:.2f}%` |" + ) + + lines.extend( + [ + "", + "## 4. 정적 코어 exact 비교", + "", + "| core filter | 1y | 2y ann | 3y ann | 4y ann | 5y ann | 5y MDD | 2025 | 2024 |", + "|---|---:|---:|---:|---:|---:|---:|---:|---:|", + ] + ) + for row in static_exact: + lines.append( + f"| `{row['name']}` | `{row['windows']['1y']['total_return'] * 100:.2f}%` | " + f"`{row['windows']['2y']['annualized_return'] * 100:.2f}%` | " + f"`{row['windows']['3y']['annualized_return'] * 100:.2f}%` | " + f"`{row['windows']['4y']['annualized_return'] * 100:.2f}%` | " + f"`{row['windows']['5y']['annualized_return'] * 100:.2f}%` | " + f"`{row['windows']['5y']['max_drawdown'] * 100:.2f}%` | " + f"`{row['years']['2025']['total_return'] * 100:.2f}%` | " + f"`{row['years']['2024']['total_return'] * 100:.2f}%` |" + ) + + lines.extend( + [ + "", + "## 5. 해석", + "", + "- soft router가 정적 코어보다 좋아지려면, adverse overlay가 `2024/2025 방어`를 만들어내면서 `5y CAGR`을 크게 훼손하지 않아야 한다.", + "- exact 결과가 정적 코어보다 약하면, 현재 adverse overlay 신호 품질 또는 overlay weight 공식이 아직 최적이 아니라는 뜻이다.", + "", + "## 6. 원본 결과", + "", + f"- JSON: [{OUT_JSON}]({OUT_JSON})", + ] + ) + OUT_MD.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_v7_branch_validation.py b/scripts/run_v7_branch_validation.py new file mode 100644 index 0000000..618e2f5 --- /dev/null +++ b/scripts/run_v7_branch_validation.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import copy +import json +import sys +from pathlib import Path + +import pandas as pd + +PACKAGE_PARENT = Path(__file__).resolve().parents[2] +if str(PACKAGE_PARENT) not in sys.path: + sys.path.insert(0, str(PACKAGE_PARENT)) + +from strategy29.backtest.window_analysis import evaluate_window_result, slice_bundle +from strategy32.backtest.simulator import Strategy32Backtester +from strategy32.config import PROFILE_V7_DEFAULT, build_strategy32_config +from strategy32.data import build_strategy32_market_bundle + + +WINDOWS = [(7, "1w"), (30, "1m"), (365, "1y"), (1095, "3y"), (1825, "5y")] + + +def balanced_score(results: dict[str, dict[str, float | int | str]]) -> float: + score = 0.0 + for label, weight in (("1y", 1.0), ("3y", 1.0), ("5y", 1.2)): + annualized = float(results[label]["annualized_return"]) + drawdown = abs(float(results[label]["max_drawdown"])) + score += weight * (annualized / max(drawdown, 0.01)) + score += 0.15 * float(results["1m"]["total_return"]) + return score + + +def main() -> None: + base = build_strategy32_config(PROFILE_V7_DEFAULT) + end = pd.Timestamp("2026-03-15 00:00:00", tz="UTC") + start = end - pd.Timedelta(days=max(days for days, _ in WINDOWS) + base.warmup_days + 14) + + variants: list[tuple[str, dict[str, bool]]] = [ + ("v7_default", {}), + ("v7_plus_expanded_hedge", {"enable_expanded_hedge": True}), + ("v7_plus_max_holding_exit", {"enable_max_holding_exit": True}), + ("v7_plus_expanded_hedge_plus_max_holding_exit", {"enable_expanded_hedge": True, "enable_max_holding_exit": True}), + ] + + print("fetching bundle...") + bundle, latest_completed_bar, accepted_symbols, rejected_symbols, quote_by_symbol = build_strategy32_market_bundle( + symbols=base.symbols, + auto_discover_symbols=base.auto_discover_symbols, + quote_assets=base.quote_assets, + excluded_base_assets=base.excluded_base_assets, + min_quote_volume_24h=base.discovery_min_quote_volume_24h, + start=start, + end=end, + timeframe=base.timeframe, + max_staleness_days=base.max_symbol_staleness_days, + ) + print("latest", latest_completed_bar) + + results: dict[str, dict[str, dict[str, float | int | str]]] = {} + summary_rows: list[dict[str, float | int | str]] = [] + for name, overrides in variants: + cfg = copy.deepcopy(base) + for attr, value in overrides.items(): + setattr(cfg, attr, value) + variant_results = {} + print(f"\nVARIANT {name}") + for days, label in WINDOWS: + eval_end = latest_completed_bar + eval_start = eval_end - pd.Timedelta(days=days) + raw_start = eval_start - pd.Timedelta(days=cfg.warmup_days) + sliced = slice_bundle(bundle, raw_start, eval_end) + backtester = Strategy32Backtester(cfg, sliced, trade_start=eval_start) + backtester.engine_config.initial_capital = 1000.0 + result = backtester.run() + metrics = evaluate_window_result(result, eval_start=eval_start, bars_per_day=backtester.engine_config.bars_per_day) + metrics["engine_pnl"] = result.engine_pnl + metrics["total_trades"] = result.total_trades + variant_results[label] = metrics + print( + label, + "ret", + round(float(metrics["total_return"]) * 100, 2), + "mdd", + round(float(metrics["max_drawdown"]) * 100, 2), + "sharpe", + round(float(metrics["sharpe"]), 2), + "trades", + metrics["trade_count"], + ) + score = balanced_score(variant_results) + results[name] = variant_results + summary_rows.append( + { + "name": name, + "balanced_score": score, + "ret_1w": float(variant_results["1w"]["total_return"]), + "ret_1m": float(variant_results["1m"]["total_return"]), + "ret_1y": float(variant_results["1y"]["total_return"]), + "ret_3y": float(variant_results["3y"]["total_return"]), + "ret_5y": float(variant_results["5y"]["total_return"]), + "mdd_1y": float(variant_results["1y"]["max_drawdown"]), + "mdd_3y": float(variant_results["3y"]["max_drawdown"]), + "mdd_5y": float(variant_results["5y"]["max_drawdown"]), + } + ) + + summary_rows.sort(key=lambda row: float(row["balanced_score"]), reverse=True) + payload = { + "strategy": "strategy32", + "analysis": "v7_branch_validation", + "profile": PROFILE_V7_DEFAULT, + "initial_capital": 1000.0, + "auto_discover_symbols": base.auto_discover_symbols, + "latest_completed_bar": str(latest_completed_bar), + "requested_symbols": [] if base.auto_discover_symbols else base.symbols, + "accepted_symbols": accepted_symbols, + "rejected_symbols": rejected_symbols, + "quote_by_symbol": quote_by_symbol, + "timeframe": base.timeframe, + "results": results, + "summary": summary_rows, + } + out = Path("/tmp/strategy32_v7_branch_validation.json") + out.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print("\nRanked variants") + for row in summary_rows: + print( + row["name"], + "score", + round(float(row["balanced_score"]), 3), + "1y", + round(float(row["ret_1y"]) * 100, 2), + "3y", + round(float(row["ret_3y"]) * 100, 2), + "5y", + round(float(row["ret_5y"]) * 100, 2), + "mdd5y", + round(float(row["mdd_5y"]) * 100, 2), + ) + print("\nwrote", out) + + +if __name__ == "__main__": + main() diff --git a/signal/__init__.py b/signal/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/signal/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/signal/router.py b/signal/router.py new file mode 100644 index 0000000..8930ab3 --- /dev/null +++ b/signal/router.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from strategy29.common.models import AllocationDecision, Regime +from strategy29.signal.allocation_router import AllocationRouter +from strategy32.config import Strategy32Budgets + + +class Strategy32Router(AllocationRouter): + def __init__(self, budgets: Strategy32Budgets): + self.budgets = budgets + super().__init__() + + def decide(self, regime: Regime) -> AllocationDecision: + momentum_budget_pct, carry_budget_pct, sideways_budget_pct = self.budgets.for_regime(regime) + cash_budget_pct = max(0.0, 1.0 - momentum_budget_pct - carry_budget_pct - sideways_budget_pct) + return AllocationDecision( + regime=regime, + momentum_budget_pct=momentum_budget_pct, + carry_budget_pct=carry_budget_pct, + spread_budget_pct=sideways_budget_pct, + cash_budget_pct=cash_budget_pct, + ) diff --git a/tests/test_strategy32.py b/tests/test_strategy32.py new file mode 100644 index 0000000..9c590dc --- /dev/null +++ b/tests/test_strategy32.py @@ -0,0 +1,794 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from urllib.error import HTTPError +from unittest.mock import patch + +import pandas as pd + +from strategy29.data import binance_history +from strategy29.common.models import MarketDataBundle, Regime +from strategy32.backtest.simulator import Strategy32Backtester, Strategy32MomentumCarryBacktester +from strategy32.config import PROFILE_V5_BASELINE, PROFILE_V7_DEFAULT, Strategy32Budgets, Strategy32Config, build_strategy32_config +from strategy32.live.executor import LiveExecutionConfig, LiveFuturesExecutor +from strategy32.live.runtime import ( + BEST_CASH_OVERLAY, + LiveMonitorConfig, + _capital_summary, + _apply_weekly_macro_filter, + _combine_targets, + _completed_bar_time, + _execution_refinement_states, + _refine_execution_targets, + _expand_core_targets, + _heartbeat_slot, + _overlay_signal_strengths, + _select_live_hard_filter_symbols, + _weekly_macro_filter_state, +) +from strategy32.research.soft_router import ( + CashOverlayCandidate, + MacroScaleSpec, + SoftRouterCandidate, + compose_cash_overlay_curve, + compose_soft_router_curve, +) +from strategy32.signal.router import Strategy32Router +from strategy32.universe import filter_momentum_frame, limit_correlated_symbols, rank_momentum_universe, score_momentum_universe, select_dynamic_universe, select_strategic_universe + + +def make_bundle(bars: int = 260) -> MarketDataBundle: + timestamps = pd.date_range("2025-01-01", periods=bars, freq="4h", tz="UTC") + prices = { + "BTC": pd.DataFrame( + { + "timestamp": timestamps, + "open": [100.0 + i * 0.20 for i in range(bars)], + "high": [100.3 + i * 0.20 for i in range(bars)], + "low": [99.8 + i * 0.20 for i in range(bars)], + "close": [100.0 + i * 0.20 for i in range(bars)], + "volume": [2_000_000.0] * bars, + } + ), + "ETH": pd.DataFrame( + { + "timestamp": timestamps, + "open": [50.0 + i * 0.25 for i in range(bars)], + "high": [50.3 + i * 0.25 for i in range(bars)], + "low": [49.8 + i * 0.25 for i in range(bars)], + "close": [50.0 + i * 0.25 for i in range(bars)], + "volume": [2_500_000.0] * bars, + } + ), + "SOL": pd.DataFrame( + { + "timestamp": timestamps, + "open": [20.0 + i * 0.18 for i in range(bars)], + "high": [20.2 + i * 0.18 for i in range(bars)], + "low": [19.9 + i * 0.18 for i in range(bars)], + "close": [20.0 + i * 0.18 for i in range(bars)], + "volume": [1_800_000.0] * bars, + } + ), + } + funding = { + "ETH": pd.DataFrame( + { + "timestamp": timestamps, + "funding_rate": [0.00015] * bars, + "basis": [0.0100 - i * 0.00001 for i in range(bars)], + } + ), + "SOL": pd.DataFrame( + { + "timestamp": timestamps, + "funding_rate": [0.00012] * bars, + "basis": [0.0090 - i * 0.00001 for i in range(bars)], + } + ), + } + return MarketDataBundle(prices=prices, funding=funding) + + +def make_execution_prices(bundle: MarketDataBundle, *, blocked_symbols: set[str] | None = None) -> dict[str, pd.DataFrame]: + blocked_symbols = blocked_symbols or set() + start = pd.Timestamp(bundle.prices["BTC"]["timestamp"].iloc[0]) + end = pd.Timestamp(bundle.prices["BTC"]["timestamp"].iloc[-1]) + timestamps = pd.date_range(start, end, freq="1h", tz="UTC") + prices: dict[str, pd.DataFrame] = {} + for symbol in bundle.prices: + base = 100.0 if symbol == "BTC" else 50.0 + if symbol in blocked_symbols: + closes = [base - i * 0.15 for i in range(len(timestamps))] + else: + closes = [base + i * 0.08 for i in range(len(timestamps))] + prices[symbol] = pd.DataFrame( + { + "timestamp": timestamps, + "open": closes, + "high": [value + 0.2 for value in closes], + "low": [value - 0.2 for value in closes], + "close": closes, + "volume": [1_000_000.0] * len(timestamps), + } + ) + return prices + + +class Strategy32Tests(unittest.TestCase): + def test_router_disables_spread(self) -> None: + decision = Strategy32Router(Strategy32Config().budgets).decide(Regime.STRONG_UP) + self.assertEqual(decision.spread_budget_pct, 0.0) + self.assertGreater(decision.momentum_budget_pct, 0.0) + + def test_dynamic_universe_picks_highest_volume_symbols(self) -> None: + bundle = make_bundle() + bundle.prices["SOL"]["volume"] = 100_000.0 + selected = select_dynamic_universe( + bundle.prices, + timestamp=bundle.prices["BTC"]["timestamp"].iloc[-1], + min_history_bars=120, + lookback_bars=30, + max_symbols=1, + min_avg_dollar_volume=1_000_000.0, + ) + self.assertEqual(selected, ["ETH"]) + + def test_dynamic_universe_supports_unlimited_selection(self) -> None: + bundle = make_bundle() + selected = select_dynamic_universe( + bundle.prices, + timestamp=bundle.prices["BTC"]["timestamp"].iloc[-1], + min_history_bars=120, + lookback_bars=30, + max_symbols=0, + min_avg_dollar_volume=1_000_000.0, + ) + self.assertEqual(selected, ["ETH", "SOL"]) + + def test_live_hard_filter_uses_daily_cut_before_ranking(self) -> None: + bundle = make_bundle() + bundle.prices["SOL"]["volume"] = 10_000.0 + selected = _select_live_hard_filter_symbols( + bundle.prices, + timestamp=bundle.prices["BTC"]["timestamp"].iloc[-1], + config=LiveMonitorConfig( + hard_filter_min_history_bars=120, + hard_filter_lookback_bars=30, + hard_filter_min_avg_dollar_volume=1_000_000.0, + ), + ) + self.assertEqual(selected, ["BTC", "ETH"]) + + def test_backtester_daily_hard_filter_cache_sticks_within_day(self) -> None: + bundle = make_bundle(bars=12) + bundle.prices["ETH"]["volume"] = 60_000.0 + bundle.prices["SOL"]["volume"] = 1.0 + bundle.prices["SOL"].loc[2, "volume"] = 120_000.0 + bundle.prices["SOL"].loc[5, "volume"] = 1.0 + config = Strategy32Config( + hard_filter_refresh_cadence="1d", + hard_filter_min_history_bars=1, + hard_filter_lookback_bars=1, + hard_filter_min_avg_dollar_volume=1_000_000.0, + ) + backtester = Strategy32MomentumCarryBacktester(config, bundle) + first_ts = bundle.prices["BTC"]["timestamp"].iloc[2] + later_same_day_ts = bundle.prices["BTC"]["timestamp"].iloc[5] + initial = backtester._hard_filter_symbols(first_ts, min_history_bars=1) + same_day = backtester._hard_filter_symbols(later_same_day_ts, min_history_bars=1) + self.assertIn("SOL", initial) + self.assertIn("SOL", same_day) + + def test_backtester_intraday_hard_filter_changes_without_daily_cache(self) -> None: + bundle = make_bundle(bars=12) + bundle.prices["ETH"]["volume"] = 60_000.0 + bundle.prices["SOL"]["volume"] = 1.0 + bundle.prices["SOL"].loc[2, "volume"] = 120_000.0 + bundle.prices["SOL"].loc[5, "volume"] = 1.0 + config = Strategy32Config( + hard_filter_refresh_cadence="4h", + hard_filter_min_history_bars=1, + hard_filter_lookback_bars=1, + hard_filter_min_avg_dollar_volume=1_000_000.0, + ) + backtester = Strategy32MomentumCarryBacktester(config, bundle) + first_ts = bundle.prices["BTC"]["timestamp"].iloc[2] + later_same_day_ts = bundle.prices["BTC"]["timestamp"].iloc[5] + initial = backtester._hard_filter_symbols(first_ts, min_history_bars=1) + same_day = backtester._hard_filter_symbols(later_same_day_ts, min_history_bars=1) + self.assertIn("SOL", initial) + self.assertNotIn("SOL", same_day) + + def test_weekly_macro_filter_flags_downtrend_as_risk_off(self) -> None: + timestamps = pd.date_range("2024-01-01", periods=400, freq="1D", tz="UTC") + prices = { + "BTC": pd.DataFrame( + { + "timestamp": timestamps, + "open": [300.0 - i * 0.4 for i in range(len(timestamps))], + "high": [301.0 - i * 0.4 for i in range(len(timestamps))], + "low": [299.0 - i * 0.4 for i in range(len(timestamps))], + "close": [300.0 - i * 0.4 for i in range(len(timestamps))], + "volume": [1_000_000.0] * len(timestamps), + } + ) + } + macro = _weekly_macro_filter_state( + prices, + timestamp=timestamps[-1], + config=LiveMonitorConfig(macro_filter_fast_weeks=10, macro_filter_slow_weeks=30), + ) + self.assertFalse(macro["risk_on"]) + + def test_weekly_macro_filter_removes_tradeable_core_targets_when_risk_off(self) -> None: + filtered = _apply_weekly_macro_filter( + [ + {"instrument": "perp:ETH", "tradeable": True, "source": "core", "weight": 0.3}, + {"instrument": "carry:SOL", "tradeable": False, "source": "core", "weight": 0.1}, + ], + macro_state={"risk_on": False}, + ) + self.assertEqual(filtered, [{"instrument": "carry:SOL", "tradeable": False, "source": "core", "weight": 0.1}]) + + def test_execution_refinement_blocks_extended_entry(self) -> None: + bars = 64 + timestamps = pd.date_range("2025-01-01", periods=bars, freq="1h", tz="UTC") + prices = { + "ETH": pd.DataFrame( + { + "timestamp": timestamps, + "open": [100.0 + i * 0.4 for i in range(bars)], + "high": [100.3 + i * 0.4 for i in range(bars)], + "low": [99.7 + i * 0.4 for i in range(bars)], + "close": [100.0 + i * 0.4 for i in range(bars - 1)] + [140.0], + "volume": [1_000_000.0] * bars, + } + ) + } + states = _execution_refinement_states( + prices, + timestamp=timestamps[-1], + config=LiveMonitorConfig( + execution_refinement_lookback_bars=48, + execution_refinement_fast_ema=8, + execution_refinement_slow_ema=21, + execution_refinement_scale_down_gap=0.008, + execution_refinement_max_chase_gap=0.018, + execution_refinement_max_recent_return=0.03, + execution_refinement_scale_down_factor=0.5, + ), + ) + self.assertEqual(states["ETH"]["action"], "block") + + def test_refined_execution_targets_scale_positive_perp_only(self) -> None: + refined = _refine_execution_targets( + [ + {"instrument": "perp:ETH", "tradeable": True, "weight": 0.4}, + {"instrument": "perp:BTC", "tradeable": True, "weight": -0.2}, + ], + refinement_states={"ETH": {"action": "scale_down", "scale": 0.5, "reason": "slightly_extended"}}, + ) + self.assertEqual(refined[0]["weight"], 0.2) + self.assertEqual(refined[0]["desired_weight"], 0.4) + self.assertEqual(refined[1]["weight"], -0.2) + + def test_executor_entry_only_refinement_does_not_force_close_existing_position(self) -> None: + class FakeClient: + def __init__(self) -> None: + self.orders: list[dict[str, object]] = [] + + def get_balance(self): + return [{"asset": "USDT", "balance": "1000"}] + + def get_position_risk(self): + return [{"symbol": "ETHUSDT", "positionAmt": "1", "markPrice": "100", "entryPrice": "100", "notional": "100", "unRealizedProfit": "0"}] + + def get_ticker_price(self, symbol): + return {"symbol": symbol, "price": "100"} + + def get_exchange_info(self): + return { + "symbols": [ + { + "symbol": "ETHUSDT", + "baseAsset": "ETH", + "quoteAsset": "USDT", + "filters": [ + {"filterType": "LOT_SIZE", "stepSize": "0.001", "minQty": "0.001"}, + {"filterType": "MIN_NOTIONAL", "notional": "5"}, + ], + } + ] + } + + def set_leverage(self, symbol, leverage): + return {"symbol": symbol, "leverage": leverage} + + def place_market_order(self, **kwargs): + self.orders.append(kwargs) + return {"status": "FILLED", **kwargs} + + executor = LiveFuturesExecutor( + FakeClient(), + LiveExecutionConfig( + enabled=True, + leverage=2, + min_target_notional_usd=25.0, + min_rebalance_notional_usd=10.0, + close_orphan_positions=True, + entry_only_refinement=True, + ), + ) + result = executor.reconcile( + { + "generated_at": "2026-03-16T00:00:00Z", + "universe": {"quote_by_symbol": {"ETH": "USDT"}}, + "execution_targets": [ + { + "instrument": "perp:ETH", + "tradeable": True, + "weight": 0.0, + "desired_weight": 0.4, + "refinement_action": "block", + "refinement_reason": "too_extended", + } + ], + } + ) + self.assertEqual(result.orders, []) + + def test_capital_summary_extracts_usdt_and_usdc(self) -> None: + summary = _capital_summary( + { + "balances": [ + {"asset": "USDT", "balance": "1200.5"}, + {"asset": "USDC", "balance": "300.25"}, + {"asset": "BTC", "balance": "0.1"}, + ] + } + ) + self.assertEqual(summary, {"usdt": 1200.5, "usdc": 300.25, "total_quote": 1500.75}) + + def test_momentum_universe_prefers_stronger_symbol(self) -> None: + bundle = make_bundle() + ranked = rank_momentum_universe( + bundle.prices, + bundle.funding, + btc_symbol="BTC", + timestamp=bundle.prices["BTC"]["timestamp"].iloc[-1], + candidate_symbols=["ETH", "SOL"], + min_history_bars=120, + liquidity_lookback_bars=30, + short_lookback_bars=18, + long_lookback_bars=72, + overheat_funding_rate=0.00025, + max_symbols=2, + ) + self.assertEqual(ranked[0], "SOL") + + def test_momentum_quality_filter_drops_overheated_symbol(self) -> None: + bundle = make_bundle() + bundle.prices["PEPE"] = bundle.prices["ETH"].copy() + bundle.prices["PEPE"]["close"] = [10.0 + i * 0.80 for i in range(len(bundle.prices["PEPE"]))] + bundle.prices["PEPE"]["volume"] = [3_000_000.0] * len(bundle.prices["PEPE"]) + bundle.funding["PEPE"] = pd.DataFrame( + { + "timestamp": bundle.prices["PEPE"]["timestamp"], + "funding_rate": [0.0008] * len(bundle.prices["PEPE"]), + "basis": [0.012] * len(bundle.prices["PEPE"]), + } + ) + frame = score_momentum_universe( + bundle.prices, + bundle.funding, + btc_symbol="BTC", + timestamp=bundle.prices["BTC"]["timestamp"].iloc[-1], + candidate_symbols=["ETH", "PEPE"], + min_history_bars=120, + liquidity_lookback_bars=30, + short_lookback_bars=18, + long_lookback_bars=72, + overheat_funding_rate=0.00025, + ) + filtered = filter_momentum_frame( + frame, + min_score=0.0, + min_relative_strength=-1.0, + min_7d_return=-1.0, + max_7d_return=0.35, + min_positive_bar_ratio=0.0, + max_short_volatility=1.0, + max_latest_funding_rate=0.00045, + max_beta=10.0, + ) + self.assertIn("ETH", filtered["symbol"].tolist()) + self.assertNotIn("PEPE", filtered["symbol"].tolist()) + + def test_strategic_universe_keeps_symbols_with_positive_edge(self) -> None: + bundle = make_bundle() + selected = select_strategic_universe( + bundle.prices, + bundle.funding, + btc_symbol="BTC", + timestamp=bundle.prices["BTC"]["timestamp"].iloc[-1], + min_history_bars=120, + lookback_bars=30, + min_avg_dollar_volume=1_000_000.0, + short_lookback_bars=18, + long_lookback_bars=72, + overheat_funding_rate=0.00025, + carry_lookback_bars=21, + carry_expected_horizon_bars=18, + carry_roundtrip_cost_pct=0.0020, + carry_basis_risk_multiplier=1.0, + momentum_min_score=0.0, + momentum_min_relative_strength=-1.0, + momentum_min_7d_return=-1.0, + momentum_max_7d_return=1.0, + momentum_min_positive_bar_ratio=0.0, + momentum_max_short_volatility=1.0, + momentum_max_latest_funding_rate=1.0, + momentum_max_beta=10.0, + carry_min_expected_edge=-1.0, + max_symbols=0, + ) + self.assertIn("ETH", selected) + self.assertIn("SOL", selected) + + def test_correlation_limit_drops_duplicate_path(self) -> None: + bundle = make_bundle() + bundle.prices["LINK"] = bundle.prices["ETH"].copy() + bundle.prices["LINK"]["close"] = [30.0 + i * 0.10 for i in range(len(bundle.prices["LINK"]))] + bundle.prices["SOL"]["close"] = [20.0 + i * 0.18 + (0.9 if i % 2 == 0 else -0.7) for i in range(len(bundle.prices["SOL"]))] + limited = limit_correlated_symbols( + bundle.prices, + timestamp=bundle.prices["BTC"]["timestamp"].iloc[-1], + candidate_symbols=["ETH", "LINK", "SOL"], + lookback_bars=36, + max_pairwise_correlation=0.80, + max_symbols=2, + ) + self.assertEqual(limited, ["ETH", "SOL"]) + + def test_backtester_runs(self) -> None: + result = Strategy32Backtester( + Strategy32Config( + symbols=["BTC", "ETH", "SOL"], + momentum_min_history_bars=120, + momentum_max_7d_return=1.0, + momentum_min_positive_bar_ratio=0.0, + momentum_max_short_volatility=1.0, + momentum_max_beta=10.0, + momentum_max_latest_funding_rate=1.0, + ), + make_bundle(), + ).run() + self.assertGreater(result.total_trades, 0) + self.assertIn("momentum", result.engine_pnl) + + def test_backtester_execution_refinement_blocks_entry_and_logs_rejection(self) -> None: + bundle = make_bundle() + bundle.prices = {"BTC": bundle.prices["BTC"], "ETH": bundle.prices["ETH"]} + bundle.funding = {"ETH": bundle.funding["ETH"]} + config = Strategy32Config( + symbols=["BTC", "ETH"], + budgets=Strategy32Budgets( + strong_up_carry=0.0, + up_carry=0.0, + sideways_carry=0.0, + down_carry=0.0, + strong_up_sideways=0.0, + up_sideways=0.0, + sideways_sideways=0.0, + down_sideways=0.0, + ), + ) + result = Strategy32Backtester( + config, + bundle, + execution_prices=make_execution_prices(bundle, blocked_symbols={"ETH"}), + ).run(close_final_positions=False) + summary = result.metadata.get("rejection_summary", {}) + self.assertGreater(summary.get("execution_refinement_blocked", 0), 0) + final_positions = result.metadata.get("final_positions", []) + self.assertTrue(all(position["engine"] != "momentum" for position in final_positions)) + + def test_backtester_rejection_logging_records_empty_universe(self) -> None: + bundle = make_bundle() + config = Strategy32Config( + symbols=["BTC", "ETH", "SOL"], + universe_min_avg_dollar_volume=1_000_000_000_000.0, + budgets=Strategy32Budgets( + strong_up_carry=0.0, + up_carry=0.0, + sideways_carry=0.0, + down_carry=0.0, + strong_up_sideways=0.0, + up_sideways=0.0, + sideways_sideways=0.0, + down_sideways=0.0, + ), + ) + result = Strategy32Backtester(config, bundle).run() + summary = result.metadata.get("rejection_summary", {}) + self.assertGreater(summary.get("tradeable_universe_empty", 0), 0) + + def test_backtester_liquidity_and_momentum_fallback_can_restore_candidates(self) -> None: + bundle = make_bundle() + config = Strategy32Config( + symbols=["BTC", "ETH", "SOL"], + momentum_min_score=10.0, + carry_min_expected_edge=10.0, + universe_fallback_min_avg_dollar_volume=1_000_000.0, + universe_fallback_top_n=2, + momentum_fallback_min_score=0.0, + momentum_fallback_min_relative_strength=-1.0, + momentum_fallback_min_7d_return=-1.0, + budgets=Strategy32Budgets( + strong_up_carry=0.0, + up_carry=0.0, + sideways_carry=0.0, + down_carry=0.0, + strong_up_sideways=0.0, + up_sideways=0.0, + sideways_sideways=0.0, + down_sideways=0.0, + ), + ) + result = Strategy32Backtester( + config, + bundle, + execution_prices=make_execution_prices(bundle), + ).run(close_final_positions=False) + summary = result.metadata.get("rejection_summary", {}) + self.assertGreater(summary.get("dynamic_universe_fallback_used", 0), 0) + self.assertGreater(summary.get("momentum_filter_fallback_used", 0), 0) + final_positions = result.metadata.get("final_positions", []) + self.assertTrue(any(position["engine"] == "momentum" for position in final_positions)) + + def test_trade_start_blocks_warmup_trades(self) -> None: + bundle = make_bundle() + trade_start = bundle.prices["BTC"]["timestamp"].iloc[-40] + result = Strategy32Backtester( + Strategy32Config(symbols=["BTC", "ETH", "SOL"]), + bundle, + trade_start=trade_start, + ).run() + self.assertTrue(all(trade.entry_time >= trade_start for trade in result.trades)) + + def test_profile_helpers_select_expected_feature_flags(self) -> None: + default_cfg = build_strategy32_config(PROFILE_V7_DEFAULT) + self.assertFalse(default_cfg.enable_sideways_engine) + self.assertTrue(default_cfg.enable_strong_kill_switch) + self.assertTrue(default_cfg.enable_daily_trend_filter) + self.assertFalse(default_cfg.enable_expanded_hedge) + self.assertFalse(default_cfg.enable_max_holding_exit) + + baseline_cfg = build_strategy32_config(PROFILE_V5_BASELINE) + self.assertTrue(baseline_cfg.enable_sideways_engine) + self.assertFalse(baseline_cfg.enable_strong_kill_switch) + self.assertFalse(baseline_cfg.enable_daily_trend_filter) + self.assertFalse(baseline_cfg.enable_expanded_hedge) + self.assertFalse(baseline_cfg.enable_max_holding_exit) + + self.assertFalse(Strategy32Config().enable_sideways_engine) + self.assertTrue(Strategy32Config().enable_strong_kill_switch) + self.assertTrue(Strategy32Config().enable_daily_trend_filter) + + def test_binance_history_fetch_uses_stale_cache_on_http_error(self) -> None: + url = "https://example.com/test.json" + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self) -> bytes: + return json.dumps({"ok": True}).encode("utf-8") + + with tempfile.TemporaryDirectory() as tmpdir: + with ( + patch.object(binance_history, "DEFAULT_CACHE_DIR", Path(tmpdir)), + patch.object(binance_history, "DEFAULT_HTTP_RETRIES", 1), + patch.object(binance_history, "urlopen", side_effect=[FakeResponse(), HTTPError(url, 418, "blocked", hdrs=None, fp=None)]), + ): + first = binance_history._fetch_json(url, ttl_seconds=0) + second = binance_history._fetch_json(url, ttl_seconds=0) + self.assertEqual(first, {"ok": True}) + self.assertEqual(second, {"ok": True}) + + def test_soft_router_weights_remain_bounded(self) -> None: + timestamps = list(pd.date_range("2025-01-01", periods=4, freq="4h", tz="UTC")) + score_frame = pd.DataFrame( + { + "timestamp": timestamps[:-1], + "core_score": [0.8, 0.2, 0.1], + "panic_score": [0.0, 0.6, 0.1], + "choppy_score": [0.1, 0.7, 0.8], + "distribution_score": [0.1, 0.2, 0.9], + } + ) + returns = pd.Series([0.01, -0.02, 0.03], index=pd.DatetimeIndex(timestamps[1:], name="timestamp")) + curve, weights = compose_soft_router_curve( + timestamps=timestamps, + score_frame=score_frame, + core_returns=returns, + cap_returns=returns * 0.5, + chop_returns=returns * 0.25, + dist_returns=returns * -0.10, + candidate=SoftRouterCandidate( + regime_profile="base", + core_filter="overheat_tolerant", + cap_engine="cap_btc_rebound", + chop_engine="chop_inverse_carry", + dist_engine="dist_inverse_carry_strict", + core_floor=0.1, + cap_max_weight=0.4, + chop_max_weight=0.3, + dist_max_weight=0.2, + chop_blend_floor=0.15, + ), + ) + self.assertEqual(len(curve), 4) + total_weights = weights[["core_weight", "cap_weight", "chop_weight", "dist_weight", "cash_weight"]].sum(axis=1) + self.assertTrue(((total_weights - 1.0).abs() < 1e-9).all()) + + def test_cash_overlay_respects_core_cash_budget(self) -> None: + timestamps = list(pd.date_range("2025-01-01", periods=4, freq="4h", tz="UTC")) + score_frame = pd.DataFrame( + { + "timestamp": timestamps[:-1], + "core_score": [0.2, 0.8, 0.1], + "panic_score": [0.8, 0.1, 0.0], + "choppy_score": [0.6, 0.7, 0.2], + "distribution_score": [0.2, 0.9, 0.8], + } + ) + core_returns = pd.Series([0.01, 0.00, 0.02], index=pd.DatetimeIndex(timestamps[1:], name="timestamp")) + overlay_returns = pd.Series([0.02, 0.01, -0.01], index=pd.DatetimeIndex(timestamps[1:], name="timestamp")) + core_exposure_frame = pd.DataFrame( + { + "timestamp": timestamps[:-1], + "cash_pct": [0.50, 0.30, 0.80], + } + ) + curve, weights = compose_cash_overlay_curve( + timestamps=timestamps, + score_frame=score_frame, + core_returns=core_returns, + core_exposure_frame=core_exposure_frame, + cap_returns=overlay_returns, + chop_returns=overlay_returns, + dist_returns=overlay_returns, + candidate=CashOverlayCandidate( + regime_profile="loose_positive", + core_filter="overheat_tolerant", + cap_engine="cap_btc_rebound", + chop_engine="chop_inverse_carry_strict", + dist_engine="dist_inverse_carry_strict", + cap_cash_weight=0.80, + chop_cash_weight=0.80, + dist_cash_weight=0.80, + cap_threshold=0.20, + chop_threshold=0.20, + dist_threshold=0.20, + core_block_threshold=0.50, + ), + ) + self.assertEqual(len(curve), 4) + self.assertTrue((weights["overlay_total"] <= weights["core_cash_pct"] + 1e-9).all()) + + def test_cash_overlay_macro_scale_reduces_core_return_and_frees_cash(self) -> None: + timestamps = list(pd.date_range("2025-01-01", periods=4, freq="4h", tz="UTC")) + score_frame = pd.DataFrame( + { + "timestamp": timestamps[:-1], + "core_score": [0.1, 0.1, 0.1], + "panic_score": [0.0, 0.0, 0.0], + "choppy_score": [0.0, 0.0, 0.0], + "distribution_score": [0.0, 0.0, 0.0], + } + ) + core_returns = pd.Series([0.10, 0.10, 0.10], index=pd.DatetimeIndex(timestamps[1:], name="timestamp")) + core_exposure_frame = pd.DataFrame( + { + "timestamp": timestamps[:-1], + "cash_pct": [0.20, 0.20, 0.20], + } + ) + curve, weights = compose_cash_overlay_curve( + timestamps=timestamps, + score_frame=score_frame, + core_returns=core_returns, + core_exposure_frame=core_exposure_frame, + cap_returns=core_returns * 0.0, + chop_returns=core_returns * 0.0, + dist_returns=core_returns * 0.0, + candidate=CashOverlayCandidate( + regime_profile="loose_positive", + core_filter="overheat_tolerant", + cap_engine="cap_btc_rebound", + chop_engine="chop_inverse_carry_strict", + dist_engine="dist_inverse_carry_strict", + cap_cash_weight=0.0, + chop_cash_weight=0.0, + dist_cash_weight=0.0, + cap_threshold=0.20, + chop_threshold=0.20, + dist_threshold=0.20, + core_block_threshold=0.50, + ), + macro_scale_map=pd.Series( + [0.50, 0.50, 0.50], + index=pd.DatetimeIndex(timestamps[:-1], name="timestamp"), + dtype=float, + ), + ) + self.assertAlmostEqual(float(weights["macro_scale"].iloc[0]), 0.50) + self.assertAlmostEqual(float(weights["core_cash_pct"].iloc[0]), 0.60) + self.assertAlmostEqual(float(curve.iloc[-1]), 1000.0 * (1.05 ** 3), places=6) + + def test_expand_core_targets_adds_btc_hedge(self) -> None: + targets = _expand_core_targets( + [ + { + "engine": "momentum", + "symbol": "ETH", + "value": 250.0, + "meta": {"hedge_ratio": 0.4}, + }, + { + "engine": "carry", + "symbol": "SOL", + "value": 100.0, + "meta": {}, + }, + ], + final_equity=1000.0, + ) + by_instrument = {row["instrument"]: row for row in targets} + self.assertAlmostEqual(float(by_instrument["perp:ETH"]["weight"]), 0.25) + self.assertAlmostEqual(float(by_instrument["perp:BTC"]["weight"]), -0.10) + self.assertFalse(bool(by_instrument["carry:SOL"]["tradeable"])) + + def test_overlay_signal_strengths_block_core_scores(self) -> None: + signals = _overlay_signal_strengths( + BEST_CASH_OVERLAY, + { + "core_score": 0.90, + "panic_score": 0.50, + "choppy_score": 0.80, + "distribution_score": 0.90, + }, + ) + self.assertGreater(signals["cap_signal"], 0.0) + self.assertLess(signals["chop_signal"], 0.25) + self.assertLess(signals["dist_signal"], 0.35) + + def test_combine_targets_aggregates_same_instrument(self) -> None: + combined = _combine_targets( + [{"instrument": "perp:BTC", "weight": -0.10, "tradeable": True, "source": "core", "note": "hedge"}], + [{"instrument": "perp:BTC", "weight": -0.05, "tradeable": True, "source": "overlay", "note": "cap"}], + equity=1000.0, + ) + self.assertEqual(len(combined), 1) + self.assertAlmostEqual(float(combined[0]["weight"]), -0.15) + self.assertAlmostEqual(float(combined[0]["notional_usd"]), -150.0) + + def test_completed_bar_time_aligns_to_4h(self) -> None: + ts = pd.Timestamp("2026-03-16 09:17:00+00:00") + self.assertEqual(_completed_bar_time(ts, "4h"), pd.Timestamp("2026-03-16 08:00:00+00:00")) + + def test_heartbeat_slot_uses_half_hour_boundaries(self) -> None: + self.assertEqual(_heartbeat_slot(pd.Timestamp("2026-03-16 09:17:00+00:00")), (2026, 3, 16, 9, 0)) + self.assertEqual(_heartbeat_slot(pd.Timestamp("2026-03-16 09:31:00+00:00")), (2026, 3, 16, 9, 30)) + + +if __name__ == "__main__": + unittest.main() diff --git a/universe.py b/universe.py new file mode 100644 index 0000000..e313519 --- /dev/null +++ b/universe.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd + + +def select_dynamic_universe( + prices: dict[str, pd.DataFrame], + *, + timestamp: pd.Timestamp, + min_history_bars: int, + lookback_bars: int, + max_symbols: int, + min_avg_dollar_volume: float, + base_symbol: str = "BTC", +) -> list[str]: + ranked: list[tuple[float, str]] = [] + for symbol, df in prices.items(): + if symbol == base_symbol: + continue + hist = df.loc[df["timestamp"] <= timestamp] + if len(hist) < min_history_bars: + continue + recent = hist.tail(lookback_bars) + if recent.empty: + continue + avg_dollar_volume = float((recent["close"] * recent["volume"]).mean()) + if avg_dollar_volume < min_avg_dollar_volume: + continue + ranked.append((avg_dollar_volume, symbol)) + ranked.sort(reverse=True) + if max_symbols > 0: + ranked = ranked[:max_symbols] + return [symbol for _, symbol in ranked] + + +def score_momentum_universe( + prices: dict[str, pd.DataFrame], + funding: dict[str, pd.DataFrame], + *, + btc_symbol: str, + timestamp: pd.Timestamp, + candidate_symbols: list[str], + min_history_bars: int, + liquidity_lookback_bars: int, + short_lookback_bars: int, + long_lookback_bars: int, + overheat_funding_rate: float, +) -> pd.DataFrame: + btc_hist = prices[btc_symbol].loc[prices[btc_symbol]["timestamp"] <= timestamp] + if len(btc_hist) < max(min_history_bars, long_lookback_bars + 5): + return pd.DataFrame( + columns=[ + "symbol", + "avg_dollar_volume", + "volume_stability", + "short_return", + "relative_strength_short", + "relative_strength_long", + "beta", + "positive_bar_ratio", + "short_volatility", + "low_volatility", + "latest_funding_rate", + "funding_penalty", + "score", + ] + ) + btc_hist = btc_hist.tail(long_lookback_bars + 5) + btc_close = btc_hist["close"] + btc_returns = btc_close.pct_change().dropna() + btc_ret_short = float(btc_close.iloc[-1] / btc_close.iloc[-short_lookback_bars] - 1.0) + btc_ret_long = float(btc_close.iloc[-1] / btc_close.iloc[0] - 1.0) + + rows: list[dict[str, float | str]] = [] + for symbol in candidate_symbols: + hist = prices[symbol].loc[prices[symbol]["timestamp"] <= timestamp] + if len(hist) < max(min_history_bars, long_lookback_bars + 5): + continue + hist = hist.tail(long_lookback_bars + 5) + recent = hist.tail(liquidity_lookback_bars) + dollar_volume = recent["close"] * recent["volume"] + returns = hist["close"].pct_change().dropna() + if len(returns) < 12: + continue + overlap = min(len(returns), len(btc_returns), long_lookback_bars) + alt_beta = returns.tail(overlap).to_numpy() + btc_beta = btc_returns.tail(overlap).to_numpy() + btc_var = float(np.var(btc_beta)) + beta = float(np.cov(alt_beta, btc_beta)[0, 1] / btc_var) if overlap >= 10 and btc_var > 1e-12 else 0.0 + short_returns = returns.tail(short_lookback_bars) + short_volatility = float(short_returns.std(ddof=0)) if len(short_returns) >= 6 else float("inf") + positive_bar_ratio = float((short_returns > 0).mean()) if len(short_returns) else 0.0 + short_return = float(hist["close"].iloc[-1] / hist["close"].iloc[-short_lookback_bars] - 1.0) + long_return = float(hist["close"].iloc[-1] / hist["close"].iloc[0] - 1.0) + latest_funding = 0.0 + if symbol in funding: + f_hist = funding[symbol].loc[funding[symbol]["timestamp"] <= timestamp] + if not f_hist.empty: + latest_funding = float(f_hist["funding_rate"].iloc[-1]) + rows.append( + { + "symbol": symbol, + "avg_dollar_volume": float(dollar_volume.mean()), + "volume_stability": float(1.0 / ((dollar_volume.std(ddof=0) / (dollar_volume.mean() + 1e-9)) + 1e-9)), + "short_return": short_return, + "relative_strength_short": short_return - btc_ret_short, + "relative_strength_long": long_return - btc_ret_long, + "beta": beta, + "positive_bar_ratio": positive_bar_ratio, + "short_volatility": short_volatility, + "low_volatility": float(1.0 / (returns.tail(short_lookback_bars).std(ddof=0) + 1e-9)), + "latest_funding_rate": latest_funding, + } + ) + if not rows: + return pd.DataFrame(columns=["symbol", "score"]) + frame = pd.DataFrame(rows) + for column in ( + "avg_dollar_volume", + "volume_stability", + "short_return", + "relative_strength_short", + "relative_strength_long", + "positive_bar_ratio", + "low_volatility", + ): + frame[f"{column}_rank"] = frame[column].rank(pct=True) + frame["funding_penalty"] = ( + (frame["latest_funding_rate"] - overheat_funding_rate).clip(lower=0.0) / max(overheat_funding_rate, 1e-9) + ).clip(upper=1.5) + frame["score"] = ( + 0.15 * frame["avg_dollar_volume_rank"] + + 0.10 * frame["volume_stability_rank"] + + 0.15 * frame["short_return_rank"] + + 0.25 * frame["relative_strength_short_rank"] + + 0.20 * frame["relative_strength_long_rank"] + + 0.10 * frame["low_volatility_rank"] + + 0.05 * frame["positive_bar_ratio_rank"] + - 0.10 * frame["funding_penalty"] + ) + return frame.sort_values("score", ascending=False).reset_index(drop=True) + + +def filter_momentum_frame( + frame: pd.DataFrame, + *, + min_score: float, + min_relative_strength: float, + min_7d_return: float, + max_7d_return: float | None = None, + min_positive_bar_ratio: float | None = None, + max_short_volatility: float | None = None, + max_latest_funding_rate: float | None = None, + max_beta: float | None = None, +) -> pd.DataFrame: + if frame.empty: + return frame + mask = ( + (frame["score"] >= min_score) + & (frame["relative_strength_short"] >= min_relative_strength) + & (frame["relative_strength_long"] >= min_relative_strength) + & (frame["short_return"] >= min_7d_return) + ) + if max_7d_return is not None: + mask &= frame["short_return"] <= max_7d_return + if min_positive_bar_ratio is not None: + mask &= frame["positive_bar_ratio"] >= min_positive_bar_ratio + if max_short_volatility is not None: + mask &= frame["short_volatility"] <= max_short_volatility + if max_latest_funding_rate is not None: + mask &= frame["latest_funding_rate"] <= max_latest_funding_rate + if max_beta is not None: + mask &= frame["beta"] <= max_beta + return frame.loc[mask].sort_values("score", ascending=False).reset_index(drop=True) + + +def rank_momentum_universe( + prices: dict[str, pd.DataFrame], + funding: dict[str, pd.DataFrame], + *, + btc_symbol: str, + timestamp: pd.Timestamp, + candidate_symbols: list[str], + min_history_bars: int, + liquidity_lookback_bars: int, + short_lookback_bars: int, + long_lookback_bars: int, + overheat_funding_rate: float, + max_symbols: int, + max_7d_return: float | None = None, + min_positive_bar_ratio: float | None = None, + max_short_volatility: float | None = None, + max_latest_funding_rate: float | None = None, + max_beta: float | None = None, +) -> list[str]: + frame = score_momentum_universe( + prices, + funding, + btc_symbol=btc_symbol, + timestamp=timestamp, + candidate_symbols=candidate_symbols, + min_history_bars=min_history_bars, + liquidity_lookback_bars=liquidity_lookback_bars, + short_lookback_bars=short_lookback_bars, + long_lookback_bars=long_lookback_bars, + overheat_funding_rate=overheat_funding_rate, + ) + frame = filter_momentum_frame( + frame, + min_score=float("-inf"), + min_relative_strength=float("-inf"), + min_7d_return=float("-inf"), + max_7d_return=max_7d_return, + min_positive_bar_ratio=min_positive_bar_ratio, + max_short_volatility=max_short_volatility, + max_latest_funding_rate=max_latest_funding_rate, + max_beta=max_beta, + ) + if frame.empty: + return candidate_symbols[:max_symbols] if max_symbols > 0 else candidate_symbols + if max_symbols > 0: + frame = frame.head(max_symbols) + return [str(symbol) for symbol in frame["symbol"].tolist()] + + +def score_carry_universe( + prices: dict[str, pd.DataFrame], + funding: dict[str, pd.DataFrame], + *, + timestamp: pd.Timestamp, + candidate_symbols: list[str], + lookback_bars: int, + expected_horizon_bars: int, + roundtrip_cost_pct: float, + basis_risk_multiplier: float, +) -> pd.DataFrame: + rows: list[dict[str, float | str]] = [] + for symbol in candidate_symbols: + if symbol not in funding: + continue + f_hist = funding[symbol].loc[funding[symbol]["timestamp"] <= timestamp].tail(lookback_bars) + p_hist = prices[symbol].loc[prices[symbol]["timestamp"] <= timestamp].tail(30) + if len(f_hist) < lookback_bars or p_hist.empty: + continue + positive_ratio = float((f_hist["funding_rate"] > 0).mean()) + mean_funding = float(f_hist["funding_rate"].mean()) + basis_volatility = float(f_hist["basis"].std(ddof=0)) + latest_basis = float(f_hist["basis"].iloc[-1]) + expected_edge = ( + mean_funding * expected_horizon_bars + + max(latest_basis, 0.0) * 0.35 + - roundtrip_cost_pct + - basis_volatility * basis_risk_multiplier + ) + avg_dollar_volume = float((p_hist["close"] * p_hist["volume"]).mean()) + rows.append( + { + "symbol": symbol, + "expected_edge": expected_edge, + "positive_ratio": positive_ratio, + "mean_funding": mean_funding, + "low_basis_volatility": float(1.0 / (basis_volatility + 1e-9)), + "avg_dollar_volume": avg_dollar_volume, + } + ) + if not rows: + return pd.DataFrame(columns=["symbol", "score", "expected_edge"]) + frame = pd.DataFrame(rows) + for column in ( + "expected_edge", + "positive_ratio", + "mean_funding", + "low_basis_volatility", + "avg_dollar_volume", + ): + frame[f"{column}_rank"] = frame[column].rank(pct=True) + frame["score"] = ( + 0.40 * frame["expected_edge_rank"] + + 0.20 * frame["positive_ratio_rank"] + + 0.20 * frame["mean_funding_rank"] + + 0.10 * frame["low_basis_volatility_rank"] + + 0.10 * frame["avg_dollar_volume_rank"] + ) + return frame.sort_values("score", ascending=False).reset_index(drop=True) + + +def rank_carry_universe( + prices: dict[str, pd.DataFrame], + funding: dict[str, pd.DataFrame], + *, + timestamp: pd.Timestamp, + candidate_symbols: list[str], + lookback_bars: int, + expected_horizon_bars: int, + roundtrip_cost_pct: float, + basis_risk_multiplier: float, + max_symbols: int, +) -> list[str]: + frame = score_carry_universe( + prices, + funding, + timestamp=timestamp, + candidate_symbols=candidate_symbols, + lookback_bars=lookback_bars, + expected_horizon_bars=expected_horizon_bars, + roundtrip_cost_pct=roundtrip_cost_pct, + basis_risk_multiplier=basis_risk_multiplier, + ) + if frame.empty: + return [] + if max_symbols > 0: + frame = frame.head(max_symbols) + return [str(symbol) for symbol in frame["symbol"].tolist()] + + +def limit_correlated_symbols( + prices: dict[str, pd.DataFrame], + *, + timestamp: pd.Timestamp, + candidate_symbols: list[str], + lookback_bars: int, + max_pairwise_correlation: float, + max_symbols: int, +) -> list[str]: + selected: list[str] = [] + returns_cache: dict[str, np.ndarray] = {} + for symbol in candidate_symbols: + hist = prices[symbol].loc[prices[symbol]["timestamp"] <= timestamp].tail(lookback_bars + 1) + rets = hist["close"].pct_change().dropna().to_numpy() + if len(rets) < max(10, lookback_bars // 3): + continue + returns_cache[symbol] = rets + too_correlated = False + for chosen in selected: + chosen_rets = returns_cache[chosen] + overlap = min(len(rets), len(chosen_rets)) + corr = float(np.corrcoef(rets[-overlap:], chosen_rets[-overlap:])[0, 1]) if overlap >= 10 else 0.0 + if np.isfinite(corr) and corr >= max_pairwise_correlation: + too_correlated = True + break + if too_correlated: + continue + selected.append(symbol) + if max_symbols > 0 and len(selected) >= max_symbols: + break + return selected + + +def select_strategic_universe( + prices: dict[str, pd.DataFrame], + funding: dict[str, pd.DataFrame], + *, + btc_symbol: str, + timestamp: pd.Timestamp, + min_history_bars: int, + lookback_bars: int, + min_avg_dollar_volume: float, + short_lookback_bars: int, + long_lookback_bars: int, + overheat_funding_rate: float, + carry_lookback_bars: int, + carry_expected_horizon_bars: int, + carry_roundtrip_cost_pct: float, + carry_basis_risk_multiplier: float, + momentum_min_score: float, + momentum_min_relative_strength: float, + momentum_min_7d_return: float, + momentum_max_7d_return: float | None, + momentum_min_positive_bar_ratio: float | None, + momentum_max_short_volatility: float | None, + momentum_max_latest_funding_rate: float | None, + momentum_max_beta: float | None, + carry_min_expected_edge: float, + max_symbols: int = 0, +) -> list[str]: + liquid_symbols = select_dynamic_universe( + prices, + timestamp=timestamp, + min_history_bars=min_history_bars, + lookback_bars=lookback_bars, + max_symbols=0, + min_avg_dollar_volume=min_avg_dollar_volume, + base_symbol=btc_symbol, + ) + momentum_frame = score_momentum_universe( + prices, + funding, + btc_symbol=btc_symbol, + timestamp=timestamp, + candidate_symbols=liquid_symbols, + min_history_bars=min_history_bars, + liquidity_lookback_bars=lookback_bars, + short_lookback_bars=short_lookback_bars, + long_lookback_bars=long_lookback_bars, + overheat_funding_rate=overheat_funding_rate, + ) + momentum_frame = filter_momentum_frame( + momentum_frame, + min_score=momentum_min_score, + min_relative_strength=momentum_min_relative_strength, + min_7d_return=momentum_min_7d_return, + max_7d_return=momentum_max_7d_return, + min_positive_bar_ratio=momentum_min_positive_bar_ratio, + max_short_volatility=momentum_max_short_volatility, + max_latest_funding_rate=momentum_max_latest_funding_rate, + max_beta=momentum_max_beta, + ) + carry_frame = score_carry_universe( + prices, + funding, + timestamp=timestamp, + candidate_symbols=liquid_symbols, + lookback_bars=carry_lookback_bars, + expected_horizon_bars=carry_expected_horizon_bars, + roundtrip_cost_pct=carry_roundtrip_cost_pct, + basis_risk_multiplier=carry_basis_risk_multiplier, + ) + + ranked_symbols: dict[str, float] = {} + if not momentum_frame.empty: + for row in momentum_frame.itertuples(index=False): + ranked_symbols[str(row.symbol)] = max(ranked_symbols.get(str(row.symbol), float("-inf")), float(row.score)) + + if not carry_frame.empty: + for row in carry_frame.itertuples(index=False): + if float(row.expected_edge) <= carry_min_expected_edge: + continue + ranked_symbols[str(row.symbol)] = max(ranked_symbols.get(str(row.symbol), float("-inf")), float(row.score)) + + if not ranked_symbols: + return [] + + ranked = sorted(ranked_symbols.items(), key=lambda item: item[1], reverse=True) + if max_symbols > 0: + ranked = ranked[:max_symbols] + return [symbol for symbol, _ in ranked]