diff --git a/README.md b/README.md index a1e2111..bfd26e5 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ paper/compatibility later” as the default workflow. - [`docs/us_equity_contract_gap_matrix.md`](./docs/us_equity_contract_gap_matrix.md): runtime-enabled profile contract gaps versus the cross-platform target. - [`docs/us_equity_value_mode_input_contract.md`](./docs/us_equity_value_mode_input_contract.md): fixed canonical input contract for the two current value-mode profiles. - [`docs/us_equity_strategy_status.zh-CN.md`](./docs/us_equity_strategy_status.zh-CN.md): Chinese operator-facing status handbook for switchable profiles, input modes, research candidates, and archived backtest evidence. +- [`docs/research/global_etf_confidence_vol_gate.md`](./docs/research/global_etf_confidence_vol_gate.md): Global ETF confidence plus relative-volatility gate research notes. - [`docs/research/mega_cap_leader_rotation.md`](./docs/research/mega_cap_leader_rotation.md): mega-cap leader rotation research notes and Top50 balanced profile notes. ### Strategy index @@ -59,6 +60,7 @@ paper/compatibility later” as the default workflow. | Canonical profile | Display name | Compatible platforms | Cadence | Benchmark | Role | Status | | --- | --- | --- | --- | --- | --- | --- | | `global_etf_rotation` | Global ETF Rotation | `InteractiveBrokersPlatform`, `CharlesSchwabPlatform`, `LongBridgePlatform`, `PaperSignalPlatform` | `quarterly + daily canary` | `VOO` | `defensive_rotation` | `runtime_enabled` | +| `global_etf_confidence_vol_gate` | Global ETF Confidence Vol Gate | `InteractiveBrokersPlatform`, `CharlesSchwabPlatform`, `LongBridgePlatform`, `PaperSignalPlatform` | `quarterly + daily canary` | `VOO` | `defensive_rotation_research_candidate` | `runtime_enabled` | | `russell_1000_multi_factor_defensive` | Russell 1000 Multi-Factor | `InteractiveBrokersPlatform`, `CharlesSchwabPlatform`, `LongBridgePlatform`, `PaperSignalPlatform` | `monthly` | `SPY` | `defensive_stock_baseline` | `runtime_enabled` | | `tech_communication_pullback_enhancement` | Tech/Communication Pullback Enhancement | `InteractiveBrokersPlatform`, `CharlesSchwabPlatform`, `LongBridgePlatform`, `PaperSignalPlatform` | `monthly` | `QQQ` | `parallel_cash_buffer_branch` | `runtime_enabled` | | `mega_cap_leader_rotation_top50_balanced` | Mega Cap Leader Rotation Top50 Balanced | `InteractiveBrokersPlatform`, `CharlesSchwabPlatform`, `LongBridgePlatform`, `PaperSignalPlatform` | `monthly` | `QQQ` | `balanced_leader_rotation` | `runtime_enabled` | @@ -78,6 +80,7 @@ Cloud Scheduler / GitHub Actions cron settings: - `execution_timing_contract` - `signal_effective_after_trading_days` - the current daily runtime profiles (`global_etf_rotation`, + `global_etf_confidence_vol_gate`, `tqqq_growth_income`, `soxl_soxx_trend_income`) are tagged as `next_trading_day` strategies at the strategy/runtime contract layer, so downstream runtimes and audit reports do not need to infer this from prose. @@ -103,6 +106,7 @@ diagnostics when account equity is below the suggested minimum. | `tqqq_growth_income` | `500 USD` | Most suitable for small accounts; TQQQ can usually trade, but BOXX/cash targets may drift. | | `soxl_soxx_trend_income` | `1000 USD` | Can run with drift on integer-share platforms; fractional-share runtimes can express the small SOXX/BOXX legs more closely. | | `global_etf_rotation` | `3000 USD` | Top-2 ETF rotation can drift when selected ETFs are too expensive for the account. | +| `global_etf_confidence_vol_gate` | `3000 USD` | Same execution caveats as `global_etf_rotation`; the profile can express unequal 75/25 targets only when share sizing supports them closely enough. | | `mega_cap_leader_rotation_top50_balanced` | `10000 USD` | The fixed 50% Top2 / 50% Top4 sleeve blend can drift when integer shares cannot represent the intended unequal weights. | | `tech_communication_pullback_enhancement` (`qqq_tech_enhancement` legacy alias) | `10000 USD` | Small accounts reduce position count and single-name concentration rises. | | `russell_1000_multi_factor_defensive` | `30000 USD` | The default 24-stock basket is not suitable for small accounts. | @@ -113,6 +117,7 @@ reports explicit about the gap between account size and backtest assumptions. ### Research candidates and archive - `mega_cap_leader_rotation_top50_balanced`: runtime-enabled monthly profile for the current Top50 balanced candidate. It consumes a transparent Top50 monthly snapshot and runs a fixed 50% Top2 cap50 sleeve plus 50% Top4 cap25 sleeve, with no broad QQQ trend de-risking by default. +- `global_etf_confidence_vol_gate`: runtime-enabled experimental variant of `global_etf_rotation`. It keeps the same universe and canary defense, uses SMA250, and only shifts from 50/50 Top2 to 75/25 Top1/Top2 when the Top1 momentum z-gap is at least `1.0` and Top1's trailing 126-day volatility is no more than `1.3x` Top2's volatility. - `mega_cap_leader_rotation`: umbrella research/backtest name for the static and dynamic variants; see [`docs/research/mega_cap_leader_rotation.md`](./docs/research/mega_cap_leader_rotation.md). ### global_etf_rotation @@ -140,6 +145,11 @@ reports explicit about the gap between account size and backtest assumptions. - If fewer than 2 names survive, the unused slot is parked in `BIL`. - On non-rebalance days, the strategy returns no target change unless the canary emergency path is triggered. +**Confidence-volatility variant** +- `global_etf_rotation` keeps confidence weighting disabled by default. +- `global_etf_confidence_vol_gate` enables the same signal engine with `sma_period=250`, `confidence_threshold=1.0`, `confidence_top1_weight=0.75`, `confidence_volatility_window=126`, and `confidence_volatility_max_ratio=1.3`. +- The variant concentrates only when Top1 is clearly ahead and not materially more volatile than Top2; otherwise it remains equal-weight Top2. + **Why it exists** - Compared with a pure tech or leveraged-Nasdaq approach, this profile is meant to be steadier. - It still allows `VOO`, `XLK`, and `SMH` to win their way into the rotation instead of hard-coding them out. @@ -332,6 +342,7 @@ The backtest output directory still includes `summary.csv`, `portfolio_returns.c - [`docs/us_equity_contract_gap_matrix.md`](./docs/us_equity_contract_gap_matrix.md):runtime-enabled profile 距离跨平台目标契约的差异矩阵。 - [`docs/us_equity_value_mode_input_contract.md`](./docs/us_equity_value_mode_input_contract.md):两条 value-mode 策略的 canonical 输入契约定稿。 - [`docs/us_equity_strategy_status.zh-CN.md`](./docs/us_equity_strategy_status.zh-CN.md):中文运行手册,集中说明可切换 profile、输入类型、研究候选和已归档回测证据。 +- [`docs/research/global_etf_confidence_vol_gate.md`](./docs/research/global_etf_confidence_vol_gate.md):Global ETF 置信度 + 相对波动过滤研究说明。 - [`docs/research/mega_cap_leader_rotation.md`](./docs/research/mega_cap_leader_rotation.md):巨头强者轮动的研究说明,以及 dynamic top20 运行 profile 说明。 ### 策略索引 @@ -339,6 +350,7 @@ The backtest output directory still includes `summary.csv`, `portfolio_returns.c | Canonical profile | 显示名 | 兼容平台仓库 | 策略频率 | 核心思路 | | --- | --- | --- | --- | --- | | `global_etf_rotation` | 全球 ETF 轮动 | `InteractiveBrokersPlatform`, `CharlesSchwabPlatform`, `LongBridgePlatform`, `PaperSignalPlatform` | 季度调仓 + 每日 canary | 22 只全球 ETF 的季度 Top 2 轮动,带每日 canary 防守 | +| `global_etf_confidence_vol_gate` | 全球 ETF 置信度波动过滤 | `InteractiveBrokersPlatform`, `CharlesSchwabPlatform`, `LongBridgePlatform`, `PaperSignalPlatform` | 季度调仓 + 每日 canary | `global_etf_rotation` 的 SMA250 实验变体,高置信且 Top1 相对波动不过高时切 75/25 | | `russell_1000_multi_factor_defensive` | 罗素1000多因子 | `InteractiveBrokersPlatform`, `CharlesSchwabPlatform`, `LongBridgePlatform`, `PaperSignalPlatform` | 月频 | Russell 1000 个股月频 price-only 选股,带 SPY + breadth 防守和 BOXX 停泊 | | `tech_communication_pullback_enhancement` | 科技通信回调增强 | `InteractiveBrokersPlatform`, `CharlesSchwabPlatform`, `LongBridgePlatform`, `PaperSignalPlatform` | 月频 | tech-heavy 月频个股选择,做受控回调,并显式保留 BOXX 缓冲 | | `mega_cap_leader_rotation_top50_balanced` | Mega Cap Top50 平衡龙头轮动 | `InteractiveBrokersPlatform`, `CharlesSchwabPlatform`, `LongBridgePlatform`, `PaperSignalPlatform` | 月频 | 当前 Top50 平衡候选,固定 50% Top2 cap50 + 50% Top4 cap25,不因 QQQ 趋势默认降仓 | @@ -364,6 +376,7 @@ cron 配置由各个平台仓库负责: | `tqqq_growth_income` | `500 USD` | 最适合小账户;通常能买到 TQQQ,但 BOXX / 现金层会有偏差。 | | `soxl_soxx_trend_income` | `1000 USD` | 整数股平台会有偏离;支持碎股的运行时可以更接近小额 SOXX / BOXX 目标仓位。 | | `global_etf_rotation` | `3000 USD` | Top2 ETF 轮动遇到高价 ETF 时会明显偏离。 | +| `global_etf_confidence_vol_gate` | `3000 USD` | 与 `global_etf_rotation` 相同;如果触发 75/25 不等权,小账户和整数股平台会更容易产生偏离。 | | `mega_cap_leader_rotation_top50_balanced` | `10000 USD` | 固定 50% Top2 / 50% Top4 袖珍组合需要不等权持仓,小账户整数股会产生明显偏离。 | | `tech_communication_pullback_enhancement`(历史别名 `qqq_tech_enhancement`) | `10000 USD` | 小账户会降低持仓数,单票集中度上升。 | | `russell_1000_multi_factor_defensive` | `30000 USD` | 默认 24 只股票组合,不适合小账户。 | @@ -373,6 +386,7 @@ cron 配置由各个平台仓库负责: ### 研究候选与存档 - `mega_cap_leader_rotation_top50_balanced`:已注册为 runtime-enabled 月频 profile,消费透明 Top50 月度 snapshot,运行固定 50% Top2 cap50 + 50% Top4 cap25 的组合,不默认使用宽基趋势降仓。 +- `global_etf_confidence_vol_gate`:`global_etf_rotation` 的 runtime-enabled 实验变体。它保留同一标的池和 canary 防守,使用 SMA250;只有当 Top1 动量 z-gap 至少为 `1.0`,且 Top1 过去 126 日波动率不超过 Top2 的 `1.3x` 时,才从 Top2 等权切到 Top1/Top2 的 `75/25`。 - `mega_cap_leader_rotation`:静态池和动态池的研究/回测总称;说明见 [`docs/research/mega_cap_leader_rotation.md`](./docs/research/mega_cap_leader_rotation.md)。 ### global_etf_rotation @@ -400,6 +414,11 @@ cron 配置由各个平台仓库负责: - 如果合格标的不满 2 个,空出来的部分停到 `BIL`。 - 非调仓日默认不改目标仓位,除非触发 canary 应急防守。 +**置信度 + 波动过滤变体** +- `global_etf_rotation` 默认关闭置信度加权,保持原有 Top2 等权行为。 +- `global_etf_confidence_vol_gate` 使用同一个信号引擎,但设置为 `sma_period=250`、`confidence_threshold=1.0`、`confidence_top1_weight=0.75`、`confidence_volatility_window=126`、`confidence_volatility_max_ratio=1.3`。 +- 只有 Top1 明显领先且相对 Top2 不显著更高波动时,才切到 `75 / 25`;否则仍保持 Top2 等权。 + **这套策略的定位** - 相比纯科技或者杠杆纳指路线,这个档位更稳。 - 但它仍然允许 `VOO`、`XLK`、`SMH` 靠表现进入组合,而不是事先把它们排除。 diff --git a/docs/research/global_etf_confidence_vol_gate.md b/docs/research/global_etf_confidence_vol_gate.md new file mode 100644 index 0000000..b0e4d14 --- /dev/null +++ b/docs/research/global_etf_confidence_vol_gate.md @@ -0,0 +1,36 @@ +# Global ETF Confidence Vol Gate Research + +_Updated: 2026-05-08_ + +## Candidate + +`global_etf_confidence_vol_gate` is an experimental variant of `global_etf_rotation`. + +- Universe, canary basket, safe haven, quarterly cadence, 13612W momentum, and hold bonus stay aligned with `global_etf_rotation`. +- The variant uses `sma_period=250`. +- It starts from the Top2 selection and normally stays equal-weight `50 / 50`. +- It shifts to `75 / 25` Top1/Top2 only when: + - Top1 momentum z-gap versus Top2 is at least `1.0`. + - Top1 trailing 126-trading-day annualized volatility is no more than `1.3x` Top2 volatility. + +## Production-Like Backtest Snapshot + +The research run used daily close history through 2026-05-07, quarterly rebalances, daily canary checks, and 5 bps turnover cost. The comparison below uses the same SMA250 baseline as the candidate. + +| Strategy | Sample | CAGR | Max drawdown | Volatility | Sharpe | Final equity | +| --- | --- | ---: | ---: | ---: | ---: | ---: | +| Top2 SMA250 baseline | 2015-01-05 to 2026-05-06 | 13.60% | -23.35% | 19.21% | 0.762 | 4.242 | +| Ungated confidence 75/25 | 2015-01-05 to 2026-05-06 | 14.43% | -28.98% | 21.55% | 0.737 | 4.605 | +| Confidence + relative volatility gate | 2015-01-05 to 2026-05-06 | 14.77% | -23.35% | 19.59% | 0.803 | 4.763 | + +## Interpretation + +The ungated confidence rule improved CAGR but widened drawdown. The relative volatility gate filtered several high-confidence Top1 cases where the leader was much more volatile than the runner-up, bringing max drawdown back to the SMA250 Top2 baseline while preserving higher CAGR and Sharpe. + +This does not make the profile a QQQ replacement: QQQ buy-and-hold still has a higher long-run CAGR in the same broad research window. The candidate is only an internal Global ETF enhancement path for operators who want the Global ETF risk profile but are willing to test a modest concentration rule. + +## Rollout Recommendation + +- Keep `global_etf_rotation` unchanged as the default defensive profile. +- Expose `global_etf_confidence_vol_gate` as a separate runtime profile. +- Use paper or small allocation first; integer-share runtimes may drift more because the candidate can target unequal `75 / 25` weights. diff --git a/docs/us_equity_contract_gap_matrix.md b/docs/us_equity_contract_gap_matrix.md index 727d697..18a8a18 100644 --- a/docs/us_equity_contract_gap_matrix.md +++ b/docs/us_equity_contract_gap_matrix.md @@ -1,6 +1,6 @@ # US equity contract gap matrix -_Updated: 2026-05-01_ +_Updated: 2026-05-08_ This document tracks the current shared US equity strategy contract across `UsEquityStrategies`, `QuantPlatformKit`, and the platform runtimes. @@ -14,6 +14,7 @@ entries in this matrix. The current runtime-enabled US equity profiles are: - `global_etf_rotation` +- `global_etf_confidence_vol_gate` - `tqqq_growth_income` - `soxl_soxx_trend_income` - `russell_1000_multi_factor_defensive` @@ -64,6 +65,7 @@ New US equity profiles should use only these canonical `required_inputs`: | Profile | `target_mode` | `required_inputs` | Adapter coverage | Runtime status | Notes | | --- | --- | --- | --- | --- | --- | | `global_etf_rotation` | `weight` | `market_history` | `ibkr`, `schwab`, `longbridge`, `paper_signal` | runtime-enabled | Quarterly top-2 ETF rotation with daily canary defense. | +| `global_etf_confidence_vol_gate` | `weight` | `market_history` | `ibkr`, `schwab`, `longbridge`, `paper_signal` | runtime-enabled | SMA250 Global ETF variant that allows 75/25 Top1/Top2 only behind a momentum-confidence and relative-volatility gate. | | `tqqq_growth_income` | `value` | `benchmark_history`, `portfolio_snapshot` | `ibkr`, `schwab`, `longbridge`, `paper_signal` | runtime-enabled | Direct QQQ/TQQQ growth-income profile with explicit portfolio input. | | `soxl_soxx_trend_income` | `value` | `derived_indicators`, `portfolio_snapshot` | `ibkr`, `schwab`, `longbridge`, `paper_signal` | runtime-enabled | Semiconductor trend profile using canonical derived indicators. | | `russell_1000_multi_factor_defensive` | `weight` | `feature_snapshot` | `ibkr`, `schwab`, `longbridge`, `paper_signal` | runtime-enabled | Artifact-backed Russell 1000 defensive selection. | diff --git a/docs/us_equity_strategy_status.zh-CN.md b/docs/us_equity_strategy_status.zh-CN.md index 8ef1143..a9349d2 100644 --- a/docs/us_equity_strategy_status.zh-CN.md +++ b/docs/us_equity_strategy_status.zh-CN.md @@ -1,6 +1,6 @@ # 美股策略状态与研究手册 -_更新日期:2026-05-04_ +_更新日期:2026-05-08_ 这份文档只记录当前可配置的美股策略 profile、输入形态和研究状态,不记录任何账户或服务正在运行的 profile。部署单元当前跑什么属于私有运行信息,应留在云端配置或私有运行记录里。 @@ -10,11 +10,12 @@ _更新日期:2026-05-04_ ## 当前可配置 profiles -这 6 条 profile 是当前 `runtime_enabled` `us_equity` 集合。它们按共享文档规范设计为通用策略,平台侧通过同一份 catalog、manifest、entrypoint 和 runtime adapter 契约接入;是否实盘启用仍由各部署配置和风控决定。 +这 7 条 profile 是当前 `runtime_enabled` `us_equity` 集合。它们按共享文档规范设计为通用策略,平台侧通过同一份 catalog、manifest、entrypoint 和 runtime adapter 契约接入;是否实盘启用仍由各部署配置和风控决定。 | Profile | 中文定位 | 输入类型 | 特点 | 当前建议 | | --- | --- | --- | --- | --- | | `global_etf_rotation` | 全球 ETF 防守轮动 | 直接运行输入 | 季度 Top2 ETF 轮动,每日 canary 防守,弱市切 `BIL`。 | 可切换;偏低波动防守线。 | +| `global_etf_confidence_vol_gate` | 全球 ETF 置信度波动过滤 | 直接运行输入 | `global_etf_rotation` 的 SMA250 实验变体;高置信且 Top1 相对波动不过高时切 `75% / 25%`。 | 可做 paper / 小比例观察;不是替代默认档。 | | `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`;剩余资金停 BOXX,可叠加收入层。 | 半导体高弹性直接输入策略;波动高于宽基。 | | `tech_communication_pullback_enhancement` | 科技通信回调增强 | feature snapshot | 科技/通信个股月频选择,受控回调入场,保留 BOXX 缓冲。 | 需要月度 snapshot;适合先小比例或观察运行。 | @@ -45,6 +46,7 @@ _更新日期:2026-05-04_ | TQQQ 买入持有参考 | 2017-01-03 至 2026-04-10 | 37.77% | -81.66% | 收益高但回撤过深,只作风险参照。 | 同上 | | Top50 `blend_top2_50_top4_50`,21 日 universe lag | 2017-10-02 至 2026-04-16 | 36.41% | -30.56% | 当前最强无杠杆候选之一;回撤可接受但 Top2 袖子带来集中风险,需要 paper 观察。 | `UsEquitySnapshotPipelines/data/output/mega_cap_leader_rotation_dynamic_top50_concentration_variants/concentration_variant_summary.csv` | | 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` | +| `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% TACO 袖子 | 1999-03-10 至 2026-04-16 | 23.89% | -56.04% | 相比合成 TQQQ 基线显著降低 2000/2008 级别灾难回撤;但该历史版本包含 TACO,不等于当前 defense-only shadow plugin。 | `UsEquitySnapshotPipelines/data/output/crisis_response_audit_trial/external_fragility10_severe10_fin_credit/summary.csv` | 暂时没有写进正式表的内容: diff --git a/pyproject.toml b/pyproject.toml index 7b45957..2bc15f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "us-equity-strategies" -version = "0.7.33" +version = "0.7.34" description = "Shared US equity strategy catalog and implementations" readme = "README.md" requires-python = ">=3.11" diff --git a/src/us_equity_strategies/account_sizing.py b/src/us_equity_strategies/account_sizing.py index 7f8292f..8ae044f 100644 --- a/src/us_equity_strategies/account_sizing.py +++ b/src/us_equity_strategies/account_sizing.py @@ -6,6 +6,7 @@ MIN_RECOMMENDED_EQUITY_USD: dict[str, float] = { "global_etf_rotation": 3_000.0, + "global_etf_confidence_vol_gate": 3_000.0, "tqqq_growth_income": 500.0, "soxl_soxx_trend_income": 1_000.0, "russell_1000_multi_factor_defensive": 30_000.0, diff --git a/src/us_equity_strategies/catalog.py b/src/us_equity_strategies/catalog.py index e8d719c..70a5e3c 100644 --- a/src/us_equity_strategies/catalog.py +++ b/src/us_equity_strategies/catalog.py @@ -17,6 +17,7 @@ ) GLOBAL_ETF_ROTATION_PROFILE = "global_etf_rotation" +GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE = "global_etf_confidence_vol_gate" TQQQ_GROWTH_INCOME_PROFILE = "tqqq_growth_income" SOXL_SOXX_TREND_INCOME_PROFILE = "soxl_soxx_trend_income" RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE = "russell_1000_multi_factor_defensive" @@ -29,6 +30,7 @@ STRATEGY_PLATFORM_COMPATIBILITY: dict[str, frozenset[str]] = { GLOBAL_ETF_ROTATION_PROFILE: FULL_SHARED_PLATFORM_MATRIX, + GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE: FULL_SHARED_PLATFORM_MATRIX, TQQQ_GROWTH_INCOME_PROFILE: FULL_SHARED_PLATFORM_MATRIX, SOXL_SOXX_TREND_INCOME_PROFILE: FULL_SHARED_PLATFORM_MATRIX, RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE: FULL_SHARED_PLATFORM_MATRIX, @@ -38,6 +40,7 @@ STRATEGY_REQUIRED_INPUTS: dict[str, frozenset[str]] = { GLOBAL_ETF_ROTATION_PROFILE: frozenset({"market_history"}), + GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE: frozenset({"market_history"}), TQQQ_GROWTH_INCOME_PROFILE: frozenset({"benchmark_history", "portfolio_snapshot"}), SOXL_SOXX_TREND_INCOME_PROFILE: frozenset({"derived_indicators", "portfolio_snapshot"}), RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE: frozenset({"feature_snapshot"}), @@ -58,6 +61,33 @@ "canary_bad_threshold": 4, "rebalance_months": (3, 6, 9, 12), "sma_period": 200, + "confidence_weighting_enabled": False, + "confidence_metric": "z_gap", + "confidence_threshold": 1.0, + "confidence_top1_weight": 0.75, + "confidence_volatility_gate_enabled": False, + "confidence_volatility_window": 126, + "confidence_volatility_max_ratio": 1.3, + }, + GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE: { + "ranking_pool": ( + "EWY", "EWT", "INDA", "FXI", "EWJ", "VGK", "VOO", "XLK", "SMH", "GLD", + "SLV", "USO", "DBA", "XLE", "XLF", "ITA", "XLP", "XLU", "XLV", "IHI", "VNQ", "KRE", + ), + "canary_assets": ("SPY", "EFA", "EEM", "AGG"), + "safe_haven": "BIL", + "top_n": 2, + "hold_bonus": 0.02, + "canary_bad_threshold": 4, + "rebalance_months": (3, 6, 9, 12), + "sma_period": 250, + "confidence_weighting_enabled": True, + "confidence_metric": "z_gap", + "confidence_threshold": 1.0, + "confidence_top1_weight": 0.75, + "confidence_volatility_gate_enabled": True, + "confidence_volatility_window": 126, + "confidence_volatility_max_ratio": 1.3, }, TQQQ_GROWTH_INCOME_PROFILE: { "benchmark_symbol": "QQQ", @@ -167,6 +197,7 @@ STRATEGY_ENTRYPOINT_ATTRIBUTES: dict[str, str] = { GLOBAL_ETF_ROTATION_PROFILE: "global_etf_rotation_entrypoint", + GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE: "global_etf_confidence_vol_gate_entrypoint", TQQQ_GROWTH_INCOME_PROFILE: "tqqq_growth_income_entrypoint", SOXL_SOXX_TREND_INCOME_PROFILE: "soxl_soxx_trend_income_entrypoint", RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE: "russell_1000_multi_factor_defensive_entrypoint", @@ -176,6 +207,7 @@ STRATEGY_TARGET_MODES: dict[str, str] = { GLOBAL_ETF_ROTATION_PROFILE: "weight", + GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE: "weight", TQQQ_GROWTH_INCOME_PROFILE: "value", SOXL_SOXX_TREND_INCOME_PROFILE: "value", RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE: "weight", @@ -225,6 +257,11 @@ def _build_strategy_definition( component_name="signal_logic", module_path="us_equity_strategies.strategies.global_etf_rotation", ), + GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE: _build_strategy_definition( + GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE, + component_name="signal_logic", + module_path="us_equity_strategies.strategies.global_etf_rotation", + ), TQQQ_GROWTH_INCOME_PROFILE: _build_strategy_definition( TQQQ_GROWTH_INCOME_PROFILE, component_name="allocation", @@ -265,6 +302,17 @@ def _build_strategy_definition( role="defensive_rotation", status="runtime_enabled", ), + GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE: StrategyMetadata( + canonical_profile=GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE, + display_name="Global ETF Confidence Vol Gate", + description="SMA250 Global ETF rotation with high-confidence 75/25 Top1/Top2 weighting gated by relative volatility.", + aliases=(), + cadence="quarterly + daily canary", + asset_scope="global_etf_rotation", + benchmark="VOO", + role="defensive_rotation_research_candidate", + status="runtime_enabled", + ), TQQQ_GROWTH_INCOME_PROFILE: StrategyMetadata( canonical_profile=TQQQ_GROWTH_INCOME_PROFILE, display_name="TQQQ Growth Income", diff --git a/src/us_equity_strategies/entrypoints/__init__.py b/src/us_equity_strategies/entrypoints/__init__.py index 53eacfb..ccf4b67 100644 --- a/src/us_equity_strategies/entrypoints/__init__.py +++ b/src/us_equity_strategies/entrypoints/__init__.py @@ -14,6 +14,7 @@ build_account_size_diagnostics_from_context, ) from us_equity_strategies.manifests import ( + global_etf_confidence_vol_gate_manifest, global_etf_rotation_manifest, mega_cap_leader_rotation_top50_balanced_manifest, qqq_tech_enhancement_manifest, @@ -200,8 +201,8 @@ def _build_tqqq_benchmark_text(notification_context: Mapping[str, object] | None ) -def evaluate_global_etf_rotation(ctx: StrategyContext) -> StrategyDecision: - config = merge_runtime_config(global_etf_rotation_manifest.default_config, ctx) +def _evaluate_global_etf_rotation_with_manifest(ctx: StrategyContext, *, manifest) -> StrategyDecision: + config = merge_runtime_config(manifest.default_config, ctx) config["ranking_pool"] = list(config.get("ranking_pool", ())) config["canary_assets"] = list(config.get("canary_assets", ())) config.pop("signal_effective_after_trading_days", None) @@ -222,7 +223,7 @@ def evaluate_global_etf_rotation(ctx: StrategyContext) -> StrategyDecision: "canary_status": canary_str, "actionable": weights is not None, } - diagnostics.update(_account_size_diagnostics(global_etf_rotation_manifest.profile, ctx)) + diagnostics.update(_account_size_diagnostics(manifest.profile, ctx)) diagnostics["signal_description"] = append_account_size_warning( str(diagnostics["signal_description"]), diagnostics, @@ -250,6 +251,14 @@ def evaluate_global_etf_rotation(ctx: StrategyContext) -> StrategyDecision: ) +def evaluate_global_etf_rotation(ctx: StrategyContext) -> StrategyDecision: + return _evaluate_global_etf_rotation_with_manifest(ctx, manifest=global_etf_rotation_manifest) + + +def evaluate_global_etf_confidence_vol_gate(ctx: StrategyContext) -> StrategyDecision: + return _evaluate_global_etf_rotation_with_manifest(ctx, manifest=global_etf_confidence_vol_gate_manifest) + + GLOBAL_ETF_ROTATION_LEGACY_DOC = "Legacy compute_signals adapter retained for platform compatibility." legacy_global_etf_rotation.compute_signals.__doc__ = ( (legacy_global_etf_rotation.compute_signals.__doc__ or "").strip() + "\n\nLegacy adapter: prefer us_equity_strategies entrypoints for new integrations." @@ -709,6 +718,10 @@ def evaluate_mega_cap_leader_rotation_top50_balanced(ctx: StrategyContext) -> St manifest=global_etf_rotation_manifest, _evaluate=evaluate_global_etf_rotation, ) +global_etf_confidence_vol_gate_entrypoint = CallableStrategyEntrypoint( + manifest=global_etf_confidence_vol_gate_manifest, + _evaluate=evaluate_global_etf_confidence_vol_gate, +) tqqq_growth_income_entrypoint = CallableStrategyEntrypoint( manifest=tqqq_growth_income_manifest, _evaluate=evaluate_tqqq_growth_income, @@ -733,12 +746,14 @@ def evaluate_mega_cap_leader_rotation_top50_balanced(ctx: StrategyContext) -> St __all__ = [ "global_etf_rotation_entrypoint", + "global_etf_confidence_vol_gate_entrypoint", "tqqq_growth_income_entrypoint", "soxl_soxx_trend_income_entrypoint", "qqq_tech_enhancement_entrypoint", "russell_1000_multi_factor_defensive_entrypoint", "mega_cap_leader_rotation_top50_balanced_entrypoint", "evaluate_global_etf_rotation", + "evaluate_global_etf_confidence_vol_gate", "evaluate_tqqq_growth_income", "evaluate_soxl_soxx_trend_income", "evaluate_russell_1000_multi_factor_defensive", diff --git a/src/us_equity_strategies/manifests/__init__.py b/src/us_equity_strategies/manifests/__init__.py index dea7204..c299da2 100644 --- a/src/us_equity_strategies/manifests/__init__.py +++ b/src/us_equity_strategies/manifests/__init__.py @@ -3,6 +3,7 @@ from quant_platform_kit.strategy_contracts import StrategyManifest TECH_COMMUNICATION_PULLBACK_ENHANCEMENT_PROFILE = "tech_communication_pullback_enhancement" +GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE = "global_etf_confidence_vol_gate" MEGA_CAP_LEADER_ROTATION_TOP50_BALANCED_PROFILE = "mega_cap_leader_rotation_top50_balanced" QQQ_TECH_ENHANCEMENT_LEGACY_PROFILE = "qqq_tech_enhancement" @@ -65,6 +66,34 @@ def _manifest( "canary_bad_threshold": 4, "rebalance_months": (3, 6, 9, 12), "sma_period": 200, + "confidence_weighting_enabled": False, + "confidence_metric": "z_gap", + "confidence_threshold": 1.0, + "confidence_top1_weight": 0.75, + "confidence_volatility_gate_enabled": False, + "confidence_volatility_window": 126, + "confidence_volatility_max_ratio": 1.3, + }, +) + +global_etf_confidence_vol_gate_manifest = _manifest( + profile=GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE, + display_name="Global ETF Confidence Vol Gate", + description=( + "Experimental SMA250 Global ETF rotation that shifts to 75/25 Top1/Top2 only when " + "momentum confidence is high and Top1 volatility is close to Top2." + ), + aliases=(), + required_inputs=frozenset({"market_history"}), + default_config={ + **global_etf_rotation_manifest.default_config, + "sma_period": 250, + "confidence_weighting_enabled": True, + "confidence_threshold": 1.0, + "confidence_top1_weight": 0.75, + "confidence_volatility_gate_enabled": True, + "confidence_volatility_window": 126, + "confidence_volatility_max_ratio": 1.3, }, ) @@ -214,6 +243,7 @@ def _manifest( MANIFESTS = { global_etf_rotation_manifest.profile: global_etf_rotation_manifest, + global_etf_confidence_vol_gate_manifest.profile: global_etf_confidence_vol_gate_manifest, tqqq_growth_income_manifest.profile: tqqq_growth_income_manifest, soxl_soxx_trend_income_manifest.profile: soxl_soxx_trend_income_manifest, russell_1000_multi_factor_defensive_manifest.profile: russell_1000_multi_factor_defensive_manifest, @@ -237,6 +267,7 @@ def get_strategy_manifest(profile: str) -> StrategyManifest: "MANIFESTS", "get_strategy_manifest", "global_etf_rotation_manifest", + "global_etf_confidence_vol_gate_manifest", "tqqq_growth_income_manifest", "soxl_soxx_trend_income_manifest", "qqq_tech_enhancement_manifest", diff --git a/src/us_equity_strategies/runtime_adapters.py b/src/us_equity_strategies/runtime_adapters.py index a81df25..1b66030 100644 --- a/src/us_equity_strategies/runtime_adapters.py +++ b/src/us_equity_strategies/runtime_adapters.py @@ -11,6 +11,7 @@ ) from us_equity_strategies.catalog import ( + GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE, MEGA_CAP_LEADER_ROTATION_TOP50_BALANCED_PROFILE, QQQ_TECH_ENHANCEMENT_PROFILE, get_strategy_definition, @@ -45,6 +46,10 @@ status_icon="🐤", runtime_policy=StrategyRuntimePolicy(signal_effective_after_trading_days=1), ), + GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE: StrategyRuntimeAdapter( + status_icon="🐤", + runtime_policy=StrategyRuntimePolicy(signal_effective_after_trading_days=1), + ), "tqqq_growth_income": StrategyRuntimeAdapter( status_icon="🐤", runtime_policy=StrategyRuntimePolicy(signal_effective_after_trading_days=1), diff --git a/src/us_equity_strategies/strategies/global_etf_rotation.py b/src/us_equity_strategies/strategies/global_etf_rotation.py index 4fd0ea3..7097550 100644 --- a/src/us_equity_strategies/strategies/global_etf_rotation.py +++ b/src/us_equity_strategies/strategies/global_etf_rotation.py @@ -40,6 +40,7 @@ HOLD_BONUS = 0.02 CANARY_BAD_THRESHOLD = 4 REBALANCE_MONTHS = {3, 6, 9, 12} +CONFIDENCE_METRIC_Z_GAP = "z_gap" def _load_nyse_calendar(): @@ -103,6 +104,45 @@ def check_sma(closes: pd.Series, period: int = SMA_PERIOD) -> bool: return bool(closes.iloc[-1] > closes.iloc[-period:].mean()) +def _annualized_volatility(closes: pd.Series, window: int) -> float: + if len(closes) <= window: + return float("nan") + returns = closes.pct_change(fill_method=None).dropna() + if len(returns) < window: + return float("nan") + volatility = returns.iloc[-window:].std() + if pd.isna(volatility): + return float("nan") + return float(volatility * np.sqrt(252)) + + +def _score_confidence(sorted_tickers: list[tuple[str, float]], *, metric: str) -> float: + if len(sorted_tickers) < 2: + return float("nan") + gap = float(sorted_tickers[0][1] - sorted_tickers[1][1]) + if metric == CONFIDENCE_METRIC_Z_GAP: + dispersion = float(np.nanstd([score for _ticker, score in sorted_tickers])) + if dispersion <= 0.0 or np.isnan(dispersion): + return float("nan") + return gap / dispersion + return gap + + +def _passes_relative_volatility_gate( + price_data: dict[str, pd.Series], + top1: str, + top2: str, + *, + window: int, + max_ratio: float, +) -> tuple[bool, float, float]: + top1_vol = _annualized_volatility(price_data.get(top1, pd.Series(dtype=float)), window) + top2_vol = _annualized_volatility(price_data.get(top2, pd.Series(dtype=float)), window) + if np.isnan(top1_vol) or np.isnan(top2_vol) or top2_vol <= 0.0: + return False, top1_vol, top2_vol + return top1_vol <= top2_vol * max_ratio, top1_vol, top2_vol + + def compute_signals( ib, current_holdings, @@ -119,11 +159,20 @@ def compute_signals( translator: Callable, pacing_sec: float, sma_period: int = SMA_PERIOD, + confidence_weighting_enabled: bool = False, + confidence_metric: str = CONFIDENCE_METRIC_Z_GAP, + confidence_threshold: float = 1.0, + confidence_top1_weight: float = 0.75, + confidence_volatility_gate_enabled: bool = False, + confidence_volatility_window: int = 126, + confidence_volatility_max_ratio: float = 1.3, ): """ Compute target weights. Returns (weights_dict, signal_description, is_emergency, canary_str). """ + ranking_pool = list(ranking_pool) + canary_assets = list(canary_assets) all_tickers = list(set(ranking_pool + canary_assets + [safe_haven])) price_data = {} for ticker in all_tickers: @@ -191,9 +240,41 @@ def compute_signals( per_weight = 1.0 / top_n weights = {ticker: per_weight for ticker, _score in top} + confidence_note = "" + if confidence_weighting_enabled and top_n == 2 and len(top) == 2: + confidence = _score_confidence(sorted_tickers, metric=str(confidence_metric)) + top1, top2 = top[0][0], top[1][0] + use_confidence_weight = not np.isnan(confidence) and confidence >= float(confidence_threshold) + top1_vol = top2_vol = float("nan") + if use_confidence_weight and confidence_volatility_gate_enabled: + use_confidence_weight, top1_vol, top2_vol = _passes_relative_volatility_gate( + price_data, + top1, + top2, + window=int(confidence_volatility_window), + max_ratio=float(confidence_volatility_max_ratio), + ) + if use_confidence_weight: + top1_weight = min(1.0, max(per_weight, float(confidence_top1_weight))) + weights = {top1: top1_weight, top2: 1.0 - top1_weight} + confidence_note = ( + f"\n Confidence: {confidence:.3f} >= {float(confidence_threshold):.3f}; " + f"{top1} weight {top1_weight:.1%}" + ) + else: + confidence_note = ( + f"\n Confidence: {confidence:.3f}" + if not np.isnan(confidence) + else "\n Confidence: n/a" + ) + if confidence_volatility_gate_enabled and not np.isnan(top1_vol) and not np.isnan(top2_vol): + confidence_note += ( + f"; vol {top1}/{top2}: {top1_vol:.1%}/{top2_vol:.1%}, " + f"max ratio {float(confidence_volatility_max_ratio):.2f}" + ) if len(top) < top_n: weights[safe_haven] = weights.get(safe_haven, 0) + per_weight * (top_n - len(top)) top_str = ", ".join(f"{ticker}({score:.3f})" for ticker, score in top) - signal_desc = translator("quarterly", n=top_n) + f"\n Top: {top_str}" + signal_desc = translator("quarterly", n=top_n) + f"\n Top: {top_str}{confidence_note}" return weights, signal_desc, False, canary_str diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 4708c13..2f837ed 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -4,6 +4,7 @@ from us_equity_strategies import get_strategy_definitions from us_equity_strategies.catalog import ( FULL_SHARED_PLATFORM_MATRIX, + GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE, GLOBAL_ETF_ROTATION_PROFILE, MEGA_CAP_LEADER_ROTATION_TOP50_BALANCED_PROFILE, QQQ_TECH_ENHANCEMENT_PROFILE, @@ -36,6 +37,16 @@ def test_catalog_contains_supported_profiles(self): frozenset({"market_history"}), ) + self.assertIn(GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE, catalog) + self.assertEqual( + get_compatible_platforms(GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE), + FULL_SHARED_PLATFORM_MATRIX, + ) + self.assertEqual( + catalog[GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE].required_inputs, + frozenset({"market_history"}), + ) + self.assertIn(TQQQ_GROWTH_INCOME_PROFILE, catalog) self.assertEqual(catalog[TQQQ_GROWTH_INCOME_PROFILE].domain, "us_equity") self.assertEqual( @@ -119,6 +130,12 @@ def test_known_profile_resolves(self): "us_equity_strategies.strategies.qqq_tech_enhancement", ) + confidence_gate_definition = get_strategy_definition(GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE) + self.assertEqual(confidence_gate_definition.profile, GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE) + self.assertEqual(confidence_gate_definition.default_config["sma_period"], 250) + self.assertTrue(confidence_gate_definition.default_config["confidence_weighting_enabled"]) + self.assertTrue(confidence_gate_definition.default_config["confidence_volatility_gate_enabled"]) + balanced_definition = get_strategy_definition("mega_cap_leader_rotation_top50_balanced") self.assertEqual(balanced_definition.profile, MEGA_CAP_LEADER_ROTATION_TOP50_BALANCED_PROFILE) balanced_module = get_strategy_component_map(balanced_definition)["signal_logic"] @@ -161,6 +178,10 @@ def test_metadata_map_exposes_display_names_and_roles(self): FULL_SHARED_PLATFORM_MATRIX, ) self.assertEqual(metadata_map[QQQ_TECH_ENHANCEMENT_PROFILE].status, "runtime_enabled") + self.assertEqual( + metadata_map[GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE].status, + "runtime_enabled", + ) self.assertEqual(get_strategy_definition("qqq_tech_enhancement").target_mode, "weight") self.assertEqual( metadata_map[MEGA_CAP_LEADER_ROTATION_TOP50_BALANCED_PROFILE].role, @@ -177,6 +198,7 @@ def test_strategy_index_rows_are_human_readable(self): self.assertEqual(by_profile[QQQ_TECH_ENHANCEMENT_PROFILE]["display_name"], "Tech/Communication Pullback Enhancement") self.assertEqual(by_profile[TQQQ_GROWTH_INCOME_PROFILE]["aliases"], ()) self.assertIn("signal_logic", by_profile[GLOBAL_ETF_ROTATION_PROFILE]["component_names"]) + self.assertIn("signal_logic", by_profile[GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE]["component_names"]) self.assertEqual( by_profile[QQQ_TECH_ENHANCEMENT_PROFILE]["compatible_platforms"], FULL_SHARED_PLATFORM_MATRIX, @@ -201,6 +223,7 @@ def test_runtime_enabled_profiles_are_the_live_catalog(self): frozenset( { GLOBAL_ETF_ROTATION_PROFILE, + GLOBAL_ETF_CONFIDENCE_VOL_GATE_PROFILE, TQQQ_GROWTH_INCOME_PROFILE, SOXL_SOXX_TREND_INCOME_PROFILE, RUSSELL_1000_MULTI_FACTOR_DEFENSIVE_PROFILE, diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 49d3aa5..8c9a55e 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -75,6 +75,10 @@ def test_global_etf_runtime_adapter_uses_canonical_market_history(self) -> None: self.assertEqual(adapter.available_inputs, frozenset({"market_history"})) self.assertEqual(adapter.available_capabilities, frozenset({"broker_client"})) self.assertEqual(adapter.runtime_policy.signal_effective_after_trading_days, 1) + confidence_adapter = get_platform_runtime_adapter("global_etf_confidence_vol_gate", platform_id="ibkr") + self.assertEqual(confidence_adapter.available_inputs, frozenset({"market_history"})) + self.assertEqual(confidence_adapter.available_capabilities, frozenset({"broker_client"})) + self.assertEqual(confidence_adapter.runtime_policy.signal_effective_after_trading_days, 1) paper_signal_adapter = get_platform_runtime_adapter("global_etf_rotation", platform_id="paper_signal") self.assertEqual(paper_signal_adapter.available_inputs, frozenset({"market_history"})) self.assertEqual(paper_signal_adapter.available_capabilities, frozenset()) diff --git a/tests/test_global_etf_rotation.py b/tests/test_global_etf_rotation.py new file mode 100644 index 0000000..2368d9f --- /dev/null +++ b/tests/test_global_etf_rotation.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +import numpy as np +import pandas as pd + +from us_equity_strategies.strategies import global_etf_rotation + + +def _history(symbol: str, *, volatility: float) -> pd.Series: + dates = pd.bdate_range(end="2026-03-31", periods=320) + returns = np.full(len(dates), 0.003) + returns[::2] += volatility + returns[1::2] -= volatility + values = 100.0 * np.cumprod(1.0 + returns) + return pd.Series(values, index=dates, name=symbol) + + +class GlobalEtfRotationConfidenceTests(unittest.TestCase): + def _run_confidence_case(self, *, top1_volatility: float) -> dict[str, float]: + histories = { + "AAA": _history("AAA", volatility=top1_volatility), + "BBB": _history("BBB", volatility=0.004), + "CCC": _history("CCC", volatility=0.003), + "SPY": _history("SPY", volatility=0.002), + "EFA": _history("EFA", volatility=0.002), + "EEM": _history("EEM", volatility=0.002), + "AGG": _history("AGG", volatility=0.001), + "BIL": _history("BIL", volatility=0.001), + } + scores = { + "AAA": 0.20, + "BBB": 0.10, + "CCC": 0.00, + "SPY": 0.01, + "EFA": 0.01, + "EEM": 0.01, + "AGG": 0.01, + } + + def score(series): + return scores.get(series.name, 0.0) + + with patch.object(global_etf_rotation, "compute_13612w_momentum", side_effect=score): + weights, _signal, is_emergency, _canary = global_etf_rotation.compute_signals( + None, + current_holdings=(), + get_historical_close=lambda _ib, ticker: histories[ticker], + as_of_date="2026-03-31", + ranking_pool=("AAA", "BBB", "CCC"), + canary_assets=("SPY", "EFA", "EEM", "AGG"), + safe_haven="BIL", + translator=lambda key, **kwargs: key, + pacing_sec=0.0, + sma_period=200, + confidence_weighting_enabled=True, + confidence_threshold=1.0, + confidence_top1_weight=0.75, + confidence_volatility_gate_enabled=True, + confidence_volatility_window=126, + confidence_volatility_max_ratio=1.3, + ) + + self.assertFalse(is_emergency) + return weights + + def test_confidence_weighting_concentrates_when_relative_volatility_passes(self) -> None: + weights = self._run_confidence_case(top1_volatility=0.003) + + self.assertEqual(weights, {"AAA": 0.75, "BBB": 0.25}) + + def test_confidence_weighting_stays_equal_weight_when_top1_volatility_is_too_high(self) -> None: + weights = self._run_confidence_case(top1_volatility=0.025) + + self.assertEqual(weights, {"AAA": 0.5, "BBB": 0.5}) + + def test_default_global_rotation_keeps_equal_weighting(self) -> None: + histories = { + "AAA": _history("AAA", volatility=0.003), + "BBB": _history("BBB", volatility=0.004), + "SPY": _history("SPY", volatility=0.002), + "EFA": _history("EFA", volatility=0.002), + "EEM": _history("EEM", volatility=0.002), + "AGG": _history("AGG", volatility=0.001), + "BIL": _history("BIL", volatility=0.001), + } + + with patch.object( + global_etf_rotation, + "compute_13612w_momentum", + side_effect=lambda series: 0.20 if series.name == "AAA" else 0.10, + ): + weights, _signal, _is_emergency, _canary = global_etf_rotation.compute_signals( + None, + current_holdings=(), + get_historical_close=lambda _ib, ticker: histories[ticker], + as_of_date="2026-03-31", + ranking_pool=("AAA", "BBB"), + canary_assets=("SPY", "EFA", "EEM", "AGG"), + safe_haven="BIL", + translator=lambda key, **kwargs: key, + pacing_sec=0.0, + sma_period=200, + ) + + self.assertEqual(weights, {"AAA": 0.5, "BBB": 0.5}) + + +if __name__ == "__main__": + unittest.main()