diff --git a/docs/us_equity_strategy_status.md b/docs/us_equity_strategy_status.md index 01c559d..2e7a66b 100644 --- a/docs/us_equity_strategy_status.md +++ b/docs/us_equity_strategy_status.md @@ -34,6 +34,7 @@ See the Chinese handbook for localized positioning text and default parameter ta | `tech_communication_pullback_enhancement` | research_only | Underperforms `russell_top50_leader_rotation` on return and drawdown. | | `QQQ` / `SPY` LEAPS growth overlay | research_only | Design: [`research/index_leaps_growth_overlay.md`](./research/index_leaps_growth_overlay.md). Proxy backtest module: `UsEquitySnapshotPipelines/docs/index-leaps-growth-overlay-research.md`. | | `us_equity_combo_leveraged_shadow_352045` | shadow_candidate | TQQQ 35% / SOXL 20% / BOXX 45% with `hard_defense_risk_exposure=0.0`. Backtest evidence: 22.21% CAGR, -33.88% max drawdown over 2010-01-05 to 2026-07-02 with 5 bps cost. Runtime config: `src/us_equity_strategies/configs/us_equity_combo_leveraged_shadow_352045.json`. | +| `us_equity_combo_leveraged_shadow_402040` | shadow_candidate | TQQQ 40% / SOXL 20% / BOXX 40% with `hard_defense_risk_exposure=0.0`. Backtest evidence: 23.81% CAGR, -35.53% max drawdown over 2010-01-05 to 2026-07-02 with 5 bps cost. Runtime config: `src/us_equity_strategies/configs/us_equity_combo_leveraged_shadow_402040.json`. | | `crisis_response_shadow` plugin | shadow candidate | Defense-only observation; no allocation impact. | ## Promotion principles diff --git a/docs/us_equity_strategy_status.zh-CN.md b/docs/us_equity_strategy_status.zh-CN.md index 34294fa..7c0d723 100644 --- a/docs/us_equity_strategy_status.zh-CN.md +++ b/docs/us_equity_strategy_status.zh-CN.md @@ -65,6 +65,7 @@ _更新日期:2026-06-28_ | `global_etf_confidence_vol_gate` production-like 研究 | 2015-01-05 至 2026-05-06 | 14.77% | -23.35% | 相比同口径 Top2/SMA250 的 13.60% CAGR、-23.35% 回撤,收益和 Sharpe 改善;仍未跑赢 QQQ 长期 CAGR,因此只作为 Global ETF 自身增强候选。 | [`docs/research/global_etf_confidence_vol_gate.md`](./research/global_etf_confidence_vol_gate.md) | | Crisis unified response historical research,含旧 5% 反弹袖子 | 1999-03-10 至 2026-04-16 | 23.89% | -56.04% | 相比合成 TQQQ 基线显著降低 2000/2008 级别灾难回撤;但该历史版本包含旧反弹袖子,不等于当前 defense-only shadow plugin。 | `UsEquitySnapshotPipelines/data/output/crisis_response_audit_trial/external_fragility10_severe10_fin_credit/summary.csv` | | `us_equity_combo_leveraged_shadow_352045`,5 bps 单边成本 | 2010-01-05 至 2026-07-02 | 22.21% | -33.88% | `TQQQ 35% / SOXL 20% / BOXX 45%`,`hard_defense_risk_exposure=0`;最大回撤进入 35%-40% 可接受区间,但收益/风险仍弱于单腿最强 proxy,因此只进入 shadow 观察,不替换 live。 | `/tmp/us_combo_core_sweep_20260704/ranking.csv`;运行配置见 `src/us_equity_strategies/configs/us_equity_combo_leveraged_shadow_352045.json` | +| `us_equity_combo_leveraged_shadow_402040`,5 bps 单边成本 | 2010-01-05 至 2026-07-02 | 23.81% | -35.53% | `TQQQ 40% / SOXL 20% / BOXX 40%`,`hard_defense_risk_exposure=0`;在 35%-40% 回撤预算下收益、Sharpe、Calmar 均优于 35/20/45,适合作为下一轮 shadow 观察配置。 | `/tmp/us_combo_rule_compare_20260704/ranking_rule_compare.csv`;运行配置见 `src/us_equity_strategies/configs/us_equity_combo_leveraged_shadow_402040.json` | 暂时没有写进正式表的内容: @@ -76,6 +77,7 @@ _更新日期:2026-06-28_ | --- | --- | --- | | `tecl_xlk_trend_income` | `research_enabled` | 重叠窗口未跑赢 live TQQQ / SOXL(2024+ CAGR 24.8%,最大回撤 -46.0%);保留策略实现与回测入口,不进入 runtime。研究文档见 [`UsEquitySnapshotPipelines/docs/tecl-xlk-optimization-research.zh-CN.md`](../../UsEquitySnapshotPipelines/docs/tecl-xlk-optimization-research.zh-CN.md)。 | | `us_equity_combo_leveraged_shadow_352045` | shadow candidate | 组合型杠杆候选:`TQQQ 35% / SOXL 20% / BOXX 45%`,risk-off 归零风险腿转 `BOXX`。当前仅提供受控 runtime_config 与测试保护,不改变默认 live;需要平台 dry-run / shadow 周期证据后才可重新评估 live。 | +| `us_equity_combo_leveraged_shadow_402040` | shadow candidate | 组合型杠杆候选:`TQQQ 40% / SOXL 20% / BOXX 40%`,risk-off 归零风险腿转 `BOXX`。这是 35%-40% 回撤预算下的下一轮 shadow 配置,不直接替换 live。 | | `crisis_response_shadow` 插件 | 可作为 `tqqq_growth_income` 的 `shadow` 插件候选,只写信号、日志和通知上下文。 | 现在是 defense-only 黑天鹅观察流,不下单、不改 allocation;需要稳定 shadow 日志后再做 evidence review。 | | 事件反弹 / MAGS 路线 | 保持 research-only,不作为运行策略 profile。 | 对 MAGS 的正贡献不稳定,且事件反弹预算不应该混进黑天鹅逃命插件。 | | `QQQ` / `SPY` LEAPS 增长增强层 | 已有 option overlay 意图框架;组合型 live profile 默认带 `option_*` 设置,但 `spy_leaps_growth_v1` / `qqq_leaps_growth_v1` 等 recipe 仍是 research candidate,当前会以 `research_only_recipe` 跳过,不产生真实订单意图,研究设计见 [`docs/research/index_leaps_growth_overlay.zh-CN.md`](./research/index_leaps_growth_overlay.zh-CN.md)([English](./research/index_leaps_growth_overlay.md));代理回测见 [`UsEquitySnapshotPipelines/docs/index-leaps-growth-overlay-research.md`](../../UsEquitySnapshotPipelines/docs/index-leaps-growth-overlay-research.md)。 | 属于有限权利金预算的增长增强层,不是当前低回撤收入层的直接替代;需要真实期权链回测后才能把 recipe 晋级为 live。 | diff --git a/src/us_equity_strategies/combo_entrypoints.py b/src/us_equity_strategies/combo_entrypoints.py index b40ce59..297d04c 100644 --- a/src/us_equity_strategies/combo_entrypoints.py +++ b/src/us_equity_strategies/combo_entrypoints.py @@ -184,8 +184,11 @@ def evaluate_us_equity_combo_leveraged(ctx: StrategyContext) -> StrategyDecision risk_flags: tuple[str, ...] = () if has_cash: risk_flags += ("cash_parked",) - if not metadata.get("spy_above_ma200", True): + regime_state = str(metadata.get("regime_state") or "") + if regime_state == "hard_defense": risk_flags += ("ma200_risk_off",) + elif regime_state == "soft_defense": + risk_flags += ("soft_defense",) budgets = _build_budgets(diagnostics) return StrategyDecision( positions=_weights_to_positions(weights), diff --git a/src/us_equity_strategies/configs/us_equity_combo_leveraged_shadow_352045.json b/src/us_equity_strategies/configs/us_equity_combo_leveraged_shadow_352045.json index 5b3e49e..9a9e406 100644 --- a/src/us_equity_strategies/configs/us_equity_combo_leveraged_shadow_352045.json +++ b/src/us_equity_strategies/configs/us_equity_combo_leveraged_shadow_352045.json @@ -13,7 +13,12 @@ }, "required_market_data": { "market_data": { - "spy_above_ma200": "boolean" + "spy_above_ma200": "boolean", + "qqq_above_ma200": "boolean", + "soxx_above_ma200": "boolean", + "spy_ma20_slope_positive": "boolean", + "qqq_ma20_slope_positive": "boolean", + "soxx_ma20_slope_positive": "boolean" } }, "backtest_evidence": { @@ -32,6 +37,7 @@ }, "notes": [ "This config does not change the strategy default or any live deployment.", + "Backtest dynamic mode uses SPY/QQQ/SOXX MA200 hard-defense inputs; runtime market_data must provide the same regime fields.", "Risk-off behavior requires hard_defense_risk_exposure=0.0; otherwise the strategy retains the default 50% risk-leg exposure.", "The backtest artifact is local research output and should be regenerated into a durable repo or CI artifact before live promotion." ] diff --git a/src/us_equity_strategies/configs/us_equity_combo_leveraged_shadow_402040.json b/src/us_equity_strategies/configs/us_equity_combo_leveraged_shadow_402040.json new file mode 100644 index 0000000..3eeadb5 --- /dev/null +++ b/src/us_equity_strategies/configs/us_equity_combo_leveraged_shadow_402040.json @@ -0,0 +1,46 @@ +{ + "role": "us_equity_combo_leveraged_shadow", + "status": "shadow_candidate", + "strategy_profile": "us_equity_combo_leveraged", + "name": "us_equity_combo_leveraged_shadow_402040", + "description": "TQQQ 40% + SOXL 20% + BOXX 40% dynamic shadow candidate with full BOXX hard defense.", + "runtime_config": { + "tqqq_weight": 0.4, + "soxl_weight": 0.2, + "boxx_weight": 0.4, + "dynamic": true, + "hard_defense_risk_exposure": 0.0 + }, + "required_market_data": { + "market_data": { + "spy_above_ma200": "boolean", + "qqq_above_ma200": "boolean", + "soxx_above_ma200": "boolean", + "spy_ma20_slope_positive": "boolean", + "qqq_ma20_slope_positive": "boolean", + "soxx_ma20_slope_positive": "boolean" + } + }, + "backtest_evidence": { + "artifact": "/tmp/us_combo_rule_compare_20260704/ranking_rule_compare.csv", + "window": "2010-01-05 to 2026-07-02", + "turnover_cost_bps": 5, + "full_cagr": 0.238097, + "max_drawdown": -0.35531, + "sharpe": 0.911368, + "calmar": 0.67011, + "rolling_1y_worst_mdd": -0.35531, + "rolling_3y_worst_mdd": -0.35531 + }, + "promotion_state": { + "live_enable_candidate": false, + "shadow_ready": true, + "promotion_gate": "requires platform-level dry-run evidence before live" + }, + "notes": [ + "This config does not change the strategy default or any live deployment.", + "Backtest dynamic mode uses SPY/QQQ/SOXX MA200 hard-defense inputs; runtime market_data must provide the same regime fields.", + "Risk-off behavior requires hard_defense_risk_exposure=0.0; otherwise the strategy retains the default 50% risk-leg exposure.", + "Candidate assumes user accepts an approximate 35-40% historical max drawdown budget." + ] +} diff --git a/src/us_equity_strategies/strategies/us_equity_combo_leveraged.py b/src/us_equity_strategies/strategies/us_equity_combo_leveraged.py index 23beaf0..7b9ee3a 100644 --- a/src/us_equity_strategies/strategies/us_equity_combo_leveraged.py +++ b/src/us_equity_strategies/strategies/us_equity_combo_leveraged.py @@ -9,9 +9,10 @@ Dynamic mode ------------ -SPY 200-day MA regime signal: -- SPY above MA200 (bull): normal weights -- SPY below MA200 (bear): risk legs retain 50% by default, BOXX receives the rest +Multi-asset 200-day MA regime signal: +- SPY/QQQ/SOXX all above MA200: normal weights +- any of SPY/QQQ/SOXX below MA200: risk legs retain 50% by default, BOXX receives the rest +- if only 20-day MA slope weakens, keep current weights and mark soft defense """ from __future__ import annotations @@ -33,6 +34,8 @@ DEFAULT_BOXX_WEIGHT = 0.40 DEFAULT_REBALANCE_THRESHOLD = 0.05 # 5% drift triggers rebalance DEFAULT_HARD_DEFENSE_RISK_EXPOSURE = 0.50 +DEFAULT_SOFT_DEFENSE_RISK_EXPOSURE = 1.00 +REGIME_SYMBOLS = ("SPY", "QQQ", "SOXX") def _noop_logger(_message: str) -> None: @@ -80,7 +83,13 @@ def load_runtime_parameters( raise ValueError("Runtime strategy config runtime_config must be an object") runtime_config = dict(raw_runtime_config) - for key in ("tqqq_weight", "soxl_weight", "boxx_weight", "hard_defense_risk_exposure"): + for key in ( + "tqqq_weight", + "soxl_weight", + "boxx_weight", + "hard_defense_risk_exposure", + "soft_defense_risk_exposure", + ): if key not in runtime_config: continue value = float(runtime_config[key]) @@ -91,6 +100,8 @@ def load_runtime_parameters( runtime_config[key] = value if "hard_defense_risk_exposure" in runtime_config and float(runtime_config["hard_defense_risk_exposure"]) > 1.0: raise ValueError("Runtime strategy config hard_defense_risk_exposure must be in [0, 1]") + if "soft_defense_risk_exposure" in runtime_config and float(runtime_config["soft_defense_risk_exposure"]) > 1.0: + raise ValueError("Runtime strategy config soft_defense_risk_exposure must be in [0, 1]") if all(key in runtime_config for key in ("tqqq_weight", "soxl_weight", "boxx_weight")): total = sum(float(runtime_config[key]) for key in ("tqqq_weight", "soxl_weight", "boxx_weight")) if total <= 0.0: @@ -103,6 +114,37 @@ def load_runtime_parameters( return runtime_config +def _market_bool(market_data: dict[str, Any], key: str, default: bool) -> bool: + value = market_data.get(key, default) + return bool(value) + + +def _resolve_regime(market_data: dict[str, Any] | None) -> dict[str, object]: + raw = market_data if isinstance(market_data, dict) else {} + above_ma200 = { + symbol: _market_bool(raw, f"{symbol.lower()}_above_ma200", True) + for symbol in REGIME_SYMBOLS + } + ma20_slope_positive = { + symbol: _market_bool(raw, f"{symbol.lower()}_ma20_slope_positive", True) + for symbol in REGIME_SYMBOLS + } + + if not all(above_ma200.values()): + regime_state = "hard_defense" + elif not all(ma20_slope_positive.values()): + regime_state = "soft_defense" + else: + regime_state = "risk_on" + + return { + "regime_state": regime_state, + "above_ma200": above_ma200, + "ma20_slope_positive": ma20_slope_positive, + "spy_above_ma200": above_ma200["SPY"], + } + + def build_target_weights( market_data: dict[str, Any] | None = None, config: dict[str, Any] | None = None, @@ -113,8 +155,10 @@ def build_target_weights( Parameters ---------- market_data : dict or None - Contains price/regime info. If it holds a 'spy_above_ma200' key, - dynamic mode uses it. + Contains price/regime info. Dynamic mode uses SPY/QQQ/SOXX + '_above_ma200' and '_ma20_slope_positive' keys when + available. Missing non-SPY keys default to risk-on for backwards + compatibility. config : dict or None Override configuration. Accepted keys: - tqqq_weight, soxl_weight, boxx_weight (overrides defaults) @@ -122,6 +166,8 @@ def build_target_weights( - hard_defense_risk_exposure (float, default 0.50): retained TQQQ/SOXL fraction when SPY is below MA200. Use 0.0 for a full BOXX hard-defense shadow. + - soft_defense_risk_exposure (float, default 1.00): retained + TQQQ/SOXL fraction when MA20 slope weakens while MA200 stays intact. Returns ------- @@ -137,15 +183,23 @@ def build_target_weights( 1.0, max(0.0, float(cfg.get("hard_defense_risk_exposure", DEFAULT_HARD_DEFENSE_RISK_EXPOSURE))), ) + soft_defense_risk_exposure = min( + 1.0, + max(0.0, float(cfg.get("soft_defense_risk_exposure", DEFAULT_SOFT_DEFENSE_RISK_EXPOSURE))), + ) - # Dynamic mode: SPY MA200 risk-off - spy_above_ma200 = True - if isinstance(market_data, dict): - spy_above_ma200 = bool(market_data.get("spy_above_ma200", True)) + regime = _resolve_regime(market_data) + regime_state = str(regime["regime_state"]) - if dynamic and not spy_above_ma200: - tqqq_weight *= hard_defense_risk_exposure - soxl_weight *= hard_defense_risk_exposure + if dynamic and regime_state == "hard_defense": + risk_exposure = hard_defense_risk_exposure + tqqq_weight *= risk_exposure + soxl_weight *= risk_exposure + boxx_weight = 1.0 - tqqq_weight - soxl_weight + elif dynamic and regime_state == "soft_defense": + risk_exposure = soft_defense_risk_exposure + tqqq_weight *= risk_exposure + soxl_weight *= risk_exposure boxx_weight = 1.0 - tqqq_weight - soxl_weight # Normalize @@ -165,7 +219,10 @@ def build_target_weights( "profile_name": PROFILE_NAME, "signal_source": SIGNAL_SOURCE, "status_icon": STATUS_ICON, - "spy_above_ma200": spy_above_ma200, + "regime_state": regime_state if dynamic else "risk_on", + "spy_above_ma200": regime["spy_above_ma200"], + "above_ma200": regime["above_ma200"], + "ma20_slope_positive": regime["ma20_slope_positive"], "effective_weights": { "TQQQ": tqqq_weight, "SOXL": soxl_weight, @@ -173,6 +230,7 @@ def build_target_weights( }, "dynamic": dynamic, "hard_defense_risk_exposure": hard_defense_risk_exposure, + "soft_defense_risk_exposure": soft_defense_risk_exposure, "rebalance": compute_portfolio_drift( weights, holdings=cfg.get("current_holdings_quantities", {}), @@ -202,13 +260,14 @@ def compute_signals( **kwargs, ) ew = metadata.get("effective_weights", {}) + regime_state = str(metadata.get("regime_state") or "risk_on") spy_status = "MA200_up" if metadata.get("spy_above_ma200", True) else "MA200_down" signal_desc = ( - f"leveraged combo {spy_status} " + f"leveraged combo {regime_state} {spy_status} " f"TQQQ={ew.get('TQQQ', 0):.0%} SOXL={ew.get('SOXL', 0):.0%} BOXX={ew.get('BOXX', 0):.0%}" ) status_desc = ( - f"{spy_status} | " + f"{regime_state} {spy_status} | " f"TQQQ={ew.get('TQQQ', 0):.0%} SOXL={ew.get('SOXL', 0):.0%} BOXX={ew.get('BOXX', 0):.0%}" ) has_cash = ew.get("BOXX", 0) > 0.5 diff --git a/tests/test_us_equity_combo.py b/tests/test_us_equity_combo.py index 205ba44..bc7432b 100644 --- a/tests/test_us_equity_combo.py +++ b/tests/test_us_equity_combo.py @@ -60,6 +60,49 @@ def test_us_equity_combo_leveraged_supports_zero_hard_defense_shadow() -> None: assert metadata["hard_defense_risk_exposure"] == 0.0 +def test_us_equity_combo_leveraged_uses_multi_asset_hard_defense() -> None: + weights, metadata = us_equity_combo_leveraged.build_target_weights( + market_data={ + "spy_above_ma200": True, + "qqq_above_ma200": False, + "soxx_above_ma200": True, + }, + config={ + "tqqq_weight": 0.35, + "soxl_weight": 0.20, + "boxx_weight": 0.45, + "hard_defense_risk_exposure": 0.0, + }, + ) + + assert weights == {"TQQQ": 0.0, "SOXL": 0.0, "BOXX": 1.0} + assert metadata["regime_state"] == "hard_defense" + assert metadata["above_ma200"]["QQQ"] is False + + +def test_us_equity_combo_leveraged_marks_soft_defense_without_changing_shadow_weights() -> None: + weights, metadata = us_equity_combo_leveraged.build_target_weights( + market_data={ + "spy_above_ma200": True, + "qqq_above_ma200": True, + "soxx_above_ma200": True, + "spy_ma20_slope_positive": True, + "qqq_ma20_slope_positive": False, + "soxx_ma20_slope_positive": True, + }, + config={ + "tqqq_weight": 0.35, + "soxl_weight": 0.20, + "boxx_weight": 0.45, + "hard_defense_risk_exposure": 0.0, + }, + ) + + assert weights == {"TQQQ": 0.35, "SOXL": 0.20, "BOXX": 0.45} + assert metadata["regime_state"] == "soft_defense" + assert metadata["ma20_slope_positive"]["QQQ"] is False + + def test_us_equity_combo_leveraged_shadow_352045_config_matches_strategy_behavior() -> None: config_path = ( Path(__file__).resolve().parents[1] @@ -123,3 +166,34 @@ def test_us_equity_combo_leveraged_loads_package_runtime_config() -> None: assert runtime_config["runtime_config_path"].startswith("package://us_equity_strategies/") assert runtime_config["tqqq_weight"] == 0.35 assert runtime_config["hard_defense_risk_exposure"] == 0.0 + + +def test_us_equity_combo_leveraged_loads_402040_shadow_config() -> None: + runtime_config = us_equity_combo_leveraged.load_runtime_parameters( + config_path=( + "package://us_equity_strategies/" + "configs/us_equity_combo_leveraged_shadow_402040.json" + ), + logger=lambda _message: None, + ) + risk_on_weights, _risk_on_metadata = us_equity_combo_leveraged.build_target_weights( + market_data={ + "spy_above_ma200": True, + "qqq_above_ma200": True, + "soxx_above_ma200": True, + }, + config=runtime_config, + ) + hard_weights, hard_metadata = us_equity_combo_leveraged.build_target_weights( + market_data={ + "spy_above_ma200": True, + "qqq_above_ma200": False, + "soxx_above_ma200": True, + }, + config=runtime_config, + ) + + assert runtime_config["runtime_config_name"] == "us_equity_combo_leveraged_shadow_402040" + assert risk_on_weights == {"TQQQ": 0.4, "SOXL": 0.2, "BOXX": 0.4} + assert hard_weights == {"TQQQ": 0.0, "SOXL": 0.0, "BOXX": 1.0} + assert hard_metadata["regime_state"] == "hard_defense"