diff --git a/README.md b/README.md index 237faf4..64b4ce6 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,7 @@ These profiles use platform-provided `market_history` and do not require a separ | Profile | Name | Input | Benchmark | Current role | | --- | --- | --- | --- | --- | -| `hk_dividend_gold_defensive_rotation` | HK Dividend-Gold Defensive Rotation | `market_history` | `03110` | Preferred HK ETF runtime profile by current risk-adjusted evidence. | -| `hk_global_etf_tactical_rotation` | HK Global ETF Tactical Rotation | `market_history` | `02800` | Secondary HK ETF runtime profile with broader ETF exposure and heavier product checks. | +| `hk_global_etf_tactical_rotation` | HK Global ETF Tactical Rotation | `market_history` | `02800` | Retained HK ETF runtime profile with broader ETF exposure and heavier product checks. | ### Snapshot-backed runtime strategy @@ -62,9 +61,9 @@ These commands are read-only unless you explicitly pass an evidence file to a va ```bash python scripts/print_hk_live_enablement_matrix.py --json -python scripts/print_hk_runtime_readiness.py --profile hk_dividend_gold_defensive_rotation --platform longbridge --json +python scripts/print_hk_runtime_readiness.py --profile hk_global_etf_tactical_rotation --platform longbridge --json python scripts/print_hk_runtime_readiness.py --profile hk_low_vol_dividend_quality_snapshot --platform longbridge --json -python scripts/validate_hk_runtime_live_enablement.py --print-template --profile hk_dividend_gold_defensive_rotation --platform longbridge --json +python scripts/validate_hk_runtime_live_enablement.py --print-template --profile hk_global_etf_tactical_rotation --platform longbridge --json ``` For local smoke coverage of the ordinary ETF rotation path: @@ -101,7 +100,6 @@ Supported HK runtime platforms currently include: - [`docs/platform_integration.md`](docs/platform_integration.md) - [`docs/research/hk_strategy_selection_20260603.md`](docs/research/hk_strategy_selection_20260603.md) -- [`docs/research/hk_dividend_gold_defensive_rotation.md`](docs/research/hk_dividend_gold_defensive_rotation.md) - [`docs/research/hk_global_etf_tactical_rotation.md`](docs/research/hk_global_etf_tactical_rotation.md) - [`docs/hk_low_vol_dividend_quality_snapshot_live_enablement.md`](docs/hk_low_vol_dividend_quality_snapshot_live_enablement.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 5bc9e34..613020e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,8 +20,7 @@ | Profile | 名称 | 输入 | 基准 | 当前角色 | | --- | --- | --- | --- | --- | -| `hk_dividend_gold_defensive_rotation` | HK Dividend-Gold Defensive Rotation | `market_history` | `03110` | 当前证据下风险收益比较好的港股 ETF runtime profile。 | -| `hk_global_etf_tactical_rotation` | HK Global ETF Tactical Rotation | `market_history` | `02800` | 第二港股 ETF runtime profile,覆盖更宽 ETF 范围,产品检查也更重。 | +| `hk_global_etf_tactical_rotation` | HK Global ETF Tactical Rotation | `market_history` | `02800` | 保留的港股 ETF runtime profile,覆盖更宽 ETF 范围,产品检查也更重。 | ### Snapshot-backed runtime 策略 @@ -62,9 +61,9 @@ python -m pytest -q ```bash python scripts/print_hk_live_enablement_matrix.py --json -python scripts/print_hk_runtime_readiness.py --profile hk_dividend_gold_defensive_rotation --platform longbridge --json +python scripts/print_hk_runtime_readiness.py --profile hk_global_etf_tactical_rotation --platform longbridge --json python scripts/print_hk_runtime_readiness.py --profile hk_low_vol_dividend_quality_snapshot --platform longbridge --json -python scripts/validate_hk_runtime_live_enablement.py --print-template --profile hk_dividend_gold_defensive_rotation --platform longbridge --json +python scripts/validate_hk_runtime_live_enablement.py --print-template --profile hk_global_etf_tactical_rotation --platform longbridge --json ``` 普通 ETF 轮动路径的本地 smoke: @@ -101,7 +100,6 @@ python scripts/smoke_hk_global_etf_tactical_rotation_dry_run.py --json - [`docs/platform_integration.md`](docs/platform_integration.md) - [`docs/research/hk_strategy_selection_20260603.md`](docs/research/hk_strategy_selection_20260603.md) -- [`docs/research/hk_dividend_gold_defensive_rotation.md`](docs/research/hk_dividend_gold_defensive_rotation.md) - [`docs/research/hk_global_etf_tactical_rotation.md`](docs/research/hk_global_etf_tactical_rotation.md) - [`docs/hk_low_vol_dividend_quality_snapshot_live_enablement.zh-CN.md`](docs/hk_low_vol_dividend_quality_snapshot_live_enablement.zh-CN.md) diff --git a/docs/platform_integration.md b/docs/platform_integration.md index c8a6ffe..d0a0f60 100644 --- a/docs/platform_integration.md +++ b/docs/platform_integration.md @@ -9,8 +9,7 @@ Runtime catalog profiles declare structural support for: | Profile | Input | Runtime status | Notes | | --- | --- | --- | --- | -| `hk_dividend_gold_defensive_rotation` | `market_history` | `runtime_enabled` | Preferred lower-drawdown non-snapshot candidate; 02840/03110 only. | -| `hk_global_etf_tactical_rotation` | `market_history` | `runtime_enabled` | Secondary ETF-rotation candidate; broader ETF product checks required. | +| `hk_global_etf_tactical_rotation` | `market_history` | `runtime_enabled` | Retained ETF-rotation candidate; broader ETF product checks required. | | `hk_low_vol_dividend_quality_snapshot` | `feature_snapshot` + manifest | `runtime_enabled` | First retained snapshot-backed profile; artifact generation stays in `HkEquitySnapshotPipelines`. | Platforms should expose only `get_runtime_enabled_profiles()` as selectable runtime targets. @@ -21,7 +20,7 @@ Use the strategy-package matrix as the machine-readable source for platform UI/s ```bash python scripts/print_hk_live_enablement_matrix.py --json -python scripts/print_hk_live_enablement_matrix.py --profile hk_dividend_gold_defensive_rotation --json +python scripts/print_hk_live_enablement_matrix.py --profile hk_global_etf_tactical_rotation --json python scripts/print_hk_live_enablement_matrix.py --profile hk_low_vol_dividend_quality_snapshot --json ``` @@ -73,7 +72,6 @@ LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH= Do not set `STRATEGY_PROFILE` to rejected research or removed snapshot scaffold profiles in Cloud Run. Current allowed strategy-package profiles are: -- `hk_dividend_gold_defensive_rotation` - `hk_global_etf_tactical_rotation` - `hk_low_vol_dividend_quality_snapshot` @@ -84,7 +82,7 @@ Dry-run versus live execution remains a platform runtime setting. Strategy-packa Before removing dry-run, every profile must pass the packaged evidence validator and platform checks: ```bash -python scripts/validate_hk_runtime_live_enablement.py --print-template --profile hk_dividend_gold_defensive_rotation --platform longbridge --json +python scripts/validate_hk_runtime_live_enablement.py --print-template --profile hk_global_etf_tactical_rotation --platform longbridge --json python scripts/validate_hk_runtime_live_enablement.py --print-template --profile hk_low_vol_dividend_quality_snapshot --platform longbridge --json ``` @@ -101,6 +99,5 @@ Required evidence includes: ## Profile notes -- `hk_dividend_gold_defensive_rotation`: uses direct `market_history` for `02840` and `03110`; no snapshot artifact required. - `hk_global_etf_tactical_rotation`: uses direct `market_history` for the HK-listed ETF universe; every ETF sleeve needs issuer/product/NAV/iNAV/spread/permission review before dry-run removal. - `hk_low_vol_dividend_quality_snapshot`: requires a published factor snapshot and manifest from `HkEquitySnapshotPipelines`; real orders require artifact validation plus runtime evidence. diff --git a/docs/research/hk_dividend_gold_defensive_rotation.md b/docs/research/hk_dividend_gold_defensive_rotation.md deleted file mode 100644 index c0a5f39..0000000 --- a/docs/research/hk_dividend_gold_defensive_rotation.md +++ /dev/null @@ -1,144 +0,0 @@ -# HK High Dividend Low-Volatility Trend Runtime Profile - -## Scope - -This research evaluates a simple non-snapshot `hk_equity` candidate: -`hk_dividend_gold_defensive_rotation`. - -The strategy rotates between two HK-listed defensive sleeves: - -- `03110` / `3110.HK`: Global X Hang Seng High Dividend Yield ETF, used as the HK high-dividend equity sleeve. -- `02840` / `2840.HK`: SPDR Gold Shares, used as the defensive diversifier. - -The 12% volatility-targeted version is registered as a runtime catalog profile. -The result is strong, but it is still heavily influenced by the 2024-2026 -gold/high-dividend regime, so platform deployments should start in dry-run or -paper mode and validate broker execution before real orders. - -## Data and methodology - -- Price source: Yahoo Finance adjusted daily close via `yfinance` for `2840.HK` and `3110.HK`. -- Sample used for scoring: `2021-09-01` to `2026-05-29`. -- Cost assumption: 10 bps per 100% turnover. -- Split discipline: - - Train / parameter selection: `2021-09-01` to `2023-12-29`. - - Out-of-sample check: `2024-01-01` to `2026-05-29`. -- The parameter grid was deliberately small: 63/126/252 day momentum, 100/150/200 day trend windows, high-dividend/gold pair variants, inverse-volatility or top-1 weighting, and 10%/12%/16% volatility caps. -- Selected live-enabled version favors positive train-period return, drawdown control, and simple explainability rather than maximum out-of-sample return. - -Reference pages: - -- Global X Hang Seng High Dividend Yield ETF `3110`: https://www.globalxetfs.com.hk/funds/hang-seng-high-dividend-yield-etf/ -- Hang Seng High Dividend Yield Index factsheet: https://www.hsi.com.hk/static/uploads/contents/en/dl_centre/factsheets/hshdyie.pdf -- Hang Seng High Dividend Yield Index methodology: https://www.hsi.com.hk/static/uploads/contents/en/dl_centre/methodologies/IM_hshdyie.pdf -- SPDR Gold Shares `2840`: https://www.ssga.com/hk/en/institutional/etfs/funds/spdr-gold-shares-2840 -- SPDR Gold Shares factsheet: https://www.ssga.com/library-content/products/factsheets/etfs/apac/factsheet-hk-en-2840.pdf -- SPDR Gold Shares HK financial information / NAV source: https://www.spdrgoldshares.com/hong-kong/english/financial-information/ - -## Selected version - -Current runtime defaults: - -| Parameter | Value | -| --- | ---: | -| Rebalance review | monthly | -| Momentum window | 63 trading days | -| Trend filter | price above 100-day moving average | -| Volatility window | 63 trading days | -| Selected ETFs | top 2 eligible ETFs | -| Minimum momentum | > 0 | -| Weighting | inverse volatility | -| Target annual volatility | 12% | -| Maximum gross exposure | 100% | -| Cost assumption | 10 bps turnover | - -Signal summary: - -- Compute 63-day momentum, 100-day trend, and 63-day annualized volatility for `02840` and `03110`. -- Eligible ETFs must have positive momentum and trade above the trend moving average. -- Rank eligible ETFs by momentum divided by volatility. -- Hold up to two ETFs using inverse-volatility weights. -- Scale exposure down when the trailing 63-day realized portfolio volatility is above the 12% target. -- If no ETF is eligible, hold cash. - -## Backtest results - -Strategy metrics from `scripts/research_hk_dividend_gold_defensive_rotation_backtest.py`: - -| Period | Annualized return | Max drawdown | Total return | -| --- | ---: | ---: | ---: | -| Full sample, 2021-09-01 to 2026-05-29 | 17.16% | -8.06% | 107.41% | -| Train, 2021-09-01 to 2023-12-29 | 3.18% | -7.70% | 7.37% | -| Out-of-sample, 2024-01-01 to 2026-05-29 | 32.54% | -8.06% | 93.17% | -| Trailing 1Y, 2025-05-30 to 2026-05-29 | 34.53% | -7.32% | 33.43% | -| Trailing 3Y, 2023-05-30 to 2026-05-29 | 26.21% | -8.06% | 97.19% | -| 2022 | -0.60% | -7.70% | -0.58% | -| 2023 | 8.41% | -7.70% | 8.09% | -| 2024 | 26.56% | -8.06% | 25.86% | -| 2025 | 42.70% | -7.12% | 41.30% | -| 2026 YTD to 2026-05-29 | 23.72% | -7.32% | 8.63% | - -Benchmarks over the full sample: - -| Benchmark | Annualized return | Max drawdown | Total return | -| --- | ---: | ---: | ---: | -| `03110` high-dividend ETF buy-and-hold | 11.10% | -32.75% | 62.42% | -| `02840` gold ETF buy-and-hold | 21.76% | -23.34% | 147.67% | -| Static 50/50 `02840` / `03110` | 17.16% | -23.23% | 107.44% | - -Other diagnostics: - -| Metric | Value | -| --- | ---: | -| Average gross exposure | 62.96% | -| Average daily turnover | 1.34% | -| Latest target weights on 2026-05-29 | `03110`: 70.81%, cash: 29.19% | - -## 中文研究结论 - -- 12% 波动率目标版本全样本年化 17.16%,最大回撤 -8.06%,比未加波动率目标版本牺牲少量收益但明显降低回撤。 -- 训练期 2021-2023 保持正收益 3.18%,比已删除的 broad ETF baseline 训练期表现更稳。 -- 相比单持 `03110`,它显著降低最大回撤;相比单持黄金,它牺牲部分收益换取更低回撤。 -- 风险是样本仍短,且 2024-2026 对黄金和高股息都非常友好,不能直接推断长期稳定;runtime enablement 不等于直接实盘下单。 - -## Decision - -Promote the 12% volatility-targeted version to `runtime_enabled`. - -Reasons to promote: - -- Full-sample return, train-period return, and drawdown are all materially better than the simple HK equity benchmarks. -- The 12% volatility target reduced full-sample max drawdown from roughly -11.28% to -8.06%. -- Turnover is low enough for HK's higher fee/spread environment. -- The implementation uses the same direct `market_history` and weight-target contract as existing non-snapshot HK runtime profiles. -- The two-ETF universe is operationally simpler than the removed broader ETF baseline. - -Risks that still require platform validation: - -- The result depends on only two ETFs and a short regime window. -- Gold exposure contributed heavily to the defensive profile; validate behavior in non-gold-led markets. -- ETF-specific spread, lot size, dividend treatment, and platform tradability must be validated per broker. -- Cash handling must be validated with both IBKR and LongBridge if no ETF passes the filter. - -Requirements before real-money trading: - -1. Validate platform `market_history` feed for `02840` and `03110` on both IBKR and LongBridge. -2. Re-run with broker-realistic fees, spreads/slippage, lot sizes, trading suspensions, and dividend treatment. -3. Add paper-trading evidence across at least one additional regime. -4. Keep platform dry-run enabled until order preview confirms symbol tradability, currency, cash residual, and order sizing. - -## Product due-diligence additions - -The live-enable evidence pack must now prove product-level lineage, not just -symbol tradability: - -- For `03110`, archive current Global X product documents, the Hang Seng High - Dividend Yield Index factsheet/methodology, NAV/iNAV evidence, distribution - policy, and concentration / yield-trap review. -- For `02840`, archive current SSGA/SPDR product documents, NAV/iNAV and - tracking-difference evidence, multi-counter currency handling, USD - creation/redemption handling, and physical-gold single-asset / trust-storage - risk review. -- For both ETFs, archive HKEX market-maker or liquidity-provider evidence, - stamp-duty / ETF tax treatment, Stock Connect ETF eligibility or sell-only status, Southbound ETF turnover/fund-flow trend, broker Southbound ETF buy-route availability, broker product permission, board lot, trading - currency, and product-document freshness before dry-run can be removed. diff --git a/docs/research/hk_global_etf_tactical_rotation.md b/docs/research/hk_global_etf_tactical_rotation.md index 803b995..db8850e 100644 --- a/docs/research/hk_global_etf_tactical_rotation.md +++ b/docs/research/hk_global_etf_tactical_rotation.md @@ -191,5 +191,4 @@ prove product-level evidence for each sleeve: margin, curve / contango / backwardation risk, non-correlation with spot oil, and explicit operator suitability review. If any of these fail, remove the ETF via universe override and rerun the backtest/readiness pack. -- `02840` and `03110`: reuse the high-dividend/gold product checks from - `hk_dividend_gold_defensive_rotation`. +- `02840` and `03110`: require current gold ETF and high-dividend ETF product checks, including issuer documents, NAV/iNAV, tracking quality, distribution treatment, spread/depth, lot size, market-maker/liquidity evidence, ETF Connect / Southbound route status, broker permissions, and product-document freshness. diff --git a/docs/research/hk_quant_strategy_ideas.md b/docs/research/hk_quant_strategy_ideas.md index d21f250..86a6514 100644 --- a/docs/research/hk_quant_strategy_ideas.md +++ b/docs/research/hk_quant_strategy_ideas.md @@ -10,16 +10,15 @@ Use [`hk_strategy_selection_20260603.md`](./hk_strategy_selection_20260603.md) a Retained profiles: -1. `hk_dividend_gold_defensive_rotation` - preferred non-snapshot runtime candidate because it has the best current return-to-drawdown profile. -2. `hk_global_etf_tactical_rotation` - secondary non-snapshot runtime candidate with higher annualized return but more ETF product-complexity risk. -3. `hk_low_vol_dividend_quality_snapshot` - only retained snapshot-backed candidate; still requires production point-in-time evidence before real orders. +1. `hk_global_etf_tactical_rotation` - retained non-snapshot runtime candidate with broader ETF exposure and product-complexity risk. +2. `hk_low_vol_dividend_quality_snapshot` - only retained snapshot-backed candidate; still requires production point-in-time evidence before real orders. ## Ideas not kept in the package surface The following ideas should not appear as runtime catalog entries, snapshot contracts, sample builders, or platform-selectable profiles unless a future research PR adds fresh point-in-time data and passes the current gates: - HSI/HSTECH mean reversion and leveraged mean-reversion variants; -- broad ETF regime-rotation baseline superseded by the two retained ETF strategies; +- broad ETF regime-rotation baseline superseded by the retained ETF strategy; - shareholder-yield, free-cash-flow, residual/liquid momentum, composite QVM, factor-mix, AH-premium, Southbound-flow, event/rebalance and central-SOE snapshot scaffolds; - raw overlay ideas involving short selling, derivatives, margin financing, governance events, connected transactions, suspensions, ESG, analyst dispersion, liquidity premia or macro timing. diff --git a/docs/research/hk_strategy_selection_20260603.md b/docs/research/hk_strategy_selection_20260603.md index b840303..b985baa 100644 --- a/docs/research/hk_strategy_selection_20260603.md +++ b/docs/research/hk_strategy_selection_20260603.md @@ -18,19 +18,18 @@ A strategy can stay in the runtime or snapshot package surface only when it has | Rank | Profile | Type | Full annualized return | Full max drawdown | Train annualized return | OOS annualized return | Decision | | ---: | --- | --- | ---: | ---: | ---: | ---: | --- | -| 1 | `hk_dividend_gold_defensive_rotation` | non-snapshot runtime | 17.16% | -8.06% | 3.18% | 32.54% | Keep as preferred risk-adjusted runtime candidate. | -| 2 | `hk_global_etf_tactical_rotation` | non-snapshot runtime | 18.84% | -20.51% | 3.69% | 35.62% | Keep as higher-return secondary runtime candidate; product checks are heavier. | -| 3 | `hk_low_vol_dividend_quality_snapshot` | snapshot-backed runtime | 13.34% | -23.05% | needs production PIT rerun | needs production PIT rerun | Keep as the only retained snapshot profile; artifact evidence remains mandatory. | +| 1 | `hk_global_etf_tactical_rotation` | non-snapshot runtime | 18.84% | -20.51% | 3.69% | 35.62% | Keep as the retained direct market-history runtime candidate; product checks are heavier. | +| 2 | `hk_low_vol_dividend_quality_snapshot` | snapshot-backed runtime | 13.34% | -23.05% | needs production PIT rerun | needs production PIT rerun | Keep as the only retained snapshot profile; artifact evidence remains mandatory. | -Why rank `hk_dividend_gold_defensive_rotation` above `hk_global_etf_tactical_rotation`: the global ETF strategy has higher annualized return, but the high-dividend/gold strategy has much lower drawdown and a better return-to-drawdown ratio. That makes it the safer first live-enable candidate. +`hk_dividend_gold_defensive_rotation` was removed after review because its strong result was heavily dependent on the 2024-2026 gold/high-dividend regime and the fixed two-ETF universe looked too hindsight-selected for live promotion. ## Rejected ordinary-strategy candidates | Candidate | Latest evidence | Decision | | --- | --- | --- | | HSI/HSTECH mean reversion | Full annualized return 0.72%, max drawdown -47.58%; the leveraged relative-pair variants had negative full/OOS annualized returns. | Removed from the package surface and source tree. | -| Broad six-ETF regime rotation baseline | Full annualized return 13.55%, max drawdown -21.56%, but train annualized return -7.24%. | Removed as a public profile. Its useful ETF-rotation primitives were retained internally for the two promoted ETF strategies. | -| Unscaled high-dividend/gold pair variant | Full annualized return 17.94%, max drawdown -11.28%. | Not promoted separately; the 12% volatility-targeted implementation gives a better return-to-drawdown ratio with only a small return sacrifice. | +| Broad six-ETF regime rotation baseline | Full annualized return 13.55%, max drawdown -21.56%, but train annualized return -7.24%. | Removed as a public profile; reusable ETF-rotation primitives remain in `hk_global_etf_tactical_rotation`. | +| High-dividend/gold pair variants | Full-sample metrics looked strong, but train-period return was weak and OOS performance concentrated in the favorable 2024-2026 gold/high-dividend regime. | Removed from runtime surface and source tree to avoid hindsight-selected two-ETF overfit. | ## Rejected snapshot candidates @@ -43,11 +42,10 @@ Why rank `hk_dividend_gold_defensive_rotation` above `hk_global_etf_tactical_rot ## Verification commands used ```bash -PYTHONPATH=src .venv/bin/python scripts/research_hk_dividend_gold_defensive_rotation_backtest.py --json-output data/output/hk_strategy_selection_20260603/high_dividend_low_vol_trend.json PYTHONPATH=src .venv/bin/python scripts/research_hk_global_etf_tactical_rotation_backtest.py --json-output data/output/hk_strategy_selection_20260603/listed_global_etf_rotation.json ``` -The rejected mean-reversion and broad-baseline metrics were captured in this pruning run before deleting those research entrypoints. The retained backtest scripts remain in this repository for future reruns. +The rejected mean-reversion, broad-baseline, and high-dividend/gold metrics were captured before deleting those research entrypoints. The retained backtest scripts remain in this repository for future reruns. ## Operational status diff --git a/scripts/research_hk_dividend_gold_defensive_rotation_backtest.py b/scripts/research_hk_dividend_gold_defensive_rotation_backtest.py deleted file mode 100755 index a11972f..0000000 --- a/scripts/research_hk_dividend_gold_defensive_rotation_backtest.py +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import math -from dataclasses import asdict, dataclass -from pathlib import Path -from typing import Any - -import pandas as pd - -from hk_equity_strategies.strategies import hk_dividend_gold_defensive_rotation as strategy - -YAHOO_SYMBOLS = { - "02840": "2840.HK", - "03110": "3110.HK", -} - - -@dataclass(frozen=True) -class BacktestConfig: - start: str = "2020-08-27" - end: str = "2026-06-01" - analysis_start: str = "2021-09-01" - cost_bps: float = 10.0 - train_end: str = "2023-12-29" - oos_start: str = "2024-01-01" - - -def _download_close(config: BacktestConfig) -> pd.DataFrame: - try: - import yfinance as yf - except Exception as exc: # pragma: no cover - research helper only - raise SystemExit("yfinance is required for this research script; install it outside production deps") from exc - raw = yf.download( - list(YAHOO_SYMBOLS.values()), - start=config.start, - end=config.end, - auto_adjust=True, - progress=False, - threads=False, - ) - close = raw["Close"].rename(columns={yahoo: symbol for symbol, yahoo in YAHOO_SYMBOLS.items()}) - close = close.loc[:, list(strategy.DEFAULT_UNIVERSE_SYMBOLS)].ffill().dropna(how="any") - return close - - -def _market_history_until(close: pd.DataFrame, as_of: pd.Timestamp) -> pd.DataFrame: - return ( - close.loc[:as_of] - .reset_index(names="date") - .melt(id_vars="date", var_name="symbol", value_name="close") - ) - - -def _metrics(returns: pd.Series) -> dict[str, float | int]: - returns = returns.dropna() - if returns.empty: - return { - "days": 0, - "annual_return": 0.0, - "max_drawdown": 0.0, - "annual_volatility": 0.0, - "total_return": 0.0, - } - equity = (1.0 + returns).cumprod() - years = len(returns) / 252.0 - drawdown = equity / equity.cummax() - 1.0 - return { - "days": int(len(returns)), - "annual_return": float(equity.iloc[-1] ** (1 / years) - 1) if years > 0 else 0.0, - "max_drawdown": float(drawdown.min()), - "annual_volatility": float(returns.std(ddof=0) * math.sqrt(252)), - "total_return": float(equity.iloc[-1] - 1.0), - } - - -def _target_weights(close: pd.DataFrame, **strategy_kwargs: Any) -> pd.DataFrame: - month_end_dates = close.resample("ME").last().index - min_history_days = int(strategy_kwargs.get("min_history_days") or strategy.DEFAULT_MIN_HISTORY_DAYS) - rows: list[dict[str, Any]] = [] - for target_date in month_end_dates: - position = close.index.searchsorted(target_date, side="right") - 1 - if position < 0: - continue - as_of = pd.Timestamp(close.index[position]) - partial_history = _market_history_until(close, as_of) - if partial_history["date"].nunique() < min_history_days: - weights: dict[str, float] = {} - metadata = {"signal_state": "warmup", "selected_symbols": (), "cash_weight": 1.0} - else: - weights, metadata = strategy.build_target_weights(partial_history, **strategy_kwargs) - rows.append( - { - "date": as_of, - **{symbol: float(weights.get(symbol, 0.0)) for symbol in strategy.DEFAULT_UNIVERSE_SYMBOLS}, - "signal_state": metadata["signal_state"], - "selected_symbols": ",".join(metadata["selected_symbols"]), - "cash_weight": float(metadata["cash_weight"]), - } - ) - targets = pd.DataFrame(rows).set_index("date") - targets = targets.reindex(close.index, method="ffill").fillna(0.0) - return targets[list(strategy.DEFAULT_UNIVERSE_SYMBOLS)].shift(1).fillna(0.0) - - -def _strategy_returns( - close: pd.DataFrame, - *, - cost_bps: float, - **strategy_kwargs: Any, -) -> tuple[pd.Series, pd.DataFrame]: - returns = close.pct_change().fillna(0.0) - targets = _target_weights(close, **strategy_kwargs) - turnover = targets.diff().abs().sum(axis=1).fillna(0.0) - net = (targets * returns).sum(axis=1) - turnover * float(cost_bps) / 10_000.0 - return net, targets - - -def _slice(series: pd.Series, start: str | None, end: str | None) -> pd.Series: - output = series - if start: - output = output.loc[pd.Timestamp(start) :] - if end: - output = output.loc[: pd.Timestamp(end)] - return output - - -def run(config: BacktestConfig) -> dict[str, Any]: - raw_close = _download_close(config) - strategy_returns, targets = _strategy_returns(raw_close, cost_bps=config.cost_bps) - strategy_returns = strategy_returns.loc[pd.Timestamp(config.analysis_start) :] - targets = targets.loc[strategy_returns.index] - close = raw_close.loc[strategy_returns.index] - benchmark_returns = { - "strategy": strategy_returns, - "gold_etf_02840": close[strategy.GOLD_ETF_SYMBOL].pct_change().fillna(0.0), - "high_dividend_etf_03110": close[strategy.HIGH_DIVIDEND_ETF_SYMBOL].pct_change().fillna(0.0), - "static_50_50": close.pct_change().fillna(0.0).mean(axis=1), - } - periods = { - "full": (None, None), - "train_2021_2023": (config.analysis_start, config.train_end), - "oos_2024_2026": (config.oos_start, "2026-05-29"), - "trailing_1y": ("2025-05-30", "2026-05-29"), - "trailing_3y": ("2023-05-30", "2026-05-29"), - "2021": ("2021-01-01", "2021-12-31"), - "2022": ("2022-01-01", "2022-12-31"), - "2023": ("2023-01-01", "2023-12-31"), - "2024": ("2024-01-01", "2024-12-31"), - "2025": ("2025-01-01", "2025-12-31"), - "2026_ytd": ("2026-01-01", "2026-05-29"), - } - variant_configs: dict[str, dict[str, Any]] = { - "unscaled_high_dividend_gold_pair": {"target_annual_volatility": None}, - } - variant_payload: dict[str, dict[str, Any]] = {} - for name, kwargs in variant_configs.items(): - variant_returns, variant_targets = _strategy_returns(raw_close, cost_bps=config.cost_bps, **kwargs) - variant_returns = variant_returns.loc[pd.Timestamp(config.analysis_start) :] - variant_targets = variant_targets.loc[variant_returns.index] - variant_payload[name] = { - "config": kwargs, - "data": { - "last_weights": variant_targets.tail(1).to_dict("records")[0], - "average_gross_exposure": float(variant_targets.sum(axis=1).mean()), - "average_daily_turnover": float(variant_targets.diff().abs().sum(axis=1).mean()), - }, - "metrics": {period: _metrics(_slice(variant_returns, start, end)) for period, (start, end) in periods.items()}, - } - return { - "config": asdict(config), - "yahoo_symbols": YAHOO_SYMBOLS, - "strategy_defaults": { - "universe_symbols": strategy.DEFAULT_UNIVERSE_SYMBOLS, - "momentum_window_days": strategy.DEFAULT_MOMENTUM_WINDOW_DAYS, - "trend_window_days": strategy.DEFAULT_TREND_WINDOW_DAYS, - "volatility_window_days": strategy.DEFAULT_VOLATILITY_WINDOW_DAYS, - "top_n": strategy.DEFAULT_TOP_N, - "weighting_mode": strategy.DEFAULT_WEIGHTING_MODE, - "target_annual_volatility": strategy.DEFAULT_TARGET_ANNUAL_VOLATILITY, - "max_gross_exposure": strategy.DEFAULT_MAX_GROSS_EXPOSURE, - "rebalance_frequency": strategy.DEFAULT_REBALANCE_FREQUENCY, - "cost_bps": config.cost_bps, - }, - "data": { - "start": close.index.min().date().isoformat(), - "end": close.index.max().date().isoformat(), - "rows": int(len(close)), - "last_weights": targets.tail(1).to_dict("records")[0], - "average_gross_exposure": float(targets.sum(axis=1).mean()), - "average_daily_turnover": float(targets.diff().abs().sum(axis=1).mean()), - }, - "metrics": { - name: {period: _metrics(_slice(series, start, end)) for period, (start, end) in periods.items()} - for name, series in benchmark_returns.items() - }, - "variant_metrics": variant_payload, - } - - -def main() -> None: - parser = argparse.ArgumentParser(description="Backtest HK high-dividend low-volatility trend research candidate.") - parser.add_argument("--json-output", type=Path) - args = parser.parse_args() - payload = run(BacktestConfig()) - text = json.dumps(payload, indent=2, sort_keys=True) - if args.json_output: - args.json_output.write_text(text + "\n") - print(text) - - -if __name__ == "__main__": - main() diff --git a/src/hk_equity_strategies/__init__.py b/src/hk_equity_strategies/__init__.py index e2b7b93..b5a3181 100644 --- a/src/hk_equity_strategies/__init__.py +++ b/src/hk_equity_strategies/__init__.py @@ -3,7 +3,6 @@ __all__ = [ "HK_EQUITY_DOMAIN", "HK_EXTERNAL_SNAPSHOT_SCAFFOLD_PROFILES", - "HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE", "HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE", "HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE", "HK_DIRECT_MARKET_HISTORY_PROFILES", @@ -41,7 +40,6 @@ def __getattr__(name: str): if name in { "HK_EQUITY_DOMAIN", "HK_EXTERNAL_SNAPSHOT_SCAFFOLD_PROFILES", - "HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE", "HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE", "HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE", "HK_DIRECT_MARKET_HISTORY_PROFILES", diff --git a/src/hk_equity_strategies/catalog.py b/src/hk_equity_strategies/catalog.py index d16cd48..d0e08bf 100644 --- a/src/hk_equity_strategies/catalog.py +++ b/src/hk_equity_strategies/catalog.py @@ -15,19 +15,16 @@ normalize_profile_name as qpk_normalize_profile_name, ) -from hk_equity_strategies.strategies import hk_dividend_gold_defensive_rotation as high_dividend_strategy from hk_equity_strategies.strategies import hk_global_etf_tactical_rotation as global_etf_strategy from hk_equity_strategies.strategies import hk_low_vol_dividend_quality_snapshot as low_vol_dividend_strategy HK_EQUITY_DOMAIN = global_etf_strategy.HK_EQUITY_DOMAIN HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE = global_etf_strategy.PROFILE_NAME -HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE = high_dividend_strategy.PROFILE_NAME HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE = low_vol_dividend_strategy.PROFILE_NAME HK_DIRECT_MARKET_HISTORY_PROFILES = frozenset( { HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, } ) HK_SNAPSHOT_BACKED_PROFILES = frozenset({HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE}) @@ -36,13 +33,11 @@ STRATEGY_PLATFORM_COMPATIBILITY: dict[str, frozenset[str]] = { HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE: frozenset({"ibkr", "longbridge"}), - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE: frozenset({"ibkr", "longbridge"}), HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: frozenset({"ibkr", "longbridge"}), } STRATEGY_REQUIRED_INPUTS: dict[str, frozenset[str]] = { HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE: frozenset({"market_history"}), - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE: frozenset({"market_history"}), HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: frozenset({"feature_snapshot"}), } @@ -61,20 +56,6 @@ "min_history_days": global_etf_strategy.DEFAULT_MIN_HISTORY_DAYS, "execution_cash_reserve_ratio": global_etf_strategy.DEFAULT_EXECUTION_CASH_RESERVE_RATIO, }, - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE: { - "universe_symbols": high_dividend_strategy.DEFAULT_UNIVERSE_SYMBOLS, - "momentum_window_days": high_dividend_strategy.DEFAULT_MOMENTUM_WINDOW_DAYS, - "trend_window_days": high_dividend_strategy.DEFAULT_TREND_WINDOW_DAYS, - "volatility_window_days": high_dividend_strategy.DEFAULT_VOLATILITY_WINDOW_DAYS, - "top_n": high_dividend_strategy.DEFAULT_TOP_N, - "min_momentum": high_dividend_strategy.DEFAULT_MIN_MOMENTUM, - "rebalance_frequency": high_dividend_strategy.DEFAULT_REBALANCE_FREQUENCY, - "weighting_mode": high_dividend_strategy.DEFAULT_WEIGHTING_MODE, - "target_annual_volatility": high_dividend_strategy.DEFAULT_TARGET_ANNUAL_VOLATILITY, - "max_gross_exposure": high_dividend_strategy.DEFAULT_MAX_GROSS_EXPOSURE, - "min_history_days": high_dividend_strategy.DEFAULT_MIN_HISTORY_DAYS, - "execution_cash_reserve_ratio": high_dividend_strategy.DEFAULT_EXECUTION_CASH_RESERVE_RATIO, - }, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: { "safe_haven": low_vol_dividend_strategy.SAFE_HAVEN, "holdings_count": low_vol_dividend_strategy.DEFAULT_HOLDINGS_COUNT, @@ -101,13 +82,11 @@ STRATEGY_ENTRYPOINT_ATTRIBUTES: dict[str, str] = { HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE: "hk_global_etf_tactical_rotation_entrypoint", - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE: "hk_dividend_gold_defensive_rotation_entrypoint", HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: "hk_low_vol_dividend_quality_snapshot_entrypoint", } STRATEGY_TARGET_MODES: dict[str, str] = { HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE: "weight", - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE: "weight", HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: "weight", } @@ -146,11 +125,6 @@ def _build_strategy_definition( component_name="signal_logic", module_path="hk_equity_strategies.strategies.hk_global_etf_tactical_rotation", ), - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE: _build_strategy_definition( - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, - component_name="signal_logic", - module_path="hk_equity_strategies.strategies.hk_dividend_gold_defensive_rotation", - ), HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: _build_strategy_definition( HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, component_name="signal_logic", @@ -173,20 +147,6 @@ def _build_strategy_definition( role="hk_non_snapshot_global_etf_rotation", status="runtime_enabled", ), - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE: StrategyMetadata( - canonical_profile=HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, - display_name="HK Dividend-Gold Defensive Rotation", - description=( - "Runtime-enabled monthly trend rotation between HK-listed high-dividend and gold ETFs " - "with a 12% annual volatility target." - ), - aliases=(), - cadence="monthly review", - asset_scope="hk_high_dividend_gold_etfs", - benchmark="03110", - role="hk_non_snapshot_high_dividend_low_vol_trend", - status="runtime_enabled", - ), HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: StrategyMetadata( canonical_profile=HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, display_name="HK Low-Vol Dividend Quality Snapshot", diff --git a/src/hk_equity_strategies/entrypoints/__init__.py b/src/hk_equity_strategies/entrypoints/__init__.py index 80c0a7c..b34bfcf 100644 --- a/src/hk_equity_strategies/entrypoints/__init__.py +++ b/src/hk_equity_strategies/entrypoints/__init__.py @@ -3,11 +3,9 @@ from quant_platform_kit.strategy_contracts import CallableStrategyEntrypoint, StrategyContext, StrategyDecision from hk_equity_strategies.manifests import ( - hk_dividend_gold_defensive_rotation_manifest, hk_global_etf_tactical_rotation_manifest, hk_low_vol_dividend_quality_snapshot_manifest, ) -from hk_equity_strategies.strategies import hk_dividend_gold_defensive_rotation as high_dividend_strategy from hk_equity_strategies.strategies import hk_global_etf_tactical_rotation as global_etf_strategy from hk_equity_strategies.strategies import hk_low_vol_dividend_quality_snapshot as low_vol_dividend_strategy @@ -46,38 +44,6 @@ def evaluate_hk_global_etf_tactical_rotation(ctx: StrategyContext) -> StrategyDe ) -def evaluate_hk_dividend_gold_defensive_rotation(ctx: StrategyContext) -> StrategyDecision: - config = merge_runtime_config(hk_dividend_gold_defensive_rotation_manifest.default_config, ctx) - config.pop("execution_cash_reserve_ratio", None) - config.pop("rebalance_frequency", None) - weights, signal_desc, has_cash_residual, status_desc, metadata = high_dividend_strategy.compute_signals( - require_market_data(ctx, "market_history"), - get_current_holdings(ctx), - **config, - ) - diagnostics = { - **metadata, - "signal_description": signal_desc, - "status_description": status_desc, - "signal_source": high_dividend_strategy.SIGNAL_SOURCE, - "actionable": True, - } - risk_flags: tuple[str, ...] = () - if has_cash_residual: - risk_flags += ("cash_residual",) - return StrategyDecision( - positions=weights_to_positions(weights), - risk_flags=risk_flags, - diagnostics=diagnostics, - ) - - -hk_dividend_gold_defensive_rotation_entrypoint = CallableStrategyEntrypoint( - manifest=hk_dividend_gold_defensive_rotation_manifest, - _evaluate=evaluate_hk_dividend_gold_defensive_rotation, -) - - def evaluate_hk_low_vol_dividend_quality_snapshot(ctx: StrategyContext) -> StrategyDecision: config = merge_runtime_config(hk_low_vol_dividend_quality_snapshot_manifest.default_config, ctx) config.pop("execution_cash_reserve_ratio", None) @@ -111,10 +77,8 @@ def evaluate_hk_low_vol_dividend_quality_snapshot(ctx: StrategyContext) -> Strat __all__ = [ - "evaluate_hk_dividend_gold_defensive_rotation", "evaluate_hk_global_etf_tactical_rotation", "evaluate_hk_low_vol_dividend_quality_snapshot", - "hk_dividend_gold_defensive_rotation_entrypoint", "hk_global_etf_tactical_rotation_entrypoint", "hk_low_vol_dividend_quality_snapshot_entrypoint", ] diff --git a/src/hk_equity_strategies/execution_capacity_policy.py b/src/hk_equity_strategies/execution_capacity_policy.py index 99ce9c4..b84a106 100644 --- a/src/hk_equity_strategies/execution_capacity_policy.py +++ b/src/hk_equity_strategies/execution_capacity_policy.py @@ -9,7 +9,6 @@ MIN_MEDIAN_DAILY_TURNOVER_HKD_BY_PROFILE: dict[str, int] = { "hk_global_etf_tactical_rotation": 20_000_000, - "hk_dividend_gold_defensive_rotation": 10_000_000, "hk_low_vol_dividend_quality_snapshot": 30_000_000, } diff --git a/src/hk_equity_strategies/live_enablement_matrix.py b/src/hk_equity_strategies/live_enablement_matrix.py index 3744b09..fa904fb 100644 --- a/src/hk_equity_strategies/live_enablement_matrix.py +++ b/src/hk_equity_strategies/live_enablement_matrix.py @@ -10,7 +10,6 @@ build_backtest_validation_policy, ) from hk_equity_strategies.catalog import ( - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, get_direct_market_history_profiles, @@ -145,32 +144,19 @@ def _common_platform_evidence_requirements(profile: str) -> tuple[str, ...]: CURATED_LIVE_ENABLEMENT_STRATEGY_RANKING: tuple[dict[str, object], ...] = ( { "rank": 1, - "profile": HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, - "profile_type": "runtime_market_history", - "decision": "keep_runtime_enabled_preferred", - "annualized_return": 0.1716, - "max_drawdown": -0.0806, - "why": ( - "Best current risk-adjusted HK runtime candidate: simple 03110/02840 universe, " - "12% volatility target, positive train period, and the lowest verified drawdown." - ), - "next_action": "Keep live-enable capable, but require broker dry-run evidence before real order submission.", - }, - { - "rank": 2, "profile": HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, "profile_type": "runtime_market_history", - "decision": "keep_runtime_enabled_secondary", + "decision": "keep_runtime_enabled_direct_market_history", "annualized_return": 0.1884, "max_drawdown": -0.2051, "why": ( - "Highest current annualized return among implemented HK strategies while staying below the 30% " - "drawdown limit; broader ETF universe improves diversification but adds product-complexity risk." + "Remaining direct market-history HK ETF rotation profile; broader ETF universe improves " + "diversification but adds product-complexity risk." ), "next_action": "Use dry-run/paper mode until every ETF sleeve has product, spread, lot-size, and platform checks.", }, { - "rank": 3, + "rank": 2, "profile": HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, "profile_type": "runtime_snapshot_backed", "decision": "keep_runtime_enabled_pending_evidence", @@ -197,7 +183,7 @@ def _common_platform_evidence_requirements(profile: str) -> tuple[str, ...]: { "profile": "hk_etf_regime_rotation", "decision": "exclude_from_live_enablement_shortlist", - "reason": "Removed as a public strategy after the 2026-06-03 rerun: the broad baseline had negative train-period annualized return; retained variants are hk_dividend_gold_defensive_rotation and hk_global_etf_tactical_rotation.", + "reason": "Removed as a public strategy after the 2026-06-03 rerun: the broad baseline had negative train-period annualized return.", }, { "profile": "snapshot_future_research_long_tail", @@ -350,26 +336,6 @@ def _common_platform_evidence_requirements(profile: str) -> tuple[str, ...]: SAMSUNG_CRUDE_OIL_FUTURES_ETF_URL = "https://www.samsungetfhk.com/en/product/3175/" RUNTIME_RESEARCH_EVIDENCE_URLS: dict[str, tuple[str, ...]] = { - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE: ( - "docs/research/hk_dividend_gold_defensive_rotation.md", - GLOBAL_X_HANG_SENG_HIGH_DIVIDEND_ETF_URL, - HSI_HIGH_DIVIDEND_YIELD_INDEX_URL, - HSI_HIGH_DIVIDEND_YIELD_FACTSHEET_URL, - HSI_HIGH_DIVIDEND_YIELD_METHODOLOGY_URL, - SSGA_SPDR_GOLD_SHARES_2840_URL, - SSGA_SPDR_GOLD_SHARES_2840_FACTSHEET_URL, - SPDR_GOLD_SHARES_HK_FINANCIAL_INFO_URL, - "https://www.hkex.com.hk/products/securities/exchange-traded-products/overview?sc_lang=en", - "https://www.hkex.com.hk/Products/Securities/Exchange-Traded-Products/Market-Makers/Overview?sc_lang=en", - HKEX_ETF_CONNECT_INCLUSION_URL, - HKEX_STOCK_CONNECT_ELIGIBLE_SECURITIES_URL, - HKEX_STOCK_CONNECT_STATISTICS_URL, - HKEX_STOCK_CONNECT_HISTORICAL_DAILY_URL, - "https://www.hkex.com.hk/-/media/HKEX-Market/Mutual-Market/Stock-Connect/Reference-Materials/Inclusion-of-ETFs-in-Stock-Connect/Inclusion_of_ETFs_in_Stock_Connect_Useful_Information_for_Issuers_Eng.pdf", - "https://www.ird.gov.hk/eng/faq/ETFs.htm", - "https://www.hkex.com.hk/News/Market-Communications/2015/150204news", - "https://www.hkex.com.hk/Services/Rules-and-Forms-and-Fees/Fees/Securities-%28Hong-Kong%29/Trading/Transaction?sc_lang=en", - ), HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE: ( "docs/research/hk_global_etf_tactical_rotation.md", TRAHK_PRODUCT_URL, @@ -397,13 +363,6 @@ def _common_platform_evidence_requirements(profile: str) -> tuple[str, ...]: } RUNTIME_PROFILE_NOTES: dict[str, tuple[str, ...]] = { - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE: ( - "Preferred lower-drawdown first HK runtime profile; keep dry-run until evidence pack passes.", - "Managed symbols are limited to 02840 and 03110, so platform validation should be simpler than broad ETF rotation.", - "03110 evidence must include current Global X product documents, Hang Seng High Dividend Yield Index methodology, NAV/iNAV, distribution and capital-distribution risk policy, and high-dividend concentration/yield-trap review.", - "02840 evidence must include current SSGA/SPDR Gold Shares product documents, NAV/iNAV, tracking difference, multi-counter currency, USD creation/redemption, and single-commodity trust/storage-risk review.", - "If either ETF is routed through Stock Connect / Southbound ETF paths, evidence must include ETF Connect eligibility or sell-only status, Southbound turnover/fund-flow trend, broker route availability, and cross-boundary settlement/holiday review.", - ), HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE: ( "Broader ETF rotation candidate with higher universe complexity; start with reduced capital if promoted after evidence.", "03175 is a crude-oil futures ETF and must be removed if product permission, spread, or suitability checks fail.", diff --git a/src/hk_equity_strategies/manifests/__init__.py b/src/hk_equity_strategies/manifests/__init__.py index bf9a750..291c119 100644 --- a/src/hk_equity_strategies/manifests/__init__.py +++ b/src/hk_equity_strategies/manifests/__init__.py @@ -2,12 +2,10 @@ from quant_platform_kit.strategy_contracts import StrategyManifest -from hk_equity_strategies.strategies import hk_dividend_gold_defensive_rotation as high_dividend_strategy from hk_equity_strategies.strategies import hk_global_etf_tactical_rotation as global_etf_strategy from hk_equity_strategies.strategies import hk_low_vol_dividend_quality_snapshot as low_vol_dividend_strategy HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE = global_etf_strategy.PROFILE_NAME -HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE = high_dividend_strategy.PROFILE_NAME HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE = low_vol_dividend_strategy.PROFILE_NAME @@ -56,31 +54,6 @@ def _manifest( }, ) -hk_dividend_gold_defensive_rotation_manifest = _manifest( - profile=HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, - display_name="HK Dividend-Gold Defensive Rotation", - description=( - "Monthly trend rotation between HK-listed high-dividend and gold ETFs " - "with a 12% annual volatility target." - ), - aliases=(), - required_inputs=frozenset({"market_history"}), - default_config={ - "universe_symbols": high_dividend_strategy.DEFAULT_UNIVERSE_SYMBOLS, - "momentum_window_days": high_dividend_strategy.DEFAULT_MOMENTUM_WINDOW_DAYS, - "trend_window_days": high_dividend_strategy.DEFAULT_TREND_WINDOW_DAYS, - "volatility_window_days": high_dividend_strategy.DEFAULT_VOLATILITY_WINDOW_DAYS, - "top_n": high_dividend_strategy.DEFAULT_TOP_N, - "min_momentum": high_dividend_strategy.DEFAULT_MIN_MOMENTUM, - "rebalance_frequency": high_dividend_strategy.DEFAULT_REBALANCE_FREQUENCY, - "weighting_mode": high_dividend_strategy.DEFAULT_WEIGHTING_MODE, - "target_annual_volatility": high_dividend_strategy.DEFAULT_TARGET_ANNUAL_VOLATILITY, - "max_gross_exposure": high_dividend_strategy.DEFAULT_MAX_GROSS_EXPOSURE, - "min_history_days": high_dividend_strategy.DEFAULT_MIN_HISTORY_DAYS, - "execution_cash_reserve_ratio": high_dividend_strategy.DEFAULT_EXECUTION_CASH_RESERVE_RATIO, - }, -) - hk_low_vol_dividend_quality_snapshot_manifest = _manifest( profile=HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, display_name="HK Low-Vol Dividend Quality Snapshot", @@ -116,7 +89,6 @@ def _manifest( MANIFESTS = { hk_global_etf_tactical_rotation_manifest.profile: hk_global_etf_tactical_rotation_manifest, - hk_dividend_gold_defensive_rotation_manifest.profile: hk_dividend_gold_defensive_rotation_manifest, hk_low_vol_dividend_quality_snapshot_manifest.profile: hk_low_vol_dividend_quality_snapshot_manifest, } @@ -133,12 +105,10 @@ def get_strategy_manifest(profile: str) -> StrategyManifest: __all__ = [ - "HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE", "HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE", "HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE", "MANIFESTS", "get_strategy_manifest", - "hk_dividend_gold_defensive_rotation_manifest", "hk_global_etf_tactical_rotation_manifest", "hk_low_vol_dividend_quality_snapshot_manifest", ] diff --git a/src/hk_equity_strategies/runtime_adapters.py b/src/hk_equity_strategies/runtime_adapters.py index 04fed03..3a47b1f 100644 --- a/src/hk_equity_strategies/runtime_adapters.py +++ b/src/hk_equity_strategies/runtime_adapters.py @@ -8,14 +8,12 @@ ) from hk_equity_strategies.catalog import ( - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, get_strategy_definition, get_strategy_definitions, resolve_canonical_profile, ) -from hk_equity_strategies.strategies import hk_dividend_gold_defensive_rotation as high_dividend_strategy from hk_equity_strategies.strategies import hk_global_etf_tactical_rotation as global_etf_strategy from hk_equity_strategies.strategies import hk_low_vol_dividend_quality_snapshot as low_vol_dividend_strategy @@ -33,10 +31,6 @@ status_icon=global_etf_strategy.STATUS_ICON, managed_symbols_extractor=global_etf_strategy.extract_managed_symbols, ), - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE: StrategyRuntimeAdapter( - status_icon=high_dividend_strategy.STATUS_ICON, - managed_symbols_extractor=high_dividend_strategy.extract_managed_symbols, - ), HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: StrategyRuntimeAdapter( status_icon=low_vol_dividend_strategy.STATUS_ICON, available_inputs=frozenset({"feature_snapshot"}), diff --git a/src/hk_equity_strategies/runtime_readiness.py b/src/hk_equity_strategies/runtime_readiness.py index cd2e022..26bd0ce 100644 --- a/src/hk_equity_strategies/runtime_readiness.py +++ b/src/hk_equity_strategies/runtime_readiness.py @@ -20,7 +20,6 @@ from hk_equity_strategies.runtime_market_data_policy import build_runtime_market_data_policy from hk_equity_strategies.catalog import ( HK_EQUITY_DOMAIN, - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, get_runtime_enabled_profiles, @@ -165,7 +164,6 @@ def get_required_live_evidence_fields(profile: str) -> tuple[str, ...]: return tuple(dict.fromkeys(fields)) HK_DERIVATIVE_OR_COMPLEX_ETF_SYMBOLS = frozenset({"03175"}) -HK_DEFENSIVE_ETF_SYMBOLS = frozenset({"02840", "03110"}) MIN_REQUIRED_WALK_FORWARD_YEARS = 3.0 PROFILE_LIVE_ENABLEMENT_THRESHOLDS: dict[str, dict[str, float]] = { @@ -178,15 +176,6 @@ def get_required_live_evidence_fields(profile: str) -> tuple[str, ...]: "min_required_oos_fold_count": MIN_REQUIRED_OOS_FOLD_COUNT, "max_single_period_return_contribution": MAX_SINGLE_PERIOD_RETURN_CONTRIBUTION, }, - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE: { - "max_allowed_backtest_drawdown": 0.12, - "min_required_return_to_drawdown_ratio": 0.50, - "max_allowed_annualized_turnover": 1.00, - "min_required_annual_return": 0.0, - "min_required_walk_forward_years": MIN_REQUIRED_WALK_FORWARD_YEARS, - "min_required_oos_fold_count": MIN_REQUIRED_OOS_FOLD_COUNT, - "max_single_period_return_contribution": MAX_SINGLE_PERIOD_RETURN_CONTRIBUTION, - }, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: { "max_allowed_backtest_drawdown": 0.30, "min_required_return_to_drawdown_ratio": 0.50, @@ -200,7 +189,7 @@ def get_required_live_evidence_fields(profile: str) -> tuple[str, ...]: PROFILE_LIVE_OPTIMIZATION_CHECKS: dict[str, tuple[str, ...]] = { HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE: ( - "Treat this as the higher-return but broader ETF-rotation candidate; start with reduced capital versus the two-ETF defensive profile.", + "Treat this as the remaining direct market-history ETF-rotation candidate; start in dry-run/paper mode until product and execution evidence passes.", "03175 is a crude-oil futures ETF; if platform product permission, liquidity, or spread checks fail, remove it via universe override and re-run backtests/readiness.", "Verify all eight ETF symbols are supported by the selected platform feed before relying on cross-sectional ranking.", "Capture issuer factsheet/KFS/prospectus, NAV/iNAV, underlying index/reference-asset, and market-maker/liquidity-provider evidence for every ETF in the broader universe.", @@ -210,13 +199,6 @@ def get_required_live_evidence_fields(profile: str) -> tuple[str, ...]: "For 02834, audit Nasdaq trading-hour gap, US-market FX, premium/discount, capital-distribution risk, and multi-counter currency handling.", "For 03175, audit futures-based complex-product status, WTI futures roll schedule, margin, curve/contango/backwardation, non-correlation with spot oil, and operator suitability; remove it if any check fails.", ), - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE: ( - "Treat this as the preferred lower-drawdown first live HK profile because the managed universe is only 02840 and 03110.", - "Keep the documented 12% annual volatility target until a new walk-forward backtest and paper-trading window are reviewed.", - "Verify 02840 and 03110 dividend/distribution treatment, lot sizes, and bid/ask spreads before increasing exposure.", - "For 03110, audit Hang Seng High Dividend Yield Index methodology, distribution policy, capital-distribution risk, and high-dividend concentration/yield-trap risk.", - "For 02840, audit SPDR Gold Shares trust structure, physical-gold single-asset risk, NAV/iNAV, tracking difference, multi-counter currency, and USD creation/redemption handling.", - ), HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: ( "Treat this as the first snapshot-backed HK runtime profile; the strategy package consumes snapshots but does not generate them.", "Require a published hk_low_vol_dividend_quality_snapshot factor snapshot and manifest that pass HkEquitySnapshotPipelines artifact-pack validation.", @@ -254,8 +236,6 @@ def _build_risk_notes(profile: str, symbols: tuple[str, ...], *, runtime_enabled notes.append("The profile is not runtime_enabled, so live order submission must remain disabled.") if HK_DERIVATIVE_OR_COMPLEX_ETF_SYMBOLS.intersection(symbols): notes.append("03175 is a crude-oil futures ETF; confirm suitability, spread, and product permission before live use.") - if profile == HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE and HK_DEFENSIVE_ETF_SYMBOLS.issubset(symbols): - notes.append("02840/03110 is the lower-drawdown live candidate, but it still requires ETF fee, spread, and distribution checks.") if profile == HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: notes.append( "This snapshot-backed profile requires a validated factor snapshot artifact and manifest at runtime." diff --git a/src/hk_equity_strategies/strategies/hk_dividend_gold_defensive_rotation.py b/src/hk_equity_strategies/strategies/hk_dividend_gold_defensive_rotation.py deleted file mode 100644 index f375228..0000000 --- a/src/hk_equity_strategies/strategies/hk_dividend_gold_defensive_rotation.py +++ /dev/null @@ -1,218 +0,0 @@ -from __future__ import annotations - -import math -from collections.abc import Sequence -from typing import Any - -import pandas as pd - -from hk_equity_strategies.strategies.etf_rotation_core import ( - apply_portfolio_volatility_target, - build_close_matrix, - normalize_symbol, -) - -HK_EQUITY_DOMAIN = "hk_equity" -SIGNAL_SOURCE = "daily_market_history" -STATUS_ICON = "🇭🇰" -PROFILE_NAME = "hk_dividend_gold_defensive_rotation" - -GOLD_ETF_SYMBOL = "02840" -HIGH_DIVIDEND_ETF_SYMBOL = "03110" -DEFAULT_UNIVERSE_SYMBOLS = (GOLD_ETF_SYMBOL, HIGH_DIVIDEND_ETF_SYMBOL) -DEFAULT_MOMENTUM_WINDOW_DAYS = 63 -DEFAULT_TREND_WINDOW_DAYS = 100 -DEFAULT_VOLATILITY_WINDOW_DAYS = 63 -DEFAULT_TOP_N = 2 -DEFAULT_MIN_MOMENTUM = 0.0 -DEFAULT_REBALANCE_FREQUENCY = "monthly" -DEFAULT_WEIGHTING_MODE = "inverse_volatility" -DEFAULT_TARGET_ANNUAL_VOLATILITY = 0.12 -DEFAULT_MAX_GROSS_EXPOSURE = 1.0 -DEFAULT_MIN_HISTORY_DAYS = 126 -DEFAULT_EXECUTION_CASH_RESERVE_RATIO = 0.02 - - -def normalize_universe_symbols(symbols: Sequence[Any] | None = None) -> tuple[str, ...]: - raw_symbols = symbols or DEFAULT_UNIVERSE_SYMBOLS - normalized: list[str] = [] - for value in raw_symbols: - symbol = normalize_symbol(value) - if symbol and symbol not in normalized: - normalized.append(symbol) - if not normalized: - raise ValueError("universe_symbols must contain at least one valid symbol") - return tuple(normalized) - - -def _finite_float(value: Any, *, default: float = float("nan")) -> float: - try: - output = float(value) - except (TypeError, ValueError): - return default - return output if math.isfinite(output) else default - - -def compute_latest_signal( - market_history: Any, - *, - universe_symbols: Sequence[Any] | None = None, - momentum_window_days: int = DEFAULT_MOMENTUM_WINDOW_DAYS, - trend_window_days: int = DEFAULT_TREND_WINDOW_DAYS, - volatility_window_days: int = DEFAULT_VOLATILITY_WINDOW_DAYS, - top_n: int = DEFAULT_TOP_N, - min_momentum: float = DEFAULT_MIN_MOMENTUM, - weighting_mode: str = DEFAULT_WEIGHTING_MODE, - target_annual_volatility: float | None = DEFAULT_TARGET_ANNUAL_VOLATILITY, - max_gross_exposure: float = DEFAULT_MAX_GROSS_EXPOSURE, - min_history_days: int = DEFAULT_MIN_HISTORY_DAYS, -) -> dict[str, object]: - if momentum_window_days <= 1: - raise ValueError("momentum_window_days must be greater than 1") - if trend_window_days <= 1: - raise ValueError("trend_window_days must be greater than 1") - if volatility_window_days <= 1: - raise ValueError("volatility_window_days must be greater than 1") - if top_n < 1: - raise ValueError("top_n must be at least 1") - if min_history_days <= max(momentum_window_days, trend_window_days, volatility_window_days): - raise ValueError("min_history_days must be greater than all lookback windows") - if target_annual_volatility is not None and float(target_annual_volatility) <= 0.0: - raise ValueError("target_annual_volatility must be positive when set") - if float(max_gross_exposure) <= 0.0: - raise ValueError("max_gross_exposure must be positive") - - symbols = normalize_universe_symbols(universe_symbols) - close = build_close_matrix(market_history, universe_symbols=symbols) - if len(close) < int(min_history_days): - raise ValueError(f"market_history requires at least {int(min_history_days)} overlapping trading days") - - returns = close.pct_change().fillna(0.0) - momentum = close.pct_change(int(momentum_window_days)) - trend = close / close.rolling(int(trend_window_days)).mean() - 1.0 - volatility = returns.rolling(int(volatility_window_days)).std(ddof=0) * math.sqrt(252) - score = momentum / volatility.replace(0.0, pd.NA) - - latest_rows: list[dict[str, object]] = [] - for symbol in symbols: - symbol_momentum = _finite_float(momentum[symbol].iloc[-1]) - symbol_trend = _finite_float(trend[symbol].iloc[-1]) - symbol_volatility = _finite_float(volatility[symbol].iloc[-1]) - symbol_score = _finite_float(score[symbol].iloc[-1], default=float("-inf")) - eligible = ( - math.isfinite(symbol_momentum) - and math.isfinite(symbol_trend) - and math.isfinite(symbol_volatility) - and symbol_momentum > float(min_momentum) - and symbol_trend > 0.0 - and symbol_volatility > 0.0 - ) - latest_rows.append( - { - "symbol": symbol, - "momentum": symbol_momentum, - "trend": symbol_trend, - "volatility": symbol_volatility, - "score": symbol_score, - "eligible": eligible, - } - ) - - ranked = sorted( - (row for row in latest_rows if row["eligible"]), - key=lambda row: float(row["score"]), - reverse=True, - )[: min(int(top_n), len(symbols))] - - weights: dict[str, float] = {} - normalized_weighting_mode = str(weighting_mode or "").strip().lower().replace("-", "_") - if ranked: - if normalized_weighting_mode in {"inverse_volatility", "inverse_vol"}: - inverse_vols = [1.0 / max(float(row["volatility"]), 1e-12) for row in ranked] - total_inverse_vol = sum(inverse_vols) - weights = { - str(row["symbol"]): float(inverse_vol / total_inverse_vol) - for row, inverse_vol in zip(ranked, inverse_vols) - } - elif normalized_weighting_mode == "equal": - weights = {str(row["symbol"]): 1.0 / len(ranked) for row in ranked} - else: - raise ValueError("weighting_mode must be 'inverse_volatility' or 'equal'") - - realized_portfolio_volatility = 0.0 - if weights: - weights, realized_portfolio_volatility = apply_portfolio_volatility_target( - returns, - weights, - volatility_window_days=int(volatility_window_days), - target_annual_volatility=target_annual_volatility, - max_gross_exposure=float(max_gross_exposure), - ) - - as_of = pd.Timestamp(close.index[-1]).date().isoformat() - cash_weight = max(0.0, 1.0 - sum(weights.values())) - return { - "as_of": as_of, - "universe_symbols": symbols, - "selected_symbols": tuple(weights), - "ranking": tuple(latest_rows), - "signal_state": "risk_on" if weights else "cash", - "cash_weight": cash_weight, - "gross_exposure": sum(weights.values()), - "history_days": int(len(close)), - "momentum_window_days": int(momentum_window_days), - "trend_window_days": int(trend_window_days), - "volatility_window_days": int(volatility_window_days), - "top_n": int(top_n), - "min_momentum": float(min_momentum), - "weighting_mode": normalized_weighting_mode, - "target_annual_volatility": ( - None if target_annual_volatility is None else float(target_annual_volatility) - ), - "max_gross_exposure": float(max_gross_exposure), - "realized_portfolio_volatility": float(realized_portfolio_volatility), - "weights": weights, - } - - -def build_target_weights(market_history: Any, **kwargs: Any) -> tuple[dict[str, float], dict[str, object]]: - signal = compute_latest_signal(market_history, **kwargs) - return dict(signal["weights"]), signal - - -def extract_managed_symbols(*_args: Any, **kwargs: Any) -> tuple[str, ...]: - return normalize_universe_symbols(kwargs.get("universe_symbols")) - - -def compute_signals(market_history: Any, _current_holdings: Any = None, **kwargs: Any): - kwargs.pop("translator", None) - kwargs.pop("signal_text_fn", None) - kwargs.pop("execution_cash_reserve_ratio", None) - kwargs.pop("rebalance_frequency", None) - weights, metadata = build_target_weights(market_history, **kwargs) - selected = ",".join(weights) if weights else "cash" - target_vol = metadata.get("target_annual_volatility") - target_vol_text = "none" if target_vol is None else f"{float(target_vol):.0%}" - signal_desc = ( - f"hk high dividend low vol trend state={metadata['signal_state']} selected={selected} " - f"gross={metadata['gross_exposure']:.0%} cash={metadata['cash_weight']:.0%} " - f"target_vol={target_vol_text}" - ) - status_desc = ( - f"state={metadata['signal_state']} | selected={selected} | " - f"momentum={metadata['momentum_window_days']}d | trend={metadata['trend_window_days']}d | " - f"target_vol={target_vol_text}" - ) - return ( - weights, - signal_desc, - bool(metadata["cash_weight"] > 1e-12), - status_desc, - { - **metadata, - "managed_symbols": extract_managed_symbols(**kwargs), - "status_icon": STATUS_ICON, - "signal_source": SIGNAL_SOURCE, - "actionable": True, - }, - ) diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 64afc9e..63a5bef 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -7,7 +7,6 @@ from hk_equity_strategies import get_strategy_definitions from hk_equity_strategies.catalog import ( HK_EQUITY_DOMAIN, - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, get_compatible_platforms, @@ -27,7 +26,6 @@ def test_catalog_declares_runtime_enabled_hk_direct_strategies(): catalog = get_strategy_definitions() assert set(catalog) == { HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, } definition = catalog[HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE] @@ -44,19 +42,6 @@ def test_catalog_declares_runtime_enabled_hk_direct_strategies(): "hk_equity_strategies.strategies.hk_global_etf_tactical_rotation" ) - high_dividend_definition = catalog[HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE] - assert high_dividend_definition.domain == HK_EQUITY_DOMAIN - assert high_dividend_definition.required_inputs == frozenset({"market_history"}) - assert high_dividend_definition.target_mode == "weight" - assert get_compatible_platforms(HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE) == frozenset({"ibkr", "longbridge"}) - assert get_strategy_metadata(HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE).status == "runtime_enabled" - assert HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE in get_runtime_enabled_profiles() - - high_dividend_component_map = get_strategy_component_map(high_dividend_definition) - assert high_dividend_component_map["signal_logic"].module_path == ( - "hk_equity_strategies.strategies.hk_dividend_gold_defensive_rotation" - ) - low_vol_definition = catalog[HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE] assert low_vol_definition.domain == HK_EQUITY_DOMAIN assert low_vol_definition.required_inputs == frozenset({"feature_snapshot"}) @@ -75,7 +60,6 @@ def test_profile_groups_keep_runtime_research_and_snapshot_scaffolds_separate(): assert get_direct_market_history_profiles() == frozenset( { HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, } ) assert get_snapshot_backed_profiles() == frozenset({HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE}) @@ -84,7 +68,6 @@ def test_profile_groups_keep_runtime_research_and_snapshot_scaffolds_separate(): assert get_runtime_enabled_profiles() == frozenset( { HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, } ) @@ -112,6 +95,7 @@ def test_profile_groups_keep_runtime_research_and_snapshot_scaffolds_separate(): "hk_blue_chip_snapshot", "hk_index_reversion", "hk_etf_rotation", + "hk_dividend_gold_defensive_rotation", ], ) def test_research_and_snapshot_scaffold_profiles_are_not_runtime_catalog_profiles(profile: str): @@ -122,7 +106,6 @@ def test_research_and_snapshot_scaffold_profiles_are_not_runtime_catalog_profile def test_canonical_profiles_resolve_without_legacy_aliases(): assert get_profile_aliases() == {} assert resolve_canonical_profile("hk-global-etf-tactical-rotation") == HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE - assert resolve_canonical_profile("hk-dividend-gold-defensive-rotation") == HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE assert resolve_canonical_profile("hk-low-vol-dividend-quality-snapshot") == ( HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE ) @@ -134,6 +117,8 @@ def test_canonical_profiles_resolve_without_legacy_aliases(): "hk_global_etf_rotation", "hk_listed_global_rotation", "hk_hd_gold_trend", + "hk_dividend_gold_defensive_rotation", + "hk-dividend-gold-defensive-rotation", "hk_high_dividend_low_vol", "hk_low_vol_dividend_snapshot", "hk_dividend_quality", diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 4465c2a..adc4c45 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -7,15 +7,10 @@ from hk_equity_strategies import get_strategy_entrypoint from hk_equity_strategies.catalog import ( - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, ) from hk_equity_strategies.strategies.hk_low_vol_dividend_quality_snapshot import SAFE_HAVEN -from hk_equity_strategies.strategies.hk_dividend_gold_defensive_rotation import ( - DEFAULT_UNIVERSE_SYMBOLS as HIGH_DIVIDEND_UNIVERSE_SYMBOLS, - HIGH_DIVIDEND_ETF_SYMBOL, -) from hk_equity_strategies.strategies.hk_global_etf_tactical_rotation import ( DEFAULT_UNIVERSE_SYMBOLS as GLOBAL_ETF_UNIVERSE_SYMBOLS, HIGH_DIVIDEND_ETF_SYMBOL as GLOBAL_HIGH_DIVIDEND_ETF_SYMBOL, @@ -46,22 +41,6 @@ def _global_etf_rotation_history() -> pd.DataFrame: return pd.DataFrame(rows) -def _high_dividend_low_vol_history() -> pd.DataFrame: - dates = pd.bdate_range("2024-01-02", periods=180) - rates = { - "02840": 1.0004, - HIGH_DIVIDEND_ETF_SYMBOL: 1.0007, - } - rows = [] - for symbol in HIGH_DIVIDEND_UNIVERSE_SYMBOLS: - price = 20.0 - for idx, date in enumerate(dates): - price *= rates[symbol] - close = price * (1.0 + 0.002 * ((idx % 5) - 2) / 5) - rows.append({"date": date, "symbol": symbol, "close": close}) - return pd.DataFrame(rows) - - def test_global_etf_rotation_entrypoint_returns_volatility_targeted_weight_targets(): entrypoint = get_strategy_entrypoint(HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE) @@ -81,24 +60,6 @@ def test_global_etf_rotation_entrypoint_returns_volatility_targeted_weight_targe assert "cash_residual" in decision.risk_flags -def test_high_dividend_low_vol_trend_entrypoint_returns_volatility_targeted_weight_targets(): - entrypoint = get_strategy_entrypoint(HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE) - - decision = entrypoint.evaluate( - StrategyContext( - as_of="2026-02-25", - market_data={"market_history": _high_dividend_low_vol_history()}, - runtime_config={"min_history_days": 126}, - ) - ) - - weights = {position.symbol: position.target_weight for position in decision.positions} - assert set(weights) == {"02840", HIGH_DIVIDEND_ETF_SYMBOL} - assert sum(weights.values()) == pytest.approx(1.0) - assert decision.diagnostics["signal_source"] == "daily_market_history" - assert decision.diagnostics["target_annual_volatility"] == pytest.approx(0.12) - - def test_low_vol_dividend_quality_entrypoint_consumes_feature_snapshot(): entrypoint = get_strategy_entrypoint(HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE) diff --git a/tests/test_hk_dividend_gold_defensive_rotation.py b/tests/test_hk_dividend_gold_defensive_rotation.py deleted file mode 100644 index f1680ea..0000000 --- a/tests/test_hk_dividend_gold_defensive_rotation.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -import pandas as pd -import pytest - -from hk_equity_strategies.strategies.hk_dividend_gold_defensive_rotation import ( - DEFAULT_UNIVERSE_SYMBOLS, - GOLD_ETF_SYMBOL, - HIGH_DIVIDEND_ETF_SYMBOL, - build_target_weights, - compute_latest_signal, - normalize_universe_symbols, -) - - -def _history(*, falling: bool = False) -> pd.DataFrame: - dates = pd.bdate_range("2024-01-02", periods=180) - rates = { - GOLD_ETF_SYMBOL: 0.9997 if falling else 1.0004, - HIGH_DIVIDEND_ETF_SYMBOL: 0.9996 if falling else 1.0007, - } - rows = [] - for symbol in DEFAULT_UNIVERSE_SYMBOLS: - price = 20.0 - for idx, date in enumerate(dates): - price *= rates[symbol] - close = price * (1.0 + 0.002 * ((idx % 5) - 2) / 5) - rows.append({"date": date, "symbol": symbol, "close": close}) - return pd.DataFrame(rows) - - -def test_normalize_universe_symbols_preserves_hk_codes(): - assert normalize_universe_symbols(["3110.HK", "2840"]) == ("03110", "02840") - - -def test_compute_latest_signal_selects_high_dividend_and_gold_when_trending(): - signal = compute_latest_signal(_history(), min_history_days=126) - - assert signal["signal_state"] == "risk_on" - assert set(signal["selected_symbols"]) == {GOLD_ETF_SYMBOL, HIGH_DIVIDEND_ETF_SYMBOL} - assert signal["cash_weight"] == pytest.approx(0.0) - assert sum(signal["weights"].values()) == pytest.approx(1.0) - assert signal["target_annual_volatility"] == pytest.approx(0.12) - - -def test_compute_latest_signal_applies_volatility_target_when_realized_volatility_is_high(): - signal = compute_latest_signal( - _history(), - min_history_days=126, - target_annual_volatility=0.01, - ) - - assert signal["signal_state"] == "risk_on" - assert 0.0 < sum(signal["weights"].values()) < 1.0 - assert signal["cash_weight"] > 0.0 - - -def test_build_target_weights_moves_to_cash_when_no_symbol_is_eligible(): - weights, metadata = build_target_weights(_history(falling=True), min_history_days=126) - - assert weights == {} - assert metadata["signal_state"] == "cash" - assert metadata["cash_weight"] == pytest.approx(1.0) diff --git a/tests/test_live_enablement_matrix.py b/tests/test_live_enablement_matrix.py index 1a8bc2c..7419991 100644 --- a/tests/test_live_enablement_matrix.py +++ b/tests/test_live_enablement_matrix.py @@ -8,7 +8,6 @@ import pytest from hk_equity_strategies.catalog import ( - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, get_external_snapshot_scaffold_profiles, @@ -41,8 +40,8 @@ def test_live_enablement_matrix_keeps_only_runtime_profiles_selectable_or_listed matrix = build_live_enablement_matrix() assert set(matrix["selectable_profiles"]) == get_runtime_enabled_profiles() - assert matrix["selectable_profile_count"] == 3 - assert matrix["profile_count"] == 3 + assert matrix["selectable_profile_count"] == 2 + assert matrix["profile_count"] == 2 assert matrix["blocked_profile_count"] == 0 assert get_external_snapshot_scaffold_profiles() == frozenset() assert get_research_backtest_only_profiles() == frozenset() @@ -56,11 +55,10 @@ def test_live_enablement_matrix_keeps_only_runtime_profiles_selectable_or_listed ranking = matrix["curated_live_enablement_strategy_ranking"] assert ranking["ranking_version"] == CURATED_LIVE_ENABLEMENT_STRATEGY_RANKING_VERSION assert [row["profile"] for row in ranking["ranking"]] == [ - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, ] - assert ranking["ranking"][2]["profile_type"] == "runtime_snapshot_backed" + assert ranking["ranking"][1]["profile_type"] == "runtime_snapshot_backed" assert ranking["future_research_curated_candidate_order"] == [] assert "hk_index_mean_reversion" in {row["profile"] for row in ranking["deprioritized_profiles"]} @@ -70,32 +68,30 @@ def test_curated_live_enablement_ranking_excludes_weaker_research_profiles(): assert ranking["live_enablement_allowed_without_evidence"] is False assert ranking["max_allowed_drawdown"] == 0.30 - assert ranking["ranking"][0]["profile"] == HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE - assert ranking["ranking"][0]["max_drawdown"] == -0.0806 - assert ranking["ranking"][1]["profile"] == HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE - assert ranking["ranking"][1]["max_drawdown"] == -0.2051 - assert ranking["ranking"][2]["profile"] == HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE - assert ranking["ranking"][2]["max_drawdown"] == -0.2305 + assert ranking["ranking"][0]["profile"] == HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE + assert ranking["ranking"][0]["max_drawdown"] == -0.2051 + assert ranking["ranking"][1]["profile"] == HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE + assert ranking["ranking"][1]["max_drawdown"] == -0.2305 assert all(item["decision"].startswith("exclude") for item in ranking["deprioritized_profiles"]) def test_runtime_rows_include_thresholds_evidence_commands_and_sources(): - row = build_live_enablement_row(HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE) + row = build_live_enablement_row(HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE) assert row["profile_type"] == "runtime_market_history" assert row["selectable_by_platform"] is True assert row["runtime_enabled"] is True assert row["live_enablement_gate"] == RUNTIME_LIVE_GATE - assert row["benchmark"] == "03110" + assert row["benchmark"] == "02800" assert row["supported_platforms"] == ["ibkr", "longbridge"] - assert any("max_allowed_backtest_drawdown=0.12" in item for item in row["required_evidence"]) + assert any("max_allowed_backtest_drawdown=0.3" in item for item in row["required_evidence"]) assert any("validate_hk_runtime_live_enablement.py" in command for command in row["evidence_commands"]) assert row["runtime_etf_product_policy"]["required"] is True assert row["runtime_market_data_policy"]["required"] is True assert "runtime_etf_product_due_diligence_verified" in row["required_evidence"] assert "bilingual_notification_delivery_log_verified" in row["required_evidence"] - assert any("globalxetfs.com.hk/funds/hang-seng-high-dividend-yield-etf" in url for url in row["research_evidence_urls"]) - assert any("single-commodity trust" in note for note in row["notes"]) + assert any("trahk.com.hk" in url for url in row["research_evidence_urls"]) + assert any("All eight ETF symbols require" in note for note in row["notes"]) def test_global_etf_row_mentions_complex_etf_risk(): @@ -144,6 +140,6 @@ def test_print_hk_live_enablement_matrix_json(): completed = subprocess.run([sys.executable, str(SCRIPT), "--json"], check=True, capture_output=True, text=True) payload = json.loads(completed.stdout) - assert payload["profile_count"] == 3 + assert payload["profile_count"] == 2 assert payload["blocked_profile_count"] == 0 assert set(payload["selectable_profiles"]) == get_runtime_enabled_profiles() diff --git a/tests/test_runtime_adapters.py b/tests/test_runtime_adapters.py index 1d2d88e..0c2a928 100644 --- a/tests/test_runtime_adapters.py +++ b/tests/test_runtime_adapters.py @@ -3,7 +3,6 @@ import pytest from hk_equity_strategies.catalog import ( - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, ) @@ -41,21 +40,6 @@ def test_global_etf_rotation_runtime_requirements_are_direct_inputs(): assert requirements["snapshot_contract_version"] is None -def test_high_dividend_low_vol_trend_runtime_adapter_uses_market_history(): - adapter = get_platform_runtime_adapter(HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, platform_id="ibkr") - - assert adapter.available_inputs == frozenset({"market_history"}) - assert adapter.available_capabilities == frozenset({"broker_client"}) - assert adapter.require_snapshot_manifest is False - - -def test_high_dividend_low_vol_trend_longbridge_adapter_adds_portfolio_for_value_native_platform(): - adapter = get_platform_runtime_adapter(HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, platform_id="longbridge") - - assert adapter.available_inputs == frozenset({"market_history", "portfolio_snapshot"}) - assert adapter.portfolio_input_name == "portfolio_snapshot" - - def test_low_vol_dividend_quality_runtime_adapter_requires_feature_snapshot_manifest(): adapter = get_platform_runtime_adapter(HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, platform_id="ibkr") @@ -93,6 +77,7 @@ def test_low_vol_dividend_quality_runtime_requirements_are_snapshot_backed(): "hk_blue_chip_leader_rotation", "hk_index_mean_reversion", "hk_etf_regime_rotation", + "hk_dividend_gold_defensive_rotation", ], ) def test_research_and_snapshot_scaffold_profiles_have_no_runtime_adapter(profile: str): diff --git a/tests/test_runtime_live_enablement_evidence.py b/tests/test_runtime_live_enablement_evidence.py index b226b49..d2b3430 100644 --- a/tests/test_runtime_live_enablement_evidence.py +++ b/tests/test_runtime_live_enablement_evidence.py @@ -6,7 +6,7 @@ from pathlib import Path from hk_equity_strategies.catalog import ( - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, + HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, ) from hk_equity_strategies.runtime_live_enablement_evidence import ( @@ -20,7 +20,7 @@ def _evidence(**overrides): payload = { "evidence_type": "hk_runtime_live_enablement", - "profile": HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, + "profile": HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, "platform": "longbridge", "validation_as_of": "2026-06-02", "strategy_backtest": { @@ -28,9 +28,9 @@ def _evidence(**overrides): "out_of_sample": True, "period_start": "2021-09-01", "period_end": "2026-05-29", - "annual_return": 0.1716, - "max_drawdown": -0.0806, - "rolling_oos_fold_max_drawdown": -0.092, + "annual_return": 0.1884, + "max_drawdown": -0.2051, + "rolling_oos_fold_max_drawdown": -0.2051, "oos_fold_count": 4, "max_single_period_return_contribution": 0.35, "annual_return_to_max_drawdown_ratio": 2.13, @@ -57,15 +57,15 @@ def _evidence(**overrides): "cash_leverage_short_borrow_and_margin_controls": True, "tail_loss_time_underwater_and_recovery_controls": True, "portfolio_correlation_and_aggregate_risk_budget_controls": True, - "benchmark_symbol": "03110", + "benchmark_symbol": "02800", "benchmark_annual_return": 0.08, - "strategy_excess_return": 0.0916, + "strategy_excess_return": 0.1084, "evidence_generated_at": "2026-04-15", - "evidence_uri": "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/backtest.json", + "evidence_uri": "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/backtest.json", }, "runtime_readiness": { "status": "passed", - "profile": HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, + "profile": HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, "platform": "longbridge", "market_history_feed_verified": True, "managed_symbols_verified": True, @@ -75,13 +75,13 @@ def _evidence(**overrides): "market_history_coverage_start": "2021-09-01", "market_history_coverage_end": "2026-05-29", "market_history_source_uri": ( - "gs://qsl-hk-prod-sources/runtime/hk-dividend-gold-defensive-rotation/20260601/market-history.parquet" + "gs://qsl-hk-prod-sources/runtime/hk-global-etf-tactical-rotation/20260601/market-history.parquet" ), "market_history_quality_report_uri": ( - "gs://qsl-hk-prod-sources/runtime/hk-dividend-gold-defensive-rotation/20260601/market-history-quality.json" + "gs://qsl-hk-prod-sources/runtime/hk-global-etf-tactical-rotation/20260601/market-history-quality.json" ), "point_in_time_data_dictionary_uri": ( - "gs://qsl-hk-prod-sources/runtime/hk-dividend-gold-defensive-rotation/20260601/data-dictionary.json" + "gs://qsl-hk-prod-sources/runtime/hk-global-etf-tactical-rotation/20260601/data-dictionary.json" ), "point_in_time_market_history": True, "adjusted_price_history": True, @@ -94,7 +94,7 @@ def _evidence(**overrides): "etf_nav_or_inav_source_verified": True, "stamp_duty_or_etf_exemption_source_verified": True, "evidence_generated_at": "2026-05-25", - "evidence_uri": "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/readiness.json", + "evidence_uri": "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/readiness.json", }, "platform_dry_run_order_preview": { "status": "passed", @@ -108,7 +108,7 @@ def _evidence(**overrides): "notification_sent": True, "notification_schema_version": "hk_live_enablement_notification.v1", "notification_event_type": "hk_runtime_live_enablement_dry_run", - "notification_correlation_id": "hk-runtime-hd-lv-20260602-dryrun-001", + "notification_correlation_id": "hk-runtime-global-etf-20260602-dryrun-001", "notification_locale_en": True, "notification_locale_zh_hans": True, "notification_contains_profile": True, @@ -117,21 +117,21 @@ def _evidence(**overrides): "notification_contains_order_preview_summary": True, "notification_redacts_sensitive_fields": True, "notification_delivery_log_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/notifications/" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/notifications/" "20260602-dryrun.json" ), - "dry_run_session_id": "hk-runtime-hd-lv-longbridge-20260602-dryrun-001", + "dry_run_session_id": "hk-runtime-global-etf-longbridge-20260602-dryrun-001", "raw_order_preview_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/order-preview/" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/order-preview/" "raw-order-preview.json" ), "raw_order_preview_sha256": "b" * 64, "quote_snapshot_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/order-preview/quote-snapshot.json" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/order-preview/quote-snapshot.json" ), "quote_snapshot_sha256": "c" * 64, "fee_breakdown_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/order-preview/fee-breakdown.json" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/order-preview/fee-breakdown.json" ), "fee_breakdown_sha256": "d" * 64, "order_preview_artifact_not_sample": True, @@ -151,7 +151,7 @@ def _evidence(**overrides): "vcm_price_band_controls_verified": True, "etf_nav_or_spread_guard_verified": True, "evidence_generated_at": "2026-05-28", - "evidence_uri": "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/order-preview.json", + "evidence_uri": "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/order-preview.json", }, "broker_permission_and_fee_verification": { "status": "passed", @@ -161,42 +161,42 @@ def _evidence(**overrides): "fees_levies_verified": True, "stamp_duty_or_etf_exemption_verified": True, "etf_product_permission_verified": True, - "etf_product_audit_id": "hk-runtime-hd-lv-longbridge-20260602-etf-product-audit-001", - "managed_etf_symbols_audited_count": 2, + "etf_product_audit_id": "hk-runtime-global-etf-longbridge-20260602-etf-product-audit-001", + "managed_etf_symbols_audited_count": 8, "etf_product_universe_audit_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/etf-product/product-universe.json" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/etf-product/product-universe.json" ), "official_product_document_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/etf-product/product-documents.json" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/etf-product/product-documents.json" ), "underlying_index_or_reference_asset_source_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/etf-product/" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/etf-product/" "underlying-index-reference-asset.json" ), "nav_or_inav_source_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/etf-product/nav-inav.json" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/etf-product/nav-inav.json" ), "market_maker_or_liquidity_provider_source_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/etf-product/" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/etf-product/" "market-maker-liquidity-provider.json" ), "stock_connect_etf_eligibility_source_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/etf-product/" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/etf-product/" "stock-connect-etf-eligibility.json" ), "southbound_etf_turnover_and_fund_flow_source_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/etf-product/" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/etf-product/" "southbound-etf-turnover-flow.json" ), "distribution_tax_and_fee_treatment_source_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/etf-product/" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/etf-product/" "distribution-tax-fees.json" ), "etf_fee_and_stamp_duty_audit_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/etf-product/fees-stamp-duty.json" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/etf-product/fees-stamp-duty.json" ), "broker_product_permission_audit_uri": ( - "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/etf-product/broker-permission.json" + "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/etf-product/broker-permission.json" ), "all_managed_symbols_confirmed_etp": True, "leveraged_inverse_or_synthetic_flags_audited": True, @@ -223,7 +223,7 @@ def _evidence(**overrides): "currency_and_board_lot_per_symbol_verified": True, "distribution_and_corporate_action_treatment_verified": True, "evidence_generated_at": "2026-05-20", - "evidence_uri": "gs://qsl-hk-evidence/runtime/hk-dividend-gold-defensive-rotation/broker-fees.json", + "evidence_uri": "gs://qsl-hk-evidence/runtime/hk-global-etf-tactical-rotation/broker-fees.json", }, "runtime_switch_plan": { "status": "passed", @@ -249,7 +249,7 @@ def _evidence(**overrides): "operator_approved": True, "live_rollout_approved": True, "dry_run_removal_approved": True, - "approval_reference": "ops-review-2026-06-hk-hd-lv", + "approval_reference": "ops-review-2026-06-hk-global-etf", }, } payload.update(overrides) @@ -262,9 +262,9 @@ def test_validate_runtime_live_enablement_evidence_accepts_complete_pack(): assert result["validation_status"] == "passed" assert result["live_enablement_allowed"] is True assert result["live_enablement_thresholds"] == { - "max_allowed_backtest_drawdown": 0.12, + "max_allowed_backtest_drawdown": 0.30, "min_required_return_to_drawdown_ratio": 0.5, - "max_allowed_annualized_turnover": 1.0, + "max_allowed_annualized_turnover": 1.5, "min_required_annual_return": 0.0, "min_required_walk_forward_years": 3.0, "min_required_oos_fold_count": 3, @@ -274,7 +274,7 @@ def test_validate_runtime_live_enablement_evidence_accepts_complete_pack(): assert "token=" in result["evidence_uri_policy"]["rejected_query_markers"] assert result["validation_as_of"] == "2026-06-02" assert result["evidence_freshness_policy"]["required_field"] == "evidence_generated_at" - assert result["execution_capacity_policy"]["min_median_daily_turnover_hkd"] == 10_000_000 + assert result["execution_capacity_policy"]["min_median_daily_turnover_hkd"] == 20_000_000 assert result["rollout_risk_policy"]["max_initial_capital_fraction"] == 0.25 assert result["runtime_etf_product_policy"]["policy_version"] == "hk_runtime_etf_product_due_diligence.v2" assert "etf_product_universe_audit_uri" in result["runtime_etf_product_policy"]["required_uri_fields"] @@ -317,13 +317,22 @@ def test_validate_runtime_live_enablement_evidence_accepts_complete_pack(): def test_build_runtime_live_enablement_evidence_template_is_not_preapproved(): - template = build_runtime_live_enablement_evidence_template(HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, platform="ibkr") + template = build_runtime_live_enablement_evidence_template(HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, platform="ibkr") assert template["evidence_type"] == "hk_runtime_live_enablement" assert template["template_status"] == "pending_operator_evidence" - assert template["profile"] == HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE + assert template["profile"] == HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE assert template["platform"] == "ibkr" - assert template["runtime_readiness"]["managed_symbols"] == ["02840", "03110"] + assert template["runtime_readiness"]["managed_symbols"] == [ + "02800", + "02822", + "03188", + "03033", + "02834", + "02840", + "03175", + "03110", + ] assert template["evidence_uri_policy"]["required"] is True assert template["evidence_uri_policy"]["allowed_schemes"] == ["gs://", "https://", "s3://"] assert template["evidence_freshness_policy"]["required_field"] == "evidence_generated_at" @@ -346,7 +355,7 @@ def test_build_runtime_live_enablement_evidence_template_is_not_preapproved(): assert template["strategy_backtest"]["oos_fold_count"] is None assert template["strategy_backtest"]["max_single_period_return_contribution"] is None assert template["strategy_backtest"]["annual_return_to_max_drawdown_ratio"] is None - assert template["strategy_backtest"]["benchmark_symbol"] == "03110" + assert template["strategy_backtest"]["benchmark_symbol"] == "02800" assert template["strategy_backtest"]["strategy_excess_return"] is None assert template["strategy_backtest"]["point_in_time_inputs_only"] is False assert template["strategy_backtest"]["no_full_sample_parameter_selection"] is False @@ -509,7 +518,7 @@ def test_build_low_vol_dividend_evidence_template_requires_snapshot_artifact_lin def test_validate_runtime_live_enablement_evidence_rejects_drawdown_above_profile_limit(): - payload = _evidence(strategy_backtest={**_evidence()["strategy_backtest"], "max_drawdown": -0.18}) + payload = _evidence(strategy_backtest={**_evidence()["strategy_backtest"], "max_drawdown": -0.35}) result = validate_runtime_live_enablement_evidence(payload) @@ -519,7 +528,7 @@ def test_validate_runtime_live_enablement_evidence_rejects_drawdown_above_profil def test_validate_runtime_live_enablement_evidence_rejects_oos_fold_drawdown_above_profile_limit(): payload = _evidence( - strategy_backtest={**_evidence()["strategy_backtest"], "rolling_oos_fold_max_drawdown": -0.18} + strategy_backtest={**_evidence()["strategy_backtest"], "rolling_oos_fold_max_drawdown": -0.35} ) result = validate_runtime_live_enablement_evidence(payload) @@ -579,7 +588,7 @@ def test_validate_runtime_live_enablement_evidence_rejects_low_computed_return_t def test_validate_runtime_live_enablement_evidence_rejects_excess_turnover(): - payload = _evidence(strategy_backtest={**_evidence()["strategy_backtest"], "annualized_turnover": 1.25}) + payload = _evidence(strategy_backtest={**_evidence()["strategy_backtest"], "annualized_turnover": 1.75}) result = validate_runtime_live_enablement_evidence(payload) @@ -677,12 +686,12 @@ def test_validate_runtime_live_enablement_evidence_rejects_non_positive_excess_r def test_validate_runtime_live_enablement_evidence_rejects_wrong_benchmark(): - payload = _evidence(strategy_backtest={**_evidence()["strategy_backtest"], "benchmark_symbol": "02800"}) + payload = _evidence(strategy_backtest={**_evidence()["strategy_backtest"], "benchmark_symbol": "03110"}) result = validate_runtime_live_enablement_evidence(payload) assert result["live_enablement_allowed"] is False - assert any("benchmark_symbol must be '03110'" in error for error in result["errors"]) + assert any("benchmark_symbol must be '02800'" in error for error in result["errors"]) def test_validate_runtime_live_enablement_evidence_rejects_short_oos_period(): @@ -841,7 +850,7 @@ def test_validate_runtime_live_enablement_evidence_requires_all_managed_etfs_aud result = validate_runtime_live_enablement_evidence(payload) assert result["live_enablement_allowed"] is False - assert any("managed_etf_symbols_audited_count must be >= 2" in error for error in result["errors"]) + assert any("managed_etf_symbols_audited_count must be >= 8" in error for error in result["errors"]) def test_validate_runtime_live_enablement_evidence_requires_complex_product_review(): @@ -1066,7 +1075,7 @@ def test_print_runtime_live_enablement_evidence_template_cli(): "hk_equity_strategies.runtime_live_enablement_evidence", "--print-template", "--profile", - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, + HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, "--platform", "longbridge", "--json", @@ -1077,7 +1086,7 @@ def test_print_runtime_live_enablement_evidence_template_cli(): ) payload = json.loads(completed.stdout) - assert payload["profile"] == HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE + assert payload["profile"] == HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE assert payload["template_status"] == "pending_operator_evidence" assert payload["evidence_uri_policy"]["required"] is True assert payload["evidence_freshness_policy"]["required"] is True diff --git a/tests/test_runtime_readiness.py b/tests/test_runtime_readiness.py index 942fdfb..67c8939 100644 --- a/tests/test_runtime_readiness.py +++ b/tests/test_runtime_readiness.py @@ -6,7 +6,6 @@ from pathlib import Path from hk_equity_strategies.catalog import ( - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, HK_GLOBAL_ETF_TACTICAL_ROTATION_PROFILE, HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, ) @@ -126,38 +125,6 @@ def test_longbridge_global_etf_readiness_requires_portfolio_snapshot_conversion( assert "portfolio_snapshot" in plan["available_inputs"] -def test_high_dividend_low_vol_trend_readiness_uses_two_managed_symbols(): - plan = build_hk_runtime_readiness( - HK_DIVIDEND_GOLD_DEFENSIVE_ROTATION_PROFILE, - platform_id="longbridge", - ) - - assert plan["runtime_enabled"] is True - assert plan["managed_symbols"] == ["02840", "03110"] - assert plan["target_conversion"] == { - "strategy_target_mode": "weight", - "platform_native_target_mode": "value", - "requires_portfolio_snapshot": True, - "portfolio_input_name": "portfolio_snapshot", - } - assert any("preferred lower-drawdown" in check for check in plan["profile_live_optimization_checks"]) - assert plan["live_enablement_thresholds"] == { - "max_allowed_backtest_drawdown": 0.12, - "min_required_return_to_drawdown_ratio": 0.50, - "max_allowed_annualized_turnover": 1.00, - "min_required_annual_return": 0.0, - "min_required_walk_forward_years": 3.0, - "min_required_oos_fold_count": 3, - "max_single_period_return_contribution": 0.60, - } - assert plan["execution_capacity_policy"]["min_median_daily_turnover_hkd"] == 10_000_000 - assert plan["rollout_risk_policy"]["min_observation_trading_days_before_scale_up"] == 20 - assert plan["runtime_market_data_policy"]["required"] is True - assert any("Hang Seng High Dividend Yield Index methodology" in check for check in plan["profile_live_optimization_checks"]) - assert any("SPDR Gold Shares trust structure" in check for check in plan["profile_live_optimization_checks"]) - assert any("02840/03110" in note for note in plan["risk_notes"]) - - def test_low_vol_dividend_quality_readiness_requires_snapshot_artifacts(): plan = build_hk_runtime_readiness( HK_LOW_VOL_DIVIDEND_QUALITY_SNAPSHOT_PROFILE,