diff --git a/src/us_equity_strategies/catalog.py b/src/us_equity_strategies/catalog.py index 86fcbc8..5e08c2d 100644 --- a/src/us_equity_strategies/catalog.py +++ b/src/us_equity_strategies/catalog.py @@ -79,7 +79,7 @@ }, TQQQ_GROWTH_INCOME_PROFILE: { "benchmark_symbol": "QQQ", - "managed_symbols": ("TQQQ", "QQQ", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI"), + "managed_symbols": ("TQQQ", "QQQM", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI"), "income_threshold_usd": 250000.0, "qqqi_income_ratio": 0.10, "cash_reserve_ratio": 0.02, @@ -93,7 +93,7 @@ "attack_allocation_mode": "fixed_qqq_tqqq_pullback", "dual_drive_qqq_weight": 0.45, "dual_drive_tqqq_weight": 0.45, - "dual_drive_unlevered_symbol": "QQQ", + "dual_drive_unlevered_symbol": "QQQM", "dual_drive_cash_reserve_ratio": 0.02, "dual_drive_allow_pullback": True, "dual_drive_require_ma20_slope": True, @@ -104,6 +104,13 @@ "dual_drive_volatility_delever_enabled": True, "dual_drive_volatility_delever_window": 5, "dual_drive_volatility_delever_threshold": 0.28, + "dual_drive_volatility_delever_exit_threshold": 0.28, + "dual_drive_volatility_delever_threshold_mode": "rolling_percentile", + "dual_drive_volatility_delever_dynamic_lookback": 252, + "dual_drive_volatility_delever_dynamic_percentile": 0.90, + "dual_drive_volatility_delever_dynamic_min_periods": 126, + "dual_drive_volatility_delever_dynamic_floor": 0.24, + "dual_drive_volatility_delever_dynamic_cap": 0.36, "dual_drive_volatility_delever_taco_veto_enabled": True, "dual_drive_macro_risk_governor_enabled": True, "dual_drive_crisis_defense_enabled": True, @@ -328,7 +335,7 @@ def _build_strategy_definition( TQQQ_GROWTH_INCOME_PROFILE: StrategyMetadata( canonical_profile=TQQQ_GROWTH_INCOME_PROFILE, display_name="TQQQ Growth Income", - description="QQQ/TQQQ dual-drive growth profile with BOXX/cash defense and additive income sleeve.", + description="QQQ-signal TQQQ/QQQM dual-drive growth profile with BOXX/cash defense and additive income sleeve.", aliases=(), cadence="daily", asset_scope="us_equity_qqq_tqqq_dual_drive", diff --git a/src/us_equity_strategies/entrypoints/__init__.py b/src/us_equity_strategies/entrypoints/__init__.py index e32c5b6..efffea3 100644 --- a/src/us_equity_strategies/entrypoints/__init__.py +++ b/src/us_equity_strategies/entrypoints/__init__.py @@ -378,9 +378,35 @@ def evaluate_tqqq_growth_income(ctx: StrategyContext) -> StrategyDecision: "pullback_rebound_volatility_multiplier": plan.get("pullback_rebound_volatility_multiplier"), "dual_drive_volatility_delever_enabled": plan.get("dual_drive_volatility_delever_enabled"), "dual_drive_volatility_delever_window": plan.get("dual_drive_volatility_delever_window"), + "dual_drive_volatility_delever_threshold_mode": plan.get("dual_drive_volatility_delever_threshold_mode"), "dual_drive_volatility_delever_threshold": plan.get("dual_drive_volatility_delever_threshold"), + "dual_drive_volatility_delever_exit_threshold": plan.get("dual_drive_volatility_delever_exit_threshold"), + "dual_drive_volatility_delever_dynamic_threshold": plan.get( + "dual_drive_volatility_delever_dynamic_threshold" + ), + "dual_drive_volatility_delever_dynamic_sample_count": plan.get( + "dual_drive_volatility_delever_dynamic_sample_count" + ), + "dual_drive_volatility_delever_dynamic_lookback": plan.get( + "dual_drive_volatility_delever_dynamic_lookback" + ), + "dual_drive_volatility_delever_dynamic_percentile": plan.get( + "dual_drive_volatility_delever_dynamic_percentile" + ), + "dual_drive_volatility_delever_dynamic_min_periods": plan.get( + "dual_drive_volatility_delever_dynamic_min_periods" + ), + "dual_drive_volatility_delever_dynamic_floor": plan.get("dual_drive_volatility_delever_dynamic_floor"), + "dual_drive_volatility_delever_dynamic_cap": plan.get("dual_drive_volatility_delever_dynamic_cap"), "dual_drive_volatility_delever_metric": plan.get("dual_drive_volatility_delever_metric"), "dual_drive_volatility_delever_triggered": plan.get("dual_drive_volatility_delever_triggered"), + "dual_drive_volatility_delever_entry_triggered": plan.get( + "dual_drive_volatility_delever_entry_triggered" + ), + "dual_drive_volatility_delever_hysteresis_triggered": plan.get( + "dual_drive_volatility_delever_hysteresis_triggered" + ), + "dual_drive_volatility_delever_trigger_reason": plan.get("dual_drive_volatility_delever_trigger_reason"), "dual_drive_volatility_delever_applied": plan.get("dual_drive_volatility_delever_applied"), "dual_drive_volatility_delever_vetoed": plan.get("dual_drive_volatility_delever_vetoed"), "dual_drive_volatility_delever_veto_reason": plan.get("dual_drive_volatility_delever_veto_reason"), @@ -439,7 +465,42 @@ def evaluate_tqqq_growth_income(ctx: StrategyContext) -> StrategyDecision: "long_trend_value": plan["ma200"], "exit_line": plan["exit_line"], "dual_drive_volatility_delever_enabled": plan.get("dual_drive_volatility_delever_enabled"), + "dual_drive_volatility_delever_window": plan.get("dual_drive_volatility_delever_window"), + "dual_drive_volatility_delever_threshold_mode": plan.get( + "dual_drive_volatility_delever_threshold_mode" + ), + "dual_drive_volatility_delever_threshold": plan.get("dual_drive_volatility_delever_threshold"), + "dual_drive_volatility_delever_exit_threshold": plan.get( + "dual_drive_volatility_delever_exit_threshold" + ), + "dual_drive_volatility_delever_dynamic_threshold": plan.get( + "dual_drive_volatility_delever_dynamic_threshold" + ), + "dual_drive_volatility_delever_dynamic_sample_count": plan.get( + "dual_drive_volatility_delever_dynamic_sample_count" + ), + "dual_drive_volatility_delever_dynamic_lookback": plan.get( + "dual_drive_volatility_delever_dynamic_lookback" + ), + "dual_drive_volatility_delever_dynamic_percentile": plan.get( + "dual_drive_volatility_delever_dynamic_percentile" + ), + "dual_drive_volatility_delever_dynamic_min_periods": plan.get( + "dual_drive_volatility_delever_dynamic_min_periods" + ), + "dual_drive_volatility_delever_dynamic_floor": plan.get("dual_drive_volatility_delever_dynamic_floor"), + "dual_drive_volatility_delever_dynamic_cap": plan.get("dual_drive_volatility_delever_dynamic_cap"), + "dual_drive_volatility_delever_metric": plan.get("dual_drive_volatility_delever_metric"), "dual_drive_volatility_delever_triggered": plan.get("dual_drive_volatility_delever_triggered"), + "dual_drive_volatility_delever_entry_triggered": plan.get( + "dual_drive_volatility_delever_entry_triggered" + ), + "dual_drive_volatility_delever_hysteresis_triggered": plan.get( + "dual_drive_volatility_delever_hysteresis_triggered" + ), + "dual_drive_volatility_delever_trigger_reason": plan.get( + "dual_drive_volatility_delever_trigger_reason" + ), "dual_drive_volatility_delever_applied": plan.get("dual_drive_volatility_delever_applied"), "dual_drive_volatility_delever_vetoed": plan.get("dual_drive_volatility_delever_vetoed"), "dual_drive_volatility_delever_veto_reason": plan.get( diff --git a/src/us_equity_strategies/manifests/__init__.py b/src/us_equity_strategies/manifests/__init__.py index 83fe57a..5c0a063 100644 --- a/src/us_equity_strategies/manifests/__init__.py +++ b/src/us_equity_strategies/manifests/__init__.py @@ -89,12 +89,12 @@ def _manifest( tqqq_growth_income_manifest = _manifest( profile="tqqq_growth_income", display_name="TQQQ Growth Income", - description="QQQ/TQQQ dual-drive growth profile with BOXX/cash defense and additive income sleeve.", + description="QQQ-signal TQQQ/QQQM dual-drive growth profile with BOXX/cash defense and additive income sleeve.", aliases=(), required_inputs=frozenset({"benchmark_history", "portfolio_snapshot"}), default_config={ "benchmark_symbol": "QQQ", - "managed_symbols": ("TQQQ", "QQQ", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI"), + "managed_symbols": ("TQQQ", "QQQM", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI"), "income_threshold_usd": 250000.0, "qqqi_income_ratio": 0.10, "cash_reserve_ratio": 0.02, @@ -108,7 +108,7 @@ def _manifest( "attack_allocation_mode": "fixed_qqq_tqqq_pullback", "dual_drive_qqq_weight": 0.45, "dual_drive_tqqq_weight": 0.45, - "dual_drive_unlevered_symbol": "QQQ", + "dual_drive_unlevered_symbol": "QQQM", "dual_drive_cash_reserve_ratio": 0.02, "dual_drive_allow_pullback": True, "dual_drive_require_ma20_slope": True, @@ -119,6 +119,13 @@ def _manifest( "dual_drive_volatility_delever_enabled": True, "dual_drive_volatility_delever_window": 5, "dual_drive_volatility_delever_threshold": 0.28, + "dual_drive_volatility_delever_exit_threshold": 0.28, + "dual_drive_volatility_delever_threshold_mode": "rolling_percentile", + "dual_drive_volatility_delever_dynamic_lookback": 252, + "dual_drive_volatility_delever_dynamic_percentile": 0.90, + "dual_drive_volatility_delever_dynamic_min_periods": 126, + "dual_drive_volatility_delever_dynamic_floor": 0.24, + "dual_drive_volatility_delever_dynamic_cap": 0.36, "dual_drive_volatility_delever_taco_veto_enabled": True, "dual_drive_crisis_defense_enabled": True, "market_regime_control_enabled": True, diff --git a/src/us_equity_strategies/strategies/tqqq_growth_income.py b/src/us_equity_strategies/strategies/tqqq_growth_income.py index 1c2ac76..c62dcfd 100644 --- a/src/us_equity_strategies/strategies/tqqq_growth_income.py +++ b/src/us_equity_strategies/strategies/tqqq_growth_income.py @@ -23,6 +23,12 @@ PULLBACK_REBOUND_THRESHOLD_MODE_FIXED, PULLBACK_REBOUND_THRESHOLD_MODE_VOLATILITY_SCALED, } +VOLATILITY_DELEVER_THRESHOLD_MODE_FIXED = "fixed" +VOLATILITY_DELEVER_THRESHOLD_MODE_ROLLING_PERCENTILE = "rolling_percentile" +VOLATILITY_DELEVER_THRESHOLD_MODES = { + VOLATILITY_DELEVER_THRESHOLD_MODE_FIXED, + VOLATILITY_DELEVER_THRESHOLD_MODE_ROLLING_PERCENTILE, +} CORE_ASSETS = ("TQQQ", "BOXX") TACO_REBOUND_ROUTES = frozenset({"taco_rebound", "taco_fake_crisis"}) TRUE_CRISIS_ROUTE = "true_crisis" @@ -308,6 +314,76 @@ def _resolve_realized_volatility(close: pd.Series, *, window: int) -> float | No return float(volatility * np.sqrt(252)) +def _resolve_volatility_delever_thresholds( + close: pd.Series, + *, + volatility_window: int, + mode: str, + fixed_entry_threshold: float, + fixed_exit_threshold: float | None, + percentile_lookback: int, + percentile: float, + min_periods: int, + floor: float | None, + cap: float | None, +) -> dict[str, object]: + threshold_mode = str(mode or VOLATILITY_DELEVER_THRESHOLD_MODE_FIXED).strip().lower() + if threshold_mode not in VOLATILITY_DELEVER_THRESHOLD_MODES: + modes = ", ".join(sorted(VOLATILITY_DELEVER_THRESHOLD_MODES)) + raise ValueError(f"Unsupported volatility delever threshold mode: {threshold_mode!r}; expected one of {modes}") + + fixed_entry = _as_float_or_none(fixed_entry_threshold) + if fixed_entry is None: + fixed_entry = 0.28 + fixed_entry = max(0.0, float(fixed_entry)) + fixed_exit = _as_float_or_none(fixed_exit_threshold) + if fixed_exit is None: + fixed_exit = fixed_entry + fixed_exit = max(0.0, min(fixed_entry, float(fixed_exit))) + + returns = pd.to_numeric(close, errors="coerce").pct_change(fill_method=None) + realized_volatility = returns.rolling(int(volatility_window), min_periods=int(volatility_window)).std() * np.sqrt(252) + metric = realized_volatility.iloc[-1] + metric_value = None if pd.isna(metric) else float(metric) + + dynamic_threshold = None + dynamic_sample_count = 0 + lookback = _as_positive_int(percentile_lookback, default=252) + min_count = max(1, min(lookback, _as_positive_int(min_periods, default=min(126, lookback)))) + quantile_value = _as_float_or_none(percentile) + if quantile_value is None: + quantile_value = 0.90 + quantile = max(0.0, min(1.0, float(quantile_value))) + floor_value = _as_float_or_none(floor) + cap_value = _as_float_or_none(cap) + if threshold_mode == VOLATILITY_DELEVER_THRESHOLD_MODE_ROLLING_PERCENTILE: + recent = realized_volatility.dropna().tail(lookback) + dynamic_sample_count = int(recent.count()) + if dynamic_sample_count >= min_count: + threshold = float(recent.quantile(quantile)) + if floor_value is not None: + threshold = max(float(floor_value), threshold) + if cap_value is not None: + threshold = min(float(cap_value), threshold) + dynamic_threshold = max(0.0, threshold) + + entry_threshold = dynamic_threshold if dynamic_threshold is not None else fixed_entry + exit_threshold = entry_threshold if dynamic_threshold is not None else fixed_exit + return { + "mode": threshold_mode, + "metric": metric_value, + "entry_threshold": float(entry_threshold), + "exit_threshold": float(max(0.0, min(entry_threshold, exit_threshold))), + "dynamic_threshold": dynamic_threshold, + "dynamic_sample_count": dynamic_sample_count, + "dynamic_lookback": lookback, + "dynamic_percentile": quantile, + "dynamic_min_periods": min_count, + "dynamic_floor": None if floor_value is None else float(floor_value), + "dynamic_cap": None if cap_value is None else float(cap_value), + } + + def _resolve_pullback_rebound_threshold( close: pd.Series, *, @@ -359,7 +435,7 @@ def build_rebalance_plan( attack_allocation_mode="fixed_qqq_tqqq_pullback", dual_drive_qqq_weight=0.45, dual_drive_tqqq_weight=0.45, - dual_drive_unlevered_symbol="QQQ", + dual_drive_unlevered_symbol="QQQM", dual_drive_cash_reserve_ratio=0.02, dual_drive_allow_pullback=True, dual_drive_require_ma20_slope=True, @@ -370,6 +446,13 @@ def build_rebalance_plan( dual_drive_volatility_delever_enabled=True, dual_drive_volatility_delever_window=5, dual_drive_volatility_delever_threshold=0.28, + dual_drive_volatility_delever_exit_threshold=None, + dual_drive_volatility_delever_threshold_mode=VOLATILITY_DELEVER_THRESHOLD_MODE_ROLLING_PERCENTILE, + dual_drive_volatility_delever_dynamic_lookback=252, + dual_drive_volatility_delever_dynamic_percentile=0.90, + dual_drive_volatility_delever_dynamic_min_periods=126, + dual_drive_volatility_delever_dynamic_floor=0.24, + dual_drive_volatility_delever_dynamic_cap=0.36, dual_drive_volatility_delever_taco_veto_enabled=True, dual_drive_macro_risk_governor_enabled=True, dual_drive_crisis_defense_enabled=True, @@ -385,7 +468,7 @@ def build_rebalance_plan( if allocation_mode != "fixed_qqq_tqqq_pullback": raise ValueError("tqqq_growth_income only supports fixed_qqq_tqqq_pullback") - unlevered_symbol = str(dual_drive_unlevered_symbol or "QQQ").strip().upper() + unlevered_symbol = str(dual_drive_unlevered_symbol or "QQQM").strip().upper() if not unlevered_symbol: raise ValueError("dual_drive_unlevered_symbol must be a non-empty ticker") legacy_qqqi_ratio = as_clamped_ratio(qqqi_income_ratio, default=0.5) @@ -460,14 +543,29 @@ def build_rebalance_plan( ) volatility_delever_enabled = _as_bool(dual_drive_volatility_delever_enabled, default=True) volatility_delever_window = _as_positive_int(dual_drive_volatility_delever_window, default=5) - volatility_delever_threshold = _as_float_or_none(dual_drive_volatility_delever_threshold) - if volatility_delever_threshold is None: - volatility_delever_threshold = 0.28 - volatility_delever_metric = ( - _resolve_realized_volatility(df_qqq["close"], window=volatility_delever_window) - if volatility_delever_enabled - else None + volatility_delever_thresholds = _resolve_volatility_delever_thresholds( + df_qqq["close"], + volatility_window=volatility_delever_window, + mode=dual_drive_volatility_delever_threshold_mode, + fixed_entry_threshold=dual_drive_volatility_delever_threshold, + fixed_exit_threshold=dual_drive_volatility_delever_exit_threshold, + percentile_lookback=dual_drive_volatility_delever_dynamic_lookback, + percentile=dual_drive_volatility_delever_dynamic_percentile, + min_periods=dual_drive_volatility_delever_dynamic_min_periods, + floor=dual_drive_volatility_delever_dynamic_floor, + cap=dual_drive_volatility_delever_dynamic_cap, ) + volatility_delever_threshold_mode = str(volatility_delever_thresholds["mode"]) + volatility_delever_threshold = float(volatility_delever_thresholds["entry_threshold"]) + volatility_delever_exit_threshold = float(volatility_delever_thresholds["exit_threshold"]) + volatility_delever_dynamic_threshold = volatility_delever_thresholds["dynamic_threshold"] + volatility_delever_dynamic_sample_count = int(volatility_delever_thresholds["dynamic_sample_count"]) + volatility_delever_dynamic_lookback = int(volatility_delever_thresholds["dynamic_lookback"]) + volatility_delever_dynamic_percentile = float(volatility_delever_thresholds["dynamic_percentile"]) + volatility_delever_dynamic_min_periods = int(volatility_delever_thresholds["dynamic_min_periods"]) + volatility_delever_dynamic_floor = volatility_delever_thresholds["dynamic_floor"] + volatility_delever_dynamic_cap = volatility_delever_thresholds["dynamic_cap"] + volatility_delever_metric = volatility_delever_thresholds["metric"] if volatility_delever_enabled else None taco_veto_enabled = _as_bool(dual_drive_volatility_delever_taco_veto_enabled, default=True) market_regime_control_enabled = _as_bool(market_regime_control_enabled, default=True) market_regime_control_context = ( @@ -561,12 +659,34 @@ def build_rebalance_plan( target_unlevered_val = 0.0 target_boxx_val = max(0.0, strategy_equity - reserved) icon = "crisis_defense" - volatility_delever_triggered = ( + currently_volatility_delevered = ( + current_risk_active + and quantities.get("TQQQ", 0) <= 0 + and quantities.get(unlevered_symbol, 0) > 0 + ) + volatility_delever_entry_triggered = ( volatility_delever_enabled and target_tqqq_val > 0.0 and volatility_delever_metric is not None and volatility_delever_metric >= volatility_delever_threshold ) + volatility_delever_hysteresis_triggered = ( + volatility_delever_enabled + and target_tqqq_val > 0.0 + and currently_volatility_delevered + and volatility_delever_metric is not None + and volatility_delever_metric >= volatility_delever_exit_threshold + ) + volatility_delever_triggered = bool( + volatility_delever_entry_triggered or volatility_delever_hysteresis_triggered + ) + volatility_delever_trigger_reason = ( + "entry_threshold" + if volatility_delever_entry_triggered + else "hysteresis_hold" + if volatility_delever_hysteresis_triggered + else None + ) volatility_delever_vetoed = bool( volatility_delever_triggered and taco_veto_enabled @@ -607,9 +727,21 @@ def build_rebalance_plan( "dual_drive_volatility_delever": { "enabled": volatility_delever_enabled, "window": volatility_delever_window, + "threshold_mode": volatility_delever_threshold_mode, "threshold": float(volatility_delever_threshold), + "exit_threshold": float(volatility_delever_exit_threshold), + "dynamic_threshold": volatility_delever_dynamic_threshold, + "dynamic_sample_count": volatility_delever_dynamic_sample_count, + "dynamic_lookback": volatility_delever_dynamic_lookback, + "dynamic_percentile": volatility_delever_dynamic_percentile, + "dynamic_min_periods": volatility_delever_dynamic_min_periods, + "dynamic_floor": volatility_delever_dynamic_floor, + "dynamic_cap": volatility_delever_dynamic_cap, "metric": volatility_delever_metric, "triggered": volatility_delever_triggered, + "entry_triggered": volatility_delever_entry_triggered, + "hysteresis_triggered": volatility_delever_hysteresis_triggered, + "trigger_reason": volatility_delever_trigger_reason, "applied": volatility_delever_applied, "vetoed": volatility_delever_vetoed, "veto_reason": volatility_delever_veto_reason, @@ -703,8 +835,14 @@ def build_rebalance_plan( status = "applied" if volatility_delever_applied else "vetoed" if volatility_delever_vetoed else "watch" dashboard += ( f"\nVol Delever: {status} | QQQ {volatility_delever_window}d vol " - f"{volatility_delever_metric * 100:.1f}% / {volatility_delever_threshold * 100:.1f}%" + f"{volatility_delever_metric * 100:.1f}% / enter {volatility_delever_threshold * 100:.1f}%" + f" / exit {volatility_delever_exit_threshold * 100:.1f}%" ) + if volatility_delever_threshold_mode == VOLATILITY_DELEVER_THRESHOLD_MODE_ROLLING_PERCENTILE: + dashboard += ( + f" | mode p{volatility_delever_dynamic_percentile * 100:.0f}" + f"/{volatility_delever_dynamic_lookback}d" + ) if macro_risk_governor_enabled and macro_risk_governor_context["found"]: status = "applied" if macro_risk_governor_applied else "watch" score = macro_risk_governor_context["actionable_score"] @@ -762,9 +900,21 @@ def build_rebalance_plan( "pullback_rebound_volatility_multiplier": float(dual_drive_pullback_rebound_volatility_multiplier or 0.0), "dual_drive_volatility_delever_enabled": volatility_delever_enabled, "dual_drive_volatility_delever_window": volatility_delever_window, + "dual_drive_volatility_delever_threshold_mode": volatility_delever_threshold_mode, "dual_drive_volatility_delever_threshold": float(volatility_delever_threshold), + "dual_drive_volatility_delever_exit_threshold": float(volatility_delever_exit_threshold), + "dual_drive_volatility_delever_dynamic_threshold": volatility_delever_dynamic_threshold, + "dual_drive_volatility_delever_dynamic_sample_count": volatility_delever_dynamic_sample_count, + "dual_drive_volatility_delever_dynamic_lookback": volatility_delever_dynamic_lookback, + "dual_drive_volatility_delever_dynamic_percentile": volatility_delever_dynamic_percentile, + "dual_drive_volatility_delever_dynamic_min_periods": volatility_delever_dynamic_min_periods, + "dual_drive_volatility_delever_dynamic_floor": volatility_delever_dynamic_floor, + "dual_drive_volatility_delever_dynamic_cap": volatility_delever_dynamic_cap, "dual_drive_volatility_delever_metric": volatility_delever_metric, "dual_drive_volatility_delever_triggered": volatility_delever_triggered, + "dual_drive_volatility_delever_entry_triggered": volatility_delever_entry_triggered, + "dual_drive_volatility_delever_hysteresis_triggered": volatility_delever_hysteresis_triggered, + "dual_drive_volatility_delever_trigger_reason": volatility_delever_trigger_reason, "dual_drive_volatility_delever_applied": volatility_delever_applied, "dual_drive_volatility_delever_vetoed": volatility_delever_vetoed, "dual_drive_volatility_delever_veto_reason": volatility_delever_veto_reason, diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 8d63813..a1481dd 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -267,7 +267,7 @@ def test_tqqq_growth_income_entrypoint_maps_target_values_without_platform_layou target_values = {position.symbol: position.target_value for position in decision.positions} self.assertEqual(target_values, legacy_plan["target_values"]) strategy_equity = snapshot.total_equity - 3200.0 - self.assertAlmostEqual(target_values["QQQ"], strategy_equity * 0.45) + self.assertAlmostEqual(target_values["QQQM"], strategy_equity * 0.45) self.assertAlmostEqual(target_values["TQQQ"], strategy_equity * 0.45) self.assertAlmostEqual(target_values["BOXX"], strategy_equity * 0.08) self.assertEqual(target_values["SCHD"], 0.0) @@ -322,16 +322,16 @@ def test_tqqq_growth_income_entrypoint_maps_target_values_without_platform_layou ) self.assertEqual( entrypoint.manifest.default_config["managed_symbols"], - ("TQQQ", "QQQ", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI"), + ("TQQQ", "QQQM", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI"), ) - def test_tqqq_growth_income_defaults_to_fixed_dual_drive_live_profile(self) -> None: + def test_tqqq_growth_income_defaults_to_dynamic_dual_drive_live_profile(self) -> None: config = get_strategy_entrypoint("tqqq_growth_income").manifest.default_config self.assertEqual(config["attack_allocation_mode"], "fixed_qqq_tqqq_pullback") self.assertEqual(config["dual_drive_qqq_weight"], 0.45) self.assertEqual(config["dual_drive_tqqq_weight"], 0.45) - self.assertEqual(config["dual_drive_unlevered_symbol"], "QQQ") + self.assertEqual(config["dual_drive_unlevered_symbol"], "QQQM") self.assertEqual(config["dual_drive_cash_reserve_ratio"], 0.02) self.assertEqual(config["dual_drive_pullback_rebound_window"], 20) self.assertEqual(config["dual_drive_pullback_rebound_threshold_mode"], "volatility_scaled") @@ -340,6 +340,13 @@ def test_tqqq_growth_income_defaults_to_fixed_dual_drive_live_profile(self) -> N self.assertIs(config["dual_drive_volatility_delever_enabled"], True) self.assertEqual(config["dual_drive_volatility_delever_window"], 5) self.assertEqual(config["dual_drive_volatility_delever_threshold"], 0.28) + self.assertEqual(config["dual_drive_volatility_delever_exit_threshold"], 0.28) + self.assertEqual(config["dual_drive_volatility_delever_threshold_mode"], "rolling_percentile") + self.assertEqual(config["dual_drive_volatility_delever_dynamic_lookback"], 252) + self.assertEqual(config["dual_drive_volatility_delever_dynamic_percentile"], 0.90) + self.assertEqual(config["dual_drive_volatility_delever_dynamic_min_periods"], 126) + self.assertEqual(config["dual_drive_volatility_delever_dynamic_floor"], 0.24) + self.assertEqual(config["dual_drive_volatility_delever_dynamic_cap"], 0.36) self.assertIs(config["dual_drive_volatility_delever_taco_veto_enabled"], True) self.assertIs(config["market_regime_control_enabled"], True) self.assertEqual(config["cash_reserve_ratio"], 0.02) @@ -366,7 +373,8 @@ def test_tqqq_growth_income_defaults_to_fixed_dual_drive_live_profile(self) -> N self.assertFalse(config["ai_extensions"]["enabled"]) self.assertFalse(config["ai_extensions"]["modules"]["taco_panic_rebound"]["enabled"]) self.assertFalse(config["ai_extensions"]["modules"]["crisis_regime_guard"]["enabled"]) - self.assertIn("QQQ", config["managed_symbols"]) + self.assertIn("QQQM", config["managed_symbols"]) + self.assertNotIn("QQQ", config["managed_symbols"]) def test_weight_mode_profiles_default_to_income_layer_config(self) -> None: expected = { @@ -441,7 +449,7 @@ def test_tqqq_growth_income_entrypoint_uses_live_dual_drive_config(self) -> None positions=( Position(symbol="TQQQ", quantity=10, market_value=8000.0), Position(symbol="BOXX", quantity=20, market_value=4000.0), - Position(symbol="QQQ", quantity=0, market_value=0.0), + Position(symbol="QQQM", quantity=0, market_value=0.0), Position(symbol="SPYI", quantity=30, market_value=1500.0), Position(symbol="QQQI", quantity=30, market_value=1700.0), ), @@ -465,8 +473,8 @@ def test_tqqq_growth_income_entrypoint_uses_live_dual_drive_config(self) -> None ) target_values = {position.symbol: position.target_value for position in decision.positions} - self.assertIn("QQQ", target_values) - self.assertGreater(target_values["QQQ"], 0.0) + self.assertIn("QQQM", target_values) + self.assertGreater(target_values["QQQM"], 0.0) def test_tqqq_growth_income_entrypoint_accepts_qqqm_unlevered_sleeve(self) -> None: entrypoint = get_strategy_entrypoint("tqqq_growth_income") diff --git a/tests/test_strategy_plans.py b/tests/test_strategy_plans.py index a10d049..cb02c82 100644 --- a/tests/test_strategy_plans.py +++ b/tests/test_strategy_plans.py @@ -53,19 +53,19 @@ def test_tqqq_growth_income_exposes_live_dual_drive_metadata(self): dual_drive_cash_reserve_ratio=0.10, ) - self.assertEqual(plan["sell_order_symbols"], ("TQQQ", "QQQ", "SPYI", "QQQI", "BOXX")) - self.assertEqual(plan["buy_order_symbols"], ("SPYI", "QQQI", "TQQQ", "QQQ")) - self.assertEqual(plan["portfolio_rows"], (("TQQQ", "QQQ", "BOXX"), ("SPYI", "QQQI"))) + self.assertEqual(plan["sell_order_symbols"], ("TQQQ", "QQQM", "SPYI", "QQQI", "BOXX")) + self.assertEqual(plan["buy_order_symbols"], ("SPYI", "QQQI", "TQQQ", "QQQM")) + self.assertEqual(plan["portfolio_rows"], (("TQQQ", "QQQM", "BOXX"), ("SPYI", "QQQI"))) self.assertEqual(plan["account_hash"], "acct-1") self.assertEqual(plan["allocation_mode"], "fixed_qqq_tqqq_pullback") self.assertAlmostEqual(plan["target_values"]["TQQQ"], 150000.0 * 0.45) - self.assertAlmostEqual(plan["target_values"]["QQQ"], 150000.0 * 0.45) + self.assertAlmostEqual(plan["target_values"]["QQQM"], 150000.0 * 0.45) self.assertAlmostEqual(plan["reserved"], 150000.0 * 0.10) self.assertEqual(plan["real_buying_power"], 20000.0) self.assertEqual(plan["investable_buying_power"], 5000.0) self.assertEqual(plan["target_values"]["BOXX"], 0.0) self.assertEqual(plan["exit_line"], plan["ma200"]) - self.assertIn("QQQ: $", plan["dashboard"]) + self.assertIn("QQQM: $", plan["dashboard"]) self.assertIn("buying_power: $20,000.00", plan["dashboard"]) self.assertIn("Reserved Cash: $15,000.00", plan["dashboard"]) self.assertIn("Investable Cash: $5,000.00", plan["dashboard"]) @@ -75,7 +75,7 @@ def test_tqqq_growth_income_exposes_live_dual_drive_metadata(self): self.assertEqual(plan["notification_context"]["benchmark"]["symbol"], "QQQ") self.assertEqual( plan["notification_context"]["portfolio"]["holdings_order"], - ("TQQQ", "QQQ", "BOXX", "SPYI", "QQQI"), + ("TQQQ", "QQQM", "BOXX", "SPYI", "QQQI"), ) self.assertEqual(plan["notification_context"]["portfolio"]["raw_buying_power"], 20000.0) self.assertEqual(plan["notification_context"]["portfolio"]["reserved_cash"], 15000.0) @@ -147,7 +147,7 @@ def test_tqqq_growth_income_accepts_portfolio_without_account_hash(self): ) self.assertIsNone(plan["account_hash"]) - self.assertEqual(plan["sell_order_symbols"], ("TQQQ", "QQQ", "SPYI", "QQQI", "BOXX")) + self.assertEqual(plan["sell_order_symbols"], ("TQQQ", "QQQM", "SPYI", "QQQI", "BOXX")) self.assertEqual(plan["notification_context"]["portfolio"]["raw_buying_power"], 20000.0) def test_tqqq_growth_income_can_trade_qqqm_while_using_qqq_signal(self): @@ -240,13 +240,13 @@ def test_tqqq_growth_income_supports_diversified_income_layer(self): ) self.assertEqual(plan["income_layer_symbols"], income_symbols) - self.assertEqual(plan["strategy_symbols"], ["TQQQ", "QQQ", "BOXX", *income_symbols]) - self.assertEqual(plan["buy_order_symbols"], (*income_symbols, "TQQQ", "QQQ")) - self.assertEqual(plan["portfolio_rows"], (("TQQQ", "QQQ", "BOXX"), income_symbols)) + self.assertEqual(plan["strategy_symbols"], ["TQQQ", "QQQM", "BOXX", *income_symbols]) + self.assertEqual(plan["buy_order_symbols"], (*income_symbols, "TQQQ", "QQQM")) + self.assertEqual(plan["portfolio_rows"], (("TQQQ", "QQQM", "BOXX"), income_symbols)) self.assertAlmostEqual(plan["income_layer_ratio"], 0.0790489865839401) self.assertAlmostEqual(plan["income_layer_value"], 17786.021981386522) self.assertAlmostEqual(plan["target_values"]["TQQQ"], 93246.29010837656) - self.assertAlmostEqual(plan["target_values"]["QQQ"], 93246.29010837656) + self.assertAlmostEqual(plan["target_values"]["QQQM"], 93246.29010837656) self.assertAlmostEqual(plan["target_values"]["BOXX"], 16577.118241489167) self.assertAlmostEqual(plan["target_values"]["SCHD"], 7114.408792554609) self.assertAlmostEqual(plan["target_values"]["DGRO"], 3557.2043962773044) @@ -304,13 +304,13 @@ def test_tqqq_growth_income_live_dual_drive_uses_stateful_ma200_exit(self): ) flat_plan = build_tqqq_plan(qqq_history, flat_snapshot, **common_kwargs) self.assertEqual(flat_plan["target_values"]["TQQQ"], 0.0) - self.assertEqual(flat_plan["target_values"]["QQQ"], 0.0) + self.assertEqual(flat_plan["target_values"]["QQQM"], 0.0) self.assertAlmostEqual(flat_plan["target_values"]["BOXX"], 100000.0 * 0.98) active_snapshot = SimpleNamespace( positions=[ SimpleNamespace(symbol="TQQQ", market_value=45000.0, quantity=100), - SimpleNamespace(symbol="QQQ", market_value=45000.0, quantity=100), + SimpleNamespace(symbol="QQQM", market_value=45000.0, quantity=100), SimpleNamespace(symbol="BOXX", market_value=8000.0, quantity=80), ], total_equity=100000.0, @@ -319,7 +319,7 @@ def test_tqqq_growth_income_live_dual_drive_uses_stateful_ma200_exit(self): ) active_plan = build_tqqq_plan(qqq_history, active_snapshot, **common_kwargs) self.assertAlmostEqual(active_plan["target_values"]["TQQQ"], 100000.0 * 0.45) - self.assertAlmostEqual(active_plan["target_values"]["QQQ"], 100000.0 * 0.45) + self.assertAlmostEqual(active_plan["target_values"]["QQQM"], 100000.0 * 0.45) self.assertAlmostEqual(active_plan["target_values"]["BOXX"], 100000.0 * 0.08) self.assertAlmostEqual(active_plan["reserved"], 100000.0 * 0.02) @@ -383,7 +383,7 @@ def build_history(recent): **common_kwargs, ) self.assertEqual(weak_rebound_plan["target_values"]["TQQQ"], 0.0) - self.assertEqual(weak_rebound_plan["target_values"]["QQQ"], 0.0) + self.assertEqual(weak_rebound_plan["target_values"]["QQQM"], 0.0) self.assertEqual(weak_rebound_plan["pullback_rebound_threshold_mode"], "volatility_scaled") self.assertGreater( weak_rebound_plan["pullback_rebound_threshold"], @@ -396,7 +396,7 @@ def build_history(recent): **common_kwargs, ) self.assertAlmostEqual(strong_rebound_plan["target_values"]["TQQQ"], 100000.0 * 0.45) - self.assertAlmostEqual(strong_rebound_plan["target_values"]["QQQ"], 100000.0 * 0.45) + self.assertAlmostEqual(strong_rebound_plan["target_values"]["QQQM"], 100000.0 * 0.45) self.assertLess( strong_rebound_plan["pullback_rebound_threshold"], strong_rebound_plan["pullback_rebound"], @@ -440,12 +440,124 @@ def test_tqqq_growth_income_volatility_delever_redirects_tqqq_to_unlevered_sleev self.assertTrue(plan["dual_drive_volatility_delever_applied"]) self.assertFalse(plan["dual_drive_volatility_delever_vetoed"]) self.assertGreater(plan["dual_drive_volatility_delever_metric"], 0.10) - self.assertEqual(plan["dual_drive_volatility_delever_redirect_symbol"], "QQQ") + self.assertTrue(plan["dual_drive_volatility_delever_entry_triggered"]) + self.assertFalse(plan["dual_drive_volatility_delever_hysteresis_triggered"]) + self.assertEqual(plan["dual_drive_volatility_delever_trigger_reason"], "entry_threshold") + self.assertEqual(plan["dual_drive_volatility_delever_redirect_symbol"], "QQQM") self.assertEqual(plan["target_values"]["TQQQ"], 0.0) - self.assertAlmostEqual(plan["target_values"]["QQQ"], 100000.0 * 0.90) + self.assertAlmostEqual(plan["target_values"]["QQQM"], 100000.0 * 0.90) self.assertAlmostEqual(plan["target_values"]["BOXX"], 100000.0 * 0.08) self.assertIn("Vol Delever: applied", plan["dashboard"]) + def test_tqqq_growth_income_volatility_delever_hysteresis_holds_unlevered_sleeve(self): + _skip_if_missing_numeric_stack() + from us_equity_strategies.strategies.tqqq_growth_income import ( + build_rebalance_plan as build_tqqq_plan, + ) + + qqq_history = [{"close": 100.0, "high": 101.0, "low": 99.0} for _ in range(230)] + [ + {"close": close, "high": close + 1.0, "low": close - 1.0} + for close in (104.0, 99.0, 106.0, 100.0, 108.0, 101.0, 110.0, 103.0, 112.0, 106.0, 115.0) + ] + snapshot = SimpleNamespace( + positions=[SimpleNamespace(symbol="QQQM", market_value=90000.0, quantity=100)], + total_equity=100000.0, + buying_power=2000.0, + metadata={"account_hash": "acct-1"}, + ) + + plan = build_tqqq_plan( + qqq_history, + snapshot, + signal_text_fn=lambda icon: icon, + translator=_translator, + income_threshold_usd=1_000_000_000.0, + qqqi_income_ratio=0.5, + cash_reserve_ratio=0.02, + rebalance_threshold_ratio=0.01, + dual_drive_qqq_weight=0.45, + dual_drive_tqqq_weight=0.45, + dual_drive_cash_reserve_ratio=0.02, + dual_drive_volatility_delever_enabled=True, + dual_drive_volatility_delever_window=5, + dual_drive_volatility_delever_threshold_mode="fixed", + dual_drive_volatility_delever_threshold=10.00, + dual_drive_volatility_delever_exit_threshold=0.10, + ) + + self.assertTrue(plan["dual_drive_volatility_delever_triggered"]) + self.assertTrue(plan["dual_drive_volatility_delever_applied"]) + self.assertFalse(plan["dual_drive_volatility_delever_entry_triggered"]) + self.assertTrue(plan["dual_drive_volatility_delever_hysteresis_triggered"]) + self.assertEqual(plan["dual_drive_volatility_delever_trigger_reason"], "hysteresis_hold") + self.assertGreaterEqual( + plan["dual_drive_volatility_delever_metric"], + plan["dual_drive_volatility_delever_exit_threshold"], + ) + self.assertLess( + plan["dual_drive_volatility_delever_metric"], + plan["dual_drive_volatility_delever_threshold"], + ) + self.assertEqual(plan["target_values"]["TQQQ"], 0.0) + self.assertAlmostEqual(plan["target_values"]["QQQM"], 100000.0 * 0.90) + + def test_tqqq_growth_income_volatility_delever_uses_dynamic_percentile_threshold(self): + _skip_if_missing_numeric_stack() + from us_equity_strategies.strategies.tqqq_growth_income import ( + build_rebalance_plan as build_tqqq_plan, + ) + + calm_history = [ + {"close": 100.0 + index * 0.03, "high": 101.0 + index * 0.03, "low": 99.0 + index * 0.03} + for index in range(245) + ] + volatile_tail = [ + {"close": close, "high": close + 1.0, "low": close - 1.0} + for close in (108.0, 102.0, 111.0, 104.0, 114.0, 106.0, 118.0, 109.0, 121.0, 112.0, 126.0) + ] + snapshot = SimpleNamespace( + positions=[SimpleNamespace(symbol="BOXX", market_value=100000.0, quantity=1000)], + total_equity=100000.0, + buying_power=2000.0, + metadata={"account_hash": "acct-1"}, + ) + + plan = build_tqqq_plan( + [*calm_history, *volatile_tail], + snapshot, + signal_text_fn=lambda icon: icon, + translator=_translator, + income_threshold_usd=1_000_000_000.0, + qqqi_income_ratio=0.5, + cash_reserve_ratio=0.02, + rebalance_threshold_ratio=0.01, + dual_drive_qqq_weight=0.45, + dual_drive_tqqq_weight=0.45, + dual_drive_cash_reserve_ratio=0.02, + dual_drive_volatility_delever_enabled=True, + dual_drive_volatility_delever_window=5, + dual_drive_volatility_delever_threshold=0.99, + dual_drive_volatility_delever_threshold_mode="rolling_percentile", + dual_drive_volatility_delever_dynamic_lookback=60, + dual_drive_volatility_delever_dynamic_percentile=0.80, + dual_drive_volatility_delever_dynamic_min_periods=30, + dual_drive_volatility_delever_dynamic_cap=0.50, + ) + + self.assertEqual(plan["dual_drive_volatility_delever_threshold_mode"], "rolling_percentile") + self.assertIsNotNone(plan["dual_drive_volatility_delever_dynamic_threshold"]) + self.assertEqual(plan["dual_drive_volatility_delever_dynamic_sample_count"], 60) + self.assertLess(plan["dual_drive_volatility_delever_threshold"], 0.99) + self.assertLessEqual(plan["dual_drive_volatility_delever_threshold"], 0.50) + self.assertGreaterEqual( + plan["dual_drive_volatility_delever_metric"], + plan["dual_drive_volatility_delever_threshold"], + ) + self.assertTrue(plan["dual_drive_volatility_delever_applied"]) + self.assertEqual(plan["target_values"]["TQQQ"], 0.0) + self.assertAlmostEqual(plan["target_values"]["QQQM"], 100000.0 * 0.90) + self.assertIn("mode p80/60d", plan["dashboard"]) + def test_tqqq_growth_income_taco_context_vetoes_volatility_delever(self): _skip_if_missing_numeric_stack() from us_equity_strategies.strategies.tqqq_growth_income import ( @@ -493,7 +605,7 @@ def test_tqqq_growth_income_taco_context_vetoes_volatility_delever(self): self.assertTrue(plan["dual_drive_volatility_delever_taco_rebound_context_active"]) self.assertFalse(plan["dual_drive_volatility_delever_true_crisis_active"]) self.assertAlmostEqual(plan["target_values"]["TQQQ"], 100000.0 * 0.45) - self.assertAlmostEqual(plan["target_values"]["QQQ"], 100000.0 * 0.45) + self.assertAlmostEqual(plan["target_values"]["QQQM"], 100000.0 * 0.45) self.assertIn("Vol Delever: vetoed", plan["dashboard"]) def test_tqqq_growth_income_true_crisis_overrides_taco_volatility_delever_veto(self): @@ -548,11 +660,11 @@ def test_tqqq_growth_income_true_crisis_overrides_taco_volatility_delever_veto(s self.assertTrue(plan["dual_drive_crisis_defense_triggered"]) self.assertTrue(plan["dual_drive_crisis_defense_applied"]) self.assertEqual(plan["target_values"]["TQQQ"], 0.0) - self.assertEqual(plan["target_values"]["QQQ"], 0.0) + self.assertEqual(plan["target_values"]["QQQM"], 0.0) self.assertAlmostEqual(plan["target_values"]["BOXX"], 100000.0 * 0.98) self.assertIn("Crisis Defense: applied", plan["dashboard"]) - def test_tqqq_growth_income_macro_risk_governor_delever_redirects_tqqq_to_qqq(self): + def test_tqqq_growth_income_macro_risk_governor_delever_redirects_tqqq_to_qqqm(self): _skip_if_missing_numeric_stack() from us_equity_strategies.strategies.tqqq_growth_income import ( build_rebalance_plan as build_tqqq_plan, @@ -565,7 +677,7 @@ def test_tqqq_growth_income_macro_risk_governor_delever_redirects_tqqq_to_qqq(se snapshot = SimpleNamespace( positions=[ SimpleNamespace(symbol="TQQQ", market_value=45000.0, quantity=1000), - SimpleNamespace(symbol="QQQ", market_value=45000.0, quantity=100), + SimpleNamespace(symbol="QQQM", market_value=45000.0, quantity=100), SimpleNamespace(symbol="BOXX", market_value=8000.0, quantity=80), ], total_equity=100000.0, @@ -602,7 +714,7 @@ def test_tqqq_growth_income_macro_risk_governor_delever_redirects_tqqq_to_qqq(se self.assertTrue(plan["dual_drive_macro_risk_governor_applied"]) self.assertEqual(plan["dual_drive_macro_risk_governor_route"], "delever") self.assertEqual(plan["target_values"]["TQQQ"], 0.0) - self.assertAlmostEqual(plan["target_values"]["QQQ"], 100000.0 * 0.90) + self.assertAlmostEqual(plan["target_values"]["QQQM"], 100000.0 * 0.90) self.assertAlmostEqual(plan["target_values"]["BOXX"], 100000.0 * 0.08) self.assertAlmostEqual(plan["dual_drive_macro_risk_governor_redirected_to_unlevered"], 100000.0 * 0.45) self.assertAlmostEqual(plan["dual_drive_macro_risk_governor_removed_value"], 0.0) @@ -621,7 +733,7 @@ def test_tqqq_growth_income_macro_risk_governor_crisis_moves_risk_to_boxx(self): snapshot = SimpleNamespace( positions=[ SimpleNamespace(symbol="TQQQ", market_value=45000.0, quantity=1000), - SimpleNamespace(symbol="QQQ", market_value=45000.0, quantity=100), + SimpleNamespace(symbol="QQQM", market_value=45000.0, quantity=100), SimpleNamespace(symbol="BOXX", market_value=8000.0, quantity=80), ], total_equity=100000.0, @@ -656,7 +768,7 @@ def test_tqqq_growth_income_macro_risk_governor_crisis_moves_risk_to_boxx(self): self.assertTrue(plan["dual_drive_macro_risk_governor_applied"]) self.assertEqual(plan["dual_drive_macro_risk_governor_route"], "crisis") self.assertEqual(plan["target_values"]["TQQQ"], 0.0) - self.assertEqual(plan["target_values"]["QQQ"], 0.0) + self.assertEqual(plan["target_values"]["QQQM"], 0.0) self.assertAlmostEqual(plan["target_values"]["BOXX"], 100000.0 * 0.98) self.assertAlmostEqual(plan["dual_drive_macro_risk_governor_removed_value"], 100000.0 * 0.90) self.assertIn("Macro Risk Governor: applied", plan["dashboard"]) @@ -674,7 +786,7 @@ def test_tqqq_growth_income_market_regime_control_delever_latest_policy_moves_ri snapshot = SimpleNamespace( positions=[ SimpleNamespace(symbol="TQQQ", market_value=45000.0, quantity=1000), - SimpleNamespace(symbol="QQQ", market_value=45000.0, quantity=100), + SimpleNamespace(symbol="QQQM", market_value=45000.0, quantity=100), SimpleNamespace(symbol="BOXX", market_value=8000.0, quantity=80), ], total_equity=100000.0, @@ -726,7 +838,7 @@ def test_tqqq_growth_income_market_regime_control_delever_latest_policy_moves_ri self.assertEqual(plan["market_regime_control_route"], "risk_reduced") self.assertTrue(plan["dual_drive_macro_risk_governor_applied"]) self.assertEqual(plan["target_values"]["TQQQ"], 0.0) - self.assertAlmostEqual(plan["target_values"]["QQQ"], 0.0) + self.assertAlmostEqual(plan["target_values"]["QQQM"], 0.0) self.assertAlmostEqual(plan["target_values"]["BOXX"], 100000.0 * 0.98) self.assertIn("Market Regime Control: applied", plan["dashboard"]) @@ -743,7 +855,7 @@ def test_tqqq_growth_income_can_disable_market_regime_control_position_effect(se snapshot = SimpleNamespace( positions=[ SimpleNamespace(symbol="TQQQ", market_value=45000.0, quantity=1000), - SimpleNamespace(symbol="QQQ", market_value=45000.0, quantity=100), + SimpleNamespace(symbol="QQQM", market_value=45000.0, quantity=100), SimpleNamespace(symbol="BOXX", market_value=8000.0, quantity=80), ], total_equity=100000.0, @@ -785,7 +897,7 @@ def test_tqqq_growth_income_can_disable_market_regime_control_position_effect(se self.assertFalse(plan["dual_drive_macro_risk_governor_found"]) self.assertFalse(plan["dual_drive_macro_risk_governor_applied"]) self.assertAlmostEqual(plan["target_values"]["TQQQ"], 100000.0 * 0.45) - self.assertAlmostEqual(plan["target_values"]["QQQ"], 100000.0 * 0.45) + self.assertAlmostEqual(plan["target_values"]["QQQM"], 100000.0 * 0.45) self.assertAlmostEqual(plan["target_values"]["BOXX"], 100000.0 * 0.08) self.assertFalse(plan["notification_context"]["risk_controls"]["market_regime_control"]["enabled"])