diff --git a/README.md b/README.md index b5cb60b..e130031 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Production schedules should live in domain repositories and call this CLI or the quant-lifecycle monitor --domain us_equity quant-lifecycle drift --domain us_equity quant-lifecycle autopilot --domain us_equity --dry-run +quant-lifecycle evidence --file path/to/evidence.json quant-lifecycle dashboard --format all ``` @@ -94,6 +95,9 @@ rw.update_secret("my-token", "new-token-value") ``` ## Useful docs +- [`docs/strategy_lifecycle_policy.md`](docs/strategy_lifecycle_policy.md) +- [`docs/strategy_portfolio_action_matrix.md`](docs/strategy_portfolio_action_matrix.md) +- [`docs/evidence_package_template.md`](docs/evidence_package_template.md) - [`docs/platform_notification_outcomes.md`](docs/platform_notification_outcomes.md) - [`docs/platform_notification_outcomes.zh-CN.md`](docs/platform_notification_outcomes.zh-CN.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 7e38848..004547d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -41,9 +41,16 @@ python -m pytest -q quant-lifecycle monitor --domain us_equity quant-lifecycle drift --domain us_equity quant-lifecycle autopilot --domain us_equity --dry-run +quant-lifecycle evidence --file path/to/evidence.json quant-lifecycle dashboard --format all ``` +## 延伸文档 + +- [`docs/strategy_lifecycle_policy.zh-CN.md`](docs/strategy_lifecycle_policy.zh-CN.md) +- [`docs/strategy_portfolio_action_matrix.zh-CN.md`](docs/strategy_portfolio_action_matrix.zh-CN.md) +- [`docs/evidence_package_template.zh-CN.md`](docs/evidence_package_template.zh-CN.md) + ## 云服务抽象层 `quant_platform_kit.cloud` 包为常用云服务定义了协议接口——密钥管理、对象存储、文档数据库、计算发现和部署上下文。平台代码可以通过这些接口编写,无需硬编码到特定云厂商。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2584419..4b0852a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -145,7 +145,7 @@ Strategy profiles expose a common contract: | `name` | str | Profile name (snake_case) | | `domain` | str | Market domain (`us_equity`, `hk_equity`, `crypto`, `quant_combo`) | | `compatible_platforms` | list[str] | Allowed platform IDs | -| `status` | str | `research_enabled` or `runtime_enabled` | +| `status` | str | lifecycle stage, for example `research_backtest_only`, `shadow_candidate`, `live_candidate`, or `runtime_enabled` | | `evaluate()` | function | The pure decision function: `(ctx: StrategyContext) -> StrategyDecision` | Strategy packages and their coverage: @@ -158,7 +158,7 @@ Strategy packages and their coverage: | `crypto-strategies` | Crypto | Live pool rotation, BTC DCA, trend rotation, equity combo | BinancePlatform | | `quant-us-combo-strategies` | US Combo | Russell Top50 + IBIT (50/50), leveraged combo (TQQQ/SOXL/BOXX + SPY MA200) | Schwab, IBKR, LongBridge, Firstrade | -Catalog metadata is published through `get_runtime_enabled_profiles()` which platforms call to discover available strategies. Status promotion gates (`research_enabled` -> `runtime_enabled`) require passing evidence checks (backtest performance, drawdown limits, slippage tolerance). +Catalog metadata is published through lifecycle-aware profile helpers which platforms call to discover available strategies. Status promotion gates (`research_backtest_only` -> `shadow_candidate` -> `live_candidate` -> `runtime_enabled`) require passing evidence checks (backtest performance, drawdown limits, slippage tolerance). ### 2.3 Snapshot Layer @@ -375,7 +375,7 @@ Exclusion lists handle strategies that are technically compatible but not yet ap ```python SCHWAB_EXCLUDED_LIVE_PROFILES = [ - "tecl_xlk_trend_income", # research_enabled, not cleared for live + "tecl_xlk_trend_income", # research_backtest_only, not cleared for live "soxl_soxx_trend_income", # awaiting promo gate signoff ] ``` diff --git a/docs/STRATEGY_PLATFORM_SPEC.md b/docs/STRATEGY_PLATFORM_SPEC.md index ec8651c..317d450 100644 --- a/docs/STRATEGY_PLATFORM_SPEC.md +++ b/docs/STRATEGY_PLATFORM_SPEC.md @@ -29,6 +29,16 @@ auto-sync layer described below. ## Adding a New Strategy +See also: [`strategy_lifecycle_policy.md`](./strategy_lifecycle_policy.md) for the +promotion ladder and live-enable gate model. + +Before requesting live enablement, attach an evidence package that covers: + +- backtest summary +- drift / regime notes +- platform compatibility evidence +- plugin gate status, if applicable + 1. Add strategy definition to the shared catalog (`us_equity_strategies` or `hk_equity_strategies`) 2. Set `compatible_platforms` to list which platforms support it 3. Add to `runtime_enabled_profiles` for the rollout diff --git a/docs/evidence_package_template.md b/docs/evidence_package_template.md new file mode 100644 index 0000000..a81f6b5 --- /dev/null +++ b/docs/evidence_package_template.md @@ -0,0 +1,43 @@ +# Strategy Promotion Evidence Package + +This template defines the minimum package to request `live_candidate` review. + +## Required contents + +- strategy profile name +- target platform(s) +- backtest summary +- drift / regime notes +- platform compatibility evidence +- plugin gate status, if applicable +- operator notes and rollout constraints + +## Suggested structure + +```text +profile: cn_chinext_growth_momentum_quality +market: cn_equity +requested_stage: live_candidate + +1. Backtest summary +2. Drift and regime observations +3. Risk review +4. Platform compatibility evidence +5. Plugin gate status +6. Rollout notes +``` + +## Acceptance rule + +If any of the following are missing, keep the profile out of live settings: + +- the profile is not runtime-compatible with the target platform +- the plugin gate is not clearly approved or notification-only +- the evidence does not cover both performance and regime sensitivity +- the request is based on a single good window only + +## Ownership + +- strategy repo: produces the evidence package +- platform repo: verifies runtime compatibility and gate status +- operator review: makes the final live decision diff --git a/docs/evidence_package_template.zh-CN.md b/docs/evidence_package_template.zh-CN.md new file mode 100644 index 0000000..8792610 --- /dev/null +++ b/docs/evidence_package_template.zh-CN.md @@ -0,0 +1,43 @@ +# 策略晋级证据包 + +这份模板定义了请求 `live_candidate` 复核时最少应提交的内容。 + +## 必要内容 + +- 策略 profile 名称 +- 目标平台 +- 回测摘要 +- drift / regime 观察 +- 平台兼容性证据 +- 插件门槛状态(如适用) +- 操作备注与 rollout 限制 + +## 建议结构 + +```text +profile: cn_chinext_growth_momentum_quality +market: cn_equity +requested_stage: live_candidate + +1. 回测摘要 +2. drift 与 regime 观察 +3. 风险复核 +4. 平台兼容性证据 +5. 插件门槛状态 +6. rollout 备注 +``` + +## 接受规则 + +如果缺少以下任一项,就应继续留在 live 之外: + +- profile 与目标平台不兼容 +- 插件门槛不是明确 approved 或 notification-only +- 证据没有同时覆盖收益表现和 regime 敏感性 +- 只依赖单一好窗口 + +## 责任划分 + +- 策略仓库:产出证据包 +- 平台仓库:验证 runtime 兼容性和门槛状态 +- 操作审批:做最终 live 决策 diff --git a/docs/strategy_lifecycle_policy.md b/docs/strategy_lifecycle_policy.md new file mode 100644 index 0000000..aaa917a --- /dev/null +++ b/docs/strategy_lifecycle_policy.md @@ -0,0 +1,84 @@ +# Strategy Lifecycle Policy + +[简体中文](./strategy_lifecycle_policy.zh-CN.md) + +This document defines the lifecycle gates for strategy profiles across the +quant repositories. + +## Design Goal + +The lifecycle should be permissive for research and monitoring, but strict for +capital impact. + +- AI monitoring may accelerate review and highlight drift. +- AI monitoring must not bypass the live-enable gate. +- A strategy can be observed earlier than it can trade. +- Live enablement remains a platform decision, not just a backtest decision. + +## Recommended Lifecycle Stages + +| Stage | Meaning | Capital impact | Typical owner | +| --- | --- | --- | --- | +| `research_backtest_only` | Backtests, feature work, or evidence collection only | none | strategy repo | +| `ai_monitored_candidate` | Eligible for AI review, drift scoring, and shadow tracking | none | strategy lifecycle | +| `shadow_candidate` | Shadow runs are stable enough for repeatable comparisons | none | strategy lifecycle | +| `live_candidate` | Passed validation and is awaiting platform enablement | gated | platform + strategy | +| `runtime_enabled` | Exposed by `get_runtime_enabled_profiles()` and allowed in runtime settings | yes | platform repo | + +### Practical interpretation + +- `research_backtest_only` is the default for anything new. +- `ai_monitored_candidate` is the lowest-friction review stage when the + organization already has automated monitoring. +- `shadow_candidate` should require repeatable shadow consistency, not just one + good backtest. +- `live_candidate` should only be used when the strategy has enough evidence to + justify platform enablement. +- `runtime_enabled` is the only stage that should influence live deployment + defaults. + +## Three-Gate Rule + +A strategy should clear all three gates before live use: + +1. **Strategy gate** + - Does the strategy have enough history, risk characterization, and drift + tolerance to move beyond research? +2. **Plugin gate** + - If the strategy depends on plugins, are those plugins at least + `automation_approved` or explicitly `notification_only`? +3. **Platform gate** + - Does the target platform expose the profile via + `get_runtime_enabled_profiles()` and accept the required runtime inputs? + +Any one of these gates failing should keep the profile out of live settings. + +## Recommended Promotion Policy + +- Keep the monitoring threshold relatively low so candidates are visible early. +- If AI monitoring already exists, use it to move promising profiles into + `ai_monitored_candidate` quickly; this stage is for visibility, not capital. +- Keep the live-enable threshold high so runtime exposure remains deliberate. +- Prefer promotion by evidence package, not by ad hoc overrides. A promotion + package should include backtest summary, drift notes, risk review, and + platform compatibility evidence. +- See [`evidence_package_template.md`](./evidence_package_template.md) for the + recommended package shape. +- When a strategy is a wrapper or orchestrator, promote it only after the + wrapped components are stable and the wrapper itself has been validated. + +## Repo-Level Guidance + +- **US equity**: long-history trend / rotation profiles can move through the + lifecycle earlier; wrapper combos should stay candidate-first. +- **HK equity**: keep live exposure narrow and promote only stable runtime + profiles. +- **CN equity**: treat QMT-specific optional runtime profiles separately from + the main live catalog. +- **Crypto**: keep the monitoring stage permissive, but use a stricter live + gate because regime shifts are faster. + +## Operational Rule + +If a profile is not returned by `get_runtime_enabled_profiles()`, it should +stay out of live runtime settings regardless of monitoring status. diff --git a/docs/strategy_lifecycle_policy.zh-CN.md b/docs/strategy_lifecycle_policy.zh-CN.md new file mode 100644 index 0000000..043f7ef --- /dev/null +++ b/docs/strategy_lifecycle_policy.zh-CN.md @@ -0,0 +1,70 @@ +# 策略生命周期门槛策略 + +[English](./strategy_lifecycle_policy.md) + +本文定义量化仓库中策略 profile 的生命周期门槛。 + +## 设计目标 + +生命周期应当对研究和监控保持宽松,但对资金影响保持严格。 + +- AI 监控可以加快复核和暴露 drift。 +- AI 监控不能绕过 live 启用门槛。 +- 策略可以先被观察,再允许交易。 +- live 启用仍然是平台决策,不只是回测结论。 + +## 推荐生命周期阶段 + +| 阶段 | 含义 | 资金影响 | 常见归属 | +| --- | --- | --- | --- | +| `research_backtest_only` | 仅回测、特征开发或证据收集 | 无 | 策略仓库 | +| `ai_monitored_candidate` | 可进入 AI 复核、drift 打分和 shadow 跟踪 | 无 | 策略生命周期 | +| `shadow_candidate` | shadow 运行已经稳定,可做重复对比 | 无 | 策略生命周期 | +| `live_candidate` | 已通过验证,等待平台启用 | 受门槛控制 | 平台 + 策略 | +| `runtime_enabled` | 会被 `get_runtime_enabled_profiles()` 暴露并允许进入运行时设置 | 有 | 平台仓库 | + +### 实际含义 + +- `research_backtest_only` 适合作为所有新策略的默认起点。 +- 如果组织已经有自动监控,`ai_monitored_candidate` 可以作为最低摩擦的复核阶段。 +- `shadow_candidate` 应该要求重复 shadow 一致性,而不是只看一次漂亮回测。 +- `live_candidate` 只适合证据足够支持平台启用的策略。 +- `runtime_enabled` 才应该影响 live 配置默认值。 + +## 三道门槛 + +策略进入 live 之前,应同时通过三道门槛: + +1. **策略门槛** + - 是否具备足够的历史、风险画像和 drift 容忍度,能从研究阶段前进? +2. **插件门槛** + - 如果策略依赖插件,这些插件是否至少是 `automation_approved`, + 或者被明确标记为 `notification_only`? +3. **平台门槛** + - 目标平台是否通过 `get_runtime_enabled_profiles()` 暴露该 profile, + 并接受所需运行时输入? + +任意一项不过关,都应该继续留在 live 之外。 + +## 推荐晋级策略 + +- 监控阈值可以相对低一些,让候选策略尽早可见。 +- 如果组织已经有自动 AI 监控,就用它尽快把合适的 profile 推到 + `ai_monitored_candidate`;这一层只为可见性,不为资金。 +- live 启用阈值要保持高一些,确保运行时暴露是明确决策。 +- 以证据包晋级,而不是临时 override。证据包至少应包含回测摘要、 + drift 记录、风险复核和平台兼容性证据。 +- 证据包建议结构见 [`evidence_package_template.zh-CN.md`](./evidence_package_template.zh-CN.md)。 +- 如果策略是 wrapper / orchestrator,应先确认被包装组件稳定,再考虑给 wrapper 晋级。 + +## 仓库级建议 + +- **美股**:长历史趋势 / 轮动策略可以更早进入生命周期推进;wrapper combo 应优先保持 candidate 状态。 +- **港股**:保持 live 暴露范围窄,只晋级稳定的 runtime profile。 +- **A 股**:把 QMT 专用的可选 runtime profile 和主 live catalog 分开看待。 +- **加密货币**:监控阶段可以宽松一些,但 live 门槛应更严格,因为 regime 切换更快。 + +## 运行规则 + +如果某个 profile 没有被 `get_runtime_enabled_profiles()` 返回,那么它就不应该进入 live runtime settings, +无论监控状态是否已经打开。 diff --git a/docs/strategy_portfolio_action_matrix.md b/docs/strategy_portfolio_action_matrix.md new file mode 100644 index 0000000..e1526ba --- /dev/null +++ b/docs/strategy_portfolio_action_matrix.md @@ -0,0 +1,103 @@ +# Strategy Portfolio Action Matrix + +[简体中文](./strategy_portfolio_action_matrix.zh-CN.md) + +This document turns the current cross-repository review into an execution plan. +It uses the present repo state as the source of truth and separates: + +- keep tuning +- rebuild / redesign +- deprecate / downgrade +- new strategy ideas + +## Decision rules + +- If a profile is still `runtime_enabled` and has clear evidence, keep tuning it. +- If a profile is useful but its shape is wrong, keep the idea and rebuild the + wrapper / orchestrator. +- If a profile is clearly weaker than the main live line, downgrade it to + `shadow_candidate`, `research_backtest_only`, or archive it. +- If a market lacks a good live candidate, prefer a new architecture instead of + adding more variants. + +## Cross-market matrix + +| Market | Keep tuning | Rebuild / redesign | Downgrade / retire | New ideas worth testing | +| --- | --- | --- | --- | --- | +| US equity | `global_etf_rotation`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `russell_top50_leader_rotation`, DCA lines | `us_equity_combo`, `us_equity_combo_leveraged` should stay as shadow/orchestrator layers rather than live profiles | `tecl_xlk_trend_income` stays research-only | Optional LEAPS overlay, but only as a separately gated research track | +| HK equity | `hk_global_etf_tactical_rotation`, `hk_low_vol_dividend_quality_snapshot` | `hk_equity_combo` should be a research/orchestration wrapper, not a live profile | any combo-style live promotion should be blocked until evidence improves | A dedicated factor-enhanced rotation wrapper only after the core lines are stable | +| CN equity | `cn_industry_etf_rotation`, `cn_industry_etf_rotation_aggressive` | `cn_equity_combo` should be rebuilt as an orchestrator / dual-track composition, not a direct live profile | `cn_index_etf_tactical_rotation` stays legacy/research; `cn_chinext_tactical_rotation` and `cn_chinext_growth_momentum_quality_snapshot` should be redesigned, not discarded | `cn_dual_track_combo`, a first-class ChiNext growth sleeve, and a separate STAR growth sleeve | +| Crypto | `crypto_live_pool_rotation`, `crypto_btc_dca` | `crypto_trend_rotation`, `crypto_equity_combo` need redesign before any live promotion | all non-live crypto profiles stay research/shadow until proven otherwise | A volatility-regime filter or stablecoin cash-deploy layer before adding more rotation variants | + +## What this means by market + +### US + +US already has a strong main live set. The right move is not more live variants; +it is better separation: + +- keep the live trend and rotation lines as the core +- keep combo logic as a shadow/orchestrator layer +- retire weak legacy templates like TECL + +### HK + +HK should stay narrow: + +- keep the ETF rotation and snapshot-backed dividend-quality line +- treat combo as a research wrapper +- do not broaden live exposure until the evidence gap is closed + +### CN + +CN still has room for improvement, but the current strongest path is: + +- one direct ETF rotation main line +- one aggressive variant under controlled rollout +- one future orchestrator / dual-track line + +ChiNext and STAR should not be treated as disposable research branches; they +are board-specific growth sleeves whose design needs to match the market +regime. + +The combo profile should not compete with the main live line until it is +rebuilt as a composition engine. + +### Crypto + +Crypto is the most regime-sensitive domain, so the live gate should stay strict: + +- keep the live pool rotation line as the main runtime strategy +- keep BTC DCA as a simple tuneable accumulation line +- redesign trend and combo variants before any live promotion + +## Plugin and gate implications + +- `notification_only` is fine for monitoring and research visibility. +- `automation_candidate` is fine for shadow and pre-live validation. +- `automation_approved + position_control_allowed` remains the hard gate for + any automatic position impact. +- A strategy being AI-monitored is not the same as being live-enabled. + +## Recommended next implementation steps + +1. Keep the current live lines stable. +2. Rebuild combo/orchestrator profiles around composition, not duplication. +3. Add any new strategies only if they solve a real gap in the matrix above. +4. Use evidence packages to promote, not ad hoc exceptions. + +## Reference strategy families + +The external research that best matches this matrix is still: + +- time-series momentum / trend following +- factor investing: quality, momentum, low volatility, dividend yield +- multi-asset value + momentum overlays + +Official references: + +- AQR Trends Everywhere +- AQR Value and Momentum Everywhere +- Kenneth French Data Library +- MSCI Factor Indexes +- EDHEC trend-following research diff --git a/docs/strategy_portfolio_action_matrix.zh-CN.md b/docs/strategy_portfolio_action_matrix.zh-CN.md new file mode 100644 index 0000000..3606eed --- /dev/null +++ b/docs/strategy_portfolio_action_matrix.zh-CN.md @@ -0,0 +1,97 @@ +# 策略组合行动矩阵 + +[English](./strategy_portfolio_action_matrix.md) + +这份文档把当前跨仓库 review 结果整理成可执行方案。它以现有仓库状态为准, +把结论分成: + +- 继续调参 +- 重构 / 重设 +- 降级 / 废弃 +- 值得新增的方向 + +## 判定规则 + +- 如果 profile 仍然是 `runtime_enabled`,而且证据清楚,就继续调参。 +- 如果策略思路有价值,但形态不对,就保留思路、重构 wrapper / orchestrator。 +- 如果某条线明显弱于主 live 线,就降级到 `shadow_candidate`、 + `research_backtest_only`,或者直接归档。 +- 如果某个市场还没有合适的 live 候选,就优先改架构,不要继续堆变体。 + +## 跨市场矩阵 + +| 市场 | 继续调参 | 重构 / 重设 | 降级 / 废弃 | 值得测试的新方向 | +| --- | --- | --- | --- | --- | +| 美股 | `global_etf_rotation`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`russell_top50_leader_rotation`、各 DCA 线 | `us_equity_combo`、`us_equity_combo_leveraged` 更适合作为 shadow / orchestrator 层,而不是 live profile | `tecl_xlk_trend_income` 保持 research-only | LEAPS 增强层可以继续研究,但必须单独门控 | +| 港股 | `hk_global_etf_tactical_rotation`、`hk_low_vol_dividend_quality_snapshot` | `hk_equity_combo` 应该是 research/orchestration wrapper,不应当是 live profile | 任何 combo 型 live 推进都应先阻断,直到证据补齐 | 只有核心线稳定后,才考虑独立的因子增强 wrapper | +| A 股 | `cn_industry_etf_rotation`、`cn_industry_etf_rotation_aggressive` | `cn_equity_combo` 应重做成 orchestrator / dual-track composition,而不是直接 live profile | `cn_index_etf_tactical_rotation` 保持 legacy / research;`cn_chinext_tactical_rotation` 和 `cn_chinext_growth_momentum_quality_snapshot` 应重设计,不是简单废弃 | `cn_dual_track_combo`、独立的创业板成长 sleeve、以及独立的科创板成长 sleeve | +| 加密 | `crypto_live_pool_rotation`、`crypto_btc_dca` | `crypto_trend_rotation`、`crypto_equity_combo` 需要重构后再谈 live | 其余非 live crypto profile 保持 research/shadow,直到证据充分 | 先加波动率 regime 过滤或 stablecoin 资金投放层,再考虑更多轮动变体 | + +## 各市场解释 + +### 美股 + +美股已经有较强的主 live 线,重点不是再加 live 变体,而是分层更清楚: + +- 主 live 线继续稳定 +- combo 逻辑保留为 shadow / orchestrator +- TECL 这类弱线继续退场 + +### 港股 + +港股应该保持窄而稳: + +- 保留 ETF 轮动和 snapshot 红利质量线 +- combo 只做 research wrapper +- 在证据缺口补齐前,不要扩 live 面 + +### A 股 + +A 股还有改进空间,但当前最强路径仍然是: + +- 一条直接 ETF 轮动主线 +- 一条受控的 aggressive 变体 +- 一条未来可组合的 orchestrator / dual-track 线 + +创业板和科创板不应当被当成可随手丢弃的研究分支;它们是板块级 +成长 sleeve,设计必须贴合真实市场 regime。 + +combo 现在不该和主 live 线争位置,应该先重做成 composition engine。 + +### 加密 + +加密是 regime 切换最快的域,所以 live 门槛要更严格: + +- 保持 live pool rotation 作为主 runtime 策略 +- 保持 BTC DCA 作为简单、可调的积累线 +- trend 和 combo 变体先重构,再谈 live + +## 插件和门槛含义 + +- `notification_only` 适合监控和研究可见性 +- `automation_candidate` 适合 shadow 和 pre-live 验证 +- `automation_approved + position_control_allowed` 仍然是自动仓位影响的硬门槛 +- AI monitored 不等于 live enabled + +## 推荐的下一步 + +1. 先稳住当前 live 主线 +2. 把 combo / orchestrator 做成真正的 composition,而不是复制主线 +3. 新策略只在能补齐真实缺口时再加 +4. 用证据包晋级,不用临时特批 + +## 参考策略家族 + +和这份矩阵最吻合的外部研究主要还是: + +- 时间序列动量 / 趋势跟随 +- 因子投资:质量、动量、低波动、股息收益 +- 多资产 value + momentum overlay + +官方参考: + +- AQR Trends Everywhere +- AQR Value and Momentum Everywhere +- Kenneth French Data Library +- MSCI Factor Indexes +- EDHEC 趋势跟随研究 diff --git a/scripts/validate_evidence_package.py b/scripts/validate_evidence_package.py new file mode 100644 index 0000000..fccd575 --- /dev/null +++ b/scripts/validate_evidence_package.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +"""Validate a strategy promotion evidence package.""" + +from __future__ import annotations + +import sys + +from quant_platform_kit.strategy_lifecycle.cli import main + + +if __name__ == "__main__": + raise SystemExit(main(["evidence", "--file", *sys.argv[1:]]) if len(sys.argv) > 1 else 2) diff --git a/src/quant_platform_kit/strategy_lifecycle/__init__.py b/src/quant_platform_kit/strategy_lifecycle/__init__.py index 50c09b8..c23591d 100644 --- a/src/quant_platform_kit/strategy_lifecycle/__init__.py +++ b/src/quant_platform_kit/strategy_lifecycle/__init__.py @@ -14,6 +14,13 @@ UpdateStage, WindowPerformance, ) +from quant_platform_kit.strategy_lifecycle.evidence_gate import ( + EvidenceGateResult, + EvidencePackage, + load_evidence_package, + validate_evidence_package, + validate_evidence_package_file, +) __all__ = [ "BacktestResult", @@ -28,4 +35,9 @@ "UpdateLogEntry", "UpdateStage", "WindowPerformance", + "EvidenceGateResult", + "EvidencePackage", + "load_evidence_package", + "validate_evidence_package", + "validate_evidence_package_file", ] diff --git a/src/quant_platform_kit/strategy_lifecycle/cli.py b/src/quant_platform_kit/strategy_lifecycle/cli.py index dfbd087..d9a1863 100644 --- a/src/quant_platform_kit/strategy_lifecycle/cli.py +++ b/src/quant_platform_kit/strategy_lifecycle/cli.py @@ -158,6 +158,27 @@ def _run_lifecycle(args: argparse.Namespace) -> int: return 0 +def _run_evidence(args: argparse.Namespace) -> int: + _print(f"[evidence] Validating package file={args.file}") + validate_evidence_package_file = _load_callable( + "quant_platform_kit.strategy_lifecycle.evidence_gate", + "validate_evidence_package_file", + ) + result = validate_evidence_package_file(args.file) + if getattr(args, "json", False): + import json + + _print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2)) + else: + status = "valid" if result.valid else "invalid" + _print(f"[evidence] status={status} issues={len(result.issues)} warnings={len(result.warnings)}") + for item in result.issues: + _print(f" - issue: {item}") + for item in result.warnings: + _print(f" - warning: {item}") + return 0 if result.valid else 1 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="quant-lifecycle", description="Quant strategy lifecycle CLI.") parser.add_argument("--version", action="version", version="quant-lifecycle 0.10.0") @@ -197,6 +218,11 @@ def build_parser() -> argparse.ArgumentParser: autopilot.add_argument("--no-issues", action="store_true") autopilot.set_defaults(func=_run_autopilot) + evidence = subparsers.add_parser("evidence", help="Validate a strategy promotion evidence package.") + evidence.add_argument("--file", required=True) + evidence.add_argument("--json", action="store_true") + evidence.set_defaults(func=_run_evidence) + lifecycle = subparsers.add_parser("lifecycle", help="Run the full lifecycle pipeline.") lifecycle.add_argument("--domain", default="us_equity") lifecycle.add_argument("--strategy", default=None) diff --git a/src/quant_platform_kit/strategy_lifecycle/evidence_gate.py b/src/quant_platform_kit/strategy_lifecycle/evidence_gate.py new file mode 100644 index 0000000..37f2b07 --- /dev/null +++ b/src/quant_platform_kit/strategy_lifecycle/evidence_gate.py @@ -0,0 +1,237 @@ +"""Evidence package validation for lifecycle promotion requests.""" + +from __future__ import annotations + +import json +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping + +ALLOWED_EVIDENCE_STAGES = ( + "research_backtest_only", + "ai_monitored_candidate", + "shadow_candidate", + "live_candidate", + "runtime_enabled", +) +LIVE_EVIDENCE_STAGES = {"live_candidate", "runtime_enabled"} +ALLOWED_PLUGIN_GATE_STATUSES = { + "automation_approved", + "deprecated_compatibility", + "notification_only", + "research_only", + "shadow_observer", +} + + +@dataclass(frozen=True) +class EvidencePackage: + strategy_profile: str + domain: str + requested_stage: str + target_platforms: tuple[str, ...] = () + backtest_summary: Mapping[str, Any] = field(default_factory=dict) + drift_notes: Any = None + platform_compatibility: Mapping[str, Any] | None = None + plugin_gate: Any = None + rollout_notes: Any = None + operator_notes: Any = None + evidence_version: str = "evidence_package.v1" + submitted_at: str = "" + + def to_dict(self) -> dict[str, object]: + return { + "strategy_profile": self.strategy_profile, + "domain": self.domain, + "requested_stage": self.requested_stage, + "target_platforms": list(self.target_platforms), + "backtest_summary": dict(self.backtest_summary), + "drift_notes": self.drift_notes, + "platform_compatibility": self.platform_compatibility, + "plugin_gate": self.plugin_gate, + "rollout_notes": self.rollout_notes, + "operator_notes": self.operator_notes, + "evidence_version": self.evidence_version, + "submitted_at": self.submitted_at, + } + + +@dataclass(frozen=True) +class EvidenceGateResult: + valid: bool + package: EvidencePackage + issues: tuple[str, ...] = () + warnings: tuple[str, ...] = () + + def to_dict(self) -> dict[str, object]: + return { + "valid": self.valid, + "issues": list(self.issues), + "warnings": list(self.warnings), + "package": self.package.to_dict(), + } + + +def load_evidence_package(path: str | Path) -> dict[str, Any]: + evidence_path = Path(path) + raw = evidence_path.read_text(encoding="utf-8") + suffix = evidence_path.suffix.lower() + if suffix == ".json": + payload = json.loads(raw) + elif suffix == ".toml": + payload = tomllib.loads(raw) + else: + raise ValueError(f"Unsupported evidence package format: {evidence_path.suffix or ''}") + if not isinstance(payload, dict): + raise ValueError("Evidence package must decode to a mapping") + return payload + + +def validate_evidence_package(raw: Mapping[str, Any]) -> EvidenceGateResult: + issues: list[str] = [] + warnings: list[str] = [] + + strategy_profile = _first_str(raw, "strategy_profile", "profile") + domain = _first_str(raw, "domain", "market") + requested_stage = _first_str(raw, "requested_stage", "stage") + target_platforms = _normalize_platforms( + _first_value(raw, "target_platforms", "platforms", "target_platform", "runtime_targets") + ) + backtest_summary = _first_mapping(raw, "backtest_summary", "backtest", "evidence") + drift_notes = _first_value(raw, "drift_notes", "drift", "regime_notes") + platform_compatibility = _first_mapping( + raw, + "platform_compatibility", + "compatibility", + "platform_evidence", + ) + plugin_gate = _first_value(raw, "plugin_gate", "plugin_gates", "plugins") + rollout_notes = _first_value(raw, "rollout_notes", "rollout", "operator_notes") + evidence_version = str(_first_value(raw, "evidence_version", "schema", default="evidence_package.v1") or "evidence_package.v1") + submitted_at = str(_first_value(raw, "submitted_at", "generated_at", "created_at", default="") or "") + + if not strategy_profile: + issues.append("missing strategy_profile/profile") + if not domain: + issues.append("missing domain/market") + if requested_stage not in ALLOWED_EVIDENCE_STAGES: + issues.append(f"unsupported requested_stage: {requested_stage!r}") + if not backtest_summary: + issues.append("missing backtest_summary") + elif not _backtest_summary_has_evidence(backtest_summary): + issues.append("backtest_summary missing core metrics or observation count") + + if requested_stage in LIVE_EVIDENCE_STAGES: + if not target_platforms: + issues.append("live request requires target_platforms/platforms") + if not _is_non_empty(drift_notes): + issues.append("live request requires drift_notes") + if not platform_compatibility: + issues.append("live request requires platform_compatibility evidence") + elif not _platform_compatibility_is_usable(platform_compatibility): + issues.append("platform_compatibility evidence is incomplete") + if plugin_gate is not None and not _plugin_gate_is_usable(plugin_gate): + issues.append("plugin_gate evidence is incomplete or unsupported") + + if requested_stage in {"research_backtest_only", "ai_monitored_candidate"} and not _is_non_empty(rollout_notes): + warnings.append("rollout_notes is recommended for candidates") + + package = EvidencePackage( + strategy_profile=str(strategy_profile or ""), + domain=str(domain or ""), + requested_stage=str(requested_stage or ""), + target_platforms=target_platforms, + backtest_summary=backtest_summary, + drift_notes=drift_notes, + platform_compatibility=platform_compatibility, + plugin_gate=plugin_gate, + rollout_notes=rollout_notes, + operator_notes=_first_value(raw, "operator_notes", "notes"), + evidence_version=evidence_version, + submitted_at=submitted_at, + ) + return EvidenceGateResult(valid=not issues, package=package, issues=tuple(issues), warnings=tuple(warnings)) + + +def validate_evidence_package_file(path: str | Path) -> EvidenceGateResult: + return validate_evidence_package(load_evidence_package(path)) + + +def _first_value(raw: Mapping[str, Any], *keys: str, default: Any = None) -> Any: + for key in keys: + if key in raw and raw[key] is not None: + return raw[key] + return default + + +def _first_mapping(raw: Mapping[str, Any], *keys: str) -> Mapping[str, Any]: + value = _first_value(raw, *keys) + return value if isinstance(value, Mapping) else {} + + +def _first_str(raw: Mapping[str, Any], *keys: str) -> str: + value = _first_value(raw, *keys) + return str(value).strip() if value is not None else "" + + +def _normalize_platforms(value: Any) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str): + items = [value] + elif isinstance(value, (list, tuple, set, frozenset)): + items = list(value) + else: + items = [value] + return tuple(str(item).strip() for item in items if str(item).strip()) + + +def _is_non_empty(value: Any) -> bool: + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + if isinstance(value, Mapping): + return bool(value) + if isinstance(value, (list, tuple, set, frozenset)): + return bool(value) + return True + + +def _backtest_summary_has_evidence(summary: Mapping[str, Any]) -> bool: + if not summary: + return False + if summary.get("observation_count") not in (None, "", 0): + return True + if _is_non_empty(summary.get("windows")): + return True + for key in ("cagr", "sharpe_ratio", "total_return", "max_drawdown"): + if summary.get(key) not in (None, ""): + return True + return False + + +def _platform_compatibility_is_usable(compatibility: Mapping[str, Any]) -> bool: + if not compatibility: + return False + if any(bool(compatibility.get(key)) for key in ("verified", "compatible", "supported", "enabled")): + return True + if _is_non_empty(compatibility.get("runtime_enabled_profiles")): + return True + if _is_non_empty(compatibility.get("target_platforms")): + return True + return False + + +def _plugin_gate_is_usable(plugin_gate: Any) -> bool: + if isinstance(plugin_gate, Mapping): + status = str(plugin_gate.get("status") or plugin_gate.get("evidence_status") or "").strip().lower() + if status and status not in ALLOWED_PLUGIN_GATE_STATUSES: + return False + if status == "automation_approved": + return bool(plugin_gate.get("position_control_allowed", False)) + return True + if isinstance(plugin_gate, (list, tuple)): + return all(_plugin_gate_is_usable(item) for item in plugin_gate) + return False diff --git a/tests/test_lifecycle_evidence_gate.py b/tests/test_lifecycle_evidence_gate.py new file mode 100644 index 0000000..04c25a5 --- /dev/null +++ b/tests/test_lifecycle_evidence_gate.py @@ -0,0 +1,80 @@ +"""Tests for strategy_lifecycle.evidence_gate.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from quant_platform_kit.strategy_lifecycle.cli import main +from quant_platform_kit.strategy_lifecycle.evidence_gate import ( + load_evidence_package, + validate_evidence_package, + validate_evidence_package_file, +) + + +def test_research_package_valid() -> None: + result = validate_evidence_package( + { + "profile": "cn_equity_combo", + "market": "cn_equity", + "requested_stage": "research_backtest_only", + "backtest_summary": {"observation_count": 252, "sharpe_ratio": 1.2}, + } + ) + + assert result.valid + assert result.package.strategy_profile == "cn_equity_combo" + + +def test_live_package_requires_compatibility_and_drift() -> None: + result = validate_evidence_package( + { + "strategy_profile": "cn_chinext_growth_momentum_quality", + "domain": "cn_equity", + "requested_stage": "live_candidate", + "target_platforms": ["qmt"], + "backtest_summary": {"observation_count": 252, "sharpe_ratio": 1.1}, + } + ) + + assert not result.valid + assert "drift_notes" in " ".join(result.issues) + assert "platform_compatibility" in " ".join(result.issues) + + +def test_live_package_valid_when_required_sections_present() -> None: + result = validate_evidence_package( + { + "strategy_profile": "cn_chinext_growth_momentum_quality", + "domain": "cn_equity", + "requested_stage": "live_candidate", + "target_platforms": ["qmt"], + "backtest_summary": {"observation_count": 252, "sharpe_ratio": 1.1}, + "drift_notes": {"status": "watch", "summary": "stable"}, + "platform_compatibility": {"verified": True}, + "plugin_gate": {"status": "notification_only"}, + } + ) + + assert result.valid + + +def test_file_loader_and_cli(tmp_path: Path) -> None: + payload = { + "strategy_profile": "cn_equity_combo", + "domain": "cn_equity", + "requested_stage": "research_backtest_only", + "backtest_summary": {"observation_count": 126, "total_return": 0.12}, + } + path = tmp_path / "evidence.json" + path.write_text(json.dumps(payload), encoding="utf-8") + + loaded = load_evidence_package(path) + assert loaded["strategy_profile"] == "cn_equity_combo" + + result = validate_evidence_package_file(path) + assert result.valid + + exit_code = main(["evidence", "--file", str(path)]) + assert exit_code == 0