diff --git a/docs/research/income_layer_design.md b/docs/research/income_layer_design.md index f144d6b..85ed3fe 100644 --- a/docs/research/income_layer_design.md +++ b/docs/research/income_layer_design.md @@ -77,11 +77,11 @@ SOXL core overlay review: | Core version | CAGR | Max drawdown | Note | | --- | ---: | ---: | --- | -| current manifest: `SOXX 10d vol >= 55%, SOXL -> SOXX` | `49.74%` | `-42.31%` | kept; best combined result after income layer | +| May 2026 manifest: `SOXX 10d vol >= 55%, SOXL -> SOXX` | `49.74%` | `-42.31%` | kept in this income-layer study; best combined result after income layer | | `SOXX 10d vol >= 55%, SOXL -> BOXX` | `49.84%` | `-42.31%` | core-only CAGR is slightly higher, but combined income-layer result is worse | | `SOXX 10d vol >= 50%, SOXL -> SOXX` | `48.48%` | `-42.31%` | more frequent de-levering lowers return | -Therefore the SOXL core `blend_gate_volatility_delever_*` defaults stay unchanged; only the income-layer defaults changed. +Therefore this income-layer study left the SOXL core `blend_gate_volatility_delever_*` defaults unchanged; a later June 2026 volatility-threshold recheck promoted a bounded dynamic threshold separately. A lightweight 2026-06-04 refresh using Nasdaq real history and official yield proxies moved the SOXL income layer to the earlier, more SGOV-heavy `start=150000, max=95%, log_factor=0.50` version. In that sample it produced about `38.73%` CAGR and `-9.28%` max drawdown while still passing the SPY drawdown-window constraint. diff --git a/docs/research/income_layer_design.zh-CN.md b/docs/research/income_layer_design.zh-CN.md index 827a9fe..d626aa5 100644 --- a/docs/research/income_layer_design.zh-CN.md +++ b/docs/research/income_layer_design.zh-CN.md @@ -75,11 +75,11 @@ SOXL 核心 overlay 也做了窄候选复核: | Core version | CAGR | Max drawdown | Note | | --- | ---: | ---: | --- | -| current manifest:`SOXX 10d vol >= 55%, SOXL -> SOXX` | `49.74%` | `-42.31%` | 保留;与收入层组合后的 CAGR 最高 | +| 2026-05 manifest:`SOXX 10d vol >= 55%, SOXL -> SOXX` | `49.74%` | `-42.31%` | 本次收入层研究中保留;与收入层组合后的 CAGR 最高 | | `SOXX 10d vol >= 55%, SOXL -> BOXX` | `49.84%` | `-42.31%` | 核心略高,但加入收入层后不如当前 manifest | | `SOXX 10d vol >= 50%, SOXL -> SOXX` | `48.48%` | `-42.31%` | 更频繁降档,收益低于当前 manifest | -因此 SOXL 本次只调整收入层,不改核心 `blend_gate_volatility_delever_*` 默认值。 +因此本次收入层研究没有调整 SOXL 核心 `blend_gate_volatility_delever_*` 默认值;后续 2026-06 波动率阈值复核已单独推广有边界的动态阈值。 2026-06-04 使用 Nasdaq 真实历史和官方收益率代理做轻量复核后,SOXL 默认收入层进一步切到更早启动、更偏 SGOV 的 `start=150000, max=95%, log_factor=0.50` 版本;样本内 CAGR 约 `38.73%`、最大回撤约 `-9.28%`,仍通过 SPY 窗口回撤约束。 diff --git a/docs/us_equity_strategy_status.zh-CN.md b/docs/us_equity_strategy_status.zh-CN.md index 59ae783..9537032 100644 --- a/docs/us_equity_strategy_status.zh-CN.md +++ b/docs/us_equity_strategy_status.zh-CN.md @@ -1,6 +1,6 @@ # 美股策略状态与研究手册 -_更新日期:2026-06-04_ +_更新日期:2026-06-09_ 这份文档只记录当前可配置的美股策略 profile、输入形态和研究状态,不记录任何账户或服务正在运行的 profile。部署单元当前跑什么属于私有运行信息,应留在云端配置或私有运行记录里。 @@ -16,7 +16,7 @@ _更新日期:2026-06-04_ | --- | --- | --- | --- | --- | | `global_etf_rotation` | 全球 ETF 防守轮动 | 直接运行输入 | 季度 Top2 ETF 轮动,默认启用 SMA250 置信度 + 相对波动门控;每日 canary 防守,弱市切 `BIL`。 | 默认保留;当前推荐档。 | | `tqqq_growth_income` | TQQQ 增长收益 | 直接运行输入 | `QQQ` / `TQQQ` 双轮增长,默认 `45% / 45% / 8% BOXX / 2% cash`;`QQQM` 可作为低单价交易代理。 | 小账户最容易落地;不需要 snapshot artifact。 | -| `soxl_soxx_trend_income` | SOXL/SOXX 半导体趋势收益 | 直接运行输入 | 以 `SOXX` 140 日趋势闸门控制 `SOXL` / `SOXX` / `BOXX`;默认 `SOXX` 10 日实际波动率 `>=55%` 时将 `SOXL` 转向 `SOXX`,并叠加收入层。 | 半导体高弹性直接输入策略;波动高于宽基。 | +| `soxl_soxx_trend_income` | SOXL/SOXX 半导体趋势收益 | 直接运行输入 | 以 `SOXX` 140 日趋势闸门控制 `SOXL` / `SOXX` / `BOXX`;默认用 `SOXX` 10 日年化实际波动率的 252 日滚动 95 分位阈值,边界 `50%`-`75%`,样本不足时回退固定 `55%`,触发后将 `SOXL` 转向 `SOXX`;并叠加收入层。 | 半导体高弹性直接输入策略;波动高于宽基。 | | `nasdaq_sp500_smart_dca` | 纳斯达克 / 标普智能定投 | 直接运行输入 | 只买不卖;用 `QQQ/SPY` 的 200 日均线距离、252 日回撤和 RSI 过热状态决定本期定投金额倍数,默认买入 `QQQM/SPLG`。 | 适合现金账户长期积累;建议月度窗口运行。 | | `russell_1000_multi_factor_defensive` | Russell 1000 多因子防守 | feature snapshot | Russell 1000 price-only 多因子,SPY 趋势 + breadth 防守,默认 24 股。 | 可切换但更适合大账户;长周期代理研究仍需补归档。 | | `mega_cap_leader_rotation_top50_balanced` | Top50 平衡龙头轮动 | feature snapshot | 固定 `50% Top2 cap50 + 50% Top4 cap25` 袖子混合,不默认趋势降仓。 | 当前保留的无杠杆龙头轮动路线;建议 paper 观察。 | diff --git a/pyproject.toml b/pyproject.toml index 433a8a7..75045cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "us-equity-strategies" -version = "0.7.52" +version = "0.7.53" description = "Shared US equity strategy catalog and implementations" readme = "README.md" requires-python = ">=3.11" dependencies = [ "pandas>=2.0", "pytz>=2024.1", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@e0f760255232b62481444a8c1d6637546ba2c07e", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@023641c88506c732624a7329e48b51b9dbbe3c2a", ] [tool.setuptools] diff --git a/src/us_equity_strategies/catalog.py b/src/us_equity_strategies/catalog.py index 5e08c2d..708be99 100644 --- a/src/us_equity_strategies/catalog.py +++ b/src/us_equity_strategies/catalog.py @@ -147,6 +147,12 @@ "blend_gate_volatility_delever_symbol": "SOXX", "blend_gate_volatility_delever_window": 10, "blend_gate_volatility_delever_threshold": 0.55, + "blend_gate_volatility_delever_threshold_mode": "rolling_percentile", + "blend_gate_volatility_delever_dynamic_lookback": 252, + "blend_gate_volatility_delever_dynamic_percentile": 0.95, + "blend_gate_volatility_delever_dynamic_min_periods": 126, + "blend_gate_volatility_delever_dynamic_floor": 0.50, + "blend_gate_volatility_delever_dynamic_cap": 0.75, "blend_gate_volatility_delever_retention_ratio": 0.0, "blend_gate_volatility_delever_redirect_symbol": "SOXX", }, diff --git a/src/us_equity_strategies/entrypoints/__init__.py b/src/us_equity_strategies/entrypoints/__init__.py index efffea3..070e5ff 100644 --- a/src/us_equity_strategies/entrypoints/__init__.py +++ b/src/us_equity_strategies/entrypoints/__init__.py @@ -653,6 +653,24 @@ def evaluate_soxl_soxx_trend_income(ctx: StrategyContext) -> StrategyDecision: "blend_gate_volatility_delever_symbol": plan.get("blend_gate_volatility_delever_symbol"), "blend_gate_volatility_delever_window": plan.get("blend_gate_volatility_delever_window"), "blend_gate_volatility_delever_threshold": plan.get("blend_gate_volatility_delever_threshold"), + "blend_gate_volatility_delever_threshold_mode": plan.get("blend_gate_volatility_delever_threshold_mode"), + "blend_gate_volatility_delever_dynamic_threshold": plan.get( + "blend_gate_volatility_delever_dynamic_threshold" + ), + "blend_gate_volatility_delever_dynamic_sample_count": plan.get( + "blend_gate_volatility_delever_dynamic_sample_count" + ), + "blend_gate_volatility_delever_dynamic_lookback": plan.get( + "blend_gate_volatility_delever_dynamic_lookback" + ), + "blend_gate_volatility_delever_dynamic_percentile": plan.get( + "blend_gate_volatility_delever_dynamic_percentile" + ), + "blend_gate_volatility_delever_dynamic_min_periods": plan.get( + "blend_gate_volatility_delever_dynamic_min_periods" + ), + "blend_gate_volatility_delever_dynamic_floor": plan.get("blend_gate_volatility_delever_dynamic_floor"), + "blend_gate_volatility_delever_dynamic_cap": plan.get("blend_gate_volatility_delever_dynamic_cap"), "blend_gate_volatility_delever_metric": plan.get("blend_gate_volatility_delever_metric"), "blend_gate_volatility_delever_triggered": plan.get("blend_gate_volatility_delever_triggered"), "blend_gate_volatility_delever_retention_ratio": plan.get("blend_gate_volatility_delever_retention_ratio"), @@ -716,6 +734,24 @@ def evaluate_soxl_soxx_trend_income(ctx: StrategyContext) -> StrategyDecision: "blend_gate_volatility_delever_symbol": plan.get("blend_gate_volatility_delever_symbol"), "blend_gate_volatility_delever_window": plan.get("blend_gate_volatility_delever_window"), "blend_gate_volatility_delever_threshold": plan.get("blend_gate_volatility_delever_threshold"), + "blend_gate_volatility_delever_threshold_mode": plan.get("blend_gate_volatility_delever_threshold_mode"), + "blend_gate_volatility_delever_dynamic_threshold": plan.get( + "blend_gate_volatility_delever_dynamic_threshold" + ), + "blend_gate_volatility_delever_dynamic_sample_count": plan.get( + "blend_gate_volatility_delever_dynamic_sample_count" + ), + "blend_gate_volatility_delever_dynamic_lookback": plan.get( + "blend_gate_volatility_delever_dynamic_lookback" + ), + "blend_gate_volatility_delever_dynamic_percentile": plan.get( + "blend_gate_volatility_delever_dynamic_percentile" + ), + "blend_gate_volatility_delever_dynamic_min_periods": plan.get( + "blend_gate_volatility_delever_dynamic_min_periods" + ), + "blend_gate_volatility_delever_dynamic_floor": plan.get("blend_gate_volatility_delever_dynamic_floor"), + "blend_gate_volatility_delever_dynamic_cap": plan.get("blend_gate_volatility_delever_dynamic_cap"), "blend_gate_volatility_delever_metric": plan.get("blend_gate_volatility_delever_metric"), "blend_gate_volatility_delever_triggered": plan.get("blend_gate_volatility_delever_triggered"), "blend_gate_volatility_delever_retention_ratio": plan.get("blend_gate_volatility_delever_retention_ratio"), diff --git a/src/us_equity_strategies/manifests/__init__.py b/src/us_equity_strategies/manifests/__init__.py index 5c0a063..e48946a 100644 --- a/src/us_equity_strategies/manifests/__init__.py +++ b/src/us_equity_strategies/manifests/__init__.py @@ -169,6 +169,12 @@ def _manifest( "blend_gate_volatility_delever_symbol": "SOXX", "blend_gate_volatility_delever_window": 10, "blend_gate_volatility_delever_threshold": 0.55, + "blend_gate_volatility_delever_threshold_mode": "rolling_percentile", + "blend_gate_volatility_delever_dynamic_lookback": 252, + "blend_gate_volatility_delever_dynamic_percentile": 0.95, + "blend_gate_volatility_delever_dynamic_min_periods": 126, + "blend_gate_volatility_delever_dynamic_floor": 0.50, + "blend_gate_volatility_delever_dynamic_cap": 0.75, "blend_gate_volatility_delever_retention_ratio": 0.0, "blend_gate_volatility_delever_redirect_symbol": "SOXX", }, diff --git a/src/us_equity_strategies/strategies/soxl_soxx_trend_income.py b/src/us_equity_strategies/strategies/soxl_soxx_trend_income.py index 67b8f02..6bc8174 100644 --- a/src/us_equity_strategies/strategies/soxl_soxx_trend_income.py +++ b/src/us_equity_strategies/strategies/soxl_soxx_trend_income.py @@ -15,6 +15,11 @@ SOXX_GATE_TIERED_BLEND_MODE = "soxx_gate_tiered_blend" CORE_ASSETS = ("SOXL", "SOXX", "BOXX") +VOLATILITY_DELEVER_THRESHOLD_MODE_FIXED = "fixed" +VOLATILITY_DELEVER_THRESHOLD_MODE_ROLLING_PERCENTILE = "rolling_percentile" +VOLATILITY_DELEVER_THRESHOLD_MODES = frozenset( + {VOLATILITY_DELEVER_THRESHOLD_MODE_FIXED, VOLATILITY_DELEVER_THRESHOLD_MODE_ROLLING_PERCENTILE} +) MARKET_REGIME_CONTROL_PROFILE = "market_regime_control" MARKET_REGIME_POSITION_ROUTES = frozenset({"risk_reduced", "risk_off"}) LEGACY_CRISIS_RESPONSE_PROFILE = "crisis_response_shadow" @@ -23,6 +28,8 @@ "INCOME_LAYER_RATIO_MODE_LOG_TOTAL_DRAWDOWN_BUDGET", "INCOME_LAYER_RATIO_MODES", "SOXX_GATE_TIERED_BLEND_MODE", + "VOLATILITY_DELEVER_THRESHOLD_MODE_FIXED", + "VOLATILITY_DELEVER_THRESHOLD_MODE_ROLLING_PERCENTILE", "build_rebalance_plan", "get_income_layer_ratio", ] @@ -80,6 +87,21 @@ def _as_positive_int(value, *, default: int) -> int: return max(1, result) +def _as_unit_interval(value, *, default: float) -> float: + result = _as_float_or_none(value) + if result is None or result <= 0.0 or result >= 1.0: + return float(default) + return float(result) + + +def _indicator_first_float(indicators, symbol: str, keys: Sequence[str]) -> float | None: + for key in keys: + value = _as_float_or_none(_indicator_value(indicators, symbol, key)) + if value is not None: + return value + return None + + def _iter_mapping_payloads(value, *, _depth: int = 0): if _depth > 4: return @@ -298,6 +320,12 @@ def build_rebalance_plan( blend_gate_volatility_delever_symbol="SOXX", blend_gate_volatility_delever_window=10, blend_gate_volatility_delever_threshold=0.55, + blend_gate_volatility_delever_threshold_mode=VOLATILITY_DELEVER_THRESHOLD_MODE_FIXED, + blend_gate_volatility_delever_dynamic_lookback=252, + blend_gate_volatility_delever_dynamic_percentile=0.95, + blend_gate_volatility_delever_dynamic_min_periods=126, + blend_gate_volatility_delever_dynamic_floor=0.50, + blend_gate_volatility_delever_dynamic_cap=0.75, blend_gate_volatility_delever_retention_ratio=0.0, blend_gate_volatility_delever_redirect_symbol="SOXX", market_regime_control_enabled=False, @@ -412,9 +440,36 @@ def build_rebalance_plan( if not volatility_delever_symbol: volatility_delever_symbol = trend_symbol volatility_delever_window = _as_positive_int(blend_gate_volatility_delever_window, default=10) - volatility_delever_threshold = _as_float_or_none(blend_gate_volatility_delever_threshold) - if volatility_delever_threshold is None: - volatility_delever_threshold = 0.55 + volatility_delever_fixed_threshold = _as_float_or_none(blend_gate_volatility_delever_threshold) + if volatility_delever_fixed_threshold is None: + volatility_delever_fixed_threshold = 0.55 + volatility_delever_threshold_mode = str( + blend_gate_volatility_delever_threshold_mode or VOLATILITY_DELEVER_THRESHOLD_MODE_FIXED + ).strip().lower() + if volatility_delever_threshold_mode not in VOLATILITY_DELEVER_THRESHOLD_MODES: + volatility_delever_threshold_mode = VOLATILITY_DELEVER_THRESHOLD_MODE_FIXED + volatility_delever_dynamic_lookback = _as_positive_int( + blend_gate_volatility_delever_dynamic_lookback, + default=252, + ) + volatility_delever_dynamic_percentile = _as_unit_interval( + blend_gate_volatility_delever_dynamic_percentile, + default=0.95, + ) + volatility_delever_dynamic_min_periods = min( + volatility_delever_dynamic_lookback, + _as_positive_int(blend_gate_volatility_delever_dynamic_min_periods, default=126), + ) + volatility_delever_dynamic_floor = _as_clamped_ratio( + blend_gate_volatility_delever_dynamic_floor, + default=0.50, + ) + volatility_delever_dynamic_cap = _as_clamped_ratio( + blend_gate_volatility_delever_dynamic_cap, + default=0.75, + ) + if volatility_delever_dynamic_cap < volatility_delever_dynamic_floor: + volatility_delever_dynamic_cap = volatility_delever_dynamic_floor volatility_delever_retention_ratio = _as_clamped_ratio( blend_gate_volatility_delever_retention_ratio, default=0.0, @@ -435,6 +490,38 @@ def build_rebalance_plan( volatility_delever_metric = _as_float_or_none( _indicator_value(indicators, volatility_delever_symbol, "realized_volatility") ) + volatility_delever_dynamic_threshold = _indicator_first_float( + indicators, + volatility_delever_symbol, + ( + f"realized_volatility_{volatility_delever_window}_dynamic_threshold", + "realized_volatility_dynamic_threshold", + ), + ) + volatility_delever_dynamic_sample_count = _indicator_first_float( + indicators, + volatility_delever_symbol, + ( + f"realized_volatility_{volatility_delever_window}_dynamic_sample_count", + "realized_volatility_dynamic_sample_count", + ), + ) + if volatility_delever_dynamic_threshold is not None: + volatility_delever_dynamic_threshold = max( + volatility_delever_dynamic_floor, + min(volatility_delever_dynamic_cap, volatility_delever_dynamic_threshold), + ) + if ( + volatility_delever_dynamic_sample_count is not None + and volatility_delever_dynamic_sample_count < volatility_delever_dynamic_min_periods + ): + volatility_delever_dynamic_threshold = None + volatility_delever_threshold = volatility_delever_fixed_threshold + if ( + volatility_delever_threshold_mode == VOLATILITY_DELEVER_THRESHOLD_MODE_ROLLING_PERCENTILE + and volatility_delever_dynamic_threshold is not None + ): + volatility_delever_threshold = volatility_delever_dynamic_threshold rsi_threshold = _as_float_or_none(blend_gate_rsi_threshold) if rsi_threshold is None: rsi_threshold = 70.0 @@ -668,6 +755,14 @@ def build_rebalance_plan( "volatility_delever_symbol": volatility_delever_symbol, "volatility_delever_window": volatility_delever_window, "volatility_delever_threshold": volatility_delever_threshold, + "volatility_delever_threshold_mode": volatility_delever_threshold_mode, + "volatility_delever_dynamic_threshold": volatility_delever_dynamic_threshold, + "volatility_delever_dynamic_sample_count": volatility_delever_dynamic_sample_count, + "volatility_delever_dynamic_lookback": volatility_delever_dynamic_lookback, + "volatility_delever_dynamic_percentile": volatility_delever_dynamic_percentile, + "volatility_delever_dynamic_min_periods": volatility_delever_dynamic_min_periods, + "volatility_delever_dynamic_floor": volatility_delever_dynamic_floor, + "volatility_delever_dynamic_cap": volatility_delever_dynamic_cap, "volatility_delever_metric": volatility_delever_metric, "volatility_delever_triggered": volatility_delever_triggered, "volatility_delever_retention_ratio": volatility_delever_retention_ratio, @@ -783,6 +878,14 @@ def build_rebalance_plan( "blend_gate_volatility_delever_symbol": volatility_delever_symbol, "blend_gate_volatility_delever_window": volatility_delever_window, "blend_gate_volatility_delever_threshold": volatility_delever_threshold, + "blend_gate_volatility_delever_threshold_mode": volatility_delever_threshold_mode, + "blend_gate_volatility_delever_dynamic_threshold": volatility_delever_dynamic_threshold, + "blend_gate_volatility_delever_dynamic_sample_count": volatility_delever_dynamic_sample_count, + "blend_gate_volatility_delever_dynamic_lookback": volatility_delever_dynamic_lookback, + "blend_gate_volatility_delever_dynamic_percentile": volatility_delever_dynamic_percentile, + "blend_gate_volatility_delever_dynamic_min_periods": volatility_delever_dynamic_min_periods, + "blend_gate_volatility_delever_dynamic_floor": volatility_delever_dynamic_floor, + "blend_gate_volatility_delever_dynamic_cap": volatility_delever_dynamic_cap, "blend_gate_volatility_delever_metric": volatility_delever_metric, "blend_gate_volatility_delever_triggered": volatility_delever_triggered, "blend_gate_volatility_delever_retention_ratio": volatility_delever_retention_ratio, diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 3f63e5d..cc04a59 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -108,6 +108,14 @@ def test_known_profile_resolves(self): self.assertTrue(longbridge_definition.default_config["blend_gate_volatility_delever_enabled"]) self.assertEqual(longbridge_definition.default_config["blend_gate_volatility_delever_window"], 10) self.assertEqual(longbridge_definition.default_config["blend_gate_volatility_delever_threshold"], 0.55) + self.assertEqual( + longbridge_definition.default_config["blend_gate_volatility_delever_threshold_mode"], + "rolling_percentile", + ) + self.assertEqual(longbridge_definition.default_config["blend_gate_volatility_delever_dynamic_lookback"], 252) + self.assertEqual(longbridge_definition.default_config["blend_gate_volatility_delever_dynamic_percentile"], 0.95) + self.assertEqual(longbridge_definition.default_config["blend_gate_volatility_delever_dynamic_floor"], 0.50) + self.assertEqual(longbridge_definition.default_config["blend_gate_volatility_delever_dynamic_cap"], 0.75) self.assertEqual(longbridge_definition.default_config["blend_gate_volatility_delever_redirect_symbol"], "SOXX") longbridge_module = get_strategy_component_map(longbridge_definition)["allocation"] self.assertEqual( diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index a1481dd..9dff44d 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -556,7 +556,13 @@ def test_soxl_soxx_trend_income_entrypoint_maps_target_values_without_execution_ entrypoint = get_strategy_entrypoint("soxl_soxx_trend_income") indicators = { "soxl": {"price": 80.0, "ma_trend": 75.0}, - "soxx": {"price": 80.0, "ma_trend": 75.0, "realized_volatility_10": 0.20}, + "soxx": { + "price": 80.0, + "ma_trend": 75.0, + "realized_volatility_10": 0.20, + "realized_volatility_10_dynamic_threshold": 0.50, + "realized_volatility_10_dynamic_sample_count": 252.0, + }, } account_state = { "available_cash": 10000.0, @@ -655,6 +661,14 @@ def test_soxl_soxx_trend_income_entrypoint_maps_target_values_without_execution_ decision.diagnostics["execution_annotations"]["investable_cash"], legacy_plan["investable_cash"], ) + self.assertEqual( + decision.diagnostics["execution_annotations"]["blend_gate_volatility_delever_threshold_mode"], + "rolling_percentile", + ) + self.assertEqual( + decision.diagnostics["execution_annotations"]["blend_gate_volatility_delever_dynamic_threshold"], + 0.50, + ) self.assertIn( f"Buying power: ${legacy_plan['available_cash']:,.2f}", decision.diagnostics["dashboard"], diff --git a/tests/test_strategy_plans.py b/tests/test_strategy_plans.py index cb02c82..a771216 100644 --- a/tests/test_strategy_plans.py +++ b/tests/test_strategy_plans.py @@ -1352,6 +1352,73 @@ def test_soxl_soxx_trend_income_volatility_delever_redirects_soxl_to_soxx(self): "signal_blend_gate_overlay_capped", ) + def test_soxl_soxx_trend_income_uses_dynamic_volatility_delever_threshold(self): + _skip_if_missing_numeric_stack() + from us_equity_strategies.strategies.soxl_soxx_trend_income import ( + SOXX_GATE_TIERED_BLEND_MODE, + VOLATILITY_DELEVER_THRESHOLD_MODE_ROLLING_PERCENTILE, + build_rebalance_plan as build_soxl_soxx_plan, + ) + + account_state = { + "available_cash": 5000.0, + "market_values": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 100000.0, "QQQI": 0.0, "SPYI": 0.0}, + "quantities": {"SOXL": 0, "SOXX": 0, "BOXX": 1000, "QQQI": 0, "SPYI": 0}, + "sellable_quantities": {"SOXL": 0, "SOXX": 0, "BOXX": 1000, "QQQI": 0, "SPYI": 0}, + "total_strategy_equity": 100000.0, + } + + plan = build_soxl_soxx_plan( + { + "soxl": {"price": 50.0, "ma_trend": 45.0}, + "soxx": { + "price": 109.0, + "ma_trend": 100.0, + "realized_volatility_10": 0.61, + "realized_volatility_10_dynamic_threshold": 0.60, + "realized_volatility_10_dynamic_sample_count": 252.0, + }, + }, + account_state, + trend_ma_window=140, + translator=_translator, + cash_reserve_ratio=0.03, + min_trade_ratio=0.01, + min_trade_floor=100.0, + rebalance_threshold_ratio=0.01, + income_layer_start_usd=150000.0, + income_layer_max_ratio=0.15, + income_layer_qqqi_weight=0.70, + income_layer_spyi_weight=0.30, + attack_allocation_mode=SOXX_GATE_TIERED_BLEND_MODE, + blend_gate_trend_source="SOXX", + trend_entry_buffer=0.08, + trend_mid_buffer=0.06, + trend_exit_buffer=0.02, + blend_gate_soxl_weight=0.70, + blend_gate_mid_soxl_weight=0.65, + blend_gate_active_soxx_weight=0.20, + blend_gate_defensive_soxx_weight=0.15, + blend_gate_volatility_delever_enabled=True, + blend_gate_volatility_delever_symbol="SOXX", + blend_gate_volatility_delever_window=10, + blend_gate_volatility_delever_threshold=0.55, + blend_gate_volatility_delever_threshold_mode=VOLATILITY_DELEVER_THRESHOLD_MODE_ROLLING_PERCENTILE, + blend_gate_volatility_delever_dynamic_min_periods=126, + blend_gate_volatility_delever_retention_ratio=0.0, + blend_gate_volatility_delever_redirect_symbol="SOXX", + ) + + self.assertTrue(plan["blend_gate_volatility_delever_triggered"]) + self.assertEqual( + plan["blend_gate_volatility_delever_threshold_mode"], + VOLATILITY_DELEVER_THRESHOLD_MODE_ROLLING_PERCENTILE, + ) + self.assertAlmostEqual(plan["blend_gate_volatility_delever_threshold"], 0.60) + self.assertAlmostEqual(plan["blend_gate_volatility_delever_dynamic_threshold"], 0.60) + self.assertAlmostEqual(plan["blend_gate_volatility_delever_dynamic_sample_count"], 252.0) + self.assertEqual(plan["overlay_trigger_codes"], ("blend_gate_reason_volatility_delever",)) + def test_soxl_soxx_trend_income_market_regime_control_delever_moves_risk_to_boxx(self): _skip_if_missing_numeric_stack() from us_equity_strategies.strategies.soxl_soxx_trend_income import (