diff --git a/docs/us_equity_cross_platform_strategy_spec.md b/docs/us_equity_cross_platform_strategy_spec.md index 0085c93..05bd026 100644 --- a/docs/us_equity_cross_platform_strategy_spec.md +++ b/docs/us_equity_cross_platform_strategy_spec.md @@ -37,6 +37,44 @@ In practice that means: Strategy code must not branch on broker platform. +## Responsibility boundaries + +`QuantPlatformKit` owns shared contracts and common runtime helpers: + +- `StrategyManifest`, `StrategyDecision`, and `StrategyRuntimeAdapter` +- `StrategyArtifactContract` and `StrategyRuntimePolicy` +- artifact path resolution, runtime config resolution, and feature snapshot guards +- standard `StrategyContext` builders and input validation + +`UsEquityStrategies` owns strategy semantics: + +- profile catalog, manifest, and default config +- pure `evaluate(ctx)` implementations +- platform-neutral base runtime adapter specs +- feature snapshot schema, manifest contract version, and managed symbol extractors +- packaged or published canonical strategy config + +Platform repositories own runtime and broker integration: + +- platform env, secrets, scheduler parameters, and broker sessions +- market data, account data, holdings, and portfolio snapshots +- `StrategyContext` assembly from declared contracts +- mapping `StrategyDecision` to broker orders, notifications, and runtime reports +- deployment, retries, idempotency, and reconciliation output + +The snapshot production pipeline owns artifact publication: + +- snapshot files, manifests, checksums, and contract versions +- config checksum alignment with profile and config name +- GCS or local runtime path delivery + +Do not introduce reverse coupling: + +- strategy code must not import broker SDKs or read platform env vars +- platform code must not hard-code private strategy symbol pools, snapshot schemas, or config paths by profile name +- platform workflows must not keep a second list of snapshot profiles and must read derived adapter requirements +- live strategy config must not depend on platform `research/` directories + ## Mandatory layers ### 1. Strategy definition layer @@ -212,6 +250,8 @@ Current live profiles can migrate incrementally, but the end state should be: `feature_snapshot` artifact delivery - `tech_communication_pullback_enhancement`: portable through standardized `feature_snapshot` artifact delivery +- `mega_cap_leader_rotation_aggressive`: portable through standardized + `feature_snapshot` artifact delivery - `mega_cap_leader_rotation_dynamic_top20`: portable through standardized `feature_snapshot` artifact delivery - `dynamic_mega_leveraged_pullback`: portable through standardized diff --git a/docs/us_equity_cross_platform_strategy_spec.zh-CN.md b/docs/us_equity_cross_platform_strategy_spec.zh-CN.md index fdf617d..ae02a99 100644 --- a/docs/us_equity_cross_platform_strategy_spec.zh-CN.md +++ b/docs/us_equity_cross_platform_strategy_spec.zh-CN.md @@ -37,6 +37,44 @@ Telegram 文案细节。 策略代码里不能按平台分支。 +## 职责边界 + +`QuantPlatformKit` 负责共享契约和通用运行 helper: + +- `StrategyManifest`、`StrategyDecision`、`StrategyRuntimeAdapter` +- `StrategyArtifactContract` 和 `StrategyRuntimePolicy` +- artifact path 解析、runtime config 解析、feature snapshot guard +- 标准 `StrategyContext` 构建和输入校验 + +`UsEquityStrategies` 负责策略语义: + +- profile catalog、manifest、default config +- 纯策略 `evaluate(ctx)` 实现 +- 平台无关的基础 runtime adapter spec +- feature snapshot schema、manifest contract version、managed symbols extractor +- canonical strategy config 的打包或发布规则 + +平台仓库只负责运行时和券商集成: + +- 解析平台 env、secrets、调度参数和 broker session +- 拉取行情、账户、持仓、portfolio snapshot +- 按契约组装 `StrategyContext` +- 把 `StrategyDecision` 映射成券商订单、通知和运行报告 +- 处理平台自己的部署、重试、幂等和 reconciliation 输出 + +snapshot 生产链负责生成和发布 artifact: + +- snapshot 文件、manifest、checksum、contract version +- config checksum 与 profile/config name 对齐 +- GCS 或本地运行路径的交付 + +禁止反向耦合: + +- 策略代码不能 import 券商 SDK,不能读取平台 env。 +- 平台代码不能按 profile 名硬编码策略私有股票池、snapshot schema 或 config 路径。 +- 平台 workflow 不能维护第二套 snapshot profile 名单,必须读取 adapter 派生出来的需求。 +- 策略配置不能散落在平台 `research/` 目录里作为 live 运行依赖。 + ## 必须有的四层 ### 1)策略定义层 @@ -212,6 +250,8 @@ allowlist 只影响 `enabled`,不要再手写一堆“这个策略天生只能 - 通过标准化 `feature_snapshot` artifact contract 实现跨平台 - `tech_communication_pullback_enhancement` - 通过标准化 `feature_snapshot` artifact contract 实现跨平台 +- `mega_cap_leader_rotation_aggressive` + - 通过标准化 `feature_snapshot` artifact contract 实现跨平台 - `mega_cap_leader_rotation_dynamic_top20` - 通过标准化 `feature_snapshot` artifact contract 实现跨平台 - `dynamic_mega_leveraged_pullback` diff --git a/docs/us_equity_live_switch_runbook.md b/docs/us_equity_live_switch_runbook.md index f9e57ab..8cdf697 100644 --- a/docs/us_equity_live_switch_runbook.md +++ b/docs/us_equity_live_switch_runbook.md @@ -12,6 +12,7 @@ Current live US equity profiles: - `dynamic_mega_leveraged_pullback` - `global_etf_rotation` +- `mega_cap_leader_rotation_aggressive` - `mega_cap_leader_rotation_dynamic_top20` - `russell_1000_multi_factor_defensive` - `soxl_soxx_trend_income` @@ -26,7 +27,7 @@ Current runtime platforms: - `schwab` - `longbridge` -For the current seven-profile scope, all three platforms now report the full matrix as `eligible=true` and `enabled=true`. That means live switching is now an operational change, not a strategy-contract migration. +For the current eight-profile scope, all three platforms now report the full matrix as `eligible=true` and `enabled=true`. That means live switching is now an operational change, not a strategy-contract migration. ## Operational profile groups @@ -38,6 +39,7 @@ Treat the live profiles as two operational groups: - `soxl_soxx_trend_income` - **Snapshot-backed profiles** - `dynamic_mega_leveraged_pullback` + - `mega_cap_leader_rotation_aggressive` - `mega_cap_leader_rotation_dynamic_top20` - `russell_1000_multi_factor_defensive` - `tech_communication_pullback_enhancement` @@ -48,6 +50,9 @@ The platform scripts now expose this view directly: - `requires_snapshot_artifacts` - `requires_snapshot_manifest_path` - `requires_strategy_config_path` +- `config_source_policy` +- `reconciliation_output_policy` +- `runtime_execution_window_trading_days` So the operator does not need to remember the distinction from profile names alone. @@ -114,15 +119,17 @@ If any of those checks fail, stop. That is a code or rollout problem, not a live | --- | --- | | `dynamic_mega_leveraged_pullback` | feature snapshot path + snapshot manifest path | | `global_etf_rotation` | none | +| `mega_cap_leader_rotation_aggressive` | feature snapshot path + snapshot manifest path | | `mega_cap_leader_rotation_dynamic_top20` | feature snapshot path + snapshot manifest path | | `russell_1000_multi_factor_defensive` | feature snapshot path | | `soxl_soxx_trend_income` | none | | `tqqq_growth_income` | none | -| `tech_communication_pullback_enhancement` | feature snapshot path + snapshot manifest path + strategy config path | +| `tech_communication_pullback_enhancement` | feature snapshot path + snapshot manifest path; strategy config path is optional unless the rollout overrides the packaged config | Notes: - `tech_communication_pullback_enhancement` on IBKR may also keep a reconciliation output path when the deployment wants that artifact. +- `tech_communication_pullback_enhancement` now has `config_source_policy=bundled_or_env`, so the packaged canonical config is used unless an env path is deliberately set. - `dynamic_mega_leveraged_pullback` also uses market history, benchmark history, and portfolio snapshot, but the platform runtime supplies those from broker/runtime data, not extra artifact env. - `russell_1000_multi_factor_defensive` currently requires the snapshot path but not a manifest path. - When switching away from a feature-snapshot profile, remove stale snapshot/config envs from the service instead of leaving them behind. @@ -150,7 +157,7 @@ Feature-snapshot profiles additionally need: - `IBKR_FEATURE_SNAPSHOT_PATH` - `IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH` when the selected profile requires a manifest -- `IBKR_STRATEGY_CONFIG_PATH` when the selected profile requires an external runtime config +- `IBKR_STRATEGY_CONFIG_PATH` only when `config_source_policy=env_only`, or as an explicit override for `bundled_or_env` Remove when not needed: @@ -173,7 +180,7 @@ Feature-snapshot profiles additionally need: - `SCHWAB_FEATURE_SNAPSHOT_PATH` - `SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH` when the selected profile requires a manifest -- `SCHWAB_STRATEGY_CONFIG_PATH` when the selected profile requires an external runtime config +- `SCHWAB_STRATEGY_CONFIG_PATH` only when `config_source_policy=env_only`, or as an explicit override for `bundled_or_env` Remove when not needed: @@ -200,7 +207,7 @@ Feature-snapshot profiles additionally need: - `LONGBRIDGE_FEATURE_SNAPSHOT_PATH` - `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH` when the selected profile requires a manifest -- `LONGBRIDGE_STRATEGY_CONFIG_PATH` when the selected profile requires an external runtime config +- `LONGBRIDGE_STRATEGY_CONFIG_PATH` only when `config_source_policy=env_only`, or as an explicit override for `bundled_or_env` Remove when not needed: @@ -299,6 +306,9 @@ Set: - `STRATEGY_PROFILE=tech_communication_pullback_enhancement` - `SCHWAB_FEATURE_SNAPSHOT_PATH` - `SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH` + +Optional override: + - `SCHWAB_STRATEGY_CONFIG_PATH` Keep or remove separately depending on the rollout choice: @@ -308,7 +318,7 @@ Keep or remove separately depending on the rollout choice: Why: - `tech_communication_pullback_enhancement` is a feature-snapshot profile -- the strategy also expects its external config path on the runtime side +- the strategy has a packaged canonical config; set the env path only when overriding it ### Example C: switch LongBridge HK to `russell_1000_multi_factor_defensive` @@ -324,16 +334,16 @@ Set: - `STRATEGY_PROFILE=russell_1000_multi_factor_defensive` - `LONGBRIDGE_FEATURE_SNAPSHOT_PATH` -- `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH` Remove if present: +- `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH` - `LONGBRIDGE_STRATEGY_CONFIG_PATH` Why: - Russell uses the feature snapshot contract -- unlike `tech_communication_pullback_enhancement`, it does not need the extra strategy config path +- it currently requires the snapshot path but not the manifest or strategy config path ### Example D: switch LongBridge SG back to a non-snapshot profile diff --git a/docs/us_equity_live_switch_runbook.zh-CN.md b/docs/us_equity_live_switch_runbook.zh-CN.md index 8fb5451..1fc07d6 100644 --- a/docs/us_equity_live_switch_runbook.zh-CN.md +++ b/docs/us_equity_live_switch_runbook.zh-CN.md @@ -15,6 +15,7 @@ - `dynamic_mega_leveraged_pullback` - `global_etf_rotation` +- `mega_cap_leader_rotation_aggressive` - `mega_cap_leader_rotation_dynamic_top20` - `russell_1000_multi_factor_defensive` - `soxl_soxx_trend_income` @@ -29,7 +30,7 @@ - `schwab` - `longbridge` -对当前这 7 条策略来说,三个平台现在都已经是 `eligible=true` 且 `enabled=true`。也就是说,接下来换线上策略主要是运维切换,不再是契约迁移。 +对当前这 8 条策略来说,三个平台现在都已经是 `eligible=true` 且 `enabled=true`。也就是说,接下来换线上策略主要是运维切换,不再是契约迁移。 ## 运维分组 @@ -41,6 +42,7 @@ - `soxl_soxx_trend_income` - **snapshot 驱动策略** - `dynamic_mega_leveraged_pullback` + - `mega_cap_leader_rotation_aggressive` - `mega_cap_leader_rotation_dynamic_top20` - `russell_1000_multi_factor_defensive` - `tech_communication_pullback_enhancement` @@ -51,6 +53,9 @@ - `requires_snapshot_artifacts` - `requires_snapshot_manifest_path` - `requires_strategy_config_path` +- `config_source_policy` +- `reconciliation_output_policy` +- `runtime_execution_window_trading_days` 这样切换时不用再靠记忆判断“这条是不是 snapshot 策略”。 @@ -117,15 +122,17 @@ PYTHONPATH=/Users/lisiyi/Projects/QuantPlatformKit/src:/Users/lisiyi/Projects/Us | --- | --- | | `dynamic_mega_leveraged_pullback` | feature snapshot 路径 + manifest 路径 | | `global_etf_rotation` | 无 | +| `mega_cap_leader_rotation_aggressive` | feature snapshot 路径 + manifest 路径 | | `mega_cap_leader_rotation_dynamic_top20` | feature snapshot 路径 + manifest 路径 | | `russell_1000_multi_factor_defensive` | feature snapshot 路径 | | `soxl_soxx_trend_income` | 无 | | `tqqq_growth_income` | 无 | -| `tech_communication_pullback_enhancement` | feature snapshot 路径 + manifest 路径 + strategy config 路径 | +| `tech_communication_pullback_enhancement` | feature snapshot 路径 + manifest 路径;strategy config 路径只在要覆盖包内配置时才需要 | 说明: - `tech_communication_pullback_enhancement` 在 IBKR 上如果还要留对账产物,可以继续配 reconciliation output path。 +- `tech_communication_pullback_enhancement` 现在是 `config_source_policy=bundled_or_env`,默认使用策略包里的 canonical config,只有显式覆盖时才配 env path。 - `dynamic_mega_leveraged_pullback` 还会用到 market history、benchmark history 和 portfolio snapshot,但这些由平台运行时从券商/行情侧供应,不是额外 artifact env。 - `russell_1000_multi_factor_defensive` 目前只强制 snapshot 路径,不强制 manifest 路径。 - 如果从 feature-snapshot 策略切回普通策略,要把旧的 snapshot/config env 一起删掉,不要留脏状态。 @@ -153,7 +160,7 @@ PYTHONPATH=/Users/lisiyi/Projects/QuantPlatformKit/src:/Users/lisiyi/Projects/Us - `IBKR_FEATURE_SNAPSHOT_PATH` - `IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH`(当目标 profile 要求 manifest 时) -- `IBKR_STRATEGY_CONFIG_PATH`(当目标 profile 要求外部 runtime config 时) +- `IBKR_STRATEGY_CONFIG_PATH`(仅当 `config_source_policy=env_only`,或要显式覆盖 `bundled_or_env` 的包内配置时) 不再需要时要删掉: @@ -176,7 +183,7 @@ PYTHONPATH=/Users/lisiyi/Projects/QuantPlatformKit/src:/Users/lisiyi/Projects/Us - `SCHWAB_FEATURE_SNAPSHOT_PATH` - `SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH`(当目标 profile 要求 manifest 时) -- `SCHWAB_STRATEGY_CONFIG_PATH`(当目标 profile 要求外部 runtime config 时) +- `SCHWAB_STRATEGY_CONFIG_PATH`(仅当 `config_source_policy=env_only`,或要显式覆盖 `bundled_or_env` 的包内配置时) 不再需要时要删掉: @@ -203,7 +210,7 @@ PYTHONPATH=/Users/lisiyi/Projects/QuantPlatformKit/src:/Users/lisiyi/Projects/Us - `LONGBRIDGE_FEATURE_SNAPSHOT_PATH` - `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`(当目标 profile 要求 manifest 时) -- `LONGBRIDGE_STRATEGY_CONFIG_PATH`(当目标 profile 要求外部 runtime config 时) +- `LONGBRIDGE_STRATEGY_CONFIG_PATH`(仅当 `config_source_policy=env_only`,或要显式覆盖 `bundled_or_env` 的包内配置时) 不再需要时要删掉: @@ -300,6 +307,9 @@ gcloud run services describe longbridge-quant-sg-service \ - `STRATEGY_PROFILE=tech_communication_pullback_enhancement` - `SCHWAB_FEATURE_SNAPSHOT_PATH` - `SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH` + +可选覆盖: + - `SCHWAB_STRATEGY_CONFIG_PATH` 是否保留下面这个开关,单独按 rollout 决定: @@ -309,7 +319,7 @@ gcloud run services describe longbridge-quant-sg-service \ 原因: - `tech_communication_pullback_enhancement` 是 feature-snapshot 策略 -- 运行时还需要它对应的外部 config 路径 +- 策略包已经带 canonical config,只有要覆盖它时才设置 env path ### 示例 C:把 LongBridge HK 切到 `russell_1000_multi_factor_defensive` @@ -325,16 +335,16 @@ gcloud run services describe longbridge-quant-sg-service \ - `STRATEGY_PROFILE=russell_1000_multi_factor_defensive` - `LONGBRIDGE_FEATURE_SNAPSHOT_PATH` -- `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH` 如果还留着,就删掉: +- `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH` - `LONGBRIDGE_STRATEGY_CONFIG_PATH` 原因: - Russell 走的是 feature snapshot 合约 -- 但它不像 `tech_communication_pullback_enhancement`,不需要额外的 strategy config path +- 目前只强制 snapshot 路径,不需要 manifest 或 strategy config path ### 示例 D:把 LongBridge SG 切回非 snapshot 策略 diff --git a/docs/us_equity_strategy_onboarding.md b/docs/us_equity_strategy_onboarding.md index e2f0f50..6503fe0 100644 --- a/docs/us_equity_strategy_onboarding.md +++ b/docs/us_equity_strategy_onboarding.md @@ -1,6 +1,6 @@ # US Equity Strategy Onboarding Checklist -_Verified snapshot: 2026-04-15_ +_Verified snapshot: 2026-04-16_ This checklist describes how a new `us_equity` strategy becomes available on the three US equity broker platforms without adding a manual allowlist entry in each platform repository. @@ -42,6 +42,9 @@ In `UsEquityStrategies`, a new live profile needs: - a manifest and unified entrypoint - focused strategy tests - one platform-neutral runtime adapter spec +- `StrategyArtifactContract` when artifacts are required +- `StrategyRuntimePolicy` when runtime windows or reconciliation output rules are required +- packaged or artifact-published canonical config when a live default config exists The current standard input names are: @@ -61,8 +64,9 @@ The base adapter should declare strategy-owned metadata: - required feature snapshot columns when the strategy uses `feature_snapshot` - snapshot date/freshness rules when the artifact has date-sensitive content -- manifest requirements and contract version when a manifest is required -- runtime config loader only when the strategy genuinely needs an external config file +- `StrategyArtifactContract`, including snapshot, manifest, strategy config, and config source policy +- `StrategyRuntimePolicy`, including reconciliation output and runtime execution window requirements +- runtime config loaders only as strategy config readers, not as platform deployment logic Platform-specific adapters are generated from: @@ -75,6 +79,17 @@ When a weight-mode strategy runs on a value-native platform, the generated adapt The adapter generator is the bridge between strategy contract and platform runtime. Strategy code should not branch on broker platform ids. +When adapters change, platform scripts should only consume the derived runtime requirements: + +- `requires_snapshot_artifacts` +- `requires_snapshot_manifest_path` +- `requires_strategy_config_path` +- `config_source_policy` +- `reconciliation_output_policy` +- `runtime_execution_window_trading_days` + +Platform code should not add `if profile == "..."` branches for strategy-private behavior. + ## Platform repository requirements The three US equity platform repositories should stay thin: @@ -89,6 +104,8 @@ The platform registry should derive live profiles from `UsEquityStrategies.get_r Platform repositories should not hard-code strategy symbol pools, private strategy constants, or manual live profile allowlists. +Platform repositories may change when a new broker capability is needed, such as a new market-data input builder. They should not change their core execution flow just to accommodate a strategy-private parameter. + ## Artifact-backed profiles If a strategy uses `feature_snapshot`, the platform env sync workflow must enforce the artifact env vars before Cloud Run is updated. The workflow should derive this from `scripts/print_strategy_profile_status.py --json`, not from a hard-coded profile-name list. @@ -101,7 +118,20 @@ Current env mapping: | Schwab | `SCHWAB_FEATURE_SNAPSHOT_PATH` | `SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH` | `SCHWAB_STRATEGY_CONFIG_PATH` | | LongBridge | `LONGBRIDGE_FEATURE_SNAPSHOT_PATH` | `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH` | `LONGBRIDGE_STRATEGY_CONFIG_PATH` | -Only profiles whose adapter requires a manifest should require the manifest path. Only profiles with a runtime config loader should require the strategy config path. +Only profiles whose adapter requires a manifest should require the manifest path. +Only profiles with `config_source_policy="env_only"` should require the strategy config path. +Profiles with `config_source_policy="bundled_or_env"` use the packaged canonical config by default and treat env paths as explicit overrides. + +## Minimum onboarding steps + +1. Register the profile, manifest, default config, and unified entrypoint in `UsEquityStrategies`. +2. Declare `required_inputs`, `target_mode`, `supported_platforms`, and `status`. +3. Add the base `StrategyRuntimeAdapter` with `StrategyArtifactContract` and `StrategyRuntimePolicy`. +4. If the profile uses `feature_snapshot`, declare schema, date columns, freshness, manifest contract version, and managed symbol extraction. +5. If live config is required, prefer packaging it with the strategy package; use `env_only` only when it cannot be packaged. +6. Run strategy contract governance and entrypoint tests, then verify `describe_platform_runtime_requirements()`. +7. Run each platform status or switch-plan script and confirm eligible/enabled state plus artifact env requirements come from the adapter. +8. Modify platform core flow only when a new broker data source or execution capability is required. ## Test gates diff --git a/docs/us_equity_strategy_onboarding.zh-CN.md b/docs/us_equity_strategy_onboarding.zh-CN.md index df0f412..c1f2e39 100644 --- a/docs/us_equity_strategy_onboarding.zh-CN.md +++ b/docs/us_equity_strategy_onboarding.zh-CN.md @@ -1,6 +1,6 @@ # 美股策略接入清单 -_校验快照日期:2026-04-15_ +_校验快照日期:2026-04-16_ 这份清单说明一条新的 `us_equity` 策略,如何在不手写三个平台 allowlist 的情况下,进入 IBKR / Schwab / LongBridge 三个美股运行平台。 @@ -42,6 +42,9 @@ _校验快照日期:2026-04-15_ - 有 manifest 和统一 entrypoint - 有聚焦的策略测试 - 一份平台无关的 runtime adapter spec +- 如果依赖 artifact,要声明 `StrategyArtifactContract` +- 如果有运行窗口、reconciliation 输出等运行策略,要声明 `StrategyRuntimePolicy` +- 如果有 live 默认配置,要把 canonical config 放在策略包或 artifact 发布链里 当前标准输入名: @@ -61,8 +64,9 @@ _校验快照日期:2026-04-15_ - 使用 `feature_snapshot` 时需要的 snapshot columns - artifact 对日期敏感时的日期列和 freshness 规则 -- 需要 manifest 时的 manifest 要求和 contract version -- 只有策略确实需要外部配置文件时,才加 runtime config loader +- `StrategyArtifactContract`,包括是否需要 snapshot、manifest、strategy config,以及 config 来源策略 +- `StrategyRuntimePolicy`,包括 reconciliation 输出是否必需和运行窗口等策略运行约束 +- runtime config loader 只作为读取策略配置的适配入口,不能承担平台部署逻辑 平台 adapter 会根据下面这些信息自动生成: @@ -75,6 +79,17 @@ _校验快照日期:2026-04-15_ adapter 生成器是策略契约和平台运行时之间的桥。策略代码本身不要按券商平台分支。 +新增或修改 adapter 时,平台脚本只能消费派生后的运行需求,例如: + +- `requires_snapshot_artifacts` +- `requires_snapshot_manifest_path` +- `requires_strategy_config_path` +- `config_source_policy` +- `reconciliation_output_policy` +- `runtime_execution_window_trading_days` + +平台代码不要再写 `if profile == "..."` 这类策略私有分支。 + ## 平台仓库要求 三个美股平台仓库应该保持薄运行层: @@ -89,6 +104,9 @@ adapter 生成器是策略契约和平台运行时之间的桥。策略代码本 平台仓库不要硬编码策略股票池、策略私有常量或手写 live profile allowlist。 +平台仓库可以因为新增 broker 能力而改代码,例如补一个新的行情输入 builder; +但不能因为某条策略的私有参数而改平台执行主流程。 + ## Artifact 驱动策略 如果策略使用 `feature_snapshot`,平台 env sync workflow 必须在更新 Cloud Run 前校验 artifact env。workflow 应该从 `scripts/print_strategy_profile_status.py --json` 动态解析需求,不要维护硬编码 profile 名单。 @@ -101,7 +119,20 @@ adapter 生成器是策略契约和平台运行时之间的桥。策略代码本 | Schwab | `SCHWAB_FEATURE_SNAPSHOT_PATH` | `SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH` | `SCHWAB_STRATEGY_CONFIG_PATH` | | LongBridge | `LONGBRIDGE_FEATURE_SNAPSHOT_PATH` | `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH` | `LONGBRIDGE_STRATEGY_CONFIG_PATH` | -只有 adapter 要求 manifest 的 profile,才强制 manifest path。只有带 runtime config loader 的 profile,才强制 strategy config path。 +只有 adapter 要求 manifest 的 profile,才强制 manifest path。 +只有 `config_source_policy="env_only"` 的 profile,才强制 strategy config path。 +`config_source_policy="bundled_or_env"` 的 profile 默认使用策略包内 canonical config,env path 只作为显式覆盖。 + +## 新策略最小接入步骤 + +1. 在 `UsEquityStrategies` 注册 profile、manifest、default config 和统一 entrypoint。 +2. 明确 `required_inputs`、`target_mode`、`supported_platforms` 和 `status`。 +3. 添加基础 `StrategyRuntimeAdapter`,同时声明 `StrategyArtifactContract` 和 `StrategyRuntimePolicy`。 +4. 如果使用 `feature_snapshot`,补齐 schema、date columns、freshness、manifest contract version 和 managed symbols extractor。 +5. 如果需要 live config,优先打包到策略包;只有不能打包时才使用 `env_only`。 +6. 跑策略仓 contract governance 和 entrypoint 测试,确认 `describe_platform_runtime_requirements()` 输出正确。 +7. 跑三个平台的 status/switch plan 脚本,确认 eligible/enabled 和 artifact env 需求来自 adapter。 +8. 只有新增 broker 数据源或执行能力时,才修改平台仓主流程。 ## 测试门槛 diff --git a/src/quant_platform_kit/__init__.py b/src/quant_platform_kit/__init__.py index 1819e80..dcbcb6e 100644 --- a/src/quant_platform_kit/__init__.py +++ b/src/quant_platform_kit/__init__.py @@ -16,18 +16,23 @@ AllocationIntent, BudgetIntent, PositionTarget, + StrategyArtifactContract, StrategyContext, StrategyContractValidationError, StrategyDecision as StrategyContractDecision, StrategyEntrypoint, StrategyManifest, StrategyRuntimeAdapter, + StrategyRuntimePolicy, build_allocation_intent, build_allocation_payload, build_value_target_allocation_intent, + resolve_strategy_artifact_contract, + validate_strategy_artifact_contract, validate_strategy_decision, validate_strategy_manifest, validate_strategy_runtime_adapter, + validate_strategy_runtime_policy, build_value_target_plan_payload, ) from .common.execution_translation import ( @@ -82,6 +87,7 @@ "PricePoint", "PriceSeries", "QuoteSnapshot", + "StrategyArtifactContract", "StrategyCatalog", "StrategyContext", "StrategyContractDecision", @@ -93,6 +99,7 @@ "StrategyManifest", "StrategyMetadata", "StrategyRuntimeAdapter", + "StrategyRuntimePolicy", "ValueTargetPortfolioInputs", "US_EQUITY_DOMAIN", "build_allocation_intent", @@ -122,7 +129,10 @@ "normalize_profile_name", "resolve_catalog_profile", "resolve_platform_strategy_definition", + "resolve_strategy_artifact_contract", + "validate_strategy_artifact_contract", "validate_strategy_decision", "validate_strategy_manifest", "validate_strategy_runtime_adapter", + "validate_strategy_runtime_policy", ] diff --git a/src/quant_platform_kit/common/feature_snapshot_runtime.py b/src/quant_platform_kit/common/feature_snapshot_runtime.py new file mode 100644 index 0000000..960bb07 --- /dev/null +++ b/src/quant_platform_kit/common/feature_snapshot_runtime.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable, Mapping + +from .feature_snapshot import load_feature_snapshot_guarded +from .strategy_contracts import ( + StrategyContext, + StrategyDecision, + StrategyEntrypoint, + StrategyRuntimeAdapter, + build_strategy_context_from_available_inputs, +) + + +FEATURE_SNAPSHOT_INPUT = "feature_snapshot" + + +@dataclass(frozen=True) +class FeatureSnapshotRuntimeSettings: + feature_snapshot_path: str | None + feature_snapshot_manifest_path: str | None = None + strategy_config_path: str | None = None + strategy_config_source: str | None = None + dry_run_only: bool = False + + +@dataclass(frozen=True) +class FeatureSnapshotContextRequest: + entrypoint: StrategyEntrypoint + runtime_adapter: StrategyRuntimeAdapter + as_of: Any + available_inputs: Mapping[str, Any] + runtime_config: Mapping[str, Any] + + +@dataclass(frozen=True) +class FeatureSnapshotEvaluationResult: + decision: StrategyDecision + metadata: Mapping[str, Any] = field(default_factory=dict) + + +ContextBuilder = Callable[[FeatureSnapshotContextRequest], StrategyContext] +SnapshotLoader = Callable[..., Any] + + +def default_feature_snapshot_context_builder( + request: FeatureSnapshotContextRequest, +) -> StrategyContext: + return build_strategy_context_from_available_inputs( + entrypoint=request.entrypoint, + runtime_adapter=request.runtime_adapter, + as_of=request.as_of, + available_inputs=request.available_inputs, + runtime_config=request.runtime_config, + ) + + +def evaluate_feature_snapshot_strategy( + *, + entrypoint: StrategyEntrypoint, + runtime_adapter: StrategyRuntimeAdapter, + runtime_settings: FeatureSnapshotRuntimeSettings, + runtime_config: Mapping[str, Any], + merged_runtime_config: Mapping[str, Any], + available_inputs: Mapping[str, Any] | None = None, + as_of: Any | None = None, + base_managed_symbols: tuple[str, ...] = (), + status_icon: str | None = None, + include_strategy_display_name: bool = False, + include_safe_haven_metadata: bool = True, + set_run_as_of: bool = False, + default_benchmark_symbol: str = "QQQ", + default_safe_haven_symbol: str | None = "BOXX", + build_available_inputs: Callable[[Any], Mapping[str, Any]] | None = None, + context_builder: ContextBuilder = default_feature_snapshot_context_builder, + snapshot_loader: SnapshotLoader = load_feature_snapshot_guarded, + on_guard_metadata: Callable[[Mapping[str, Any]], None] | None = None, + extra_success_metadata: Callable[ + [Any, tuple[str, ...], StrategyDecision], + Mapping[str, Any], + ] + | None = None, + catch_evaluation_errors: bool = False, +) -> FeatureSnapshotEvaluationResult: + profile = entrypoint.manifest.profile + evaluation_as_of = as_of if as_of is not None else datetime.now(timezone.utc) + runtime_config = dict(runtime_config) + if set_run_as_of: + runtime_config.setdefault("run_as_of", evaluation_as_of) + _apply_runtime_policy(runtime_config, runtime_adapter) + + runtime_config_name = str( + merged_runtime_config.get("runtime_config_name") or profile + ) + runtime_config_path = ( + merged_runtime_config.get("runtime_config_path") + or runtime_settings.strategy_config_path + ) + runtime_config_source = ( + merged_runtime_config.get("runtime_config_source") + or runtime_settings.strategy_config_source + ) + benchmark_symbol = _resolve_symbol( + merged_runtime_config.get("benchmark_symbol"), + default=default_benchmark_symbol, + ) + safe_haven_symbol = _resolve_symbol( + merged_runtime_config.get("safe_haven"), + default=default_safe_haven_symbol, + ) + + if not runtime_settings.feature_snapshot_path: + return _fail_closed_result( + profile=profile, + display_name=entrypoint.manifest.display_name, + include_strategy_display_name=include_strategy_display_name, + feature_snapshot_path=None, + runtime_config_path=runtime_config_path, + runtime_config_source=runtime_config_source, + dry_run_only=runtime_settings.dry_run_only, + managed_symbols=base_managed_symbols, + safe_haven_symbol=safe_haven_symbol, + include_safe_haven_metadata=include_safe_haven_metadata, + signal_description="feature snapshot required", + decision_text="fail_closed", + reason="feature_snapshot_path_missing", + metadata={ + "snapshot_guard_decision": "fail_closed", + "fail_reason": "feature_snapshot_path_missing", + }, + ) + + guard_result = snapshot_loader( + runtime_settings.feature_snapshot_path, + run_as_of=evaluation_as_of, + required_columns=runtime_adapter.required_feature_columns, + snapshot_date_columns=tuple(runtime_adapter.snapshot_date_columns), + max_snapshot_month_lag=int(runtime_adapter.max_snapshot_month_lag), + manifest_path=runtime_settings.feature_snapshot_manifest_path, + require_manifest=bool(runtime_adapter.require_snapshot_manifest), + expected_strategy_profile=profile, + expected_config_name=runtime_config_name, + expected_config_path=runtime_config_path, + expected_contract_version=runtime_adapter.snapshot_contract_version, + ) + guard_metadata = dict(guard_result.metadata) + if on_guard_metadata is not None: + on_guard_metadata(guard_metadata) + + if guard_metadata.get("snapshot_guard_decision") != "proceed": + decision_text = str(guard_metadata.get("snapshot_guard_decision") or "fail_closed") + reason = guard_metadata.get("fail_reason") or guard_metadata.get("no_op_reason") + return _fail_closed_result( + profile=profile, + display_name=entrypoint.manifest.display_name, + include_strategy_display_name=include_strategy_display_name, + feature_snapshot_path=runtime_settings.feature_snapshot_path, + runtime_config_path=runtime_config_path, + runtime_config_source=runtime_config_source, + dry_run_only=runtime_settings.dry_run_only, + managed_symbols=base_managed_symbols, + safe_haven_symbol=safe_haven_symbol, + include_safe_haven_metadata=include_safe_haven_metadata, + signal_description="feature snapshot guard blocked execution", + decision_text=decision_text, + reason=reason, + metadata=guard_metadata, + ) + + feature_snapshot = guard_result.frame + evaluation_inputs = dict(available_inputs or {}) + if build_available_inputs is None: + evaluation_inputs[FEATURE_SNAPSHOT_INPUT] = feature_snapshot + else: + evaluation_inputs.update(build_available_inputs(feature_snapshot)) + + ctx = context_builder( + FeatureSnapshotContextRequest( + entrypoint=entrypoint, + runtime_adapter=runtime_adapter, + as_of=evaluation_as_of, + available_inputs=evaluation_inputs, + runtime_config=runtime_config, + ) + ) + try: + decision = entrypoint.evaluate(ctx) + except Exception as exc: + if not catch_evaluation_errors: + raise + fail_reason = f"feature_snapshot_compute_failed:{type(exc).__name__}:{exc}" + metadata = { + **guard_metadata, + "snapshot_guard_decision": "fail_closed", + "fail_reason": fail_reason, + } + return _fail_closed_result( + profile=profile, + display_name=entrypoint.manifest.display_name, + include_strategy_display_name=include_strategy_display_name, + feature_snapshot_path=runtime_settings.feature_snapshot_path, + runtime_config_path=runtime_config_path, + runtime_config_source=runtime_config_source, + dry_run_only=runtime_settings.dry_run_only, + managed_symbols=(), + safe_haven_symbol=safe_haven_symbol, + include_safe_haven_metadata=include_safe_haven_metadata, + signal_description="feature snapshot compute failed", + decision_text="fail_closed", + reason=fail_reason, + metadata=metadata, + ) + + managed_symbols = extract_feature_snapshot_managed_symbols( + runtime_adapter=runtime_adapter, + feature_snapshot=feature_snapshot, + benchmark_symbol=benchmark_symbol, + safe_haven_symbol=safe_haven_symbol, + fallback_symbols=base_managed_symbols, + ) + metadata = _base_metadata( + profile=profile, + display_name=entrypoint.manifest.display_name, + include_strategy_display_name=include_strategy_display_name, + runtime_config_path=runtime_config_path, + runtime_config_source=runtime_config_source, + dry_run_only=runtime_settings.dry_run_only, + managed_symbols=managed_symbols, + safe_haven_symbol=safe_haven_symbol, + include_safe_haven_metadata=include_safe_haven_metadata, + status_icon=status_icon or runtime_adapter.status_icon, + ) + metadata.update( + { + "feature_snapshot_path": runtime_settings.feature_snapshot_path, + **guard_metadata, + } + ) + if extra_success_metadata is not None: + metadata.update(extra_success_metadata(feature_snapshot, managed_symbols, decision)) + return FeatureSnapshotEvaluationResult(decision=decision, metadata=metadata) + + +def extract_feature_snapshot_managed_symbols( + *, + runtime_adapter: StrategyRuntimeAdapter, + feature_snapshot: Any, + benchmark_symbol: str, + safe_haven_symbol: str | None, + fallback_symbols: tuple[str, ...] = (), +) -> tuple[str, ...]: + extractor = runtime_adapter.managed_symbols_extractor + if callable(extractor): + return tuple( + extractor( + feature_snapshot, + benchmark_symbol=benchmark_symbol, + safe_haven=safe_haven_symbol, + ) + ) + if safe_haven_symbol: + return (safe_haven_symbol,) + return fallback_symbols + + +def _apply_runtime_policy( + runtime_config: dict[str, Any], + runtime_adapter: StrategyRuntimeAdapter, +) -> None: + trading_days = runtime_adapter.runtime_policy.runtime_execution_window_trading_days + if trading_days is not None: + runtime_config.setdefault("runtime_execution_window_trading_days", trading_days) + + +def _resolve_symbol(raw_value: Any, *, default: str | None) -> str | None: + value = str(raw_value or default or "").strip().upper() + return value or None + + +def _fail_closed_result( + *, + profile: str, + display_name: str, + include_strategy_display_name: bool, + feature_snapshot_path: str | None, + runtime_config_path: Any, + runtime_config_source: Any, + dry_run_only: bool, + managed_symbols: tuple[str, ...], + safe_haven_symbol: str | None, + include_safe_haven_metadata: bool, + signal_description: str, + decision_text: str, + reason: Any, + metadata: Mapping[str, Any], +) -> FeatureSnapshotEvaluationResult: + result_metadata = _base_metadata( + profile=profile, + display_name=display_name, + include_strategy_display_name=include_strategy_display_name, + runtime_config_path=runtime_config_path, + runtime_config_source=runtime_config_source, + dry_run_only=dry_run_only, + managed_symbols=managed_symbols, + safe_haven_symbol=safe_haven_symbol, + include_safe_haven_metadata=include_safe_haven_metadata, + status_icon="🛑", + ) + result_metadata["feature_snapshot_path"] = feature_snapshot_path + result_metadata.update(metadata) + decision = StrategyDecision( + risk_flags=("no_execute",), + diagnostics={ + "signal_description": signal_description, + "status_description": f"{decision_text} | reason={reason}", + "actionable": False, + "snapshot_guard_decision": decision_text, + "fail_reason": metadata.get("fail_reason"), + "no_op_reason": metadata.get("no_op_reason"), + }, + ) + return FeatureSnapshotEvaluationResult(decision=decision, metadata=result_metadata) + + +def _base_metadata( + *, + profile: str, + display_name: str, + include_strategy_display_name: bool, + runtime_config_path: Any, + runtime_config_source: Any, + dry_run_only: bool, + managed_symbols: tuple[str, ...], + safe_haven_symbol: str | None, + include_safe_haven_metadata: bool, + status_icon: str, +) -> dict[str, Any]: + metadata: dict[str, Any] = { + "strategy_profile": profile, + "strategy_config_path": runtime_config_path, + "strategy_config_source": runtime_config_source, + "dry_run_only": dry_run_only, + "managed_symbols": managed_symbols, + "status_icon": status_icon, + } + if include_strategy_display_name: + metadata["strategy_display_name"] = str(display_name) + if include_safe_haven_metadata: + metadata["safe_haven_symbol"] = safe_haven_symbol + return metadata diff --git a/src/quant_platform_kit/common/runtime_config.py b/src/quant_platform_kit/common/runtime_config.py new file mode 100644 index 0000000..507020d --- /dev/null +++ b/src/quant_platform_kit/common/runtime_config.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +from .strategies import ( + StrategyCatalog, + StrategyDefinition, + StrategyMetadata, + derive_strategy_artifact_paths, +) + + +@dataclass(frozen=True) +class StrategyRuntimePathSettings: + strategy_profile: str + strategy_display_name: str + strategy_domain: str + strategy_target_mode: str | None + strategy_artifact_root: str | None + strategy_artifact_dir: str | None + feature_snapshot_path: str | None + feature_snapshot_manifest_path: str | None + strategy_config_path: str | None + strategy_config_source: str | None + reconciliation_output_path: str | None = None + + +def first_non_empty(*values: str | None) -> str | None: + for value in values: + text = str(value or "").strip() + if text: + return text + return None + + +def resolve_bool_value(raw_value: str | None) -> bool: + return str(raw_value or "").strip().lower() in {"1", "true", "yes", "y", "on"} + + +def resolve_strategy_config_path( + *, + explicit_path: str | None, + bundled_path: str | None, +) -> tuple[str | None, str | None]: + path = first_non_empty(explicit_path) + if path is not None: + return path, "env" + + bundled = first_non_empty(bundled_path) + if bundled is not None and Path(bundled).exists(): + return bundled, "bundled_canonical_default" + return None, None + + +def resolve_strategy_runtime_path_settings( + *, + strategy_catalog: StrategyCatalog, + strategy_definition: StrategyDefinition, + strategy_metadata: StrategyMetadata, + platform_env_prefix: str, + env: Mapping[str, str | None], + repo_root: str | Path | None, + include_reconciliation_output: bool = False, +) -> StrategyRuntimePathSettings: + prefix = str(platform_env_prefix).strip().upper() + if not prefix: + raise ValueError("platform_env_prefix must be non-empty") + + artifact_paths = derive_strategy_artifact_paths( + strategy_catalog, + strategy_definition.profile, + artifact_root=first_non_empty( + env.get(f"{prefix}_STRATEGY_ARTIFACT_ROOT"), + env.get("STRATEGY_ARTIFACT_ROOT"), + ), + repo_root=repo_root, + ) + strategy_config_path, strategy_config_source = resolve_strategy_config_path( + explicit_path=first_non_empty( + env.get(f"{prefix}_STRATEGY_CONFIG_PATH"), + env.get("STRATEGY_CONFIG_PATH"), + ), + bundled_path=( + str(artifact_paths.bundled_config_path) + if artifact_paths.bundled_config_path is not None + else None + ), + ) + + reconciliation_output_path = None + if include_reconciliation_output: + reconciliation_output_path = first_non_empty( + env.get(f"{prefix}_RECONCILIATION_OUTPUT_PATH"), + env.get("RECONCILIATION_OUTPUT_PATH"), + str(artifact_paths.reconciliation_output_dir) + if artifact_paths.reconciliation_output_dir is not None + else None, + ) + + return StrategyRuntimePathSettings( + strategy_profile=strategy_definition.profile, + strategy_display_name=strategy_metadata.display_name, + strategy_domain=strategy_definition.domain, + strategy_target_mode=strategy_definition.target_mode, + strategy_artifact_root=str(artifact_paths.artifact_root) + if artifact_paths.artifact_root is not None + else None, + strategy_artifact_dir=str(artifact_paths.artifact_dir) + if artifact_paths.artifact_dir is not None + else None, + feature_snapshot_path=first_non_empty( + env.get(f"{prefix}_FEATURE_SNAPSHOT_PATH"), + env.get("FEATURE_SNAPSHOT_PATH"), + str(artifact_paths.feature_snapshot_path) + if artifact_paths.feature_snapshot_path is not None + else None, + ), + feature_snapshot_manifest_path=first_non_empty( + env.get(f"{prefix}_FEATURE_SNAPSHOT_MANIFEST_PATH"), + env.get("FEATURE_SNAPSHOT_MANIFEST_PATH"), + str(artifact_paths.feature_snapshot_manifest_path) + if artifact_paths.feature_snapshot_manifest_path is not None + else None, + ), + strategy_config_path=strategy_config_path, + strategy_config_source=strategy_config_source, + reconciliation_output_path=reconciliation_output_path, + ) diff --git a/src/quant_platform_kit/common/strategies.py b/src/quant_platform_kit/common/strategies.py index 0cf45e9..fa337d2 100644 --- a/src/quant_platform_kit/common/strategies.py +++ b/src/quant_platform_kit/common/strategies.py @@ -302,7 +302,18 @@ def derive_strategy_artifact_paths( bundled_config_path = None bundled_config_relpath = str(definition.bundled_config_relpath or "").strip() - if bundled_config_relpath and repo_root_path is not None: + if bundled_config_relpath.startswith("package://"): + package_resource = bundled_config_relpath.removeprefix("package://") + package_name, separator, resource_path = package_resource.partition("/") + if not separator or not package_name or not resource_path: + raise ValueError( + f"Invalid package bundled config path {bundled_config_relpath!r}" + ) + package_module = import_module(package_name) + if package_module.__file__ is None: + raise ValueError(f"Package {package_name!r} does not expose a filesystem path") + bundled_config_path = Path(package_module.__file__).resolve().parent / resource_path + elif bundled_config_relpath and repo_root_path is not None: bundled_config_path = repo_root_path / bundled_config_relpath feature_snapshot_path = None diff --git a/src/quant_platform_kit/common/strategy_contracts.py b/src/quant_platform_kit/common/strategy_contracts.py index 307c001..eeb2267 100644 --- a/src/quant_platform_kit/common/strategy_contracts.py +++ b/src/quant_platform_kit/common/strategy_contracts.py @@ -129,6 +129,21 @@ class StrategyEntrypoint(Protocol): def evaluate(self, ctx: StrategyContext) -> StrategyDecision: ... +@dataclass(frozen=True) +class StrategyArtifactContract: + requires_snapshot_artifacts: bool = False + requires_snapshot_manifest_path: bool = False + requires_strategy_config_path: bool = False + snapshot_contract_version: str | None = None + config_source_policy: str = "none" + + +@dataclass(frozen=True) +class StrategyRuntimePolicy: + reconciliation_output_policy: str = "none" + runtime_execution_window_trading_days: int | None = None + + @dataclass(frozen=True) class StrategyRuntimeAdapter: status_icon: str = "🐤" @@ -142,6 +157,8 @@ class StrategyRuntimeAdapter: runtime_parameter_loader: Callable[..., Mapping[str, object]] | None = None managed_symbols_extractor: Callable[..., tuple[str, ...]] | None = None portfolio_input_name: str | None = None + artifact_contract: StrategyArtifactContract | None = None + runtime_policy: StrategyRuntimePolicy = field(default_factory=StrategyRuntimePolicy) @dataclass(frozen=True) @@ -171,6 +188,35 @@ def _ensure_finite_number(value: float | None, *, field_name: str) -> None: raise StrategyContractValidationError(f"{field_name} must be a finite number when provided") +def _ensure_bool(value: bool, *, field_name: str) -> None: + if not isinstance(value, bool): + raise StrategyContractValidationError(f"{field_name} must be a bool") + + +def _ensure_allowed_string( + value: str, + *, + field_name: str, + allowed_values: frozenset[str], +) -> None: + _ensure_non_empty_string(value, field_name=field_name) + if value not in allowed_values: + allowed = ", ".join(sorted(allowed_values)) + raise StrategyContractValidationError(f"{field_name} must be one of: {allowed}") + + +_CONFIG_SOURCE_POLICIES = frozenset( + { + "none", + "bundled_or_env", + "env_only", + "artifact_manifest", + "runtime_parameter_loader", + } +) +_RECONCILIATION_OUTPUT_POLICIES = frozenset({"none", "optional", "required"}) + + def validate_strategy_manifest(manifest: StrategyManifest) -> StrategyManifest: if not isinstance(manifest, StrategyManifest): raise StrategyContractValidationError( @@ -231,6 +277,69 @@ def validate_strategy_decision(decision: StrategyDecision) -> StrategyDecision: return decision +def validate_strategy_artifact_contract( + contract: StrategyArtifactContract, +) -> StrategyArtifactContract: + if not isinstance(contract, StrategyArtifactContract): + raise StrategyContractValidationError( + f"artifact contract must be StrategyArtifactContract, got {type(contract).__name__}" + ) + + _ensure_bool( + contract.requires_snapshot_artifacts, + field_name="artifact_contract.requires_snapshot_artifacts", + ) + _ensure_bool( + contract.requires_snapshot_manifest_path, + field_name="artifact_contract.requires_snapshot_manifest_path", + ) + _ensure_bool( + contract.requires_strategy_config_path, + field_name="artifact_contract.requires_strategy_config_path", + ) + if contract.requires_snapshot_manifest_path and not contract.requires_snapshot_artifacts: + raise StrategyContractValidationError( + "artifact_contract.requires_snapshot_manifest_path requires snapshot artifacts" + ) + if contract.requires_strategy_config_path and contract.config_source_policy == "none": + raise StrategyContractValidationError( + "artifact_contract.config_source_policy must describe required strategy config" + ) + if contract.snapshot_contract_version is not None: + _ensure_non_empty_string( + contract.snapshot_contract_version, + field_name="artifact_contract.snapshot_contract_version", + ) + _ensure_allowed_string( + contract.config_source_policy, + field_name="artifact_contract.config_source_policy", + allowed_values=_CONFIG_SOURCE_POLICIES, + ) + return contract + + +def validate_strategy_runtime_policy(policy: StrategyRuntimePolicy) -> StrategyRuntimePolicy: + if not isinstance(policy, StrategyRuntimePolicy): + raise StrategyContractValidationError( + f"runtime policy must be StrategyRuntimePolicy, got {type(policy).__name__}" + ) + + _ensure_allowed_string( + policy.reconciliation_output_policy, + field_name="runtime_policy.reconciliation_output_policy", + allowed_values=_RECONCILIATION_OUTPUT_POLICIES, + ) + if policy.runtime_execution_window_trading_days is not None: + if ( + not isinstance(policy.runtime_execution_window_trading_days, int) + or policy.runtime_execution_window_trading_days <= 0 + ): + raise StrategyContractValidationError( + "runtime_policy.runtime_execution_window_trading_days must be a positive integer" + ) + return policy + + def validate_strategy_runtime_adapter(adapter: StrategyRuntimeAdapter) -> StrategyRuntimeAdapter: if not isinstance(adapter, StrategyRuntimeAdapter): raise StrategyContractValidationError( @@ -276,9 +385,40 @@ def validate_strategy_runtime_adapter(adapter: StrategyRuntimeAdapter) -> Strate adapter.portfolio_input_name, field_name="runtime_adapter.portfolio_input_name", ) + if adapter.artifact_contract is not None: + validate_strategy_artifact_contract(adapter.artifact_contract) + validate_strategy_runtime_policy(adapter.runtime_policy) return adapter +def resolve_strategy_artifact_contract( + adapter: StrategyRuntimeAdapter, + *, + required_inputs: frozenset[str] | set[str] | tuple[str, ...] = frozenset(), +) -> StrategyArtifactContract: + validate_strategy_runtime_adapter(adapter) + if adapter.artifact_contract is not None: + return adapter.artifact_contract + + normalized_required_inputs = frozenset(str(value).strip() for value in required_inputs) + requires_snapshot_artifacts = "feature_snapshot" in normalized_required_inputs + requires_strategy_config_path = bool( + requires_snapshot_artifacts and callable(adapter.runtime_parameter_loader) + ) + config_source_policy = "runtime_parameter_loader" if requires_strategy_config_path else "none" + return validate_strategy_artifact_contract( + StrategyArtifactContract( + requires_snapshot_artifacts=requires_snapshot_artifacts, + requires_snapshot_manifest_path=bool( + requires_snapshot_artifacts and adapter.require_snapshot_manifest + ), + requires_strategy_config_path=requires_strategy_config_path, + snapshot_contract_version=adapter.snapshot_contract_version, + config_source_policy=config_source_policy, + ) + ) + + def build_value_target_execution_plan( decision: StrategyDecision, *, diff --git a/src/quant_platform_kit/ibkr/runtime_inputs.py b/src/quant_platform_kit/ibkr/runtime_inputs.py index f24a14e..f861cd6 100644 --- a/src/quant_platform_kit/ibkr/runtime_inputs.py +++ b/src/quant_platform_kit/ibkr/runtime_inputs.py @@ -71,7 +71,7 @@ def build_semiconductor_rotation_indicators( ib: Any, historical_close_loader: Callable[..., Any], *, - trend_ma_window: int = 150, + trend_ma_window: int = 140, lookback_buffer: int = 20, ) -> dict[str, dict[str, float]]: effective_lookback = max(220, int(trend_ma_window) + int(lookback_buffer)) @@ -121,7 +121,7 @@ def build_semiconductor_rotation_inputs( ib: Any, historical_close_loader: Callable[..., Any], *, - trend_ma_window: int = 150, + trend_ma_window: int = 140, lookback_buffer: int = 20, ) -> dict[str, dict[str, dict[str, float]]]: return { diff --git a/src/quant_platform_kit/strategy_contracts.py b/src/quant_platform_kit/strategy_contracts.py index cb98eef..f68e7db 100644 --- a/src/quant_platform_kit/strategy_contracts.py +++ b/src/quant_platform_kit/strategy_contracts.py @@ -3,12 +3,14 @@ BudgetIntent, CallableStrategyEntrypoint, PositionTarget, + StrategyArtifactContract, StrategyContext, StrategyContractValidationError, StrategyDecision, StrategyEntrypoint, StrategyManifest, StrategyRuntimeAdapter, + StrategyRuntimePolicy, ValueTargetExecutionPlan, ValueTargetExecutionAnnotations, ValueTargetPortfolioPlan, @@ -20,9 +22,12 @@ build_value_target_portfolio_plan, build_strategy_context_from_available_inputs, build_value_target_execution_plan, + resolve_strategy_artifact_contract, + validate_strategy_artifact_contract, validate_strategy_decision, validate_strategy_manifest, validate_strategy_runtime_adapter, + validate_strategy_runtime_policy, ) from .common.execution_translation import ( ValueTargetPortfolioInputs, @@ -45,12 +50,14 @@ "BudgetIntent", "CallableStrategyEntrypoint", "PositionTarget", + "StrategyArtifactContract", "StrategyContext", "StrategyContractValidationError", "StrategyDecision", "StrategyEntrypoint", "StrategyManifest", "StrategyRuntimeAdapter", + "StrategyRuntimePolicy", "ValueTargetExecutionAnnotations", "ValueTargetExecutionPlan", "ValueTargetPortfolioInputs", @@ -73,7 +80,10 @@ "build_value_target_runtime_plan", "build_strategy_context_from_available_inputs", "build_value_target_execution_plan", + "resolve_strategy_artifact_contract", + "validate_strategy_artifact_contract", "validate_strategy_decision", "validate_strategy_manifest", "validate_strategy_runtime_adapter", + "validate_strategy_runtime_policy", ] diff --git a/tests/test_feature_snapshot_runtime.py b/tests/test_feature_snapshot_runtime.py new file mode 100644 index 0000000..5679ba9 --- /dev/null +++ b/tests/test_feature_snapshot_runtime.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timezone +from typing import Any + +from quant_platform_kit.common.feature_snapshot_runtime import ( + FeatureSnapshotContextRequest, + FeatureSnapshotRuntimeSettings, + evaluate_feature_snapshot_strategy, +) +from quant_platform_kit.strategy_contracts import ( + CallableStrategyEntrypoint, + PositionTarget, + StrategyContext, + StrategyDecision, + StrategyManifest, + StrategyRuntimeAdapter, + StrategyRuntimePolicy, +) + + +def _entrypoint() -> CallableStrategyEntrypoint: + return CallableStrategyEntrypoint( + manifest=StrategyManifest( + profile="feature_snapshot_strategy", + domain="us_equity", + display_name="Feature Snapshot Strategy", + description="test", + required_inputs=frozenset({"feature_snapshot"}), + ), + _evaluate=lambda ctx: StrategyDecision( + positions=( + PositionTarget( + symbol=ctx.market_data["feature_snapshot"][0]["symbol"], + target_weight=1.0, + ), + ), + diagnostics={"run_as_of": ctx.runtime_config.get("run_as_of")}, + ), + ) + + +class FeatureSnapshotRuntimeTests(unittest.TestCase): + def test_fail_closes_when_path_missing(self) -> None: + result = evaluate_feature_snapshot_strategy( + entrypoint=_entrypoint(), + runtime_adapter=StrategyRuntimeAdapter(status_icon="🧲"), + runtime_settings=FeatureSnapshotRuntimeSettings( + feature_snapshot_path=None, + strategy_config_path="/tmp/config.json", + strategy_config_source="env", + ), + runtime_config={}, + merged_runtime_config={"managed_symbols": ("AAPL", "BOXX")}, + base_managed_symbols=("AAPL", "BOXX"), + ) + + self.assertEqual(result.decision.risk_flags, ("no_execute",)) + self.assertEqual(result.metadata["snapshot_guard_decision"], "fail_closed") + self.assertEqual(result.metadata["fail_reason"], "feature_snapshot_path_missing") + self.assertEqual(result.metadata["status_icon"], "🛑") + self.assertEqual(result.metadata["managed_symbols"], ("AAPL", "BOXX")) + + def test_loads_snapshot_into_context(self) -> None: + observed: dict[str, Any] = {} + as_of = datetime(2026, 4, 15, tzinfo=timezone.utc) + + def snapshot_loader(path: str, **kwargs: Any): + observed["path"] = path + observed["kwargs"] = kwargs + return type( + "GuardResult", + (), + { + "frame": [{"symbol": "AAPL", "close": 100.0}], + "metadata": { + "snapshot_guard_decision": "proceed", + "snapshot_as_of": "2026-04-15", + }, + }, + )() + + result = evaluate_feature_snapshot_strategy( + entrypoint=_entrypoint(), + runtime_adapter=StrategyRuntimeAdapter( + status_icon="🧲", + required_feature_columns=frozenset({"symbol", "close"}), + managed_symbols_extractor=lambda *_args, **_kwargs: ("AAPL", "BOXX"), + runtime_policy=StrategyRuntimePolicy(runtime_execution_window_trading_days=1), + ), + runtime_settings=FeatureSnapshotRuntimeSettings( + feature_snapshot_path="gs://bucket/snapshot.csv", + feature_snapshot_manifest_path="gs://bucket/snapshot.csv.manifest.json", + dry_run_only=True, + ), + runtime_config={}, + merged_runtime_config={"safe_haven": "BOXX", "benchmark_symbol": "QQQ"}, + as_of=as_of, + set_run_as_of=True, + snapshot_loader=snapshot_loader, + ) + + self.assertEqual(observed["path"], "gs://bucket/snapshot.csv") + self.assertEqual(observed["kwargs"]["run_as_of"], as_of) + self.assertEqual( + observed["kwargs"]["manifest_path"], + "gs://bucket/snapshot.csv.manifest.json", + ) + self.assertEqual(result.decision.positions[0].symbol, "AAPL") + self.assertEqual(result.decision.diagnostics["run_as_of"], as_of) + self.assertEqual(result.metadata["managed_symbols"], ("AAPL", "BOXX")) + self.assertEqual(result.metadata["status_icon"], "🧲") + self.assertIs(result.metadata["dry_run_only"], True) + + def test_supports_custom_context_builder(self) -> None: + def snapshot_loader(_path: str, **_kwargs: Any): + return type( + "GuardResult", + (), + { + "frame": [{"symbol": "MSFT", "close": 200.0}], + "metadata": {"snapshot_guard_decision": "proceed"}, + }, + )() + + def build_inputs(frame): + return {"custom_snapshot": frame} + + def context_builder(request: FeatureSnapshotContextRequest) -> StrategyContext: + return StrategyContext( + as_of=request.as_of, + market_data={"feature_snapshot": request.available_inputs["custom_snapshot"]}, + runtime_config=request.runtime_config, + ) + + result = evaluate_feature_snapshot_strategy( + entrypoint=_entrypoint(), + runtime_adapter=StrategyRuntimeAdapter(status_icon="📏"), + runtime_settings=FeatureSnapshotRuntimeSettings(feature_snapshot_path="/tmp/snapshot.csv"), + runtime_config={}, + merged_runtime_config={}, + build_available_inputs=build_inputs, + context_builder=context_builder, + snapshot_loader=snapshot_loader, + ) + + self.assertEqual(result.decision.positions[0].symbol, "MSFT") + + def test_can_fail_close_entrypoint_errors(self) -> None: + entrypoint = CallableStrategyEntrypoint( + manifest=StrategyManifest( + profile="feature_snapshot_strategy", + domain="us_equity", + display_name="Feature Snapshot Strategy", + description="test", + required_inputs=frozenset({"feature_snapshot"}), + ), + _evaluate=lambda _ctx: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + def snapshot_loader(_path: str, **_kwargs: Any): + return type( + "GuardResult", + (), + { + "frame": [{"symbol": "AAPL", "close": 100.0}], + "metadata": {"snapshot_guard_decision": "proceed"}, + }, + )() + + result = evaluate_feature_snapshot_strategy( + entrypoint=entrypoint, + runtime_adapter=StrategyRuntimeAdapter(status_icon="📏"), + runtime_settings=FeatureSnapshotRuntimeSettings(feature_snapshot_path="/tmp/snapshot.csv"), + runtime_config={}, + merged_runtime_config={}, + snapshot_loader=snapshot_loader, + catch_evaluation_errors=True, + ) + + self.assertEqual(result.decision.risk_flags, ("no_execute",)) + self.assertEqual(result.metadata["snapshot_guard_decision"], "fail_closed") + self.assertEqual( + result.metadata["fail_reason"], + "feature_snapshot_compute_failed:RuntimeError:boom", + ) + + def test_raises_entrypoint_errors_by_default(self) -> None: + entrypoint = CallableStrategyEntrypoint( + manifest=StrategyManifest( + profile="feature_snapshot_strategy", + domain="us_equity", + display_name="Feature Snapshot Strategy", + description="test", + required_inputs=frozenset({"feature_snapshot"}), + ), + _evaluate=lambda _ctx: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + def snapshot_loader(_path: str, **_kwargs: Any): + return type( + "GuardResult", + (), + { + "frame": [{"symbol": "AAPL", "close": 100.0}], + "metadata": {"snapshot_guard_decision": "proceed"}, + }, + )() + + with self.assertRaisesRegex(RuntimeError, "boom"): + evaluate_feature_snapshot_strategy( + entrypoint=entrypoint, + runtime_adapter=StrategyRuntimeAdapter(status_icon="📏"), + runtime_settings=FeatureSnapshotRuntimeSettings(feature_snapshot_path="/tmp/snapshot.csv"), + runtime_config={}, + merged_runtime_config={}, + snapshot_loader=snapshot_loader, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ibkr_runtime_inputs.py b/tests/test_ibkr_runtime_inputs.py index 1282d77..47ffc2a 100644 --- a/tests/test_ibkr_runtime_inputs.py +++ b/tests/test_ibkr_runtime_inputs.py @@ -44,7 +44,7 @@ def test_build_ibkr_strategy_context_uses_required_inputs_and_portfolio(self) -> (), { "manifest": StrategyManifest( - profile="semiconductor_rotation_income", + profile="soxl_soxx_trend_income", domain="us_equity", display_name="SOXL/SOXX Semiconductor Trend Income", description="test", @@ -87,7 +87,7 @@ def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"): indicators = build_semiconductor_rotation_indicators( "fake-ib", fake_loader, - trend_ma_window=150, + trend_ma_window=140, ) self.assertEqual(observed[0], ("SOXL", "220 D", "1 day")) @@ -95,12 +95,12 @@ def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"): self.assertEqual(indicators["soxl"]["price"], 269.0) self.assertAlmostEqual( indicators["soxl"]["ma_trend"], - sum(100.0 + idx for idx in range(20, 170)) / 150, + sum(100.0 + idx for idx in range(30, 170)) / 140, ) self.assertEqual(indicators["soxx"]["price"], 369.0) self.assertAlmostEqual( indicators["soxx"]["ma_trend"], - sum(200.0 + idx for idx in range(20, 170)) / 150, + sum(200.0 + idx for idx in range(30, 170)) / 140, ) self.assertAlmostEqual( indicators["soxx"]["ma20"], @@ -119,7 +119,7 @@ def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"): payload = build_semiconductor_rotation_inputs( "fake-ib", fake_loader, - trend_ma_window=150, + trend_ma_window=140, ) self.assertEqual(set(payload), {"derived_indicators"}) @@ -139,7 +139,7 @@ def fake_loader(_ib, symbol, duration="2 Y", bar_size="1 day"): build_semiconductor_rotation_indicators( "fake-ib", fake_loader, - trend_ma_window=150, + trend_ma_window=140, ) diff --git a/tests/test_runtime_config.py b/tests/test_runtime_config.py new file mode 100644 index 0000000..f27f82f --- /dev/null +++ b/tests/test_runtime_config.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from quant_platform_kit.common.runtime_config import ( + first_non_empty, + resolve_bool_value, + resolve_strategy_config_path, + resolve_strategy_runtime_path_settings, +) +from quant_platform_kit.common.strategies import ( + US_EQUITY_DOMAIN, + StrategyDefinition, + StrategyMetadata, + build_strategy_catalog, +) + + +class RuntimeConfigTests(unittest.TestCase): + def test_common_runtime_config_helpers_normalize_basic_values(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + bundled_config = tmp_path / "strategy.json" + bundled_config.write_text("{}", encoding="utf-8") + + self.assertEqual(first_non_empty("", None, " value "), "value") + self.assertIs(resolve_bool_value("yes"), True) + self.assertIs(resolve_bool_value("0"), False) + self.assertEqual( + resolve_strategy_config_path( + explicit_path=" /tmp/live.json ", + bundled_path=str(bundled_config), + ), + ("/tmp/live.json", "env"), + ) + self.assertEqual( + resolve_strategy_config_path( + explicit_path=None, + bundled_path=str(bundled_config), + ), + (str(bundled_config), "bundled_canonical_default"), + ) + + def test_resolve_strategy_runtime_path_settings_derives_platform_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + bundled_config = tmp_path / "configs" / "strategy.json" + bundled_config.parent.mkdir() + bundled_config.write_text("{}", encoding="utf-8") + catalog = build_strategy_catalog( + strategy_definitions={ + "feature_snapshot_strategy": StrategyDefinition( + profile="feature_snapshot_strategy", + domain=US_EQUITY_DOMAIN, + supported_platforms=frozenset({"ibkr"}), + required_inputs=frozenset({"feature_snapshot"}), + target_mode="weight", + bundled_config_relpath="configs/strategy.json", + ) + } + ) + definition = catalog.definitions["feature_snapshot_strategy"] + metadata = StrategyMetadata( + canonical_profile="feature_snapshot_strategy", + display_name="Feature Snapshot Strategy", + description="test", + ) + + settings = resolve_strategy_runtime_path_settings( + strategy_catalog=catalog, + strategy_definition=definition, + strategy_metadata=metadata, + platform_env_prefix="IBKR", + env={"IBKR_STRATEGY_ARTIFACT_ROOT": str(tmp_path / "artifacts")}, + repo_root=tmp_path, + include_reconciliation_output=True, + ) + + self.assertEqual(settings.strategy_profile, "feature_snapshot_strategy") + self.assertEqual(settings.strategy_display_name, "Feature Snapshot Strategy") + self.assertEqual(settings.strategy_domain, US_EQUITY_DOMAIN) + self.assertEqual(settings.strategy_target_mode, "weight") + self.assertEqual(settings.strategy_artifact_root, str(tmp_path / "artifacts")) + self.assertEqual( + settings.strategy_artifact_dir, + str(tmp_path / "artifacts" / "feature_snapshot_strategy"), + ) + self.assertEqual( + settings.feature_snapshot_path, + str( + tmp_path + / "artifacts" + / "feature_snapshot_strategy" + / "feature_snapshot_strategy_feature_snapshot_latest.csv" + ), + ) + self.assertEqual( + settings.feature_snapshot_manifest_path, + str( + tmp_path + / "artifacts" + / "feature_snapshot_strategy" + / "feature_snapshot_strategy_feature_snapshot_latest.csv.manifest.json" + ), + ) + self.assertEqual(settings.strategy_config_path, str(bundled_config)) + self.assertEqual(settings.strategy_config_source, "bundled_canonical_default") + self.assertEqual( + settings.reconciliation_output_path, + str(tmp_path / "artifacts" / "feature_snapshot_strategy" / "reconciliation"), + ) + + def test_resolve_strategy_runtime_path_settings_prefers_env_over_derived_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + catalog = build_strategy_catalog( + strategy_definitions={ + "feature_snapshot_strategy": StrategyDefinition( + profile="feature_snapshot_strategy", + domain=US_EQUITY_DOMAIN, + supported_platforms=frozenset({"schwab"}), + required_inputs=frozenset({"feature_snapshot"}), + ) + } + ) + definition = catalog.definitions["feature_snapshot_strategy"] + metadata = StrategyMetadata( + canonical_profile="feature_snapshot_strategy", + display_name="Feature Snapshot Strategy", + description="test", + ) + + settings = resolve_strategy_runtime_path_settings( + strategy_catalog=catalog, + strategy_definition=definition, + strategy_metadata=metadata, + platform_env_prefix="SCHWAB", + env={ + "SCHWAB_STRATEGY_ARTIFACT_ROOT": str(tmp_path / "artifacts"), + "SCHWAB_FEATURE_SNAPSHOT_PATH": "gs://bucket/snapshot.csv", + "FEATURE_SNAPSHOT_MANIFEST_PATH": "gs://bucket/snapshot.csv.manifest.json", + "STRATEGY_CONFIG_PATH": "/workspace/config.json", + }, + repo_root=tmp_path, + ) + + self.assertEqual(settings.feature_snapshot_path, "gs://bucket/snapshot.csv") + self.assertEqual( + settings.feature_snapshot_manifest_path, + "gs://bucket/snapshot.csv.manifest.json", + ) + self.assertEqual(settings.strategy_config_path, "/workspace/config.json") + self.assertEqual(settings.strategy_config_source, "env") + self.assertIsNone(settings.reconciliation_output_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_reports.py b/tests/test_runtime_reports.py index ee590f9..c2a9f34 100644 --- a/tests/test_runtime_reports.py +++ b/tests/test_runtime_reports.py @@ -24,7 +24,7 @@ def test_build_runtime_report_base_sets_shared_fields(self) -> None: platform="charles_schwab", deploy_target="cloud_run", service_name="schwab-runtime", - strategy_profile="hybrid_growth_income", + strategy_profile="tqqq_growth_income", strategy_domain="us_equity", run_id="run-001", run_source="cloud_run", @@ -77,7 +77,7 @@ def test_runtime_report_relative_path_matches_expected_layout(self) -> None: platform="longbridge", deploy_target="cloud_run", service_name="longbridge-runtime", - strategy_profile="semiconductor_rotation_income", + strategy_profile="soxl_soxx_trend_income", strategy_domain="us_equity", account_scope="HK", run_id="run-003", @@ -87,7 +87,7 @@ def test_runtime_report_relative_path_matches_expected_layout(self) -> None: self.assertEqual( runtime_report_relative_path(report).as_posix(), - "longbridge/semiconductor_rotation_income/HK/2026-04/run-003.json", + "longbridge/soxl_soxx_trend_income/HK/2026-04/run-003.json", ) def test_build_runtime_report_gcs_uri_uses_relative_layout(self) -> None: @@ -118,7 +118,7 @@ def test_persist_runtime_report_writes_local_and_uploads_gcs(self) -> None: platform="charles_schwab", deploy_target="cloud_run", service_name="schwab-runtime", - strategy_profile="hybrid_growth_income", + strategy_profile="tqqq_growth_income", strategy_domain="us_equity", run_id="run-005", run_source="cloud_run", @@ -178,14 +178,14 @@ def build_fake_client(*, project: str | None = None) -> FakeClient: self.assertEqual(result.local_path, str(default_runtime_report_path(report, base_dir=tmp_dir))) self.assertEqual( result.gcs_uri, - "gs://demo-bucket/runtime-reports/charles_schwab/hybrid_growth_income/2026-04/run-005.json", + "gs://demo-bucket/runtime-reports/charles_schwab/tqqq_growth_income/2026-04/run-005.json", ) self.assertEqual(payload["artifacts"]["runtime_report_gcs_uri"], result.gcs_uri) self.assertEqual(payload["artifacts"]["runtime_report_local_path"], result.local_path) self.assertEqual(fake_clients[0].project, "demo-project") self.assertEqual( fake_clients[0].buckets["demo-bucket"].last_blob.name, - "runtime-reports/charles_schwab/hybrid_growth_income/2026-04/run-005.json", + "runtime-reports/charles_schwab/tqqq_growth_income/2026-04/run-005.json", ) uploaded_payload = json.loads(fake_clients[0].buckets["demo-bucket"].last_blob.payload) self.assertEqual(uploaded_payload["artifacts"]["runtime_report_gcs_uri"], result.gcs_uri) diff --git a/tests/test_strategies.py b/tests/test_strategies.py index 4d4ca0b..52edd31 100644 --- a/tests/test_strategies.py +++ b/tests/test_strategies.py @@ -271,6 +271,28 @@ def test_catalog_helpers_expose_target_mode_and_artifact_paths(self) -> None: "/var/strategy-artifacts/feature_snapshot_strategy/reconciliation", ) + def test_artifact_paths_resolve_package_bundled_config(self) -> None: + catalog = build_strategy_catalog( + strategy_definitions={ + "feature_snapshot_strategy": StrategyDefinition( + profile="feature_snapshot_strategy", + domain=US_EQUITY_DOMAIN, + supported_platforms=frozenset({"ibkr"}), + required_inputs=frozenset({"feature_snapshot"}), + bundled_config_relpath="package://quant_platform_kit/__init__.py", + ) + } + ) + + paths = derive_strategy_artifact_paths( + catalog, + "feature_snapshot_strategy", + repo_root="/workspace/runtime", + ) + + self.assertIsNotNone(paths.bundled_config_path) + self.assertTrue(str(paths.bundled_config_path).endswith("quant_platform_kit/__init__.py")) + def test_platform_capability_matrix_derives_eligible_profiles(self) -> None: catalog = build_strategy_catalog( strategy_definitions={ @@ -281,15 +303,15 @@ def test_platform_capability_matrix_derives_eligible_profiles(self) -> None: required_inputs=frozenset({"historical_close_loader"}), target_mode="weight", ), - "hybrid_growth_income": StrategyDefinition( - profile="hybrid_growth_income", + "tqqq_growth_income": StrategyDefinition( + profile="tqqq_growth_income", domain=US_EQUITY_DOMAIN, supported_platforms=frozenset({"schwab"}), required_inputs=frozenset({"qqq_history", "snapshot"}), target_mode="value", ), - "tech_pullback_cash_buffer": StrategyDefinition( - profile="tech_pullback_cash_buffer", + "tech_communication_pullback_enhancement": StrategyDefinition( + profile="tech_communication_pullback_enhancement", domain=US_EQUITY_DOMAIN, supported_platforms=frozenset({"ibkr"}), required_inputs=frozenset({"feature_snapshot"}), @@ -316,10 +338,10 @@ def test_platform_capability_matrix_derives_eligible_profiles(self) -> None: available_inputs=frozenset({"historical_close_loader"}), available_capabilities=frozenset({"broker_client"}), ), - "hybrid_growth_income": StrategyRuntimeAdapter( + "tqqq_growth_income": StrategyRuntimeAdapter( available_inputs=frozenset({"qqq_history", "snapshot"}), ), - "tech_pullback_cash_buffer": StrategyRuntimeAdapter( + "tech_communication_pullback_enhancement": StrategyRuntimeAdapter( available_inputs=frozenset({"feature_snapshot"}), ), "schwab_only_weight": StrategyRuntimeAdapter( @@ -335,7 +357,7 @@ def test_platform_capability_matrix_derives_eligible_profiles(self) -> None: self.assertEqual( eligible, - frozenset({"global_etf_rotation", "tech_pullback_cash_buffer"}), + frozenset({"global_etf_rotation", "tech_communication_pullback_enhancement"}), ) def test_platform_capability_matrix_applies_rollout_allowlist(self) -> None: @@ -348,8 +370,8 @@ def test_platform_capability_matrix_applies_rollout_allowlist(self) -> None: required_inputs=frozenset({"historical_close_loader"}), target_mode="weight", ), - "tech_pullback_cash_buffer": StrategyDefinition( - profile="tech_pullback_cash_buffer", + "tech_communication_pullback_enhancement": StrategyDefinition( + profile="tech_communication_pullback_enhancement", domain=US_EQUITY_DOMAIN, supported_platforms=frozenset({"ibkr"}), required_inputs=frozenset({"feature_snapshot"}), @@ -369,7 +391,7 @@ def test_platform_capability_matrix_applies_rollout_allowlist(self) -> None: available_inputs=frozenset({"historical_close_loader"}), available_capabilities=frozenset({"broker_client"}), ), - "tech_pullback_cash_buffer": StrategyRuntimeAdapter( + "tech_communication_pullback_enhancement": StrategyRuntimeAdapter( available_inputs=frozenset({"feature_snapshot"}), ), } @@ -378,10 +400,10 @@ def test_platform_capability_matrix_applies_rollout_allowlist(self) -> None: catalog, capability_matrix=ibkr_matrix, runtime_adapter_loader=lambda profile: adapters[profile], - rollout_allowlist=("tech_pullback_cash_buffer",), + rollout_allowlist=("tech_communication_pullback_enhancement",), ) - self.assertEqual(enabled, frozenset({"tech_pullback_cash_buffer"})) + self.assertEqual(enabled, frozenset({"tech_communication_pullback_enhancement"})) def test_build_profile_aliases_rejects_duplicate_alias(self) -> None: with self.assertRaisesRegex(ValueError, "Duplicate strategy alias"): diff --git a/tests/test_strategy_contracts.py b/tests/test_strategy_contracts.py index ade4cfc..cc0305b 100644 --- a/tests/test_strategy_contracts.py +++ b/tests/test_strategy_contracts.py @@ -23,11 +23,13 @@ AllocationIntent, CallableStrategyEntrypoint, PositionTarget, + StrategyArtifactContract, StrategyContext, StrategyContractValidationError, StrategyDecision, StrategyManifest, StrategyRuntimeAdapter, + StrategyRuntimePolicy, ValueTargetExecutionAnnotations, ValueTargetExecutionPlan, build_allocation_intent, @@ -44,13 +46,16 @@ build_value_target_portfolio_plan, build_value_target_runtime_plan, build_strategy_context_from_available_inputs, + resolve_strategy_artifact_contract, resolve_decision_target_mode, translate_decision_to_target_mode, translate_value_decision_to_weight_targets, translate_weight_decision_to_value_targets, + validate_strategy_artifact_contract, validate_strategy_decision, validate_strategy_manifest, validate_strategy_runtime_adapter, + validate_strategy_runtime_policy, ) @@ -124,9 +129,9 @@ def test_load_strategy_entrypoint_prefers_explicit_entrypoint_definition(self) - def test_load_strategy_entrypoint_falls_back_to_legacy_component_module(self) -> None: module_name = "_quant_platform_kit_test_legacy_component" manifest = StrategyManifest( - profile="tech_pullback_cash_buffer", + profile="tech_communication_pullback_enhancement", domain=US_EQUITY_DOMAIN, - display_name="Tech Pullback Cash Buffer", + display_name="Tech Communication Pullback Enhancement", description="legacy component with manifest/evaluate", required_inputs=frozenset({"market_data"}), ) @@ -140,7 +145,7 @@ def evaluate(ctx: StrategyContext) -> StrategyDecision: self._install_module(module_name, manifest=manifest, evaluate=evaluate) definition = StrategyDefinition( - profile="tech_pullback_cash_buffer", + profile="tech_communication_pullback_enhancement", domain=US_EQUITY_DOMAIN, supported_platforms=frozenset({"ibkr"}), components=( @@ -159,7 +164,7 @@ def evaluate(ctx: StrategyContext) -> StrategyDecision: decision = loaded.evaluate(StrategyContext(as_of="2026-04-06")) self.assertEqual(decision.risk_flags, ("cash_buffer",)) - self.assertEqual(loaded.manifest.display_name, "Tech Pullback Cash Buffer") + self.assertEqual(loaded.manifest.display_name, "Tech Communication Pullback Enhancement") def test_load_strategy_entrypoint_rejects_missing_inputs_and_legacy_platform_fallback(self) -> None: module_name = "_quant_platform_kit_test_requirements" @@ -242,6 +247,60 @@ def test_validators_reject_invalid_manifest_and_decision_shapes(self) -> None: self.assertEqual(adapter.available_inputs, frozenset({"feature_snapshot"})) self.assertEqual(adapter.available_capabilities, frozenset({"broker_client"})) + def test_runtime_adapter_supports_explicit_artifact_contract_and_policy(self) -> None: + contract = validate_strategy_artifact_contract( + StrategyArtifactContract( + requires_snapshot_artifacts=True, + requires_snapshot_manifest_path=True, + requires_strategy_config_path=True, + snapshot_contract_version="tech.feature_snapshot.v1", + config_source_policy="bundled_or_env", + ) + ) + policy = validate_strategy_runtime_policy( + StrategyRuntimePolicy( + reconciliation_output_policy="optional", + runtime_execution_window_trading_days=1, + ) + ) + adapter = validate_strategy_runtime_adapter( + StrategyRuntimeAdapter( + available_inputs=frozenset({"feature_snapshot"}), + artifact_contract=contract, + runtime_policy=policy, + ) + ) + + resolved_contract = resolve_strategy_artifact_contract( + adapter, + required_inputs=frozenset({"feature_snapshot"}), + ) + + self.assertIs(resolved_contract, contract) + self.assertTrue(resolved_contract.requires_snapshot_manifest_path) + self.assertTrue(resolved_contract.requires_strategy_config_path) + self.assertEqual(resolved_contract.config_source_policy, "bundled_or_env") + self.assertEqual(adapter.runtime_policy.reconciliation_output_policy, "optional") + self.assertEqual(adapter.runtime_policy.runtime_execution_window_trading_days, 1) + + def test_artifact_contract_resolver_preserves_legacy_adapter_inference(self) -> None: + adapter = StrategyRuntimeAdapter( + require_snapshot_manifest=True, + snapshot_contract_version="legacy.feature_snapshot.v1", + runtime_parameter_loader=lambda **_kwargs: {"name": "legacy"}, + ) + + contract = resolve_strategy_artifact_contract( + adapter, + required_inputs=frozenset({"feature_snapshot"}), + ) + + self.assertTrue(contract.requires_snapshot_artifacts) + self.assertTrue(contract.requires_snapshot_manifest_path) + self.assertTrue(contract.requires_strategy_config_path) + self.assertEqual(contract.snapshot_contract_version, "legacy.feature_snapshot.v1") + self.assertEqual(contract.config_source_policy, "runtime_parameter_loader") + def test_build_account_state_from_portfolio_snapshot_filters_strategy_symbols(self) -> None: snapshot = PortfolioSnapshot( as_of="2026-04-09", @@ -352,10 +411,10 @@ def test_build_value_target_execution_plan_groups_symbols_by_role(self) -> None: plan = build_value_target_execution_plan( decision, - strategy_profile="hybrid_growth_income", + strategy_profile="tqqq_growth_income", ) - self.assertEqual(plan.strategy_profile, "hybrid_growth_income") + self.assertEqual(plan.strategy_profile, "tqqq_growth_income") self.assertEqual(plan.target_values["BOXX"], 35000.0) self.assertEqual(plan.risk_symbols, ("TQQQ",)) self.assertEqual(plan.income_symbols, ("QQQI", "SPYI")) @@ -459,7 +518,7 @@ def test_build_value_target_execution_plan_rejects_weight_only_positions(self) - ): build_value_target_execution_plan( decision, - strategy_profile="hybrid_growth_income", + strategy_profile="tqqq_growth_income", ) def test_build_allocation_intent_for_weight_targets(self) -> None: @@ -472,7 +531,7 @@ def test_build_allocation_intent_for_weight_targets(self) -> None: intent = build_allocation_intent( decision, - strategy_profile="tech_pullback_cash_buffer", + strategy_profile="tech_communication_pullback_enhancement", strategy_symbols_order="risk_safe_income", ) @@ -496,13 +555,13 @@ def test_build_allocation_intent_rejects_mixed_target_modes(self) -> None: with self.assertRaisesRegex(StrategyContractValidationError, "single target mode"): build_allocation_intent( decision, - strategy_profile="tech_pullback_cash_buffer", + strategy_profile="tech_communication_pullback_enhancement", ) def test_build_strategy_context_from_available_inputs_uses_required_inputs_and_portfolio_mapping(self) -> None: entrypoint = CallableStrategyEntrypoint( manifest=StrategyManifest( - profile="hybrid_growth_income", + profile="tqqq_growth_income", domain=US_EQUITY_DOMAIN, display_name="Hybrid Growth Income", description="test", @@ -528,7 +587,7 @@ def test_build_strategy_context_from_available_inputs_uses_required_inputs_and_p def test_build_strategy_context_from_available_inputs_rejects_missing_required_input(self) -> None: entrypoint = CallableStrategyEntrypoint( manifest=StrategyManifest( - profile="hybrid_growth_income", + profile="tqqq_growth_income", domain=US_EQUITY_DOMAIN, display_name="Hybrid Growth Income", description="test", @@ -556,7 +615,7 @@ def test_build_value_target_portfolio_plan_normalizes_state_and_layout(self) -> ) execution_plan = build_value_target_execution_plan( decision, - strategy_profile="hybrid_growth_income", + strategy_profile="tqqq_growth_income", ) portfolio_plan = build_value_target_portfolio_plan( @@ -577,7 +636,7 @@ def test_build_value_target_portfolio_plan_normalizes_state_and_layout(self) -> def test_build_value_target_portfolio_plan_rejects_unknown_layout(self) -> None: execution_plan = ValueTargetExecutionPlan( - strategy_profile="hybrid_growth_income", + strategy_profile="tqqq_growth_income", target_values={"TQQQ": 1.0}, risk_symbols=("TQQQ",), income_symbols=(), @@ -609,7 +668,7 @@ def test_build_value_target_plan_payload_supports_field_selection_and_defaults(s ) execution_plan = build_value_target_execution_plan( decision, - strategy_profile="semiconductor_rotation_income", + strategy_profile="soxl_soxx_trend_income", ) portfolio_plan = build_value_target_portfolio_plan( execution_plan, @@ -623,7 +682,7 @@ def test_build_value_target_plan_payload_supports_field_selection_and_defaults(s annotations = build_value_target_execution_annotations(decision) payload = build_value_target_plan_payload( - strategy_profile="semiconductor_rotation_income", + strategy_profile="soxl_soxx_trend_income", portfolio_plan=portfolio_plan, annotations=annotations, include_sellable_quantities=True, @@ -639,7 +698,7 @@ def test_build_value_target_plan_payload_supports_field_selection_and_defaults(s }, ) - self.assertEqual(payload["strategy_profile"], "semiconductor_rotation_income") + self.assertEqual(payload["strategy_profile"], "soxl_soxx_trend_income") self.assertEqual(payload["allocation"]["target_mode"], "value") self.assertEqual(payload["allocation"]["targets"]["SOXL"], 30000.0) self.assertEqual(payload["allocation"]["positions"][1]["role"], "safe_haven") @@ -653,7 +712,7 @@ def test_build_value_target_plan_payload_supports_field_selection_and_defaults(s def test_build_value_target_allocation_intent_reuses_portfolio_symbol_order(self) -> None: portfolio_plan = build_value_target_portfolio_plan( ValueTargetExecutionPlan( - strategy_profile="hybrid_growth_income", + strategy_profile="tqqq_growth_income", target_values={"TQQQ": 30000.0, "BOXX": 35000.0, "QQQI": 18000.0}, risk_symbols=("TQQQ",), income_symbols=("QQQI",), @@ -785,7 +844,7 @@ def test_build_value_target_runtime_plan_translates_decision_with_shared_helper( payload = build_value_target_runtime_plan( decision, - strategy_profile="semiconductor_rotation_income", + strategy_profile="soxl_soxx_trend_income", portfolio_inputs=inputs, portfolio_rows_layout=("risk", "safe"), execution_fields=(