From 77160feb4aba6d173d696ef3284c9aa8fad48311 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:54:13 +0800 Subject: [PATCH] Add CN growth sleeves and lifecycle gates Co-Authored-By: Codex --- README.md | 12 +- README.zh-CN.md | 10 + docs/platform_integration.md | 2 + .../cn_growth_sleeve_redesign_20260705.md | 124 ++++++++++++ .../cn_growth_sleeve_structure_20260705.md | 184 ++++++++++++++++++ ..._growth_sleeve_structure_20260705.zh-CN.md | 171 ++++++++++++++++ src/cn_equity_strategies/__init__.py | 4 + src/cn_equity_strategies/catalog.py | 135 +++++++++++-- src/cn_equity_strategies/combo_manifests.py | 4 +- .../entrypoints/__init__.py | 88 ++++++++- .../manifests/__init__.py | 112 +++++++++-- .../strategies/__init__.py | 4 + .../cn_chinext_growth_momentum_quality.py | 107 ++++++++++ .../strategies/cn_equity_combo.py | 102 ++++++---- .../cn_star_growth_momentum_quality.py | 107 ++++++++++ tests/test_catalog.py | 18 ++ tests/test_cn_equity_combo.py | 2 +- tests/test_entrypoints.py | 76 ++++++++ tests/test_runtime_adapters.py | 11 ++ 19 files changed, 1188 insertions(+), 85 deletions(-) create mode 100644 docs/research/cn_growth_sleeve_redesign_20260705.md create mode 100644 docs/research/cn_growth_sleeve_structure_20260705.md create mode 100644 docs/research/cn_growth_sleeve_structure_20260705.zh-CN.md create mode 100644 src/cn_equity_strategies/strategies/cn_chinext_growth_momentum_quality.py create mode 100644 src/cn_equity_strategies/strategies/cn_star_growth_momentum_quality.py diff --git a/README.md b/README.md index ffec484..8e52087 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ These profiles use platform-provided `market_history` and do not require a separ | Profile | Name | Input | Benchmark | Current role | | --- | --- | --- | --- | --- | | `cn_dividend_quality_snapshot` | CN Dividend Quality Snapshot | `feature_snapshot` + manifest | `510300` | Dividend + quality composite selector for defensive allocation. | -| `cn_equity_combo` | CN Equity Combo | `market_history` + `feature_snapshot` | `510300` | Research-only composite track combining ETF, stock, and dividend sleeves. | +| `cn_equity_combo` | CN Equity Combo | `market_history` + `feature_snapshot` | `510300` | Research-only orchestrator combining ETF, growth sleeves, and dividend sleeves. | ### Live candidate @@ -42,6 +42,13 @@ These profiles use platform-provided `market_history` and do not require a separ | --- | --- | --- | --- | --- | | `cn_chinext_growth_momentum_quality_snapshot` | CN ChiNext Growth Momentum Quality Snapshot | `feature_snapshot` + manifest | `399006` | Research-only ChiNext growth-momentum-quality selector. | +### Research-only growth sleeves + +| Profile | Name | Input | Benchmark | Current role | +| --- | --- | --- | --- | --- | +| `cn_chinext_growth_momentum_quality` | CN ChiNext Growth Momentum Quality | `market_history` | `510300` | Research-only ChiNext growth sleeve; board-specific growth + momentum + quality. | +| `cn_star_growth_momentum_quality` | CN Star Growth Momentum Quality | `market_history` | `510300` | Research-only STAR growth sleeve; stricter liquidity and concentration controls. | + ### Planned external snapshot scaffolds | Profile | Name | Notes | @@ -101,6 +108,9 @@ The intended A-share runtime platform is: - [`docs/platform_integration.md`](docs/platform_integration.md) - [`docs/research/cn_index_etf_tactical_rotation.md`](docs/research/cn_index_etf_tactical_rotation.md) +- [`docs/research/cn_growth_sleeve_redesign_20260705.md`](docs/research/cn_growth_sleeve_redesign_20260705.md) +- [`docs/research/cn_growth_sleeve_structure_20260705.md`](docs/research/cn_growth_sleeve_structure_20260705.md) +- [`docs/research/cn_chinext_growth_momentum_quality_snapshot_20260705.md`](docs/research/cn_chinext_growth_momentum_quality_snapshot_20260705.md) ## Safety and contribution notes diff --git a/README.zh-CN.md b/README.zh-CN.md index f016f1b..dca6f43 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -34,6 +34,13 @@ | --- | --- | --- | --- | --- | | `cn_chinext_growth_momentum_quality_snapshot` | CN ChiNext Growth Momentum Quality Snapshot | `feature_snapshot` + manifest | `399006` | **研究轨**:创业板成长动量质量选择。 | +### 研究侧成长 sleeve + +| Profile | 名称 | 输入 | 基准 | 当前角色 | +| --- | --- | --- | --- | --- | +| `cn_chinext_growth_momentum_quality` | CN ChiNext Growth Momentum Quality | `market_history` | `510300` | **研究轨**:创业板成长 sleeve,板块级成长 + 动量 + 质量。 | +| `cn_star_growth_momentum_quality` | CN Star Growth Momentum Quality | `market_history` | `510300` | **研究轨**:科创板成长 sleeve,流动性和集中度更严格。 | + ### 计划中的 snapshot-backed 策略 | Profile | 名称 | 输入 | 基准 | 当前角色 | @@ -94,6 +101,9 @@ python scripts/smoke_cn_index_etf_tactical_rotation_dry_run.py --json - [`docs/platform_integration.md`](docs/platform_integration.md) - [`docs/research/cn_index_etf_tactical_rotation.md`](docs/research/cn_index_etf_tactical_rotation.md) +- [`docs/research/cn_growth_sleeve_redesign_20260705.md`](docs/research/cn_growth_sleeve_redesign_20260705.md) +- [`docs/research/cn_growth_sleeve_structure_20260705.md`](docs/research/cn_growth_sleeve_structure_20260705.md) +- [`docs/research/cn_chinext_growth_momentum_quality_snapshot_20260705.md`](docs/research/cn_chinext_growth_momentum_quality_snapshot_20260705.md) ## 安全和贡献说明 diff --git a/docs/platform_integration.md b/docs/platform_integration.md index bc03c7c..691f04d 100644 --- a/docs/platform_integration.md +++ b/docs/platform_integration.md @@ -18,8 +18,10 @@ | --- | --- | --- | --- | | `cn_index_etf_tactical_rotation` | `market_history` | `qmt` | `weight` | | `cn_dividend_quality_snapshot` | `feature_snapshot` + manifest | `qmt` | `weight` | +| `cn_chinext_growth_momentum_quality` | `market_history` | `qmt` | `weight` | | `cn_chinext_growth_momentum_quality_snapshot` | `feature_snapshot` + manifest | `qmt` | `weight` | | `cn_chinext_tactical_rotation` | `market_history` | `qmt` | `weight` | +| `cn_star_growth_momentum_quality` | `market_history` | `qmt` | `weight` | | `cn_equity_combo` | `market_history` + `feature_snapshot` | `qmt` | `weight` | ## Environment variables (planned for QmtPlatform) diff --git a/docs/research/cn_growth_sleeve_redesign_20260705.md b/docs/research/cn_growth_sleeve_redesign_20260705.md new file mode 100644 index 0000000..57d4be2 --- /dev/null +++ b/docs/research/cn_growth_sleeve_redesign_20260705.md @@ -0,0 +1,124 @@ +# CN Growth Sleeve Redesign Plan (2026-07-05) + +> This is an engineering/research plan, not investment advice. + +## Why this needs a redesign + +The earlier read treated ChiNext and STAR as weaker or secondary because the +existing strategy shapes were too narrow. That was the wrong conclusion. + +Recent market evidence shows both growth boards can be strong regime leaders: + +- ChiNext has printed major highs and has materially outperformed in the current + growth-led phase. +- STAR market / STAR50 has also led in 2026 on a number of public data sources. + +So the issue is not "these boards are weak." The issue is: + +1. the current strategy shapes are too small or too tactical, +2. the board-specific regime is not modeled explicitly enough, +3. the combo layer is using the wrong abstraction. + +## Design conclusion + +ChiNext and STAR should be treated as **independent growth sleeves**, not as +subordinate research leftovers. + +That means: + +- the board itself is a first-class universe, +- the sleeve gets its own regime gate, +- the sleeve gets its own growth / quality / liquidity logic, +- the sleeve can later become a combo component, but not as a dumb appendage. + +## Recommended target architecture + +### 1) ChiNext growth sleeve + +Proposed new shape: + +- universe: ChiNext board +- style: growth + momentum + quality +- cadence: monthly +- gating: benchmark trend filter + liquidity + overheat / crowding penalty + +Key signals: + +- revenue growth +- profit growth +- ROE / profitability stability +- 12M-1M momentum +- 60/120-day trend +- liquidity and execution cost +- board breadth / regime filter + +This should evolve from `cn_chinext_tactical_rotation` into a more explicit +`cn_chinext_growth_momentum_quality` line. + +### 2) STAR growth sleeve + +Proposed new shape: + +- universe: STAR50 or STAR composite / broader STAR universe +- style: growth + quality + liquidity with heavier risk controls +- cadence: monthly +- gating: stricter liquidity and concentration constraints than ChiNext + +STAR should not start as a narrow champion-picking experiment. It should begin +as a board-level enhancement sleeve so the design matches how the market is +actually behaving. + +### 3) CN combo as allocator, not alpha claimant + +`cn_equity_combo` should be rebuilt as a composition/orchestration layer: + +- industry ETF core +- ChiNext growth sleeve +- STAR growth sleeve +- defensive dividend/quality sleeve + +The combo should decide weights and risk budgets. It should not pretend to be a +single alpha source. + +## What should change in policy language + +- `research_backtest_only` should no longer imply "low priority" for ChiNext or + STAR. +- `live_candidate` should only be assigned after the board-specific sleeve beats + the current main line on the same horizon. +- `cn_chinext_tactical_rotation` and `cn_chinext_growth_momentum_quality_snapshot` + should be treated as **growth-sleeve candidates**, not discardable leftovers. + +## What not to do + +- Do not promote a board sleeve just because a short window is hot. +- Do not merge board growth sleeves into the industry ETF main line. +- Do not keep multiple weak variants alive just because each one has a slightly + different filter. +- Do not force live enablement before the board-specific gate is complete. + +## Proposed implementation sequence + +1. Define a board-specific growth sleeve contract for ChiNext. +2. Define a board-specific growth sleeve contract for STAR. +3. Reframe `cn_equity_combo` as a true allocator/orchestrator. +4. Re-run the same-cycle comparisons against the current industry ETF main line. +5. Promote only if the sleeve is still competitive after liquidity and risk + penalties. + +## Practical status recommendation + +- `cn_industry_etf_rotation`: keep as the core A-share main line. +- `cn_chinext_*`: redesign into a first-class growth sleeve. +- `cn_star_*`: add as a separate growth sleeve once the STAR contract is + defined. +- `cn_equity_combo`: rebuild later as composition logic. + +## Reference anchors + +These are the main external reference families used for the redesign: + +- STAR 50 / STAR 100 / STAR 200 official index construction rules from SSE. +- ChiNext as a board-level growth universe with its own index structure. +- MSCI factor index families for growth, quality, momentum, value, and low-volatility framing. +- EDHEC / AQR trend-following research for regime and gating logic. diff --git a/docs/research/cn_growth_sleeve_structure_20260705.md b/docs/research/cn_growth_sleeve_structure_20260705.md new file mode 100644 index 0000000..d05c0e3 --- /dev/null +++ b/docs/research/cn_growth_sleeve_structure_20260705.md @@ -0,0 +1,184 @@ +# CN Growth Sleeve Structure Draft (2026-07-05) + +> This is a design draft for the CN growth stack. It is not a trading +> recommendation. + +## Goal + +Turn the CN growth side from "a few tactical or research leftovers" into a +clean structure with three first-class roles: + +1. **ChiNext growth sleeve** +2. **STAR growth sleeve** +3. **CN combo orchestrator** + +This is a design correction, not just a parameter tweak. + +## Why the old shape was wrong + +The earlier shape treated ChiNext / STAR too much like side research tracks. +That is too narrow for the current market regime. + +Public market evidence shows that both growth boards can lead strongly in a +growth-led phase. The better interpretation is: + +- the boards themselves are valid growth universes, +- the strategy shape needs board-specific gates, +- the combo layer should allocate sleeves, not pretend to be the only alpha. + +## Proposed layer 1: ChiNext growth sleeve + +### Proposed profile name + +- `cn_chinext_growth_momentum_quality` + +### Role + +Board-specific growth sleeve for the ChiNext market. + +### Shape + +- universe: ChiNext board +- cadence: monthly +- style: growth + momentum + quality +- explicit regime gate: benchmark trend + breadth + liquidity + crowding + +### Suggested signals + +- revenue growth +- profit growth +- ROE / profitability stability +- 12M-1M momentum +- 60/120-day trend +- liquidity and execution cost +- overheat / crowding penalty + +### Suggested behavior + +- Can be promoted from the existing research snapshot work. +- Should not be forced to share the same gate model as the industry ETF main line. +- Can later be used as a sleeve inside the combo orchestrator. + +## Proposed layer 2: STAR growth sleeve + +### Proposed profile name + +- `cn_star_growth_momentum_quality` + +### Role + +Board-specific growth sleeve for the STAR market / STAR50-like universe. + +### Shape + +- universe: STAR50, or a broader STAR composite universe such as STAR100 / + STAR200 / special innovation board universes if later evidence supports it +- cadence: monthly +- style: growth + quality + liquidity +- stricter concentration and liquidity controls than ChiNext + +### Suggested signals + +- revenue / earnings growth +- R&D intensity or innovation proxy +- profitability stability +- 12M-1M momentum +- relative trend vs STAR benchmark +- liquidity / turnover / spread penalty + +### Suggested behavior + +- Start as a board-level enhancement sleeve, not a narrow stock picker. +- Use a stricter gate than ChiNext because STAR is more concentration- and + liquidity-sensitive. +- Keep the first version simple and reviewable. + +## Proposed layer 3: CN combo orchestrator + +### Current problem + +`cn_equity_combo` currently reads like a blended research strategy. That is too +ambiguous. + +### New role + +Make it a true orchestrator / allocator: + +- allocates across sleeves +- manages regime budgets +- decides when to emphasize offense vs defense +- does **not** claim to be a single alpha source + +### Suggested sleeves + +- core industry ETF sleeve +- ChiNext growth sleeve +- STAR growth sleeve +- defensive dividend / quality sleeve + +### Suggested runtime shape + +```json +{ + "profile": "cn_equity_combo", + "sleeves": [ + {"profile": "cn_industry_etf_rotation", "weight": 0.40}, + {"profile": "cn_chinext_growth_momentum_quality", "weight": 0.25}, + {"profile": "cn_star_growth_momentum_quality", "weight": 0.15}, + {"profile": "cn_dividend_quality_snapshot", "weight": 0.20} + ] +} +``` + +The actual weights should stay configurable, but the separation of responsibilities +should not change. + +## Gate model + +### Keep / tune + +- keep the industry ETF main line as the core A-share runtime strategy +- keep the aggressive ETF variant as the controlled enhancement lane + +### Redesign + +- ChiNext tactical and ChiNext growth snapshot should be redesigned into the + ChiNext growth sleeve +- STAR should get its own sleeve instead of being folded into the ChiNext logic +- combo should be rebuilt as an orchestrator + +### Do not do + +- do not collapse board growth into the industry ETF main line +- do not keep many small variants alive just because they all have slightly + different filters +- do not promote before the board-specific sleeve beats the current live main + line on the same horizon + +## Evidence sequence + +1. Build the board-specific growth sleeve contract. +2. Run same-cycle comparisons against the current industry ETF main line. +3. Add liquidity / spread / crowding penalties. +4. Confirm the sleeve still wins after penalties. +5. Only then decide whether it becomes `live_candidate` or remains research. + +## External reference shape + +This structure matches the way official board/index sources describe the +universes: + +- ChiNext is a growth-oriented Shenzhen board universe with strong innovation + characteristics. +- STAR Market / STAR50 is the Shanghai innovation board universe, with a + naturally higher concentration and liquidity sensitivity. + +That is why both should be modeled as dedicated sleeves instead of generic +tactical leftovers. + +## Reference anchors + +- SSE official STAR 50 / STAR 100 / STAR 200 index construction rules +- SSE official index and fund disclosures for STAR-linked products +- MSCI factor index families for growth / quality / momentum / low volatility +- EDHEC and AQR trend-following research for regime gating patterns diff --git a/docs/research/cn_growth_sleeve_structure_20260705.zh-CN.md b/docs/research/cn_growth_sleeve_structure_20260705.zh-CN.md new file mode 100644 index 0000000..880dc38 --- /dev/null +++ b/docs/research/cn_growth_sleeve_structure_20260705.zh-CN.md @@ -0,0 +1,171 @@ +# CN 成长 sleeve 结构草案(2026-07-05) + +> 这是一份 CN 成长栈的设计草案,不是交易建议。 + +## 目标 + +把 CN 成长侧从“几个战术线 / 研究残留”整理成清晰结构,拆成三层: + +1. **创业板成长 sleeve** +2. **科创板成长 sleeve** +3. **CN 组合 orchestrator** + +这属于设计纠正,不只是参数微调。 + +## 为什么原来的形态不对 + +之前的形态把创业板 / 科创板当成了偏附属的研究分支,这太窄了。 + +公开市场表现说明这两个成长板块在当前成长行情里是可以领跑的。 +更合理的理解是: + +- 板块本身就是有效的成长 universe, +- 策略形态要单独建板块 gate, +- combo 层应该负责分配 sleeve,不该冒充唯一 alpha。 + +## 第一层:创业板成长 sleeve + +### 建议 profile 名称 + +- `cn_chinext_growth_momentum_quality` + +### 角色 + +面向创业板市场的独立成长 sleeve。 + +### 形态 + +- universe:创业板全池 +- 频率:月频 +- 风格:成长 + 动量 + 质量 +- 显式 regime gate:基准趋势 + 广度 + 流动性 + 拥挤度 + +### 建议因子 + +- 营收增长 +- 利润增长 +- ROE / 盈利稳定性 +- 12M-1M 动量 +- 60/120 日趋势 +- 流动性和执行成本 +- 过热 / 拥挤惩罚 + +### 建议行为 + +- 可以从现有研究 snapshot 继续晋级。 +- 不应该和行业 ETF 主线共用同一套 gate。 +- 后续可以作为 combo orchestrator 的一个 sleeve。 + +## 第二层:科创板成长 sleeve + +### 建议 profile 名称 + +- `cn_star_growth_momentum_quality` + +### 角色 + +面向科创板 / STAR50 universe 的独立成长 sleeve。 + +### 形态 + +- universe:STAR50,或在后续证据支持下扩展到 STAR100 / STAR200 / + 专精特新等更广的科创板 universe +- 频率:月频 +- 风格:成长 + 质量 + 流动性 +- 比创业板更严格的集中度和流动性约束 + +### 建议因子 + +- 营收 / 盈利增长 +- 研发强度或创新代理指标 +- 盈利稳定性 +- 12M-1M 动量 +- 相对 STAR 基准趋势 +- 流动性 / 换手 / spread 惩罚 + +### 建议行为 + +- 第一版先做成板块级增强 sleeve,不要一上来就做很窄的选股冠军。 +- 比创业板更严格,因为科创板对集中度和流动性更敏感。 +- 第一版尽量简单、可审阅。 + +## 第三层:CN combo orchestrator + +### 当前问题 + +`cn_equity_combo` 现在更像一个混合研究策略,语义太模糊。 + +### 新角色 + +把它改成真正的 orchestrator / allocator: + +- 分配不同 sleeve +- 管理 regime budget +- 决定进攻 / 防守的预算倾斜 +- **不再**声称自己是一条单一 alpha 线 + +### 建议 sleeve + +- 行业 ETF 主 sleeve +- 创业板成长 sleeve +- 科创板成长 sleeve +- 防守红利 / 质量 sleeve + +### 建议 runtime 形态 + +```json +{ + "profile": "cn_equity_combo", + "sleeves": [ + {"profile": "cn_industry_etf_rotation", "weight": 0.40}, + {"profile": "cn_chinext_growth_momentum_quality", "weight": 0.25}, + {"profile": "cn_star_growth_momentum_quality", "weight": 0.15}, + {"profile": "cn_dividend_quality_snapshot", "weight": 0.20} + ] +} +``` + +权重可以配置,但职责拆分不能变。 + +## 门槛模型 + +### 继续调参 + +- 行业 ETF 主线继续作为 A 股 runtime 核心策略 +- aggressive ETF 继续作为受控增强线 + +### 需要重构 + +- 创业板 tactical 和创业板 growth snapshot 要重构成创业板成长 sleeve +- 科创板要单独有自己的 sleeve,不要硬塞进创业板逻辑 +- combo 要重做成 orchestrator + +### 不要做 + +- 不要把板块成长直接并进行业 ETF 主线 +- 不要保留太多小变体,只因为每个变体都只改了一点点 +- 不要在同周期里还打不过主线时就提前 live + +## 证据顺序 + +1. 先建板块级成长 sleeve contract。 +2. 跑同周期对照,直接对比当前行业 ETF 主线。 +3. 加入流动性 / spread / 拥挤度惩罚。 +4. 确认惩罚后仍然能赢。 +5. 再决定是 `live_candidate` 还是继续 research。 + +## 外部参考形态 + +这个结构和官方板块/指数定义是匹配的: + +- 创业板本身就是成长属性很强的深交所板块 universe。 +- 科创板 / 科创50 是上交所创新板块 universe,天然更集中、更敏感。 + +所以这两条线应该是独立 sleeve,而不是 generic tactical leftovers。 + +## 参考锚点 + +- 上交所官方 STAR 50 / STAR 100 / STAR 200 指数编制方案 +- 上交所科创板相关指数和基金披露材料 +- MSCI 因子指数家族中关于 growth / quality / momentum / low volatility 的框架 +- EDHEC 和 AQR 趋势跟随研究中的 regime gate 逻辑 diff --git a/src/cn_equity_strategies/__init__.py b/src/cn_equity_strategies/__init__.py index d85698e..af6bdb4 100644 --- a/src/cn_equity_strategies/__init__.py +++ b/src/cn_equity_strategies/__init__.py @@ -2,6 +2,7 @@ __all__ = [ "CN_EQUITY_DOMAIN", + "CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE", "CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE", "CN_EXTERNAL_SNAPSHOT_SCAFFOLD_PROFILES", "CN_INDEX_ETF_TACTICAL_ROTATION_PROFILE", @@ -11,6 +12,7 @@ "CN_RESEARCH_BACKTEST_ONLY_PROFILES", "CN_SNAPSHOT_BACKED_PROFILES", "CN_SCAFFOLD_PROFILES", + "CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE", "STRATEGY_CATALOG", "STRATEGY_DEFINITIONS", "get_compatible_platforms", @@ -41,6 +43,7 @@ def __getattr__(name: str): if name in { "CN_EQUITY_DOMAIN", + "CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE", "CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE", "CN_EXTERNAL_SNAPSHOT_SCAFFOLD_PROFILES", "CN_INDEX_ETF_TACTICAL_ROTATION_PROFILE", @@ -50,6 +53,7 @@ def __getattr__(name: str): "CN_RESEARCH_BACKTEST_ONLY_PROFILES", "CN_SNAPSHOT_BACKED_PROFILES", "CN_SCAFFOLD_PROFILES", + "CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE", "STRATEGY_CATALOG", "STRATEGY_DEFINITIONS", "get_compatible_platforms", diff --git a/src/cn_equity_strategies/catalog.py b/src/cn_equity_strategies/catalog.py index 8063514..d4f1e39 100644 --- a/src/cn_equity_strategies/catalog.py +++ b/src/cn_equity_strategies/catalog.py @@ -17,12 +17,14 @@ from cn_equity_strategies.strategies import cn_dividend_quality_snapshot as dividend_quality_strategy from cn_equity_strategies.strategies import ( - cn_chinext_growth_momentum_quality_snapshot as chinext_growth_momentum_quality_strategy, + cn_chinext_growth_momentum_quality as chinext_growth_momentum_quality_strategy, + cn_chinext_growth_momentum_quality_snapshot as chinext_growth_momentum_quality_snapshot_strategy, ) from cn_equity_strategies.strategies import cn_chinext_tactical_rotation as chinext_tactical_strategy from cn_equity_strategies.strategies import cn_index_etf_tactical_rotation as index_etf_strategy from cn_equity_strategies.strategies import cn_industry_etf_rotation as industry_etf_strategy from cn_equity_strategies.strategies import cn_industry_etf_rotation_aggressive as industry_etf_aggressive_strategy +from cn_equity_strategies.strategies import cn_star_growth_momentum_quality as star_growth_momentum_quality_strategy from cn_equity_strategies.strategies import cn_equity_combo as cn_combo_strategy @@ -32,14 +34,18 @@ CN_INDUSTRY_ETF_ROTATION_AGGRESSIVE_PROFILE = industry_etf_aggressive_strategy.PROFILE_NAME CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE = dividend_quality_strategy.PROFILE_NAME CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE = ( + chinext_growth_momentum_quality_snapshot_strategy.PROFILE_NAME +) +CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE = ( chinext_growth_momentum_quality_strategy.PROFILE_NAME ) +CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE = star_growth_momentum_quality_strategy.PROFILE_NAME CN_CHINEXT_TACTICAL_ROTATION_PROFILE = chinext_tactical_strategy.PROFILE_NAME CN_EQUITY_COMBO_PROFILE = cn_combo_strategy.PROFILE_NAME STRATEGY_STATUS_RUNTIME_ENABLED = "runtime_enabled" STRATEGY_STATUS_LIVE_CANDIDATE = "live_candidate" -STRATEGY_STATUS_SHADOW = "shadow" +STRATEGY_STATUS_SHADOW = "shadow_candidate" STRATEGY_STATUS_RESEARCH_BACKTEST_ONLY = "research_backtest_only" STRATEGY_STATUS_SCAFFOLD = "scaffold" @@ -62,10 +68,12 @@ CN_RESEARCH_BACKTEST_ONLY_PROFILES = frozenset( { CN_INDEX_ETF_TACTICAL_ROTATION_PROFILE, + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE, CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE, CN_EQUITY_COMBO_PROFILE, CN_CHINEXT_TACTICAL_ROTATION_PROFILE, + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE, } ) CN_SHADOW_PROFILES = frozenset() @@ -78,9 +86,11 @@ CN_INDUSTRY_ETF_ROTATION_PROFILE: frozenset({"qmt"}), CN_INDUSTRY_ETF_ROTATION_AGGRESSIVE_PROFILE: frozenset({"qmt"}), CN_INDEX_ETF_TACTICAL_ROTATION_PROFILE: frozenset({"qmt"}), + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE: frozenset({"qmt"}), CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: frozenset({"qmt"}), CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE: frozenset({"qmt"}), CN_CHINEXT_TACTICAL_ROTATION_PROFILE: frozenset({"qmt"}), + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE: frozenset({"qmt"}), CN_EQUITY_COMBO_PROFILE: frozenset({"qmt"}), } @@ -88,9 +98,11 @@ CN_INDUSTRY_ETF_ROTATION_PROFILE: frozenset({"market_history"}), CN_INDUSTRY_ETF_ROTATION_AGGRESSIVE_PROFILE: frozenset({"market_history"}), CN_INDEX_ETF_TACTICAL_ROTATION_PROFILE: frozenset({"market_history"}), + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE: frozenset({"market_history"}), CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: frozenset({"feature_snapshot"}), CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE: frozenset({"feature_snapshot"}), CN_CHINEXT_TACTICAL_ROTATION_PROFILE: frozenset({"market_history"}), + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE: frozenset({"market_history"}), CN_EQUITY_COMBO_PROFILE: frozenset({"market_history", "feature_snapshot"}), } @@ -152,6 +164,25 @@ "min_history_days": index_etf_strategy.DEFAULT_MIN_HISTORY_DAYS, "execution_cash_reserve_ratio": index_etf_strategy.DEFAULT_EXECUTION_CASH_RESERVE_RATIO, }, + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE: { + "universe_symbols": chinext_growth_momentum_quality_strategy.DEFAULT_UNIVERSE_SYMBOLS, + "defensive_symbols": chinext_growth_momentum_quality_strategy.DEFAULT_DEFENSIVE_SYMBOLS, + "benchmark_symbol": chinext_growth_momentum_quality_strategy.DEFAULT_BENCHMARK_SYMBOL, + "momentum_window_days": chinext_growth_momentum_quality_strategy.DEFAULT_MOMENTUM_WINDOW_DAYS, + "trend_window_days": chinext_growth_momentum_quality_strategy.DEFAULT_TREND_WINDOW_DAYS, + "benchmark_trend_window_days": chinext_growth_momentum_quality_strategy.DEFAULT_BENCHMARK_TREND_WINDOW_DAYS, + "volatility_window_days": chinext_growth_momentum_quality_strategy.DEFAULT_VOLATILITY_WINDOW_DAYS, + "top_n": chinext_growth_momentum_quality_strategy.DEFAULT_TOP_N, + "min_momentum": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_MOMENTUM, + "rebalance_frequency": chinext_growth_momentum_quality_strategy.DEFAULT_REBALANCE_FREQUENCY, + "weighting_mode": chinext_growth_momentum_quality_strategy.DEFAULT_WEIGHTING_MODE, + "target_annual_volatility": chinext_growth_momentum_quality_strategy.DEFAULT_TARGET_ANNUAL_VOLATILITY, + "max_gross_exposure": chinext_growth_momentum_quality_strategy.DEFAULT_MAX_GROSS_EXPOSURE, + "min_history_days": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_HISTORY_DAYS, + "max_pair_correlation": chinext_growth_momentum_quality_strategy.DEFAULT_MAX_PAIR_CORRELATION, + "sentiment_mode": chinext_growth_momentum_quality_strategy.DEFAULT_SENTIMENT_MODE, + "execution_cash_reserve_ratio": chinext_growth_momentum_quality_strategy.DEFAULT_EXECUTION_CASH_RESERVE_RATIO, + }, CN_CHINEXT_TACTICAL_ROTATION_PROFILE: { "universe_symbols": chinext_tactical_strategy.DEFAULT_UNIVERSE_SYMBOLS, "defensive_symbols": chinext_tactical_strategy.DEFAULT_DEFENSIVE_SYMBOLS, @@ -169,6 +200,25 @@ "min_history_days": chinext_tactical_strategy.DEFAULT_MIN_HISTORY_DAYS, "max_pair_correlation": chinext_tactical_strategy.DEFAULT_MAX_PAIR_CORRELATION, }, + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE: { + "universe_symbols": star_growth_momentum_quality_strategy.DEFAULT_UNIVERSE_SYMBOLS, + "defensive_symbols": star_growth_momentum_quality_strategy.DEFAULT_DEFENSIVE_SYMBOLS, + "benchmark_symbol": star_growth_momentum_quality_strategy.DEFAULT_BENCHMARK_SYMBOL, + "momentum_window_days": star_growth_momentum_quality_strategy.DEFAULT_MOMENTUM_WINDOW_DAYS, + "trend_window_days": star_growth_momentum_quality_strategy.DEFAULT_TREND_WINDOW_DAYS, + "benchmark_trend_window_days": star_growth_momentum_quality_strategy.DEFAULT_BENCHMARK_TREND_WINDOW_DAYS, + "volatility_window_days": star_growth_momentum_quality_strategy.DEFAULT_VOLATILITY_WINDOW_DAYS, + "top_n": star_growth_momentum_quality_strategy.DEFAULT_TOP_N, + "min_momentum": star_growth_momentum_quality_strategy.DEFAULT_MIN_MOMENTUM, + "rebalance_frequency": star_growth_momentum_quality_strategy.DEFAULT_REBALANCE_FREQUENCY, + "weighting_mode": star_growth_momentum_quality_strategy.DEFAULT_WEIGHTING_MODE, + "target_annual_volatility": star_growth_momentum_quality_strategy.DEFAULT_TARGET_ANNUAL_VOLATILITY, + "max_gross_exposure": star_growth_momentum_quality_strategy.DEFAULT_MAX_GROSS_EXPOSURE, + "min_history_days": star_growth_momentum_quality_strategy.DEFAULT_MIN_HISTORY_DAYS, + "max_pair_correlation": star_growth_momentum_quality_strategy.DEFAULT_MAX_PAIR_CORRELATION, + "sentiment_mode": star_growth_momentum_quality_strategy.DEFAULT_SENTIMENT_MODE, + "execution_cash_reserve_ratio": star_growth_momentum_quality_strategy.DEFAULT_EXECUTION_CASH_RESERVE_RATIO, + }, CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: { "safe_haven": dividend_quality_strategy.SAFE_HAVEN, "holdings_count": dividend_quality_strategy.DEFAULT_HOLDINGS_COUNT, @@ -194,29 +244,30 @@ "rebalance_frequency": "monthly", }, CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE: { - "safe_haven": chinext_growth_momentum_quality_strategy.SAFE_HAVEN, - "holdings_count": chinext_growth_momentum_quality_strategy.DEFAULT_HOLDINGS_COUNT, - "single_name_cap": chinext_growth_momentum_quality_strategy.DEFAULT_SINGLE_NAME_CAP, - "sector_cap": chinext_growth_momentum_quality_strategy.DEFAULT_SECTOR_CAP, - "min_adv20_cny": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_ADV20_CNY, - "min_market_cap_cny": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_MARKET_CAP_CNY, - "min_revenue_yoy": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_REVENUE_YOY, - "min_profit_yoy": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_PROFIT_YOY, - "min_momentum": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_MOMENTUM, - "min_list_days": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_LIST_DAYS, - "hold_buffer": chinext_growth_momentum_quality_strategy.DEFAULT_HOLD_BUFFER, - "hold_bonus": chinext_growth_momentum_quality_strategy.DEFAULT_HOLD_BONUS, - "risk_on_exposure": chinext_growth_momentum_quality_strategy.DEFAULT_RISK_ON_EXPOSURE, - "soft_defense_exposure": chinext_growth_momentum_quality_strategy.DEFAULT_SOFT_DEFENSE_EXPOSURE, - "hard_defense_exposure": chinext_growth_momentum_quality_strategy.DEFAULT_HARD_DEFENSE_EXPOSURE, - "soft_breadth_threshold": chinext_growth_momentum_quality_strategy.DEFAULT_SOFT_BREADTH_THRESHOLD, - "hard_breadth_threshold": chinext_growth_momentum_quality_strategy.DEFAULT_HARD_BREADTH_THRESHOLD, - "execution_cash_reserve_ratio": chinext_growth_momentum_quality_strategy.DEFAULT_EXECUTION_CASH_RESERVE_RATIO, + "safe_haven": chinext_growth_momentum_quality_snapshot_strategy.SAFE_HAVEN, + "holdings_count": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_HOLDINGS_COUNT, + "single_name_cap": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_SINGLE_NAME_CAP, + "sector_cap": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_SECTOR_CAP, + "min_adv20_cny": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_MIN_ADV20_CNY, + "min_market_cap_cny": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_MIN_MARKET_CAP_CNY, + "min_revenue_yoy": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_MIN_REVENUE_YOY, + "min_profit_yoy": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_MIN_PROFIT_YOY, + "min_momentum": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_MIN_MOMENTUM, + "min_list_days": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_MIN_LIST_DAYS, + "hold_buffer": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_HOLD_BUFFER, + "hold_bonus": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_HOLD_BONUS, + "risk_on_exposure": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_RISK_ON_EXPOSURE, + "soft_defense_exposure": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_SOFT_DEFENSE_EXPOSURE, + "hard_defense_exposure": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_HARD_DEFENSE_EXPOSURE, + "soft_breadth_threshold": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_SOFT_BREADTH_THRESHOLD, + "hard_breadth_threshold": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_HARD_BREADTH_THRESHOLD, + "execution_cash_reserve_ratio": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_EXECUTION_CASH_RESERVE_RATIO, "rebalance_frequency": "monthly", }, CN_EQUITY_COMBO_PROFILE: { "etf_weight": 0.30, "stock_weight": 0.50, + "growth_weight": 0.50, "dividend_weight": 0.20, "execution_cash_reserve_ratio": 0.02, "rebalance_frequency": "monthly", @@ -227,11 +278,13 @@ CN_INDUSTRY_ETF_ROTATION_PROFILE: "cn_industry_etf_rotation_entrypoint", CN_INDUSTRY_ETF_ROTATION_AGGRESSIVE_PROFILE: "cn_industry_etf_rotation_aggressive_entrypoint", CN_INDEX_ETF_TACTICAL_ROTATION_PROFILE: "cn_index_etf_tactical_rotation_entrypoint", + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE: "cn_chinext_growth_momentum_quality_entrypoint", CN_CHINEXT_TACTICAL_ROTATION_PROFILE: "cn_chinext_tactical_rotation_entrypoint", CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: "cn_dividend_quality_snapshot_entrypoint", CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE: ( "cn_chinext_growth_momentum_quality_snapshot_entrypoint" ), + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE: "cn_star_growth_momentum_quality_entrypoint", CN_EQUITY_COMBO_PROFILE: "cn_equity_combo_entrypoint", } @@ -239,9 +292,11 @@ CN_INDUSTRY_ETF_ROTATION_PROFILE: "weight", CN_INDUSTRY_ETF_ROTATION_AGGRESSIVE_PROFILE: "weight", CN_INDEX_ETF_TACTICAL_ROTATION_PROFILE: "weight", + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE: "weight", CN_CHINEXT_TACTICAL_ROTATION_PROFILE: "weight", CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: "weight", CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE: "weight", + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE: "weight", CN_EQUITY_COMBO_PROFILE: "weight", } @@ -288,6 +343,11 @@ def _build_strategy_definition( component_name="signal_logic", module_path="cn_equity_strategies.strategies.cn_index_etf_tactical_rotation", ), + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE: _build_strategy_definition( + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE, + component_name="signal_logic", + module_path="cn_equity_strategies.strategies.cn_chinext_growth_momentum_quality", + ), CN_CHINEXT_TACTICAL_ROTATION_PROFILE: _build_strategy_definition( CN_CHINEXT_TACTICAL_ROTATION_PROFILE, component_name="signal_logic", @@ -303,6 +363,11 @@ def _build_strategy_definition( component_name="signal_logic", module_path="cn_equity_strategies.strategies.cn_chinext_growth_momentum_quality_snapshot", ), + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE: _build_strategy_definition( + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE, + component_name="signal_logic", + module_path="cn_equity_strategies.strategies.cn_star_growth_momentum_quality", + ), CN_EQUITY_COMBO_PROFILE: _build_strategy_definition( CN_EQUITY_COMBO_PROFILE, component_name="signal_logic", @@ -372,6 +437,20 @@ def _build_strategy_definition( role="cn_non_snapshot_chinext_etf_rotation", status="research_backtest_only", ), + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE: StrategyMetadata( + canonical_profile=CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE, + display_name="CN ChiNext Growth Momentum Quality", + localized_display_names={"zh": "创业板成长动量质量"}, + description=( + "Research-only ChiNext growth sleeve using momentum, trend, and benchmark risk-off switching." + ), + aliases=(), + cadence="monthly review", + asset_scope="cn_listed_chinext_etfs", + benchmark="510300", + role="cn_non_snapshot_chinext_growth_momentum_quality", + status="research_backtest_only", + ), CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE: StrategyMetadata( canonical_profile=CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, display_name="CN Dividend Quality Snapshot", @@ -402,11 +481,25 @@ def _build_strategy_definition( role="cn_snapshot_chinext_growth_momentum_quality", status="research_backtest_only", ), + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE: StrategyMetadata( + canonical_profile=CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE, + display_name="CN Star Growth Momentum Quality", + localized_display_names={"zh": "科创板成长动量质量"}, + description=( + "Research-only STAR sleeve using momentum, trend, and benchmark risk-off switching." + ), + aliases=(), + cadence="monthly review", + asset_scope="cn_listed_star_etfs", + benchmark="510300", + role="cn_non_snapshot_star_growth_momentum_quality", + status="research_backtest_only", + ), CN_EQUITY_COMBO_PROFILE: StrategyMetadata( canonical_profile=CN_EQUITY_COMBO_PROFILE, display_name="CN Equity Combo", description=( - "Research-only CN equity combo: ETF rotation (30%) + stock momentum (50%) + " + "Research-only CN equity combo orchestrator: ETF rotation (30%) + growth sleeves (50%) + " "dividend quality (20%)." ), aliases=(), diff --git a/src/cn_equity_strategies/combo_manifests.py b/src/cn_equity_strategies/combo_manifests.py index 4572a28..310e247 100644 --- a/src/cn_equity_strategies/combo_manifests.py +++ b/src/cn_equity_strategies/combo_manifests.py @@ -9,10 +9,10 @@ profile=CN_EQUITY_COMBO_PROFILE, domain="cn_equity", display_name="CN Equity Combo", - description="CN equity combo: ETF rotation (30%) + stock momentum (50%) + dividend quality (20%)", + description="CN equity combo orchestrator: ETF rotation (30%) + growth sleeves (50%) + dividend quality (20%)", aliases=(), required_inputs=frozenset({"market_history", "feature_snapshot"}), - default_config={"etf_weight": 0.30, "stock_weight": 0.50, "dividend_weight": 0.20, "execution_cash_reserve_ratio": 0.02, "rebalance_frequency": "monthly"}, + default_config={"etf_weight": 0.30, "stock_weight": 0.50, "growth_weight": 0.50, "dividend_weight": 0.20, "execution_cash_reserve_ratio": 0.02, "rebalance_frequency": "monthly"}, ) __all__ = ["CN_EQUITY_COMBO_PROFILE", "cn_equity_combo_manifest"] diff --git a/src/cn_equity_strategies/entrypoints/__init__.py b/src/cn_equity_strategies/entrypoints/__init__.py index a0ec5dd..0517d06 100644 --- a/src/cn_equity_strategies/entrypoints/__init__.py +++ b/src/cn_equity_strategies/entrypoints/__init__.py @@ -3,21 +3,25 @@ from quant_platform_kit.strategy_contracts import CallableStrategyEntrypoint, StrategyContext, StrategyDecision from cn_equity_strategies.manifests import ( + cn_chinext_growth_momentum_quality_manifest, cn_chinext_growth_momentum_quality_snapshot_manifest, cn_chinext_tactical_rotation_manifest, cn_dividend_quality_snapshot_manifest, cn_index_etf_tactical_rotation_manifest, cn_industry_etf_rotation_aggressive_manifest, cn_industry_etf_rotation_manifest, + cn_star_growth_momentum_quality_manifest, ) from cn_equity_strategies.strategies import ( - cn_chinext_growth_momentum_quality_snapshot as chinext_growth_momentum_quality_strategy, + cn_chinext_growth_momentum_quality as chinext_growth_momentum_quality_strategy, + cn_chinext_growth_momentum_quality_snapshot as chinext_growth_momentum_quality_snapshot_strategy, ) from cn_equity_strategies.strategies import cn_chinext_tactical_rotation as chinext_tactical_strategy from cn_equity_strategies.strategies import cn_dividend_quality_snapshot as dividend_quality_strategy from cn_equity_strategies.strategies import cn_index_etf_tactical_rotation as index_etf_strategy from cn_equity_strategies.strategies import cn_industry_etf_rotation as industry_etf_strategy from cn_equity_strategies.strategies import cn_industry_etf_rotation_aggressive as industry_etf_aggressive_strategy +from cn_equity_strategies.strategies import cn_star_growth_momentum_quality as star_growth_momentum_quality_strategy from ._common import get_current_holdings, merge_runtime_config, require_market_data, weights_to_positions @@ -158,13 +162,50 @@ def evaluate_cn_chinext_tactical_rotation(ctx: StrategyContext) -> StrategyDecis ) +def evaluate_cn_chinext_growth_momentum_quality(ctx: StrategyContext) -> StrategyDecision: + config = merge_runtime_config(cn_chinext_growth_momentum_quality_manifest.default_config, ctx) + config.pop("execution_cash_reserve_ratio", None) + config.pop("rebalance_frequency", None) + config.pop("run_as_of", None) + weights, signal_desc, has_cash_residual, status_desc, metadata = ( + chinext_growth_momentum_quality_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": chinext_growth_momentum_quality_strategy.SIGNAL_SOURCE, + "actionable": True, + } + risk_flags: tuple[str, ...] = () + if has_cash_residual: + risk_flags += ("cash_residual",) + if metadata.get("benchmark_risk_off"): + risk_flags += ("benchmark_risk_off",) + return StrategyDecision( + positions=weights_to_positions(weights), + risk_flags=risk_flags, + diagnostics=diagnostics, + ) + + +cn_chinext_growth_momentum_quality_entrypoint = CallableStrategyEntrypoint( + manifest=cn_chinext_growth_momentum_quality_manifest, + _evaluate=evaluate_cn_chinext_growth_momentum_quality, +) + + def evaluate_cn_chinext_growth_momentum_quality_snapshot(ctx: StrategyContext) -> StrategyDecision: config = merge_runtime_config(cn_chinext_growth_momentum_quality_snapshot_manifest.default_config, ctx) config.pop("execution_cash_reserve_ratio", None) config.pop("rebalance_frequency", None) config.pop("run_as_of", None) weights, signal_desc, has_cash_residual, status_desc, metadata = ( - chinext_growth_momentum_quality_strategy.compute_signals( + chinext_growth_momentum_quality_snapshot_strategy.compute_signals( require_market_data(ctx, "feature_snapshot"), get_current_holdings(ctx), **config, @@ -174,7 +215,7 @@ def evaluate_cn_chinext_growth_momentum_quality_snapshot(ctx: StrategyContext) - **metadata, "signal_description": signal_desc, "status_description": status_desc, - "signal_source": chinext_growth_momentum_quality_strategy.SIGNAL_SOURCE, + "signal_source": chinext_growth_momentum_quality_snapshot_strategy.SIGNAL_SOURCE, "actionable": True, } risk_flags: tuple[str, ...] = () @@ -193,6 +234,43 @@ def evaluate_cn_chinext_growth_momentum_quality_snapshot(ctx: StrategyContext) - ) +def evaluate_cn_star_growth_momentum_quality(ctx: StrategyContext) -> StrategyDecision: + config = merge_runtime_config(cn_star_growth_momentum_quality_manifest.default_config, ctx) + config.pop("execution_cash_reserve_ratio", None) + config.pop("rebalance_frequency", None) + config.pop("run_as_of", None) + weights, signal_desc, has_cash_residual, status_desc, metadata = ( + star_growth_momentum_quality_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": star_growth_momentum_quality_strategy.SIGNAL_SOURCE, + "actionable": True, + } + risk_flags: tuple[str, ...] = () + if has_cash_residual: + risk_flags += ("cash_residual",) + if metadata.get("benchmark_risk_off"): + risk_flags += ("benchmark_risk_off",) + return StrategyDecision( + positions=weights_to_positions(weights), + risk_flags=risk_flags, + diagnostics=diagnostics, + ) + + +cn_star_growth_momentum_quality_entrypoint = CallableStrategyEntrypoint( + manifest=cn_star_growth_momentum_quality_manifest, + _evaluate=evaluate_cn_star_growth_momentum_quality, +) + + def evaluate_cn_dividend_quality_snapshot(ctx: StrategyContext) -> StrategyDecision: config = merge_runtime_config(cn_dividend_quality_snapshot_manifest.default_config, ctx) config.pop("execution_cash_reserve_ratio", None) @@ -245,18 +323,22 @@ def evaluate_cn_equity_combo(ctx: StrategyContext) -> StrategyDecision: __all__ = [ + "evaluate_cn_chinext_growth_momentum_quality", "evaluate_cn_chinext_growth_momentum_quality_snapshot", "evaluate_cn_dividend_quality_snapshot", "evaluate_cn_chinext_tactical_rotation", "evaluate_cn_index_etf_tactical_rotation", "evaluate_cn_industry_etf_rotation", "evaluate_cn_industry_etf_rotation_aggressive", + "evaluate_cn_star_growth_momentum_quality", "evaluate_cn_equity_combo", + "cn_chinext_growth_momentum_quality_entrypoint", "cn_chinext_growth_momentum_quality_snapshot_entrypoint", "cn_chinext_tactical_rotation_entrypoint", "cn_dividend_quality_snapshot_entrypoint", "cn_index_etf_tactical_rotation_entrypoint", "cn_industry_etf_rotation_aggressive_entrypoint", "cn_industry_etf_rotation_entrypoint", + "cn_star_growth_momentum_quality_entrypoint", "cn_equity_combo_entrypoint", ] diff --git a/src/cn_equity_strategies/manifests/__init__.py b/src/cn_equity_strategies/manifests/__init__.py index 7b18855..120c1c5 100644 --- a/src/cn_equity_strategies/manifests/__init__.py +++ b/src/cn_equity_strategies/manifests/__init__.py @@ -4,18 +4,24 @@ from cn_equity_strategies.strategies import cn_dividend_quality_snapshot as dividend_quality_strategy from cn_equity_strategies.strategies import ( - cn_chinext_growth_momentum_quality_snapshot as chinext_growth_momentum_quality_strategy, + cn_chinext_growth_momentum_quality as chinext_growth_momentum_quality_strategy, + cn_chinext_growth_momentum_quality_snapshot as chinext_growth_momentum_quality_snapshot_strategy, ) from cn_equity_strategies.strategies import cn_chinext_tactical_rotation as chinext_tactical_strategy from cn_equity_strategies.strategies import cn_index_etf_tactical_rotation as index_etf_strategy from cn_equity_strategies.strategies import cn_industry_etf_rotation as industry_etf_strategy from cn_equity_strategies.strategies import cn_industry_etf_rotation_aggressive as industry_etf_aggressive_strategy +from cn_equity_strategies.strategies import cn_star_growth_momentum_quality as star_growth_momentum_quality_strategy CN_INDEX_ETF_TACTICAL_ROTATION_PROFILE = index_etf_strategy.PROFILE_NAME CN_CHINEXT_TACTICAL_ROTATION_PROFILE = chinext_tactical_strategy.PROFILE_NAME -CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE = ( +CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE = ( chinext_growth_momentum_quality_strategy.PROFILE_NAME ) +CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE = ( + chinext_growth_momentum_quality_snapshot_strategy.PROFILE_NAME +) +CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE = star_growth_momentum_quality_strategy.PROFILE_NAME CN_INDUSTRY_ETF_ROTATION_PROFILE = industry_etf_strategy.PROFILE_NAME CN_INDUSTRY_ETF_ROTATION_AGGRESSIVE_PROFILE = industry_etf_aggressive_strategy.PROFILE_NAME CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE = dividend_quality_strategy.PROFILE_NAME @@ -164,6 +170,36 @@ def _manifest( }, ) +cn_chinext_growth_momentum_quality_manifest = _manifest( + profile=CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE, + domain=chinext_growth_momentum_quality_strategy.CN_EQUITY_DOMAIN, + display_name="CN ChiNext Growth Momentum Quality", + description=( + "Research-only ChiNext growth sleeve using momentum, trend, and benchmark risk-off switching." + ), + aliases=(), + required_inputs=frozenset({"market_history"}), + default_config={ + "universe_symbols": chinext_growth_momentum_quality_strategy.DEFAULT_UNIVERSE_SYMBOLS, + "defensive_symbols": chinext_growth_momentum_quality_strategy.DEFAULT_DEFENSIVE_SYMBOLS, + "benchmark_symbol": chinext_growth_momentum_quality_strategy.DEFAULT_BENCHMARK_SYMBOL, + "momentum_window_days": chinext_growth_momentum_quality_strategy.DEFAULT_MOMENTUM_WINDOW_DAYS, + "trend_window_days": chinext_growth_momentum_quality_strategy.DEFAULT_TREND_WINDOW_DAYS, + "benchmark_trend_window_days": chinext_growth_momentum_quality_strategy.DEFAULT_BENCHMARK_TREND_WINDOW_DAYS, + "volatility_window_days": chinext_growth_momentum_quality_strategy.DEFAULT_VOLATILITY_WINDOW_DAYS, + "top_n": chinext_growth_momentum_quality_strategy.DEFAULT_TOP_N, + "min_momentum": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_MOMENTUM, + "rebalance_frequency": chinext_growth_momentum_quality_strategy.DEFAULT_REBALANCE_FREQUENCY, + "weighting_mode": chinext_growth_momentum_quality_strategy.DEFAULT_WEIGHTING_MODE, + "target_annual_volatility": chinext_growth_momentum_quality_strategy.DEFAULT_TARGET_ANNUAL_VOLATILITY, + "max_gross_exposure": chinext_growth_momentum_quality_strategy.DEFAULT_MAX_GROSS_EXPOSURE, + "min_history_days": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_HISTORY_DAYS, + "max_pair_correlation": chinext_growth_momentum_quality_strategy.DEFAULT_MAX_PAIR_CORRELATION, + "sentiment_mode": chinext_growth_momentum_quality_strategy.DEFAULT_SENTIMENT_MODE, + "execution_cash_reserve_ratio": chinext_growth_momentum_quality_strategy.DEFAULT_EXECUTION_CASH_RESERVE_RATIO, + }, +) + cn_chinext_growth_momentum_quality_snapshot_manifest = _manifest( profile=CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE, domain=chinext_growth_momentum_quality_strategy.CN_EQUITY_DOMAIN, @@ -175,28 +211,58 @@ def _manifest( aliases=(), required_inputs=frozenset({"feature_snapshot"}), default_config={ - "safe_haven": chinext_growth_momentum_quality_strategy.SAFE_HAVEN, - "holdings_count": chinext_growth_momentum_quality_strategy.DEFAULT_HOLDINGS_COUNT, - "single_name_cap": chinext_growth_momentum_quality_strategy.DEFAULT_SINGLE_NAME_CAP, - "sector_cap": chinext_growth_momentum_quality_strategy.DEFAULT_SECTOR_CAP, - "min_adv20_cny": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_ADV20_CNY, - "min_market_cap_cny": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_MARKET_CAP_CNY, - "min_revenue_yoy": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_REVENUE_YOY, - "min_profit_yoy": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_PROFIT_YOY, - "min_momentum": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_MOMENTUM, - "min_list_days": chinext_growth_momentum_quality_strategy.DEFAULT_MIN_LIST_DAYS, - "hold_buffer": chinext_growth_momentum_quality_strategy.DEFAULT_HOLD_BUFFER, - "hold_bonus": chinext_growth_momentum_quality_strategy.DEFAULT_HOLD_BONUS, - "risk_on_exposure": chinext_growth_momentum_quality_strategy.DEFAULT_RISK_ON_EXPOSURE, - "soft_defense_exposure": chinext_growth_momentum_quality_strategy.DEFAULT_SOFT_DEFENSE_EXPOSURE, - "hard_defense_exposure": chinext_growth_momentum_quality_strategy.DEFAULT_HARD_DEFENSE_EXPOSURE, - "soft_breadth_threshold": chinext_growth_momentum_quality_strategy.DEFAULT_SOFT_BREADTH_THRESHOLD, - "hard_breadth_threshold": chinext_growth_momentum_quality_strategy.DEFAULT_HARD_BREADTH_THRESHOLD, - "execution_cash_reserve_ratio": chinext_growth_momentum_quality_strategy.DEFAULT_EXECUTION_CASH_RESERVE_RATIO, + "safe_haven": chinext_growth_momentum_quality_snapshot_strategy.SAFE_HAVEN, + "holdings_count": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_HOLDINGS_COUNT, + "single_name_cap": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_SINGLE_NAME_CAP, + "sector_cap": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_SECTOR_CAP, + "min_adv20_cny": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_MIN_ADV20_CNY, + "min_market_cap_cny": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_MIN_MARKET_CAP_CNY, + "min_revenue_yoy": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_MIN_REVENUE_YOY, + "min_profit_yoy": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_MIN_PROFIT_YOY, + "min_momentum": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_MIN_MOMENTUM, + "min_list_days": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_MIN_LIST_DAYS, + "hold_buffer": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_HOLD_BUFFER, + "hold_bonus": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_HOLD_BONUS, + "risk_on_exposure": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_RISK_ON_EXPOSURE, + "soft_defense_exposure": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_SOFT_DEFENSE_EXPOSURE, + "hard_defense_exposure": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_HARD_DEFENSE_EXPOSURE, + "soft_breadth_threshold": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_SOFT_BREADTH_THRESHOLD, + "hard_breadth_threshold": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_HARD_BREADTH_THRESHOLD, + "execution_cash_reserve_ratio": chinext_growth_momentum_quality_snapshot_strategy.DEFAULT_EXECUTION_CASH_RESERVE_RATIO, "rebalance_frequency": "monthly", }, ) +cn_star_growth_momentum_quality_manifest = _manifest( + profile=CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE, + domain=star_growth_momentum_quality_strategy.CN_EQUITY_DOMAIN, + display_name="CN Star Growth Momentum Quality", + description=( + "Research-only STAR sleeve using momentum, trend, and benchmark risk-off switching." + ), + aliases=(), + required_inputs=frozenset({"market_history"}), + default_config={ + "universe_symbols": star_growth_momentum_quality_strategy.DEFAULT_UNIVERSE_SYMBOLS, + "defensive_symbols": star_growth_momentum_quality_strategy.DEFAULT_DEFENSIVE_SYMBOLS, + "benchmark_symbol": star_growth_momentum_quality_strategy.DEFAULT_BENCHMARK_SYMBOL, + "momentum_window_days": star_growth_momentum_quality_strategy.DEFAULT_MOMENTUM_WINDOW_DAYS, + "trend_window_days": star_growth_momentum_quality_strategy.DEFAULT_TREND_WINDOW_DAYS, + "benchmark_trend_window_days": star_growth_momentum_quality_strategy.DEFAULT_BENCHMARK_TREND_WINDOW_DAYS, + "volatility_window_days": star_growth_momentum_quality_strategy.DEFAULT_VOLATILITY_WINDOW_DAYS, + "top_n": star_growth_momentum_quality_strategy.DEFAULT_TOP_N, + "min_momentum": star_growth_momentum_quality_strategy.DEFAULT_MIN_MOMENTUM, + "rebalance_frequency": star_growth_momentum_quality_strategy.DEFAULT_REBALANCE_FREQUENCY, + "weighting_mode": star_growth_momentum_quality_strategy.DEFAULT_WEIGHTING_MODE, + "target_annual_volatility": star_growth_momentum_quality_strategy.DEFAULT_TARGET_ANNUAL_VOLATILITY, + "max_gross_exposure": star_growth_momentum_quality_strategy.DEFAULT_MAX_GROSS_EXPOSURE, + "min_history_days": star_growth_momentum_quality_strategy.DEFAULT_MIN_HISTORY_DAYS, + "max_pair_correlation": star_growth_momentum_quality_strategy.DEFAULT_MAX_PAIR_CORRELATION, + "sentiment_mode": star_growth_momentum_quality_strategy.DEFAULT_SENTIMENT_MODE, + "execution_cash_reserve_ratio": star_growth_momentum_quality_strategy.DEFAULT_EXECUTION_CASH_RESERVE_RATIO, + }, +) + cn_dividend_quality_snapshot_manifest = _manifest( profile=CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, domain=dividend_quality_strategy.CN_EQUITY_DOMAIN, @@ -238,7 +304,9 @@ def _manifest( cn_industry_etf_rotation_aggressive_manifest.profile: cn_industry_etf_rotation_aggressive_manifest, cn_index_etf_tactical_rotation_manifest.profile: cn_index_etf_tactical_rotation_manifest, cn_chinext_tactical_rotation_manifest.profile: cn_chinext_tactical_rotation_manifest, + cn_chinext_growth_momentum_quality_manifest.profile: cn_chinext_growth_momentum_quality_manifest, cn_dividend_quality_snapshot_manifest.profile: cn_dividend_quality_snapshot_manifest, + cn_star_growth_momentum_quality_manifest.profile: cn_star_growth_momentum_quality_manifest, } MANIFEST_ALIASES = { @@ -256,16 +324,20 @@ def get_strategy_manifest(profile: str) -> StrategyManifest: __all__ = [ "CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE", "CN_CHINEXT_TACTICAL_ROTATION_PROFILE", + "CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE", "CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE", "CN_INDEX_ETF_TACTICAL_ROTATION_PROFILE", "CN_INDUSTRY_ETF_ROTATION_AGGRESSIVE_PROFILE", "CN_INDUSTRY_ETF_ROTATION_PROFILE", + "CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE", "MANIFESTS", "get_strategy_manifest", + "cn_chinext_growth_momentum_quality_manifest", "cn_chinext_growth_momentum_quality_snapshot_manifest", "cn_chinext_tactical_rotation_manifest", "cn_dividend_quality_snapshot_manifest", "cn_index_etf_tactical_rotation_manifest", "cn_industry_etf_rotation_aggressive_manifest", "cn_industry_etf_rotation_manifest", + "cn_star_growth_momentum_quality_manifest", ] diff --git a/src/cn_equity_strategies/strategies/__init__.py b/src/cn_equity_strategies/strategies/__init__.py index 5bfb2c9..e534a48 100644 --- a/src/cn_equity_strategies/strategies/__init__.py +++ b/src/cn_equity_strategies/strategies/__init__.py @@ -1,11 +1,15 @@ """A-share equity strategy implementations.""" from cn_equity_strategies.strategies import cn_equity_combo +from cn_equity_strategies.strategies import cn_chinext_growth_momentum_quality from cn_equity_strategies.strategies import cn_chinext_growth_momentum_quality_snapshot from cn_equity_strategies.strategies import cn_chinext_tactical_rotation +from cn_equity_strategies.strategies import cn_star_growth_momentum_quality __all__ = [ "cn_equity_combo", + "cn_chinext_growth_momentum_quality", "cn_chinext_growth_momentum_quality_snapshot", "cn_chinext_tactical_rotation", + "cn_star_growth_momentum_quality", ] diff --git a/src/cn_equity_strategies/strategies/cn_chinext_growth_momentum_quality.py b/src/cn_equity_strategies/strategies/cn_chinext_growth_momentum_quality.py new file mode 100644 index 0000000..02f10ff --- /dev/null +++ b/src/cn_equity_strategies/strategies/cn_chinext_growth_momentum_quality.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from typing import Any + +from cn_equity_strategies.strategies import industry_etf_rotation_core as base + +CN_EQUITY_DOMAIN = base.CN_EQUITY_DOMAIN +SIGNAL_SOURCE = base.SIGNAL_SOURCE +STATUS_ICON = base.STATUS_ICON +PROFILE_NAME = "cn_chinext_growth_momentum_quality" + +CHINEXT_ETF_SYMBOL = "159915" +CSI1000_ETF_SYMBOL = "512100" +MONEY_MARKET_ETF_SYMBOL = "511880" +GOVT_BOND_ETF_SYMBOL = "511260" + +DEFAULT_BENCHMARK_SYMBOL = base.DEFAULT_BENCHMARK_SYMBOL +DEFAULT_DEFENSIVE_SYMBOLS = base.DEFAULT_DEFENSIVE_SYMBOLS +DEFAULT_UNIVERSE_SYMBOLS = (CHINEXT_ETF_SYMBOL, CSI1000_ETF_SYMBOL) +DEFAULT_MOMENTUM_WINDOW_DAYS = base.DEFAULT_MOMENTUM_WINDOW_DAYS +DEFAULT_TREND_WINDOW_DAYS = base.DEFAULT_TREND_WINDOW_DAYS +DEFAULT_BENCHMARK_TREND_WINDOW_DAYS = base.DEFAULT_BENCHMARK_TREND_WINDOW_DAYS +DEFAULT_VOLATILITY_WINDOW_DAYS = base.DEFAULT_VOLATILITY_WINDOW_DAYS +DEFAULT_TOP_N = 1 +DEFAULT_MIN_MOMENTUM = base.DEFAULT_MIN_MOMENTUM +DEFAULT_REBALANCE_FREQUENCY = base.DEFAULT_REBALANCE_FREQUENCY +DEFAULT_WEIGHTING_MODE = base.DEFAULT_WEIGHTING_MODE +DEFAULT_TARGET_ANNUAL_VOLATILITY = 0.20 +DEFAULT_MAX_GROSS_EXPOSURE = base.DEFAULT_MAX_GROSS_EXPOSURE +DEFAULT_MIN_HISTORY_DAYS = base.DEFAULT_MIN_HISTORY_DAYS +DEFAULT_MAX_PAIR_CORRELATION = base.DEFAULT_MAX_PAIR_CORRELATION +DEFAULT_EXECUTION_CASH_RESERVE_RATIO = base.DEFAULT_EXECUTION_CASH_RESERVE_RATIO +DEFAULT_SENTIMENT_MODE = "off" + +normalize_symbol = base.normalize_symbol +normalize_universe_symbols = base.normalize_universe_symbols +build_close_matrix = base.build_close_matrix + + +def _default_kwargs(**overrides: Any) -> dict[str, Any]: + kwargs = { + "universe_symbols": DEFAULT_UNIVERSE_SYMBOLS, + "defensive_symbols": DEFAULT_DEFENSIVE_SYMBOLS, + "benchmark_symbol": DEFAULT_BENCHMARK_SYMBOL, + "momentum_window_days": DEFAULT_MOMENTUM_WINDOW_DAYS, + "trend_window_days": DEFAULT_TREND_WINDOW_DAYS, + "benchmark_trend_window_days": DEFAULT_BENCHMARK_TREND_WINDOW_DAYS, + "volatility_window_days": DEFAULT_VOLATILITY_WINDOW_DAYS, + "top_n": DEFAULT_TOP_N, + "min_momentum": DEFAULT_MIN_MOMENTUM, + "weighting_mode": DEFAULT_WEIGHTING_MODE, + "target_annual_volatility": DEFAULT_TARGET_ANNUAL_VOLATILITY, + "max_gross_exposure": DEFAULT_MAX_GROSS_EXPOSURE, + "min_history_days": DEFAULT_MIN_HISTORY_DAYS, + "max_pair_correlation": DEFAULT_MAX_PAIR_CORRELATION, + "sentiment_mode": DEFAULT_SENTIMENT_MODE, + } + kwargs.update(overrides) + return kwargs + + +def compute_latest_signal(market_history: Any, **kwargs: Any) -> dict[str, object]: + return base.compute_latest_signal(market_history, **_default_kwargs(**kwargs)) + + +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 base.extract_managed_symbols(**_default_kwargs(**kwargs)) + + +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"cn chinext growth momentum quality state={metadata['signal_state']} selected={selected} " + f"gross={metadata['gross_exposure']:.0%} cash={metadata['cash_weight']:.0%} " + f"target_vol={target_vol_text} benchmark_risk_off={metadata['benchmark_risk_off']}" + ) + status_desc = ( + f"state={metadata['signal_state']} | selected={selected} | " + f"momentum={metadata['momentum_window_days']}d | trend={metadata['trend_window_days']}d | " + f"benchmark={metadata.get('benchmark_symbol') or 'none'} | 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, + "profile_variant": "growth_momentum_quality", + }, + ) diff --git a/src/cn_equity_strategies/strategies/cn_equity_combo.py b/src/cn_equity_strategies/strategies/cn_equity_combo.py index 798d723..63be256 100644 --- a/src/cn_equity_strategies/strategies/cn_equity_combo.py +++ b/src/cn_equity_strategies/strategies/cn_equity_combo.py @@ -1,20 +1,21 @@ -"""CN equity combo strategy — ETF rotation + stock momentum + dividend quality. +"""CN equity combo strategy — ETF rotation + growth sleeves + dividend quality. -Combines three A-share sub-strategies into a single weight-allocated portfolio. +Combines board-level ETF rotation, board-level growth sleeves, and dividend +quality into a single weight-allocated portfolio. All sub-strategies imported from the pip-installable ``cn_equity_strategies`` package. Static mode ----------- -Fixed weights per leg (default: 30/50/20 ETF/stock/dividend). +Fixed weights per leg (default: 30/50/20 ETF/growth/dividend). Dynamic mode ------------ Regime-based adjustment driven by the dividend leg's breadth regime: -- risk_on: full static weights (30/50/20 ETF/stock/dividend). -- soft_defense: stock reduced to 85% of its weight; freed allocation +- risk_on: full static weights (30/50/20 ETF/growth/dividend). +- soft_defense: growth reduced to 85% of its weight; freed allocation shifts to the safe-haven ETF (510300). -- hard_defense: stock reduced to 50% of its weight; ETF reduced to 85% +- hard_defense: growth reduced to 50% of its weight; ETF reduced to 85% of its weight; freed allocation stays in the safe-haven ETF. Usage @@ -30,11 +31,12 @@ from quant_platform_kit.common.strategies import compute_portfolio_drift +from cn_equity_strategies.strategies import cn_chinext_growth_momentum_quality as chinext_growth_strategy from cn_equity_strategies.strategies import cn_dividend_quality_snapshot as dividend_strategy logger = logging.getLogger(__name__) from cn_equity_strategies.strategies import cn_industry_etf_rotation_aggressive as etf_strategy -from cn_equity_strategies.strategies import industry_etf_rotation_core as stock_strategy +from cn_equity_strategies.strategies import cn_star_growth_momentum_quality as star_growth_strategy SIGNAL_SOURCE = "combo" STATUS_ICON = "\U0001f500" @@ -54,15 +56,20 @@ "enable_benchmark_risk_off": False, } -# Stock momentum leg defaults (CSI500 vol25 MA120 risk-off) -STOCK_DEFAULT_CONFIG: dict[str, Any] = { - "defensive_symbols": ("510300",), - "benchmark_symbol": "510300", - "enable_benchmark_risk_off": True, - "benchmark_trend_window_days": 120, - "top_n": 5, - "target_annual_volatility": 0.25, - "sentiment_mode": "off", +# Growth sleeve defaults (ChiNext + STAR) +GROWTH_DEFAULT_CONFIG: dict[str, Any] = { + "chinext": { + "benchmark_symbol": "510300", + "top_n": 1, + "target_annual_volatility": 0.20, + "sentiment_mode": "off", + }, + "star": { + "benchmark_symbol": "510300", + "top_n": 1, + "target_annual_volatility": 0.18, + "sentiment_mode": "off", + }, } # Dividend leg defaults (current conservative) @@ -72,7 +79,7 @@ def _clean_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: """Remove combo-level keys before passing to sub-strategies.""" ignored = { - "etf_weight", "stock_weight", "dividend_weight", + "etf_weight", "stock_weight", "growth_weight", "dividend_weight", "dynamic_mode", "translator", "signal_text_fn", "execution_cash_reserve_ratio", "rebalance_frequency", "run_as_of", @@ -87,6 +94,7 @@ def build_target_weights( feature_snapshot: Any = None, etf_weight: float = DEFAULT_ETF_WEIGHT, stock_weight: float = DEFAULT_STOCK_WEIGHT, + growth_weight: float | None = None, dividend_weight: float = DEFAULT_DIVIDEND_WEIGHT, dynamic_mode: bool = True, etf_config: dict[str, Any] | None = None, @@ -104,7 +112,7 @@ def build_target_weights( Currently held symbol set for hold-bonus logic. feature_snapshot : pd.DataFrame or None Required for dividend quality leg. - etf_weight, stock_weight, dividend_weight : float + etf_weight, stock_weight/growth_weight, dividend_weight : float Allocation weights for each leg. Should sum to 1.0. dynamic_mode : bool If True, reduce offensive allocation when dividend leg signals @@ -117,15 +125,19 @@ def build_target_weights( resolved_etf = dict(ETF_DEFAULT_CONFIG) resolved_etf.update(etf_config or {}) - resolved_stock = dict(STOCK_DEFAULT_CONFIG) - resolved_stock.update(stock_config or {}) + resolved_growth = dict(GROWTH_DEFAULT_CONFIG) + resolved_growth.update(stock_config or {}) resolved_dividend = dict(DIVIDEND_DEFAULT_CONFIG) resolved_dividend.update(dividend_config or {}) + resolved_growth_weight = growth_weight if growth_weight is not None else stock_weight + # Compute each leg etf_weights: dict[str, float] = {} - stock_raw_weights: dict[str, float] = {} + growth_weights: dict[str, float] = {} + chinext_weights: dict[str, float] = {} + star_weights: dict[str, float] = {} dividend_weights: dict[str, float] = {} dividend_metadata: dict[str, object] = {} @@ -138,11 +150,14 @@ def build_target_weights( logger.error("ETF sub-strategy failed: %s", exc, exc_info=True) try: - stock_raw_weights, _ = stock_strategy.build_target_weights( - market_history, **resolved_stock, + chinext_weights, _ = chinext_growth_strategy.build_target_weights( + market_history, **resolved_growth.get("chinext", {}), + ) + star_weights, _ = star_growth_strategy.build_target_weights( + market_history, **resolved_growth.get("star", {}), ) except Exception as exc: - logger.error("Stock sub-strategy failed: %s", exc, exc_info=True) + logger.error("Growth sub-strategy failed: %s", exc, exc_info=True) if feature_snapshot is not None: try: @@ -157,28 +172,33 @@ def build_target_weights( # Determine effective weights (dynamic adjustment) regime = str(dividend_metadata.get("regime", "risk_on")) if dynamic_mode and regime == "soft_defense": - # Soft defense: reduce stock to 85%, shift proceeds to ETF - effective_stock = stock_weight * 0.85 - effective_etf = etf_weight + (stock_weight - effective_stock) + # Soft defense: reduce growth to 85%, shift proceeds to ETF + effective_growth = resolved_growth_weight * 0.85 + effective_etf = etf_weight + (resolved_growth_weight - effective_growth) effective_dividend = dividend_weight elif dynamic_mode and regime == "hard_defense": - # Hard defense: stock at 50%, ETF boosted to 85% allocation - effective_stock = stock_weight * 0.50 + # Hard defense: growth at 50%, ETF boosted to 85% allocation + effective_growth = resolved_growth_weight * 0.50 effective_etf = etf_weight * 0.85 effective_dividend = dividend_weight else: effective_etf = etf_weight - effective_stock = stock_weight + effective_growth = resolved_growth_weight effective_dividend = dividend_weight # Combine weights - all_symbols = set(etf_weights) | set(stock_raw_weights) | set(dividend_weights) + growth_weights = { + symbol: (chinext_weights.get(symbol, 0.0) + star_weights.get(symbol, 0.0)) / 2.0 + for symbol in set(chinext_weights) | set(star_weights) + } + + all_symbols = set(etf_weights) | set(growth_weights) | set(dividend_weights) combined: dict[str, float] = {} for symbol in all_symbols: ew = etf_weights.get(symbol, 0.0) - sw = stock_raw_weights.get(symbol, 0.0) + sw = growth_weights.get(symbol, 0.0) dw = dividend_weights.get(symbol, 0.0) - combined[symbol] = ew * effective_etf + sw * effective_stock + dw * effective_dividend + combined[symbol] = ew * effective_etf + sw * effective_growth + dw * effective_dividend # Normalize to ensure sum <= 1.0 total = sum(combined.values()) @@ -191,12 +211,20 @@ def build_target_weights( metadata: dict[str, object] = { "combo": { "etf_weight": effective_etf, - "stock_weight": effective_stock, + "stock_weight": effective_growth, + "growth_weight": effective_growth, "dividend_weight": effective_dividend, }, "legs": { "etf": {"weights": etf_weights, "configured_weight": etf_weight}, - "stock": {"weights": stock_raw_weights, "configured_weight": stock_weight}, + "stock": { + "weights": growth_weights, + "configured_weight": resolved_growth_weight, + "sub_legs": { + "chinext": chinext_weights, + "star": star_weights, + }, + }, "dividend": { "weights": dividend_weights, "configured_weight": dividend_weight, @@ -250,13 +278,13 @@ def compute_signals( f"combo regime={regime} selected={selected} " f"gross={metadata['gross_exposure']:.0%} " f"etf={combo_meta.get('etf_weight', 0):.0%} " - f"stock={combo_meta.get('stock_weight', 0):.0%} " + f"growth={combo_meta.get('growth_weight', combo_meta.get('stock_weight', 0)):.0%} " f"div={combo_meta.get('dividend_weight', 0):.0%}" ) status_desc = ( f"regime={regime} | " f"etf={combo_meta.get('etf_weight', 0):.0%} " - f"stock={combo_meta.get('stock_weight', 0):.0%} " + f"growth={combo_meta.get('growth_weight', combo_meta.get('stock_weight', 0)):.0%} " f"div={combo_meta.get('dividend_weight', 0):.0%}" ) has_cash_residual = metadata["gross_exposure"] < 0.999 diff --git a/src/cn_equity_strategies/strategies/cn_star_growth_momentum_quality.py b/src/cn_equity_strategies/strategies/cn_star_growth_momentum_quality.py new file mode 100644 index 0000000..3008689 --- /dev/null +++ b/src/cn_equity_strategies/strategies/cn_star_growth_momentum_quality.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from typing import Any + +from cn_equity_strategies.strategies import industry_etf_rotation_core as base + +CN_EQUITY_DOMAIN = base.CN_EQUITY_DOMAIN +SIGNAL_SOURCE = base.SIGNAL_SOURCE +STATUS_ICON = base.STATUS_ICON +PROFILE_NAME = "cn_star_growth_momentum_quality" + +STAR50_ETF_SYMBOL = "588000" +CHINEXT_ETF_SYMBOL = "159915" +MONEY_MARKET_ETF_SYMBOL = "511880" +GOVT_BOND_ETF_SYMBOL = "511260" + +DEFAULT_BENCHMARK_SYMBOL = base.DEFAULT_BENCHMARK_SYMBOL +DEFAULT_DEFENSIVE_SYMBOLS = base.DEFAULT_DEFENSIVE_SYMBOLS +DEFAULT_UNIVERSE_SYMBOLS = (STAR50_ETF_SYMBOL, CHINEXT_ETF_SYMBOL) +DEFAULT_MOMENTUM_WINDOW_DAYS = base.DEFAULT_MOMENTUM_WINDOW_DAYS +DEFAULT_TREND_WINDOW_DAYS = base.DEFAULT_TREND_WINDOW_DAYS +DEFAULT_BENCHMARK_TREND_WINDOW_DAYS = base.DEFAULT_BENCHMARK_TREND_WINDOW_DAYS +DEFAULT_VOLATILITY_WINDOW_DAYS = base.DEFAULT_VOLATILITY_WINDOW_DAYS +DEFAULT_TOP_N = 1 +DEFAULT_MIN_MOMENTUM = base.DEFAULT_MIN_MOMENTUM +DEFAULT_REBALANCE_FREQUENCY = base.DEFAULT_REBALANCE_FREQUENCY +DEFAULT_WEIGHTING_MODE = base.DEFAULT_WEIGHTING_MODE +DEFAULT_TARGET_ANNUAL_VOLATILITY = 0.18 +DEFAULT_MAX_GROSS_EXPOSURE = base.DEFAULT_MAX_GROSS_EXPOSURE +DEFAULT_MIN_HISTORY_DAYS = base.DEFAULT_MIN_HISTORY_DAYS +DEFAULT_MAX_PAIR_CORRELATION = base.DEFAULT_MAX_PAIR_CORRELATION +DEFAULT_EXECUTION_CASH_RESERVE_RATIO = base.DEFAULT_EXECUTION_CASH_RESERVE_RATIO +DEFAULT_SENTIMENT_MODE = "off" + +normalize_symbol = base.normalize_symbol +normalize_universe_symbols = base.normalize_universe_symbols +build_close_matrix = base.build_close_matrix + + +def _default_kwargs(**overrides: Any) -> dict[str, Any]: + kwargs = { + "universe_symbols": DEFAULT_UNIVERSE_SYMBOLS, + "defensive_symbols": DEFAULT_DEFENSIVE_SYMBOLS, + "benchmark_symbol": DEFAULT_BENCHMARK_SYMBOL, + "momentum_window_days": DEFAULT_MOMENTUM_WINDOW_DAYS, + "trend_window_days": DEFAULT_TREND_WINDOW_DAYS, + "benchmark_trend_window_days": DEFAULT_BENCHMARK_TREND_WINDOW_DAYS, + "volatility_window_days": DEFAULT_VOLATILITY_WINDOW_DAYS, + "top_n": DEFAULT_TOP_N, + "min_momentum": DEFAULT_MIN_MOMENTUM, + "weighting_mode": DEFAULT_WEIGHTING_MODE, + "target_annual_volatility": DEFAULT_TARGET_ANNUAL_VOLATILITY, + "max_gross_exposure": DEFAULT_MAX_GROSS_EXPOSURE, + "min_history_days": DEFAULT_MIN_HISTORY_DAYS, + "max_pair_correlation": DEFAULT_MAX_PAIR_CORRELATION, + "sentiment_mode": DEFAULT_SENTIMENT_MODE, + } + kwargs.update(overrides) + return kwargs + + +def compute_latest_signal(market_history: Any, **kwargs: Any) -> dict[str, object]: + return base.compute_latest_signal(market_history, **_default_kwargs(**kwargs)) + + +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 base.extract_managed_symbols(**_default_kwargs(**kwargs)) + + +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"cn star growth momentum quality state={metadata['signal_state']} selected={selected} " + f"gross={metadata['gross_exposure']:.0%} cash={metadata['cash_weight']:.0%} " + f"target_vol={target_vol_text} benchmark_risk_off={metadata['benchmark_risk_off']}" + ) + status_desc = ( + f"state={metadata['signal_state']} | selected={selected} | " + f"momentum={metadata['momentum_window_days']}d | trend={metadata['trend_window_days']}d | " + f"benchmark={metadata.get('benchmark_symbol') or 'none'} | 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, + "profile_variant": "growth_momentum_quality", + }, + ) diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 009da28..4d8b26f 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -5,6 +5,7 @@ from quant_platform_kit.common.strategies import get_strategy_component_map from cn_equity_strategies.catalog import ( + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE, CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE, CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, CN_CHINEXT_TACTICAL_ROTATION_PROFILE, @@ -13,6 +14,7 @@ CN_INDEX_ETF_TACTICAL_ROTATION_PROFILE, CN_INDUSTRY_ETF_ROTATION_AGGRESSIVE_PROFILE, CN_INDUSTRY_ETF_ROTATION_PROFILE, + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE, get_compatible_platforms, get_direct_market_history_profiles, get_external_snapshot_scaffold_profiles, @@ -34,12 +36,14 @@ def test_catalog_declares_cn_strategy_status_layers(): catalog = get_strategy_definitions() assert set(catalog) == { CN_EQUITY_COMBO_PROFILE, + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE, CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE, CN_CHINEXT_TACTICAL_ROTATION_PROFILE, CN_INDUSTRY_ETF_ROTATION_PROFILE, CN_INDUSTRY_ETF_ROTATION_AGGRESSIVE_PROFILE, CN_INDEX_ETF_TACTICAL_ROTATION_PROFILE, CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE, } industry_definition = catalog[CN_INDUSTRY_ETF_ROTATION_PROFILE] @@ -72,6 +76,12 @@ def test_catalog_declares_cn_strategy_status_layers(): assert get_compatible_platforms(CN_CHINEXT_TACTICAL_ROTATION_PROFILE) == frozenset({"qmt"}) assert get_strategy_metadata(CN_CHINEXT_TACTICAL_ROTATION_PROFILE).status == "research_backtest_only" + chinext_growth_definition = catalog[CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE] + assert chinext_growth_definition.domain == CN_EQUITY_DOMAIN + assert chinext_growth_definition.required_inputs == frozenset({"market_history"}) + assert get_compatible_platforms(CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE) == frozenset({"qmt"}) + assert get_strategy_metadata(CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE).status == "research_backtest_only" + chinext_growth_definition = catalog[CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE] assert chinext_growth_definition.domain == CN_EQUITY_DOMAIN assert chinext_growth_definition.required_inputs == frozenset({"feature_snapshot"}) @@ -86,6 +96,12 @@ def test_catalog_declares_cn_strategy_status_layers(): assert dividend_definition.required_inputs == frozenset({"feature_snapshot"}) assert get_strategy_metadata(CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE).status == "research_backtest_only" + star_growth_definition = catalog[CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE] + assert star_growth_definition.domain == CN_EQUITY_DOMAIN + assert star_growth_definition.required_inputs == frozenset({"market_history"}) + assert get_compatible_platforms(CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE) == frozenset({"qmt"}) + assert get_strategy_metadata(CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE).status == "research_backtest_only" + component_map = get_strategy_component_map(dividend_definition) assert component_map["signal_logic"].module_path == ( "cn_equity_strategies.strategies.cn_dividend_quality_snapshot" @@ -109,10 +125,12 @@ def test_profile_groups_keep_runtime_and_scaffolds_separate(): assert get_research_backtest_only_profiles() == frozenset( { CN_INDEX_ETF_TACTICAL_ROTATION_PROFILE, + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE, CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_SNAPSHOT_PROFILE, CN_CHINEXT_TACTICAL_ROTATION_PROFILE, CN_EQUITY_COMBO_PROFILE, + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE, } ) assert get_runtime_enabled_profiles() == frozenset({CN_INDUSTRY_ETF_ROTATION_PROFILE}) diff --git a/tests/test_cn_equity_combo.py b/tests/test_cn_equity_combo.py index a886d99..bb86efc 100644 --- a/tests/test_cn_equity_combo.py +++ b/tests/test_cn_equity_combo.py @@ -27,4 +27,4 @@ def test_cn_equity_combo_default_stock_leg_builds_weights() -> None: assert weights assert metadata["legs"]["stock"]["weights"] - assert "pit_index_code" not in cn_equity_combo.STOCK_DEFAULT_CONFIG + assert "pit_index_code" not in cn_equity_combo.GROWTH_DEFAULT_CONFIG.get("chinext", {}) diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index c2a93dc..563e932 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -7,13 +7,23 @@ from cn_equity_strategies import get_strategy_entrypoint from cn_equity_strategies.catalog import ( + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE, CN_CHINEXT_TACTICAL_ROTATION_PROFILE, CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, CN_INDEX_ETF_TACTICAL_ROTATION_PROFILE, CN_INDUSTRY_ETF_ROTATION_PROFILE, + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE, ) from cn_equity_strategies.strategies.cn_dividend_quality_snapshot import SAFE_HAVEN +from cn_equity_strategies.strategies.cn_chinext_growth_momentum_quality import ( + CHINEXT_ETF_SYMBOL as GROWTH_CHINEXT_ETF_SYMBOL, + CSI1000_ETF_SYMBOL, +) from test_cn_dividend_quality_snapshot import sample_factor_snapshot +from cn_equity_strategies.strategies.cn_star_growth_momentum_quality import ( + CHINEXT_ETF_SYMBOL as STAR_CHINEXT_ETF_SYMBOL, + STAR50_ETF_SYMBOL, +) from cn_equity_strategies.strategies.cn_index_etf_tactical_rotation import ( DEFAULT_UNIVERSE_SYMBOLS, NASDAQ_ETF_SYMBOL, @@ -100,6 +110,18 @@ def _chinext_tactical_history() -> pd.DataFrame: return pd.DataFrame(rows) +def _growth_history(rates: dict[str, float]) -> pd.DataFrame: + dates = pd.bdate_range("2024-01-02", periods=320) + rows = [] + for symbol, rate in rates.items(): + price = 20.0 + for idx, date in enumerate(dates): + price *= rate + close = price * (1.0 + 0.03 * ((idx % 5) - 2) / 5) + rows.append({"date": date, "symbol": symbol, "close": close, "volume": 1_000_000.0}) + return pd.DataFrame(rows) + + def test_industry_etf_rotation_entrypoint_returns_volatility_targeted_weight_targets(): entrypoint = get_strategy_entrypoint(CN_INDUSTRY_ETF_ROTATION_PROFILE) @@ -177,3 +199,57 @@ def test_chinext_tactical_rotation_entrypoint_returns_weights(): weights = {position.symbol: position.target_weight for position in decision.positions} assert weights assert decision.diagnostics["signal_source"] == "daily_market_history" + + +def test_chinext_growth_momentum_quality_entrypoint_returns_weights(): + entrypoint = get_strategy_entrypoint(CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE) + + decision = entrypoint.evaluate( + StrategyContext( + as_of="2026-02-25", + market_data={ + "market_history": _growth_history( + { + GROWTH_CHINEXT_ETF_SYMBOL: 1.0009, + CSI1000_ETF_SYMBOL: 1.0007, + "510300": 1.0002, + "511880": 1.00001, + "511260": 1.00002, + } + ) + }, + runtime_config={"min_history_days": 220}, + ) + ) + + weights = {position.symbol: position.target_weight for position in decision.positions} + assert weights + assert decision.diagnostics["signal_source"] == "daily_market_history" + assert decision.diagnostics["actionable"] is True + + +def test_star_growth_momentum_quality_entrypoint_returns_weights(): + entrypoint = get_strategy_entrypoint(CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE) + + decision = entrypoint.evaluate( + StrategyContext( + as_of="2026-02-25", + market_data={ + "market_history": _growth_history( + { + STAR50_ETF_SYMBOL: 1.0009, + STAR_CHINEXT_ETF_SYMBOL: 1.0007, + "510300": 1.0002, + "511880": 1.00001, + "511260": 1.00002, + } + ) + }, + runtime_config={"min_history_days": 220}, + ) + ) + + weights = {position.symbol: position.target_weight for position in decision.positions} + assert weights + assert decision.diagnostics["signal_source"] == "daily_market_history" + assert decision.diagnostics["actionable"] is True diff --git a/tests/test_runtime_adapters.py b/tests/test_runtime_adapters.py index de7f6d6..c0a235a 100644 --- a/tests/test_runtime_adapters.py +++ b/tests/test_runtime_adapters.py @@ -1,10 +1,12 @@ from __future__ import annotations from cn_equity_strategies.catalog import ( + CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE, CN_DIVIDEND_QUALITY_SNAPSHOT_PROFILE, CN_EQUITY_COMBO_PROFILE, CN_INDUSTRY_ETF_ROTATION_AGGRESSIVE_PROFILE, CN_INDUSTRY_ETF_ROTATION_PROFILE, + CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE, get_qmt_optional_runtime_profiles, get_qmt_rollout_allowlist, ) @@ -52,6 +54,15 @@ def test_combo_runtime_adapter_is_not_available_for_qmt(): get_platform_runtime_adapter(CN_EQUITY_COMBO_PROFILE, platform_id="qmt") +def test_growth_sleeves_are_not_available_for_qmt_runtime(): + import pytest + + with pytest.raises(ValueError): + get_platform_runtime_adapter(CN_CHINEXT_GROWTH_MOMENTUM_QUALITY_PROFILE, platform_id="qmt") + with pytest.raises(ValueError): + get_platform_runtime_adapter(CN_STAR_GROWTH_MOMENTUM_QUALITY_PROFILE, platform_id="qmt") + + def test_dividend_quality_runtime_requirements_are_snapshot_backed(): import pytest