Skip to content

Commit d6ebab6

Browse files
Pigbibicodex
andauthored
Add leveraged combo shadow config (#198)
Co-authored-by: Codex <noreply@openai.com>
1 parent ad05e4f commit d6ebab6

7 files changed

Lines changed: 261 additions & 8 deletions

File tree

docs/us_equity_strategy_status.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ See the Chinese handbook for localized positioning text and default parameter ta
3333
| `tecl_xlk_trend_income` | `research_enabled` | Failed promotion versus live TQQQ and SOXL on overlapping windows (2024+ CAGR 24.8%, max drawdown -46.0%). Code and backtest tooling retained. Docs: [`UsEquitySnapshotPipelines/docs/tecl-xlk-optimization-research.md`](../../UsEquitySnapshotPipelines/docs/tecl-xlk-optimization-research.md). |
3434
| `tech_communication_pullback_enhancement` | research_only | Underperforms `russell_top50_leader_rotation` on return and drawdown. |
3535
| `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`. |
36+
| `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`. |
3637
| `crisis_response_shadow` plugin | shadow candidate | Defense-only observation; no allocation impact. |
3738

3839
## Promotion principles

docs/us_equity_strategy_status.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ _更新日期:2026-06-28_
6464
| Top50 `top2_cap50_no_defense`,21 日 universe lag | 2017-10-02 至 2026-04-16 | 39.83% | -38.79% | 收益最高但两只股票 50/50 太集中,只作为 aggressive research 证据,不作为默认。 | `UsEquitySnapshotPipelines/docs/mega-cap-leader-rotation-dynamic-validation.md` |
6565
| `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) |
6666
| 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` |
67+
| `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` |
6768

6869
暂时没有写进正式表的内容:
6970

@@ -74,6 +75,7 @@ _更新日期:2026-06-28_
7475
| 研究方向 | 当前状态 | 不直接部署的原因 |
7576
| --- | --- | --- |
7677
| `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)|
78+
| `us_equity_combo_leveraged_shadow_352045` | shadow candidate | 组合型杠杆候选:`TQQQ 35% / SOXL 20% / BOXX 45%`,risk-off 归零风险腿转 `BOXX`。当前仅提供受控 runtime_config 与测试保护,不改变默认 live;需要平台 dry-run / shadow 周期证据后才可重新评估 live。 |
7779
| `crisis_response_shadow` 插件 | 可作为 `tqqq_growth_income``shadow` 插件候选,只写信号、日志和通知上下文。 | 现在是 defense-only 黑天鹅观察流,不下单、不改 allocation;需要稳定 shadow 日志后再做 evidence review。 |
7880
| 事件反弹 / MAGS 路线 | 保持 research-only,不作为运行策略 profile。 | 对 MAGS 的正贡献不稳定,且事件反弹预算不应该混进黑天鹅逃命插件。 |
7981
| `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。 |
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"role": "us_equity_combo_leveraged_shadow",
3+
"status": "shadow_candidate",
4+
"strategy_profile": "us_equity_combo_leveraged",
5+
"name": "us_equity_combo_leveraged_shadow_352045",
6+
"description": "TQQQ 35% + SOXL 20% + BOXX 45% dynamic shadow candidate with full BOXX hard defense.",
7+
"runtime_config": {
8+
"tqqq_weight": 0.35,
9+
"soxl_weight": 0.2,
10+
"boxx_weight": 0.45,
11+
"dynamic": true,
12+
"hard_defense_risk_exposure": 0.0
13+
},
14+
"required_market_data": {
15+
"market_data": {
16+
"spy_above_ma200": "boolean"
17+
}
18+
},
19+
"backtest_evidence": {
20+
"artifact": "/tmp/us_combo_core_sweep_20260704/ranking.csv",
21+
"window": "2010-01-05 to 2026-07-02",
22+
"turnover_cost_bps": 5,
23+
"full_cagr": 0.222073,
24+
"max_drawdown": -0.338803,
25+
"sharpe": 0.9085,
26+
"calmar": 0.655463
27+
},
28+
"promotion_state": {
29+
"live_enable_candidate": false,
30+
"shadow_ready": true,
31+
"promotion_gate": "requires shadow observation and platform-level dry-run evidence before live"
32+
},
33+
"notes": [
34+
"This config does not change the strategy default or any live deployment.",
35+
"Risk-off behavior requires hard_defense_risk_exposure=0.0; otherwise the strategy retains the default 50% risk-leg exposure.",
36+
"The backtest artifact is local research output and should be regenerated into a durable repo or CI artifact before live promotion."
37+
]
38+
}

src/us_equity_strategies/runtime_adapters.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
ibit_smart_dca as ibit_smart_dca_strategy,
2626
mega_cap_leader_rotation as mega_cap_leader_rotation_strategy,
2727
nasdaq_sp500_smart_dca as nasdaq_sp500_smart_dca_strategy,
28+
us_equity_combo_leveraged as us_equity_combo_leveraged_strategy,
2829
)
2930

3031

@@ -115,6 +116,10 @@
115116
US_EQUITY_COMBO_LEVERAGED_PROFILE: StrategyRuntimeAdapter(
116117
status_icon="\U0001f1fa\U0001f1f8",
117118
available_inputs=frozenset({"market_data"}),
119+
runtime_parameter_loader=us_equity_combo_leveraged_strategy.load_runtime_parameters,
120+
artifact_contract=StrategyArtifactContract(
121+
config_source_policy="env_only",
122+
),
118123
runtime_policy=StrategyRuntimePolicy(signal_effective_after_trading_days=0),
119124
),
120125
}

src/us_equity_strategies/strategies/us_equity_combo.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,26 @@
2424

2525
logger = logging.getLogger(__name__)
2626

27-
DEFAULT_STOCK_WEIGHT: float = 0.30
27+
DEFAULT_STOCK_WEIGHT: float = 0.50
2828
DEFAULT_ETF_WEIGHT: float = 0.50
2929
DEFAULT_REBALANCE_THRESHOLD: float = 0.05 # 5% drift triggers rebalance
3030

31+
_COMBO_ONLY_CONFIG_KEYS: frozenset[str] = frozenset(
32+
{
33+
"execution_cash_reserve_ratio",
34+
"rebalance_frequency",
35+
"income_layer_enabled",
36+
"income_layer_start_usd",
37+
"income_layer_max_ratio",
38+
"income_layer_allocations",
39+
"option_overlay_enabled",
40+
"option_growth_overlay_enabled",
41+
"option_growth_overlay_recipe",
42+
"option_growth_overlay_start_usd",
43+
"option_growth_overlay_nav_budget_ratio",
44+
}
45+
)
46+
3147
# ---------------------------------------------------------------------------
3248
# Public API
3349
# ---------------------------------------------------------------------------
@@ -50,7 +66,9 @@ def build_target_weights(
5066
config :
5167
Optional override dictionary. Accepted keys include:
5268
53-
* ``stock_weight`` / ``etf_weight`` — blend ratios
69+
* ``stock_weight`` / ``etf_weight`` — blend ratios. Runtime
70+
manifests may also pass the equivalent
71+
``russell_weight`` / ``dca_weight`` aliases.
5472
(defaults to ``DEFAULT_STOCK_WEIGHT`` /
5573
``DEFAULT_ETF_WEIGHT``).
5674
* ``dynamic`` — if truthy, SPY MA200 regime at runtime
@@ -69,13 +87,15 @@ def build_target_weights(
6987
"""
7088
cfg = {} if config is None else dict(config)
7189

72-
stock_weight = float(cfg.pop("stock_weight", DEFAULT_STOCK_WEIGHT))
73-
etf_weight = float(cfg.pop("etf_weight", DEFAULT_ETF_WEIGHT))
90+
stock_weight = float(cfg.pop("stock_weight", cfg.pop("russell_weight", DEFAULT_STOCK_WEIGHT)))
91+
etf_weight = float(cfg.pop("etf_weight", cfg.pop("dca_weight", DEFAULT_ETF_WEIGHT)))
7492
dynamic_enabled = bool(cfg.pop("dynamic", None))
7593

7694
# Extract IBIT dependencies from config (caller must supply these)
7795
market_history = cfg.pop("market_history", None)
7896
portfolio = cfg.pop("portfolio", None)
97+
for key in _COMBO_ONLY_CONFIG_KEYS:
98+
cfg.pop(key, None)
7999

80100
# --- Russell leg ----------------------------------------------------------
81101
russell_weights, ranked, russell_metadata = mega_cap_leader_rotation.build_target_weights(

src/us_equity_strategies/strategies/us_equity_combo_leveraged.py

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@
1111
------------
1212
SPY 200-day MA regime signal:
1313
- SPY above MA200 (bull): normal weights
14-
- SPY below MA200 (bear): TQQQ cut 50% -> 20%, SOXL cut 50% -> 10%, BOXX -> 70%
14+
- SPY below MA200 (bear): risk legs retain 50% by default, BOXX receives the rest
1515
"""
1616

1717
from __future__ import annotations
1818

19+
import json
20+
import math
21+
from importlib import resources
22+
from pathlib import Path
1923
from typing import Any
2024

2125
from quant_platform_kit.common.strategies import compute_portfolio_drift
@@ -28,6 +32,75 @@
2832
DEFAULT_SOXL_WEIGHT = 0.20
2933
DEFAULT_BOXX_WEIGHT = 0.40
3034
DEFAULT_REBALANCE_THRESHOLD = 0.05 # 5% drift triggers rebalance
35+
DEFAULT_HARD_DEFENSE_RISK_EXPOSURE = 0.50
36+
37+
38+
def _noop_logger(_message: str) -> None:
39+
return None
40+
41+
42+
def _read_runtime_config_text(config_path: str | Path) -> tuple[str, str]:
43+
raw_path = str(config_path).strip()
44+
if raw_path.startswith("package://"):
45+
package_resource = raw_path.removeprefix("package://")
46+
package_name, separator, resource_name = package_resource.partition("/")
47+
if not separator or not package_name or not resource_name:
48+
raise ValueError(f"Invalid package runtime config path: {raw_path!r}")
49+
resource = resources.files(package_name).joinpath(resource_name)
50+
if not resource.is_file():
51+
raise FileNotFoundError(f"Runtime strategy config not found: {raw_path}")
52+
return resource.read_text(encoding="utf-8"), raw_path
53+
54+
config_file = Path(config_path)
55+
if not config_file.exists():
56+
raise FileNotFoundError(f"Runtime strategy config not found: {config_file}")
57+
return config_file.read_text(encoding="utf-8"), str(config_file)
58+
59+
60+
def load_runtime_parameters(
61+
*,
62+
config_path: str | Path | None = None,
63+
logger=None,
64+
) -> dict[str, object]:
65+
if logger is None:
66+
logger = _noop_logger
67+
if config_path is None:
68+
return {}
69+
70+
config_text, resolved_config_path = _read_runtime_config_text(config_path)
71+
payload = json.loads(config_text)
72+
if not isinstance(payload, dict):
73+
raise ValueError("Runtime strategy config JSON root must be an object")
74+
profile = str(payload.get("strategy_profile") or PROFILE_NAME).strip()
75+
if profile != PROFILE_NAME:
76+
raise ValueError(f"Runtime strategy config strategy_profile must be {PROFILE_NAME!r}")
77+
78+
raw_runtime_config = payload.get("runtime_config", payload)
79+
if not isinstance(raw_runtime_config, dict):
80+
raise ValueError("Runtime strategy config runtime_config must be an object")
81+
82+
runtime_config = dict(raw_runtime_config)
83+
for key in ("tqqq_weight", "soxl_weight", "boxx_weight", "hard_defense_risk_exposure"):
84+
if key not in runtime_config:
85+
continue
86+
value = float(runtime_config[key])
87+
if not math.isfinite(value):
88+
raise ValueError(f"Runtime strategy config {key} must be finite")
89+
if value < 0.0:
90+
raise ValueError(f"Runtime strategy config {key} must be non-negative")
91+
runtime_config[key] = value
92+
if "hard_defense_risk_exposure" in runtime_config and float(runtime_config["hard_defense_risk_exposure"]) > 1.0:
93+
raise ValueError("Runtime strategy config hard_defense_risk_exposure must be in [0, 1]")
94+
if all(key in runtime_config for key in ("tqqq_weight", "soxl_weight", "boxx_weight")):
95+
total = sum(float(runtime_config[key]) for key in ("tqqq_weight", "soxl_weight", "boxx_weight"))
96+
if total <= 0.0:
97+
raise ValueError("Runtime strategy config weights must sum to a positive value")
98+
99+
runtime_config["runtime_config_name"] = str(payload.get("name") or Path(resolved_config_path).stem)
100+
runtime_config["runtime_config_path"] = resolved_config_path
101+
runtime_config["runtime_config_source"] = "external_config"
102+
logger(f"[{PROFILE_NAME}] runtime config source=external_config path={resolved_config_path}")
103+
return runtime_config
31104

32105

33106
def build_target_weights(
@@ -46,6 +119,9 @@ def build_target_weights(
46119
Override configuration. Accepted keys:
47120
- tqqq_weight, soxl_weight, boxx_weight (overrides defaults)
48121
- dynamic (bool, default True)
122+
- hard_defense_risk_exposure (float, default 0.50): retained
123+
TQQQ/SOXL fraction when SPY is below MA200. Use 0.0 for a
124+
full BOXX hard-defense shadow.
49125
50126
Returns
51127
-------
@@ -57,15 +133,19 @@ def build_target_weights(
57133
soxl_weight = float(cfg.get("soxl_weight", DEFAULT_SOXL_WEIGHT))
58134
boxx_weight = float(cfg.get("boxx_weight", DEFAULT_BOXX_WEIGHT))
59135
dynamic = bool(cfg.get("dynamic", True))
136+
hard_defense_risk_exposure = min(
137+
1.0,
138+
max(0.0, float(cfg.get("hard_defense_risk_exposure", DEFAULT_HARD_DEFENSE_RISK_EXPOSURE))),
139+
)
60140

61141
# Dynamic mode: SPY MA200 risk-off
62142
spy_above_ma200 = True
63143
if isinstance(market_data, dict):
64144
spy_above_ma200 = bool(market_data.get("spy_above_ma200", True))
65145

66146
if dynamic and not spy_above_ma200:
67-
tqqq_weight = DEFAULT_TQQQ_WEIGHT * 0.50
68-
soxl_weight = DEFAULT_SOXL_WEIGHT * 0.50
147+
tqqq_weight *= hard_defense_risk_exposure
148+
soxl_weight *= hard_defense_risk_exposure
69149
boxx_weight = 1.0 - tqqq_weight - soxl_weight
70150

71151
# Normalize
@@ -92,6 +172,7 @@ def build_target_weights(
92172
"BOXX": boxx_weight,
93173
},
94174
"dynamic": dynamic,
175+
"hard_defense_risk_exposure": hard_defense_risk_exposure,
95176
"rebalance": compute_portfolio_drift(
96177
weights,
97178
holdings=cfg.get("current_holdings_quantities", {}),

tests/test_us_equity_combo.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from __future__ import annotations
22

3-
from us_equity_strategies.strategies import us_equity_combo
3+
import json
4+
from pathlib import Path
5+
6+
from us_equity_strategies.combo_manifests import us_equity_combo_manifest
7+
from us_equity_strategies.strategies import us_equity_combo, us_equity_combo_leveraged
48

59
from tests.test_mega_cap_leader_rotation import _mega_snapshot
610

@@ -17,3 +21,105 @@ def test_us_equity_combo_skips_ibit_leg_without_logger_error() -> None:
1721
assert "etf=" in status_desc
1822
assert is_emergency is False
1923
assert diagnostics["dca_managed_symbols"] == ()
24+
25+
26+
def test_us_equity_combo_accepts_manifest_weight_aliases() -> None:
27+
weights, _signal_desc, _is_emergency, _status_desc, diagnostics = us_equity_combo.compute_signals(
28+
_mega_snapshot(),
29+
current_holdings=set(),
30+
config=dict(us_equity_combo_manifest.default_config),
31+
)
32+
33+
assert weights
34+
assert diagnostics["stock_weight"] == 0.50
35+
assert diagnostics["etf_weight"] == 0.50
36+
37+
38+
def test_us_equity_combo_leveraged_risk_off_uses_configured_weights() -> None:
39+
weights, metadata = us_equity_combo_leveraged.build_target_weights(
40+
market_data={"spy_above_ma200": False},
41+
config={"tqqq_weight": 0.35, "soxl_weight": 0.20, "boxx_weight": 0.45},
42+
)
43+
44+
assert weights == {"TQQQ": 0.175, "SOXL": 0.10, "BOXX": 0.725}
45+
assert metadata["hard_defense_risk_exposure"] == 0.50
46+
47+
48+
def test_us_equity_combo_leveraged_supports_zero_hard_defense_shadow() -> None:
49+
weights, metadata = us_equity_combo_leveraged.build_target_weights(
50+
market_data={"spy_above_ma200": False},
51+
config={
52+
"tqqq_weight": 0.35,
53+
"soxl_weight": 0.20,
54+
"boxx_weight": 0.45,
55+
"hard_defense_risk_exposure": 0.0,
56+
},
57+
)
58+
59+
assert weights == {"TQQQ": 0.0, "SOXL": 0.0, "BOXX": 1.0}
60+
assert metadata["hard_defense_risk_exposure"] == 0.0
61+
62+
63+
def test_us_equity_combo_leveraged_shadow_352045_config_matches_strategy_behavior() -> None:
64+
config_path = (
65+
Path(__file__).resolve().parents[1]
66+
/ "src"
67+
/ "us_equity_strategies"
68+
/ "configs"
69+
/ "us_equity_combo_leveraged_shadow_352045.json"
70+
)
71+
shadow_config = json.loads(config_path.read_text(encoding="utf-8"))
72+
runtime_config = shadow_config["runtime_config"]
73+
74+
risk_on_weights, _risk_on_metadata = us_equity_combo_leveraged.build_target_weights(
75+
market_data={"spy_above_ma200": True},
76+
config=runtime_config,
77+
)
78+
risk_off_weights, risk_off_metadata = us_equity_combo_leveraged.build_target_weights(
79+
market_data={"spy_above_ma200": False},
80+
config=runtime_config,
81+
)
82+
83+
assert shadow_config["status"] == "shadow_candidate"
84+
assert shadow_config["promotion_state"]["live_enable_candidate"] is False
85+
assert risk_on_weights == {"TQQQ": 0.35, "SOXL": 0.20, "BOXX": 0.45}
86+
assert risk_off_weights == {"TQQQ": 0.0, "SOXL": 0.0, "BOXX": 1.0}
87+
assert risk_off_metadata["hard_defense_risk_exposure"] == 0.0
88+
89+
90+
def test_us_equity_combo_leveraged_loads_shadow_runtime_config() -> None:
91+
config_path = (
92+
Path(__file__).resolve().parents[1]
93+
/ "src"
94+
/ "us_equity_strategies"
95+
/ "configs"
96+
/ "us_equity_combo_leveraged_shadow_352045.json"
97+
)
98+
99+
runtime_config = us_equity_combo_leveraged.load_runtime_parameters(
100+
config_path=config_path,
101+
logger=lambda _message: None,
102+
)
103+
104+
assert runtime_config["runtime_config_name"] == "us_equity_combo_leveraged_shadow_352045"
105+
assert runtime_config["runtime_config_path"] == str(config_path)
106+
assert runtime_config["runtime_config_source"] == "external_config"
107+
assert runtime_config["tqqq_weight"] == 0.35
108+
assert runtime_config["soxl_weight"] == 0.20
109+
assert runtime_config["boxx_weight"] == 0.45
110+
assert runtime_config["hard_defense_risk_exposure"] == 0.0
111+
112+
113+
def test_us_equity_combo_leveraged_loads_package_runtime_config() -> None:
114+
runtime_config = us_equity_combo_leveraged.load_runtime_parameters(
115+
config_path=(
116+
"package://us_equity_strategies/"
117+
"configs/us_equity_combo_leveraged_shadow_352045.json"
118+
),
119+
logger=lambda _message: None,
120+
)
121+
122+
assert runtime_config["runtime_config_name"] == "us_equity_combo_leveraged_shadow_352045"
123+
assert runtime_config["runtime_config_path"].startswith("package://us_equity_strategies/")
124+
assert runtime_config["tqqq_weight"] == 0.35
125+
assert runtime_config["hard_defense_risk_exposure"] == 0.0

0 commit comments

Comments
 (0)