diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 7461e4a..546809f 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -5,9 +5,8 @@ Quant OS 是项目名;`quant60` 是第一版 A 股 Baseline 内核、CLI names 可无人值守重放、聚宽登录后可直接回测、QMT 授权环境就绪后无需改源码即可完成 回测/影子验证,并且所有 G1—G10 都由版本绑定的真实证据支持。 -当前结论仍为 `NOT_BASELINE_60`。最近一次标准库全量测试记录为 -`Ran 224 tests`、`OK (skipped=6)`;六个 skip 是可选依赖/运行时路径。 -该结果证明代码合同,不证明真实平台、券商或合规 Gate。 +当前结论仍为 `NOT_BASELINE_60`。标准库全量测试证明代码合同,不证明真实 +平台、券商或合规 Gate;测试总数随合同补充会变化,不作为 Gate 分数。 ## Stage 1: 独立仓库与确定性执行 @@ -36,7 +35,14 @@ Quant OS 是项目名;`quant60` 是第一版 A 股 Baseline 内核、CLI names - openable / hold-only / sell-only / frozen / excluded 状态与原因码; - 透明价量特征、可执行时钟标签、train-only 预处理; - purged walk-forward、deterministic Ridge challenger、IC/RankIC 报告; -- 日期化费用、low/base/high 冲击、透明因子风险、约束优化与失败诊断; +- Ridge train-only 预处理、系数、标签、训练截止日与 lineage 的冻结 model + bundle; +- 一条真实可达的 synthetic vertical slice:Universe → Ridge Alpha → + 60 日对角风险与日期化成本参与的 deterministic Portfolio → post-risk → + reference Execution,并输出五层 hash trace; +- 无账户股数的 `TargetPackageV1`,绑定 model/data/feature/risk/cost/ + optimizer/config/source hash 与 T-close/T+1 双时钟; +- 独立组件仍包括透明因子风险、CVXPY optimizer 与 guarded TWAP/POV; - JQData adapter 通过 `get_bars` 获取 raw OHLCV/money、factor、`pre_close`、 每日涨跌停/停牌字段,通过 `get_extras` 获取 PIT `is_st`,并逐交易日 获取 PIT 指数成员;区间 `get_trade_days` 必须与 `get_all_trade_days` @@ -48,16 +54,21 @@ Quant OS 是项目名;`quant60` 是第一版 A 股 Baseline 内核、CLI names fail-closed 账户状态输入; - synthetic research artifacts 和 snapshot decision artifacts 都有精确 manifest verifier。 +- 每层部署状态由 reachability contract 分开记录 implemented、unit tested、 + local/JQ/QMT entrypoint reachable 与 real-platform observed。 **仍缺**: - 授权 JQData 账户上的真实快照、长样本质量报告和许可记录; - 完整 Security Master、基本面公告时点、公司行动与退市链; - 真实样本风险/冲击/容量校准; -- Ridge 的冻结 model bundle、真实 OOS 报告与跨引擎推理验证。 +- 授权真实数据训练的 model bundle、真实 OOS 报告与跨引擎 TargetPackage + 执行验证; +- 把 factor risk/CVXPY/TWAP-POV 从组件级测试提升到经过取舍的候选主链。 -**状态**:功能实现和合成/fake-provider 合同验证完成,生产证据未完成。 -Ridge 仍是 research challenger,不是已部署模型。 +**状态**:synthetic 五层 vertical slice 与交换合同完成;provider-data +Baseline candidate、真实校准和生产证据未完成。Ridge 仍是 research +challenger,不是已部署模型。 ## Stage 3: 平台回测与跨引擎证据 @@ -68,6 +79,9 @@ Ridge 仍是 research challenger,不是已部署模型。 - 本地 deterministic execution/research 与 manifest 重放; - JoinQuant/QMT fake wrapper 合同和 mock parity exporter; +- JoinQuant/QMT 的 TargetPackage 消费模式:本地上游四层保持唯一权威, + 平台只做严格包验证、双时钟命中、账户/价格绑定与 execution;不存在 + momentum fallback; - 2026-07-26 使用生成 bundle 完成 2024 全年真实 JoinQuant hosted backtest,可见完成日志无 ERROR;该运行还促成 hosted namespace、 科创板保护价与 200 股最小申报的真实修复; @@ -86,8 +100,8 @@ Qlib 两次 smoke 使用的是确定性两股票 synthetic fixture,性能没 **仍缺**: -- 聚宽完整输入/plan/orders/fills 导出和相同输入本地 peer; -- 授权 QMT built-in 与 qmttools 回测导出; +- 同一个 TargetPackage 的聚宽完整 package/plan/orders/fills 导出和本地 peer; +- 同一个 TargetPackage 的授权 QMT built-in 与 qmttools 回测导出; - 至少 20 个交易日的真实 QMT/broker shadow; - 同一 canonical 输入的 L1—L5 差异报告。 diff --git a/README.md b/README.md index e4a1f41..c530107 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,11 @@ Quant OS 是项目名;`quant60` 是第一版 A 股 Baseline 的 Python 本地与 Colab 可零账号运行工程 smoke;JQData 登录后可生成不可变快照并直接 运行 PIT 本地回测/目标决策;聚宽、QMT 与 Qlib 有各自明确的运行入口。 -> 当前状态仍是 `NOT_BASELINE_60`。代码存在、单测、synthetic、fake harness、 -> Qlib fixture 和单次真实聚宽 hosted smoke,都不能代替同输入跨引擎对比、 -> 真实 QMT、券商影子盘、连续对账与合规证据。 +> 当前状态仍是 `NOT_BASELINE_60`。本地五层 vertical slice、单测、 +> synthetic、fake harness、Qlib fixture、真实聚宽 hosted 动量 smoke,以及 +> 单个 synthetic-research TargetPackage 的真实聚宽 **execution smoke**, +> 都不能代替授权真实数据 OOS、逐层同输入 parity、真实 QMT、券商影子盘、 +> 连续对账与合规证据。 > 原文章复评为设计覆盖约 70/100、可实施规格 49/100、原实现证据 6/100、 > G1—G10 为 0/10。详见 > [`docs/AUDIT_2026-07-25.md`](docs/AUDIT_2026-07-25.md) 与 @@ -40,18 +42,21 @@ python3 tools/build_public_status.py --check | 路径 | 当前能做什么 | 已有证据 | 仍缺什么 | | --- | --- | --- | --- | | 本地 synthetic execution | 周度信号、T+1 开盘撮合、费用、涨跌停、部分成交、ledger、重放 | 标准库全量测试、确定性 smoke、manifest verifier | 不代表真实市场或投资价值 | -| 本地 synthetic research | PIT/Universe、透明特征、purged walk-forward、Ridge、成本/风险、约束 target | research artifact 与 verifier | 仍是 synthetic;Ridge 尚未冻结为跨平台生产模型 | +| 本地 synthetic research | Universe → Ridge Alpha → 风险/成本参与的 Portfolio → post-risk → reference execution 五层链 | 冻结 model bundle、无股数 TargetPackage、逐层 hash trace 与 manifest verifier | 仍是 synthetic;当前风险是 60 日对角方差,尚无真实样本校准或投资价值 | | JQData → 本地 | 原始价、复权因子、前收、涨跌停、停牌、PIT ST 状态与历史指数成员快照;PIT 本地回测与单日目标 | fake-provider 端到端回归测试、完整收盘日 fail-closed 合同 | 需要用户授权登录后保存真实数据运行;基本面/公司行动尚未全接 | | Colab | 自动 clone、测试、bundle、两套 synthetic 流程;可选 JQData/Qlib | notebook 本地逐 cell 执行 | JQData/Qlib 可选 cell 需对应账号/运行时 | -| 聚宽 hosted | 可上传的 Python 单文件,直接回测 portable baseline | 2024 全年、100 万资金的真实 hosted run 已完成且可见日志无 ERROR;另有 bundle/fake/mock 合约 | 仍需完整导出、相同输入本地 peer 与逐层差异报告 | -| QMT built-in/qmttools | 可上传/驱动的硬 backtest-only portable baseline;固定中证 500 benchmark、10% 参与率、PIT 成分/ST | Python 3.6 语法、fake/qmttools 合约 | 需要已授权 QMT 客户端、历史 ST 数据权限/正例探针与实跑导出 | +| 聚宽 hosted | 默认动量 smoke;显式注入 TargetPackage 后只消费本地 post-risk 权重并绑定平台账户/价格 | 动量模式已有一次 2024 全年 run;一个 synthetic-research TargetPackage 已真实命中、绑定 `day_open` 并产生 3 笔委托/成交 | 只观察 execution consumer;无授权真实长样本/OOS、上游四层 hosted 执行、QMT peer 或逐层真实数据 parity | +| QMT built-in/qmttools | 默认动量 smoke;TargetPackage 模式为 Python 3.6 backtest-only 消费器 | Python 3.6 语法、TargetPackage/fake/qmttools 合约 | 需要已授权 QMT 客户端、历史数据权限与 TargetPackage 实跑导出 | | Qlib 0.9.7 | native momentum backtest;Alpha158/LightGBM CLI/Recorder 工作流 | Python 3.12 两条 fixture 已真实跑通,并保存表级 hash、行列/时间边界和重算指标 | synthetic 两股票没有投资价值;无真实 OOS、L3/L4 parity | | Tushare → Qlib | 只读盘点 completed Parquet、校验 checksum、构建/验证不可变 provider、运行本地回测 | 93 个 completed 文件已验证;早期未复权 provider 与两次 byte-identical momentum run | 下载仅到 1993-10;缺复权、真实 benchmark、历史成分/ST/停牌/涨跌停,不能进入生产 decision | | XtTrader shadow | 只读账户查询、HMAC 账户绑定、broker observation/snapshot、零下单 target-diff 计划与完整 verifier | fake broker/QMT 合约;所有 broker mutation 均不可达 | 需要授权 QMT 环境的账户合同探针、20 日影子、恢复/对账和券商确认 | -跨四个引擎当前共同的、可冻结生产基线是 -`portable-momentum-v1`。本地 Ridge 与 Qlib Alpha158/LightGBM 是研究候选, -在出现版本化 model bundle、真实 OOS 证据和跨引擎推理验证前,不会冒充已经部署。 +`portable-momentum-v1` 只是当前真实跑过聚宽的跨引擎连通性 smoke,不是 +60 分 Baseline。Baseline 的权威决策留在本地:冻结 Ridge model bundle, +完成五层链并发布无股数的 `TargetPackageV1`;聚宽/QMT 只做精确双时钟查找、 +账户状态绑定和执行。当前 synthetic 包已经真实通过聚宽 execution consumer, +但这只证明平台边界可运行;尚未证明上游四层的真实数据有效性、QMT peer 或 +跨引擎一致性。 ## 一条命令验证本地工程 @@ -90,6 +95,9 @@ artifacts/ │ └── report.json ├── research-smoke/ │ ├── manifest.json +│ ├── model_bundle.json +│ ├── target_package.json +│ ├── pipeline.json │ ├── signals.json │ ├── targets.json │ ├── forecasts.json @@ -175,6 +183,11 @@ provider calendar 的唯一下一交易日 Asia/Shanghai `[09:00,09:30)`;它 它仍是本地 fill model,不是聚宽/QMT 的真实成交证据。 +注意:现有 `snapshot-decision` 仍是 `portable-momentum-v1` 的 provider-data +路径,不能因为输入来自 JQData 就冒充 Ridge Baseline。完整候选链的权威交换物 +是 `TargetPackageV1`;在真实数据训练、风险/成本校准与 OOS 冻结完成前, +`research-smoke/target_package.json` 只用于验证部署结构。 + ## Colab 打开 [`notebooks/quant_os_colab.ipynb`](notebooks/quant_os_colab.ipynb) @@ -197,7 +210,7 @@ python3 tools/run_colab_notebook.py notebooks/quant_os_colab.ipynb --execute ## 聚宽:直接上传回测 -先构建: +不传目标包时,构建的是历史兼容的动量 smoke: ```bash python3 tools/bundle_platforms.py @@ -221,7 +234,7 @@ canonical 周时钟不是固定星期几:某 ISO 周第一实际交易日收 作为限价保护;共享核心拒绝不足 200 股的普通科创板申报,只保留一次性卖清 不足 200 股余额的交易所例外。 -2026-07-26 已用生成 bundle 完成一次真实聚宽全年 hosted backtest。运行记录、 +2026-07-26 已用该动量 smoke bundle 完成一次真实聚宽全年 hosted backtest。运行记录、 bundle hash、指标、真实平台发现的问题和证据边界见 [`docs/JOINQUANT_HOSTED_EVIDENCE_2026-07-26.md`](docs/JOINQUANT_HOSTED_EVIDENCE_2026-07-26.md)。 该次运行的可见完成日志没有 `ERROR`,但尚未导出相同输入、plan、orders/fills, @@ -238,13 +251,52 @@ bundle hash、指标、真实平台发现的问题和证据边界见 单次 hosted smoke 是外部运行证据,但没有同输入跨引擎 manifest 时仍不算 真实聚宽 Gate 通过。 +要验证五层候选链,必须显式传入已验真的 TargetPackage;打包器不会自动从 +动量回退或把 smoke 提升成 Baseline: + +```bash +python3 tools/bundle_platforms.py \ + --target-package artifacts/research-smoke/target_package.json \ + --output-dir dist/target-package +``` + +这个示例包来自 synthetic 数据,只能验证线路。目标包保存 post-risk **权重** +及 model/data/feature/risk/cost/optimizer/config/source hash,不保存账户股数。 +聚宽在包声明的 `next_session` 开盘读取真实 equity、持仓、可卖数量和价格, +形成 execution plan;包缺失、双时钟不匹配、hash 篡改或重复决策都会停止, +不会现场改算 momentum。 + +此时应上传 +[`dist/target-package/joinquant_strategy.py`](dist/target-package/joinquant_strategy.py), +并以同目录 +[`bundle_manifest.json`](dist/target-package/bundle_manifest.json) +核对 package ID、package SHA 与 tape SHA;不要误传默认 smoke 文件。 + +2026-07-26,`TP-20240311-795dcfba` 已用 FINAL4 在真实 JoinQuant hosted +环境完成 2024-03-11 至 2024-03-13 的 execution smoke:3 月 11/13 日为 +精确时钟 no-op,3 月 12 日按 `CURRENT_DATA_DAY_OPEN` 命中包,生成 3 笔 +委托并全部成交。`600519.XSHG` 虽有 10% 权重,但 1693.94 元开盘价使一手 +价值 169,394 元,高于 100,000 元目标名义金额,因而合法取整为 0 股。 + +- 脱敏机器记录: + [`evidence/joinquant/TP-20240311-795dcfba/2457a7c39a276e09e0fabf99e28978e1/run.json`](evidence/joinquant/TP-20240311-795dcfba/2457a7c39a276e09e0fabf99e28978e1/run.json) +- 证据边界: + [`docs/JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md`](docs/JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md) + +该 run 的 `platform_evidence_class` 是 `real_platform_runtime`,但输入 +`input_evidence_class` 仍是 `synthetic-research`;它只支持 +`execution.real_platform_observed=true`。JoinQuant 没有重跑本地冻结的 +Universe、Alpha、Portfolio、Risk,三日收益字段仅用于识别 smoke, +`performance_claim=false`,不计 G9 或 Baseline 60。 + ## QMT built-in 与 qmttools -同一 bundler 生成: +同一 bundler 可生成动量 smoke 或显式 TargetPackage 消费器: ```text -dist/qmt_builtin_strategy.py -dist/bundle_manifest.json +dist/qmt_builtin_strategy.py # 默认 momentum smoke +dist/target-package/qmt_builtin_strategy.py # synthetic 候选线路调试 +dist/target-package/bundle_manifest.json # 目标包与 bundle 身份 ``` 在 QMT Python 策略编辑器导入,选择 `1d` 与 **backtest**,主图/基准设为 @@ -253,18 +305,20 @@ dist/bundle_manifest.json QMT 文档所述的 `ContextInfo` 用户属性回滚。它硬编码 backtest-only;参数和 环境变量均不能把它提升成实盘 `passorder`。 -策略按原始毫秒 timetag 查询历史中证 500 成分,并通过 +在动量 smoke 模式,策略按原始毫秒 timetag 查询历史中证 500 成分,并通过 `ContextInfo.get_his_st_data` 读取决策日 ST/*ST/PT 区间;接口缺失、格式异常 或覆盖不足会阻止计划。首次真实验收还必须用一只已知历史 ST 股票做正例探针, 证明客户端权限和本地历史 ST 数据确实可用,不能把空结果直接解释成“当天无 -ST”。 +ST”。在 TargetPackage 模式,Universe/Alpha/Portfolio/Risk 已由本地包冻结; +QMT 不重算这些层,只在包的 signal close 命中后绑定账户和完成日线价格, +以 `quickTrade=0` 交给下一执行步。两种模式的证据不得混记。 Windows 上、已登录且安装券商分发 `xtquant` 的 qmttools 入口: ```powershell $env:PYTHONPATH = "src;." python -m platforms.qmt_research_runner ` - dist/qmt_builtin_strategy.py ` + dist/target-package/qmt_builtin_strategy.py ` --stock-code 000905.SH ` --start 20240102 ` --end 20241231 ` @@ -509,6 +563,11 @@ gate_credit = [] - `universe.py`:openable / hold-only / sell-only / frozen / excluded; - `features.py`:透明价量特征、可执行时钟标签、train-only 预处理; - `research.py` / `experiment.py`:purge/embargo、Ridge、OOS IC/RankIC; +- `model_bundle.py`:冻结 train-only 预处理、Ridge 参数、标签与训练 lineage; +- `baseline_pipeline.py`:唯一五层本地主链和因果 hash trace; +- `target_package.py`:无股数、双时钟、可防篡改的 post-risk 权重合同; +- `release_reachability.py`:逐层区分 implemented、tested、entrypoint + reachable 与 real-platform observed; - `factor_risk.py`:行业/风格暴露、EWMA 收缩因子协方差、特异风险、stress; - `costs.py`:日期化费率、最低佣金、low/base/high impact 与 capacity; - `optimizer.py`:deterministic fallback 与 lazy CVXPY 约束优化; @@ -519,22 +578,27 @@ gate_credit = [] provider snapshot 到 PIT target 与本地历史回测; - `platforms/` / `adapters/`:聚宽、QMT、Qlib 与 XtTrader 边界。 -这些模块已有自动测试,聚宽 hosted 路径也已有一次真实运行;真实数据校准、 -QMT/券商运行和运营证据仍分别计分。 +组件存在、单测通过、进入本地入口、进入平台入口和真实平台观察是五种不同 +事实。聚宽现有 TargetPackage run 只观察 execution consumer;它不证明 +factor risk、CVXPY、TWAP/POV 或 Ridge 上游四层在 hosted 环境被调用。 ## 仍未达到 60 的核心原因 - 基本面公告时点、完整公司行动、Security Master 与退市链尚未形成生产数据湖; -- Ridge/LightGBM 候选尚未冻结成四引擎共同加载的 model bundle; +- Ridge 已能冻结成可重放 model bundle,但目前只有 synthetic 候选; + 尚无授权真实长样本 OOS,也没有必要让 hosted 引擎重新加载训练栈; - 风险协方差、冲击参数和容量尚未用真实长样本校准; -- 聚宽已有真实 hosted run 但尚无完整同输入导出;QMT 尚无真实导出,QMT +- factor risk、CVXPY 和 guarded TWAP/POV 仍是组件级能力,尚未进入当前 + Baseline vertical slice; +- 聚宽已有单包 TargetPackage execution 导出,但仍缺授权真实数据的多期 + TargetPackage、本地/聚宽逐层同输入对账;QMT 尚无真实 peer 导出,QMT 历史 ST 权限正例、账户 query 合同、 20 日只读影子、回调/恢复/对账尚未形成真实证据; - 自动 scheduler、日终对账服务、监控告警与 kill-switch 演练未形成连续证据; - 实际券商程序化交易报告、权限、频率与软件要求尚未书面确认。 -因此当前是可以继续填真实证据的 Quant OS 生产候选框架,不是可直接投入资金的 -成品,也不构成投资建议。 +因此当前是“已接通本地五层与平台 TargetPackage 边界的候选骨架”,不是已经 +达到 60 分或可直接投入资金的成品,也不构成投资建议。 ## 安全与进一步文档 @@ -542,7 +606,11 @@ QMT/券商运行和运营证据仍分别计分。 行情数据一律不得进入 Git。 - 架构:[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) +- 五层真实结构与缺口: + [`docs/BASELINE_VERTICAL_SLICE.md`](docs/BASELINE_VERTICAL_SLICE.md) - 平台矩阵:[`docs/PLATFORM_MATRIX.md`](docs/PLATFORM_MATRIX.md) +- 聚宽 TargetPackage 证据: + [`docs/JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md`](docs/JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md) - 部署:[`runbooks/PLATFORM_DEPLOYMENT.md`](runbooks/PLATFORM_DEPLOYMENT.md) - 安全:[`docs/SECURITY.md`](docs/SECURITY.md) - 故障:[`runbooks/INCIDENTS.md`](runbooks/INCIDENTS.md) diff --git a/dist/bundle_manifest.json b/dist/bundle_manifest.json index 73b8bfc..463b288 100644 --- a/dist/bundle_manifest.json +++ b/dist/bundle_manifest.json @@ -2,25 +2,50 @@ "artifacts": { "joinquant": { "core": "src/quant60/portable_core.py", - "core_sha256": "af29e4e5e10705cf6aff89874394c1e80d3780f093c19d90ed53ef9d0188a934", - "effective_config_sha256": "3ef9b9efda9ec6cc5f57a5daca57cab3b68242e0368f18fdaaf9e249bdaa7873", + "core_sha256": "f9de9f18e4008bb0fc80edb0b21cae6ea06a61828d89e9b7c11057f98225fc19", + "effective_config_sha256": "d4ae2882e9b5d8d673e7c62c0dc54def7c20221c358ac091ee6529bf0413df0b", "output": "dist/joinquant_strategy.py", - "output_sha256": "22ddb0546beb3bd4e4bbedecb271452111324c3903e45be1f4ac3d39f38cce4f", + "output_sha256": "be4ca1d5764416b7c8e6c206121c0f0bd8219e7317679d79bc72a95492286b1a", "wrapper": "platforms/joinquant_strategy.py", - "wrapper_sha256": "b5a8e3b0a774a0f45f52f6bcccbe786e234913a1a4cf5b68b85e2bd989ee9294" + "wrapper_sha256": "0c672b64665b7c1d4409ba288b00d1608e976800af3a4f9a6467767ce4def592" }, "qmt_builtin": { "core": "src/quant60/portable_core.py", - "core_sha256": "af29e4e5e10705cf6aff89874394c1e80d3780f093c19d90ed53ef9d0188a934", - "effective_config_sha256": "11f189ea5c9d4eaddeb6394dc342abc871941cb3395d7ff79aae1275ce2ad98f", + "core_sha256": "f9de9f18e4008bb0fc80edb0b21cae6ea06a61828d89e9b7c11057f98225fc19", + "effective_config_sha256": "7e4739937e4e99e5b58154f53271d8d3bfaed47d8d1d5adc747101babde827f5", "output": "dist/qmt_builtin_strategy.py", - "output_sha256": "ad503e5ebde60a5deb0ec86334c04d4be2d77ab91d9607d4fe88e2119f22d5dc", + "output_sha256": "483877b7ea55c8e0f5e4e47b48a156b3add6cf0a8085011414c5c600913939b5", "wrapper": "platforms/qmt_builtin_strategy.py", - "wrapper_sha256": "efb931bbd04d0abf0a576f2422a13403fd4d659dccf59f8170e8b13b1bafa640" + "wrapper_sha256": "a1e53dc22ebd9bb01be43675786151606bbed273d8ab883fb933461e379d2727" } }, "baseline_config": "configs/baseline.json", "baseline_config_sha256": "939a7df386b06bc82869940572c35544e9361913b94d9e03027411e51b1006e1", - "portable_core_sha256": "af29e4e5e10705cf6aff89874394c1e80d3780f093c19d90ed53ef9d0188a934", - "schema_version": 1 + "decision_mode": "portable_momentum_smoke", + "joinquant_observation": { + "eod_api_policy": "best_effort_get_orders_get_trades", + "eod_chunk_characters": 1800, + "eod_chunk_encoding": "base64-canonical-json", + "events": [ + "INIT", + "TARGET_PACKAGE_NOOP", + "TARGET_PACKAGE_HIT", + "PLATFORM_BINDING_INPUT", + "TARGET_PACKAGE_PLAN", + "SKIPPED_ORDER", + "ORDER_REQUEST", + "ORDER_RETURN", + "EOD_STATUS" + ], + "fail_soft": true, + "prefix": "QUANT60_JOINQUANT_EVIDENCE_V1 ", + "schema_version": "1.0", + "target_price_source": "CURRENT_DATA_DAY_OPEN" + }, + "portable_core_sha256": "f9de9f18e4008bb0fc80edb0b21cae6ea06a61828d89e9b7c11057f98225fc19", + "schema_version": 1, + "target_package_count": 0, + "target_package_ids": [], + "target_package_sha256": [], + "target_package_tape_sha256": null } diff --git a/dist/joinquant_strategy.py b/dist/joinquant_strategy.py index 47fe481..2f1aebd 100644 --- a/dist/joinquant_strategy.py +++ b/dist/joinquant_strategy.py @@ -7,6 +7,8 @@ tool replaces the marked import block with the exact portable core source. # __PORTABLE_CORE_BUNDLE_START__ # Inlined by tools/bundle_platforms.py; do not edit this region. +import hashlib +import json import math import re @@ -30,6 +32,45 @@ _SHORT_EXCHANGES = { "XBSE": "BJ", } +_TARGET_PACKAGE_FIELDS = { + "schema_version", + "package_type", + "package_id", + "decision_id", + "release", + "evidence_class", + "model_id", + "signal_as_of", + "next_session", + "model_sha256", + "data_sha256", + "feature_sha256", + "risk_sha256", + "cost_sha256", + "optimizer_sha256", + "config_sha256", + "source_sha256", + "target_weights", + "universe", + "constraints", + "package_sha256", +} + +_TARGET_PACKAGE_HASH_FIELDS = ( + "model_sha256", + "data_sha256", + "feature_sha256", + "risk_sha256", + "cost_sha256", + "optimizer_sha256", + "config_sha256", + "source_sha256", +) + + +class TargetPackageLookupMiss(ValueError, LookupError): + """An already verified tape has no exact requested decision.""" + def _sum_numeric(values): """Sum numerics without trusting a hosted runtime's global ``sum`` name.""" @@ -40,6 +81,15 @@ def _sum_numeric(values): return total +def _any_true(values): + """Return built-in ``any`` semantics without trusting hosted globals.""" + + for value in values: + if value: + return True + return False + + def _infer_exchange(code): if code.startswith(("4", "8")): return "XBSE" @@ -92,6 +142,396 @@ def convert_symbol(symbol, target="canonical"): return normalize_symbol(symbol, target) +def _canonical_json(value): + """Match ``quant60.ledger.canonical_json`` for plain JSON values.""" + + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def _json_copy(value, name): + try: + return json.loads(_canonical_json(value)) + except (TypeError, ValueError) as error: + raise ValueError("%s must contain finite JSON values only" % name) from error + + +def _canonical_mapping(value, name, value_validator): + if not hasattr(value, "items"): + raise TypeError("%s must be a mapping" % name) + output = {} + for raw_symbol, raw_value in value.items(): + symbol = normalize_symbol(raw_symbol) + if symbol in output: + raise ValueError( + "%s contains duplicate normalized symbol %s" % (name, symbol) + ) + output[symbol] = value_validator(raw_value, "%s.%s" % (name, symbol)) + return dict((symbol, output[symbol]) for symbol in sorted(output)) + + +def _finite_non_negative(value, name): + if isinstance(value, bool): + raise ValueError("%s must be a finite non-negative number" % name) + try: + numeric = float(value) + except (TypeError, ValueError): + raise ValueError("%s must be a finite non-negative number" % name) + if not math.isfinite(numeric) or numeric < 0.0: + raise ValueError("%s must be a finite non-negative number" % name) + return numeric + + +def _non_negative_quantity(value, name): + numeric = _finite_non_negative(value, name) + if numeric != math.floor(numeric): + raise ValueError("%s must be a whole-share quantity" % name) + return int(numeric) + + +def _positive_price(value, name): + numeric = _finite_non_negative(value, name) + if numeric <= 0.0: + raise ValueError("%s must be positive" % name) + return numeric + + +def _optional_non_empty_string(value, name): + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ValueError("%s must be a non-empty string when provided" % name) + return value + + +def _sha256_hex(value, name, allow_none=False): + if value is None and allow_none: + return None + if ( + not isinstance(value, str) + or len(value) != 64 + or _any_true( + character not in "0123456789abcdef" + for character in value + ) + ): + raise ValueError( + "%s must be a 64-character lowercase SHA-256 digest" % name + ) + if value == "0" * 64: + raise ValueError("%s cannot be an all-zero placeholder" % name) + return value + + +def _reject_quantity_fields(value, path="$"): + if hasattr(value, "items"): + for raw_key, child in value.items(): + key = str(raw_key) + normalized = key.lower().replace("-", "_") + if "quantity" in normalized or "quantities" in normalized: + raise ValueError( + "%s.%s: quantities do not belong in TargetPackageV1" + % (path, key) + ) + _reject_quantity_fields(child, "%s.%s" % (path, key)) + elif isinstance(value, list): + for index, child in enumerate(value): + _reject_quantity_fields(child, "%s[%d]" % (path, index)) + + +def _verify_identifier(value, name): + if ( + not isinstance(value, str) + or re.match(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", value) is None + ): + raise ValueError("%s is not a canonical identifier" % name) + return value + + +def _verify_target_package_clock(signal_as_of, next_session): + if ( + not isinstance(signal_as_of, str) + or re.match( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T15:00:00\+08:00$", + signal_as_of, + ) + is None + ): + raise ValueError("signal_as_of must be canonical China-market close") + if ( + not isinstance(next_session, str) + or re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", next_session) is None + ): + raise ValueError("next_session must use canonical YYYY-MM-DD") + if next_session <= signal_as_of[:10]: + raise ValueError("next_session must follow signal_as_of") + + +def _target_package_content_sha256(package): + body = dict(package) + body.pop("package_sha256", None) + _reject_quantity_fields(body) + try: + payload = _canonical_json(body).encode("utf-8") + except (TypeError, ValueError) as error: + raise ValueError( + "target package must contain finite JSON values only" + ) from error + return hashlib.sha256(payload).hexdigest() + + +def verify_target_package( + package, + expected_signal_as_of=None, + expected_next_session=None, + expected_decision_id=None, + expected_package_sha256=None, +): + """Verify the platform-critical subset of a sealed ``TargetPackageV1``. + + This intentionally duplicates a small, Python-3.6-compatible safety + boundary from the richer local validator. Hosted runtimes must verify the + embedded package again instead of trusting the bundling process. + """ + + if not hasattr(package, "items"): + raise TypeError("target package must be a mapping") + candidate = _json_copy(package, "target package") + _reject_quantity_fields(candidate) + fields = set(candidate) + if fields != _TARGET_PACKAGE_FIELDS: + missing = sorted(_TARGET_PACKAGE_FIELDS - fields) + unknown = sorted(fields - _TARGET_PACKAGE_FIELDS) + raise ValueError( + "target package fields mismatch; missing=%s, unknown=%s" + % (missing, unknown) + ) + if candidate["schema_version"] != "1.0": + raise ValueError("unsupported target package schema_version") + if candidate["package_type"] != "TargetPackageV1": + raise ValueError("unsupported target package type") + for name in ( + "package_id", + "decision_id", + "release", + "evidence_class", + "model_id", + ): + _verify_identifier(candidate[name], name) + _verify_target_package_clock( + candidate["signal_as_of"], candidate["next_session"] + ) + for name in _TARGET_PACKAGE_HASH_FIELDS: + _sha256_hex(candidate[name], name) + _sha256_hex(candidate["package_sha256"], "package_sha256") + + raw_weights = candidate["target_weights"] + if not hasattr(raw_weights, "items") or not raw_weights: + raise ValueError("target_weights must be a non-empty mapping") + weights = {} + for raw_symbol, raw_weight in raw_weights.items(): + symbol = normalize_symbol(raw_symbol) + if symbol != raw_symbol: + raise ValueError( + "target_weights symbol must be canonical: %s" % raw_symbol + ) + if symbol in weights: + raise ValueError("duplicate target weight for %s" % symbol) + weight = _finite_non_negative( + raw_weight, "target_weights.%s" % symbol + ) + if weight > 1.0: + raise ValueError("target weight exceeds one for %s" % symbol) + weights[symbol] = weight + gross = math.fsum(weights.values()) + if gross > 1.0 + 1e-12: + raise ValueError("target_weights gross exceeds one") + + universe = candidate["universe"] + if not hasattr(universe, "items") or not universe: + raise ValueError("universe must be a non-empty mapping") + canonical_universe = {} + for raw_symbol, raw_state in universe.items(): + symbol = normalize_symbol(raw_symbol) + if symbol != raw_symbol: + raise ValueError("universe symbol must be canonical: %s" % raw_symbol) + if symbol in canonical_universe: + raise ValueError("duplicate universe symbol %s" % symbol) + if not hasattr(raw_state, "items"): + raise ValueError("universe state must be a mapping for %s" % symbol) + state = raw_state.get("state") + if state not in ( + "openable", + "hold_only", + "sell_only", + "frozen", + "excluded", + ): + raise ValueError("unsupported universe state for %s" % symbol) + canonical_universe[symbol] = state + missing_universe = sorted(set(weights) - set(canonical_universe)) + if missing_universe: + raise ValueError( + "target symbols are absent from universe: %s" % missing_universe + ) + for symbol, weight in weights.items(): + if canonical_universe[symbol] == "excluded" and weight > 1e-12: + raise ValueError("excluded symbol has positive weight: %s" % symbol) + + constraints = candidate["constraints"] + if not hasattr(constraints, "items"): + raise ValueError("constraints must be a mapping") + if constraints.get("long_only") is not True: + raise ValueError("TargetPackageV1 must be long-only") + declared_gross = _finite_non_negative( + constraints.get("actual_gross_weight"), + "constraints.actual_gross_weight", + ) + max_gross = _finite_non_negative( + constraints.get("max_gross_weight"), + "constraints.max_gross_weight", + ) + max_single = _finite_non_negative( + constraints.get("max_single_weight"), + "constraints.max_single_weight", + ) + if abs(declared_gross - gross) > 1e-12: + raise ValueError("declared gross does not equal target_weights gross") + if gross > max_gross + 1e-12 or max_gross > 1.0: + raise ValueError("target_weights exceed max_gross_weight") + if max_single <= 0.0 or max_single > 1.0: + raise ValueError("invalid max_single_weight") + if _any_true( + weight > max_single + 1e-12 for weight in weights.values() + ): + raise ValueError("target_weights exceed max_single_weight") + model_claim = " ".join( + ( + candidate["release"], + candidate["evidence_class"], + ) + ).lower() + if ( + "baseline" in model_claim + and "portable-momentum" in candidate["model_id"].lower() + ): + raise ValueError( + "portable-momentum cannot claim a baseline target package" + ) + + expected_hash = _target_package_content_sha256(candidate) + if candidate["package_sha256"] != expected_hash: + raise ValueError("target package content hash mismatch") + if ( + expected_signal_as_of is not None + and candidate["signal_as_of"] != expected_signal_as_of + ): + raise ValueError("target package signal_as_of does not match request") + if ( + expected_next_session is not None + and candidate["next_session"] != expected_next_session + ): + raise ValueError("target package next_session does not match request") + if ( + expected_decision_id is not None + and candidate["decision_id"] != expected_decision_id + ): + raise ValueError("target package decision_id does not match request") + if expected_package_sha256 is not None: + _sha256_hex( + expected_package_sha256, + "expected_package_sha256", + ) + if candidate["package_sha256"] != expected_package_sha256: + raise ValueError("target package digest does not match request") + return candidate + + +def target_package_tape_sha256(packages): + """Hash the exact ordered canonical JSON package array.""" + + if not isinstance(packages, (list, tuple)): + raise TypeError("target package tape must be a list or tuple") + try: + payload = _canonical_json(list(packages)).encode("utf-8") + except (TypeError, ValueError) as error: + raise ValueError( + "target package tape must contain finite JSON values only" + ) from error + return hashlib.sha256(payload).hexdigest() + + +def lookup_target_package_exact( + packages, + signal_as_of, + next_session, + decision_id=None, + expected_tape_sha256=None, + expected_count=None, +): + """Verify a complete tape, then return one exact dual-clock decision.""" + + if not isinstance(packages, (list, tuple)) or not packages: + raise ValueError("target package tape must be a non-empty list or tuple") + _verify_target_package_clock(signal_as_of, next_session) + decision_id = _optional_non_empty_string(decision_id, "decision_id") + if expected_count is not None: + if ( + isinstance(expected_count, bool) + or not isinstance(expected_count, int) + or expected_count < 0 + ): + raise ValueError("expected_count must be a non-negative integer") + if len(packages) != expected_count: + raise ValueError("target package tape count mismatch") + if expected_tape_sha256 is not None: + _sha256_hex(expected_tape_sha256, "expected_tape_sha256") + if target_package_tape_sha256(packages) != expected_tape_sha256: + raise ValueError("target package tape digest mismatch") + + verified = [] + package_ids = set() + decision_ids = set() + clock_keys = set() + for raw_package in packages: + package = verify_target_package(raw_package) + clock_key = (package["signal_as_of"], package["next_session"]) + if package["package_id"] in package_ids: + raise ValueError("duplicate target package_id") + if package["decision_id"] in decision_ids: + raise ValueError("duplicate target decision_id") + if clock_key in clock_keys: + raise ValueError("duplicate target package clock") + package_ids.add(package["package_id"]) + decision_ids.add(package["decision_id"]) + clock_keys.add(clock_key) + verified.append(package) + + matches = [ + package + for package in verified + if package["signal_as_of"] == signal_as_of + and package["next_session"] == next_session + ] + if not matches: + raise TargetPackageLookupMiss( + "no target package for exact signal_as_of/next_session clock" + ) + if len(matches) != 1: + raise ValueError("ambiguous exact target package decision") + if ( + decision_id is not None + and matches[0]["decision_id"] != decision_id + ): + raise ValueError("exact target package decision_id mismatch") + return _json_copy(matches[0], "target package") + + def momentum_score(prices, lookback=20, skip=0): """Return trailing simple momentum, or ``None`` if history is insufficient. @@ -318,6 +758,108 @@ def order_deltas( return result +def build_target_weight_plan( + target_weights, + prices, + current, + sellable, + equity, + cash_buffer=0.02, + lot_size=100, + decision_id=None, + package_sha256=None, +): + """Bind canonical post-risk weights to one account without recomputing Alpha. + + ``prices`` must cover exactly every target or currently managed symbol. + Missing prices fail closed even for a held symbol whose desired weight is + zero. A missing ``sellable`` entry is safe and means zero sellable shares. + """ + + weights = _canonical_mapping( + target_weights, + "target_weights", + _finite_non_negative, + ) + gross = math.fsum(weights.values()) + if _any_true(weight > 1.0 for weight in weights.values()): + raise ValueError("target weight must not exceed one") + if gross > 1.0 + 1e-12: + raise ValueError("target weight gross must not exceed one") + + canonical_current = _canonical_mapping( + current, + "current", + _non_negative_quantity, + ) + canonical_sellable = _canonical_mapping( + sellable, + "sellable", + _non_negative_quantity, + ) + unknown_sellable = sorted(set(canonical_sellable) - set(canonical_current)) + if unknown_sellable: + raise ValueError( + "sellable contains symbols absent from current: %s" + % unknown_sellable + ) + for symbol, quantity in canonical_sellable.items(): + if quantity > canonical_current[symbol]: + raise ValueError( + "sellable exceeds current quantity for %s" % symbol + ) + + canonical_prices = _canonical_mapping( + prices, + "prices", + _positive_price, + ) + managed_symbols = set(weights) | set(canonical_current) + price_symbols = set(canonical_prices) + missing_prices = sorted(managed_symbols - price_symbols) + extra_prices = sorted(price_symbols - managed_symbols) + if missing_prices or extra_prices: + if missing_prices: + raise KeyError( + "prices do not cover managed symbols: missing=%s" + % missing_prices + ) + raise ValueError( + "prices contain unmanaged symbols: extra=%s" % extra_prices + ) + + complete_weights = dict( + (symbol, weights.get(symbol, 0.0)) + for symbol in sorted(managed_symbols) + ) + targets = target_quantities( + complete_weights, + canonical_prices, + equity, + lot_size, + cash_buffer, + ) + orders = order_deltas( + targets, + canonical_current, + canonical_sellable, + lot_size, + ) + decision_id = _optional_non_empty_string(decision_id, "decision_id") + package_sha256 = _sha256_hex( + package_sha256, + "package_sha256", + allow_none=True, + ) + return { + "decision_id": decision_id, + "package_sha256": package_sha256, + "weights": complete_weights, + "targets": targets, + "orders": orders, + } + + def build_rebalance_plan( price_history, current, @@ -363,17 +905,24 @@ def build_rebalance_plan( __all__ = [ "build_rebalance_plan", + "build_target_weight_plan", "capped_target_weights", "convert_symbol", + "lookup_target_package_exact", "momentum_score", "normalize_symbol", "order_deltas", "rank_momentum", "round_board_lot", + "TargetPackageLookupMiss", + "target_package_tape_sha256", "target_quantities", + "verify_target_package", ] # __PORTABLE_CORE_BUNDLE_END__ +import base64 as _q60_base64 +import json as _q60_json import math @@ -391,7 +940,10 @@ class ClockStateError(RuntimeError): # __BASELINE_CONFIG_START__ # Generated from configs/baseline.json; do not edit this region. -CONFIG = {'universe_mode': 'pit_index', +CONFIG = {'decision_mode': 'portable_momentum_smoke', + 'target_package_count': 0, + 'target_package_tape_sha256': None, + 'universe_mode': 'pit_index', 'index_symbol': '000905.XSHG', 'qmt_sector_name': '\u4e2d\u8bc1500', 'dedicated_account_required': True, @@ -413,21 +965,363 @@ CONFIG = {'universe_mode': 'pit_index', 'rebalance_schedule': 'weekly_first_close'} # __BASELINE_CONFIG_END__ +# __TARGET_PACKAGE_TAPE_START__ +# Generated by tools/bundle_platforms.py; do not edit this region. +TARGET_PACKAGES = [] +# __TARGET_PACKAGE_TAPE_END__ + + +MOMENTUM_SMOKE_MODE = "portable_momentum_smoke" +TARGET_PACKAGE_MODE = "target_package" +EVIDENCE_PREFIX = "QUANT60_JOINQUANT_EVIDENCE_V1 " +EVIDENCE_CHUNK_CHARACTERS = 1800 +TARGET_PRICE_SOURCE = "CURRENT_DATA_DAY_OPEN" + + +def _evidence_value(value, depth=0): + """Return stable JSON data without allowing observation to stop trading.""" + if depth > 8: + return "" + if value is None or isinstance(value, (bool, int, str)): + return value + if isinstance(value, float): + return value if math.isfinite(value) else None + if isinstance(value, dict): + return { + str(key): _evidence_value(item, depth + 1) + for key, item in sorted( + value.items(), + key=lambda pair: str(pair[0]), + ) + } + if isinstance(value, (list, tuple)): + return [_evidence_value(item, depth + 1) for item in value] + isoformat = getattr(value, "isoformat", None) + if callable(isoformat): + try: + return str(isoformat()) + except Exception: + return "" + try: + return str(value) + except Exception: + return "" + + +def _evidence_context_time(context): + try: + return _evidence_value(getattr(context, "current_dt", None)) + except Exception: + return None + + +def _emit_evidence( + event, + payload=None, + context=None, + chunk_index=None, + chunk_count=None, +): + """Print one prefixed canonical JSON record and always fail soft.""" + try: + sequence = int( + getattr(g, "quant60_evidence_sequence", 0) or 0 + ) + 1 + g.quant60_evidence_sequence = sequence + record = { + "context_time": _evidence_context_time(context), + "decision_mode": getattr( + g, + "quant60_decision_mode", + CONFIG.get("decision_mode"), + ), + "event": str(event), + "payload": _evidence_value(payload or {}), + "schema_version": "1.0", + "sequence": sequence, + "source": "joinquant_hosted", + } + if chunk_index is not None or chunk_count is not None: + if ( + type(chunk_index) is not int + or type(chunk_count) is not int + or chunk_index <= 0 + or chunk_count <= 0 + or chunk_index > chunk_count + ): + return None + record["chunk_index"] = chunk_index + record["chunk_count"] = chunk_count + encoded = _q60_json.dumps( + record, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + print(EVIDENCE_PREFIX + encoded) + except Exception: + # Evidence is observational. A logging/serialization failure must + # never alter an already verified decision or hosted order path. + return None + return record + + +def _emit_chunked_evidence(event, payload, context=None): + """Emit reconstructable base64 chunks of one canonical JSON payload.""" + try: + canonical = _q60_json.dumps( + _evidence_value(payload), + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("ascii") + encoded = _q60_base64.b64encode(canonical).decode("ascii") + chunks = [ + encoded[index : index + EVIDENCE_CHUNK_CHARACTERS] + for index in range(0, len(encoded), EVIDENCE_CHUNK_CHARACTERS) + ] or [""] + count = len(chunks) + for index, content in enumerate(chunks, 1): + _emit_evidence( + event, + { + "chunk_encoding": "base64-canonical-json", + "content": content, + "encoded_length": len(encoded), + }, + context, + chunk_index=index, + chunk_count=count, + ) + except Exception: + return None + return count + + +def _observe_object(value, fields): + if value is None: + return None + output = {"object_type": type(value).__name__} + for name in fields: + try: + item = getattr(value, name) + except Exception: + continue + if callable(item): + continue + output[name] = _evidence_value(item) + return output + + +def _observe_order(value): + return _observe_object( + value, + ( + "order_id", + "add_time", + "security", + "amount", + "filled", + "price", + "avg_cost", + "side", + "action", + "status", + "target_quantity", + ), + ) + + +def _observe_trade(value): + return _observe_object( + value, + ( + "trade_id", + "order_id", + "time", + "security", + "amount", + "price", + ), + ) + + +def _observe_platform_collection(api_name, observer): + """Read an optional hosted collection without assuming API availability.""" + function = globals().get(api_name) + if not callable(function): + return { + "available": False, + "reason": "API_UNAVAILABLE", + } + try: + raw = function() + except Exception as exc: + return { + "available": False, + "error_type": type(exc).__name__, + "reason": "API_CALL_FAILED", + } + if hasattr(raw, "items"): + items = [ + { + "key": str(key), + "value": observer(value), + } + for key, value in sorted( + raw.items(), + key=lambda pair: str(pair[0]), + ) + ] + elif isinstance(raw, (list, tuple)): + items = [observer(value) for value in raw] + elif raw is None: + items = [] + else: + items = [observer(raw)] + return { + "available": True, + "count": len(items), + "items": items, + } + + +def _observe_portfolio(context): + try: + portfolio = context.portfolio + except Exception as exc: + return { + "available": False, + "error_type": type(exc).__name__, + } + output = { + "available": True, + "fields": {}, + "positions": [], + } + for name in ( + "total_value", + "cash", + "available_cash", + "positions_value", + "returns", + ): + try: + value = getattr(portfolio, name) + except Exception: + continue + output["fields"][name] = _evidence_value(value) + try: + positions = getattr(portfolio, "positions", {}) + if hasattr(positions, "items"): + for symbol, position in sorted( + positions.items(), + key=lambda pair: str(pair[0]), + ): + item = _observe_object( + position, + ( + "total_amount", + "closeable_amount", + "price", + "avg_cost", + "value", + ), + ) + item["symbol"] = str(symbol) + output["positions"].append(item) + except Exception as exc: + output["positions_error_type"] = type(exc).__name__ + return output + + +def _plan_evidence(plan): + if not isinstance(plan, dict): + return None + return { + "decision_mode": plan.get("decision_mode"), + "execution_contract": plan.get("execution_contract"), + "lineage": plan.get("lineage"), + "orders": plan.get("orders"), + "targets": plan.get("targets"), + "universe": plan.get("universe"), + "weights": plan.get("weights"), + } + + +def _validated_target_packages(config): + mode = config.get("decision_mode") + if mode not in (MOMENTUM_SMOKE_MODE, TARGET_PACKAGE_MODE): + raise ValueError("unsupported decision_mode") + if not isinstance(TARGET_PACKAGES, list): + raise ValueError("TARGET_PACKAGES must be a list") + expected_count = config.get("target_package_count") + expected_sha256 = config.get("target_package_tape_sha256") + if mode == MOMENTUM_SMOKE_MODE: + if ( + type(expected_count) is not int + or expected_count != 0 + or expected_sha256 is not None + or TARGET_PACKAGES + ): + raise ValueError( + "portable_momentum_smoke cannot carry target packages" + ) + return [] + if type(expected_count) is not int or expected_count <= 0: + raise ValueError( + "target_package mode requires a positive target_package_count" + ) + if expected_count != len(TARGET_PACKAGES): + raise ValueError("target package count mismatch") + actual_sha256 = target_package_tape_sha256(TARGET_PACKAGES) + if expected_sha256 != actual_sha256: + raise ValueError("target package tape sha256 mismatch") + verified = [] + package_ids = set() + decision_ids = set() + clocks = set() + for raw_package in TARGET_PACKAGES: + package = verify_target_package(raw_package) + package_id = package["package_id"] + decision_id = package["decision_id"] + clock = (package["signal_as_of"], package["next_session"]) + if ( + package_id in package_ids + or decision_id in decision_ids + or clock in clocks + ): + raise ValueError("duplicate target package decision identity") + package_ids.add(package_id) + decision_ids.add(decision_id) + clocks.add(clock) + verified.append(package) + return verified + def initialize(context): - """Configure a daily-open clock that executes a first-week-close signal.""" + """Configure the selected daily-open decision mode.""" set_option("avoid_future_data", True) set_option("use_real_price", True) + g.quant60_evidence_sequence = 0 + g.quant60_order_observations = [] g.quant60_config = dict(CONFIG) - if g.quant60_config.get("rebalance_schedule") != "weekly_first_close": - raise ValueError("unsupported rebalance_schedule") - if g.quant60_config.get("universe_mode") not in ("pit_index", "fixed"): - raise ValueError("unsupported universe_mode") - if ( - g.quant60_config.get("universe_mode") == "pit_index" - and not g.quant60_config.get("dedicated_account_required") - ): - raise ValueError("pit_index mode requires a dedicated account") + g.quant60_target_packages = _validated_target_packages( + g.quant60_config + ) + g.quant60_decision_mode = g.quant60_config["decision_mode"] + if g.quant60_config["decision_mode"] == MOMENTUM_SMOKE_MODE: + if g.quant60_config.get("rebalance_schedule") != "weekly_first_close": + raise ValueError("unsupported rebalance_schedule") + if g.quant60_config.get("universe_mode") not in ("pit_index", "fixed"): + raise ValueError("unsupported universe_mode") + if ( + g.quant60_config.get("universe_mode") == "pit_index" + and not g.quant60_config.get("dedicated_account_required") + ): + raise ValueError("pit_index mode requires a dedicated account") set_benchmark(g.quant60_config["index_symbol"]) commission = ( float(g.quant60_config["commission_rate"]) @@ -465,6 +1359,30 @@ def initialize(context): g.quant60_pending_first_session = None g.quant60_last_callback_date = None run_daily(rebalance, time="open") + _emit_evidence( + "INIT", + { + "benchmark": g.quant60_config["index_symbol"], + "package_count": len(g.quant60_target_packages), + "package_ids": [ + package["package_id"] + for package in g.quant60_target_packages + ], + "rebalance_callback": "run_daily_open", + "target_package_tape_sha256": g.quant60_config.get( + "target_package_tape_sha256" + ), + }, + context, + ) + + +def _decision_mode(): + configured = g.quant60_config.get("decision_mode") + frozen = getattr(g, "quant60_decision_mode", configured) + if configured != frozen: + raise ValueError("decision_mode cannot change after initialize") + return frozen def _close_history(symbol, count): @@ -619,7 +1537,10 @@ def _snapshot_portfolio(context, managed): current = {} sellable = {} managed = set(convert_symbol(symbol, "canonical") for symbol in managed) - dynamic = g.quant60_config.get("universe_mode") == "pit_index" + dynamic = ( + _decision_mode() == MOMENTUM_SMOKE_MODE + and g.quant60_config.get("universe_mode") == "pit_index" + ) positions = getattr(context.portfolio, "positions", {}) for symbol, position in positions.items(): canonical = convert_symbol(str(symbol), "canonical") @@ -699,8 +1620,156 @@ def _order_request(symbol, delta): return {"style": style} +def _target_package_for_open(context): + current_date = _as_date(getattr(context, "current_dt", None), "current_dt") + previous_date = _as_date( + getattr(context, "previous_date", None), + "previous_date", + ) + signal_as_of = ( + previous_date.isoformat() + "T15:00:00+08:00" + ) + next_session = current_date.isoformat() + config = g.quant60_config + packages = _validated_target_packages(config) + matches = [ + package + for package in packages + if package["signal_as_of"] == signal_as_of + and package["next_session"] == next_session + ] + if not matches: + _emit_evidence( + "TARGET_PACKAGE_NOOP", + { + "fallback_invoked": False, + "next_session": next_session, + "orders_submitted": 0, + "package_count": len(packages), + "reason": "NO_EXACT_CLOCK_MATCH", + "signal_as_of": signal_as_of, + "target_package_tape_sha256": config[ + "target_package_tape_sha256" + ], + }, + context, + ) + return None + if len(matches) != 1: + raise ValueError("ambiguous target package clock") + package = lookup_target_package_exact( + packages, + signal_as_of, + next_session, + decision_id=matches[0]["decision_id"], + expected_tape_sha256=config["target_package_tape_sha256"], + expected_count=config["target_package_count"], + ) + _emit_evidence( + "TARGET_PACKAGE_HIT", + { + "lineage": _target_lineage(package), + "target_package_tape_sha256": config[ + "target_package_tape_sha256" + ], + }, + context, + ) + return package + + +def _target_prices(symbols): + try: + current_data = get_current_data() + except Exception as exc: + raise PriceHistoryUnavailable( + "current_data is unavailable: %s" % exc + ) + prices = {} + for canonical in sorted(symbols): + symbol = convert_symbol(canonical, "joinquant") + try: + raw_price = getattr(current_data[symbol], "day_open") + price = float(raw_price) + except (KeyError, AttributeError, TypeError, ValueError): + raise PriceHistoryUnavailable( + "%s has no usable current day_open" % symbol + ) + if not math.isfinite(price) or price <= 0.0: + raise PriceHistoryUnavailable( + "%s has no usable current day_open" % symbol + ) + prices[canonical] = price + return prices + + +def _target_lineage(package): + return { + "package_id": package["package_id"], + "package_sha256": package["package_sha256"], + "decision_id": package["decision_id"], + "signal_as_of": package["signal_as_of"], + "next_session": package["next_session"], + "release": package["release"], + "model_id": package["model_id"], + } + + +def _compute_target_package_plan(context): + package = _target_package_for_open(context) + if package is None: + return None + managed = sorted(package["universe"]) + current, sellable, equity = _snapshot_portfolio(context, managed) + if equity <= 0: + raise ValueError("portfolio.total_value must be positive") + prices = _target_prices( + set(package["target_weights"]) | set(current) + ) + _emit_evidence( + "PLATFORM_BINDING_INPUT", + { + "current": current, + "equity": equity, + "lineage": _target_lineage(package), + "price_source": TARGET_PRICE_SOURCE, + "prices": prices, + "sellable": sellable, + }, + context, + ) + plan = build_target_weight_plan( + target_weights=package["target_weights"], + prices=prices, + current=current, + sellable=sellable, + equity=equity, + cash_buffer=g.quant60_config["cash_buffer"], + lot_size=g.quant60_config["lot_size"], + decision_id=package["decision_id"], + package_sha256=package["package_sha256"], + ) + plan["decision_mode"] = TARGET_PACKAGE_MODE + plan["lineage"] = _target_lineage(package) + plan["universe"] = package["universe"] + plan["execution_contract"] = { + "signal_price_source": TARGET_PRICE_SOURCE, + "submit_trigger": "DECLARED_NEXT_SESSION_OPEN_CALLBACK", + "declared_next_session": package["next_session"], + "real_platform_observed": False, + } + _emit_evidence( + "TARGET_PACKAGE_PLAN", + _plan_evidence(plan), + context, + ) + return plan + + def compute_plan(context): - """Return the portable plan without submitting orders.""" + """Return the selected portable plan without submitting orders.""" + if _decision_mode() == TARGET_PACKAGE_MODE: + return _compute_target_package_plan(context) config = g.quant60_config required = int(config["lookback"]) + int(config["skip"]) + 1 platform_universe = _decision_universe(context) @@ -732,13 +1801,17 @@ def compute_plan(context): return plan -def _execute_rebalance(context): - """Submit the portable plan's exact executable share deltas.""" - plan = compute_plan(context) +def _submit_plan(context, plan, managed): + """Submit a portable plan's exact executable share deltas.""" + if plan is None: + return None + observe_target_package = ( + plan.get("decision_mode") == TARGET_PACKAGE_MODE + ) order_deltas = plan["orders"] current, _, unused_equity = _snapshot_portfolio( context, - g.quant60_last_universe or g.quant60_config["universe"], + managed, ) del unused_equity all_symbols = set(current) @@ -758,23 +1831,107 @@ def _execute_rebalance(context): if delta == 0: continue platform_symbol = convert_symbol(canonical, "joinquant") + skipped_before = len(g.quant60_skipped_orders) request = _order_request(platform_symbol, delta) if request is None: + skipped = g.quant60_skipped_orders[skipped_before:] + if not skipped: + skipped = [ + { + "symbol": platform_symbol, + "reason": "ORDER_REQUEST_NOT_BUILT", + } + ] + if observe_target_package: + for item in skipped: + _emit_evidence( + "SKIPPED_ORDER", + { + "canonical_symbol": canonical, + "delta": delta, + "lineage": plan.get("lineage"), + "skip": item, + }, + context, + ) continue target = int(current.get(canonical, 0)) + delta - if request["style"] is None: - order_target(platform_symbol, target) - else: - order_target( - platform_symbol, - target, - style=request["style"], + request_evidence = { + "canonical_symbol": canonical, + "current_quantity": int(current.get(canonical, 0)), + "delta": delta, + "lineage": plan.get("lineage"), + "platform_symbol": platform_symbol, + "style": _observe_object( + request["style"], + ("kind", "limit_price"), + ), + "target_quantity": target, + } + if observe_target_package: + _emit_evidence( + "ORDER_REQUEST", + request_evidence, + context, + ) + try: + if request["style"] is None: + order_result = order_target(platform_symbol, target) + else: + order_result = order_target( + platform_symbol, + target, + style=request["style"], + ) + except Exception as exc: + if observe_target_package: + _emit_evidence( + "ORDER_RETURN", + { + "error_type": type(exc).__name__, + "lineage": plan.get("lineage"), + "outcome": "EXCEPTION", + "platform_symbol": platform_symbol, + "target_quantity": target, + }, + context, + ) + raise + observation = { + "lineage": plan.get("lineage"), + "order": _observe_order(order_result), + "outcome": "RETURNED", + "platform_symbol": platform_symbol, + "target_quantity": target, + } + if observe_target_package: + g.quant60_order_observations.append(observation) + _emit_evidence( + "ORDER_RETURN", + observation, + context, ) g.quant60_last_plan = plan return plan +def _execute_rebalance(context): + """Submit the selected portable plan's exact executable share deltas.""" + plan = compute_plan(context) + if plan is None: + return None + managed = ( + sorted(plan["universe"]) + if "universe" in plan + else ( + g.quant60_last_universe + or g.quant60_config["universe"] + ) + ) + return _submit_plan(context, plan, managed) + + def _as_date(value, name): if hasattr(value, "date"): value = value.date() @@ -796,6 +1953,16 @@ def rebalance(context): ) if g.quant60_last_callback_date == current_date: return None + if _decision_mode() == TARGET_PACKAGE_MODE: + # EOD evidence is session-scoped. Clear the prior session only after + # the duplicate-date guard so a same-day retry cannot erase evidence. + g.quant60_last_plan = None + g.quant60_order_observations = [] + g.quant60_skipped_orders = [] + # Consume the daily clock token before crossing the hosted order + # boundary so callback retries cannot duplicate an accepted prefix. + g.quant60_last_callback_date = current_date + return _execute_rebalance(context) pending = g.quant60_pending_first_session is_first_session = ( previous_date.isocalendar()[:2] @@ -820,3 +1987,37 @@ def rebalance(context): g.quant60_pending_first_session = current_date g.quant60_last_callback_date = current_date return None + + +def after_trading_end(context): + """Emit a best-effort end-of-day observation without changing state.""" + if _decision_mode() != TARGET_PACKAGE_MODE: + return None + return _emit_chunked_evidence( + "EOD_STATUS", + { + "last_plan": _plan_evidence( + getattr(g, "quant60_last_plan", None) + ), + "order_returns": getattr( + g, + "quant60_order_observations", + [], + ), + "orders_api": _observe_platform_collection( + "get_orders", + _observe_order, + ), + "portfolio": _observe_portfolio(context), + "skipped_orders": getattr( + g, + "quant60_skipped_orders", + [], + ), + "trades_api": _observe_platform_collection( + "get_trades", + _observe_trade, + ), + }, + context, + ) diff --git a/dist/qmt_builtin_strategy.py b/dist/qmt_builtin_strategy.py index 81a0ea8..511fabf 100644 --- a/dist/qmt_builtin_strategy.py +++ b/dist/qmt_builtin_strategy.py @@ -3,6 +3,8 @@ # __PORTABLE_CORE_BUNDLE_START__ # Inlined by tools/bundle_platforms.py; do not edit this region. +import hashlib +import json import math import re @@ -26,6 +28,45 @@ _SHORT_EXCHANGES = { "XBSE": "BJ", } +_TARGET_PACKAGE_FIELDS = { + "schema_version", + "package_type", + "package_id", + "decision_id", + "release", + "evidence_class", + "model_id", + "signal_as_of", + "next_session", + "model_sha256", + "data_sha256", + "feature_sha256", + "risk_sha256", + "cost_sha256", + "optimizer_sha256", + "config_sha256", + "source_sha256", + "target_weights", + "universe", + "constraints", + "package_sha256", +} + +_TARGET_PACKAGE_HASH_FIELDS = ( + "model_sha256", + "data_sha256", + "feature_sha256", + "risk_sha256", + "cost_sha256", + "optimizer_sha256", + "config_sha256", + "source_sha256", +) + + +class TargetPackageLookupMiss(ValueError, LookupError): + """An already verified tape has no exact requested decision.""" + def _sum_numeric(values): """Sum numerics without trusting a hosted runtime's global ``sum`` name.""" @@ -36,6 +77,15 @@ def _sum_numeric(values): return total +def _any_true(values): + """Return built-in ``any`` semantics without trusting hosted globals.""" + + for value in values: + if value: + return True + return False + + def _infer_exchange(code): if code.startswith(("4", "8")): return "XBSE" @@ -88,6 +138,396 @@ def convert_symbol(symbol, target="canonical"): return normalize_symbol(symbol, target) +def _canonical_json(value): + """Match ``quant60.ledger.canonical_json`` for plain JSON values.""" + + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def _json_copy(value, name): + try: + return json.loads(_canonical_json(value)) + except (TypeError, ValueError) as error: + raise ValueError("%s must contain finite JSON values only" % name) from error + + +def _canonical_mapping(value, name, value_validator): + if not hasattr(value, "items"): + raise TypeError("%s must be a mapping" % name) + output = {} + for raw_symbol, raw_value in value.items(): + symbol = normalize_symbol(raw_symbol) + if symbol in output: + raise ValueError( + "%s contains duplicate normalized symbol %s" % (name, symbol) + ) + output[symbol] = value_validator(raw_value, "%s.%s" % (name, symbol)) + return dict((symbol, output[symbol]) for symbol in sorted(output)) + + +def _finite_non_negative(value, name): + if isinstance(value, bool): + raise ValueError("%s must be a finite non-negative number" % name) + try: + numeric = float(value) + except (TypeError, ValueError): + raise ValueError("%s must be a finite non-negative number" % name) + if not math.isfinite(numeric) or numeric < 0.0: + raise ValueError("%s must be a finite non-negative number" % name) + return numeric + + +def _non_negative_quantity(value, name): + numeric = _finite_non_negative(value, name) + if numeric != math.floor(numeric): + raise ValueError("%s must be a whole-share quantity" % name) + return int(numeric) + + +def _positive_price(value, name): + numeric = _finite_non_negative(value, name) + if numeric <= 0.0: + raise ValueError("%s must be positive" % name) + return numeric + + +def _optional_non_empty_string(value, name): + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ValueError("%s must be a non-empty string when provided" % name) + return value + + +def _sha256_hex(value, name, allow_none=False): + if value is None and allow_none: + return None + if ( + not isinstance(value, str) + or len(value) != 64 + or _any_true( + character not in "0123456789abcdef" + for character in value + ) + ): + raise ValueError( + "%s must be a 64-character lowercase SHA-256 digest" % name + ) + if value == "0" * 64: + raise ValueError("%s cannot be an all-zero placeholder" % name) + return value + + +def _reject_quantity_fields(value, path="$"): + if hasattr(value, "items"): + for raw_key, child in value.items(): + key = str(raw_key) + normalized = key.lower().replace("-", "_") + if "quantity" in normalized or "quantities" in normalized: + raise ValueError( + "%s.%s: quantities do not belong in TargetPackageV1" + % (path, key) + ) + _reject_quantity_fields(child, "%s.%s" % (path, key)) + elif isinstance(value, list): + for index, child in enumerate(value): + _reject_quantity_fields(child, "%s[%d]" % (path, index)) + + +def _verify_identifier(value, name): + if ( + not isinstance(value, str) + or re.match(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", value) is None + ): + raise ValueError("%s is not a canonical identifier" % name) + return value + + +def _verify_target_package_clock(signal_as_of, next_session): + if ( + not isinstance(signal_as_of, str) + or re.match( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T15:00:00\+08:00$", + signal_as_of, + ) + is None + ): + raise ValueError("signal_as_of must be canonical China-market close") + if ( + not isinstance(next_session, str) + or re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", next_session) is None + ): + raise ValueError("next_session must use canonical YYYY-MM-DD") + if next_session <= signal_as_of[:10]: + raise ValueError("next_session must follow signal_as_of") + + +def _target_package_content_sha256(package): + body = dict(package) + body.pop("package_sha256", None) + _reject_quantity_fields(body) + try: + payload = _canonical_json(body).encode("utf-8") + except (TypeError, ValueError) as error: + raise ValueError( + "target package must contain finite JSON values only" + ) from error + return hashlib.sha256(payload).hexdigest() + + +def verify_target_package( + package, + expected_signal_as_of=None, + expected_next_session=None, + expected_decision_id=None, + expected_package_sha256=None, +): + """Verify the platform-critical subset of a sealed ``TargetPackageV1``. + + This intentionally duplicates a small, Python-3.6-compatible safety + boundary from the richer local validator. Hosted runtimes must verify the + embedded package again instead of trusting the bundling process. + """ + + if not hasattr(package, "items"): + raise TypeError("target package must be a mapping") + candidate = _json_copy(package, "target package") + _reject_quantity_fields(candidate) + fields = set(candidate) + if fields != _TARGET_PACKAGE_FIELDS: + missing = sorted(_TARGET_PACKAGE_FIELDS - fields) + unknown = sorted(fields - _TARGET_PACKAGE_FIELDS) + raise ValueError( + "target package fields mismatch; missing=%s, unknown=%s" + % (missing, unknown) + ) + if candidate["schema_version"] != "1.0": + raise ValueError("unsupported target package schema_version") + if candidate["package_type"] != "TargetPackageV1": + raise ValueError("unsupported target package type") + for name in ( + "package_id", + "decision_id", + "release", + "evidence_class", + "model_id", + ): + _verify_identifier(candidate[name], name) + _verify_target_package_clock( + candidate["signal_as_of"], candidate["next_session"] + ) + for name in _TARGET_PACKAGE_HASH_FIELDS: + _sha256_hex(candidate[name], name) + _sha256_hex(candidate["package_sha256"], "package_sha256") + + raw_weights = candidate["target_weights"] + if not hasattr(raw_weights, "items") or not raw_weights: + raise ValueError("target_weights must be a non-empty mapping") + weights = {} + for raw_symbol, raw_weight in raw_weights.items(): + symbol = normalize_symbol(raw_symbol) + if symbol != raw_symbol: + raise ValueError( + "target_weights symbol must be canonical: %s" % raw_symbol + ) + if symbol in weights: + raise ValueError("duplicate target weight for %s" % symbol) + weight = _finite_non_negative( + raw_weight, "target_weights.%s" % symbol + ) + if weight > 1.0: + raise ValueError("target weight exceeds one for %s" % symbol) + weights[symbol] = weight + gross = math.fsum(weights.values()) + if gross > 1.0 + 1e-12: + raise ValueError("target_weights gross exceeds one") + + universe = candidate["universe"] + if not hasattr(universe, "items") or not universe: + raise ValueError("universe must be a non-empty mapping") + canonical_universe = {} + for raw_symbol, raw_state in universe.items(): + symbol = normalize_symbol(raw_symbol) + if symbol != raw_symbol: + raise ValueError("universe symbol must be canonical: %s" % raw_symbol) + if symbol in canonical_universe: + raise ValueError("duplicate universe symbol %s" % symbol) + if not hasattr(raw_state, "items"): + raise ValueError("universe state must be a mapping for %s" % symbol) + state = raw_state.get("state") + if state not in ( + "openable", + "hold_only", + "sell_only", + "frozen", + "excluded", + ): + raise ValueError("unsupported universe state for %s" % symbol) + canonical_universe[symbol] = state + missing_universe = sorted(set(weights) - set(canonical_universe)) + if missing_universe: + raise ValueError( + "target symbols are absent from universe: %s" % missing_universe + ) + for symbol, weight in weights.items(): + if canonical_universe[symbol] == "excluded" and weight > 1e-12: + raise ValueError("excluded symbol has positive weight: %s" % symbol) + + constraints = candidate["constraints"] + if not hasattr(constraints, "items"): + raise ValueError("constraints must be a mapping") + if constraints.get("long_only") is not True: + raise ValueError("TargetPackageV1 must be long-only") + declared_gross = _finite_non_negative( + constraints.get("actual_gross_weight"), + "constraints.actual_gross_weight", + ) + max_gross = _finite_non_negative( + constraints.get("max_gross_weight"), + "constraints.max_gross_weight", + ) + max_single = _finite_non_negative( + constraints.get("max_single_weight"), + "constraints.max_single_weight", + ) + if abs(declared_gross - gross) > 1e-12: + raise ValueError("declared gross does not equal target_weights gross") + if gross > max_gross + 1e-12 or max_gross > 1.0: + raise ValueError("target_weights exceed max_gross_weight") + if max_single <= 0.0 or max_single > 1.0: + raise ValueError("invalid max_single_weight") + if _any_true( + weight > max_single + 1e-12 for weight in weights.values() + ): + raise ValueError("target_weights exceed max_single_weight") + model_claim = " ".join( + ( + candidate["release"], + candidate["evidence_class"], + ) + ).lower() + if ( + "baseline" in model_claim + and "portable-momentum" in candidate["model_id"].lower() + ): + raise ValueError( + "portable-momentum cannot claim a baseline target package" + ) + + expected_hash = _target_package_content_sha256(candidate) + if candidate["package_sha256"] != expected_hash: + raise ValueError("target package content hash mismatch") + if ( + expected_signal_as_of is not None + and candidate["signal_as_of"] != expected_signal_as_of + ): + raise ValueError("target package signal_as_of does not match request") + if ( + expected_next_session is not None + and candidate["next_session"] != expected_next_session + ): + raise ValueError("target package next_session does not match request") + if ( + expected_decision_id is not None + and candidate["decision_id"] != expected_decision_id + ): + raise ValueError("target package decision_id does not match request") + if expected_package_sha256 is not None: + _sha256_hex( + expected_package_sha256, + "expected_package_sha256", + ) + if candidate["package_sha256"] != expected_package_sha256: + raise ValueError("target package digest does not match request") + return candidate + + +def target_package_tape_sha256(packages): + """Hash the exact ordered canonical JSON package array.""" + + if not isinstance(packages, (list, tuple)): + raise TypeError("target package tape must be a list or tuple") + try: + payload = _canonical_json(list(packages)).encode("utf-8") + except (TypeError, ValueError) as error: + raise ValueError( + "target package tape must contain finite JSON values only" + ) from error + return hashlib.sha256(payload).hexdigest() + + +def lookup_target_package_exact( + packages, + signal_as_of, + next_session, + decision_id=None, + expected_tape_sha256=None, + expected_count=None, +): + """Verify a complete tape, then return one exact dual-clock decision.""" + + if not isinstance(packages, (list, tuple)) or not packages: + raise ValueError("target package tape must be a non-empty list or tuple") + _verify_target_package_clock(signal_as_of, next_session) + decision_id = _optional_non_empty_string(decision_id, "decision_id") + if expected_count is not None: + if ( + isinstance(expected_count, bool) + or not isinstance(expected_count, int) + or expected_count < 0 + ): + raise ValueError("expected_count must be a non-negative integer") + if len(packages) != expected_count: + raise ValueError("target package tape count mismatch") + if expected_tape_sha256 is not None: + _sha256_hex(expected_tape_sha256, "expected_tape_sha256") + if target_package_tape_sha256(packages) != expected_tape_sha256: + raise ValueError("target package tape digest mismatch") + + verified = [] + package_ids = set() + decision_ids = set() + clock_keys = set() + for raw_package in packages: + package = verify_target_package(raw_package) + clock_key = (package["signal_as_of"], package["next_session"]) + if package["package_id"] in package_ids: + raise ValueError("duplicate target package_id") + if package["decision_id"] in decision_ids: + raise ValueError("duplicate target decision_id") + if clock_key in clock_keys: + raise ValueError("duplicate target package clock") + package_ids.add(package["package_id"]) + decision_ids.add(package["decision_id"]) + clock_keys.add(clock_key) + verified.append(package) + + matches = [ + package + for package in verified + if package["signal_as_of"] == signal_as_of + and package["next_session"] == next_session + ] + if not matches: + raise TargetPackageLookupMiss( + "no target package for exact signal_as_of/next_session clock" + ) + if len(matches) != 1: + raise ValueError("ambiguous exact target package decision") + if ( + decision_id is not None + and matches[0]["decision_id"] != decision_id + ): + raise ValueError("exact target package decision_id mismatch") + return _json_copy(matches[0], "target package") + + def momentum_score(prices, lookback=20, skip=0): """Return trailing simple momentum, or ``None`` if history is insufficient. @@ -314,6 +754,108 @@ def order_deltas( return result +def build_target_weight_plan( + target_weights, + prices, + current, + sellable, + equity, + cash_buffer=0.02, + lot_size=100, + decision_id=None, + package_sha256=None, +): + """Bind canonical post-risk weights to one account without recomputing Alpha. + + ``prices`` must cover exactly every target or currently managed symbol. + Missing prices fail closed even for a held symbol whose desired weight is + zero. A missing ``sellable`` entry is safe and means zero sellable shares. + """ + + weights = _canonical_mapping( + target_weights, + "target_weights", + _finite_non_negative, + ) + gross = math.fsum(weights.values()) + if _any_true(weight > 1.0 for weight in weights.values()): + raise ValueError("target weight must not exceed one") + if gross > 1.0 + 1e-12: + raise ValueError("target weight gross must not exceed one") + + canonical_current = _canonical_mapping( + current, + "current", + _non_negative_quantity, + ) + canonical_sellable = _canonical_mapping( + sellable, + "sellable", + _non_negative_quantity, + ) + unknown_sellable = sorted(set(canonical_sellable) - set(canonical_current)) + if unknown_sellable: + raise ValueError( + "sellable contains symbols absent from current: %s" + % unknown_sellable + ) + for symbol, quantity in canonical_sellable.items(): + if quantity > canonical_current[symbol]: + raise ValueError( + "sellable exceeds current quantity for %s" % symbol + ) + + canonical_prices = _canonical_mapping( + prices, + "prices", + _positive_price, + ) + managed_symbols = set(weights) | set(canonical_current) + price_symbols = set(canonical_prices) + missing_prices = sorted(managed_symbols - price_symbols) + extra_prices = sorted(price_symbols - managed_symbols) + if missing_prices or extra_prices: + if missing_prices: + raise KeyError( + "prices do not cover managed symbols: missing=%s" + % missing_prices + ) + raise ValueError( + "prices contain unmanaged symbols: extra=%s" % extra_prices + ) + + complete_weights = dict( + (symbol, weights.get(symbol, 0.0)) + for symbol in sorted(managed_symbols) + ) + targets = target_quantities( + complete_weights, + canonical_prices, + equity, + lot_size, + cash_buffer, + ) + orders = order_deltas( + targets, + canonical_current, + canonical_sellable, + lot_size, + ) + decision_id = _optional_non_empty_string(decision_id, "decision_id") + package_sha256 = _sha256_hex( + package_sha256, + "package_sha256", + allow_none=True, + ) + return { + "decision_id": decision_id, + "package_sha256": package_sha256, + "weights": complete_weights, + "targets": targets, + "orders": orders, + } + + def build_rebalance_plan( price_history, current, @@ -359,14 +901,19 @@ def build_rebalance_plan( __all__ = [ "build_rebalance_plan", + "build_target_weight_plan", "capped_target_weights", "convert_symbol", + "lookup_target_package_exact", "momentum_score", "normalize_symbol", "order_deltas", "rank_momentum", "round_board_lot", + "TargetPackageLookupMiss", + "target_package_tape_sha256", "target_quantities", + "verify_target_package", ] # __PORTABLE_CORE_BUNDLE_END__ @@ -399,6 +946,9 @@ g = G() # __BASELINE_CONFIG_START__ # Generated from configs/baseline.json; do not edit this region. CONFIG = {'account_id': 'test', + 'decision_mode': 'portable_momentum_smoke', + 'target_package_count': 0, + 'target_package_tape_sha256': None, 'universe_mode': 'pit_index', 'index_symbol': '000905.XSHG', 'qmt_sector_name': '\u4e2d\u8bc1500', @@ -421,6 +971,77 @@ CONFIG = {'account_id': 'test', 'rebalance_schedule': 'weekly_first_close'} # __BASELINE_CONFIG_END__ +# __TARGET_PACKAGE_TAPE_START__ +# Generated by tools/bundle_platforms.py; do not edit this region. +TARGET_PACKAGES = [] +# __TARGET_PACKAGE_TAPE_END__ + + +MOMENTUM_SMOKE_MODE = "portable_momentum_smoke" +TARGET_PACKAGE_MODE = "target_package" + + +def _validated_target_packages(config): + mode = config.get("decision_mode") + if mode not in (MOMENTUM_SMOKE_MODE, TARGET_PACKAGE_MODE): + raise UnsupportedStrategyPeriod("unsupported decision_mode") + if not isinstance(TARGET_PACKAGES, list): + raise UnsupportedStrategyPeriod( + "TARGET_PACKAGES must be a list" + ) + expected_count = config.get("target_package_count") + expected_sha256 = config.get("target_package_tape_sha256") + if mode == MOMENTUM_SMOKE_MODE: + if ( + type(expected_count) is not int + or expected_count != 0 + or expected_sha256 is not None + or TARGET_PACKAGES + ): + raise UnsupportedStrategyPeriod( + "portable_momentum_smoke cannot carry target packages" + ) + return [] + if type(expected_count) is not int or expected_count <= 0: + raise UnsupportedStrategyPeriod( + "target_package mode requires a positive target_package_count" + ) + if expected_count != len(TARGET_PACKAGES): + raise UnsupportedStrategyPeriod( + "target package count mismatch" + ) + actual_sha256 = target_package_tape_sha256(TARGET_PACKAGES) + if expected_sha256 != actual_sha256: + raise UnsupportedStrategyPeriod( + "target package tape sha256 mismatch" + ) + verified = [] + package_ids = set() + decision_ids = set() + clocks = set() + signal_clocks = set() + for raw_package in TARGET_PACKAGES: + package = verify_target_package(raw_package) + package_id = package["package_id"] + decision_id = package["decision_id"] + clock = (package["signal_as_of"], package["next_session"]) + signal_as_of = package["signal_as_of"] + if ( + package_id in package_ids + or decision_id in decision_ids + or clock in clocks + or signal_as_of in signal_clocks + ): + raise UnsupportedStrategyPeriod( + "duplicate or ambiguous target package decision identity" + ) + package_ids.add(package_id) + decision_ids.add(decision_id) + clocks.add(clock) + signal_clocks.add(signal_as_of) + verified.append(package) + return verified + def _param(ContextInfo, name, default=None): params = getattr(ContextInfo, "_param", {}) @@ -442,22 +1063,25 @@ def init(ContextInfo): override = _param(ContextInfo, "q60_" + key, None) if override is not None: g.config[key] = override - if ( - g.config.get("rebalance_schedule") - != "weekly_first_close" - ): - raise UnsupportedStrategyPeriod( - "unsupported rebalance_schedule" - ) - if g.config.get("universe_mode") not in ("pit_index", "fixed"): - raise UnsupportedStrategyPeriod("unsupported universe_mode") - if ( - g.config.get("universe_mode") == "pit_index" - and not g.config.get("dedicated_account_required") - ): - raise UnsupportedStrategyPeriod( - "pit_index mode requires a dedicated account" - ) + g.target_packages = _validated_target_packages(g.config) + g.decision_mode = g.config["decision_mode"] + if g.config["decision_mode"] == MOMENTUM_SMOKE_MODE: + if ( + g.config.get("rebalance_schedule") + != "weekly_first_close" + ): + raise UnsupportedStrategyPeriod( + "unsupported rebalance_schedule" + ) + if g.config.get("universe_mode") not in ("pit_index", "fixed"): + raise UnsupportedStrategyPeriod("unsupported universe_mode") + if ( + g.config.get("universe_mode") == "pit_index" + and not g.config.get("dedicated_account_required") + ): + raise UnsupportedStrategyPeriod( + "pit_index mode requires a dedicated account" + ) set_commission = getattr(ContextInfo, "set_commission", None) set_slippage = getattr(ContextInfo, "set_slippage", None) if not callable(set_commission) or not callable(set_slippage): @@ -487,6 +1111,7 @@ def init(ContextInfo): g.last_plan = None g.last_universe = None g.last_universe_as_of = None + g.last_target_signal_as_of = None if ( g.config.get("universe_mode") == "fixed" and hasattr(ContextInfo, "set_universe") @@ -494,6 +1119,16 @@ def init(ContextInfo): ContextInfo.set_universe(g.config["universe"]) +def _decision_mode(): + configured = g.config.get("decision_mode") + frozen = getattr(g, "decision_mode", configured) + if configured != frozen: + raise UnsupportedStrategyPeriod( + "decision_mode cannot change after init" + ) + return frozen + + def _bar_datetime(ContextInfo): timetag = ContextInfo.get_bar_timetag(ContextInfo.barpos) return datetime.datetime.fromtimestamp(float(timetag) / 1000.0) @@ -670,7 +1305,10 @@ def _portfolio(ContextInfo, managed): convert_symbol(symbol, "canonical") for symbol in managed ) - dynamic = g.config.get("universe_mode") == "pit_index" + dynamic = ( + _decision_mode() == MOMENTUM_SMOKE_MODE + and g.config.get("universe_mode") == "pit_index" + ) for position in _trade_details(ContextInfo, "position") or []: code = str( _get_attr(position, ["stock_code", "m_strInstrumentID"], "") @@ -716,6 +1354,8 @@ def _portfolio(ContextInfo, managed): def compute_plan(ContextInfo, bar_time=None): + if _decision_mode() == TARGET_PACKAGE_MODE: + return _compute_target_package_plan(ContextInfo, bar_time) if bar_time is None: bar_time = _bar_datetime(ContextInfo) membership_timetag = int( @@ -748,6 +1388,136 @@ def compute_plan(ContextInfo, bar_time=None): ) +def _signal_as_of(bar_time): + if ( + int(bar_time.hour) != 15 + or int(bar_time.minute) != 0 + or int(bar_time.second) != 0 + or int(bar_time.microsecond) != 0 + ): + raise UnsupportedStrategyPeriod( + "target_package mode requires the completed 15:00 daily close" + ) + return bar_time.strftime("%Y-%m-%dT15:00:00+08:00") + + +def _target_package_for_close(bar_time): + signal_as_of = _signal_as_of(bar_time) + config = g.config + packages = _validated_target_packages(config) + matches = [ + package + for package in packages + if package["signal_as_of"] == signal_as_of + ] + if not matches: + return None + if len(matches) != 1: + raise UnsupportedStrategyPeriod( + "ambiguous target package signal clock" + ) + package = matches[0] + return lookup_target_package_exact( + packages, + signal_as_of, + package["next_session"], + decision_id=package["decision_id"], + expected_tape_sha256=config["target_package_tape_sha256"], + expected_count=config["target_package_count"], + ) + + +def _target_close_prices(ContextInfo, bar_time, symbols): + platform_symbols = [ + convert_symbol(symbol, "qmt") + for symbol in sorted(symbols) + ] + raw = ContextInfo.get_market_data_ex( + fields=["close"], + stock_code=platform_symbols, + period="1d", + start_time="", + end_time=bar_time.strftime("%Y%m%d"), + count=1, + dividend_type="front_ratio", + fill_data=False, + subscribe=False, + ) + if not hasattr(raw, "get"): + raise PriceHistoryUnavailable( + "target close query returned an invalid payload" + ) + prices = {} + for symbol in platform_symbols: + values = _series_close(raw.get(symbol)) + if len(values) != 1: + raise PriceHistoryUnavailable( + "%s needs one completed daily close" % symbol + ) + try: + price = float(values[0]) + except (TypeError, ValueError): + raise PriceHistoryUnavailable( + "%s contains a missing/non-numeric close" % symbol + ) + if not math.isfinite(price) or price <= 0.0: + raise PriceHistoryUnavailable( + "%s contains a missing/non-positive close" % symbol + ) + prices[convert_symbol(symbol, "canonical")] = price + return prices + + +def _target_lineage(package): + return { + "package_id": package["package_id"], + "package_sha256": package["package_sha256"], + "decision_id": package["decision_id"], + "signal_as_of": package["signal_as_of"], + "next_session": package["next_session"], + "release": package["release"], + "model_id": package["model_id"], + } + + +def _compute_target_package_plan(ContextInfo, bar_time=None): + if bar_time is None: + bar_time = _bar_datetime(ContextInfo) + package = _target_package_for_close(bar_time) + if package is None: + return None + managed = sorted(package["universe"]) + current, sellable, equity = _portfolio(ContextInfo, managed) + if equity <= 0: + raise ValueError("QMT account equity must be positive") + plan = build_target_weight_plan( + target_weights=package["target_weights"], + prices=_target_close_prices( + ContextInfo, + bar_time, + set(package["target_weights"]) | set(current), + ), + current=current, + sellable=sellable, + equity=equity, + cash_buffer=g.config["cash_buffer"], + lot_size=g.config["lot_size"], + decision_id=package["decision_id"], + package_sha256=package["package_sha256"], + ) + plan["decision_mode"] = TARGET_PACKAGE_MODE + plan["lineage"] = _target_lineage(package) + plan["universe"] = package["universe"] + plan["execution_contract"] = { + "signal_price_source": "COMPLETED_DAILY_CLOSE", + "submit_trigger": "NEXT_BAR_FIRST_TICK", + "quick_trade": 0, + "declared_next_session": package["next_session"], + "real_platform_observed": False, + } + return plan + + def _orders_allowed(ContextInfo): # This hosted wrapper is deliberately backtest-only. Its cached account # query and in-memory weekly marker cannot provide crash-safe live @@ -810,12 +1580,76 @@ def _submit_delta(ContextInfo, canonical, delta, week_key): ) +def _submit_target_delta(ContextInfo, canonical, delta, package_sha256): + config = g.config + order_code = convert_symbol(canonical, "qmt") + operation = 23 if delta > 0 else 24 + volume = abs(int(delta)) + user_order_id = "q60t-%s-%s" % ( + str(package_sha256)[:8], + order_code.split(".")[0], + ) + function = globals()["passorder"] + arg_count = getattr(getattr(function, "__code__", None), "co_argcount", 11) + if arg_count <= 8: + function( + operation, + 1101, + config["account_id"], + order_code, + 5, + -1, + volume, + ContextInfo, + ) + else: + function( + operation, + 1101, + config["account_id"], + order_code, + 5, + -1, + volume, + "quant60", + 0, + user_order_id, + ContextInfo, + ) + + def handlebar(ContextInfo): if not _is_backtest(ContextInfo) and hasattr(ContextInfo, "is_last_bar"): if not ContextInfo.is_last_bar(): return None bar_time = _bar_datetime(ContextInfo) + if _decision_mode() == TARGET_PACKAGE_MODE: + signal_as_of = _signal_as_of(bar_time) + if g.last_target_signal_as_of == signal_as_of: + return None + plan = compute_plan(ContextInfo, bar_time) + # Consume the clock before crossing passorder so a callback retry + # cannot duplicate an already accepted order prefix. + g.last_target_signal_as_of = signal_as_of + if plan is None: + return None + g.last_plan = plan + if not _orders_allowed(ContextInfo): + return plan + ordered = sorted( + plan["orders"].items(), + key=lambda item: item[1], + ) + for canonical, delta in ordered: + if int(delta) != 0: + _submit_target_delta( + ContextInfo, + canonical, + delta, + plan["lineage"]["package_sha256"], + ) + return plan year, week, unused = bar_time.isocalendar() del unused week_key = (year, week) diff --git a/dist/target-package/bundle_manifest.json b/dist/target-package/bundle_manifest.json new file mode 100644 index 0000000..886904e --- /dev/null +++ b/dist/target-package/bundle_manifest.json @@ -0,0 +1,55 @@ +{ + "artifacts": { + "joinquant": { + "core": "src/quant60/portable_core.py", + "core_sha256": "f9de9f18e4008bb0fc80edb0b21cae6ea06a61828d89e9b7c11057f98225fc19", + "effective_config_sha256": "640980026f397df2e6d0f9e3884b58c7e2343e1be70ca1b629f6111b889ec7cb", + "output": "dist/target-package/joinquant_strategy.py", + "output_sha256": "0586930ddc483f4489f09b562f4d4232b458b703e6a1194af809b707211be8c4", + "wrapper": "platforms/joinquant_strategy.py", + "wrapper_sha256": "0c672b64665b7c1d4409ba288b00d1608e976800af3a4f9a6467767ce4def592" + }, + "qmt_builtin": { + "core": "src/quant60/portable_core.py", + "core_sha256": "f9de9f18e4008bb0fc80edb0b21cae6ea06a61828d89e9b7c11057f98225fc19", + "effective_config_sha256": "ace13da661a2921e6bf75e48a574fd605fb6eef87c0025b89460082326a39d36", + "output": "dist/target-package/qmt_builtin_strategy.py", + "output_sha256": "21f634a9831341fef2d86a6df612d01c79121794a48e525da76df226dc0729ed", + "wrapper": "platforms/qmt_builtin_strategy.py", + "wrapper_sha256": "a1e53dc22ebd9bb01be43675786151606bbed273d8ab883fb933461e379d2727" + } + }, + "baseline_config": "configs/baseline.json", + "baseline_config_sha256": "939a7df386b06bc82869940572c35544e9361913b94d9e03027411e51b1006e1", + "decision_mode": "target_package", + "joinquant_observation": { + "eod_api_policy": "best_effort_get_orders_get_trades", + "eod_chunk_characters": 1800, + "eod_chunk_encoding": "base64-canonical-json", + "events": [ + "INIT", + "TARGET_PACKAGE_NOOP", + "TARGET_PACKAGE_HIT", + "PLATFORM_BINDING_INPUT", + "TARGET_PACKAGE_PLAN", + "SKIPPED_ORDER", + "ORDER_REQUEST", + "ORDER_RETURN", + "EOD_STATUS" + ], + "fail_soft": true, + "prefix": "QUANT60_JOINQUANT_EVIDENCE_V1 ", + "schema_version": "1.0", + "target_price_source": "CURRENT_DATA_DAY_OPEN" + }, + "portable_core_sha256": "f9de9f18e4008bb0fc80edb0b21cae6ea06a61828d89e9b7c11057f98225fc19", + "schema_version": 1, + "target_package_count": 1, + "target_package_ids": [ + "TP-20240311-795dcfba" + ], + "target_package_sha256": [ + "baa15c8156aa6cb54d3775d0cd562613a9d0a4f15b0c39f2a60e0f8b8c6f1f3a" + ], + "target_package_tape_sha256": "5383c376951637b8d4b1ca42fc6db80347abda74c0181c0f70d6c411fb97ea64" +} diff --git a/dist/target-package/joinquant_strategy.py b/dist/target-package/joinquant_strategy.py new file mode 100644 index 0000000..861cce2 --- /dev/null +++ b/dist/target-package/joinquant_strategy.py @@ -0,0 +1,2081 @@ +# coding: utf-8 +"""JoinQuant hosted wrapper. + +Upload the generated bundle, not this source file, to JoinQuant. The bundle +tool replaces the marked import block with the exact portable core source. +""" + +# __PORTABLE_CORE_BUNDLE_START__ +# Inlined by tools/bundle_platforms.py; do not edit this region. +import hashlib +import json +import math +import re + + +_CANONICAL_EXCHANGES = { + "SH": "XSHG", + "SSE": "XSHG", + "XSHG": "XSHG", + "SZ": "XSHE", + "SZE": "XSHE", + "SZSE": "XSHE", + "XSHE": "XSHE", + "BJ": "XBSE", + "BSE": "XBSE", + "XBSE": "XBSE", +} + +_SHORT_EXCHANGES = { + "XSHG": "SH", + "XSHE": "SZ", + "XBSE": "BJ", +} + +_TARGET_PACKAGE_FIELDS = { + "schema_version", + "package_type", + "package_id", + "decision_id", + "release", + "evidence_class", + "model_id", + "signal_as_of", + "next_session", + "model_sha256", + "data_sha256", + "feature_sha256", + "risk_sha256", + "cost_sha256", + "optimizer_sha256", + "config_sha256", + "source_sha256", + "target_weights", + "universe", + "constraints", + "package_sha256", +} + +_TARGET_PACKAGE_HASH_FIELDS = ( + "model_sha256", + "data_sha256", + "feature_sha256", + "risk_sha256", + "cost_sha256", + "optimizer_sha256", + "config_sha256", + "source_sha256", +) + + +class TargetPackageLookupMiss(ValueError, LookupError): + """An already verified tape has no exact requested decision.""" + + +def _sum_numeric(values): + """Sum numerics without trusting a hosted runtime's global ``sum`` name.""" + + total = 0.0 + for value in values: + total += value + return total + + +def _any_true(values): + """Return built-in ``any`` semantics without trusting hosted globals.""" + + for value in values: + if value: + return True + return False + + +def _infer_exchange(code): + if code.startswith(("4", "8")): + return "XBSE" + if code.startswith(("5", "6", "9")): + return "XSHG" + return "XSHE" + + +def _split_symbol(symbol): + if not isinstance(symbol, str): + raise TypeError("symbol must be a string") + value = symbol.strip().upper().replace(" ", "") + if not value: + raise ValueError("symbol must not be empty") + + match = re.match(r"^(\d{6})\.(XSHG|XSHE|XBSE|SH|SZ|BJ)$", value) + if match: + return match.group(1), _CANONICAL_EXCHANGES[match.group(2)] + + match = re.match(r"^(XSHG|XSHE|XBSE|SH|SZ|BJ)[\.:]?(\d{6})$", value) + if match: + return match.group(2), _CANONICAL_EXCHANGES[match.group(1)] + + match = re.match(r"^(\d{6})[\.:](SSE|SZE|SZSE|BSE)$", value) + if match: + return match.group(1), _CANONICAL_EXCHANGES[match.group(2)] + + if re.match(r"^\d{6}$", value): + return value, _infer_exchange(value) + raise ValueError("unsupported A-share symbol: %s" % symbol) + + +def normalize_symbol(symbol, target="canonical"): + """Convert common A-share codes to canonical/JQ, QMT, or Qlib format.""" + + code, exchange = _split_symbol(symbol) + target_name = str(target).strip().lower() + if target_name in ("canonical", "joinquant", "jq"): + return "%s.%s" % (code, exchange) + if target_name in ("qmt", "xtquant"): + return "%s.%s" % (code, _SHORT_EXCHANGES[exchange]) + if target_name == "qlib": + return "%s%s" % (_SHORT_EXCHANGES[exchange], code) + raise ValueError("unsupported target platform: %s" % target) + + +def convert_symbol(symbol, target="canonical"): + """Alias kept as the stable adapter-facing name.""" + + return normalize_symbol(symbol, target) + + +def _canonical_json(value): + """Match ``quant60.ledger.canonical_json`` for plain JSON values.""" + + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def _json_copy(value, name): + try: + return json.loads(_canonical_json(value)) + except (TypeError, ValueError) as error: + raise ValueError("%s must contain finite JSON values only" % name) from error + + +def _canonical_mapping(value, name, value_validator): + if not hasattr(value, "items"): + raise TypeError("%s must be a mapping" % name) + output = {} + for raw_symbol, raw_value in value.items(): + symbol = normalize_symbol(raw_symbol) + if symbol in output: + raise ValueError( + "%s contains duplicate normalized symbol %s" % (name, symbol) + ) + output[symbol] = value_validator(raw_value, "%s.%s" % (name, symbol)) + return dict((symbol, output[symbol]) for symbol in sorted(output)) + + +def _finite_non_negative(value, name): + if isinstance(value, bool): + raise ValueError("%s must be a finite non-negative number" % name) + try: + numeric = float(value) + except (TypeError, ValueError): + raise ValueError("%s must be a finite non-negative number" % name) + if not math.isfinite(numeric) or numeric < 0.0: + raise ValueError("%s must be a finite non-negative number" % name) + return numeric + + +def _non_negative_quantity(value, name): + numeric = _finite_non_negative(value, name) + if numeric != math.floor(numeric): + raise ValueError("%s must be a whole-share quantity" % name) + return int(numeric) + + +def _positive_price(value, name): + numeric = _finite_non_negative(value, name) + if numeric <= 0.0: + raise ValueError("%s must be positive" % name) + return numeric + + +def _optional_non_empty_string(value, name): + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ValueError("%s must be a non-empty string when provided" % name) + return value + + +def _sha256_hex(value, name, allow_none=False): + if value is None and allow_none: + return None + if ( + not isinstance(value, str) + or len(value) != 64 + or _any_true( + character not in "0123456789abcdef" + for character in value + ) + ): + raise ValueError( + "%s must be a 64-character lowercase SHA-256 digest" % name + ) + if value == "0" * 64: + raise ValueError("%s cannot be an all-zero placeholder" % name) + return value + + +def _reject_quantity_fields(value, path="$"): + if hasattr(value, "items"): + for raw_key, child in value.items(): + key = str(raw_key) + normalized = key.lower().replace("-", "_") + if "quantity" in normalized or "quantities" in normalized: + raise ValueError( + "%s.%s: quantities do not belong in TargetPackageV1" + % (path, key) + ) + _reject_quantity_fields(child, "%s.%s" % (path, key)) + elif isinstance(value, list): + for index, child in enumerate(value): + _reject_quantity_fields(child, "%s[%d]" % (path, index)) + + +def _verify_identifier(value, name): + if ( + not isinstance(value, str) + or re.match(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", value) is None + ): + raise ValueError("%s is not a canonical identifier" % name) + return value + + +def _verify_target_package_clock(signal_as_of, next_session): + if ( + not isinstance(signal_as_of, str) + or re.match( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T15:00:00\+08:00$", + signal_as_of, + ) + is None + ): + raise ValueError("signal_as_of must be canonical China-market close") + if ( + not isinstance(next_session, str) + or re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", next_session) is None + ): + raise ValueError("next_session must use canonical YYYY-MM-DD") + if next_session <= signal_as_of[:10]: + raise ValueError("next_session must follow signal_as_of") + + +def _target_package_content_sha256(package): + body = dict(package) + body.pop("package_sha256", None) + _reject_quantity_fields(body) + try: + payload = _canonical_json(body).encode("utf-8") + except (TypeError, ValueError) as error: + raise ValueError( + "target package must contain finite JSON values only" + ) from error + return hashlib.sha256(payload).hexdigest() + + +def verify_target_package( + package, + expected_signal_as_of=None, + expected_next_session=None, + expected_decision_id=None, + expected_package_sha256=None, +): + """Verify the platform-critical subset of a sealed ``TargetPackageV1``. + + This intentionally duplicates a small, Python-3.6-compatible safety + boundary from the richer local validator. Hosted runtimes must verify the + embedded package again instead of trusting the bundling process. + """ + + if not hasattr(package, "items"): + raise TypeError("target package must be a mapping") + candidate = _json_copy(package, "target package") + _reject_quantity_fields(candidate) + fields = set(candidate) + if fields != _TARGET_PACKAGE_FIELDS: + missing = sorted(_TARGET_PACKAGE_FIELDS - fields) + unknown = sorted(fields - _TARGET_PACKAGE_FIELDS) + raise ValueError( + "target package fields mismatch; missing=%s, unknown=%s" + % (missing, unknown) + ) + if candidate["schema_version"] != "1.0": + raise ValueError("unsupported target package schema_version") + if candidate["package_type"] != "TargetPackageV1": + raise ValueError("unsupported target package type") + for name in ( + "package_id", + "decision_id", + "release", + "evidence_class", + "model_id", + ): + _verify_identifier(candidate[name], name) + _verify_target_package_clock( + candidate["signal_as_of"], candidate["next_session"] + ) + for name in _TARGET_PACKAGE_HASH_FIELDS: + _sha256_hex(candidate[name], name) + _sha256_hex(candidate["package_sha256"], "package_sha256") + + raw_weights = candidate["target_weights"] + if not hasattr(raw_weights, "items") or not raw_weights: + raise ValueError("target_weights must be a non-empty mapping") + weights = {} + for raw_symbol, raw_weight in raw_weights.items(): + symbol = normalize_symbol(raw_symbol) + if symbol != raw_symbol: + raise ValueError( + "target_weights symbol must be canonical: %s" % raw_symbol + ) + if symbol in weights: + raise ValueError("duplicate target weight for %s" % symbol) + weight = _finite_non_negative( + raw_weight, "target_weights.%s" % symbol + ) + if weight > 1.0: + raise ValueError("target weight exceeds one for %s" % symbol) + weights[symbol] = weight + gross = math.fsum(weights.values()) + if gross > 1.0 + 1e-12: + raise ValueError("target_weights gross exceeds one") + + universe = candidate["universe"] + if not hasattr(universe, "items") or not universe: + raise ValueError("universe must be a non-empty mapping") + canonical_universe = {} + for raw_symbol, raw_state in universe.items(): + symbol = normalize_symbol(raw_symbol) + if symbol != raw_symbol: + raise ValueError("universe symbol must be canonical: %s" % raw_symbol) + if symbol in canonical_universe: + raise ValueError("duplicate universe symbol %s" % symbol) + if not hasattr(raw_state, "items"): + raise ValueError("universe state must be a mapping for %s" % symbol) + state = raw_state.get("state") + if state not in ( + "openable", + "hold_only", + "sell_only", + "frozen", + "excluded", + ): + raise ValueError("unsupported universe state for %s" % symbol) + canonical_universe[symbol] = state + missing_universe = sorted(set(weights) - set(canonical_universe)) + if missing_universe: + raise ValueError( + "target symbols are absent from universe: %s" % missing_universe + ) + for symbol, weight in weights.items(): + if canonical_universe[symbol] == "excluded" and weight > 1e-12: + raise ValueError("excluded symbol has positive weight: %s" % symbol) + + constraints = candidate["constraints"] + if not hasattr(constraints, "items"): + raise ValueError("constraints must be a mapping") + if constraints.get("long_only") is not True: + raise ValueError("TargetPackageV1 must be long-only") + declared_gross = _finite_non_negative( + constraints.get("actual_gross_weight"), + "constraints.actual_gross_weight", + ) + max_gross = _finite_non_negative( + constraints.get("max_gross_weight"), + "constraints.max_gross_weight", + ) + max_single = _finite_non_negative( + constraints.get("max_single_weight"), + "constraints.max_single_weight", + ) + if abs(declared_gross - gross) > 1e-12: + raise ValueError("declared gross does not equal target_weights gross") + if gross > max_gross + 1e-12 or max_gross > 1.0: + raise ValueError("target_weights exceed max_gross_weight") + if max_single <= 0.0 or max_single > 1.0: + raise ValueError("invalid max_single_weight") + if _any_true( + weight > max_single + 1e-12 for weight in weights.values() + ): + raise ValueError("target_weights exceed max_single_weight") + model_claim = " ".join( + ( + candidate["release"], + candidate["evidence_class"], + ) + ).lower() + if ( + "baseline" in model_claim + and "portable-momentum" in candidate["model_id"].lower() + ): + raise ValueError( + "portable-momentum cannot claim a baseline target package" + ) + + expected_hash = _target_package_content_sha256(candidate) + if candidate["package_sha256"] != expected_hash: + raise ValueError("target package content hash mismatch") + if ( + expected_signal_as_of is not None + and candidate["signal_as_of"] != expected_signal_as_of + ): + raise ValueError("target package signal_as_of does not match request") + if ( + expected_next_session is not None + and candidate["next_session"] != expected_next_session + ): + raise ValueError("target package next_session does not match request") + if ( + expected_decision_id is not None + and candidate["decision_id"] != expected_decision_id + ): + raise ValueError("target package decision_id does not match request") + if expected_package_sha256 is not None: + _sha256_hex( + expected_package_sha256, + "expected_package_sha256", + ) + if candidate["package_sha256"] != expected_package_sha256: + raise ValueError("target package digest does not match request") + return candidate + + +def target_package_tape_sha256(packages): + """Hash the exact ordered canonical JSON package array.""" + + if not isinstance(packages, (list, tuple)): + raise TypeError("target package tape must be a list or tuple") + try: + payload = _canonical_json(list(packages)).encode("utf-8") + except (TypeError, ValueError) as error: + raise ValueError( + "target package tape must contain finite JSON values only" + ) from error + return hashlib.sha256(payload).hexdigest() + + +def lookup_target_package_exact( + packages, + signal_as_of, + next_session, + decision_id=None, + expected_tape_sha256=None, + expected_count=None, +): + """Verify a complete tape, then return one exact dual-clock decision.""" + + if not isinstance(packages, (list, tuple)) or not packages: + raise ValueError("target package tape must be a non-empty list or tuple") + _verify_target_package_clock(signal_as_of, next_session) + decision_id = _optional_non_empty_string(decision_id, "decision_id") + if expected_count is not None: + if ( + isinstance(expected_count, bool) + or not isinstance(expected_count, int) + or expected_count < 0 + ): + raise ValueError("expected_count must be a non-negative integer") + if len(packages) != expected_count: + raise ValueError("target package tape count mismatch") + if expected_tape_sha256 is not None: + _sha256_hex(expected_tape_sha256, "expected_tape_sha256") + if target_package_tape_sha256(packages) != expected_tape_sha256: + raise ValueError("target package tape digest mismatch") + + verified = [] + package_ids = set() + decision_ids = set() + clock_keys = set() + for raw_package in packages: + package = verify_target_package(raw_package) + clock_key = (package["signal_as_of"], package["next_session"]) + if package["package_id"] in package_ids: + raise ValueError("duplicate target package_id") + if package["decision_id"] in decision_ids: + raise ValueError("duplicate target decision_id") + if clock_key in clock_keys: + raise ValueError("duplicate target package clock") + package_ids.add(package["package_id"]) + decision_ids.add(package["decision_id"]) + clock_keys.add(clock_key) + verified.append(package) + + matches = [ + package + for package in verified + if package["signal_as_of"] == signal_as_of + and package["next_session"] == next_session + ] + if not matches: + raise TargetPackageLookupMiss( + "no target package for exact signal_as_of/next_session clock" + ) + if len(matches) != 1: + raise ValueError("ambiguous exact target package decision") + if ( + decision_id is not None + and matches[0]["decision_id"] != decision_id + ): + raise ValueError("exact target package decision_id mismatch") + return _json_copy(matches[0], "target package") + + +def momentum_score(prices, lookback=20, skip=0): + """Return trailing simple momentum, or ``None`` if history is insufficient. + + ``skip=0`` uses the latest completed decision bar. Set ``skip=1`` only + when intentionally implementing a 20-1 convention. A lookback of N needs + N+skip+1 observations. + """ + + lookback = int(lookback) + skip = int(skip) + if lookback <= 0 or skip < 0: + raise ValueError("lookback must be positive and skip non-negative") + values = list(prices) + if len(values) < lookback + skip + 1: + return None + end_index = len(values) - 1 - skip + start_index = end_index - lookback + start = float(values[start_index]) + end = float(values[end_index]) + if ( + start <= 0.0 + or end <= 0.0 + or not math.isfinite(start) + or not math.isfinite(end) + ): + return None + return end / start - 1.0 + + +def rank_momentum(price_history, lookback=20, skip=0, top_n=None): + """Rank ``{symbol: closes}`` by momentum with deterministic tie-breaking.""" + + ranked = [] + for symbol in sorted(price_history): + score = momentum_score(price_history[symbol], lookback, skip) + if score is not None and math.isfinite(score): + ranked.append((normalize_symbol(symbol), score)) + ranked.sort(key=lambda item: (-item[1], item[0])) + if top_n is not None: + ranked = ranked[: max(0, int(top_n))] + return ranked + + +def capped_target_weights( + scores, max_weight=0.20, gross_target=1.0, min_score=0.0 +): + """Long-only score-proportional weights with iterative cap redistribution.""" + + max_weight = float(max_weight) + gross_target = float(gross_target) + min_score = float(min_score) + if not math.isfinite(max_weight) or not 0.0 < max_weight <= 1.0: + raise ValueError("max_weight must be finite and lie in (0, 1]") + if not math.isfinite(gross_target) or not 0.0 <= gross_target <= 1.0: + raise ValueError("gross_target must be finite and lie in [0, 1]") + if not math.isfinite(min_score): + raise ValueError("min_score must be finite") + + if hasattr(scores, "items"): + items = list(scores.items()) + else: + items = list(scores) + normalized = {} + for symbol, raw_score in items: + name = normalize_symbol(symbol) + score = float(raw_score) + if not math.isfinite(score): + raise ValueError("score must be finite for %s" % name) + normalized[name] = score + + weights = dict((symbol, 0.0) for symbol in normalized) + active = dict( + (symbol, score) + for symbol, score in normalized.items() + if score > min_score and score > 0.0 + ) + remaining = min(gross_target, len(active) * max_weight) + + while active and remaining > 1e-15: + score_sum = _sum_numeric(active.values()) + if score_sum <= 0.0: + break + capped = [] + for symbol in sorted(active): + proposed = remaining * active[symbol] / score_sum + if proposed >= max_weight - 1e-15: + weights[symbol] = max_weight + capped.append(symbol) + if not capped: + for symbol in sorted(active): + weights[symbol] = remaining * active[symbol] / score_sum + remaining = 0.0 + break + for symbol in capped: + active.pop(symbol) + remaining -= max_weight + remaining = max(0.0, remaining) + return weights + + +def round_board_lot(quantity, lot_size=100): + """Round an absolute long position down to a board-lot quantity.""" + + lot_size = int(lot_size) + if lot_size <= 0: + raise ValueError("lot_size must be positive") + if not math.isfinite(float(quantity)): + raise ValueError("quantity must be finite") + quantity = max(0, int(quantity)) + return quantity // lot_size * lot_size + + +def _is_star_market(symbol): + code, exchange = _split_symbol(symbol) + return exchange == "XSHG" and code.startswith(("688", "689")) + + +def target_quantities( + weights, prices, equity, lot_size=100, cash_buffer=0.02 +): + """Convert target weights to affordable board-lot long quantities.""" + + equity = float(equity) + cash_buffer = float(cash_buffer) + if ( + not math.isfinite(equity) + or equity < 0.0 + or not math.isfinite(cash_buffer) + or not 0.0 <= cash_buffer < 1.0 + ): + raise ValueError("invalid equity or cash_buffer") + positive_weights = [] + for raw_symbol, raw_weight in weights.items(): + weight = float(raw_weight) + if not math.isfinite(weight): + raise ValueError( + "weight must be finite for %s" % normalize_symbol(raw_symbol) + ) + positive_weights.append(max(0.0, weight)) + requested_gross = _sum_numeric(positive_weights) + gross_ceiling = 1.0 - cash_buffer + scale = ( + min(1.0, gross_ceiling / requested_gross) + if requested_gross > 0.0 + else 1.0 + ) + result = {} + for raw_symbol in sorted(weights): + symbol = normalize_symbol(raw_symbol) + weight = float(weights[raw_symbol]) + if not math.isfinite(weight): + raise ValueError("weight must be finite for %s" % symbol) + weight = max(0.0, weight) + price = prices.get(raw_symbol) + if price is None: + price = prices.get(symbol) + if price is None: + raise KeyError("missing price for %s" % symbol) + price = float(price) + if price <= 0.0 or not math.isfinite(price): + raise ValueError("invalid price for %s" % symbol) + # Weights are absolute equity weights. cash_buffer is a ceiling, not + # a second multiplicative haircut: gross=.95 and buffer=.02 stays .95. + budget = equity * weight * scale + quantity = round_board_lot(budget / price, lot_size) + # STAR Market auction orders must contain at least 200 shares. Keep + # the portable planner conservative and on the configured 100-share + # grid even though quantities above 200 may increment by one share. + if _is_star_market(symbol) and 0 < quantity < 200: + quantity = 0 + result[symbol] = quantity + return result + + +def order_deltas( + targets, current, sellable=None, lot_size=100 +): + """Return signed board-lot orders; negative sells respect sellable quantity.""" + + canonical_targets = {} + canonical_current = {} + canonical_sellable = {} + for symbol, quantity in targets.items(): + canonical_targets[normalize_symbol(symbol)] = max(0, int(quantity)) + for symbol, quantity in current.items(): + canonical_current[normalize_symbol(symbol)] = max(0, int(quantity)) + if sellable is not None: + for symbol, quantity in sellable.items(): + canonical_sellable[normalize_symbol(symbol)] = max(0, int(quantity)) + + result = {} + symbols = sorted(set(canonical_targets) | set(canonical_current)) + for symbol in symbols: + target = round_board_lot(canonical_targets.get(symbol, 0), lot_size) + if _is_star_market(symbol) and 0 < target < 200: + target = 0 + held = canonical_current.get(symbol, 0) + delta = target - held + if delta > 0: + delta = round_board_lot(delta, lot_size) + if _is_star_market(symbol) and 0 < delta < 200: + delta = 0 + elif delta < 0: + available = held + if sellable is not None: + available = min(held, canonical_sellable.get(symbol, 0)) + requested = min(-delta, available) + # A complete liquidation may submit the entire odd-lot balance. + # This also covers the STAR exception for a remaining balance + # below 200 shares. Partial orders stay on the configured grid. + full_liquidation = ( + target == 0 + and requested == held + and available >= held + ) + if full_liquidation: + delta = -held + else: + delta = -round_board_lot(requested, lot_size) + if _is_star_market(symbol) and 0 < -delta < 200: + delta = 0 + if delta: + result[symbol] = delta + return result + + +def build_target_weight_plan( + target_weights, + prices, + current, + sellable, + equity, + cash_buffer=0.02, + lot_size=100, + decision_id=None, + package_sha256=None, +): + """Bind canonical post-risk weights to one account without recomputing Alpha. + + ``prices`` must cover exactly every target or currently managed symbol. + Missing prices fail closed even for a held symbol whose desired weight is + zero. A missing ``sellable`` entry is safe and means zero sellable shares. + """ + + weights = _canonical_mapping( + target_weights, + "target_weights", + _finite_non_negative, + ) + gross = math.fsum(weights.values()) + if _any_true(weight > 1.0 for weight in weights.values()): + raise ValueError("target weight must not exceed one") + if gross > 1.0 + 1e-12: + raise ValueError("target weight gross must not exceed one") + + canonical_current = _canonical_mapping( + current, + "current", + _non_negative_quantity, + ) + canonical_sellable = _canonical_mapping( + sellable, + "sellable", + _non_negative_quantity, + ) + unknown_sellable = sorted(set(canonical_sellable) - set(canonical_current)) + if unknown_sellable: + raise ValueError( + "sellable contains symbols absent from current: %s" + % unknown_sellable + ) + for symbol, quantity in canonical_sellable.items(): + if quantity > canonical_current[symbol]: + raise ValueError( + "sellable exceeds current quantity for %s" % symbol + ) + + canonical_prices = _canonical_mapping( + prices, + "prices", + _positive_price, + ) + managed_symbols = set(weights) | set(canonical_current) + price_symbols = set(canonical_prices) + missing_prices = sorted(managed_symbols - price_symbols) + extra_prices = sorted(price_symbols - managed_symbols) + if missing_prices or extra_prices: + if missing_prices: + raise KeyError( + "prices do not cover managed symbols: missing=%s" + % missing_prices + ) + raise ValueError( + "prices contain unmanaged symbols: extra=%s" % extra_prices + ) + + complete_weights = dict( + (symbol, weights.get(symbol, 0.0)) + for symbol in sorted(managed_symbols) + ) + targets = target_quantities( + complete_weights, + canonical_prices, + equity, + lot_size, + cash_buffer, + ) + orders = order_deltas( + targets, + canonical_current, + canonical_sellable, + lot_size, + ) + decision_id = _optional_non_empty_string(decision_id, "decision_id") + package_sha256 = _sha256_hex( + package_sha256, + "package_sha256", + allow_none=True, + ) + return { + "decision_id": decision_id, + "package_sha256": package_sha256, + "weights": complete_weights, + "targets": targets, + "orders": orders, + } + + +def build_rebalance_plan( + price_history, + current, + sellable, + equity, + lookback=20, + skip=0, + top_n=10, + max_weight=0.20, + gross_target=1.0, + cash_buffer=0.02, + lot_size=100, +): + """Build the complete portable signal -> weight -> quantity -> order plan.""" + + ranked = rank_momentum(price_history, lookback, skip, top_n) + scores = dict(ranked) + weights = capped_target_weights(scores, max_weight, gross_target, 0.0) + for raw_symbol in price_history: + symbol = normalize_symbol(raw_symbol) + if symbol not in scores: + scores[symbol] = momentum_score( + price_history[raw_symbol], lookback, skip + ) + if symbol not in weights: + weights[symbol] = 0.0 + prices = {} + for raw_symbol, history in price_history.items(): + if not history: + continue + prices[normalize_symbol(raw_symbol)] = history[-1] + targets = target_quantities( + weights, prices, equity, lot_size, cash_buffer + ) + orders = order_deltas(targets, current, sellable, lot_size) + return { + "scores": scores, + "weights": weights, + "targets": targets, + "orders": orders, + } + + +__all__ = [ + "build_rebalance_plan", + "build_target_weight_plan", + "capped_target_weights", + "convert_symbol", + "lookup_target_package_exact", + "momentum_score", + "normalize_symbol", + "order_deltas", + "rank_momentum", + "round_board_lot", + "TargetPackageLookupMiss", + "target_package_tape_sha256", + "target_quantities", + "verify_target_package", +] +# __PORTABLE_CORE_BUNDLE_END__ + +import base64 as _q60_base64 +import json as _q60_json +import math + + +class PriceHistoryUnavailable(RuntimeError): + pass + + +class UnmanagedPositionError(RuntimeError): + pass + + +class ClockStateError(RuntimeError): + pass + + +# __BASELINE_CONFIG_START__ +# Generated from configs/baseline.json; do not edit this region. +CONFIG = {'decision_mode': 'target_package', + 'target_package_count': 1, + 'target_package_tape_sha256': '5383c376951637b8d4b1ca42fc6db80347abda74c0181c0f70d6c411fb97ea64', + 'universe_mode': 'fixed', + 'index_symbol': '000905.XSHG', + 'qmt_sector_name': '\u4e2d\u8bc1500', + 'dedicated_account_required': True, + 'exclude_st': True, + 'universe': ['000001.XSHE', + '000333.XSHE', + '000651.XSHE', + '002415.XSHE', + '002594.XSHE', + '300059.XSHE', + '300750.XSHE', + '600000.XSHG', + '600519.XSHG', + '601012.XSHG', + '601318.XSHG', + '688981.XSHG'], + 'lookback': 20, + 'skip': 0, + 'top_n': 20, + 'max_weight': 0.05, + 'gross_target': 0.95, + 'cash_buffer': 0.02, + 'lot_size': 100, + 'participation_rate': 0.1, + 'slippage_bps': 2.0, + 'commission_rate': 0.0002, + 'minimum_commission': 5.0, + 'stamp_duty_rate': 0.0005, + 'transfer_fee_rate': 1e-05, + 'rebalance_schedule': 'weekly_first_close'} +# __BASELINE_CONFIG_END__ + +# __TARGET_PACKAGE_TAPE_START__ +# Generated by tools/bundle_platforms.py; do not edit this region. +TARGET_PACKAGES = [{'config_sha256': 'e4edf1eb730597e220266c1f8007ddb5e1ab4d26a490cd10738823527d030470', + 'constraints': {'actual_gross_weight': 0.4, + 'constraint_state': 'FEASIBLE', + 'long_only': True, + 'max_gross_weight': 0.9, + 'max_one_way_turnover': 0.45, + 'max_single_weight': 0.1}, + 'cost_sha256': 'e2a2d8e6d3829c105a9f6c9ec80d9df18c2dee0553aafeb761abb79d378769a7', + 'data_sha256': 'f5b3e66b0a94eeccb44a114bf3a0bbfad44b5d5da9d5b698c81e83a3e0007d03', + 'decision_id': 'RD-20240311', + 'evidence_class': 'synthetic-research', + 'feature_sha256': '24e671cebc35afe7497b69c59d48bb5bad9401e30c1607f0fc41a69f59082aa1', + 'model_id': 'deterministic-ridge-alpha-5', + 'model_sha256': 'e14e8df7e2f86e6b3178a6e500d1d2c0427bb02d187b13cb4ec672bbf5f9f334', + 'next_session': '2024-03-12', + 'optimizer_sha256': 'b6aba6ab22fc375e89e4bfbdffb10c7261a2fae01b0183265caacb589643c31e', + 'package_id': 'TP-20240311-795dcfba', + 'package_sha256': 'baa15c8156aa6cb54d3775d0cd562613a9d0a4f15b0c39f2a60e0f8b8c6f1f3a', + 'package_type': 'TargetPackageV1', + 'release': 'quant60-research-candidate-v1', + 'risk_sha256': 'a3d95aa6cd569d2452856b148df053b040b2acc2e6b993bbd1072b48b46cde5a', + 'schema_version': '1.0', + 'signal_as_of': '2024-03-11T15:00:00+08:00', + 'source_sha256': '86c1e0514f608b2012b4084dbc947eada90e8015fccbdf8ae13ae99340ede178', + 'target_weights': {'000001.XSHE': 0.0, + '000333.XSHE': 0.0, + '000651.XSHE': 0.0, + '002415.XSHE': 0.0, + '002594.XSHE': 0.0, + '300059.XSHE': 0.0, + '300750.XSHE': 0.0, + '600000.XSHG': 0.0, + '600519.XSHG': 0.1, + '601012.XSHG': 0.1, + '601318.XSHG': 0.1, + '688981.XSHG': 0.1}, + 'universe': {'000001.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '000333.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '000651.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '002415.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '002594.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '300059.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '300750.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '600000.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '600519.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '601012.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '601318.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '688981.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'}}}] +# __TARGET_PACKAGE_TAPE_END__ + + +MOMENTUM_SMOKE_MODE = "portable_momentum_smoke" +TARGET_PACKAGE_MODE = "target_package" +EVIDENCE_PREFIX = "QUANT60_JOINQUANT_EVIDENCE_V1 " +EVIDENCE_CHUNK_CHARACTERS = 1800 +TARGET_PRICE_SOURCE = "CURRENT_DATA_DAY_OPEN" + + +def _evidence_value(value, depth=0): + """Return stable JSON data without allowing observation to stop trading.""" + if depth > 8: + return "" + if value is None or isinstance(value, (bool, int, str)): + return value + if isinstance(value, float): + return value if math.isfinite(value) else None + if isinstance(value, dict): + return { + str(key): _evidence_value(item, depth + 1) + for key, item in sorted( + value.items(), + key=lambda pair: str(pair[0]), + ) + } + if isinstance(value, (list, tuple)): + return [_evidence_value(item, depth + 1) for item in value] + isoformat = getattr(value, "isoformat", None) + if callable(isoformat): + try: + return str(isoformat()) + except Exception: + return "" + try: + return str(value) + except Exception: + return "" + + +def _evidence_context_time(context): + try: + return _evidence_value(getattr(context, "current_dt", None)) + except Exception: + return None + + +def _emit_evidence( + event, + payload=None, + context=None, + chunk_index=None, + chunk_count=None, +): + """Print one prefixed canonical JSON record and always fail soft.""" + try: + sequence = int( + getattr(g, "quant60_evidence_sequence", 0) or 0 + ) + 1 + g.quant60_evidence_sequence = sequence + record = { + "context_time": _evidence_context_time(context), + "decision_mode": getattr( + g, + "quant60_decision_mode", + CONFIG.get("decision_mode"), + ), + "event": str(event), + "payload": _evidence_value(payload or {}), + "schema_version": "1.0", + "sequence": sequence, + "source": "joinquant_hosted", + } + if chunk_index is not None or chunk_count is not None: + if ( + type(chunk_index) is not int + or type(chunk_count) is not int + or chunk_index <= 0 + or chunk_count <= 0 + or chunk_index > chunk_count + ): + return None + record["chunk_index"] = chunk_index + record["chunk_count"] = chunk_count + encoded = _q60_json.dumps( + record, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + print(EVIDENCE_PREFIX + encoded) + except Exception: + # Evidence is observational. A logging/serialization failure must + # never alter an already verified decision or hosted order path. + return None + return record + + +def _emit_chunked_evidence(event, payload, context=None): + """Emit reconstructable base64 chunks of one canonical JSON payload.""" + try: + canonical = _q60_json.dumps( + _evidence_value(payload), + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("ascii") + encoded = _q60_base64.b64encode(canonical).decode("ascii") + chunks = [ + encoded[index : index + EVIDENCE_CHUNK_CHARACTERS] + for index in range(0, len(encoded), EVIDENCE_CHUNK_CHARACTERS) + ] or [""] + count = len(chunks) + for index, content in enumerate(chunks, 1): + _emit_evidence( + event, + { + "chunk_encoding": "base64-canonical-json", + "content": content, + "encoded_length": len(encoded), + }, + context, + chunk_index=index, + chunk_count=count, + ) + except Exception: + return None + return count + + +def _observe_object(value, fields): + if value is None: + return None + output = {"object_type": type(value).__name__} + for name in fields: + try: + item = getattr(value, name) + except Exception: + continue + if callable(item): + continue + output[name] = _evidence_value(item) + return output + + +def _observe_order(value): + return _observe_object( + value, + ( + "order_id", + "add_time", + "security", + "amount", + "filled", + "price", + "avg_cost", + "side", + "action", + "status", + "target_quantity", + ), + ) + + +def _observe_trade(value): + return _observe_object( + value, + ( + "trade_id", + "order_id", + "time", + "security", + "amount", + "price", + ), + ) + + +def _observe_platform_collection(api_name, observer): + """Read an optional hosted collection without assuming API availability.""" + function = globals().get(api_name) + if not callable(function): + return { + "available": False, + "reason": "API_UNAVAILABLE", + } + try: + raw = function() + except Exception as exc: + return { + "available": False, + "error_type": type(exc).__name__, + "reason": "API_CALL_FAILED", + } + if hasattr(raw, "items"): + items = [ + { + "key": str(key), + "value": observer(value), + } + for key, value in sorted( + raw.items(), + key=lambda pair: str(pair[0]), + ) + ] + elif isinstance(raw, (list, tuple)): + items = [observer(value) for value in raw] + elif raw is None: + items = [] + else: + items = [observer(raw)] + return { + "available": True, + "count": len(items), + "items": items, + } + + +def _observe_portfolio(context): + try: + portfolio = context.portfolio + except Exception as exc: + return { + "available": False, + "error_type": type(exc).__name__, + } + output = { + "available": True, + "fields": {}, + "positions": [], + } + for name in ( + "total_value", + "cash", + "available_cash", + "positions_value", + "returns", + ): + try: + value = getattr(portfolio, name) + except Exception: + continue + output["fields"][name] = _evidence_value(value) + try: + positions = getattr(portfolio, "positions", {}) + if hasattr(positions, "items"): + for symbol, position in sorted( + positions.items(), + key=lambda pair: str(pair[0]), + ): + item = _observe_object( + position, + ( + "total_amount", + "closeable_amount", + "price", + "avg_cost", + "value", + ), + ) + item["symbol"] = str(symbol) + output["positions"].append(item) + except Exception as exc: + output["positions_error_type"] = type(exc).__name__ + return output + + +def _plan_evidence(plan): + if not isinstance(plan, dict): + return None + return { + "decision_mode": plan.get("decision_mode"), + "execution_contract": plan.get("execution_contract"), + "lineage": plan.get("lineage"), + "orders": plan.get("orders"), + "targets": plan.get("targets"), + "universe": plan.get("universe"), + "weights": plan.get("weights"), + } + + +def _validated_target_packages(config): + mode = config.get("decision_mode") + if mode not in (MOMENTUM_SMOKE_MODE, TARGET_PACKAGE_MODE): + raise ValueError("unsupported decision_mode") + if not isinstance(TARGET_PACKAGES, list): + raise ValueError("TARGET_PACKAGES must be a list") + expected_count = config.get("target_package_count") + expected_sha256 = config.get("target_package_tape_sha256") + if mode == MOMENTUM_SMOKE_MODE: + if ( + type(expected_count) is not int + or expected_count != 0 + or expected_sha256 is not None + or TARGET_PACKAGES + ): + raise ValueError( + "portable_momentum_smoke cannot carry target packages" + ) + return [] + if type(expected_count) is not int or expected_count <= 0: + raise ValueError( + "target_package mode requires a positive target_package_count" + ) + if expected_count != len(TARGET_PACKAGES): + raise ValueError("target package count mismatch") + actual_sha256 = target_package_tape_sha256(TARGET_PACKAGES) + if expected_sha256 != actual_sha256: + raise ValueError("target package tape sha256 mismatch") + verified = [] + package_ids = set() + decision_ids = set() + clocks = set() + for raw_package in TARGET_PACKAGES: + package = verify_target_package(raw_package) + package_id = package["package_id"] + decision_id = package["decision_id"] + clock = (package["signal_as_of"], package["next_session"]) + if ( + package_id in package_ids + or decision_id in decision_ids + or clock in clocks + ): + raise ValueError("duplicate target package decision identity") + package_ids.add(package_id) + decision_ids.add(decision_id) + clocks.add(clock) + verified.append(package) + return verified + + +def initialize(context): + """Configure the selected daily-open decision mode.""" + set_option("avoid_future_data", True) + set_option("use_real_price", True) + g.quant60_evidence_sequence = 0 + g.quant60_order_observations = [] + g.quant60_config = dict(CONFIG) + g.quant60_target_packages = _validated_target_packages( + g.quant60_config + ) + g.quant60_decision_mode = g.quant60_config["decision_mode"] + if g.quant60_config["decision_mode"] == MOMENTUM_SMOKE_MODE: + if g.quant60_config.get("rebalance_schedule") != "weekly_first_close": + raise ValueError("unsupported rebalance_schedule") + if g.quant60_config.get("universe_mode") not in ("pit_index", "fixed"): + raise ValueError("unsupported universe_mode") + if ( + g.quant60_config.get("universe_mode") == "pit_index" + and not g.quant60_config.get("dedicated_account_required") + ): + raise ValueError("pit_index mode requires a dedicated account") + set_benchmark(g.quant60_config["index_symbol"]) + commission = ( + float(g.quant60_config["commission_rate"]) + + float(g.quant60_config["transfer_fee_rate"]) + ) + set_order_cost( + OrderCost( + open_tax=0.0, + close_tax=float(g.quant60_config["stamp_duty_rate"]), + open_commission=commission, + close_commission=commission, + close_today_commission=0.0, + min_commission=float( + g.quant60_config["minimum_commission"] + ), + ), + type="stock", + ) + # JoinQuant applies half of PriceRelatedSlippage on each side. The local + # slippage_bps setting is one-sided, hence the explicit factor of two. + set_slippage( + PriceRelatedSlippage( + 2.0 * float(g.quant60_config["slippage_bps"]) / 10000.0 + ), + type="stock", + ) + set_option( + "order_volume_ratio", + float(g.quant60_config["participation_rate"]), + ) + g.quant60_last_plan = None + g.quant60_last_universe = None + g.quant60_last_universe_as_of = None + g.quant60_skipped_orders = [] + g.quant60_pending_first_session = None + g.quant60_last_callback_date = None + run_daily(rebalance, time="open") + _emit_evidence( + "INIT", + { + "benchmark": g.quant60_config["index_symbol"], + "package_count": len(g.quant60_target_packages), + "package_ids": [ + package["package_id"] + for package in g.quant60_target_packages + ], + "rebalance_callback": "run_daily_open", + "target_package_tape_sha256": g.quant60_config.get( + "target_package_tape_sha256" + ), + }, + context, + ) + + +def _decision_mode(): + configured = g.quant60_config.get("decision_mode") + frozen = getattr(g, "quant60_decision_mode", configured) + if configured != frozen: + raise ValueError("decision_mode cannot change after initialize") + return frozen + + +def _close_history(symbol, count): + raw = attribute_history( + symbol, + count, + unit="1d", + fields=["close"], + skip_paused=False, + df=False, + fq="pre", + ) + values = list(raw.get("close", [])) if hasattr(raw, "get") else [] + if len(values) != int(count): + raise PriceHistoryUnavailable( + "%s needs %d completed daily closes, got %d" + % (symbol, int(count), len(values)) + ) + output = [] + for value in values: + try: + number = float(value) + except (TypeError, ValueError): + raise PriceHistoryUnavailable( + "%s contains a missing/non-numeric close" % symbol + ) + if not math.isfinite(number) or number <= 0.0: + raise PriceHistoryUnavailable( + "%s contains a missing/non-positive close" % symbol + ) + output.append(number) + return output + + +def _close_histories(symbols, count): + raw = history( + int(count), + unit="1d", + field="close", + security_list=list(symbols), + df=False, + skip_paused=False, + fq="pre", + ) + if not hasattr(raw, "get"): + raise PriceHistoryUnavailable("history returned an invalid payload") + output = {} + for symbol in symbols: + values = list(raw.get(symbol, [])) + if len(values) != int(count): + raise PriceHistoryUnavailable( + "%s needs %d completed daily closes, got %d" + % (symbol, int(count), len(values)) + ) + clean = [] + for value in values: + try: + number = float(value) + except (TypeError, ValueError): + raise PriceHistoryUnavailable( + "%s contains a missing/non-numeric close" % symbol + ) + if not math.isfinite(number) or number <= 0.0: + raise PriceHistoryUnavailable( + "%s contains a missing/non-positive close" % symbol + ) + clean.append(number) + output[convert_symbol(symbol, "canonical")] = clean + return output + + +def _decision_universe(context): + config = g.quant60_config + if config.get("universe_mode") == "fixed": + values = list(config["universe"]) + else: + as_of = _as_date( + getattr(context, "previous_date", None), + "previous_date", + ) + values = get_index_stocks( + config["index_symbol"], + date=as_of, + ) + if not values: + raise PriceHistoryUnavailable( + "no PIT index members for %s on %s" + % (config["index_symbol"], as_of.isoformat()) + ) + g.quant60_last_universe_as_of = as_of.isoformat() + normalized = sorted( + set(convert_symbol(symbol, "joinquant") for symbol in values) + ) + if not normalized: + raise PriceHistoryUnavailable("decision universe is empty") + g.quant60_last_universe = list(normalized) + return normalized + + +def _filter_st_universe(context, symbols): + if not g.quant60_config.get("exclude_st", True): + return list(symbols) + as_of = _as_date( + getattr(context, "previous_date", None), + "previous_date", + ) + raw = get_extras( + "is_st", + list(symbols), + end_date=as_of, + count=1, + df=False, + ) + if not hasattr(raw, "get"): + raise PriceHistoryUnavailable( + "get_extras is_st returned an invalid payload" + ) + output = [] + for symbol in symbols: + values = list(raw.get(symbol, [])) + if len(values) != 1: + raise PriceHistoryUnavailable( + "%s needs one PIT is_st value on %s" + % (symbol, as_of.isoformat()) + ) + try: + numeric = float(values[0]) + except (TypeError, ValueError): + raise PriceHistoryUnavailable( + "%s contains a non-boolean PIT is_st value" % symbol + ) + if not math.isfinite(numeric) or numeric not in (0.0, 1.0): + raise PriceHistoryUnavailable( + "%s contains an invalid PIT is_st value" % symbol + ) + if not bool(numeric): + output.append(symbol) + if not output: + raise PriceHistoryUnavailable( + "PIT ST exclusion removed the entire decision universe" + ) + g.quant60_last_universe = list(output) + return output + + +def _position_amount(position, name, default=0): + value = getattr(position, name, default) + return int(value or 0) + + +def _snapshot_portfolio(context, managed): + current = {} + sellable = {} + managed = set(convert_symbol(symbol, "canonical") for symbol in managed) + dynamic = ( + _decision_mode() == MOMENTUM_SMOKE_MODE + and g.quant60_config.get("universe_mode") == "pit_index" + ) + positions = getattr(context.portfolio, "positions", {}) + for symbol, position in positions.items(): + canonical = convert_symbol(str(symbol), "canonical") + quantity = _position_amount(position, "total_amount") + if quantity and canonical not in managed and not dynamic: + raise UnmanagedPositionError( + "account contains unmanaged position %s; use a dedicated " + "strategy account or reconcile ownership before trading" % canonical + ) + if canonical not in managed and not dynamic: + continue + current[canonical] = quantity + sellable[canonical] = _position_amount( + position, "closeable_amount", current[canonical] + ) + equity = float(getattr(context.portfolio, "total_value", 0.0) or 0.0) + return current, sellable, equity + + +def _is_star_board(symbol): + code = str(symbol).split(".", 1)[0] + return code.startswith("688") or code.startswith("689") + + +def _order_request(symbol, delta): + """Build a fail-closed hosted order request from current market data. + + JoinQuant rejects an unprotected market order for STAR Market securities. + A limit at the exchange daily bound supplies equivalent worst-price + protection while keeping the rebalance target expressed in exact shares. + """ + try: + item = get_current_data()[symbol] + except Exception as exc: + g.quant60_skipped_orders.append( + {"symbol": symbol, "reason": "current_data_unavailable", "detail": str(exc)} + ) + return None + if bool(getattr(item, "paused", False)): + g.quant60_skipped_orders.append( + {"symbol": symbol, "reason": "paused"} + ) + return None + if not _is_star_board(symbol): + return {"style": None} + + field = "high_limit" if int(delta) > 0 else "low_limit" + raw_price = getattr(item, field, None) + try: + protection_price = float(raw_price) + except (TypeError, ValueError): + protection_price = float("nan") + if ( + not math.isfinite(protection_price) + or protection_price <= 0.0 + or protection_price >= 10000.0 + ): + g.quant60_skipped_orders.append( + { + "symbol": symbol, + "reason": "star_protection_price_invalid", + "detail": field, + } + ) + return None + try: + style = LimitOrderStyle(protection_price) + except Exception as exc: + g.quant60_skipped_orders.append( + { + "symbol": symbol, + "reason": "star_protection_style_unavailable", + "detail": str(exc), + } + ) + return None + return {"style": style} + + +def _target_package_for_open(context): + current_date = _as_date(getattr(context, "current_dt", None), "current_dt") + previous_date = _as_date( + getattr(context, "previous_date", None), + "previous_date", + ) + signal_as_of = ( + previous_date.isoformat() + "T15:00:00+08:00" + ) + next_session = current_date.isoformat() + config = g.quant60_config + packages = _validated_target_packages(config) + matches = [ + package + for package in packages + if package["signal_as_of"] == signal_as_of + and package["next_session"] == next_session + ] + if not matches: + _emit_evidence( + "TARGET_PACKAGE_NOOP", + { + "fallback_invoked": False, + "next_session": next_session, + "orders_submitted": 0, + "package_count": len(packages), + "reason": "NO_EXACT_CLOCK_MATCH", + "signal_as_of": signal_as_of, + "target_package_tape_sha256": config[ + "target_package_tape_sha256" + ], + }, + context, + ) + return None + if len(matches) != 1: + raise ValueError("ambiguous target package clock") + package = lookup_target_package_exact( + packages, + signal_as_of, + next_session, + decision_id=matches[0]["decision_id"], + expected_tape_sha256=config["target_package_tape_sha256"], + expected_count=config["target_package_count"], + ) + _emit_evidence( + "TARGET_PACKAGE_HIT", + { + "lineage": _target_lineage(package), + "target_package_tape_sha256": config[ + "target_package_tape_sha256" + ], + }, + context, + ) + return package + + +def _target_prices(symbols): + try: + current_data = get_current_data() + except Exception as exc: + raise PriceHistoryUnavailable( + "current_data is unavailable: %s" % exc + ) + prices = {} + for canonical in sorted(symbols): + symbol = convert_symbol(canonical, "joinquant") + try: + raw_price = getattr(current_data[symbol], "day_open") + price = float(raw_price) + except (KeyError, AttributeError, TypeError, ValueError): + raise PriceHistoryUnavailable( + "%s has no usable current day_open" % symbol + ) + if not math.isfinite(price) or price <= 0.0: + raise PriceHistoryUnavailable( + "%s has no usable current day_open" % symbol + ) + prices[canonical] = price + return prices + + +def _target_lineage(package): + return { + "package_id": package["package_id"], + "package_sha256": package["package_sha256"], + "decision_id": package["decision_id"], + "signal_as_of": package["signal_as_of"], + "next_session": package["next_session"], + "release": package["release"], + "model_id": package["model_id"], + } + + +def _compute_target_package_plan(context): + package = _target_package_for_open(context) + if package is None: + return None + managed = sorted(package["universe"]) + current, sellable, equity = _snapshot_portfolio(context, managed) + if equity <= 0: + raise ValueError("portfolio.total_value must be positive") + prices = _target_prices( + set(package["target_weights"]) | set(current) + ) + _emit_evidence( + "PLATFORM_BINDING_INPUT", + { + "current": current, + "equity": equity, + "lineage": _target_lineage(package), + "price_source": TARGET_PRICE_SOURCE, + "prices": prices, + "sellable": sellable, + }, + context, + ) + plan = build_target_weight_plan( + target_weights=package["target_weights"], + prices=prices, + current=current, + sellable=sellable, + equity=equity, + cash_buffer=g.quant60_config["cash_buffer"], + lot_size=g.quant60_config["lot_size"], + decision_id=package["decision_id"], + package_sha256=package["package_sha256"], + ) + plan["decision_mode"] = TARGET_PACKAGE_MODE + plan["lineage"] = _target_lineage(package) + plan["universe"] = package["universe"] + plan["execution_contract"] = { + "signal_price_source": TARGET_PRICE_SOURCE, + "submit_trigger": "DECLARED_NEXT_SESSION_OPEN_CALLBACK", + "declared_next_session": package["next_session"], + "real_platform_observed": False, + } + _emit_evidence( + "TARGET_PACKAGE_PLAN", + _plan_evidence(plan), + context, + ) + return plan + + +def compute_plan(context): + """Return the selected portable plan without submitting orders.""" + if _decision_mode() == TARGET_PACKAGE_MODE: + return _compute_target_package_plan(context) + config = g.quant60_config + required = int(config["lookback"]) + int(config["skip"]) + 1 + platform_universe = _decision_universe(context) + platform_universe = _filter_st_universe( + context, + platform_universe, + ) + price_history = _close_histories(platform_universe, required) + + current, sellable, equity = _snapshot_portfolio( + context, + platform_universe, + ) + if equity <= 0: + raise ValueError("portfolio.total_value must be positive") + plan = build_rebalance_plan( + price_history=price_history, + current=current, + sellable=sellable, + equity=equity, + lookback=config["lookback"], + skip=config["skip"], + top_n=config["top_n"], + max_weight=config["max_weight"], + gross_target=config["gross_target"], + cash_buffer=config["cash_buffer"], + lot_size=config["lot_size"], + ) + return plan + + +def _submit_plan(context, plan, managed): + """Submit a portable plan's exact executable share deltas.""" + if plan is None: + return None + observe_target_package = ( + plan.get("decision_mode") == TARGET_PACKAGE_MODE + ) + order_deltas = plan["orders"] + current, _, unused_equity = _snapshot_portfolio( + context, + managed, + ) + del unused_equity + all_symbols = set(current) + all_symbols.update(order_deltas) + + # Exits first. The requested target is current + executable delta, not the + # unconstrained portfolio target; this preserves T+1 sellable caps and odd + # lots from the portable plan. + for canonical in sorted( + all_symbols, + key=lambda item: ( + int(order_deltas.get(item, 0)) > 0, + item, + ), + ): + delta = int(order_deltas.get(canonical, 0)) + if delta == 0: + continue + platform_symbol = convert_symbol(canonical, "joinquant") + skipped_before = len(g.quant60_skipped_orders) + request = _order_request(platform_symbol, delta) + if request is None: + skipped = g.quant60_skipped_orders[skipped_before:] + if not skipped: + skipped = [ + { + "symbol": platform_symbol, + "reason": "ORDER_REQUEST_NOT_BUILT", + } + ] + if observe_target_package: + for item in skipped: + _emit_evidence( + "SKIPPED_ORDER", + { + "canonical_symbol": canonical, + "delta": delta, + "lineage": plan.get("lineage"), + "skip": item, + }, + context, + ) + continue + target = int(current.get(canonical, 0)) + delta + request_evidence = { + "canonical_symbol": canonical, + "current_quantity": int(current.get(canonical, 0)), + "delta": delta, + "lineage": plan.get("lineage"), + "platform_symbol": platform_symbol, + "style": _observe_object( + request["style"], + ("kind", "limit_price"), + ), + "target_quantity": target, + } + if observe_target_package: + _emit_evidence( + "ORDER_REQUEST", + request_evidence, + context, + ) + try: + if request["style"] is None: + order_result = order_target(platform_symbol, target) + else: + order_result = order_target( + platform_symbol, + target, + style=request["style"], + ) + except Exception as exc: + if observe_target_package: + _emit_evidence( + "ORDER_RETURN", + { + "error_type": type(exc).__name__, + "lineage": plan.get("lineage"), + "outcome": "EXCEPTION", + "platform_symbol": platform_symbol, + "target_quantity": target, + }, + context, + ) + raise + observation = { + "lineage": plan.get("lineage"), + "order": _observe_order(order_result), + "outcome": "RETURNED", + "platform_symbol": platform_symbol, + "target_quantity": target, + } + if observe_target_package: + g.quant60_order_observations.append(observation) + _emit_evidence( + "ORDER_RETURN", + observation, + context, + ) + + g.quant60_last_plan = plan + return plan + + +def _execute_rebalance(context): + """Submit the selected portable plan's exact executable share deltas.""" + plan = compute_plan(context) + if plan is None: + return None + managed = ( + sorted(plan["universe"]) + if "universe" in plan + else ( + g.quant60_last_universe + or g.quant60_config["universe"] + ) + ) + return _submit_plan(context, plan, managed) + + +def _as_date(value, name): + if hasattr(value, "date"): + value = value.date() + if not hasattr(value, "isocalendar"): + raise ClockStateError("%s must be a date/datetime" % name) + return value + + +def rebalance(context): + """Execute on the session immediately after a week's first close. + + A daily state machine is used instead of ``run_weekly(..., 2)`` so a + one-session holiday week still executes on the next available session. + """ + current_date = _as_date(getattr(context, "current_dt", None), "current_dt") + previous_date = _as_date( + getattr(context, "previous_date", None), + "previous_date", + ) + if g.quant60_last_callback_date == current_date: + return None + if _decision_mode() == TARGET_PACKAGE_MODE: + # EOD evidence is session-scoped. Clear the prior session only after + # the duplicate-date guard so a same-day retry cannot erase evidence. + g.quant60_last_plan = None + g.quant60_order_observations = [] + g.quant60_skipped_orders = [] + # Consume the daily clock token before crossing the hosted order + # boundary so callback retries cannot duplicate an accepted prefix. + g.quant60_last_callback_date = current_date + return _execute_rebalance(context) + pending = g.quant60_pending_first_session + is_first_session = ( + previous_date.isocalendar()[:2] + != current_date.isocalendar()[:2] + ) + if pending is not None: + if previous_date != pending: + raise ClockStateError( + "pending first-session close is not the immediately " + "previous trading session" + ) + # Consume the clock token before crossing any hosted order boundary. + # A partial API failure must not make a callback retry duplicate the + # already accepted prefix of the order list. + g.quant60_pending_first_session = ( + current_date if is_first_session else None + ) + g.quant60_last_callback_date = current_date + plan = _execute_rebalance(context) + return plan + if is_first_session: + g.quant60_pending_first_session = current_date + g.quant60_last_callback_date = current_date + return None + + +def after_trading_end(context): + """Emit a best-effort end-of-day observation without changing state.""" + if _decision_mode() != TARGET_PACKAGE_MODE: + return None + return _emit_chunked_evidence( + "EOD_STATUS", + { + "last_plan": _plan_evidence( + getattr(g, "quant60_last_plan", None) + ), + "order_returns": getattr( + g, + "quant60_order_observations", + [], + ), + "orders_api": _observe_platform_collection( + "get_orders", + _observe_order, + ), + "portfolio": _observe_portfolio(context), + "skipped_orders": getattr( + g, + "quant60_skipped_orders", + [], + ), + "trades_api": _observe_platform_collection( + "get_trades", + _observe_trade, + ), + }, + context, + ) diff --git a/dist/target-package/qmt_builtin_strategy.py b/dist/target-package/qmt_builtin_strategy.py new file mode 100644 index 0000000..19dd5a2 --- /dev/null +++ b/dist/target-package/qmt_builtin_strategy.py @@ -0,0 +1,1735 @@ +# coding: gbk +"""QMT built-in Python 3.6 strategy wrapper. Keep this source ASCII-only.""" + +# __PORTABLE_CORE_BUNDLE_START__ +# Inlined by tools/bundle_platforms.py; do not edit this region. +import hashlib +import json +import math +import re + + +_CANONICAL_EXCHANGES = { + "SH": "XSHG", + "SSE": "XSHG", + "XSHG": "XSHG", + "SZ": "XSHE", + "SZE": "XSHE", + "SZSE": "XSHE", + "XSHE": "XSHE", + "BJ": "XBSE", + "BSE": "XBSE", + "XBSE": "XBSE", +} + +_SHORT_EXCHANGES = { + "XSHG": "SH", + "XSHE": "SZ", + "XBSE": "BJ", +} + +_TARGET_PACKAGE_FIELDS = { + "schema_version", + "package_type", + "package_id", + "decision_id", + "release", + "evidence_class", + "model_id", + "signal_as_of", + "next_session", + "model_sha256", + "data_sha256", + "feature_sha256", + "risk_sha256", + "cost_sha256", + "optimizer_sha256", + "config_sha256", + "source_sha256", + "target_weights", + "universe", + "constraints", + "package_sha256", +} + +_TARGET_PACKAGE_HASH_FIELDS = ( + "model_sha256", + "data_sha256", + "feature_sha256", + "risk_sha256", + "cost_sha256", + "optimizer_sha256", + "config_sha256", + "source_sha256", +) + + +class TargetPackageLookupMiss(ValueError, LookupError): + """An already verified tape has no exact requested decision.""" + + +def _sum_numeric(values): + """Sum numerics without trusting a hosted runtime's global ``sum`` name.""" + + total = 0.0 + for value in values: + total += value + return total + + +def _any_true(values): + """Return built-in ``any`` semantics without trusting hosted globals.""" + + for value in values: + if value: + return True + return False + + +def _infer_exchange(code): + if code.startswith(("4", "8")): + return "XBSE" + if code.startswith(("5", "6", "9")): + return "XSHG" + return "XSHE" + + +def _split_symbol(symbol): + if not isinstance(symbol, str): + raise TypeError("symbol must be a string") + value = symbol.strip().upper().replace(" ", "") + if not value: + raise ValueError("symbol must not be empty") + + match = re.match(r"^(\d{6})\.(XSHG|XSHE|XBSE|SH|SZ|BJ)$", value) + if match: + return match.group(1), _CANONICAL_EXCHANGES[match.group(2)] + + match = re.match(r"^(XSHG|XSHE|XBSE|SH|SZ|BJ)[\.:]?(\d{6})$", value) + if match: + return match.group(2), _CANONICAL_EXCHANGES[match.group(1)] + + match = re.match(r"^(\d{6})[\.:](SSE|SZE|SZSE|BSE)$", value) + if match: + return match.group(1), _CANONICAL_EXCHANGES[match.group(2)] + + if re.match(r"^\d{6}$", value): + return value, _infer_exchange(value) + raise ValueError("unsupported A-share symbol: %s" % symbol) + + +def normalize_symbol(symbol, target="canonical"): + """Convert common A-share codes to canonical/JQ, QMT, or Qlib format.""" + + code, exchange = _split_symbol(symbol) + target_name = str(target).strip().lower() + if target_name in ("canonical", "joinquant", "jq"): + return "%s.%s" % (code, exchange) + if target_name in ("qmt", "xtquant"): + return "%s.%s" % (code, _SHORT_EXCHANGES[exchange]) + if target_name == "qlib": + return "%s%s" % (_SHORT_EXCHANGES[exchange], code) + raise ValueError("unsupported target platform: %s" % target) + + +def convert_symbol(symbol, target="canonical"): + """Alias kept as the stable adapter-facing name.""" + + return normalize_symbol(symbol, target) + + +def _canonical_json(value): + """Match ``quant60.ledger.canonical_json`` for plain JSON values.""" + + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def _json_copy(value, name): + try: + return json.loads(_canonical_json(value)) + except (TypeError, ValueError) as error: + raise ValueError("%s must contain finite JSON values only" % name) from error + + +def _canonical_mapping(value, name, value_validator): + if not hasattr(value, "items"): + raise TypeError("%s must be a mapping" % name) + output = {} + for raw_symbol, raw_value in value.items(): + symbol = normalize_symbol(raw_symbol) + if symbol in output: + raise ValueError( + "%s contains duplicate normalized symbol %s" % (name, symbol) + ) + output[symbol] = value_validator(raw_value, "%s.%s" % (name, symbol)) + return dict((symbol, output[symbol]) for symbol in sorted(output)) + + +def _finite_non_negative(value, name): + if isinstance(value, bool): + raise ValueError("%s must be a finite non-negative number" % name) + try: + numeric = float(value) + except (TypeError, ValueError): + raise ValueError("%s must be a finite non-negative number" % name) + if not math.isfinite(numeric) or numeric < 0.0: + raise ValueError("%s must be a finite non-negative number" % name) + return numeric + + +def _non_negative_quantity(value, name): + numeric = _finite_non_negative(value, name) + if numeric != math.floor(numeric): + raise ValueError("%s must be a whole-share quantity" % name) + return int(numeric) + + +def _positive_price(value, name): + numeric = _finite_non_negative(value, name) + if numeric <= 0.0: + raise ValueError("%s must be positive" % name) + return numeric + + +def _optional_non_empty_string(value, name): + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ValueError("%s must be a non-empty string when provided" % name) + return value + + +def _sha256_hex(value, name, allow_none=False): + if value is None and allow_none: + return None + if ( + not isinstance(value, str) + or len(value) != 64 + or _any_true( + character not in "0123456789abcdef" + for character in value + ) + ): + raise ValueError( + "%s must be a 64-character lowercase SHA-256 digest" % name + ) + if value == "0" * 64: + raise ValueError("%s cannot be an all-zero placeholder" % name) + return value + + +def _reject_quantity_fields(value, path="$"): + if hasattr(value, "items"): + for raw_key, child in value.items(): + key = str(raw_key) + normalized = key.lower().replace("-", "_") + if "quantity" in normalized or "quantities" in normalized: + raise ValueError( + "%s.%s: quantities do not belong in TargetPackageV1" + % (path, key) + ) + _reject_quantity_fields(child, "%s.%s" % (path, key)) + elif isinstance(value, list): + for index, child in enumerate(value): + _reject_quantity_fields(child, "%s[%d]" % (path, index)) + + +def _verify_identifier(value, name): + if ( + not isinstance(value, str) + or re.match(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", value) is None + ): + raise ValueError("%s is not a canonical identifier" % name) + return value + + +def _verify_target_package_clock(signal_as_of, next_session): + if ( + not isinstance(signal_as_of, str) + or re.match( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T15:00:00\+08:00$", + signal_as_of, + ) + is None + ): + raise ValueError("signal_as_of must be canonical China-market close") + if ( + not isinstance(next_session, str) + or re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", next_session) is None + ): + raise ValueError("next_session must use canonical YYYY-MM-DD") + if next_session <= signal_as_of[:10]: + raise ValueError("next_session must follow signal_as_of") + + +def _target_package_content_sha256(package): + body = dict(package) + body.pop("package_sha256", None) + _reject_quantity_fields(body) + try: + payload = _canonical_json(body).encode("utf-8") + except (TypeError, ValueError) as error: + raise ValueError( + "target package must contain finite JSON values only" + ) from error + return hashlib.sha256(payload).hexdigest() + + +def verify_target_package( + package, + expected_signal_as_of=None, + expected_next_session=None, + expected_decision_id=None, + expected_package_sha256=None, +): + """Verify the platform-critical subset of a sealed ``TargetPackageV1``. + + This intentionally duplicates a small, Python-3.6-compatible safety + boundary from the richer local validator. Hosted runtimes must verify the + embedded package again instead of trusting the bundling process. + """ + + if not hasattr(package, "items"): + raise TypeError("target package must be a mapping") + candidate = _json_copy(package, "target package") + _reject_quantity_fields(candidate) + fields = set(candidate) + if fields != _TARGET_PACKAGE_FIELDS: + missing = sorted(_TARGET_PACKAGE_FIELDS - fields) + unknown = sorted(fields - _TARGET_PACKAGE_FIELDS) + raise ValueError( + "target package fields mismatch; missing=%s, unknown=%s" + % (missing, unknown) + ) + if candidate["schema_version"] != "1.0": + raise ValueError("unsupported target package schema_version") + if candidate["package_type"] != "TargetPackageV1": + raise ValueError("unsupported target package type") + for name in ( + "package_id", + "decision_id", + "release", + "evidence_class", + "model_id", + ): + _verify_identifier(candidate[name], name) + _verify_target_package_clock( + candidate["signal_as_of"], candidate["next_session"] + ) + for name in _TARGET_PACKAGE_HASH_FIELDS: + _sha256_hex(candidate[name], name) + _sha256_hex(candidate["package_sha256"], "package_sha256") + + raw_weights = candidate["target_weights"] + if not hasattr(raw_weights, "items") or not raw_weights: + raise ValueError("target_weights must be a non-empty mapping") + weights = {} + for raw_symbol, raw_weight in raw_weights.items(): + symbol = normalize_symbol(raw_symbol) + if symbol != raw_symbol: + raise ValueError( + "target_weights symbol must be canonical: %s" % raw_symbol + ) + if symbol in weights: + raise ValueError("duplicate target weight for %s" % symbol) + weight = _finite_non_negative( + raw_weight, "target_weights.%s" % symbol + ) + if weight > 1.0: + raise ValueError("target weight exceeds one for %s" % symbol) + weights[symbol] = weight + gross = math.fsum(weights.values()) + if gross > 1.0 + 1e-12: + raise ValueError("target_weights gross exceeds one") + + universe = candidate["universe"] + if not hasattr(universe, "items") or not universe: + raise ValueError("universe must be a non-empty mapping") + canonical_universe = {} + for raw_symbol, raw_state in universe.items(): + symbol = normalize_symbol(raw_symbol) + if symbol != raw_symbol: + raise ValueError("universe symbol must be canonical: %s" % raw_symbol) + if symbol in canonical_universe: + raise ValueError("duplicate universe symbol %s" % symbol) + if not hasattr(raw_state, "items"): + raise ValueError("universe state must be a mapping for %s" % symbol) + state = raw_state.get("state") + if state not in ( + "openable", + "hold_only", + "sell_only", + "frozen", + "excluded", + ): + raise ValueError("unsupported universe state for %s" % symbol) + canonical_universe[symbol] = state + missing_universe = sorted(set(weights) - set(canonical_universe)) + if missing_universe: + raise ValueError( + "target symbols are absent from universe: %s" % missing_universe + ) + for symbol, weight in weights.items(): + if canonical_universe[symbol] == "excluded" and weight > 1e-12: + raise ValueError("excluded symbol has positive weight: %s" % symbol) + + constraints = candidate["constraints"] + if not hasattr(constraints, "items"): + raise ValueError("constraints must be a mapping") + if constraints.get("long_only") is not True: + raise ValueError("TargetPackageV1 must be long-only") + declared_gross = _finite_non_negative( + constraints.get("actual_gross_weight"), + "constraints.actual_gross_weight", + ) + max_gross = _finite_non_negative( + constraints.get("max_gross_weight"), + "constraints.max_gross_weight", + ) + max_single = _finite_non_negative( + constraints.get("max_single_weight"), + "constraints.max_single_weight", + ) + if abs(declared_gross - gross) > 1e-12: + raise ValueError("declared gross does not equal target_weights gross") + if gross > max_gross + 1e-12 or max_gross > 1.0: + raise ValueError("target_weights exceed max_gross_weight") + if max_single <= 0.0 or max_single > 1.0: + raise ValueError("invalid max_single_weight") + if _any_true( + weight > max_single + 1e-12 for weight in weights.values() + ): + raise ValueError("target_weights exceed max_single_weight") + model_claim = " ".join( + ( + candidate["release"], + candidate["evidence_class"], + ) + ).lower() + if ( + "baseline" in model_claim + and "portable-momentum" in candidate["model_id"].lower() + ): + raise ValueError( + "portable-momentum cannot claim a baseline target package" + ) + + expected_hash = _target_package_content_sha256(candidate) + if candidate["package_sha256"] != expected_hash: + raise ValueError("target package content hash mismatch") + if ( + expected_signal_as_of is not None + and candidate["signal_as_of"] != expected_signal_as_of + ): + raise ValueError("target package signal_as_of does not match request") + if ( + expected_next_session is not None + and candidate["next_session"] != expected_next_session + ): + raise ValueError("target package next_session does not match request") + if ( + expected_decision_id is not None + and candidate["decision_id"] != expected_decision_id + ): + raise ValueError("target package decision_id does not match request") + if expected_package_sha256 is not None: + _sha256_hex( + expected_package_sha256, + "expected_package_sha256", + ) + if candidate["package_sha256"] != expected_package_sha256: + raise ValueError("target package digest does not match request") + return candidate + + +def target_package_tape_sha256(packages): + """Hash the exact ordered canonical JSON package array.""" + + if not isinstance(packages, (list, tuple)): + raise TypeError("target package tape must be a list or tuple") + try: + payload = _canonical_json(list(packages)).encode("utf-8") + except (TypeError, ValueError) as error: + raise ValueError( + "target package tape must contain finite JSON values only" + ) from error + return hashlib.sha256(payload).hexdigest() + + +def lookup_target_package_exact( + packages, + signal_as_of, + next_session, + decision_id=None, + expected_tape_sha256=None, + expected_count=None, +): + """Verify a complete tape, then return one exact dual-clock decision.""" + + if not isinstance(packages, (list, tuple)) or not packages: + raise ValueError("target package tape must be a non-empty list or tuple") + _verify_target_package_clock(signal_as_of, next_session) + decision_id = _optional_non_empty_string(decision_id, "decision_id") + if expected_count is not None: + if ( + isinstance(expected_count, bool) + or not isinstance(expected_count, int) + or expected_count < 0 + ): + raise ValueError("expected_count must be a non-negative integer") + if len(packages) != expected_count: + raise ValueError("target package tape count mismatch") + if expected_tape_sha256 is not None: + _sha256_hex(expected_tape_sha256, "expected_tape_sha256") + if target_package_tape_sha256(packages) != expected_tape_sha256: + raise ValueError("target package tape digest mismatch") + + verified = [] + package_ids = set() + decision_ids = set() + clock_keys = set() + for raw_package in packages: + package = verify_target_package(raw_package) + clock_key = (package["signal_as_of"], package["next_session"]) + if package["package_id"] in package_ids: + raise ValueError("duplicate target package_id") + if package["decision_id"] in decision_ids: + raise ValueError("duplicate target decision_id") + if clock_key in clock_keys: + raise ValueError("duplicate target package clock") + package_ids.add(package["package_id"]) + decision_ids.add(package["decision_id"]) + clock_keys.add(clock_key) + verified.append(package) + + matches = [ + package + for package in verified + if package["signal_as_of"] == signal_as_of + and package["next_session"] == next_session + ] + if not matches: + raise TargetPackageLookupMiss( + "no target package for exact signal_as_of/next_session clock" + ) + if len(matches) != 1: + raise ValueError("ambiguous exact target package decision") + if ( + decision_id is not None + and matches[0]["decision_id"] != decision_id + ): + raise ValueError("exact target package decision_id mismatch") + return _json_copy(matches[0], "target package") + + +def momentum_score(prices, lookback=20, skip=0): + """Return trailing simple momentum, or ``None`` if history is insufficient. + + ``skip=0`` uses the latest completed decision bar. Set ``skip=1`` only + when intentionally implementing a 20-1 convention. A lookback of N needs + N+skip+1 observations. + """ + + lookback = int(lookback) + skip = int(skip) + if lookback <= 0 or skip < 0: + raise ValueError("lookback must be positive and skip non-negative") + values = list(prices) + if len(values) < lookback + skip + 1: + return None + end_index = len(values) - 1 - skip + start_index = end_index - lookback + start = float(values[start_index]) + end = float(values[end_index]) + if ( + start <= 0.0 + or end <= 0.0 + or not math.isfinite(start) + or not math.isfinite(end) + ): + return None + return end / start - 1.0 + + +def rank_momentum(price_history, lookback=20, skip=0, top_n=None): + """Rank ``{symbol: closes}`` by momentum with deterministic tie-breaking.""" + + ranked = [] + for symbol in sorted(price_history): + score = momentum_score(price_history[symbol], lookback, skip) + if score is not None and math.isfinite(score): + ranked.append((normalize_symbol(symbol), score)) + ranked.sort(key=lambda item: (-item[1], item[0])) + if top_n is not None: + ranked = ranked[: max(0, int(top_n))] + return ranked + + +def capped_target_weights( + scores, max_weight=0.20, gross_target=1.0, min_score=0.0 +): + """Long-only score-proportional weights with iterative cap redistribution.""" + + max_weight = float(max_weight) + gross_target = float(gross_target) + min_score = float(min_score) + if not math.isfinite(max_weight) or not 0.0 < max_weight <= 1.0: + raise ValueError("max_weight must be finite and lie in (0, 1]") + if not math.isfinite(gross_target) or not 0.0 <= gross_target <= 1.0: + raise ValueError("gross_target must be finite and lie in [0, 1]") + if not math.isfinite(min_score): + raise ValueError("min_score must be finite") + + if hasattr(scores, "items"): + items = list(scores.items()) + else: + items = list(scores) + normalized = {} + for symbol, raw_score in items: + name = normalize_symbol(symbol) + score = float(raw_score) + if not math.isfinite(score): + raise ValueError("score must be finite for %s" % name) + normalized[name] = score + + weights = dict((symbol, 0.0) for symbol in normalized) + active = dict( + (symbol, score) + for symbol, score in normalized.items() + if score > min_score and score > 0.0 + ) + remaining = min(gross_target, len(active) * max_weight) + + while active and remaining > 1e-15: + score_sum = _sum_numeric(active.values()) + if score_sum <= 0.0: + break + capped = [] + for symbol in sorted(active): + proposed = remaining * active[symbol] / score_sum + if proposed >= max_weight - 1e-15: + weights[symbol] = max_weight + capped.append(symbol) + if not capped: + for symbol in sorted(active): + weights[symbol] = remaining * active[symbol] / score_sum + remaining = 0.0 + break + for symbol in capped: + active.pop(symbol) + remaining -= max_weight + remaining = max(0.0, remaining) + return weights + + +def round_board_lot(quantity, lot_size=100): + """Round an absolute long position down to a board-lot quantity.""" + + lot_size = int(lot_size) + if lot_size <= 0: + raise ValueError("lot_size must be positive") + if not math.isfinite(float(quantity)): + raise ValueError("quantity must be finite") + quantity = max(0, int(quantity)) + return quantity // lot_size * lot_size + + +def _is_star_market(symbol): + code, exchange = _split_symbol(symbol) + return exchange == "XSHG" and code.startswith(("688", "689")) + + +def target_quantities( + weights, prices, equity, lot_size=100, cash_buffer=0.02 +): + """Convert target weights to affordable board-lot long quantities.""" + + equity = float(equity) + cash_buffer = float(cash_buffer) + if ( + not math.isfinite(equity) + or equity < 0.0 + or not math.isfinite(cash_buffer) + or not 0.0 <= cash_buffer < 1.0 + ): + raise ValueError("invalid equity or cash_buffer") + positive_weights = [] + for raw_symbol, raw_weight in weights.items(): + weight = float(raw_weight) + if not math.isfinite(weight): + raise ValueError( + "weight must be finite for %s" % normalize_symbol(raw_symbol) + ) + positive_weights.append(max(0.0, weight)) + requested_gross = _sum_numeric(positive_weights) + gross_ceiling = 1.0 - cash_buffer + scale = ( + min(1.0, gross_ceiling / requested_gross) + if requested_gross > 0.0 + else 1.0 + ) + result = {} + for raw_symbol in sorted(weights): + symbol = normalize_symbol(raw_symbol) + weight = float(weights[raw_symbol]) + if not math.isfinite(weight): + raise ValueError("weight must be finite for %s" % symbol) + weight = max(0.0, weight) + price = prices.get(raw_symbol) + if price is None: + price = prices.get(symbol) + if price is None: + raise KeyError("missing price for %s" % symbol) + price = float(price) + if price <= 0.0 or not math.isfinite(price): + raise ValueError("invalid price for %s" % symbol) + # Weights are absolute equity weights. cash_buffer is a ceiling, not + # a second multiplicative haircut: gross=.95 and buffer=.02 stays .95. + budget = equity * weight * scale + quantity = round_board_lot(budget / price, lot_size) + # STAR Market auction orders must contain at least 200 shares. Keep + # the portable planner conservative and on the configured 100-share + # grid even though quantities above 200 may increment by one share. + if _is_star_market(symbol) and 0 < quantity < 200: + quantity = 0 + result[symbol] = quantity + return result + + +def order_deltas( + targets, current, sellable=None, lot_size=100 +): + """Return signed board-lot orders; negative sells respect sellable quantity.""" + + canonical_targets = {} + canonical_current = {} + canonical_sellable = {} + for symbol, quantity in targets.items(): + canonical_targets[normalize_symbol(symbol)] = max(0, int(quantity)) + for symbol, quantity in current.items(): + canonical_current[normalize_symbol(symbol)] = max(0, int(quantity)) + if sellable is not None: + for symbol, quantity in sellable.items(): + canonical_sellable[normalize_symbol(symbol)] = max(0, int(quantity)) + + result = {} + symbols = sorted(set(canonical_targets) | set(canonical_current)) + for symbol in symbols: + target = round_board_lot(canonical_targets.get(symbol, 0), lot_size) + if _is_star_market(symbol) and 0 < target < 200: + target = 0 + held = canonical_current.get(symbol, 0) + delta = target - held + if delta > 0: + delta = round_board_lot(delta, lot_size) + if _is_star_market(symbol) and 0 < delta < 200: + delta = 0 + elif delta < 0: + available = held + if sellable is not None: + available = min(held, canonical_sellable.get(symbol, 0)) + requested = min(-delta, available) + # A complete liquidation may submit the entire odd-lot balance. + # This also covers the STAR exception for a remaining balance + # below 200 shares. Partial orders stay on the configured grid. + full_liquidation = ( + target == 0 + and requested == held + and available >= held + ) + if full_liquidation: + delta = -held + else: + delta = -round_board_lot(requested, lot_size) + if _is_star_market(symbol) and 0 < -delta < 200: + delta = 0 + if delta: + result[symbol] = delta + return result + + +def build_target_weight_plan( + target_weights, + prices, + current, + sellable, + equity, + cash_buffer=0.02, + lot_size=100, + decision_id=None, + package_sha256=None, +): + """Bind canonical post-risk weights to one account without recomputing Alpha. + + ``prices`` must cover exactly every target or currently managed symbol. + Missing prices fail closed even for a held symbol whose desired weight is + zero. A missing ``sellable`` entry is safe and means zero sellable shares. + """ + + weights = _canonical_mapping( + target_weights, + "target_weights", + _finite_non_negative, + ) + gross = math.fsum(weights.values()) + if _any_true(weight > 1.0 for weight in weights.values()): + raise ValueError("target weight must not exceed one") + if gross > 1.0 + 1e-12: + raise ValueError("target weight gross must not exceed one") + + canonical_current = _canonical_mapping( + current, + "current", + _non_negative_quantity, + ) + canonical_sellable = _canonical_mapping( + sellable, + "sellable", + _non_negative_quantity, + ) + unknown_sellable = sorted(set(canonical_sellable) - set(canonical_current)) + if unknown_sellable: + raise ValueError( + "sellable contains symbols absent from current: %s" + % unknown_sellable + ) + for symbol, quantity in canonical_sellable.items(): + if quantity > canonical_current[symbol]: + raise ValueError( + "sellable exceeds current quantity for %s" % symbol + ) + + canonical_prices = _canonical_mapping( + prices, + "prices", + _positive_price, + ) + managed_symbols = set(weights) | set(canonical_current) + price_symbols = set(canonical_prices) + missing_prices = sorted(managed_symbols - price_symbols) + extra_prices = sorted(price_symbols - managed_symbols) + if missing_prices or extra_prices: + if missing_prices: + raise KeyError( + "prices do not cover managed symbols: missing=%s" + % missing_prices + ) + raise ValueError( + "prices contain unmanaged symbols: extra=%s" % extra_prices + ) + + complete_weights = dict( + (symbol, weights.get(symbol, 0.0)) + for symbol in sorted(managed_symbols) + ) + targets = target_quantities( + complete_weights, + canonical_prices, + equity, + lot_size, + cash_buffer, + ) + orders = order_deltas( + targets, + canonical_current, + canonical_sellable, + lot_size, + ) + decision_id = _optional_non_empty_string(decision_id, "decision_id") + package_sha256 = _sha256_hex( + package_sha256, + "package_sha256", + allow_none=True, + ) + return { + "decision_id": decision_id, + "package_sha256": package_sha256, + "weights": complete_weights, + "targets": targets, + "orders": orders, + } + + +def build_rebalance_plan( + price_history, + current, + sellable, + equity, + lookback=20, + skip=0, + top_n=10, + max_weight=0.20, + gross_target=1.0, + cash_buffer=0.02, + lot_size=100, +): + """Build the complete portable signal -> weight -> quantity -> order plan.""" + + ranked = rank_momentum(price_history, lookback, skip, top_n) + scores = dict(ranked) + weights = capped_target_weights(scores, max_weight, gross_target, 0.0) + for raw_symbol in price_history: + symbol = normalize_symbol(raw_symbol) + if symbol not in scores: + scores[symbol] = momentum_score( + price_history[raw_symbol], lookback, skip + ) + if symbol not in weights: + weights[symbol] = 0.0 + prices = {} + for raw_symbol, history in price_history.items(): + if not history: + continue + prices[normalize_symbol(raw_symbol)] = history[-1] + targets = target_quantities( + weights, prices, equity, lot_size, cash_buffer + ) + orders = order_deltas(targets, current, sellable, lot_size) + return { + "scores": scores, + "weights": weights, + "targets": targets, + "orders": orders, + } + + +__all__ = [ + "build_rebalance_plan", + "build_target_weight_plan", + "capped_target_weights", + "convert_symbol", + "lookup_target_package_exact", + "momentum_score", + "normalize_symbol", + "order_deltas", + "rank_momentum", + "round_board_lot", + "TargetPackageLookupMiss", + "target_package_tape_sha256", + "target_quantities", + "verify_target_package", +] +# __PORTABLE_CORE_BUNDLE_END__ + +import datetime +import math + + +class PriceHistoryUnavailable(RuntimeError): + pass + + +class UnmanagedPositionError(RuntimeError): + pass + + +class UnsupportedStrategyPeriod(RuntimeError): + pass + + +class G(object): + pass + + +# QMT explicitly documents that custom attributes written to ContextInfo can +# roll back before the next handlebar call. Keep all user-owned mutable state +# in a module global and use ContextInfo only for platform-owned fields/APIs. +g = G() + + +# __BASELINE_CONFIG_START__ +# Generated from configs/baseline.json; do not edit this region. +CONFIG = {'account_id': 'test', + 'decision_mode': 'target_package', + 'target_package_count': 1, + 'target_package_tape_sha256': '5383c376951637b8d4b1ca42fc6db80347abda74c0181c0f70d6c411fb97ea64', + 'universe_mode': 'fixed', + 'index_symbol': '000905.XSHG', + 'qmt_sector_name': '\u4e2d\u8bc1500', + 'dedicated_account_required': True, + 'exclude_st': True, + 'universe': ['000001.SZ', + '000333.SZ', + '000651.SZ', + '002415.SZ', + '002594.SZ', + '300059.SZ', + '300750.SZ', + '600000.SH', + '600519.SH', + '601012.SH', + '601318.SH', + '688981.SH'], + 'lookback': 20, + 'skip': 0, + 'top_n': 20, + 'max_weight': 0.05, + 'gross_target': 0.95, + 'cash_buffer': 0.02, + 'lot_size': 100, + 'participation_rate': 0.1, + 'slippage_bps': 2.0, + 'commission_rate': 0.0002, + 'minimum_commission': 5.0, + 'stamp_duty_rate': 0.0005, + 'transfer_fee_rate': 1e-05, + 'rebalance_schedule': 'weekly_first_close'} +# __BASELINE_CONFIG_END__ + +# __TARGET_PACKAGE_TAPE_START__ +# Generated by tools/bundle_platforms.py; do not edit this region. +TARGET_PACKAGES = [{'config_sha256': 'e4edf1eb730597e220266c1f8007ddb5e1ab4d26a490cd10738823527d030470', + 'constraints': {'actual_gross_weight': 0.4, + 'constraint_state': 'FEASIBLE', + 'long_only': True, + 'max_gross_weight': 0.9, + 'max_one_way_turnover': 0.45, + 'max_single_weight': 0.1}, + 'cost_sha256': 'e2a2d8e6d3829c105a9f6c9ec80d9df18c2dee0553aafeb761abb79d378769a7', + 'data_sha256': 'f5b3e66b0a94eeccb44a114bf3a0bbfad44b5d5da9d5b698c81e83a3e0007d03', + 'decision_id': 'RD-20240311', + 'evidence_class': 'synthetic-research', + 'feature_sha256': '24e671cebc35afe7497b69c59d48bb5bad9401e30c1607f0fc41a69f59082aa1', + 'model_id': 'deterministic-ridge-alpha-5', + 'model_sha256': 'e14e8df7e2f86e6b3178a6e500d1d2c0427bb02d187b13cb4ec672bbf5f9f334', + 'next_session': '2024-03-12', + 'optimizer_sha256': 'b6aba6ab22fc375e89e4bfbdffb10c7261a2fae01b0183265caacb589643c31e', + 'package_id': 'TP-20240311-795dcfba', + 'package_sha256': 'baa15c8156aa6cb54d3775d0cd562613a9d0a4f15b0c39f2a60e0f8b8c6f1f3a', + 'package_type': 'TargetPackageV1', + 'release': 'quant60-research-candidate-v1', + 'risk_sha256': 'a3d95aa6cd569d2452856b148df053b040b2acc2e6b993bbd1072b48b46cde5a', + 'schema_version': '1.0', + 'signal_as_of': '2024-03-11T15:00:00+08:00', + 'source_sha256': '86c1e0514f608b2012b4084dbc947eada90e8015fccbdf8ae13ae99340ede178', + 'target_weights': {'000001.XSHE': 0.0, + '000333.XSHE': 0.0, + '000651.XSHE': 0.0, + '002415.XSHE': 0.0, + '002594.XSHE': 0.0, + '300059.XSHE': 0.0, + '300750.XSHE': 0.0, + '600000.XSHG': 0.0, + '600519.XSHG': 0.1, + '601012.XSHG': 0.1, + '601318.XSHG': 0.1, + '688981.XSHG': 0.1}, + 'universe': {'000001.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '000333.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '000651.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '002415.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '002594.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '300059.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '300750.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '600000.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '600519.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '601012.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '601318.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'}, + '688981.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'}}}] +# __TARGET_PACKAGE_TAPE_END__ + + +MOMENTUM_SMOKE_MODE = "portable_momentum_smoke" +TARGET_PACKAGE_MODE = "target_package" + + +def _validated_target_packages(config): + mode = config.get("decision_mode") + if mode not in (MOMENTUM_SMOKE_MODE, TARGET_PACKAGE_MODE): + raise UnsupportedStrategyPeriod("unsupported decision_mode") + if not isinstance(TARGET_PACKAGES, list): + raise UnsupportedStrategyPeriod( + "TARGET_PACKAGES must be a list" + ) + expected_count = config.get("target_package_count") + expected_sha256 = config.get("target_package_tape_sha256") + if mode == MOMENTUM_SMOKE_MODE: + if ( + type(expected_count) is not int + or expected_count != 0 + or expected_sha256 is not None + or TARGET_PACKAGES + ): + raise UnsupportedStrategyPeriod( + "portable_momentum_smoke cannot carry target packages" + ) + return [] + if type(expected_count) is not int or expected_count <= 0: + raise UnsupportedStrategyPeriod( + "target_package mode requires a positive target_package_count" + ) + if expected_count != len(TARGET_PACKAGES): + raise UnsupportedStrategyPeriod( + "target package count mismatch" + ) + actual_sha256 = target_package_tape_sha256(TARGET_PACKAGES) + if expected_sha256 != actual_sha256: + raise UnsupportedStrategyPeriod( + "target package tape sha256 mismatch" + ) + verified = [] + package_ids = set() + decision_ids = set() + clocks = set() + signal_clocks = set() + for raw_package in TARGET_PACKAGES: + package = verify_target_package(raw_package) + package_id = package["package_id"] + decision_id = package["decision_id"] + clock = (package["signal_as_of"], package["next_session"]) + signal_as_of = package["signal_as_of"] + if ( + package_id in package_ids + or decision_id in decision_ids + or clock in clocks + or signal_as_of in signal_clocks + ): + raise UnsupportedStrategyPeriod( + "duplicate or ambiguous target package decision identity" + ) + package_ids.add(package_id) + decision_ids.add(decision_id) + clocks.add(clock) + signal_clocks.add(signal_as_of) + verified.append(package) + return verified + + +def _param(ContextInfo, name, default=None): + params = getattr(ContextInfo, "_param", {}) + if isinstance(params, dict) and name in params: + return params[name] + return default + + +def init(ContextInfo): + period = str( + getattr(ContextInfo, "period", _param(ContextInfo, "period", "1d")) + ).strip().lower() + if period not in ("1d", "day"): + raise UnsupportedStrategyPeriod( + "quant60 QMT wrapper requires a 1d driving period" + ) + g.config = dict(CONFIG) + for key in CONFIG: + override = _param(ContextInfo, "q60_" + key, None) + if override is not None: + g.config[key] = override + g.target_packages = _validated_target_packages(g.config) + g.decision_mode = g.config["decision_mode"] + if g.config["decision_mode"] == MOMENTUM_SMOKE_MODE: + if ( + g.config.get("rebalance_schedule") + != "weekly_first_close" + ): + raise UnsupportedStrategyPeriod( + "unsupported rebalance_schedule" + ) + if g.config.get("universe_mode") not in ("pit_index", "fixed"): + raise UnsupportedStrategyPeriod("unsupported universe_mode") + if ( + g.config.get("universe_mode") == "pit_index" + and not g.config.get("dedicated_account_required") + ): + raise UnsupportedStrategyPeriod( + "pit_index mode requires a dedicated account" + ) + set_commission = getattr(ContextInfo, "set_commission", None) + set_slippage = getattr(ContextInfo, "set_slippage", None) + if not callable(set_commission) or not callable(set_slippage): + raise UnsupportedStrategyPeriod( + "QMT backtest commission/slippage APIs are unavailable" + ) + commission = ( + float(g.config["commission_rate"]) + + float(g.config["transfer_fee_rate"]) + ) + set_commission( + 0, + [ + 0.0, + float(g.config["stamp_duty_rate"]), + commission, + commission, + 0.0, + float(g.config["minimum_commission"]), + ], + ) + set_slippage( + 2, + float(g.config["slippage_bps"]) / 10000.0, + ) + g.last_week = None + g.last_plan = None + g.last_universe = None + g.last_universe_as_of = None + g.last_target_signal_as_of = None + if ( + g.config.get("universe_mode") == "fixed" + and hasattr(ContextInfo, "set_universe") + ): + ContextInfo.set_universe(g.config["universe"]) + + +def _decision_mode(): + configured = g.config.get("decision_mode") + frozen = getattr(g, "decision_mode", configured) + if configured != frozen: + raise UnsupportedStrategyPeriod( + "decision_mode cannot change after init" + ) + return frozen + + +def _bar_datetime(ContextInfo): + timetag = ContextInfo.get_bar_timetag(ContextInfo.barpos) + return datetime.datetime.fromtimestamp(float(timetag) / 1000.0) + + +def _series_close(frame): + if frame is None: + return [] + if hasattr(frame, "__getitem__"): + try: + values = frame["close"] + if hasattr(values, "tolist"): + values = values.tolist() + return list(values) + except (KeyError, TypeError): + pass + if isinstance(frame, dict): + values = frame.get("close", []) + return list(values) + return [] + + +def _decision_universe(ContextInfo, membership_timetag): + config = g.config + if config.get("universe_mode") == "fixed": + values = list(config["universe"]) + else: + function = getattr( + ContextInfo, + "get_stock_list_in_sector", + None, + ) + if not callable(function): + raise PriceHistoryUnavailable( + "historical sector membership API is unavailable" + ) + values = function( + config["qmt_sector_name"], + int(membership_timetag), + ) + if not values: + raise PriceHistoryUnavailable( + "no PIT index members for %s on %s" + % (config["index_symbol"], membership_timetag) + ) + normalized = sorted( + set(convert_symbol(symbol, "qmt") for symbol in values) + ) + if not normalized: + raise PriceHistoryUnavailable("decision universe is empty") + g.last_universe = list(normalized) + g.last_universe_as_of = int(membership_timetag) + return normalized + + +def _history(ContextInfo, end_time, universe): + config = g.config + count = int(config["lookback"]) + int(config["skip"]) + 1 + raw = ContextInfo.get_market_data_ex( + fields=["close"], + stock_code=universe, + period="1d", + start_time="", + end_time=end_time, + count=count, + dividend_type="front_ratio", + fill_data=False, + subscribe=False, + ) + output = {} + for symbol in universe: + values = _series_close(raw.get(symbol)) + if len(values) != count: + raise PriceHistoryUnavailable( + "%s needs %d completed daily closes, got %d" + % (symbol, count, len(values)) + ) + clean = [] + for value in values: + try: + number = float(value) + except (TypeError, ValueError): + raise PriceHistoryUnavailable( + "%s contains a missing/non-numeric close" % symbol + ) + if not math.isfinite(number) or number <= 0.0: + raise PriceHistoryUnavailable( + "%s contains a missing/non-positive close" % symbol + ) + clean.append(number) + output[convert_symbol(symbol, "canonical")] = clean + return output + + +def _filter_st_universe(ContextInfo, universe, decision_date): + if not g.config.get("exclude_st", True): + return list(universe) + function = getattr(ContextInfo, "get_his_st_data", None) + if not callable(function): + raise PriceHistoryUnavailable( + "historical ST API is unavailable; QMT VIP ST data is required" + ) + output = [] + for symbol in universe: + raw = function(symbol) + if not isinstance(raw, dict): + raise PriceHistoryUnavailable( + "%s historical ST query returned an invalid payload" % symbol + ) + excluded = False + for status in ("ST", "*ST", "PT"): + periods = raw.get(status, []) + if not isinstance(periods, (list, tuple)): + raise PriceHistoryUnavailable( + "%s historical %s periods are invalid" % (symbol, status) + ) + for period in periods: + if ( + not isinstance(period, (list, tuple)) + or len(period) != 2 + ): + raise PriceHistoryUnavailable( + "%s historical %s period is invalid" + % (symbol, status) + ) + start = str(period[0]) + end = str(period[1]) + if ( + len(start) != 8 + or len(end) != 8 + or not start.isdigit() + or not end.isdigit() + or start > end + ): + raise PriceHistoryUnavailable( + "%s historical %s date range is invalid" + % (symbol, status) + ) + if start <= decision_date <= end: + excluded = True + if not excluded: + output.append(symbol) + if not output: + raise PriceHistoryUnavailable( + "PIT ST exclusion removed the entire decision universe" + ) + g.last_universe = list(output) + return output + + +def _get_attr(obj, names, default=0): + for name in names: + if hasattr(obj, name): + value = getattr(obj, name) + if value is not None: + return value + return default + + +def _trade_details(ContextInfo, kind): + account_id = g.config["account_id"] + if hasattr(ContextInfo, "get_trade_detail_data"): + return ContextInfo.get_trade_detail_data(account_id, "STOCK", kind) + fn = globals().get("get_trade_detail_data") + if fn is None: + return [] + return fn(account_id, "STOCK", kind) + + +def _portfolio(ContextInfo, managed): + current = {} + sellable = {} + managed = set( + convert_symbol(symbol, "canonical") + for symbol in managed + ) + dynamic = ( + _decision_mode() == MOMENTUM_SMOKE_MODE + and g.config.get("universe_mode") == "pit_index" + ) + for position in _trade_details(ContextInfo, "position") or []: + code = str( + _get_attr(position, ["stock_code", "m_strInstrumentID"], "") + ) + market = str(_get_attr(position, ["market", "m_strExchangeID"], "")) + if "." not in code and market: + code = code + "." + market + if not code: + continue + canonical = convert_symbol(code, "canonical") + quantity = int( + _get_attr(position, ["volume", "m_nVolume", "total_amount"], 0) + ) + if quantity and canonical not in managed and not dynamic: + raise UnmanagedPositionError( + "account contains unmanaged position %s; use a dedicated " + "strategy account or reconcile ownership before trading" % canonical + ) + if canonical not in managed and not dynamic: + continue + current[canonical] = quantity + sellable[canonical] = int( + _get_attr( + position, + ["can_use_volume", "m_nCanUseVolume", "closeable_amount"], + current[canonical], + ) + ) + + equity = 0.0 + assets = _trade_details(ContextInfo, "account") or [] + if assets: + equity = float( + _get_attr( + assets[0], + ["total_asset", "m_dBalance", "m_dTotalAsset", "asset"], + 0.0, + ) + ) + if equity <= 0: + equity = float(_param(ContextInfo, "asset", 0.0) or 0.0) + return current, sellable, equity + + +def compute_plan(ContextInfo, bar_time=None): + if _decision_mode() == TARGET_PACKAGE_MODE: + return _compute_target_package_plan(ContextInfo, bar_time) + if bar_time is None: + bar_time = _bar_datetime(ContextInfo) + membership_timetag = int( + ContextInfo.get_bar_timetag(ContextInfo.barpos) + ) + end_time = bar_time.strftime("%Y%m%d") + universe = _decision_universe(ContextInfo, membership_timetag) + universe = _filter_st_universe( + ContextInfo, + universe, + end_time, + ) + history = _history(ContextInfo, end_time, universe) + current, sellable, equity = _portfolio(ContextInfo, universe) + if equity <= 0: + raise ValueError("QMT account equity must be positive") + config = g.config + return build_rebalance_plan( + price_history=history, + current=current, + sellable=sellable, + equity=equity, + lookback=config["lookback"], + skip=config["skip"], + top_n=config["top_n"], + max_weight=config["max_weight"], + gross_target=config["gross_target"], + cash_buffer=config["cash_buffer"], + lot_size=config["lot_size"], + ) + + +def _signal_as_of(bar_time): + if ( + int(bar_time.hour) != 15 + or int(bar_time.minute) != 0 + or int(bar_time.second) != 0 + or int(bar_time.microsecond) != 0 + ): + raise UnsupportedStrategyPeriod( + "target_package mode requires the completed 15:00 daily close" + ) + return bar_time.strftime("%Y-%m-%dT15:00:00+08:00") + + +def _target_package_for_close(bar_time): + signal_as_of = _signal_as_of(bar_time) + config = g.config + packages = _validated_target_packages(config) + matches = [ + package + for package in packages + if package["signal_as_of"] == signal_as_of + ] + if not matches: + return None + if len(matches) != 1: + raise UnsupportedStrategyPeriod( + "ambiguous target package signal clock" + ) + package = matches[0] + return lookup_target_package_exact( + packages, + signal_as_of, + package["next_session"], + decision_id=package["decision_id"], + expected_tape_sha256=config["target_package_tape_sha256"], + expected_count=config["target_package_count"], + ) + + +def _target_close_prices(ContextInfo, bar_time, symbols): + platform_symbols = [ + convert_symbol(symbol, "qmt") + for symbol in sorted(symbols) + ] + raw = ContextInfo.get_market_data_ex( + fields=["close"], + stock_code=platform_symbols, + period="1d", + start_time="", + end_time=bar_time.strftime("%Y%m%d"), + count=1, + dividend_type="front_ratio", + fill_data=False, + subscribe=False, + ) + if not hasattr(raw, "get"): + raise PriceHistoryUnavailable( + "target close query returned an invalid payload" + ) + prices = {} + for symbol in platform_symbols: + values = _series_close(raw.get(symbol)) + if len(values) != 1: + raise PriceHistoryUnavailable( + "%s needs one completed daily close" % symbol + ) + try: + price = float(values[0]) + except (TypeError, ValueError): + raise PriceHistoryUnavailable( + "%s contains a missing/non-numeric close" % symbol + ) + if not math.isfinite(price) or price <= 0.0: + raise PriceHistoryUnavailable( + "%s contains a missing/non-positive close" % symbol + ) + prices[convert_symbol(symbol, "canonical")] = price + return prices + + +def _target_lineage(package): + return { + "package_id": package["package_id"], + "package_sha256": package["package_sha256"], + "decision_id": package["decision_id"], + "signal_as_of": package["signal_as_of"], + "next_session": package["next_session"], + "release": package["release"], + "model_id": package["model_id"], + } + + +def _compute_target_package_plan(ContextInfo, bar_time=None): + if bar_time is None: + bar_time = _bar_datetime(ContextInfo) + package = _target_package_for_close(bar_time) + if package is None: + return None + managed = sorted(package["universe"]) + current, sellable, equity = _portfolio(ContextInfo, managed) + if equity <= 0: + raise ValueError("QMT account equity must be positive") + plan = build_target_weight_plan( + target_weights=package["target_weights"], + prices=_target_close_prices( + ContextInfo, + bar_time, + set(package["target_weights"]) | set(current), + ), + current=current, + sellable=sellable, + equity=equity, + cash_buffer=g.config["cash_buffer"], + lot_size=g.config["lot_size"], + decision_id=package["decision_id"], + package_sha256=package["package_sha256"], + ) + plan["decision_mode"] = TARGET_PACKAGE_MODE + plan["lineage"] = _target_lineage(package) + plan["universe"] = package["universe"] + plan["execution_contract"] = { + "signal_price_source": "COMPLETED_DAILY_CLOSE", + "submit_trigger": "NEXT_BAR_FIRST_TICK", + "quick_trade": 0, + "declared_next_session": package["next_session"], + "real_platform_observed": False, + } + return plan + + +def _orders_allowed(ContextInfo): + # This hosted wrapper is deliberately backtest-only. Its cached account + # query and in-memory weekly marker cannot provide crash-safe live + # idempotency or a complete fresh reconciliation barrier. Real/shadow + # broker integration belongs behind the external XtTrader adapter. + return _is_backtest(ContextInfo) + + +def _is_backtest(ContextInfo): + if hasattr(ContextInfo, "do_back_test"): + value = getattr(ContextInfo, "do_back_test") + if callable(value): + value = value() + if value is True: + return True + mode = str( + getattr(ContextInfo, "trade_mode", _param(ContextInfo, "trade_mode", "")) + ).strip().lower() + return mode == "backtest" + + +def _submit_delta(ContextInfo, canonical, delta, week_key): + config = g.config + order_code = convert_symbol(canonical, "qmt") + operation = 23 if delta > 0 else 24 + volume = abs(int(delta)) + user_order_id = "q60-%04d%02d-%s" % ( + int(week_key[0]), + int(week_key[1]), + order_code.split(".")[0], + ) + function = globals()["passorder"] + arg_count = getattr(getattr(function, "__code__", None), "co_argcount", 11) + if arg_count <= 8: + # qmttools native-Python compatibility signature. + function( + operation, + 1101, + config["account_id"], + order_code, + 5, + -1, + volume, + ContextInfo, + ) + else: + # QMT built-in hosted signature. + function( + operation, + 1101, + config["account_id"], + order_code, + 5, + -1, + volume, + "quant60", + 0, + user_order_id, + ContextInfo, + ) + + +def _submit_target_delta(ContextInfo, canonical, delta, package_sha256): + config = g.config + order_code = convert_symbol(canonical, "qmt") + operation = 23 if delta > 0 else 24 + volume = abs(int(delta)) + user_order_id = "q60t-%s-%s" % ( + str(package_sha256)[:8], + order_code.split(".")[0], + ) + function = globals()["passorder"] + arg_count = getattr(getattr(function, "__code__", None), "co_argcount", 11) + if arg_count <= 8: + function( + operation, + 1101, + config["account_id"], + order_code, + 5, + -1, + volume, + ContextInfo, + ) + else: + function( + operation, + 1101, + config["account_id"], + order_code, + 5, + -1, + volume, + "quant60", + 0, + user_order_id, + ContextInfo, + ) + + +def handlebar(ContextInfo): + if not _is_backtest(ContextInfo) and hasattr(ContextInfo, "is_last_bar"): + if not ContextInfo.is_last_bar(): + return None + + bar_time = _bar_datetime(ContextInfo) + if _decision_mode() == TARGET_PACKAGE_MODE: + signal_as_of = _signal_as_of(bar_time) + if g.last_target_signal_as_of == signal_as_of: + return None + plan = compute_plan(ContextInfo, bar_time) + # Consume the clock before crossing passorder so a callback retry + # cannot duplicate an already accepted order prefix. + g.last_target_signal_as_of = signal_as_of + if plan is None: + return None + g.last_plan = plan + if not _orders_allowed(ContextInfo): + return plan + ordered = sorted( + plan["orders"].items(), + key=lambda item: item[1], + ) + for canonical, delta in ordered: + if int(delta) != 0: + _submit_target_delta( + ContextInfo, + canonical, + delta, + plan["lineage"]["package_sha256"], + ) + return plan + year, week, unused = bar_time.isocalendar() + del unused + week_key = (year, week) + if g.last_week is None: + # The strategy may be attached to a run in the middle of a week. + # Treat the first observed week as an initialization window; only a + # later ISO-week transition proves that this is the first observed + # trading session of a complete strategy week. + g.last_week = week_key + return None + if g.last_week == week_key: + return None + + plan = compute_plan(ContextInfo, bar_time) + g.last_week = week_key + g.last_plan = plan + if not _orders_allowed(ContextInfo): + return plan + + # Sells before buys. passorder is the QMT-hosted order boundary. + ordered = sorted(plan["orders"].items(), key=lambda item: item[1]) + for canonical, delta in ordered: + if int(delta) != 0: + _submit_delta(ContextInfo, canonical, delta, week_key) + return plan diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3392069..f736644 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,23 +13,26 @@ JQData (authorized account) canonical immutable snapshot exact-set/hash + semantic reconstruction | - +---------+----------+ - | | - v v - snapshot-backtest snapshot-decision - local event engine Signal -> Target -> Order Delta - | | - +---------+----------+ - | - portable-momentum-v1 + +----------------------+ + | + v + snapshot-backtest / snapshot-decision + portable-momentum-v1 compatibility path (smoke only) + +local Baseline authority: + Universe + -> frozen Ridge model bundle / Alpha + -> risk + cost-aware Portfolio + -> post-risk TargetPackageV1 (weights, no account quantities) + -> five-layer hash trace | +------------+-------------+ - | | | - JoinQuant bundle QMT bundle local contract oracle - hosted backtest built-in/qmttools backtest-only + | | + JoinQuant consumer QMT consumer + T+1-open account bind completed-close bind + quickTrade=0 + order_target passorder (backtest-only) -synthetic PIT/features -> purged walk-forward -> Ridge challenger - (not deployment-frozen) +Current TargetPackage fixture is synthetic research evidence, not market proof. Qlib 0.9.7: Tushare completed/checksummed Parquet -> immutable managed provider @@ -159,19 +162,24 @@ terminal/xtquant build 探针时不假设它与 `price × quantity` 的精确误 ## Research and model boundary -synthetic research pipeline 已实现: +synthetic research vertical slice 当前实际接通: - PIT table 和未来值拒绝; - Universe 原因码; - 透明价量特征与 T+1-open 标签时钟; - train-only winsor/impute/standardize; - purge/embargo walk-forward; -- deterministic Ridge、OOS IC/RankIC; -- factor risk、dated cost/capacity 和 constrained target。 +- deterministic Ridge、OOS IC/RankIC 与可重放 model bundle; +- 60 日对角 realized variance; +- dated explicit fee + spread + square-root impact/capacity; +- deterministic constrained portfolio、独立 post-risk gate; +- reference execution binding、完整五层 hash trace 与 TargetPackageV1。 -这些功能证明研究管线可执行。Ridge 尚未冻结成带版本、输入 schema 和推理 -hash 的 deployment bundle,也没有授权真实长样本 OOS 与四引擎推理验证,因此 -不是生产 champion。 +`factor_risk.py`、CVXPY 路径和 guarded TWAP/POV 仍是有单测的独立组件, +不在上述 vertical slice 中。Ridge 已能冻结预处理、系数、标签、训练截止日 +和 lineage,但目前只有 synthetic 候选;没有授权真实长样本 OOS、真实风险/ +成本校准、多期逐层 parity 或 QMT peer,因此不是生产 champion。已有的单包 +JoinQuant run 只观察 execution consumer。 Qlib 路径固定为 `0.9.7`。CPython 3.12 实际 fixture smoke 已得到: @@ -191,9 +199,15 @@ L6,不声称 L3 target 或 L4 order-intent parity。 ## Portable strategy and platform boundary -当前可跨 hosted wrapper 冻结的共同基线是 `portable-momentum-v1`。平台 API -不得进入 symbol normalization、momentum score、target weight、target -quantity 和 order delta 计算。 +hosted artifact 有两个互斥模式: + +- `portable_momentum_smoke`:保留历史连通性回归,平台端计算动量; +- `target_package`:本地唯一权威已完成 Universe/Alpha/Portfolio/Risk, + hosted wrapper 只验证包、精确匹配 `signal_as_of + next_session`、读取账户 + 事实并构造 execution plan,绝不回退到 momentum。 + +`portable-momentum-v1` 不是生产 Baseline。TargetPackage 只有权重,没有股数、 +现金、订单或成交;这些属于下一交易日平台账户绑定后的 execution layer。 canonical 股票代码采用 JoinQuant 形式: @@ -208,8 +222,9 @@ canonical 股票代码采用 JoinQuant 形式: QMT built-in bundle 和 qmttools runner 都硬限制为 backtest/history。built-in 状态存放在模块全局 `g`,不写入可能在下个 `handlebar` 回滚的 -`ContextInfo` 用户属性。两条路径固定中证 500 benchmark、PIT 历史成分/ST -与 baseline 费率;built-in 的 10% 参与率由 QMT GUI 设置,qmttools +`ContextInfo` 用户属性。动量 smoke 路径固定中证 500 benchmark、PIT 历史 +成分/ST 与费率;TargetPackage 路径消费本地冻结 Universe/权重,不重新计算 +Alpha/Portfolio/Risk。built-in 的 10% 参与率由 QMT GUI 设置,qmttools 程序化传入。live mutation 只存在于外部 XtTrader adapter,并且默认关闭、 未认证、没有 live 启动命令;operator 可运行的 shadow CLI 只接受查询边界。 @@ -217,6 +232,9 @@ QMT built-in bundle 和 qmttools runner 都硬限制为 backtest/history。built 最低交换合同是: +- [`model_bundle.schema.json`](../schemas/model_bundle.schema.json); +- [`target_package.schema.json`](../schemas/target_package.schema.json); +- [`release_reachability.schema.json`](../schemas/release_reachability.schema.json); - [`signal.schema.json`](../schemas/signal.schema.json); - [`target.schema.json`](../schemas/target.schema.json); - [`order_event.schema.json`](../schemas/order_event.schema.json); @@ -228,8 +246,13 @@ saved/current source aggregate、saved/current schema aggregate 和 payload schema。`verify-snapshot-decision` 与 data snapshot semantic verifier 承担 各自边界的同类职责。 -这些合同已有自动测试;最近一次标准库套件为 224 tests、OK、6 个可选路径 -skip。测试结果不能替代 schema 迁移演练、真实平台导出或券商回调认证。 +`release_reachability` 对每层分别记录 `implemented`、`unit_tested`、 +`local_entrypoint_reachable`、`jq_entrypoint_reachable`、 +`qmt_entrypoint_reachable` 和 `real_platform_observed`,并逐文件验证路径与 +SHA。源文件总 hash 不能冒充真实运行。测试结果仍不能替代 schema 迁移演练、 +真实平台导出或券商回调认证。当前只有 +`execution.real_platform_observed=true`,由一个真实 JoinQuant +TargetPackage smoke 支持;其他四层及 all-layer summary 仍为 false。 ## Clocks and reconciliation @@ -274,12 +297,12 @@ synthetic/fake 数据是合同测试,不是市场证据;Qlib synthetic fixtu 当前还缺: - 授权 JQData 真实快照、基本面公告 PIT、完整公司行动/Security Master; -- Ridge/LightGBM 冻结模型包和真实 OOS 证据; +- 授权真实数据训练的冻结模型包和真实 OOS 证据; - 真实数据校准的风险、冲击、容量和 TCA; -- 真实 JoinQuant/QMT 导出; +- 授权真实数据的多期 JoinQuant 导出、逐层本地对账与真实 QMT peer; - 券商状态映射、restart recovery daemon、20 日 shadow 与连续对账; - 运维监控、kill-switch 演练和程序化交易合规确认。 -所以 Quant OS 是可执行、可继续填证据的生产候选框架,不是已经达到整体 -60 分的实盘系统。证据政策见 +所以 Quant OS 已具备本地五层 vertical slice 与 TargetPackage 平台边界, +但仍只是可继续填证据的候选骨架,不是已经达到整体 60 分的实盘系统。证据政策见 [`AUDIT_2026-07-25.md`](AUDIT_2026-07-25.md)。 diff --git a/docs/AUDIT_2026-07-25.md b/docs/AUDIT_2026-07-25.md index 83a1090..e5a3c68 100644 --- a/docs/AUDIT_2026-07-25.md +++ b/docs/AUDIT_2026-07-25.md @@ -25,7 +25,11 @@ A 股 Baseline 内核。它的新增得分必须在授权数据、真实平台 截至 2026-07-26 的实现事实快照: -- 截至 2026-07-26 的最新标准库全量测试为 `Ran 224 tests`、`OK (skipped=6)`; +> 这是复评当日的历史快照,不是滚动状态页。当前五层主链、测试数和平台证据 +> 边界以 [`BASELINE_VERTICAL_SLICE.md`](BASELINE_VERTICAL_SLICE.md)、 +> [`gate_scorecard.json`](../gate_scorecard.json) 和生成的公开状态为准。 + +- 本次复评当时的标准库全量测试为 `Ran 224 tests`、`OK (skipped=6)`; - JQData raw/factor/pre-close/limits/paused + 每日 PIT `is_st`/指数成员 已连接到 semantic snapshot verifier、`snapshot-backtest` 和 `snapshot-decision`,并拒绝把抓取当日未到 24:00 的日线标成完整收盘, @@ -39,8 +43,12 @@ A 股 Baseline 内核。它的新增得分必须在授权数据、真实平台 - fixture 只有两只 synthetic 股票,任何性能数字都没有投资意义; - JoinQuant/QMT mock parity exporter 已实现,但 `real_platform_pass=false` 且不计 G9; -- 2026-07-26 已完成一次 2024 全年真实 JoinQuant hosted smoke,可见日志 - 无 ERROR;它证明 runtime/path,但尚无完整同输入导出和本地 strict peer; +- 2026-07-26 已完成一次 2024 全年真实 JoinQuant hosted momentum smoke,可见 + 日志无 ERROR;它证明 `portable_momentum_smoke` runtime/path,不是 + Ridge/TargetPackage Baseline 证据,且尚无完整同输入导出和本地 strict peer; +- 同日一个 `synthetic-research` TargetPackage 已在真实 JoinQuant hosted + 环境完成 exact-clock/`day_open` 绑定、3 笔委托和 3 笔成交;该证据只支持 + execution 层真实平台观察,不支持上游四层、G9 或投资表现; - XtTrader 已有严格只读、HMAC 账户绑定的 observation/snapshot/shadow plan 与 verifier,但真实 QMT、broker callback/shadow 均未运行, G1—G10 仍全部 `not_passed`。 @@ -233,8 +241,8 @@ PYTHONPATH=src:. python -m quant60 snapshot-backtest \ 1. 用户授权登录 JQData,生成不可变真实 snapshot,保存许可/lineage,并通过 semantic verifier、`snapshot-backtest` 和 `snapshot-decision`; -2. 基于已完成的聚宽 hosted smoke,冻结一个小型 canonical 平台输入并导出 - close arrays/hash、plan、orders/fills; +2. 把已完成的单包 JoinQuant execution smoke 扩展为授权真实数据的多期 + TargetPackage,并保存完整 identity/plan/orders/fills; 3. 用本地与聚宽对相同输入逐层比较 L1—L4,而不是先比较净值;mock parity 只作为回归辅助; 4. 把基本面公告 available-time、完整公司行动/Security Master 加入真实 diff --git a/docs/BASELINE_VERTICAL_SLICE.md b/docs/BASELINE_VERTICAL_SLICE.md new file mode 100644 index 0000000..1d92b0b --- /dev/null +++ b/docs/BASELINE_VERTICAL_SLICE.md @@ -0,0 +1,134 @@ +# Quant OS Baseline vertical slice:当前真实结构与缺口 + +更新时间:2026-07-26。 + +## 一句话结论 + +Quant OS 现在已经不是“只有一堆互不相连的模块”:本地 synthetic 路径已经把 +Universe、Ridge Alpha、风险/成本参与的 Portfolio、post-risk gate 和 +reference Execution 串成唯一五层链,并发布冻结模型包与无股数 +`TargetPackageV1`。 + +但它仍然是 `NOT_BASELINE_60`。原因不是代码行数,而是当前候选只在 synthetic +数据上形成上游四层;虽然一个 synthetic-research TargetPackage 已真实通过 +JoinQuant execution consumer 并成交,真实 JQData 长样本 OOS、上游逐层 +真实数据 parity、真实 QMT peer 与 20 日 shadow 都还没有证据。 + +## 两条不能混记的运行路径 + +| 模式 | 上游决策在哪里 | 平台上运行什么 | 当前真实平台事实 | +| --- | --- | --- | --- | +| `portable_momentum_smoke` | JoinQuant/QMT wrapper 现场用完成日线算动量 | 动量、封顶权重、股数与 delta | JoinQuant 2024 全年跑过;不是 Baseline | +| `target_package` | 本地 Quant OS 完成 Universe → Alpha → Portfolio → Risk | 验包、精确双时钟命中、读取 equity/position/sellable/price、Execution | JoinQuant 已有一个真实 execution smoke(3 单/3 成交);上游仍是 synthetic,QMT 未实跑 | + +默认 bundler 仍生成 smoke,保持历史回归兼容。只有显式传 +`--target-package` 才会生成候选消费者;目标包缺失、重复、篡改或时钟不匹配 +都会停止,不会回退到 momentum。 + +## 五层实际用了什么 + +| 层 | 当前 vertical slice 的实际实现 | 已接通的策略 | 明确未接通/未证明 | +| --- | --- | --- | --- | +| Universe | `SecurityEligibility` → 五态 state machine,原因码进入 trace/package | openable、hold-only、sell-only、frozen、excluded | synthetic 成员;真实基本面 PIT、Security Master、退市链未完成 | +| Alpha | train-only winsor/impute/standardize + `DeterministicRidge(alpha=5)` | 冻结 feature order、transform、系数、截距、训练截止与标签 | 无授权真实 OOS;Qlib Alpha158/LightGBM 是另一研究路径 | +| Portfolio | deterministic long-only constrained optimizer | Alpha 减显式成本,按预测方差调整;单名、行业、gross、capacity、turnover 约束 | CVXPY 有组件测试但未进入当前主链 | +| Risk | 60 日 realized diagonal variance 进入优化,随后独立 concentration/gross/turnover post-check | 风险输入变化必须改变 target;风险 gate 不可旁路 | `factor_risk.py` 的因子协方差/stress 尚未进入主链,也未真实校准 | +| Execution | 本地 reference account 生成 quantity/delta;平台从 TargetPackage 权重重新绑定真实账户 | 百股、科创板最小申报、T+1 sellable、卖单优先、平台保护价;JoinQuant 单包已有 3 单/3 fill | guarded TWAP/POV 尚未进入这条 slice;无 QMT peer、多期真实数据回放或长期 callback 证据 | + +权威实现和合同: + +- `src/quant60/baseline_pipeline.py` +- `src/quant60/model_bundle.py` +- `schemas/model_bundle.schema.json` +- `src/quant60/target_package.py` +- `schemas/target_package.schema.json` +- `src/quant60/release_reachability.py` +- `schemas/release_reachability.schema.json` +- `releases/quant60-research-candidate-v1/reachability.json` + +可直接做平台线路调试的 synthetic 单文件在: + +- `dist/target-package/joinquant_strategy.py` +- `dist/target-package/qmt_builtin_strategy.py` +- `dist/target-package/bundle_manifest.json` + +它们只用于验证 TargetPackage 消费与执行边界,不能当作真实数据 Baseline +或投资结果。 + +## 为什么 hosted 文件仍然可以很短 + +“完整 Baseline”不等于把训练、特征工程、协方差估计和优化器全部复制进聚宽或 +QMT。那样会制造多个决策权威,也会被平台 Python 版本、依赖和数据语义拖成 +不同策略。 + +目标架构是: + +```text +本地唯一权威 + 冻结数据/模型/配置 + -> 五层决策与逐层 hash + -> post-risk TargetPackage(权重) + +平台薄消费者 + 验证同一个 TargetPackage + -> 绑定平台账户事实和执行价格 + -> quantity / order delta + -> order API 与导出 +``` + +所以平台代码短本身是合理的;不合理的是薄 wrapper 自己改算 momentum,却被 +文档称为完整 Baseline。现在这两种模式已经在配置、manifest、测试和文档中 +分开。 + +## 当前机器可证明到哪里 + +每一层不再只写一个“已实现”,而是分别记录: + +1. `implemented` +2. `unit_tested` +3. `local_entrypoint_reachable` +4. `jq_entrypoint_reachable` +5. `qmt_entrypoint_reachable` +6. `real_platform_observed` + +`release_reachability` 要求每个 true 状态有类型匹配、仓库相对路径和实际 +SHA-256 证据。源文件 aggregate hash 不能冒充真实平台运行。2026-07-26 的 +TargetPackage hosted run 只允许把 `execution.real_platform_observed` 设为 +true;Universe、Alpha、Portfolio、Risk 仍为 false,因此 +`all_layers_real_platform_observed` 也必须保持 false。 + +该真实执行记录使用 `synthetic-research` 输入,只证明验包、时钟、 +`day_open`、账户绑定、下单和成交: + +- [`JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md`](JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md) +- [`../evidence/joinquant/TP-20240311-795dcfba/2457a7c39a276e09e0fabf99e28978e1/run.json`](../evidence/joinquant/TP-20240311-795dcfba/2457a7c39a276e09e0fabf99e28978e1/run.json) + +## 还差什么 + +按先后顺序: + +1. 用授权 JQData/其他合法数据形成真实 PIT 长样本,训练并冻结候选 model + bundle;封住测试集,保存 OOS、容量和多状态报告。 +2. 用真实数据校准 risk/cost;决定并接通 factor risk、CVXPY 是否进入 + Baseline 60,而不是因为模块存在就自动算完成。 +3. 从同一个真实候选生成多期 TargetPackage tape;不能用当前单个 synthetic + package 代替。 +4. 把同一真实 tape 分别打入 JoinQuant/QMT;现有 JoinQuant 单包 execution + smoke 作为线路先验,但仍要导出完整多期 identity/plan/orders/fills。 +5. 做 local/JoinQuant/QMT 逐层差异;任何 `UNEXPLAINED` 必须为零。当前无 + QMT peer,所以 G9 仍未通过。 +6. 完成至少 20 个交易日 QMT 只读 shadow、重启/回调/对账和合规确认。 + +## 用户什么时候需要参与 + +当前代码、测试、合同和文档整理不需要用户提供任何凭据,可以直接完成。 +后续只有三处需要用户环境: + +- JQData:在本机授权环境抓取可保存的脱敏快照; +- JoinQuant:已有一个 synthetic 单包 execution smoke;真实候选形成后仍需 + 上传多期 tape 并导出完整结果; +- Windows QMT/MiniQMT:使用已登录且有历史数据权限的客户端做 backtest 和 + read-only shadow。 + +不要把密码、cookie、token、QMT userdata、account id 或持牌二进制发到聊天、 +Git 或文档中。 diff --git a/docs/JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md b/docs/JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md new file mode 100644 index 0000000..4932b0f --- /dev/null +++ b/docs/JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md @@ -0,0 +1,104 @@ +# JoinQuant TargetPackage execution evidence — 2026-07-26 + +This is a conservative record of one real JoinQuant hosted execution smoke. +It proves that the frozen TargetPackage consumer reached the real hosted +account/price/order/fill boundary. It does not prove that JoinQuant recomputed +the upstream Universe, Alpha, Portfolio, or Risk layers, and it is not an +investment-performance claim. + +## Frozen identity + +- Run: + [JoinQuant backtest detail](https://www.joinquant.com/algorithm/backtest/detail?backtestId=2457a7c39a276e09e0fabf99e28978e1) +- Run ID: `2457a7c39a276e09e0fabf99e28978e1` +- Observed at: `2026-07-26T08:50:15Z` +- Bundle SHA-256: + `0586930ddc483f4489f09b562f4d4232b458b703e6a1194af809b707211be8c4` +- Bundle manifest SHA-256: + `91f4b4dc0bdd0d9f99b3466c830600e2678be78dd991d4499a4154bffec8597a` +- Portable core SHA-256: + `f9de9f18e4008bb0fc80edb0b21cae6ea06a61828d89e9b7c11057f98225fc19` +- Wrapper SHA-256: + `0c672b64665b7c1d4409ba288b00d1608e976800af3a4f9a6467767ce4def592` +- TargetPackage ID: `TP-20240311-795dcfba` +- Package SHA-256: + `baa15c8156aa6cb54d3775d0cd562613a9d0a4f15b0c39f2a60e0f8b8c6f1f3a` +- Tape SHA-256: + `5383c376951637b8d4b1ca42fc6db80347abda74c0181c0f70d6c411fb97ea64` +- Evidence class: `synthetic-research` + +The uploaded browser snapshot and the frozen bundle had the same SHA-256. +The machine record distinguishes +`platform_evidence_class=real_platform_runtime` from +`input_evidence_class=synthetic-research` and fixes +`performance_claim=false`. +The tracked, redacted machine record is +[`run.json`](../evidence/joinquant/TP-20240311-795dcfba/2457a7c39a276e09e0fabf99e28978e1/run.json). +It contains no account identifier, cookie, token, credentials, screenshots, or +personal information. + +## Runtime and observed sessions + +- Period: 2024-03-11 through 2024-03-13 +- Initial capital: CNY 1,000,000 +- Frequency/runtime: daily, Python 3 +- Benchmark: CSI 500 (`000905.XSHG`) +- Decision mode: `target_package` + +| Session | Hosted observation | Orders | Fills | +| --- | --- | ---: | ---: | +| 2024-03-11 | Exact-clock miss; `TARGET_PACKAGE_NOOP`; no fallback | 0 | 0 | +| 2024-03-12 | Package hit; prices bound from `CURRENT_DATA_DAY_OPEN` | 3 | 3 | +| 2024-03-13 | Exact-clock miss; `TARGET_PACKAGE_NOOP`; no fallback | 0 | 0 | + +The hosted plan preserved the package weights exactly: 10% each for +`600519.XSHG`, `601012.XSHG`, `601318.XSHG`, and `688981.XSHG`; all other +weights were zero. + +The resulting targets and fills were: + +| Symbol | Weight | Target quantity | Filled quantity | Fill price | +| --- | ---: | ---: | ---: | ---: | +| `600519.XSHG` | 0.10 | 0 | 0 | — | +| `601012.XSHG` | 0.10 | 4,500 | 4,500 | 22.22 | +| `601318.XSHG` | 0.10 | 2,300 | 2,300 | 43.18 | +| `688981.XSHG` | 0.10 | 2,000 | 2,000 | 47.87 | + +`600519.XSHG` opened at CNY 1,693.94. One 100-share board lot therefore +required CNY 169,394, above the CNY 100,000 target notional. The deterministic +lot rule correctly produced a zero-share target; this was not a rejected or +skipped order. The STAR Market request for `688981.XSHG` used the configured +hosted limit-price protection. + +## What this evidence proves + +For the `execution` layer only, this run observes: + +- exact bundle/package identity verification; +- exact signal-close/next-session clock matching with no momentum fallback; +- real hosted equity and `day_open` price binding; +- deterministic target-quantity and order construction; +- three accepted orders and three real hosted fills; +- clean no-op behavior on the surrounding sessions. + +The release reachability manifest may therefore mark only +`execution.real_platform_observed=true`, backed by the tracked machine record. +Universe, Alpha, Portfolio, and Risk remain +`real_platform_observed=false`: those layers were computed locally and carried +to JoinQuant as a frozen TargetPackage rather than recomputed inside JoinQuant. + +## What this evidence does not prove + +The package still has evidence class `synthetic-research`. This three-session, +single-package run provides no: + +- authorized real-data long-sample or out-of-sample validation; +- real-data calibration of the upstream Alpha, Portfolio, or Risk layers; +- multi-period same-input layer-by-layer local/JoinQuant parity; +- real QMT peer run or 20-trading-day broker shadow; +- G9 cross-engine acceptance, `BASELINE_60`, release verification, live + readiness, or investment-value evidence. + +The hosted performance fields are retained in the machine record solely to +identify the completed smoke. They are economically meaningless over this +three-session window and must not be presented as strategy performance. diff --git a/docs/PLATFORM_MATRIX.md b/docs/PLATFORM_MATRIX.md index 9ae543f..e8546db 100644 --- a/docs/PLATFORM_MATRIX.md +++ b/docs/PLATFORM_MATRIX.md @@ -5,16 +5,16 @@ | Path | Intended use | Runtime / prerequisite | Current verified fact | Missing release evidence | | --- | --- | --- | --- | --- | -| Local synthetic execution | 确定性事件回测、ledger/replay | Python ≥3.10,无账号 | 标准库套件最近记录为 224 tests、OK、6 skip;smoke + exact manifest verifier 可运行 | synthetic 不代表真实市场/收益 | -| Local synthetic research | PIT/features/walk-forward/Ridge/risk/cost/target | Python ≥3.10,无账号 | research smoke + manifest verifier 可运行 | Ridge 未冻结部署,未做授权长样本 OOS | +| Local synthetic execution | 确定性事件回测、ledger/replay | Python ≥3.10,无账号 | 标准库套件、smoke + exact manifest verifier 可运行 | synthetic 不代表真实市场/收益 | +| Local synthetic research | 五层 vertical slice:Universe/Ridge Alpha/risk+cost Portfolio/post-risk/reference Execution | Python ≥3.10,无账号 | 冻结 model bundle、TargetPackageV1、layer trace + manifest verifier 可运行;风险/成本扰动必须改变 target | 仍是 synthetic;当前只接对角风险与 deterministic optimizer,未做授权长样本 OOS | | JQData ingestion | 授权数据到 canonical immutable snapshot | `jqdatasdk==1.9.8` + 授权账号 | fake-provider 测试覆盖 raw/factor/pre-close/limits/paused/PIT `is_st`/membership、24:00 完整收盘约束和语义 verifier | 尚未登录真实账号保存快照、许可与 lineage | | Snapshot local backtest | PIT membership 的 provider-data 本地回测 | semantic verifier 通过的 snapshot | `snapshot-backtest` 复用 production decision path,输出普通 run manifest | 尚无授权真实 snapshot;仍是本地 fill model | | Snapshot decision | 某决策日 Signal/Target/Order Delta | snapshot + equity 或完整 broker snapshot | fail-closed broker state 与 exact decision manifest verifier 已实现 | 未接真实账户 snapshot,未冻结生产模型 | | Colab | clone、测试、bundle、本地两条 synthetic 链;可选 JQData/Qlib | Colab runtime | notebook 已可本地逐 cell 执行 | 可选账号/依赖 cell 仍需运行时授权;不是平台证据 | -| JoinQuant local harness | wrapper API 合同 | Python ≥3.10,无账号 | fake lifecycle 与 mock parity 可运行 | 不证明 JoinQuant | -| JoinQuant hosted bundle | hosted backtest | JoinQuant 登录/数据权限 | 真实 2024 全年 run 已完成且可见日志 0 ERROR;单文件、PIT index/ST、批量 history、费率/滑点/10% 参与率、科创板保护价/200 股约束已运行 | 尚无完整输入/plan/orders/fills 导出和同输入本地比较 | -| QMT local harness | built-in wrapper 合同 | Python ≥3.10,无账号 | Python 3.6 source、`ContextInfo` 回滚模拟和 mock parity | 不证明 QMT | -| QMT built-in bundle | GUI backtest-only | 授权 QMT 客户端/板块与历史 ST 数据 | bundle 硬限制 backtest;毫秒 timetag PIT 成分/ST;固定中证 500、费率/滑点;模块全局 `g` | 尚无真实 QMT run/export;10% GUI 配置与历史 ST 正例探针待取证 | +| JoinQuant local harness | wrapper API 合同 | Python ≥3.10,无账号 | 动量 smoke 与 TargetPackage 消费模式均有 fake lifecycle;目标包缺失/篡改不回退 | 不证明 JoinQuant | +| JoinQuant hosted bundle | hosted backtest | JoinQuant 登录/数据权限 | 动量 smoke 的真实 2024 全年 run 已完成;FINAL4 的单个 synthetic-research TargetPackage 已在真实 hosted 环境按 `day_open` 命中并产生 3 单/3 fill | 只观察 execution consumer;无授权真实长样本/OOS、上游四层 hosted 执行、QMT peer 或逐层真实数据 parity | +| QMT local harness | built-in wrapper 合同 | Python ≥3.10,无账号 | Python 3.6 source、`ContextInfo` 回滚模拟、动量 smoke 与 TargetPackage 合同 | 不证明 QMT | +| QMT built-in bundle | GUI backtest-only | 授权 QMT 客户端/历史数据 | 两种模式都硬限制 backtest;TargetPackage 模式不重算上游四层;模块全局 `g` | 尚无 TargetPackage 真实 QMT run/export;10% GUI 配置和执行时钟待取证 | | qmttools runner | native-Python QMT backtest/history | 券商分发 `xtquant` + 已登录 terminal | 固定中证 500/10% 参与率、参数、只读 preflight 和 hard backtest/history 合同已测 | 尚无专有运行时实跑 | | XtTrader read-only shadow | 账户绑定 target-diff 与 broker observation | MiniQMT/QMT、账户查询权限、既有本地 HMAC key | HMAC 账户绑定 + authenticated evidence envelope、decision/observation semantic replay、资产/持仓/委托/成交恒等式、fail-closed exact verifier 与 fake broker 合同 | HMAC 不是 broker attestation;仍缺官方 query 失败/空结果合同、callback/restart、20 日真实 shadow;not live-ready | | Qlib native momentum | signal/model research | CPython 3.12 + exactly `pyqlib==0.9.7` | 真实本地 fixture 成功,24 signal;保存 signal/report hash、表边界和重算 portfolio 指标 | synthetic 两股票;无真实数据/OOS,无 L3/L4 parity | @@ -22,12 +22,14 @@ | Tushare local → Qlib | completed Parquet 的只读盘点、不可变 provider 与本地研究 smoke | 同上 + `pyarrow==24.0.0`;本地镜像 | 93 个 completed 文件全部通过 size/SHA/row-count;v4 provider verifier 成功;同一动量命令连续两次 evidence JSON 逐 byte 相同 | `daily` 仅完成约 8.2%,缺复权、真实 benchmark、PIT 成分/ST/停牌/涨跌停;`production_ready=false` | | Mock parity exporter | JoinQuant/QMT wrapper 的 L2—L4 合同回归 | Python ≥3.10 | `mock_contract_only`、`real_platform_pass=false`、`gate_credit=[]` | 不计 G9;必须换成真实平台导出 | -G1—G10 当前都未通过。JoinQuant hosted smoke 与 Qlib fixture smoke 是真实 -runtime 证据,但不是市场有效性或跨平台 Gate 通过证据。 +G1—G10 当前都未通过。JoinQuant TargetPackage run 支持且只支持 +`execution.real_platform_observed=true`;输入仍是 `synthetic-research`, +Universe/Alpha/Portfolio/Risk 的真实平台观察均为 false。它与旧 momentum +smoke、Qlib fixture 一样,不是市场有效性或跨平台 Gate 通过证据。 ## Stable strategy boundary -当前四引擎共同可冻结的是 `portable-momentum-v1`: +`portable-momentum-v1` 当前只作为 smoke 冻结: - canonical symbol normalization; - momentum score/rank; @@ -36,8 +38,11 @@ runtime 证据,但不是市场有效性或跨平台 Gate 通过证据。 - T+1/sellable-aware order delta。 - 科创板 200 股最小申报、余额一次性卖清与 hosted 最坏价保护。 -本地 Ridge 与 Qlib Alpha158/LightGBM 是研究候选。没有 versioned model -bundle、授权真实 OOS 和跨引擎推理结果前,不能称为已部署 champion。 +本地 Ridge 已能冻结为 versioned model bundle,并通过 +`TargetPackageV1` 把 post-risk 权重移交给 JoinQuant/QMT;平台不需要、也 +不应重新运行训练栈。一个 synthetic 包已通过真实 JoinQuant execution +consumer,但没有授权真实 OOS、真实校准、多期逐层 parity 和 QMT peer, +仍不能称为已部署 champion。 Qlib 的单一 `limit_threshold` 是市场级近似,不能表达逐日板块/ST 限制; 因此它只参加 L1/L2 和诊断 L6。XtTrader mutation 边界目前只接受 `.SH` @@ -81,8 +86,13 @@ PYTHONPATH=src:. python -m quant60 snapshot-decision \ --config configs/baseline.json --as-of 2024-12-31 \ --equity 1000000 --output artifacts/csi500-2024-decision -# hosted bundle 和 mock-only parity +# 默认只构建 portable momentum smoke python tools/bundle_platforms.py + +# 显式构建无 fallback 的 TargetPackage 消费器 +python tools/bundle_platforms.py \ + --target-package artifacts/research-smoke/target_package.json \ + --output-dir dist/target-package PYTHONPATH=src:. python tools/export_mock_parity.py \ export --output artifacts/mock-parity/report.json PYTHONPATH=src:. python tools/export_mock_parity.py \ diff --git a/evidence/joinquant/TP-20240311-795dcfba/2457a7c39a276e09e0fabf99e28978e1/run.json b/evidence/joinquant/TP-20240311-795dcfba/2457a7c39a276e09e0fabf99e28978e1/run.json new file mode 100644 index 0000000..83f17b3 --- /dev/null +++ b/evidence/joinquant/TP-20240311-795dcfba/2457a7c39a276e09e0fabf99e28978e1/run.json @@ -0,0 +1,205 @@ +{ + "schema_version": "1.0", + "record_type": "joinquant_target_package_execution_smoke", + "platform": "joinquant", + "status": "completed", + "platform_evidence_class": "real_platform_runtime", + "input_evidence_class": "synthetic-research", + "performance_claim": false, + "observed_at": "2026-07-26T08:50:15Z", + "run_id": "2457a7c39a276e09e0fabf99e28978e1", + "backtest": { + "id": "2457a7c39a276e09e0fabf99e28978e1", + "url": "https://www.joinquant.com/algorithm/backtest/detail?backtestId=2457a7c39a276e09e0fabf99e28978e1", + "start_date": "2024-03-11", + "end_date": "2024-03-13", + "initial_capital_cny": 1000000, + "frequency": "day", + "python": "Python3", + "benchmark": "000905.XSHG", + "timezone": "Asia/Shanghai" + }, + "artifact_identity": { + "bundle_sha256": "0586930ddc483f4489f09b562f4d4232b458b703e6a1194af809b707211be8c4", + "browser_snapshot_sha256": "0586930ddc483f4489f09b562f4d4232b458b703e6a1194af809b707211be8c4", + "exact_browser_match": true, + "bundle_manifest_sha256": "91f4b4dc0bdd0d9f99b3466c830600e2678be78dd991d4499a4154bffec8597a", + "portable_core_sha256": "f9de9f18e4008bb0fc80edb0b21cae6ea06a61828d89e9b7c11057f98225fc19", + "wrapper_sha256": "0c672b64665b7c1d4409ba288b00d1608e976800af3a4f9a6467767ce4def592" + }, + "target_package": { + "package_id": "TP-20240311-795dcfba", + "decision_id": "RD-20240311", + "release": "quant60-research-candidate-v1", + "evidence_class": "synthetic-research", + "package_sha256": "baa15c8156aa6cb54d3775d0cd562613a9d0a4f15b0c39f2a60e0f8b8c6f1f3a", + "tape_sha256": "5383c376951637b8d4b1ca42fc6db80347abda74c0181c0f70d6c411fb97ea64", + "signal_as_of": "2024-03-11T15:00:00+08:00", + "next_session": "2024-03-12" + }, + "session_observations": [ + { + "date": "2024-03-11", + "event": "TARGET_PACKAGE_NOOP", + "exact_clock_match": false, + "fallback_invoked": false, + "orders": 0, + "fills": 0 + }, + { + "date": "2024-03-12", + "event": "TARGET_PACKAGE_HIT", + "exact_clock_match": true, + "price_source": "CURRENT_DATA_DAY_OPEN", + "package_weights_exact": true, + "orders": 3, + "fills": 3 + }, + { + "date": "2024-03-13", + "event": "TARGET_PACKAGE_NOOP", + "exact_clock_match": false, + "fallback_invoked": false, + "orders": 0, + "fills": 0 + } + ], + "day_open_prices": { + "000001.XSHE": 10.48, + "000333.XSHE": 65.35, + "000651.XSHE": 40.46, + "002415.XSHE": 34.21, + "002594.XSHE": 201.0, + "300059.XSHE": 13.85, + "300750.XSHE": 183.34, + "600000.XSHG": 7.11, + "600519.XSHG": 1693.94, + "601012.XSHG": 22.22, + "601318.XSHG": 43.17, + "688981.XSHG": 47.86 + }, + "target_weights": { + "000001.XSHE": 0.0, + "000333.XSHE": 0.0, + "000651.XSHE": 0.0, + "002415.XSHE": 0.0, + "002594.XSHE": 0.0, + "300059.XSHE": 0.0, + "300750.XSHE": 0.0, + "600000.XSHG": 0.0, + "600519.XSHG": 0.1, + "601012.XSHG": 0.1, + "601318.XSHG": 0.1, + "688981.XSHG": 0.1 + }, + "target_quantities": { + "000001.XSHE": 0, + "000333.XSHE": 0, + "000651.XSHE": 0, + "002415.XSHE": 0, + "002594.XSHE": 0, + "300059.XSHE": 0, + "300750.XSHE": 0, + "600000.XSHG": 0, + "600519.XSHG": 0, + "601012.XSHG": 4500, + "601318.XSHG": 2300, + "688981.XSHG": 2000 + }, + "lot_rounding": { + "symbol": "600519.XSHG", + "target_weight": 0.1, + "day_open": 1693.94, + "board_lot": 100, + "target_notional_cny": 100000, + "one_board_lot_notional_cny": 169394, + "target_quantity": 0, + "reason": "one board lot costs more than target notional" + }, + "orders": [ + { + "security": "601012.XSHG", + "target_quantity": 4500, + "submitted_quantity": 4500, + "style": "market", + "status": "held" + }, + { + "security": "601318.XSHG", + "target_quantity": 2300, + "submitted_quantity": 2300, + "style": "market", + "status": "held" + }, + { + "security": "688981.XSHG", + "target_quantity": 2000, + "submitted_quantity": 2000, + "style": "limit", + "protection_price": 57.59, + "status": "held" + } + ], + "fills": [ + { + "security": "601012.XSHG", + "quantity": 4500, + "price": 22.22, + "time": "2024-03-12T09:30:00" + }, + { + "security": "601318.XSHG", + "quantity": 2300, + "price": 43.18, + "time": "2024-03-12T09:30:00" + }, + { + "security": "688981.XSHG", + "quantity": 2000, + "price": 47.87, + "time": "2024-03-12T09:30:00" + } + ], + "counts": { + "orders": 3, + "fills": 3, + "skipped_orders": 0 + }, + "performance": { + "strategy_return": -0.0029, + "annualized_return": -0.2118, + "excess_return": -0.0149, + "benchmark_return": 0.0122, + "alpha": -0.341, + "beta": 0.052, + "sharpe": -4.712, + "max_drawdown": 0.0047, + "final_total_value_cny": 997148.04076, + "scope": "three-session execution wiring smoke only", + "investment_value_claim": false + }, + "claim_boundary": { + "real_platform_observed_layers": [ + "execution" + ], + "upstream_layers_computed_in_joinquant": [], + "upstream_layers_real_platform_observed": false, + "authorized_real_data_oos_observed": false, + "qmt_peer_observed": false, + "same_input_cross_engine_parity_observed": false, + "all_five_layers_real_platform_observed": false, + "g9_passed": false, + "baseline_60_passed": false, + "release_verified": false, + "investment_value_claim": false, + "note": "This run proves only the hosted TargetPackage consumer, account/price binding, order submission, and fills for one synthetic-research package." + }, + "redaction": { + "contains_account_identifier": false, + "contains_cookie": false, + "contains_token": false, + "contains_credentials": false, + "contains_personal_information": false, + "raw_screenshots_or_logs_included": false + } +} diff --git a/gate_scorecard.json b/gate_scorecard.json index 511377a..d037090 100644 --- a/gate_scorecard.json +++ b/gate_scorecard.json @@ -33,22 +33,31 @@ "current_delivery": { "score": null, "status": "not_scored", - "reason": "Quant OS 已有可执行本地流水线、真实 Qlib fixture smoke、带校验但不完整的 Tushare→Qlib 技术 smoke,以及一次完成的真实聚宽 hosted smoke;但仍缺完整复权 PIT 数据、同输入聚宽比较、真实 QMT、券商影子盘、连续对账与合规证据,因此不能计入 Baseline 60。", + "reason": "Quant OS 已接通本地 synthetic 五层候选链,能冻结 Ridge ModelBundle、逐层 trace 和无股数 TargetPackageV1;一个 synthetic-research TargetPackage 已在真实 JoinQuant hosted 环境完成消费、账户/开盘价绑定、3 笔委托和 3 笔成交,因此 execution 层已有真实平台观察。但真实 provider-data 长样本/OOS 尚未接入,JoinQuant 没有重跑上游 Universe/Alpha/Portfolio/Risk,QMT peer 与逐层真实数据 parity 仍缺失,factor risk、CVXPY 和 guarded TWAP/POV 也尚未进入当前主链。因此它仍是 NOT_BASELINE_60,G9 未通过。", "verified_supporting_facts": [ - "The latest standard-library suite completed 224 tests with OK and 6 optional-runtime skips; the CPython 3.12 pyqlib environment completed the same 224 tests with the native Qlib smoke enabled and no skips.", + "The current standard-library suite completed 285 tests with OK and 6 optional-runtime skips; successful local tests do not prove real-platform, broker or compliance gates.", + "The local synthetic research entrypoint now executes one causally linked Universe → Ridge Alpha → cost/risk-aware Portfolio → post-risk gate → reference Execution path, freezes a replayable ModelBundle, and publishes a hash-linked TargetPackageV1 and five-layer trace. This is structural synthetic evidence only.", + "The connected candidate currently uses 60-session diagonal realized variance, explicit spread/fee/square-root-impact cost forecasts, the deterministic constrained optimizer, a post-target risk gate, and reference lot/T+1 order deltas. factor_risk.py, the optional CVXPY solver mode, and guarded TWAP/POV are not selected by the current main slice.", + "JoinQuant and QMT target-package modes verify an exact embedded package tape, match the declared dual clock, bind platform equity/positions/sellable quantities/prices, and reject missing, duplicate or tampered packages without falling back to momentum. Both modes have local harness/contract evidence; JoinQuant execution alone now also has one narrow real hosted smoke.", + "JoinQuant backtest 2457a7c39a276e09e0fabf99e28978e1 ran the exact FINAL4 bundle for 2024-03-11 through 2024-03-13 with CNY 1,000,000, daily Python3 and CSI 500 benchmark. It observed 3/11 and 3/13 no-op, an exact package hit on 3/12 using CURRENT_DATA_DAY_OPEN, exact package weights, three accepted orders and three fills. The input evidence class remains synthetic-research; only execution.real_platform_observed is true, performance_claim is false, and this run earns no G9 or Baseline score.", "The JQData adapter maps raw bars, adjustment factor, previous close, daily limits, pause state, PIT is_st and daily PIT index membership into a semantically revalidated snapshot; it rejects an end date that has not reached the provider's complete daily-bar boundary, and only fake-provider tests have been run so far.", "The local snapshot can drive snapshot-backtest and snapshot-decision with manifest verification; it freezes a cross-checked provider trading calendar, and a T-close signal can bind only to a broker observation in the unique next session's Asia/Shanghai [09:00,09:30) window without collapsing the two clocks.", "CPython 3.12 with pyqlib 0.9.7 completed a native momentum fixture smoke with 24 signals and saved audited portfolio-table hashes, bounds and recomputed metrics.", "Qlib Alpha158, LightGBM and Recorder completed a real local fixture smoke with audited portfolio output; the deterministic two-stock synthetic fixture has no investment-value meaning.", "The local Tushare inventory verified all 93 completed Parquet files against recorded size, SHA-256 and row count. An immutable managed Qlib provider and the same early-history momentum command produced byte-identical evidence JSON twice with PYTHONHASHSEED=0. The mirror is only about 8.2% complete for daily partitions and lacks adjustment, real benchmark, point-in-time membership, ST, suspension and limit data, so production_ready and investment_value_claim remain false and no gate credit is earned.", - "A real JoinQuant daily hosted backtest completed for 2024-01-02 through 2024-12-31 with CNY 1,000,000; the visible completed-run log had zero ERROR/Traceback/order-rejection entries. This proves hosted runtime execution only: exact input/plan/order/fill export and a local same-input peer are still missing.", + "A real JoinQuant daily hosted backtest completed for 2024-01-02 through 2024-12-31 with CNY 1,000,000; the visible completed-run log had zero ERROR/Traceback/order-rejection entries. That run used portable_momentum_smoke, not the Ridge/TargetPackage Baseline candidate, and earns no candidate or cross-engine gate credit.", "The QMT shadow CLI is strictly read-only and produces HMAC account-bound broker observation, broker snapshot and target-diff artifacts. QMT_SHADOW_EVIDENCE_V1 authenticates semantic content, deterministic published bytes, the original decision, planner/engine/operator source and fixed policy; semantic replay verifies exact derived state. Independent local QA completed 58 deterministic scenarios, 500 malformed fuzz cases and 300 legal-observation fuzz cases with no P0/P1. This is Quant OS publisher integrity, not broker attestation, and it still has only fake-broker evidence.", "JoinQuant/QMT mock parity is mock_contract_only, real_platform_pass is false and it earns no G9 gate credit." ], + "internal_delivery_gaps": [ + "The authorized provider-data path has not yet been connected end to end to the frozen Ridge ModelBundle, calibrated risk/cost inputs, five-layer Baseline candidate and TargetPackage tape; the current connected candidate remains synthetic.", + "factor_risk.py, the optional CVXPY optimizer mode and guarded TWAP/POV exist as components or optional paths but are not selected by the current Baseline main slice; the connected risk model is 60-session diagonal realized variance and execution is the reference one-shot planner.", + "One synthetic-research TargetPackageV1 has been observed at the real JoinQuant execution boundary, but JoinQuant did not recompute the upstream four layers; there is no authorized real-data multi-period tape, same-input layer-by-layer parity, or real QMT peer." + ], "external_evidence_missing": [ - "authorized JQData market snapshot and licence/lineage record", - "complete JoinQuant hosted input/plan/order/fill export and same-input local comparison", - "real QMT built-in and qmttools backtest exports", + "authorized JQData market snapshot, licence/lineage record and a frozen provider-data candidate tape", + "authorized real-data multi-period JoinQuant TargetPackage run plus same-input local/JoinQuant layer-by-layer reconciliation", + "real QMT TargetPackage built-in and qmttools backtest exports", "real QMT historical-ST positive control, terminal/xtquant build record, broker query contract and trade-amount semantic probes, callback/restart and at least 20 trading days of shadow evidence", "continuous reconciliation, recovery drills and broker compliance confirmation" ] diff --git a/platforms/fake_joinquant_harness.py b/platforms/fake_joinquant_harness.py index 86993c5..8a1ab8a 100644 --- a/platforms/fake_joinquant_harness.py +++ b/platforms/fake_joinquant_harness.py @@ -43,8 +43,10 @@ class FakeJoinQuantHarness(object): is_st=False, high_limit=9999.0, low_limit=0.01, + day_open=float(values[-1]), + last_price=float(values[-1]), ) - for symbol in histories + for symbol, values in self.histories.items() } self.context = SimpleNamespace( current_dt=datetime.datetime(2026, 7, 20, 9, 30), diff --git a/platforms/joinquant_strategy.py b/platforms/joinquant_strategy.py index 3b9bfc3..aea77d0 100644 --- a/platforms/joinquant_strategy.py +++ b/platforms/joinquant_strategy.py @@ -6,9 +6,18 @@ tool replaces the marked import block with the exact portable core source. """ # __PORTABLE_CORE_BUNDLE_START__ -from quant60.portable_core import build_rebalance_plan, convert_symbol +from quant60.portable_core import ( + build_rebalance_plan, + build_target_weight_plan, + convert_symbol, + lookup_target_package_exact, + target_package_tape_sha256, + verify_target_package, +) # __PORTABLE_CORE_BUNDLE_END__ +import base64 as _q60_base64 +import json as _q60_json import math @@ -26,6 +35,9 @@ class ClockStateError(RuntimeError): # __BASELINE_CONFIG_START__ CONFIG = { + "decision_mode": "portable_momentum_smoke", + "target_package_count": 0, + "target_package_tape_sha256": None, "universe_mode": "pit_index", "index_symbol": "000905.XSHG", "qmt_sector_name": "中证500", @@ -56,21 +68,362 @@ CONFIG = { } # __BASELINE_CONFIG_END__ +# __TARGET_PACKAGE_TAPE_START__ +TARGET_PACKAGES = [] +# __TARGET_PACKAGE_TAPE_END__ + + +MOMENTUM_SMOKE_MODE = "portable_momentum_smoke" +TARGET_PACKAGE_MODE = "target_package" +EVIDENCE_PREFIX = "QUANT60_JOINQUANT_EVIDENCE_V1 " +EVIDENCE_CHUNK_CHARACTERS = 1800 +TARGET_PRICE_SOURCE = "CURRENT_DATA_DAY_OPEN" + + +def _evidence_value(value, depth=0): + """Return stable JSON data without allowing observation to stop trading.""" + if depth > 8: + return "" + if value is None or isinstance(value, (bool, int, str)): + return value + if isinstance(value, float): + return value if math.isfinite(value) else None + if isinstance(value, dict): + return { + str(key): _evidence_value(item, depth + 1) + for key, item in sorted( + value.items(), + key=lambda pair: str(pair[0]), + ) + } + if isinstance(value, (list, tuple)): + return [_evidence_value(item, depth + 1) for item in value] + isoformat = getattr(value, "isoformat", None) + if callable(isoformat): + try: + return str(isoformat()) + except Exception: + return "" + try: + return str(value) + except Exception: + return "" + + +def _evidence_context_time(context): + try: + return _evidence_value(getattr(context, "current_dt", None)) + except Exception: + return None + + +def _emit_evidence( + event, + payload=None, + context=None, + chunk_index=None, + chunk_count=None, +): + """Print one prefixed canonical JSON record and always fail soft.""" + try: + sequence = int( + getattr(g, "quant60_evidence_sequence", 0) or 0 + ) + 1 + g.quant60_evidence_sequence = sequence + record = { + "context_time": _evidence_context_time(context), + "decision_mode": getattr( + g, + "quant60_decision_mode", + CONFIG.get("decision_mode"), + ), + "event": str(event), + "payload": _evidence_value(payload or {}), + "schema_version": "1.0", + "sequence": sequence, + "source": "joinquant_hosted", + } + if chunk_index is not None or chunk_count is not None: + if ( + type(chunk_index) is not int + or type(chunk_count) is not int + or chunk_index <= 0 + or chunk_count <= 0 + or chunk_index > chunk_count + ): + return None + record["chunk_index"] = chunk_index + record["chunk_count"] = chunk_count + encoded = _q60_json.dumps( + record, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + print(EVIDENCE_PREFIX + encoded) + except Exception: + # Evidence is observational. A logging/serialization failure must + # never alter an already verified decision or hosted order path. + return None + return record + + +def _emit_chunked_evidence(event, payload, context=None): + """Emit reconstructable base64 chunks of one canonical JSON payload.""" + try: + canonical = _q60_json.dumps( + _evidence_value(payload), + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("ascii") + encoded = _q60_base64.b64encode(canonical).decode("ascii") + chunks = [ + encoded[index : index + EVIDENCE_CHUNK_CHARACTERS] + for index in range(0, len(encoded), EVIDENCE_CHUNK_CHARACTERS) + ] or [""] + count = len(chunks) + for index, content in enumerate(chunks, 1): + _emit_evidence( + event, + { + "chunk_encoding": "base64-canonical-json", + "content": content, + "encoded_length": len(encoded), + }, + context, + chunk_index=index, + chunk_count=count, + ) + except Exception: + return None + return count + + +def _observe_object(value, fields): + if value is None: + return None + output = {"object_type": type(value).__name__} + for name in fields: + try: + item = getattr(value, name) + except Exception: + continue + if callable(item): + continue + output[name] = _evidence_value(item) + return output + + +def _observe_order(value): + return _observe_object( + value, + ( + "order_id", + "add_time", + "security", + "amount", + "filled", + "price", + "avg_cost", + "side", + "action", + "status", + "target_quantity", + ), + ) + + +def _observe_trade(value): + return _observe_object( + value, + ( + "trade_id", + "order_id", + "time", + "security", + "amount", + "price", + ), + ) + + +def _observe_platform_collection(api_name, observer): + """Read an optional hosted collection without assuming API availability.""" + function = globals().get(api_name) + if not callable(function): + return { + "available": False, + "reason": "API_UNAVAILABLE", + } + try: + raw = function() + except Exception as exc: + return { + "available": False, + "error_type": type(exc).__name__, + "reason": "API_CALL_FAILED", + } + if hasattr(raw, "items"): + items = [ + { + "key": str(key), + "value": observer(value), + } + for key, value in sorted( + raw.items(), + key=lambda pair: str(pair[0]), + ) + ] + elif isinstance(raw, (list, tuple)): + items = [observer(value) for value in raw] + elif raw is None: + items = [] + else: + items = [observer(raw)] + return { + "available": True, + "count": len(items), + "items": items, + } + + +def _observe_portfolio(context): + try: + portfolio = context.portfolio + except Exception as exc: + return { + "available": False, + "error_type": type(exc).__name__, + } + output = { + "available": True, + "fields": {}, + "positions": [], + } + for name in ( + "total_value", + "cash", + "available_cash", + "positions_value", + "returns", + ): + try: + value = getattr(portfolio, name) + except Exception: + continue + output["fields"][name] = _evidence_value(value) + try: + positions = getattr(portfolio, "positions", {}) + if hasattr(positions, "items"): + for symbol, position in sorted( + positions.items(), + key=lambda pair: str(pair[0]), + ): + item = _observe_object( + position, + ( + "total_amount", + "closeable_amount", + "price", + "avg_cost", + "value", + ), + ) + item["symbol"] = str(symbol) + output["positions"].append(item) + except Exception as exc: + output["positions_error_type"] = type(exc).__name__ + return output + + +def _plan_evidence(plan): + if not isinstance(plan, dict): + return None + return { + "decision_mode": plan.get("decision_mode"), + "execution_contract": plan.get("execution_contract"), + "lineage": plan.get("lineage"), + "orders": plan.get("orders"), + "targets": plan.get("targets"), + "universe": plan.get("universe"), + "weights": plan.get("weights"), + } + + +def _validated_target_packages(config): + mode = config.get("decision_mode") + if mode not in (MOMENTUM_SMOKE_MODE, TARGET_PACKAGE_MODE): + raise ValueError("unsupported decision_mode") + if not isinstance(TARGET_PACKAGES, list): + raise ValueError("TARGET_PACKAGES must be a list") + expected_count = config.get("target_package_count") + expected_sha256 = config.get("target_package_tape_sha256") + if mode == MOMENTUM_SMOKE_MODE: + if ( + type(expected_count) is not int + or expected_count != 0 + or expected_sha256 is not None + or TARGET_PACKAGES + ): + raise ValueError( + "portable_momentum_smoke cannot carry target packages" + ) + return [] + if type(expected_count) is not int or expected_count <= 0: + raise ValueError( + "target_package mode requires a positive target_package_count" + ) + if expected_count != len(TARGET_PACKAGES): + raise ValueError("target package count mismatch") + actual_sha256 = target_package_tape_sha256(TARGET_PACKAGES) + if expected_sha256 != actual_sha256: + raise ValueError("target package tape sha256 mismatch") + verified = [] + package_ids = set() + decision_ids = set() + clocks = set() + for raw_package in TARGET_PACKAGES: + package = verify_target_package(raw_package) + package_id = package["package_id"] + decision_id = package["decision_id"] + clock = (package["signal_as_of"], package["next_session"]) + if ( + package_id in package_ids + or decision_id in decision_ids + or clock in clocks + ): + raise ValueError("duplicate target package decision identity") + package_ids.add(package_id) + decision_ids.add(decision_id) + clocks.add(clock) + verified.append(package) + return verified + def initialize(context): - """Configure a daily-open clock that executes a first-week-close signal.""" + """Configure the selected daily-open decision mode.""" set_option("avoid_future_data", True) set_option("use_real_price", True) + g.quant60_evidence_sequence = 0 + g.quant60_order_observations = [] g.quant60_config = dict(CONFIG) - if g.quant60_config.get("rebalance_schedule") != "weekly_first_close": - raise ValueError("unsupported rebalance_schedule") - if g.quant60_config.get("universe_mode") not in ("pit_index", "fixed"): - raise ValueError("unsupported universe_mode") - if ( - g.quant60_config.get("universe_mode") == "pit_index" - and not g.quant60_config.get("dedicated_account_required") - ): - raise ValueError("pit_index mode requires a dedicated account") + g.quant60_target_packages = _validated_target_packages( + g.quant60_config + ) + g.quant60_decision_mode = g.quant60_config["decision_mode"] + if g.quant60_config["decision_mode"] == MOMENTUM_SMOKE_MODE: + if g.quant60_config.get("rebalance_schedule") != "weekly_first_close": + raise ValueError("unsupported rebalance_schedule") + if g.quant60_config.get("universe_mode") not in ("pit_index", "fixed"): + raise ValueError("unsupported universe_mode") + if ( + g.quant60_config.get("universe_mode") == "pit_index" + and not g.quant60_config.get("dedicated_account_required") + ): + raise ValueError("pit_index mode requires a dedicated account") set_benchmark(g.quant60_config["index_symbol"]) commission = ( float(g.quant60_config["commission_rate"]) @@ -108,6 +461,30 @@ def initialize(context): g.quant60_pending_first_session = None g.quant60_last_callback_date = None run_daily(rebalance, time="open") + _emit_evidence( + "INIT", + { + "benchmark": g.quant60_config["index_symbol"], + "package_count": len(g.quant60_target_packages), + "package_ids": [ + package["package_id"] + for package in g.quant60_target_packages + ], + "rebalance_callback": "run_daily_open", + "target_package_tape_sha256": g.quant60_config.get( + "target_package_tape_sha256" + ), + }, + context, + ) + + +def _decision_mode(): + configured = g.quant60_config.get("decision_mode") + frozen = getattr(g, "quant60_decision_mode", configured) + if configured != frozen: + raise ValueError("decision_mode cannot change after initialize") + return frozen def _close_history(symbol, count): @@ -262,7 +639,10 @@ def _snapshot_portfolio(context, managed): current = {} sellable = {} managed = set(convert_symbol(symbol, "canonical") for symbol in managed) - dynamic = g.quant60_config.get("universe_mode") == "pit_index" + dynamic = ( + _decision_mode() == MOMENTUM_SMOKE_MODE + and g.quant60_config.get("universe_mode") == "pit_index" + ) positions = getattr(context.portfolio, "positions", {}) for symbol, position in positions.items(): canonical = convert_symbol(str(symbol), "canonical") @@ -342,8 +722,156 @@ def _order_request(symbol, delta): return {"style": style} +def _target_package_for_open(context): + current_date = _as_date(getattr(context, "current_dt", None), "current_dt") + previous_date = _as_date( + getattr(context, "previous_date", None), + "previous_date", + ) + signal_as_of = ( + previous_date.isoformat() + "T15:00:00+08:00" + ) + next_session = current_date.isoformat() + config = g.quant60_config + packages = _validated_target_packages(config) + matches = [ + package + for package in packages + if package["signal_as_of"] == signal_as_of + and package["next_session"] == next_session + ] + if not matches: + _emit_evidence( + "TARGET_PACKAGE_NOOP", + { + "fallback_invoked": False, + "next_session": next_session, + "orders_submitted": 0, + "package_count": len(packages), + "reason": "NO_EXACT_CLOCK_MATCH", + "signal_as_of": signal_as_of, + "target_package_tape_sha256": config[ + "target_package_tape_sha256" + ], + }, + context, + ) + return None + if len(matches) != 1: + raise ValueError("ambiguous target package clock") + package = lookup_target_package_exact( + packages, + signal_as_of, + next_session, + decision_id=matches[0]["decision_id"], + expected_tape_sha256=config["target_package_tape_sha256"], + expected_count=config["target_package_count"], + ) + _emit_evidence( + "TARGET_PACKAGE_HIT", + { + "lineage": _target_lineage(package), + "target_package_tape_sha256": config[ + "target_package_tape_sha256" + ], + }, + context, + ) + return package + + +def _target_prices(symbols): + try: + current_data = get_current_data() + except Exception as exc: + raise PriceHistoryUnavailable( + "current_data is unavailable: %s" % exc + ) + prices = {} + for canonical in sorted(symbols): + symbol = convert_symbol(canonical, "joinquant") + try: + raw_price = getattr(current_data[symbol], "day_open") + price = float(raw_price) + except (KeyError, AttributeError, TypeError, ValueError): + raise PriceHistoryUnavailable( + "%s has no usable current day_open" % symbol + ) + if not math.isfinite(price) or price <= 0.0: + raise PriceHistoryUnavailable( + "%s has no usable current day_open" % symbol + ) + prices[canonical] = price + return prices + + +def _target_lineage(package): + return { + "package_id": package["package_id"], + "package_sha256": package["package_sha256"], + "decision_id": package["decision_id"], + "signal_as_of": package["signal_as_of"], + "next_session": package["next_session"], + "release": package["release"], + "model_id": package["model_id"], + } + + +def _compute_target_package_plan(context): + package = _target_package_for_open(context) + if package is None: + return None + managed = sorted(package["universe"]) + current, sellable, equity = _snapshot_portfolio(context, managed) + if equity <= 0: + raise ValueError("portfolio.total_value must be positive") + prices = _target_prices( + set(package["target_weights"]) | set(current) + ) + _emit_evidence( + "PLATFORM_BINDING_INPUT", + { + "current": current, + "equity": equity, + "lineage": _target_lineage(package), + "price_source": TARGET_PRICE_SOURCE, + "prices": prices, + "sellable": sellable, + }, + context, + ) + plan = build_target_weight_plan( + target_weights=package["target_weights"], + prices=prices, + current=current, + sellable=sellable, + equity=equity, + cash_buffer=g.quant60_config["cash_buffer"], + lot_size=g.quant60_config["lot_size"], + decision_id=package["decision_id"], + package_sha256=package["package_sha256"], + ) + plan["decision_mode"] = TARGET_PACKAGE_MODE + plan["lineage"] = _target_lineage(package) + plan["universe"] = package["universe"] + plan["execution_contract"] = { + "signal_price_source": TARGET_PRICE_SOURCE, + "submit_trigger": "DECLARED_NEXT_SESSION_OPEN_CALLBACK", + "declared_next_session": package["next_session"], + "real_platform_observed": False, + } + _emit_evidence( + "TARGET_PACKAGE_PLAN", + _plan_evidence(plan), + context, + ) + return plan + + def compute_plan(context): - """Return the portable plan without submitting orders.""" + """Return the selected portable plan without submitting orders.""" + if _decision_mode() == TARGET_PACKAGE_MODE: + return _compute_target_package_plan(context) config = g.quant60_config required = int(config["lookback"]) + int(config["skip"]) + 1 platform_universe = _decision_universe(context) @@ -375,13 +903,17 @@ def compute_plan(context): return plan -def _execute_rebalance(context): - """Submit the portable plan's exact executable share deltas.""" - plan = compute_plan(context) +def _submit_plan(context, plan, managed): + """Submit a portable plan's exact executable share deltas.""" + if plan is None: + return None + observe_target_package = ( + plan.get("decision_mode") == TARGET_PACKAGE_MODE + ) order_deltas = plan["orders"] current, _, unused_equity = _snapshot_portfolio( context, - g.quant60_last_universe or g.quant60_config["universe"], + managed, ) del unused_equity all_symbols = set(current) @@ -401,23 +933,107 @@ def _execute_rebalance(context): if delta == 0: continue platform_symbol = convert_symbol(canonical, "joinquant") + skipped_before = len(g.quant60_skipped_orders) request = _order_request(platform_symbol, delta) if request is None: + skipped = g.quant60_skipped_orders[skipped_before:] + if not skipped: + skipped = [ + { + "symbol": platform_symbol, + "reason": "ORDER_REQUEST_NOT_BUILT", + } + ] + if observe_target_package: + for item in skipped: + _emit_evidence( + "SKIPPED_ORDER", + { + "canonical_symbol": canonical, + "delta": delta, + "lineage": plan.get("lineage"), + "skip": item, + }, + context, + ) continue target = int(current.get(canonical, 0)) + delta - if request["style"] is None: - order_target(platform_symbol, target) - else: - order_target( - platform_symbol, - target, - style=request["style"], + request_evidence = { + "canonical_symbol": canonical, + "current_quantity": int(current.get(canonical, 0)), + "delta": delta, + "lineage": plan.get("lineage"), + "platform_symbol": platform_symbol, + "style": _observe_object( + request["style"], + ("kind", "limit_price"), + ), + "target_quantity": target, + } + if observe_target_package: + _emit_evidence( + "ORDER_REQUEST", + request_evidence, + context, + ) + try: + if request["style"] is None: + order_result = order_target(platform_symbol, target) + else: + order_result = order_target( + platform_symbol, + target, + style=request["style"], + ) + except Exception as exc: + if observe_target_package: + _emit_evidence( + "ORDER_RETURN", + { + "error_type": type(exc).__name__, + "lineage": plan.get("lineage"), + "outcome": "EXCEPTION", + "platform_symbol": platform_symbol, + "target_quantity": target, + }, + context, + ) + raise + observation = { + "lineage": plan.get("lineage"), + "order": _observe_order(order_result), + "outcome": "RETURNED", + "platform_symbol": platform_symbol, + "target_quantity": target, + } + if observe_target_package: + g.quant60_order_observations.append(observation) + _emit_evidence( + "ORDER_RETURN", + observation, + context, ) g.quant60_last_plan = plan return plan +def _execute_rebalance(context): + """Submit the selected portable plan's exact executable share deltas.""" + plan = compute_plan(context) + if plan is None: + return None + managed = ( + sorted(plan["universe"]) + if "universe" in plan + else ( + g.quant60_last_universe + or g.quant60_config["universe"] + ) + ) + return _submit_plan(context, plan, managed) + + def _as_date(value, name): if hasattr(value, "date"): value = value.date() @@ -439,6 +1055,16 @@ def rebalance(context): ) if g.quant60_last_callback_date == current_date: return None + if _decision_mode() == TARGET_PACKAGE_MODE: + # EOD evidence is session-scoped. Clear the prior session only after + # the duplicate-date guard so a same-day retry cannot erase evidence. + g.quant60_last_plan = None + g.quant60_order_observations = [] + g.quant60_skipped_orders = [] + # Consume the daily clock token before crossing the hosted order + # boundary so callback retries cannot duplicate an accepted prefix. + g.quant60_last_callback_date = current_date + return _execute_rebalance(context) pending = g.quant60_pending_first_session is_first_session = ( previous_date.isocalendar()[:2] @@ -463,3 +1089,37 @@ def rebalance(context): g.quant60_pending_first_session = current_date g.quant60_last_callback_date = current_date return None + + +def after_trading_end(context): + """Emit a best-effort end-of-day observation without changing state.""" + if _decision_mode() != TARGET_PACKAGE_MODE: + return None + return _emit_chunked_evidence( + "EOD_STATUS", + { + "last_plan": _plan_evidence( + getattr(g, "quant60_last_plan", None) + ), + "order_returns": getattr( + g, + "quant60_order_observations", + [], + ), + "orders_api": _observe_platform_collection( + "get_orders", + _observe_order, + ), + "portfolio": _observe_portfolio(context), + "skipped_orders": getattr( + g, + "quant60_skipped_orders", + [], + ), + "trades_api": _observe_platform_collection( + "get_trades", + _observe_trade, + ), + }, + context, + ) diff --git a/platforms/qmt_builtin_strategy.py b/platforms/qmt_builtin_strategy.py index 4123706..7b5f564 100644 --- a/platforms/qmt_builtin_strategy.py +++ b/platforms/qmt_builtin_strategy.py @@ -2,7 +2,14 @@ """QMT built-in Python 3.6 strategy wrapper. Keep this source ASCII-only.""" # __PORTABLE_CORE_BUNDLE_START__ -from quant60.portable_core import build_rebalance_plan, convert_symbol +from quant60.portable_core import ( + build_rebalance_plan, + build_target_weight_plan, + convert_symbol, + lookup_target_package_exact, + target_package_tape_sha256, + verify_target_package, +) # __PORTABLE_CORE_BUNDLE_END__ import datetime @@ -34,6 +41,9 @@ g = G() # __BASELINE_CONFIG_START__ CONFIG = { "account_id": "test", + "decision_mode": "portable_momentum_smoke", + "target_package_count": 0, + "target_package_tape_sha256": None, "universe_mode": "pit_index", "index_symbol": "000905.XSHG", "qmt_sector_name": "\u4e2d\u8bc1500", @@ -63,6 +73,76 @@ CONFIG = { } # __BASELINE_CONFIG_END__ +# __TARGET_PACKAGE_TAPE_START__ +TARGET_PACKAGES = [] +# __TARGET_PACKAGE_TAPE_END__ + + +MOMENTUM_SMOKE_MODE = "portable_momentum_smoke" +TARGET_PACKAGE_MODE = "target_package" + + +def _validated_target_packages(config): + mode = config.get("decision_mode") + if mode not in (MOMENTUM_SMOKE_MODE, TARGET_PACKAGE_MODE): + raise UnsupportedStrategyPeriod("unsupported decision_mode") + if not isinstance(TARGET_PACKAGES, list): + raise UnsupportedStrategyPeriod( + "TARGET_PACKAGES must be a list" + ) + expected_count = config.get("target_package_count") + expected_sha256 = config.get("target_package_tape_sha256") + if mode == MOMENTUM_SMOKE_MODE: + if ( + type(expected_count) is not int + or expected_count != 0 + or expected_sha256 is not None + or TARGET_PACKAGES + ): + raise UnsupportedStrategyPeriod( + "portable_momentum_smoke cannot carry target packages" + ) + return [] + if type(expected_count) is not int or expected_count <= 0: + raise UnsupportedStrategyPeriod( + "target_package mode requires a positive target_package_count" + ) + if expected_count != len(TARGET_PACKAGES): + raise UnsupportedStrategyPeriod( + "target package count mismatch" + ) + actual_sha256 = target_package_tape_sha256(TARGET_PACKAGES) + if expected_sha256 != actual_sha256: + raise UnsupportedStrategyPeriod( + "target package tape sha256 mismatch" + ) + verified = [] + package_ids = set() + decision_ids = set() + clocks = set() + signal_clocks = set() + for raw_package in TARGET_PACKAGES: + package = verify_target_package(raw_package) + package_id = package["package_id"] + decision_id = package["decision_id"] + clock = (package["signal_as_of"], package["next_session"]) + signal_as_of = package["signal_as_of"] + if ( + package_id in package_ids + or decision_id in decision_ids + or clock in clocks + or signal_as_of in signal_clocks + ): + raise UnsupportedStrategyPeriod( + "duplicate or ambiguous target package decision identity" + ) + package_ids.add(package_id) + decision_ids.add(decision_id) + clocks.add(clock) + signal_clocks.add(signal_as_of) + verified.append(package) + return verified + def _param(ContextInfo, name, default=None): params = getattr(ContextInfo, "_param", {}) @@ -84,22 +164,25 @@ def init(ContextInfo): override = _param(ContextInfo, "q60_" + key, None) if override is not None: g.config[key] = override - if ( - g.config.get("rebalance_schedule") - != "weekly_first_close" - ): - raise UnsupportedStrategyPeriod( - "unsupported rebalance_schedule" - ) - if g.config.get("universe_mode") not in ("pit_index", "fixed"): - raise UnsupportedStrategyPeriod("unsupported universe_mode") - if ( - g.config.get("universe_mode") == "pit_index" - and not g.config.get("dedicated_account_required") - ): - raise UnsupportedStrategyPeriod( - "pit_index mode requires a dedicated account" - ) + g.target_packages = _validated_target_packages(g.config) + g.decision_mode = g.config["decision_mode"] + if g.config["decision_mode"] == MOMENTUM_SMOKE_MODE: + if ( + g.config.get("rebalance_schedule") + != "weekly_first_close" + ): + raise UnsupportedStrategyPeriod( + "unsupported rebalance_schedule" + ) + if g.config.get("universe_mode") not in ("pit_index", "fixed"): + raise UnsupportedStrategyPeriod("unsupported universe_mode") + if ( + g.config.get("universe_mode") == "pit_index" + and not g.config.get("dedicated_account_required") + ): + raise UnsupportedStrategyPeriod( + "pit_index mode requires a dedicated account" + ) set_commission = getattr(ContextInfo, "set_commission", None) set_slippage = getattr(ContextInfo, "set_slippage", None) if not callable(set_commission) or not callable(set_slippage): @@ -129,6 +212,7 @@ def init(ContextInfo): g.last_plan = None g.last_universe = None g.last_universe_as_of = None + g.last_target_signal_as_of = None if ( g.config.get("universe_mode") == "fixed" and hasattr(ContextInfo, "set_universe") @@ -136,6 +220,16 @@ def init(ContextInfo): ContextInfo.set_universe(g.config["universe"]) +def _decision_mode(): + configured = g.config.get("decision_mode") + frozen = getattr(g, "decision_mode", configured) + if configured != frozen: + raise UnsupportedStrategyPeriod( + "decision_mode cannot change after init" + ) + return frozen + + def _bar_datetime(ContextInfo): timetag = ContextInfo.get_bar_timetag(ContextInfo.barpos) return datetime.datetime.fromtimestamp(float(timetag) / 1000.0) @@ -312,7 +406,10 @@ def _portfolio(ContextInfo, managed): convert_symbol(symbol, "canonical") for symbol in managed ) - dynamic = g.config.get("universe_mode") == "pit_index" + dynamic = ( + _decision_mode() == MOMENTUM_SMOKE_MODE + and g.config.get("universe_mode") == "pit_index" + ) for position in _trade_details(ContextInfo, "position") or []: code = str( _get_attr(position, ["stock_code", "m_strInstrumentID"], "") @@ -358,6 +455,8 @@ def _portfolio(ContextInfo, managed): def compute_plan(ContextInfo, bar_time=None): + if _decision_mode() == TARGET_PACKAGE_MODE: + return _compute_target_package_plan(ContextInfo, bar_time) if bar_time is None: bar_time = _bar_datetime(ContextInfo) membership_timetag = int( @@ -390,6 +489,136 @@ def compute_plan(ContextInfo, bar_time=None): ) +def _signal_as_of(bar_time): + if ( + int(bar_time.hour) != 15 + or int(bar_time.minute) != 0 + or int(bar_time.second) != 0 + or int(bar_time.microsecond) != 0 + ): + raise UnsupportedStrategyPeriod( + "target_package mode requires the completed 15:00 daily close" + ) + return bar_time.strftime("%Y-%m-%dT15:00:00+08:00") + + +def _target_package_for_close(bar_time): + signal_as_of = _signal_as_of(bar_time) + config = g.config + packages = _validated_target_packages(config) + matches = [ + package + for package in packages + if package["signal_as_of"] == signal_as_of + ] + if not matches: + return None + if len(matches) != 1: + raise UnsupportedStrategyPeriod( + "ambiguous target package signal clock" + ) + package = matches[0] + return lookup_target_package_exact( + packages, + signal_as_of, + package["next_session"], + decision_id=package["decision_id"], + expected_tape_sha256=config["target_package_tape_sha256"], + expected_count=config["target_package_count"], + ) + + +def _target_close_prices(ContextInfo, bar_time, symbols): + platform_symbols = [ + convert_symbol(symbol, "qmt") + for symbol in sorted(symbols) + ] + raw = ContextInfo.get_market_data_ex( + fields=["close"], + stock_code=platform_symbols, + period="1d", + start_time="", + end_time=bar_time.strftime("%Y%m%d"), + count=1, + dividend_type="front_ratio", + fill_data=False, + subscribe=False, + ) + if not hasattr(raw, "get"): + raise PriceHistoryUnavailable( + "target close query returned an invalid payload" + ) + prices = {} + for symbol in platform_symbols: + values = _series_close(raw.get(symbol)) + if len(values) != 1: + raise PriceHistoryUnavailable( + "%s needs one completed daily close" % symbol + ) + try: + price = float(values[0]) + except (TypeError, ValueError): + raise PriceHistoryUnavailable( + "%s contains a missing/non-numeric close" % symbol + ) + if not math.isfinite(price) or price <= 0.0: + raise PriceHistoryUnavailable( + "%s contains a missing/non-positive close" % symbol + ) + prices[convert_symbol(symbol, "canonical")] = price + return prices + + +def _target_lineage(package): + return { + "package_id": package["package_id"], + "package_sha256": package["package_sha256"], + "decision_id": package["decision_id"], + "signal_as_of": package["signal_as_of"], + "next_session": package["next_session"], + "release": package["release"], + "model_id": package["model_id"], + } + + +def _compute_target_package_plan(ContextInfo, bar_time=None): + if bar_time is None: + bar_time = _bar_datetime(ContextInfo) + package = _target_package_for_close(bar_time) + if package is None: + return None + managed = sorted(package["universe"]) + current, sellable, equity = _portfolio(ContextInfo, managed) + if equity <= 0: + raise ValueError("QMT account equity must be positive") + plan = build_target_weight_plan( + target_weights=package["target_weights"], + prices=_target_close_prices( + ContextInfo, + bar_time, + set(package["target_weights"]) | set(current), + ), + current=current, + sellable=sellable, + equity=equity, + cash_buffer=g.config["cash_buffer"], + lot_size=g.config["lot_size"], + decision_id=package["decision_id"], + package_sha256=package["package_sha256"], + ) + plan["decision_mode"] = TARGET_PACKAGE_MODE + plan["lineage"] = _target_lineage(package) + plan["universe"] = package["universe"] + plan["execution_contract"] = { + "signal_price_source": "COMPLETED_DAILY_CLOSE", + "submit_trigger": "NEXT_BAR_FIRST_TICK", + "quick_trade": 0, + "declared_next_session": package["next_session"], + "real_platform_observed": False, + } + return plan + + def _orders_allowed(ContextInfo): # This hosted wrapper is deliberately backtest-only. Its cached account # query and in-memory weekly marker cannot provide crash-safe live @@ -452,12 +681,76 @@ def _submit_delta(ContextInfo, canonical, delta, week_key): ) +def _submit_target_delta(ContextInfo, canonical, delta, package_sha256): + config = g.config + order_code = convert_symbol(canonical, "qmt") + operation = 23 if delta > 0 else 24 + volume = abs(int(delta)) + user_order_id = "q60t-%s-%s" % ( + str(package_sha256)[:8], + order_code.split(".")[0], + ) + function = globals()["passorder"] + arg_count = getattr(getattr(function, "__code__", None), "co_argcount", 11) + if arg_count <= 8: + function( + operation, + 1101, + config["account_id"], + order_code, + 5, + -1, + volume, + ContextInfo, + ) + else: + function( + operation, + 1101, + config["account_id"], + order_code, + 5, + -1, + volume, + "quant60", + 0, + user_order_id, + ContextInfo, + ) + + def handlebar(ContextInfo): if not _is_backtest(ContextInfo) and hasattr(ContextInfo, "is_last_bar"): if not ContextInfo.is_last_bar(): return None bar_time = _bar_datetime(ContextInfo) + if _decision_mode() == TARGET_PACKAGE_MODE: + signal_as_of = _signal_as_of(bar_time) + if g.last_target_signal_as_of == signal_as_of: + return None + plan = compute_plan(ContextInfo, bar_time) + # Consume the clock before crossing passorder so a callback retry + # cannot duplicate an already accepted order prefix. + g.last_target_signal_as_of = signal_as_of + if plan is None: + return None + g.last_plan = plan + if not _orders_allowed(ContextInfo): + return plan + ordered = sorted( + plan["orders"].items(), + key=lambda item: item[1], + ) + for canonical, delta in ordered: + if int(delta) != 0: + _submit_target_delta( + ContextInfo, + canonical, + delta, + plan["lineage"]["package_sha256"], + ) + return plan year, week, unused = bar_time.isocalendar() del unused week_key = (year, week) diff --git a/releases/quant60-research-candidate-v1/reachability.json b/releases/quant60-research-candidate-v1/reachability.json new file mode 100644 index 0000000..97e5bdd --- /dev/null +++ b/releases/quant60-research-candidate-v1/reachability.json @@ -0,0 +1 @@ +{"artifact_type":"quant60-release-reachability","generated_at":"2026-07-26T12:00:00Z","layers":{"alpha":{"implemented":{"artifacts":[{"artifact_id":"alpha-implemented-frozen-model-bundle","kind":"source","path":"src/quant60/model_bundle.py","platform":"repository","sha256":"6714ddee1457962e9ae19dd52af85b38b7535f7ec1ac66a09db0d6f28f431c68"},{"artifact_id":"alpha-implemented-baseline-pipeline","kind":"source","path":"src/quant60/baseline_pipeline.py","platform":"repository","sha256":"1b90439a6ddb9b2c71635fbaa96b0842f485159ca4361597210cdd2a6a5075fe"}],"status":true},"jq_entrypoint_reachable":{"artifacts":[{"artifact_id":"alpha-alpha-lineage-derived-target-via-targetpackagev1-consumer-jq","kind":"entrypoint","path":"platforms/joinquant_strategy.py","platform":"joinquant","sha256":"0c672b64665b7c1d4409ba288b00d1608e976800af3a4f9a6467767ce4def592"}],"status":true},"local_entrypoint_reachable":{"artifacts":[{"artifact_id":"alpha-local-research-smoke-cli","kind":"entrypoint","path":"src/quant60/cli.py","platform":"local","sha256":"dbf0c14267d4cf9795549c9bc5c9373079cdd5e2c5836e39dda91fa8ad460ba5"},{"artifact_id":"alpha-local-research-experiment","kind":"entrypoint","path":"src/quant60/experiment.py","platform":"local","sha256":"b920e3ba78611d855e8daf39af098ed1a07bb30ab2fd264e7e40d34956c7652d"}],"status":true},"qmt_entrypoint_reachable":{"artifacts":[{"artifact_id":"alpha-alpha-lineage-derived-target-via-targetpackagev1-consumer-qmt","kind":"entrypoint","path":"platforms/qmt_builtin_strategy.py","platform":"qmt","sha256":"a1e53dc22ebd9bb01be43675786151606bbed273d8ab883fb933461e379d2727"}],"status":true},"real_platform_observed":{"artifacts":[],"status":false},"unit_tested":{"artifacts":[{"artifact_id":"alpha-unit-tested-five-layer-causality","kind":"test","path":"tests/test_baseline_pipeline.py","platform":"repository","sha256":"28fda6a2a18d7b82a76be8901e8d41b61e05b39921dc6408a769e4cc42aa7c82"},{"artifact_id":"alpha-unit-tested-frozen-model-replay","kind":"test","path":"tests/test_model_bundle.py","platform":"repository","sha256":"a7108772624964177b4ce8baa36d337476b686d81ac0db95acc07c0d9fd161de"}],"status":true}},"execution":{"implemented":{"artifacts":[{"artifact_id":"execution-implemented-portable-execution-core","kind":"source","path":"src/quant60/portable_core.py","platform":"repository","sha256":"f9de9f18e4008bb0fc80edb0b21cae6ea06a61828d89e9b7c11057f98225fc19"},{"artifact_id":"execution-implemented-baseline-pipeline","kind":"source","path":"src/quant60/baseline_pipeline.py","platform":"repository","sha256":"1b90439a6ddb9b2c71635fbaa96b0842f485159ca4361597210cdd2a6a5075fe"}],"status":true},"jq_entrypoint_reachable":{"artifacts":[{"artifact_id":"execution-targetpackagev1-hosted-execution-entrypoint-jq","kind":"entrypoint","path":"platforms/joinquant_strategy.py","platform":"joinquant","sha256":"0c672b64665b7c1d4409ba288b00d1608e976800af3a4f9a6467767ce4def592"}],"status":true},"local_entrypoint_reachable":{"artifacts":[{"artifact_id":"execution-local-research-smoke-cli","kind":"entrypoint","path":"src/quant60/cli.py","platform":"local","sha256":"dbf0c14267d4cf9795549c9bc5c9373079cdd5e2c5836e39dda91fa8ad460ba5"},{"artifact_id":"execution-local-research-experiment","kind":"entrypoint","path":"src/quant60/experiment.py","platform":"local","sha256":"b920e3ba78611d855e8daf39af098ed1a07bb30ab2fd264e7e40d34956c7652d"}],"status":true},"qmt_entrypoint_reachable":{"artifacts":[{"artifact_id":"execution-targetpackagev1-hosted-execution-entrypoint-qmt","kind":"entrypoint","path":"platforms/qmt_builtin_strategy.py","platform":"qmt","sha256":"a1e53dc22ebd9bb01be43675786151606bbed273d8ab883fb933461e379d2727"}],"status":true},"real_platform_observed":{"artifacts":[{"artifact_id":"execution-real-platform-targetpackagev1-smoke-jq","kind":"platform_run","observed_at":"2026-07-26T08:50:15Z","path":"evidence/joinquant/TP-20240311-795dcfba/2457a7c39a276e09e0fabf99e28978e1/run.json","platform":"joinquant","run_id":"2457a7c39a276e09e0fabf99e28978e1","sha256":"9956c5e61bdc1c82bc24f20f24c1d65a201bab2f71c2c9e4e23eb5bd40eca6ce"}],"status":true},"unit_tested":{"artifacts":[{"artifact_id":"execution-unit-tested-five-layer-causality","kind":"test","path":"tests/test_baseline_pipeline.py","platform":"repository","sha256":"28fda6a2a18d7b82a76be8901e8d41b61e05b39921dc6408a769e4cc42aa7c82"},{"artifact_id":"execution-unit-tested-portable-order-plan","kind":"test","path":"tests/test_portable_core.py","platform":"repository","sha256":"2dd13a5e76bf521dbd2ce9f0ccf9fdc1b5acfe9cf4c58ee70380edcc7f8981f1"}],"status":true}},"portfolio":{"implemented":{"artifacts":[{"artifact_id":"portfolio-implemented-optimizer","kind":"source","path":"src/quant60/optimizer.py","platform":"repository","sha256":"df97df0df72b12a47f091ec6593645a1d98e5cb0deff254e950f986aea7283b0"},{"artifact_id":"portfolio-implemented-baseline-pipeline","kind":"source","path":"src/quant60/baseline_pipeline.py","platform":"repository","sha256":"1b90439a6ddb9b2c71635fbaa96b0842f485159ca4361597210cdd2a6a5075fe"}],"status":true},"jq_entrypoint_reachable":{"artifacts":[{"artifact_id":"portfolio-portfolio-weight-via-targetpackagev1-consumer-jq","kind":"entrypoint","path":"platforms/joinquant_strategy.py","platform":"joinquant","sha256":"0c672b64665b7c1d4409ba288b00d1608e976800af3a4f9a6467767ce4def592"}],"status":true},"local_entrypoint_reachable":{"artifacts":[{"artifact_id":"portfolio-local-research-smoke-cli","kind":"entrypoint","path":"src/quant60/cli.py","platform":"local","sha256":"dbf0c14267d4cf9795549c9bc5c9373079cdd5e2c5836e39dda91fa8ad460ba5"},{"artifact_id":"portfolio-local-research-experiment","kind":"entrypoint","path":"src/quant60/experiment.py","platform":"local","sha256":"b920e3ba78611d855e8daf39af098ed1a07bb30ab2fd264e7e40d34956c7652d"}],"status":true},"qmt_entrypoint_reachable":{"artifacts":[{"artifact_id":"portfolio-portfolio-weight-via-targetpackagev1-consumer-qmt","kind":"entrypoint","path":"platforms/qmt_builtin_strategy.py","platform":"qmt","sha256":"a1e53dc22ebd9bb01be43675786151606bbed273d8ab883fb933461e379d2727"}],"status":true},"real_platform_observed":{"artifacts":[],"status":false},"unit_tested":{"artifacts":[{"artifact_id":"portfolio-unit-tested-five-layer-causality","kind":"test","path":"tests/test_baseline_pipeline.py","platform":"repository","sha256":"28fda6a2a18d7b82a76be8901e8d41b61e05b39921dc6408a769e4cc42aa7c82"}],"status":true}},"risk":{"implemented":{"artifacts":[{"artifact_id":"risk-implemented-risk-gate","kind":"source","path":"src/quant60/risk.py","platform":"repository","sha256":"456569f4306d72d9388a6ced85b2906ac577cb52cb263397a8f2b74f72b8b7da"},{"artifact_id":"risk-implemented-baseline-pipeline","kind":"source","path":"src/quant60/baseline_pipeline.py","platform":"repository","sha256":"1b90439a6ddb9b2c71635fbaa96b0842f485159ca4361597210cdd2a6a5075fe"}],"status":true},"jq_entrypoint_reachable":{"artifacts":[{"artifact_id":"risk-post-risk-weight-constraints-via-targetpackagev1-consumer-jq","kind":"entrypoint","path":"platforms/joinquant_strategy.py","platform":"joinquant","sha256":"0c672b64665b7c1d4409ba288b00d1608e976800af3a4f9a6467767ce4def592"}],"status":true},"local_entrypoint_reachable":{"artifacts":[{"artifact_id":"risk-local-research-smoke-cli","kind":"entrypoint","path":"src/quant60/cli.py","platform":"local","sha256":"dbf0c14267d4cf9795549c9bc5c9373079cdd5e2c5836e39dda91fa8ad460ba5"},{"artifact_id":"risk-local-research-experiment","kind":"entrypoint","path":"src/quant60/experiment.py","platform":"local","sha256":"b920e3ba78611d855e8daf39af098ed1a07bb30ab2fd264e7e40d34956c7652d"}],"status":true},"qmt_entrypoint_reachable":{"artifacts":[{"artifact_id":"risk-post-risk-weight-constraints-via-targetpackagev1-consumer-qmt","kind":"entrypoint","path":"platforms/qmt_builtin_strategy.py","platform":"qmt","sha256":"a1e53dc22ebd9bb01be43675786151606bbed273d8ab883fb933461e379d2727"}],"status":true},"real_platform_observed":{"artifacts":[],"status":false},"unit_tested":{"artifacts":[{"artifact_id":"risk-unit-tested-five-layer-causality","kind":"test","path":"tests/test_baseline_pipeline.py","platform":"repository","sha256":"28fda6a2a18d7b82a76be8901e8d41b61e05b39921dc6408a769e4cc42aa7c82"}],"status":true}},"universe":{"implemented":{"artifacts":[{"artifact_id":"universe-implemented-baseline-pipeline","kind":"source","path":"src/quant60/baseline_pipeline.py","platform":"repository","sha256":"1b90439a6ddb9b2c71635fbaa96b0842f485159ca4361597210cdd2a6a5075fe"}],"status":true},"jq_entrypoint_reachable":{"artifacts":[{"artifact_id":"universe-universe-state-via-targetpackagev1-consumer-jq","kind":"entrypoint","path":"platforms/joinquant_strategy.py","platform":"joinquant","sha256":"0c672b64665b7c1d4409ba288b00d1608e976800af3a4f9a6467767ce4def592"}],"status":true},"local_entrypoint_reachable":{"artifacts":[{"artifact_id":"universe-local-research-smoke-cli","kind":"entrypoint","path":"src/quant60/cli.py","platform":"local","sha256":"dbf0c14267d4cf9795549c9bc5c9373079cdd5e2c5836e39dda91fa8ad460ba5"},{"artifact_id":"universe-local-research-experiment","kind":"entrypoint","path":"src/quant60/experiment.py","platform":"local","sha256":"b920e3ba78611d855e8daf39af098ed1a07bb30ab2fd264e7e40d34956c7652d"}],"status":true},"qmt_entrypoint_reachable":{"artifacts":[{"artifact_id":"universe-universe-state-via-targetpackagev1-consumer-qmt","kind":"entrypoint","path":"platforms/qmt_builtin_strategy.py","platform":"qmt","sha256":"a1e53dc22ebd9bb01be43675786151606bbed273d8ab883fb933461e379d2727"}],"status":true},"real_platform_observed":{"artifacts":[],"status":false},"unit_tested":{"artifacts":[{"artifact_id":"universe-unit-tested-five-layer-causality","kind":"test","path":"tests/test_baseline_pipeline.py","platform":"repository","sha256":"28fda6a2a18d7b82a76be8901e8d41b61e05b39921dc6408a769e4cc42aa7c82"}],"status":true}}},"manifest_sha256":"bf3379b1d8d73ea64d73db8317cfa27307b5dc218884ea2710ffe71dd116c017","release_id":"quant60-research-candidate-v1","schema_version":"1.0","summary":{"all_layers_implemented":true,"all_layers_jq_entrypoint_reachable":true,"all_layers_local_entrypoint_reachable":true,"all_layers_qmt_entrypoint_reachable":true,"all_layers_real_platform_observed":false,"all_layers_unit_tested":true}} diff --git a/schemas/model_bundle.schema.json b/schemas/model_bundle.schema.json new file mode 100644 index 0000000..98ce6da --- /dev/null +++ b/schemas/model_bundle.schema.json @@ -0,0 +1,190 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://boat.local/quant60/schemas/model_bundle.schema.json", + "title": "Quant60 deterministic Ridge model bundle", + "description": "Frozen train-only feature transforms, Ridge parameters, training cutoff, label semantics, and lineage hashes for deterministic replay.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "artifact_type", + "bundle_sha256", + "trained_through", + "label", + "lineage", + "preprocessor", + "model" + ], + "properties": { + "schema_version": { + "const": "1.0" + }, + "artifact_type": { + "const": "quant60-deterministic-ridge-bundle" + }, + "bundle_sha256": { + "$ref": "#/$defs/sha256" + }, + "trained_through": { + "type": "string", + "format": "date" + }, + "label": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "horizon_trading_days" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "horizon_trading_days": { + "type": "integer", + "minimum": 1 + } + } + }, + "lineage": { + "type": "object", + "additionalProperties": false, + "required": [ + "data_sha256", + "source_sha256", + "config_sha256" + ], + "properties": { + "data_sha256": { + "$ref": "#/$defs/sha256" + }, + "source_sha256": { + "$ref": "#/$defs/sha256" + }, + "config_sha256": { + "$ref": "#/$defs/sha256" + } + } + }, + "preprocessor": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "winsor_fraction", + "feature_order", + "output_feature_order", + "transforms" + ], + "properties": { + "type": { + "const": "train-only-winsor-impute-standardize" + }, + "winsor_fraction": { + "type": "number", + "minimum": 0, + "maximum": 0.5 + }, + "feature_order": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "output_feature_order": { + "type": "array", + "minItems": 2, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "transforms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/feature_transform" + } + } + } + }, + "model": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "alpha", + "fit_intercept", + "coefficients", + "intercept" + ], + "properties": { + "type": { + "const": "deterministic-ridge" + }, + "alpha": { + "type": "number", + "minimum": 0 + }, + "fit_intercept": { + "type": "boolean" + }, + "coefficients": { + "type": "array", + "minItems": 1, + "items": { + "type": "number" + } + }, + "intercept": { + "type": "number" + } + } + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "feature_transform": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "lower", + "upper", + "median", + "mean", + "scale" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "lower": { + "type": "number" + }, + "upper": { + "type": "number" + }, + "median": { + "type": "number" + }, + "mean": { + "type": "number" + }, + "scale": { + "type": "number", + "exclusiveMinimum": 0 + } + } + } + } +} diff --git a/schemas/release_reachability.schema.json b/schemas/release_reachability.schema.json new file mode 100644 index 0000000..8cc0b66 --- /dev/null +++ b/schemas/release_reachability.schema.json @@ -0,0 +1,221 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://boat.local/quant60/schemas/release_reachability.schema.json", + "title": "Quant60 Release Reachability", + "description": "Per-layer evidence for implementation, tests, entrypoint reachability and real-platform observation. A repository source hash is not runtime evidence.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "artifact_type", + "release_id", + "generated_at", + "layers", + "summary", + "manifest_sha256" + ], + "properties": { + "schema_version": { + "const": "1.0" + }, + "artifact_type": { + "const": "quant60-release-reachability" + }, + "release_id": { + "$ref": "#/$defs/identifier" + }, + "generated_at": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + }, + "layers": { + "$ref": "#/$defs/layers" + }, + "summary": { + "$ref": "#/$defs/summary" + }, + "manifest_sha256": { + "$ref": "#/$defs/sha256" + } + }, + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": [ + "artifact_id", + "kind", + "path", + "sha256", + "platform" + ], + "properties": { + "artifact_id": { + "$ref": "#/$defs/identifier" + }, + "kind": { + "type": "string", + "enum": [ + "source", + "test", + "entrypoint", + "platform_run" + ] + }, + "path": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "$ref": "#/$defs/sha256" + }, + "platform": { + "type": "string", + "enum": [ + "repository", + "local", + "joinquant", + "qmt" + ] + }, + "observed_at": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + }, + "run_id": { + "$ref": "#/$defs/identifier" + } + }, + "dependentRequired": { + "observed_at": [ + "run_id" + ], + "run_id": [ + "observed_at" + ] + } + }, + "claim": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "artifacts" + ], + "properties": { + "status": { + "type": "boolean" + }, + "artifacts": { + "type": "array", + "items": { + "$ref": "#/$defs/artifact" + } + } + } + }, + "layer": { + "type": "object", + "additionalProperties": false, + "required": [ + "implemented", + "unit_tested", + "local_entrypoint_reachable", + "jq_entrypoint_reachable", + "qmt_entrypoint_reachable", + "real_platform_observed" + ], + "properties": { + "implemented": { + "$ref": "#/$defs/claim" + }, + "unit_tested": { + "$ref": "#/$defs/claim" + }, + "local_entrypoint_reachable": { + "$ref": "#/$defs/claim" + }, + "jq_entrypoint_reachable": { + "$ref": "#/$defs/claim" + }, + "qmt_entrypoint_reachable": { + "$ref": "#/$defs/claim" + }, + "real_platform_observed": { + "$ref": "#/$defs/claim" + } + } + }, + "layers": { + "type": "object", + "additionalProperties": false, + "required": [ + "universe", + "alpha", + "portfolio", + "risk", + "execution" + ], + "properties": { + "universe": { + "$ref": "#/$defs/layer" + }, + "alpha": { + "$ref": "#/$defs/layer" + }, + "portfolio": { + "$ref": "#/$defs/layer" + }, + "risk": { + "$ref": "#/$defs/layer" + }, + "execution": { + "$ref": "#/$defs/layer" + } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "all_layers_implemented", + "all_layers_unit_tested", + "all_layers_local_entrypoint_reachable", + "all_layers_jq_entrypoint_reachable", + "all_layers_qmt_entrypoint_reachable", + "all_layers_real_platform_observed" + ], + "properties": { + "all_layers_implemented": { + "type": "boolean" + }, + "all_layers_unit_tested": { + "type": "boolean" + }, + "all_layers_local_entrypoint_reachable": { + "type": "boolean" + }, + "all_layers_jq_entrypoint_reachable": { + "type": "boolean" + }, + "all_layers_qmt_entrypoint_reachable": { + "type": "boolean" + }, + "all_layers_real_platform_observed": { + "type": "boolean" + } + } + } + } +} diff --git a/schemas/target_package.schema.json b/schemas/target_package.schema.json new file mode 100644 index 0000000..447daef --- /dev/null +++ b/schemas/target_package.schema.json @@ -0,0 +1,205 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://boat.local/quant60/schemas/target_package.schema.json", + "title": "Quant60 TargetPackageV1", + "description": "Canonical post-risk portfolio weights. Share quantities, orders, fills and account facts are forbidden and belong to the downstream execution plan.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "package_type", + "package_id", + "decision_id", + "release", + "evidence_class", + "model_id", + "signal_as_of", + "next_session", + "model_sha256", + "data_sha256", + "feature_sha256", + "risk_sha256", + "cost_sha256", + "optimizer_sha256", + "config_sha256", + "source_sha256", + "target_weights", + "universe", + "constraints", + "package_sha256" + ], + "properties": { + "schema_version": { + "const": "1.0" + }, + "package_type": { + "const": "TargetPackageV1" + }, + "package_id": { + "$ref": "#/$defs/identifier" + }, + "decision_id": { + "$ref": "#/$defs/identifier" + }, + "release": { + "$ref": "#/$defs/identifier" + }, + "evidence_class": { + "$ref": "#/$defs/identifier" + }, + "model_id": { + "$ref": "#/$defs/identifier" + }, + "signal_as_of": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T15:00:00\\+08:00$" + }, + "next_session": { + "type": "string", + "format": "date" + }, + "model_sha256": { + "$ref": "#/$defs/sha256" + }, + "data_sha256": { + "$ref": "#/$defs/sha256" + }, + "feature_sha256": { + "$ref": "#/$defs/sha256" + }, + "risk_sha256": { + "$ref": "#/$defs/sha256" + }, + "cost_sha256": { + "$ref": "#/$defs/sha256" + }, + "optimizer_sha256": { + "$ref": "#/$defs/sha256" + }, + "config_sha256": { + "$ref": "#/$defs/sha256" + }, + "source_sha256": { + "$ref": "#/$defs/sha256" + }, + "target_weights": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "$ref": "#/$defs/canonical_symbol" + }, + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "universe": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "$ref": "#/$defs/canonical_symbol" + }, + "additionalProperties": { + "$ref": "#/$defs/universe_member" + } + }, + "constraints": { + "$ref": "#/$defs/constraints" + }, + "package_sha256": { + "$ref": "#/$defs/sha256" + } + }, + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "canonical_symbol": { + "type": "string", + "pattern": "^[0-9]{6}\\.(XSHG|XSHE|XBSE)$" + }, + "universe_member": { + "type": "object", + "additionalProperties": false, + "required": [ + "state", + "reasons" + ], + "properties": { + "state": { + "type": "string", + "enum": [ + "openable", + "hold_only", + "sell_only", + "frozen", + "excluded" + ] + }, + "reasons": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*$" + } + } + } + }, + "constraints": { + "type": "object", + "additionalProperties": false, + "required": [ + "constraint_state", + "long_only", + "max_single_weight", + "max_gross_weight", + "max_one_way_turnover", + "actual_gross_weight" + ], + "properties": { + "constraint_state": { + "type": "string", + "enum": [ + "FEASIBLE", + "REPAIRED", + "RISK_REDUCTION_ONLY", + "NO_TRADE" + ] + }, + "long_only": { + "const": true + }, + "max_single_weight": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1 + }, + "max_gross_weight": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "max_one_way_turnover": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "actual_gross_weight": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + } + } +} diff --git a/src/quant60/baseline_pipeline.py b/src/quant60/baseline_pipeline.py new file mode 100644 index 0000000..d3ae207 --- /dev/null +++ b/src/quant60/baseline_pipeline.py @@ -0,0 +1,502 @@ +"""Canonical five-layer decision pipeline for a Baseline candidate. + +This module is deliberately platform-neutral. Hosted adapters may consume its +post-risk target weights, but they must not silently replace any layer with the +portable momentum smoke strategy. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Iterable, Mapping + +from .ledger import canonical_json +from .optimizer import ( + PortfolioCandidate, + PortfolioConstraints, + PortfolioSolution, + optimize_cvxpy, + optimize_deterministic, +) +from .portable_core import normalize_symbol, order_deltas, target_quantities +from .risk import RiskDecision, check_target_weights +from .universe import UniverseState + + +FIVE_LAYER_ORDER = ( + "universe", + "alpha", + "portfolio", + "risk", + "execution", +) + + +class BaselinePipelineError(ValueError): + """The canonical Baseline decision cannot be built safely.""" + + +def _finite(value: float, name: str) -> float: + numeric = float(value) + if not math.isfinite(numeric): + raise BaselinePipelineError(f"{name} must be finite") + return numeric + + +def _sha256(payload: Any) -> str: + import hashlib + + return hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest() + + +@dataclass(frozen=True, slots=True) +class AlphaForecastInput: + symbol: str + expected_excess_return: float + model_version: str + feature_version: str + + def __post_init__(self) -> None: + object.__setattr__(self, "symbol", normalize_symbol(self.symbol)) + _finite(self.expected_excess_return, "expected_excess_return") + if not str(self.model_version).strip(): + raise BaselinePipelineError("model_version is required") + if not str(self.feature_version).strip(): + raise BaselinePipelineError("feature_version is required") + + +@dataclass(frozen=True, slots=True) +class RiskForecastInput: + symbol: str + predicted_variance: float + risk_model_version: str + + def __post_init__(self) -> None: + object.__setattr__(self, "symbol", normalize_symbol(self.symbol)) + variance = _finite(self.predicted_variance, "predicted_variance") + if variance <= 0: + raise BaselinePipelineError("predicted_variance must be positive") + if not str(self.risk_model_version).strip(): + raise BaselinePipelineError("risk_model_version is required") + + +@dataclass(frozen=True, slots=True) +class CostForecastInput: + symbol: str + base_bps: float + max_adv_weight: float + cost_model_version: str + + def __post_init__(self) -> None: + object.__setattr__(self, "symbol", normalize_symbol(self.symbol)) + base_bps = _finite(self.base_bps, "base_bps") + max_adv_weight = _finite(self.max_adv_weight, "max_adv_weight") + if base_bps < 0: + raise BaselinePipelineError("base_bps must be non-negative") + if not 0 <= max_adv_weight <= 1: + raise BaselinePipelineError("max_adv_weight must lie in [0, 1]") + if not str(self.cost_model_version).strip(): + raise BaselinePipelineError("cost_model_version is required") + + +@dataclass(frozen=True, slots=True) +class SecurityStateInput: + symbol: str + state: UniverseState + industry: str + price: float + current_quantity: int = 0 + sellable_quantity: int = 0 + reasons: tuple[str, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "symbol", normalize_symbol(self.symbol)) + if not isinstance(self.state, UniverseState): + raise BaselinePipelineError("state must be a UniverseState") + if not str(self.industry).strip(): + raise BaselinePipelineError("industry is required") + price = _finite(self.price, "price") + if price <= 0: + raise BaselinePipelineError("price must be positive") + if type(self.current_quantity) is not int or self.current_quantity < 0: + raise BaselinePipelineError( + "current_quantity must be a non-negative integer" + ) + if ( + type(self.sellable_quantity) is not int + or self.sellable_quantity < 0 + or self.sellable_quantity > self.current_quantity + ): + raise BaselinePipelineError( + "sellable_quantity must lie within current_quantity" + ) + if any(not str(reason).strip() for reason in self.reasons): + raise BaselinePipelineError("universe reasons must be non-empty strings") + + +@dataclass(frozen=True, slots=True) +class BaselinePipelinePolicy: + constraints: PortfolioConstraints = PortfolioConstraints() + solver: str = "deterministic" + lot_size: int = 100 + cash_buffer: float = 0.05 + release_role: str = "baseline_candidate" + + def validate(self) -> None: + self.constraints.validate() + if self.solver not in {"deterministic", "cvxpy"}: + raise BaselinePipelineError("solver must be deterministic or cvxpy") + if type(self.lot_size) is not int or self.lot_size <= 0: + raise BaselinePipelineError("lot_size must be a positive integer") + cash_buffer = _finite(self.cash_buffer, "cash_buffer") + if not 0 <= cash_buffer < 1: + raise BaselinePipelineError("cash_buffer must lie in [0, 1)") + if self.release_role not in {"baseline_candidate", "research_candidate"}: + raise BaselinePipelineError("unsupported release_role") + + +@dataclass(frozen=True, slots=True) +class BaselinePipelineResult: + pipeline_id: str + release_role: str + solution: PortfolioSolution + risk_decision: RiskDecision + target_quantities: dict[str, int] + order_deltas: dict[str, int] + layer_payloads: dict[str, dict[str, Any]] + layer_trace: tuple[dict[str, Any], ...] + + def as_dict(self) -> dict[str, Any]: + return { + "pipeline_id": self.pipeline_id, + "release_role": self.release_role, + "weights": self.solution.weights, + "target_quantities": self.target_quantities, + "order_deltas": self.order_deltas, + "portfolio": { + "solver": self.solution.solver, + "status": self.solution.status, + "gross_weight": self.solution.gross_weight, + "cash_weight": self.solution.cash_weight, + "one_way_turnover": self.solution.one_way_turnover, + "predicted_variance": self.solution.predicted_variance, + "expected_score": self.solution.expected_score, + "estimated_cost_bps_weighted": ( + self.solution.estimated_cost_bps_weighted + ), + "diagnostics": self.solution.diagnostics, + }, + "risk": { + "approved": self.risk_decision.approved, + "gross_weight": self.risk_decision.gross_weight, + "one_way_turnover": self.risk_decision.one_way_turnover, + "violations": [ + { + "code": item.code, + "message": item.message, + "symbol": item.symbol, + } + for item in self.risk_decision.violations + ], + }, + "layer_payloads": self.layer_payloads, + "layer_trace": list(self.layer_trace), + } + + +def _unique_by_symbol( + values: Iterable[Any], + *, + name: str, +) -> dict[str, Any]: + result: dict[str, Any] = {} + for item in values: + symbol = normalize_symbol(item.symbol) + if symbol in result: + raise BaselinePipelineError(f"duplicate {name} for {symbol}") + result[symbol] = item + if not result: + raise BaselinePipelineError(f"{name} inputs are empty") + return result + + +def _trace_event( + *, + layer: str, + implementation: str, + input_sha256: str, + output_payload: Mapping[str, Any], + previous_event_sha256: str | None, +) -> dict[str, Any]: + output_sha256 = _sha256(output_payload) + body = { + "sequence": FIVE_LAYER_ORDER.index(layer) + 1, + "layer": layer, + "implementation": implementation, + "input_sha256": input_sha256, + "output_sha256": output_sha256, + "previous_event_sha256": previous_event_sha256, + } + body["event_sha256"] = _sha256(body) + return body + + +def verify_layer_trace( + trace: Iterable[Mapping[str, Any]], + layer_payloads: Mapping[str, Mapping[str, Any]], +) -> None: + events = tuple(dict(item) for item in trace) + if tuple(item.get("layer") for item in events) != FIVE_LAYER_ORDER: + raise BaselinePipelineError("layer trace is not the canonical five-layer path") + previous = None + for sequence, event in enumerate(events, 1): + if event.get("sequence") != sequence: + raise BaselinePipelineError("layer trace sequence is invalid") + if event.get("previous_event_sha256") != previous: + raise BaselinePipelineError("layer trace hash chain is broken") + layer = str(event["layer"]) + if layer not in layer_payloads: + raise BaselinePipelineError(f"missing layer payload for {layer}") + if event.get("output_sha256") != _sha256(layer_payloads[layer]): + raise BaselinePipelineError(f"layer output hash mismatch for {layer}") + body = dict(event) + declared = body.pop("event_sha256", None) + if declared != _sha256(body): + raise BaselinePipelineError(f"layer event hash mismatch for {layer}") + previous = str(declared) + + +def build_baseline_pipeline( + *, + alpha: Iterable[AlphaForecastInput], + risk: Iterable[RiskForecastInput], + cost: Iterable[CostForecastInput], + securities: Iterable[SecurityStateInput], + equity: float, + policy: BaselinePipelinePolicy = BaselinePipelinePolicy(), +) -> BaselinePipelineResult: + """Compose all five layers and return their causally linked decision trace.""" + + policy.validate() + account_equity = _finite(equity, "equity") + if account_equity <= 0: + raise BaselinePipelineError("equity must be positive") + alpha_by_symbol = _unique_by_symbol(alpha, name="alpha") + risk_by_symbol = _unique_by_symbol(risk, name="risk") + cost_by_symbol = _unique_by_symbol(cost, name="cost") + state_by_symbol = _unique_by_symbol(securities, name="universe") + symbol_sets = { + "alpha": set(alpha_by_symbol), + "risk": set(risk_by_symbol), + "cost": set(cost_by_symbol), + "universe": set(state_by_symbol), + } + if len({frozenset(values) for values in symbol_sets.values()}) != 1: + raise BaselinePipelineError( + "alpha/risk/cost/universe symbol sets must match exactly: " + + ", ".join( + f"{name}={sorted(values)}" + for name, values in sorted(symbol_sets.items()) + ) + ) + symbols = sorted(state_by_symbol) + model_versions = {alpha_by_symbol[symbol].model_version for symbol in symbols} + feature_versions = { + alpha_by_symbol[symbol].feature_version for symbol in symbols + } + risk_versions = { + risk_by_symbol[symbol].risk_model_version for symbol in symbols + } + cost_versions = { + cost_by_symbol[symbol].cost_model_version for symbol in symbols + } + for name, versions in ( + ("model", model_versions), + ("feature", feature_versions), + ("risk model", risk_versions), + ("cost model", cost_versions), + ): + if len(versions) != 1: + raise BaselinePipelineError(f"{name} versions are inconsistent") + model_version = next(iter(model_versions)) + if ( + policy.release_role == "baseline_candidate" + and model_version == "portable-momentum-v1" + ): + raise BaselinePipelineError( + "portable-momentum-v1 is a platform smoke strategy, not a " + "Baseline candidate model" + ) + + current_quantities = { + symbol: state_by_symbol[symbol].current_quantity for symbol in symbols + } + sellable = { + symbol: state_by_symbol[symbol].sellable_quantity for symbol in symbols + } + prices = {symbol: state_by_symbol[symbol].price for symbol in symbols} + current_weights = { + symbol: ( + current_quantities[symbol] * prices[symbol] / account_equity + ) + for symbol in symbols + } + candidates = [ + PortfolioCandidate( + symbol=symbol, + score=alpha_by_symbol[symbol].expected_excess_return, + variance=risk_by_symbol[symbol].predicted_variance, + current_weight=current_weights[symbol], + cost_bps=cost_by_symbol[symbol].base_bps, + max_adv_weight=cost_by_symbol[symbol].max_adv_weight, + industry=state_by_symbol[symbol].industry, + state=state_by_symbol[symbol].state, + ) + for symbol in symbols + ] + + universe_payload = { + "symbols": [ + { + "symbol": symbol, + "state": state_by_symbol[symbol].state.value, + "industry": state_by_symbol[symbol].industry, + "reasons": list(state_by_symbol[symbol].reasons), + "current_quantity": current_quantities[symbol], + "sellable_quantity": sellable[symbol], + } + for symbol in symbols + ] + } + alpha_payload = { + "model_version": model_version, + "feature_version": next(iter(feature_versions)), + "forecasts": { + symbol: alpha_by_symbol[symbol].expected_excess_return + for symbol in symbols + }, + } + portfolio_input = { + "alpha_sha256": _sha256(alpha_payload), + "risk_model_version": next(iter(risk_versions)), + "risk": { + symbol: risk_by_symbol[symbol].predicted_variance + for symbol in symbols + }, + "cost_model_version": next(iter(cost_versions)), + "cost_bps": { + symbol: cost_by_symbol[symbol].base_bps for symbol in symbols + }, + "capacity": { + symbol: cost_by_symbol[symbol].max_adv_weight for symbol in symbols + }, + "current_weights": current_weights, + } + solution = ( + optimize_cvxpy(candidates, policy.constraints) + if policy.solver == "cvxpy" + else optimize_deterministic(candidates, policy.constraints) + ) + portfolio_payload = { + "solver": solution.solver, + "status": solution.status, + "weights": solution.weights, + "gross_weight": solution.gross_weight, + "cash_weight": solution.cash_weight, + "one_way_turnover": solution.one_way_turnover, + "predicted_variance": solution.predicted_variance, + "estimated_cost_bps_weighted": solution.estimated_cost_bps_weighted, + "input_sha256": _sha256(portfolio_input), + } + risk_decision = check_target_weights( + solution.weights, + current_weights, + max_single_weight=policy.constraints.single_name_cap, + max_gross_weight=policy.constraints.gross_target, + max_one_way_turnover=policy.constraints.one_way_turnover_cap, + ) + risk_decision.require_approved() + risk_payload = { + "approved": risk_decision.approved, + "gross_weight": risk_decision.gross_weight, + "one_way_turnover": risk_decision.one_way_turnover, + "violations": [], + "post_risk_weights": solution.weights, + } + quantities = target_quantities( + solution.weights, + prices, + account_equity, + lot_size=policy.lot_size, + cash_buffer=policy.cash_buffer, + ) + deltas = order_deltas( + quantities, + current_quantities, + sellable, + lot_size=policy.lot_size, + ) + execution_payload = { + "equity": account_equity, + "prices": prices, + "lot_size": policy.lot_size, + "cash_buffer": policy.cash_buffer, + "target_quantities": quantities, + "order_deltas": deltas, + } + layer_payloads = { + "universe": universe_payload, + "alpha": alpha_payload, + "portfolio": portfolio_payload, + "risk": risk_payload, + "execution": execution_payload, + } + implementation = { + "universe": "quant60.baseline_pipeline:SecurityStateInput", + "alpha": "quant60.baseline_pipeline:AlphaForecastInput", + "portfolio": ( + "quant60.optimizer:optimize_cvxpy" + if policy.solver == "cvxpy" + else "quant60.optimizer:optimize_deterministic" + ), + "risk": "quant60.risk:check_target_weights", + "execution": "quant60.portable_core:target_quantities+order_deltas", + } + trace: list[dict[str, Any]] = [] + previous_event_sha256 = None + previous_output_sha256 = _sha256( + { + "equity": account_equity, + "release_role": policy.release_role, + } + ) + for layer in FIVE_LAYER_ORDER: + event = _trace_event( + layer=layer, + implementation=implementation[layer], + input_sha256=previous_output_sha256, + output_payload=layer_payloads[layer], + previous_event_sha256=previous_event_sha256, + ) + trace.append(event) + previous_event_sha256 = event["event_sha256"] + previous_output_sha256 = event["output_sha256"] + verify_layer_trace(trace, layer_payloads) + pipeline_id = "q60-pipeline-" + _sha256( + { + "release_role": policy.release_role, + "model_version": model_version, + "layers": [event["event_sha256"] for event in trace], + } + )[:16] + return BaselinePipelineResult( + pipeline_id=pipeline_id, + release_role=policy.release_role, + solution=solution, + risk_decision=risk_decision, + target_quantities=quantities, + order_deltas=deltas, + layer_payloads=layer_payloads, + layer_trace=tuple(trace), + ) diff --git a/src/quant60/experiment.py b/src/quant60/experiment.py index beabcdf..7dd60c2 100644 --- a/src/quant60/experiment.py +++ b/src/quant60/experiment.py @@ -13,20 +13,33 @@ from datetime import date from pathlib import Path from typing import Any +from .baseline_pipeline import ( + AlphaForecastInput, + BaselinePipelinePolicy, + CostForecastInput, + RiskForecastInput, + SecurityStateInput, + build_baseline_pipeline, + verify_layer_trace, +) +from .config import FeeConfig from .contracts import validate_records -from .domain import Bar +from .costs import ( + CostModelParameters, + DatedFeeRule, + forecast_trade_cost, +) +from .domain import Bar, Side from .features import ( TrainOnlyFeaturePreprocessor, build_executable_label, compute_price_features, ) from .ledger import canonical_json, jsonable +from .model_bundle import create_model_bundle, verify_model_bundle from .optimizer import ( - PortfolioCandidate, PortfolioConstraints, - optimize_deterministic, ) -from .portable_core import target_quantities from .research import ( DeterministicRidge, pearson_correlation, @@ -34,6 +47,7 @@ from .research import ( spearman_correlation, ) from .synthetic import generate_synthetic_bars +from .target_package import seal_target_package, validate_target_package from .universe import SecurityEligibility, classify_security @@ -119,6 +133,32 @@ def _bar_data_version(bars: list[Bar]) -> str: return _sha256(payload) +def _training_rows_version(rows: list[dict[str, Any]]) -> str: + return _sha256( + [ + { + "sample_id": row["sample_id"], + "decision_date": row["decision_date"], + "entry_date": row["entry_date"], + "exit_date": row["exit_date"], + "symbol": row["symbol"], + "features": row["features"], + "label": row["label"], + } + for row in sorted( + rows, + key=lambda item: (item["decision_date"], item["symbol"]), + ) + ] + ) + + +def _label_is_available(row: dict[str, Any], target_date: date) -> bool: + """Return whether a completed label is known strictly before a decision.""" + + return date.fromisoformat(row["exit_date"]) < target_date + + def _first_sessions_by_week(dates: list[date]) -> list[date]: result: list[date] = [] prior_week = None @@ -320,94 +360,25 @@ def run_research_smoke( } ) - # Train strictly before the final decision and emit a point-in-time target. + # Train only on labels whose exit observation was known before the final + # decision. Filtering merely on decision_date would leak the final + # horizon's future opens into the fitted model. target_date = decision_dates[-1] final_train = [ row for row in rows - if date.fromisoformat(row["decision_date"]) < target_date + if _label_is_available(row, target_date) ] final_rows = rows_by_date[target_date] - final_processor = TrainOnlyFeaturePreprocessor().fit( - [row["features"] for row in final_train] + if not final_train: + raise ValueError("no labels are available before the final decision") + sessions = sorted({bar.trading_date for bar in bars}) + next_session = next(session for session in sessions if session > target_date) + full_data_version = _bar_data_version(bars) + decision_data_version = _bar_data_version( + [bar for bar in bars if bar.trading_date <= target_date] ) - final_model = DeterministicRidge(alpha=5.0).fit( - _matrix(final_train, final_processor), - [row["label"] for row in final_train], - ) - final_predictions = final_model.predict(_matrix(final_rows, final_processor)) - centered = statistics.fmean(final_predictions) - scores = { - row["symbol"]: prediction - centered - for row, prediction in zip(final_rows, final_predictions) - } - candidates = [] - current_weights = {symbol: 0.0 for symbol in RESEARCH_SYMBOLS} - latest_prices = {} - for symbol in RESEARCH_SYMBOLS: - completed = [ - bar for bar in by_symbol[symbol] if bar.trading_date <= target_date - ] - latest = completed[-1] - latest_prices[symbol] = float(latest.close) - returns = [ - float(completed[index].close) - / float(completed[index - 1].close) - - 1.0 - for index in range(max(1, len(completed) - 60), len(completed)) - ] - variance = ( - statistics.variance(returns) if len(returns) > 1 else 1e-4 - ) - feature_row = next( - row for row in final_rows if row["symbol"] == symbol - ) - turnover = feature_row["features"]["median_turnover_20"] or 0.0 - eligibility = SecurityEligibility( - symbol=symbol, - index_member=True, - listing_sessions=500, - median_turnover=turnover, - minimum_turnover=0.0, - held_quantity=0, - sellable_quantity=0, - ) - state = classify_security(eligibility).state - candidates.append( - PortfolioCandidate( - symbol=symbol, - score=scores[symbol], - variance=max(1e-6, variance), - current_weight=current_weights[symbol], - cost_bps=15.0, - max_adv_weight=max( - 0.0, - min(0.10, turnover * 0.03 / 1_000_000.0), - ), - industry=INDUSTRY_BY_SYMBOL[symbol], - state=state, - ) - ) - solution = optimize_deterministic( - candidates, - PortfolioConstraints( - gross_target=0.90, - single_name_cap=0.10, - industry_cap=0.30, - one_way_turnover_cap=0.45, - risk_aversion=1.0, - cost_aversion=1.0, - ), - ) - quantities = target_quantities( - solution.weights, - latest_prices, - 1_000_000.0, - lot_size=100, - cash_buffer=0.05, - ) - - data_version = _bar_data_version(bars) + training_data_version = _training_rows_version(final_train) source_sha256, sources = _source_identity() config = { "trading_days": trading_days, @@ -423,16 +394,284 @@ def run_research_smoke( "test_dates": test_size, "purge_dates": 1, "embargo_dates": 1, + "final_label_availability": "exit_date_strictly_before_target", + }, + "portfolio": { + "solver": "deterministic", + "gross_target": 0.90, + "single_name_cap": 0.10, + "industry_cap": 0.30, + "one_way_turnover_cap": 0.45, + "risk_aversion": 1.0, + "cost_aversion": 1.0, + }, + "cost_model": { + "fee_version": "cn-a-share-synthetic-v1", + "half_spread_bps": 2.0, + "impact_coefficient": 0.10, + "max_participation": 0.10, + "reference_notional_weight": 0.10, }, } config_hash = _sha256(config) + final_processor = TrainOnlyFeaturePreprocessor().fit( + [row["features"] for row in final_train] + ) + final_model = DeterministicRidge(alpha=5.0).fit( + _matrix(final_train, final_processor), + [row["label"] for row in final_train], + ) + trained_through = max( + date.fromisoformat(row["exit_date"]) for row in final_train + ) + model_bundle = create_model_bundle( + final_processor, + final_model, + trained_through=trained_through, + label_name=config["label"], + horizon_trading_days=horizon_sessions, + data_sha256=training_data_version, + source_sha256=source_sha256, + config_sha256=config_hash, + ) + # Drive the published decision from the frozen artifact replay. The + # model digest carried by TargetPackageV1 is therefore a causal dependency + # of the target, not just a sidecar identity for an in-memory model. + frozen_model = verify_model_bundle( + model_bundle, + expected_data_sha256=training_data_version, + expected_source_sha256=source_sha256, + expected_config_sha256=config_hash, + ) + final_predictions = frozen_model.predict( + [row["features"] for row in final_rows] + ) + centered = statistics.fmean(final_predictions) + scores = { + row["symbol"]: prediction - centered + for row, prediction in zip(final_rows, final_predictions) + } + alpha_inputs = [] + risk_inputs = [] + cost_inputs = [] + security_inputs = [] + cost_forecasts = {} + risk_forecasts = {} + fee_rule = DatedFeeRule( + effective_from=date(2000, 1, 1), + effective_to=None, + fees=FeeConfig(), + version=config["cost_model"]["fee_version"], + ) + cost_parameters = CostModelParameters( + half_spread_bps=config["cost_model"]["half_spread_bps"], + impact_coefficient=config["cost_model"]["impact_coefficient"], + max_participation=config["cost_model"]["max_participation"], + ) + for symbol in RESEARCH_SYMBOLS: + completed = [ + bar for bar in by_symbol[symbol] if bar.trading_date <= target_date + ] + latest = completed[-1] + price = float(latest.close) + returns = [ + float(completed[index].close) + / float(completed[index - 1].close) + - 1.0 + for index in range(max(1, len(completed) - 60), len(completed)) + ] + variance = ( + statistics.variance(returns) if len(returns) > 1 else 1e-4 + ) + daily_volatility = ( + statistics.stdev(returns) if len(returns) > 1 else 0.01 + ) + adv_quantity = statistics.median( + max(1, int(bar.volume)) for bar in completed[-20:] + ) + reference_quantity = max( + 100, + int( + 1_000_000.0 + * config["cost_model"]["reference_notional_weight"] + / price + ) + // 100 + * 100, + ) + cost_forecast = forecast_trade_cost( + symbol=symbol, + side=Side.BUY, + quantity=reference_quantity, + price=price, + adv_quantity=adv_quantity, + daily_volatility=max(1e-6, daily_volatility), + fee_rule=fee_rule, + parameters=cost_parameters, + ) + max_adv_weight = min( + config["portfolio"]["single_name_cap"], + adv_quantity + * cost_parameters.max_participation + * price + / 1_000_000.0, + ) + feature_row = next( + row for row in final_rows if row["symbol"] == symbol + ) + turnover = feature_row["features"]["median_turnover_20"] or 0.0 + eligibility = SecurityEligibility( + symbol=symbol, + index_member=True, + listing_sessions=500, + median_turnover=turnover, + minimum_turnover=0.0, + held_quantity=0, + sellable_quantity=0, + ) + universe_decision = classify_security(eligibility) + alpha_inputs.append( + AlphaForecastInput( + symbol=symbol, + expected_excess_return=scores[symbol], + model_version="deterministic-ridge-alpha-5", + feature_version=config["feature_version"], + ) + ) + risk_inputs.append( + RiskForecastInput( + symbol=symbol, + predicted_variance=max(1e-6, variance), + risk_model_version="diagonal-realized-variance-60d-v1", + ) + ) + cost_inputs.append( + CostForecastInput( + symbol=symbol, + base_bps=cost_forecast.base_bps, + max_adv_weight=max(0.0, max_adv_weight), + cost_model_version="explicit-spread-sqrt-impact-v1", + ) + ) + security_inputs.append( + SecurityStateInput( + symbol=symbol, + state=universe_decision.state, + industry=INDUSTRY_BY_SYMBOL[symbol], + price=price, + current_quantity=0, + sellable_quantity=0, + reasons=universe_decision.reasons, + ) + ) + risk_forecasts[symbol] = max(1e-6, variance) + cost_forecasts[symbol] = { + "base_bps": cost_forecast.base_bps, + "participation": cost_forecast.participation, + "capacity_breached": cost_forecast.capacity_breached, + "max_adv_weight": max(0.0, max_adv_weight), + "fee_version": cost_forecast.fee_version, + } + constraints = PortfolioConstraints( + gross_target=config["portfolio"]["gross_target"], + single_name_cap=config["portfolio"]["single_name_cap"], + industry_cap=config["portfolio"]["industry_cap"], + one_way_turnover_cap=config["portfolio"]["one_way_turnover_cap"], + risk_aversion=config["portfolio"]["risk_aversion"], + cost_aversion=config["portfolio"]["cost_aversion"], + ) + pipeline = build_baseline_pipeline( + alpha=alpha_inputs, + risk=risk_inputs, + cost=cost_inputs, + securities=security_inputs, + equity=1_000_000.0, + policy=BaselinePipelinePolicy( + constraints=constraints, + solver=config["portfolio"]["solver"], + lot_size=100, + cash_buffer=0.05, + release_role="research_candidate", + ), + ) + solution = pipeline.solution + quantities = pipeline.target_quantities + risk_sha256 = _sha256( + { + "model_version": "diagonal-realized-variance-60d-v1", + "forecasts": risk_forecasts, + } + ) + cost_sha256 = _sha256( + { + "model_version": "explicit-spread-sqrt-impact-v1", + "forecasts": cost_forecasts, + } + ) + feature_sha256 = _sha256( + { + "feature_version": config["feature_version"], + "as_of": target_date.isoformat(), + "rows": { + row["symbol"]: row["features"] + for row in sorted(final_rows, key=lambda item: item["symbol"]) + }, + } + ) + optimizer_sha256 = _sha256( + { + "implementation": pipeline.layer_trace[2]["implementation"], + "constraints": config["portfolio"], + "portfolio_output_sha256": pipeline.layer_trace[2]["output_sha256"], + } + ) run_id = "q60-research-" + _sha256( { - "data": data_version, + "data": full_data_version, "source": source_sha256, "config": config_hash, } )[:16] + target_package = seal_target_package( + { + "schema_version": "1.0", + "package_type": "TargetPackageV1", + "package_id": f"TP-{target_date.strftime('%Y%m%d')}-{run_id[-8:]}", + "decision_id": f"RD-{target_date.strftime('%Y%m%d')}", + "release": "quant60-research-candidate-v1", + "evidence_class": "synthetic-research", + "model_id": "deterministic-ridge-alpha-5", + "signal_as_of": f"{target_date.isoformat()}T15:00:00+08:00", + "next_session": next_session.isoformat(), + "model_sha256": model_bundle["bundle_sha256"], + "data_sha256": decision_data_version, + "feature_sha256": feature_sha256, + "risk_sha256": risk_sha256, + "cost_sha256": cost_sha256, + "optimizer_sha256": optimizer_sha256, + "config_sha256": config_hash, + "source_sha256": source_sha256, + "target_weights": dict(sorted(solution.weights.items())), + "universe": { + item.symbol: { + "state": item.state.value, + "reasons": sorted(item.reasons), + } + for item in sorted( + security_inputs, + key=lambda security: security.symbol, + ) + }, + "constraints": { + "constraint_state": "FEASIBLE", + "long_only": True, + "max_single_weight": constraints.single_name_cap, + "max_gross_weight": constraints.gross_target, + "max_one_way_turnover": constraints.one_way_turnover_cap, + "actual_gross_weight": solution.gross_weight, + }, + } + ) signal_records = [ { "schema_version": "1.0", @@ -445,7 +684,7 @@ def run_research_smoke( "expected_excess_return": scores[symbol], "confidence": None, "model_version": "deterministic-ridge-alpha-5", - "data_version": data_version, + "data_version": decision_data_version, "feature_version": "transparent-price-v1", "metadata": { "evidence_class": "synthetic-research", @@ -483,7 +722,9 @@ def run_research_smoke( "evidence_class": "synthetic-research", "investment_value_claim": False, "config_hash": config_hash, - "data_version": data_version, + "data_version": decision_data_version, + "full_synthetic_data_version": full_data_version, + "training_data_version": training_data_version, "engine_source_sha256": source_sha256, "sample_count": len(rows), "decision_date_count": len(decision_dates), @@ -500,6 +741,19 @@ def run_research_smoke( else 0.0 ), "target_date": target_date.isoformat(), + "next_session": next_session.isoformat(), + "final_training_max_label_exit": trained_through.isoformat(), + "model_bundle_sha256": model_bundle["bundle_sha256"], + "target_package_sha256": target_package["package_sha256"], + "pipeline_id": pipeline.pipeline_id, + "five_layer_trace": [ + { + "layer": item["layer"], + "implementation": item["implementation"], + "event_sha256": item["event_sha256"], + } + for item in pipeline.layer_trace + ], "target_solver": solution.solver, "target_gross_weight": solution.gross_weight, "target_cash_weight": solution.cash_weight, @@ -529,7 +783,9 @@ def run_research_smoke( "evidence_class": "synthetic-research", "config": config, "config_hash": config_hash, - "data_version": data_version, + "data_version": decision_data_version, + "full_synthetic_data_version": full_data_version, + "training_data_version": training_data_version, "engine_source_sha256": source_sha256, "engine_sources": sources, "deterministic": True, @@ -542,6 +798,9 @@ def run_research_smoke( "targets": target_records, "forecasts": forecasts, "samples": sample_index, + "model_bundle": model_bundle, + "target_package": target_package, + "pipeline": pipeline.as_dict(), } @@ -551,6 +810,9 @@ RESEARCH_ARTIFACTS = { "targets": "targets.json", "forecasts": "forecasts.json", "samples": "samples.json", + "model_bundle": "model_bundle.json", + "target_package": "target_package.json", + "pipeline": "pipeline.json", } @@ -612,9 +874,39 @@ def verify_research_manifest(path: str | Path) -> dict[str, Any]: targets = json.loads((root / RESEARCH_ARTIFACTS["targets"]).read_text()) validate_records(signals, "signal.schema.json") validate_records(targets, "target.schema.json") + model_bundle = json.loads( + (root / RESEARCH_ARTIFACTS["model_bundle"]).read_text() + ) + verified_model = verify_model_bundle( + model_bundle, + expected_source_sha256=manifest["engine_source_sha256"], + expected_config_sha256=manifest["config_hash"], + ) + target_package = json.loads( + (root / RESEARCH_ARTIFACTS["target_package"]).read_text() + ) + validated_package = validate_target_package(target_package) + if validated_package["model_sha256"] != verified_model.bundle_sha256: + raise ValueError("target package/model bundle identity mismatch") + pipeline = json.loads((root / RESEARCH_ARTIFACTS["pipeline"]).read_text()) + verify_layer_trace(pipeline["layer_trace"], pipeline["layer_payloads"]) + if validated_package["target_weights"] != pipeline["weights"]: + raise ValueError("target package/pipeline weight mismatch") report = json.loads((root / RESEARCH_ARTIFACTS["report"]).read_text()) if report["run_id"] != manifest["run_id"]: raise ValueError("research report/manifest run_id mismatch") + if report["model_bundle_sha256"] != verified_model.bundle_sha256: + raise ValueError("research report/model bundle identity mismatch") + if ( + report["target_package_sha256"] + != validated_package["package_sha256"] + ): + raise ValueError("research report/target package identity mismatch") + if not ( + date.fromisoformat(report["final_training_max_label_exit"]) + < date.fromisoformat(report["target_date"]) + ): + raise ValueError("final model contains a label unavailable at decision") return { "ok": True, "run_id": manifest["run_id"], diff --git a/src/quant60/model_bundle.py b/src/quant60/model_bundle.py new file mode 100644 index 0000000..4330d07 --- /dev/null +++ b/src/quant60/model_bundle.py @@ -0,0 +1,602 @@ +"""Frozen, dependency-free Ridge model artifacts and deterministic replay. + +The bundle is deliberately limited to the model path that Quant60 can replay +without a research environment: ``TrainOnlyFeaturePreprocessor`` followed by +``DeterministicRidge``. A self-hash detects byte/content tampering, while the +semantic verifier checks the relationships that JSON Schema cannot express +(feature order, transform order, and coefficient width). +""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import re +import tempfile +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import Any, Mapping, Sequence + +from .contracts import load_schema, validate_contract +from .features import FeatureTransform, TrainOnlyFeaturePreprocessor +from .ledger import canonical_json +from .research import DeterministicRidge + + +MODEL_BUNDLE_SCHEMA_VERSION = "1.0" +MODEL_BUNDLE_ARTIFACT_TYPE = "quant60-deterministic-ridge-bundle" +PREPROCESSOR_TYPE = "train-only-winsor-impute-standardize" +MODEL_TYPE = "deterministic-ridge" +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") + + +class ModelBundleIntegrityError(ValueError): + """A model bundle is malformed, inconsistent, or fails its identity check.""" + + +@dataclass(frozen=True, slots=True) +class FrozenFeatureTransform: + name: str + lower: float + upper: float + median: float + mean: float + scale: float + + def as_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "lower": self.lower, + "upper": self.upper, + "median": self.median, + "mean": self.mean, + "scale": self.scale, + } + + +@dataclass(frozen=True, slots=True) +class FrozenRidgeModelBundle: + """An immutable, semantically verified model ready for replay.""" + + bundle_sha256: str + trained_through: date + label_name: str + horizon_trading_days: int + data_sha256: str + source_sha256: str + config_sha256: str + winsor_fraction: float + feature_order: tuple[str, ...] + output_feature_order: tuple[str, ...] + transforms: tuple[FrozenFeatureTransform, ...] + alpha: float + fit_intercept: bool + coefficients: tuple[float, ...] + intercept: float + + def as_dict(self) -> dict[str, Any]: + return { + "schema_version": MODEL_BUNDLE_SCHEMA_VERSION, + "artifact_type": MODEL_BUNDLE_ARTIFACT_TYPE, + "bundle_sha256": self.bundle_sha256, + "trained_through": self.trained_through.isoformat(), + "label": { + "name": self.label_name, + "horizon_trading_days": self.horizon_trading_days, + }, + "lineage": { + "data_sha256": self.data_sha256, + "source_sha256": self.source_sha256, + "config_sha256": self.config_sha256, + }, + "preprocessor": { + "type": PREPROCESSOR_TYPE, + "winsor_fraction": self.winsor_fraction, + "feature_order": list(self.feature_order), + "output_feature_order": list(self.output_feature_order), + "transforms": [ + transform.as_dict() for transform in self.transforms + ], + }, + "model": { + "type": MODEL_TYPE, + "alpha": self.alpha, + "fit_intercept": self.fit_intercept, + "coefficients": list(self.coefficients), + "intercept": self.intercept, + }, + } + + @property + def canonical_bytes(self) -> bytes: + """Return the exact deterministic bytes used by ``write_model_bundle``.""" + + return _published_bytes(self.as_dict()) + + def _preprocessor(self) -> TrainOnlyFeaturePreprocessor: + processor = TrainOnlyFeaturePreprocessor(self.winsor_fraction) + processor.transforms = { + transform.name: FeatureTransform( + lower=transform.lower, + upper=transform.upper, + median=transform.median, + mean=transform.mean, + scale=transform.scale, + ) + for transform in self.transforms + } + return processor + + def _model(self) -> DeterministicRidge: + model = DeterministicRidge( + alpha=self.alpha, + fit_intercept=self.fit_intercept, + ) + model.coef_ = self.coefficients + model.intercept_ = self.intercept + return model + + def predict( + self, + rows: Sequence[Mapping[str, float | None]], + ) -> list[float]: + """Replay predictions using only the state frozen in this bundle.""" + + processor = self._preprocessor() + transformed = processor.transform(rows) + matrix = [ + [row[name] for name in self.output_feature_order] + for row in transformed + ] + return self._model().predict(matrix) + + def prediction_bytes( + self, + rows: Sequence[Mapping[str, float | None]], + ) -> bytes: + """Return a deterministic prediction envelope suitable for hashing.""" + + return _published_bytes( + { + "bundle_sha256": self.bundle_sha256, + "predictions": self.predict(rows), + } + ) + + +def _published_bytes(value: Any) -> bytes: + return (canonical_json(value) + "\n").encode("utf-8") + + +def _bundle_body(payload: Mapping[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in payload.items() + if key != "bundle_sha256" + } + + +def model_bundle_sha256(payload: Mapping[str, Any]) -> str: + """Hash the canonical bundle body, excluding its self-hash field.""" + + return hashlib.sha256( + canonical_json(_bundle_body(payload)).encode("utf-8") + ).hexdigest() + + +def _strict_json(path: Path) -> Any: + def reject_constant(value: str) -> None: + raise ModelBundleIntegrityError( + f"non-finite JSON constant in model bundle: {value}" + ) + + def reject_duplicate_keys( + pairs: list[tuple[str, Any]], + ) -> dict[str, Any]: + output: dict[str, Any] = {} + for key, value in pairs: + if key in output: + raise ModelBundleIntegrityError( + f"duplicate JSON key in model bundle: {key}" + ) + output[key] = value + return output + + try: + return json.loads( + path.read_text(encoding="utf-8"), + parse_constant=reject_constant, + object_pairs_hook=reject_duplicate_keys, + ) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ModelBundleIntegrityError( + f"model bundle is not valid UTF-8 JSON: {exc}" + ) from exc + + +def _json_copy(payload: Mapping[str, Any]) -> dict[str, Any]: + try: + copied = json.loads(canonical_json(dict(payload))) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise ModelBundleIntegrityError( + f"model bundle is not canonical JSON data: {exc}" + ) from exc + if not isinstance(copied, dict): + raise ModelBundleIntegrityError("model bundle root must be an object") + return copied + + +def _finite_float(value: Any, field: str) -> float: + if isinstance(value, bool): + raise ModelBundleIntegrityError(f"{field} must be a finite number") + try: + normalized = float(value) + except (TypeError, ValueError) as exc: + raise ModelBundleIntegrityError( + f"{field} must be a finite number" + ) from exc + if not math.isfinite(normalized): + raise ModelBundleIntegrityError(f"{field} must be a finite number") + return normalized + + +def _require_sha256(value: Any, field: str) -> str: + normalized = str(value) + if _SHA256_PATTERN.fullmatch(normalized) is None: + raise ModelBundleIntegrityError( + f"{field} must be a lowercase SHA-256 digest" + ) + if normalized == "0" * 64: + raise ModelBundleIntegrityError( + f"{field} cannot be an all-zero placeholder" + ) + return normalized + + +def _semantic_model_bundle(payload: Mapping[str, Any]) -> FrozenRidgeModelBundle: + try: + trained_through = date.fromisoformat(str(payload["trained_through"])) + label = payload["label"] + lineage = payload["lineage"] + preprocessor = payload["preprocessor"] + model = payload["model"] + except (KeyError, TypeError, ValueError) as exc: + raise ModelBundleIntegrityError( + f"model bundle metadata is invalid: {exc}" + ) from exc + + label_name = str(label["name"]) + if not label_name.strip() or label_name != label_name.strip(): + raise ModelBundleIntegrityError( + "label.name must be non-empty without surrounding whitespace" + ) + horizon = label["horizon_trading_days"] + if type(horizon) is not int or horizon <= 0: + raise ModelBundleIntegrityError( + "label.horizon_trading_days must be a positive integer" + ) + + winsor_fraction = _finite_float( + preprocessor["winsor_fraction"], + "preprocessor.winsor_fraction", + ) + if not 0.0 <= winsor_fraction < 0.5: + raise ModelBundleIntegrityError( + "preprocessor.winsor_fraction must lie in [0, 0.5)" + ) + + feature_order = tuple(preprocessor["feature_order"]) + output_order = tuple(preprocessor["output_feature_order"]) + if ( + not feature_order + or any( + not isinstance(name, str) + or not name + or name != name.strip() + for name in feature_order + ) + ): + raise ModelBundleIntegrityError( + "preprocessor.feature_order must contain non-empty names" + ) + if len(set(feature_order)) != len(feature_order): + raise ModelBundleIntegrityError( + "preprocessor.feature_order contains duplicates" + ) + if feature_order != tuple(sorted(feature_order)): + raise ModelBundleIntegrityError( + "preprocessor.feature_order is not canonical" + ) + expected_output_order = tuple( + item + for name in feature_order + for item in (name, f"{name}__missing") + ) + if output_order != expected_output_order: + raise ModelBundleIntegrityError( + "preprocessor.output_feature_order is inconsistent" + ) + if len(set(output_order)) != len(output_order): + raise ModelBundleIntegrityError( + "preprocessor.output_feature_order contains collisions" + ) + + raw_transforms = preprocessor["transforms"] + if not isinstance(raw_transforms, list): + raise ModelBundleIntegrityError( + "preprocessor.transforms must be an array" + ) + transforms: list[FrozenFeatureTransform] = [] + for index, raw in enumerate(raw_transforms): + if not isinstance(raw, dict): + raise ModelBundleIntegrityError( + f"preprocessor.transforms[{index}] must be an object" + ) + name = raw.get("name") + lower = _finite_float(raw.get("lower"), f"transforms[{index}].lower") + upper = _finite_float(raw.get("upper"), f"transforms[{index}].upper") + median = _finite_float(raw.get("median"), f"transforms[{index}].median") + mean = _finite_float(raw.get("mean"), f"transforms[{index}].mean") + scale = _finite_float(raw.get("scale"), f"transforms[{index}].scale") + if lower > upper: + raise ModelBundleIntegrityError( + f"preprocessor transform {name!r} has lower above upper" + ) + if not lower <= median <= upper: + raise ModelBundleIntegrityError( + f"preprocessor transform {name!r} has median outside bounds" + ) + if not lower <= mean <= upper: + raise ModelBundleIntegrityError( + f"preprocessor transform {name!r} has mean outside bounds" + ) + if scale <= 0: + raise ModelBundleIntegrityError( + f"preprocessor transform {name!r} has non-positive scale" + ) + transforms.append( + FrozenFeatureTransform( + name=str(name), + lower=lower, + upper=upper, + median=median, + mean=mean, + scale=scale, + ) + ) + transform_names = tuple(item.name for item in transforms) + if transform_names != feature_order: + raise ModelBundleIntegrityError( + "preprocessor transform order does not match feature_order" + ) + + alpha = _finite_float(model["alpha"], "model.alpha") + if alpha < 0: + raise ModelBundleIntegrityError("model.alpha must be non-negative") + fit_intercept = model["fit_intercept"] + if type(fit_intercept) is not bool: + raise ModelBundleIntegrityError("model.fit_intercept must be a bool") + coefficients = tuple( + _finite_float(value, f"model.coefficients[{index}]") + for index, value in enumerate(model["coefficients"]) + ) + if len(coefficients) != len(output_order): + raise ModelBundleIntegrityError( + "model coefficient width does not match output_feature_order" + ) + intercept = _finite_float(model["intercept"], "model.intercept") + + bundle = FrozenRidgeModelBundle( + bundle_sha256=_require_sha256( + payload["bundle_sha256"], + "bundle_sha256", + ), + trained_through=trained_through, + label_name=label_name, + horizon_trading_days=horizon, + data_sha256=_require_sha256( + lineage["data_sha256"], + "lineage.data_sha256", + ), + source_sha256=_require_sha256( + lineage["source_sha256"], + "lineage.source_sha256", + ), + config_sha256=_require_sha256( + lineage["config_sha256"], + "lineage.config_sha256", + ), + winsor_fraction=winsor_fraction, + feature_order=feature_order, + output_feature_order=output_order, + transforms=tuple(transforms), + alpha=alpha, + fit_intercept=fit_intercept, + coefficients=coefficients, + intercept=intercept, + ) + if bundle.as_dict() != payload: + raise ModelBundleIntegrityError( + "model bundle is not in canonical semantic form" + ) + return bundle + + +def create_model_bundle( + preprocessor: TrainOnlyFeaturePreprocessor, + model: DeterministicRidge, + *, + trained_through: date | str, + label_name: str, + horizon_trading_days: int, + data_sha256: str, + source_sha256: str, + config_sha256: str, +) -> dict[str, Any]: + """Freeze a fitted preprocessor and Ridge model into a verified payload.""" + + if not isinstance(preprocessor, TrainOnlyFeaturePreprocessor): + raise TypeError( + "preprocessor must be a TrainOnlyFeaturePreprocessor" + ) + if preprocessor.transforms is None: + raise ValueError("preprocessor is not fitted") + if not isinstance(model, DeterministicRidge): + raise TypeError("model must be a DeterministicRidge") + if model.coef_ is None: + raise ValueError("model is not fitted") + try: + training_date = ( + trained_through + if isinstance(trained_through, date) + else date.fromisoformat(str(trained_through)) + ) + except ValueError as exc: + raise ValueError("trained_through must be an ISO date") from exc + + feature_order = tuple(sorted(preprocessor.transforms)) + transforms = [ + { + "name": name, + "lower": preprocessor.transforms[name].lower, + "upper": preprocessor.transforms[name].upper, + "median": preprocessor.transforms[name].median, + "mean": preprocessor.transforms[name].mean, + "scale": preprocessor.transforms[name].scale, + } + for name in feature_order + ] + payload: dict[str, Any] = { + "schema_version": MODEL_BUNDLE_SCHEMA_VERSION, + "artifact_type": MODEL_BUNDLE_ARTIFACT_TYPE, + "bundle_sha256": "0" * 64, + "trained_through": training_date.isoformat(), + "label": { + "name": label_name, + "horizon_trading_days": horizon_trading_days, + }, + "lineage": { + "data_sha256": data_sha256, + "source_sha256": source_sha256, + "config_sha256": config_sha256, + }, + "preprocessor": { + "type": PREPROCESSOR_TYPE, + "winsor_fraction": preprocessor.winsor_fraction, + "feature_order": list(feature_order), + "output_feature_order": list(preprocessor.output_names), + "transforms": transforms, + }, + "model": { + "type": MODEL_TYPE, + "alpha": model.alpha, + "fit_intercept": model.fit_intercept, + "coefficients": list(model.coef_), + "intercept": model.intercept_, + }, + } + payload["bundle_sha256"] = model_bundle_sha256(payload) + return verify_model_bundle(payload).as_dict() + + +def verify_model_bundle( + value: Mapping[str, Any] | str | Path, + *, + expected_bundle_sha256: str | None = None, + expected_data_sha256: str | None = None, + expected_source_sha256: str | None = None, + expected_config_sha256: str | None = None, +) -> FrozenRidgeModelBundle: + """Validate schema, self-hash, semantic relationships, and lineage pins.""" + + if isinstance(value, Mapping): + payload = _json_copy(value) + else: + path = Path(value).expanduser().resolve() + raw = _strict_json(path) + if not isinstance(raw, dict): + raise ModelBundleIntegrityError( + "model bundle root must be an object" + ) + payload = raw + try: + validate_contract( + payload, + load_schema("model_bundle.schema.json"), + ) + except (FileNotFoundError, KeyError, TypeError, ValueError) as exc: + raise ModelBundleIntegrityError( + f"model bundle schema validation failed: {exc}" + ) from exc + + actual_hash = model_bundle_sha256(payload) + if payload.get("bundle_sha256") != actual_hash: + raise ModelBundleIntegrityError("model bundle SHA-256 mismatch") + bundle = _semantic_model_bundle(payload) + + expected = { + "bundle_sha256": expected_bundle_sha256, + "data_sha256": expected_data_sha256, + "source_sha256": expected_source_sha256, + "config_sha256": expected_config_sha256, + } + actual = { + "bundle_sha256": bundle.bundle_sha256, + "data_sha256": bundle.data_sha256, + "source_sha256": bundle.source_sha256, + "config_sha256": bundle.config_sha256, + } + for field, expected_value in expected.items(): + if expected_value is None: + continue + normalized = _require_sha256(expected_value, f"expected_{field}") + if actual[field] != normalized: + raise ModelBundleIntegrityError( + f"model bundle {field} does not match expected identity" + ) + return bundle + + +def write_model_bundle( + bundle: FrozenRidgeModelBundle | Mapping[str, Any], + path: str | Path, +) -> Path: + """Atomically write a verified bundle in deterministic canonical JSON.""" + + verified = verify_model_bundle( + bundle.as_dict() + if isinstance(bundle, FrozenRidgeModelBundle) + else bundle + ) + destination = Path(path) + destination.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{destination.name}.", + suffix=".tmp", + dir=str(destination.parent), + ) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(verified.canonical_bytes) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_name, destination) + except BaseException: + try: + os.unlink(temporary_name) + except FileNotFoundError: + pass + raise + return destination + + +def load_verified_model_bundle( + path: str | Path, + **expected_identity: str | None, +) -> FrozenRidgeModelBundle: + """Read a bundle from disk and fail closed unless all checks pass.""" + + return verify_model_bundle(path, **expected_identity) diff --git a/src/quant60/portable_core.py b/src/quant60/portable_core.py index b1373ee..5f29854 100644 --- a/src/quant60/portable_core.py +++ b/src/quant60/portable_core.py @@ -7,6 +7,8 @@ single file when importing the complete ``quant60`` package is not possible. from __future__ import division +import hashlib +import json import math import re @@ -30,6 +32,45 @@ _SHORT_EXCHANGES = { "XBSE": "BJ", } +_TARGET_PACKAGE_FIELDS = { + "schema_version", + "package_type", + "package_id", + "decision_id", + "release", + "evidence_class", + "model_id", + "signal_as_of", + "next_session", + "model_sha256", + "data_sha256", + "feature_sha256", + "risk_sha256", + "cost_sha256", + "optimizer_sha256", + "config_sha256", + "source_sha256", + "target_weights", + "universe", + "constraints", + "package_sha256", +} + +_TARGET_PACKAGE_HASH_FIELDS = ( + "model_sha256", + "data_sha256", + "feature_sha256", + "risk_sha256", + "cost_sha256", + "optimizer_sha256", + "config_sha256", + "source_sha256", +) + + +class TargetPackageLookupMiss(ValueError, LookupError): + """An already verified tape has no exact requested decision.""" + def _sum_numeric(values): """Sum numerics without trusting a hosted runtime's global ``sum`` name.""" @@ -40,6 +81,15 @@ def _sum_numeric(values): return total +def _any_true(values): + """Return built-in ``any`` semantics without trusting hosted globals.""" + + for value in values: + if value: + return True + return False + + def _infer_exchange(code): if code.startswith(("4", "8")): return "XBSE" @@ -92,6 +142,396 @@ def convert_symbol(symbol, target="canonical"): return normalize_symbol(symbol, target) +def _canonical_json(value): + """Match ``quant60.ledger.canonical_json`` for plain JSON values.""" + + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def _json_copy(value, name): + try: + return json.loads(_canonical_json(value)) + except (TypeError, ValueError) as error: + raise ValueError("%s must contain finite JSON values only" % name) from error + + +def _canonical_mapping(value, name, value_validator): + if not hasattr(value, "items"): + raise TypeError("%s must be a mapping" % name) + output = {} + for raw_symbol, raw_value in value.items(): + symbol = normalize_symbol(raw_symbol) + if symbol in output: + raise ValueError( + "%s contains duplicate normalized symbol %s" % (name, symbol) + ) + output[symbol] = value_validator(raw_value, "%s.%s" % (name, symbol)) + return dict((symbol, output[symbol]) for symbol in sorted(output)) + + +def _finite_non_negative(value, name): + if isinstance(value, bool): + raise ValueError("%s must be a finite non-negative number" % name) + try: + numeric = float(value) + except (TypeError, ValueError): + raise ValueError("%s must be a finite non-negative number" % name) + if not math.isfinite(numeric) or numeric < 0.0: + raise ValueError("%s must be a finite non-negative number" % name) + return numeric + + +def _non_negative_quantity(value, name): + numeric = _finite_non_negative(value, name) + if numeric != math.floor(numeric): + raise ValueError("%s must be a whole-share quantity" % name) + return int(numeric) + + +def _positive_price(value, name): + numeric = _finite_non_negative(value, name) + if numeric <= 0.0: + raise ValueError("%s must be positive" % name) + return numeric + + +def _optional_non_empty_string(value, name): + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ValueError("%s must be a non-empty string when provided" % name) + return value + + +def _sha256_hex(value, name, allow_none=False): + if value is None and allow_none: + return None + if ( + not isinstance(value, str) + or len(value) != 64 + or _any_true( + character not in "0123456789abcdef" + for character in value + ) + ): + raise ValueError( + "%s must be a 64-character lowercase SHA-256 digest" % name + ) + if value == "0" * 64: + raise ValueError("%s cannot be an all-zero placeholder" % name) + return value + + +def _reject_quantity_fields(value, path="$"): + if hasattr(value, "items"): + for raw_key, child in value.items(): + key = str(raw_key) + normalized = key.lower().replace("-", "_") + if "quantity" in normalized or "quantities" in normalized: + raise ValueError( + "%s.%s: quantities do not belong in TargetPackageV1" + % (path, key) + ) + _reject_quantity_fields(child, "%s.%s" % (path, key)) + elif isinstance(value, list): + for index, child in enumerate(value): + _reject_quantity_fields(child, "%s[%d]" % (path, index)) + + +def _verify_identifier(value, name): + if ( + not isinstance(value, str) + or re.match(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", value) is None + ): + raise ValueError("%s is not a canonical identifier" % name) + return value + + +def _verify_target_package_clock(signal_as_of, next_session): + if ( + not isinstance(signal_as_of, str) + or re.match( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T15:00:00\+08:00$", + signal_as_of, + ) + is None + ): + raise ValueError("signal_as_of must be canonical China-market close") + if ( + not isinstance(next_session, str) + or re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", next_session) is None + ): + raise ValueError("next_session must use canonical YYYY-MM-DD") + if next_session <= signal_as_of[:10]: + raise ValueError("next_session must follow signal_as_of") + + +def _target_package_content_sha256(package): + body = dict(package) + body.pop("package_sha256", None) + _reject_quantity_fields(body) + try: + payload = _canonical_json(body).encode("utf-8") + except (TypeError, ValueError) as error: + raise ValueError( + "target package must contain finite JSON values only" + ) from error + return hashlib.sha256(payload).hexdigest() + + +def verify_target_package( + package, + expected_signal_as_of=None, + expected_next_session=None, + expected_decision_id=None, + expected_package_sha256=None, +): + """Verify the platform-critical subset of a sealed ``TargetPackageV1``. + + This intentionally duplicates a small, Python-3.6-compatible safety + boundary from the richer local validator. Hosted runtimes must verify the + embedded package again instead of trusting the bundling process. + """ + + if not hasattr(package, "items"): + raise TypeError("target package must be a mapping") + candidate = _json_copy(package, "target package") + _reject_quantity_fields(candidate) + fields = set(candidate) + if fields != _TARGET_PACKAGE_FIELDS: + missing = sorted(_TARGET_PACKAGE_FIELDS - fields) + unknown = sorted(fields - _TARGET_PACKAGE_FIELDS) + raise ValueError( + "target package fields mismatch; missing=%s, unknown=%s" + % (missing, unknown) + ) + if candidate["schema_version"] != "1.0": + raise ValueError("unsupported target package schema_version") + if candidate["package_type"] != "TargetPackageV1": + raise ValueError("unsupported target package type") + for name in ( + "package_id", + "decision_id", + "release", + "evidence_class", + "model_id", + ): + _verify_identifier(candidate[name], name) + _verify_target_package_clock( + candidate["signal_as_of"], candidate["next_session"] + ) + for name in _TARGET_PACKAGE_HASH_FIELDS: + _sha256_hex(candidate[name], name) + _sha256_hex(candidate["package_sha256"], "package_sha256") + + raw_weights = candidate["target_weights"] + if not hasattr(raw_weights, "items") or not raw_weights: + raise ValueError("target_weights must be a non-empty mapping") + weights = {} + for raw_symbol, raw_weight in raw_weights.items(): + symbol = normalize_symbol(raw_symbol) + if symbol != raw_symbol: + raise ValueError( + "target_weights symbol must be canonical: %s" % raw_symbol + ) + if symbol in weights: + raise ValueError("duplicate target weight for %s" % symbol) + weight = _finite_non_negative( + raw_weight, "target_weights.%s" % symbol + ) + if weight > 1.0: + raise ValueError("target weight exceeds one for %s" % symbol) + weights[symbol] = weight + gross = math.fsum(weights.values()) + if gross > 1.0 + 1e-12: + raise ValueError("target_weights gross exceeds one") + + universe = candidate["universe"] + if not hasattr(universe, "items") or not universe: + raise ValueError("universe must be a non-empty mapping") + canonical_universe = {} + for raw_symbol, raw_state in universe.items(): + symbol = normalize_symbol(raw_symbol) + if symbol != raw_symbol: + raise ValueError("universe symbol must be canonical: %s" % raw_symbol) + if symbol in canonical_universe: + raise ValueError("duplicate universe symbol %s" % symbol) + if not hasattr(raw_state, "items"): + raise ValueError("universe state must be a mapping for %s" % symbol) + state = raw_state.get("state") + if state not in ( + "openable", + "hold_only", + "sell_only", + "frozen", + "excluded", + ): + raise ValueError("unsupported universe state for %s" % symbol) + canonical_universe[symbol] = state + missing_universe = sorted(set(weights) - set(canonical_universe)) + if missing_universe: + raise ValueError( + "target symbols are absent from universe: %s" % missing_universe + ) + for symbol, weight in weights.items(): + if canonical_universe[symbol] == "excluded" and weight > 1e-12: + raise ValueError("excluded symbol has positive weight: %s" % symbol) + + constraints = candidate["constraints"] + if not hasattr(constraints, "items"): + raise ValueError("constraints must be a mapping") + if constraints.get("long_only") is not True: + raise ValueError("TargetPackageV1 must be long-only") + declared_gross = _finite_non_negative( + constraints.get("actual_gross_weight"), + "constraints.actual_gross_weight", + ) + max_gross = _finite_non_negative( + constraints.get("max_gross_weight"), + "constraints.max_gross_weight", + ) + max_single = _finite_non_negative( + constraints.get("max_single_weight"), + "constraints.max_single_weight", + ) + if abs(declared_gross - gross) > 1e-12: + raise ValueError("declared gross does not equal target_weights gross") + if gross > max_gross + 1e-12 or max_gross > 1.0: + raise ValueError("target_weights exceed max_gross_weight") + if max_single <= 0.0 or max_single > 1.0: + raise ValueError("invalid max_single_weight") + if _any_true( + weight > max_single + 1e-12 for weight in weights.values() + ): + raise ValueError("target_weights exceed max_single_weight") + model_claim = " ".join( + ( + candidate["release"], + candidate["evidence_class"], + ) + ).lower() + if ( + "baseline" in model_claim + and "portable-momentum" in candidate["model_id"].lower() + ): + raise ValueError( + "portable-momentum cannot claim a baseline target package" + ) + + expected_hash = _target_package_content_sha256(candidate) + if candidate["package_sha256"] != expected_hash: + raise ValueError("target package content hash mismatch") + if ( + expected_signal_as_of is not None + and candidate["signal_as_of"] != expected_signal_as_of + ): + raise ValueError("target package signal_as_of does not match request") + if ( + expected_next_session is not None + and candidate["next_session"] != expected_next_session + ): + raise ValueError("target package next_session does not match request") + if ( + expected_decision_id is not None + and candidate["decision_id"] != expected_decision_id + ): + raise ValueError("target package decision_id does not match request") + if expected_package_sha256 is not None: + _sha256_hex( + expected_package_sha256, + "expected_package_sha256", + ) + if candidate["package_sha256"] != expected_package_sha256: + raise ValueError("target package digest does not match request") + return candidate + + +def target_package_tape_sha256(packages): + """Hash the exact ordered canonical JSON package array.""" + + if not isinstance(packages, (list, tuple)): + raise TypeError("target package tape must be a list or tuple") + try: + payload = _canonical_json(list(packages)).encode("utf-8") + except (TypeError, ValueError) as error: + raise ValueError( + "target package tape must contain finite JSON values only" + ) from error + return hashlib.sha256(payload).hexdigest() + + +def lookup_target_package_exact( + packages, + signal_as_of, + next_session, + decision_id=None, + expected_tape_sha256=None, + expected_count=None, +): + """Verify a complete tape, then return one exact dual-clock decision.""" + + if not isinstance(packages, (list, tuple)) or not packages: + raise ValueError("target package tape must be a non-empty list or tuple") + _verify_target_package_clock(signal_as_of, next_session) + decision_id = _optional_non_empty_string(decision_id, "decision_id") + if expected_count is not None: + if ( + isinstance(expected_count, bool) + or not isinstance(expected_count, int) + or expected_count < 0 + ): + raise ValueError("expected_count must be a non-negative integer") + if len(packages) != expected_count: + raise ValueError("target package tape count mismatch") + if expected_tape_sha256 is not None: + _sha256_hex(expected_tape_sha256, "expected_tape_sha256") + if target_package_tape_sha256(packages) != expected_tape_sha256: + raise ValueError("target package tape digest mismatch") + + verified = [] + package_ids = set() + decision_ids = set() + clock_keys = set() + for raw_package in packages: + package = verify_target_package(raw_package) + clock_key = (package["signal_as_of"], package["next_session"]) + if package["package_id"] in package_ids: + raise ValueError("duplicate target package_id") + if package["decision_id"] in decision_ids: + raise ValueError("duplicate target decision_id") + if clock_key in clock_keys: + raise ValueError("duplicate target package clock") + package_ids.add(package["package_id"]) + decision_ids.add(package["decision_id"]) + clock_keys.add(clock_key) + verified.append(package) + + matches = [ + package + for package in verified + if package["signal_as_of"] == signal_as_of + and package["next_session"] == next_session + ] + if not matches: + raise TargetPackageLookupMiss( + "no target package for exact signal_as_of/next_session clock" + ) + if len(matches) != 1: + raise ValueError("ambiguous exact target package decision") + if ( + decision_id is not None + and matches[0]["decision_id"] != decision_id + ): + raise ValueError("exact target package decision_id mismatch") + return _json_copy(matches[0], "target package") + + def momentum_score(prices, lookback=20, skip=0): """Return trailing simple momentum, or ``None`` if history is insufficient. @@ -318,6 +758,108 @@ def order_deltas( return result +def build_target_weight_plan( + target_weights, + prices, + current, + sellable, + equity, + cash_buffer=0.02, + lot_size=100, + decision_id=None, + package_sha256=None, +): + """Bind canonical post-risk weights to one account without recomputing Alpha. + + ``prices`` must cover exactly every target or currently managed symbol. + Missing prices fail closed even for a held symbol whose desired weight is + zero. A missing ``sellable`` entry is safe and means zero sellable shares. + """ + + weights = _canonical_mapping( + target_weights, + "target_weights", + _finite_non_negative, + ) + gross = math.fsum(weights.values()) + if _any_true(weight > 1.0 for weight in weights.values()): + raise ValueError("target weight must not exceed one") + if gross > 1.0 + 1e-12: + raise ValueError("target weight gross must not exceed one") + + canonical_current = _canonical_mapping( + current, + "current", + _non_negative_quantity, + ) + canonical_sellable = _canonical_mapping( + sellable, + "sellable", + _non_negative_quantity, + ) + unknown_sellable = sorted(set(canonical_sellable) - set(canonical_current)) + if unknown_sellable: + raise ValueError( + "sellable contains symbols absent from current: %s" + % unknown_sellable + ) + for symbol, quantity in canonical_sellable.items(): + if quantity > canonical_current[symbol]: + raise ValueError( + "sellable exceeds current quantity for %s" % symbol + ) + + canonical_prices = _canonical_mapping( + prices, + "prices", + _positive_price, + ) + managed_symbols = set(weights) | set(canonical_current) + price_symbols = set(canonical_prices) + missing_prices = sorted(managed_symbols - price_symbols) + extra_prices = sorted(price_symbols - managed_symbols) + if missing_prices or extra_prices: + if missing_prices: + raise KeyError( + "prices do not cover managed symbols: missing=%s" + % missing_prices + ) + raise ValueError( + "prices contain unmanaged symbols: extra=%s" % extra_prices + ) + + complete_weights = dict( + (symbol, weights.get(symbol, 0.0)) + for symbol in sorted(managed_symbols) + ) + targets = target_quantities( + complete_weights, + canonical_prices, + equity, + lot_size, + cash_buffer, + ) + orders = order_deltas( + targets, + canonical_current, + canonical_sellable, + lot_size, + ) + decision_id = _optional_non_empty_string(decision_id, "decision_id") + package_sha256 = _sha256_hex( + package_sha256, + "package_sha256", + allow_none=True, + ) + return { + "decision_id": decision_id, + "package_sha256": package_sha256, + "weights": complete_weights, + "targets": targets, + "orders": orders, + } + + def build_rebalance_plan( price_history, current, @@ -363,12 +905,17 @@ def build_rebalance_plan( __all__ = [ "build_rebalance_plan", + "build_target_weight_plan", "capped_target_weights", "convert_symbol", + "lookup_target_package_exact", "momentum_score", "normalize_symbol", "order_deltas", "rank_momentum", "round_board_lot", + "TargetPackageLookupMiss", + "target_package_tape_sha256", "target_quantities", + "verify_target_package", ] diff --git a/src/quant60/release_reachability.py b/src/quant60/release_reachability.py new file mode 100644 index 0000000..9377982 --- /dev/null +++ b/src/quant60/release_reachability.py @@ -0,0 +1,619 @@ +"""Tamper-evident, per-layer deployment reachability evidence. + +This contract deliberately separates six different claims for every Quant60 +layer. A source file can prove that code is implemented; it cannot, by +itself, prove that the code is tested, reachable from an entrypoint, or +observed on a real trading platform. + +All evidence is repository-relative, content-addressed, and checked against +the filesystem. The manifest also carries a self-hash so edits to statuses or +evidence fail closed even when every referenced artifact is unchanged. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +import os +import re +import tempfile +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any, Mapping + +from .contracts import ContractValidationError, load_schema, validate_contract +from .ledger import canonical_json + + +SCHEMA_VERSION = "1.0" +ARTIFACT_TYPE = "quant60-release-reachability" +LAYERS = ("universe", "alpha", "portfolio", "risk", "execution") +CAPABILITIES = ( + "implemented", + "unit_tested", + "local_entrypoint_reachable", + "jq_entrypoint_reachable", + "qmt_entrypoint_reachable", + "real_platform_observed", +) +CAPABILITY_EVIDENCE = { + "implemented": ("source", "repository"), + "unit_tested": ("test", "repository"), + "local_entrypoint_reachable": ("entrypoint", "local"), + "jq_entrypoint_reachable": ("entrypoint", "joinquant"), + "qmt_entrypoint_reachable": ("entrypoint", "qmt"), +} +RUN_EVIDENCE_ROOTS = frozenset({"artifacts", "evidence", "runs"}) +ENTRYPOINT_ROOTS = frozenset({"platforms", "src", "tools"}) +IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") + + +class ReleaseReachabilityError(ValueError): + """A reachability manifest is malformed or its evidence cannot be trusted.""" + + +def _project_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _json_copy(value: Any) -> Any: + try: + return json.loads(canonical_json(value)) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise ReleaseReachabilityError( + "reachability manifest must contain finite JSON values only" + ) from exc + + +def _strict_json(path: Path) -> Any: + def reject_constant(value: str) -> None: + raise ReleaseReachabilityError( + f"non-finite JSON constant in reachability manifest: {value}" + ) + + def reject_duplicate_keys( + pairs: list[tuple[str, Any]], + ) -> dict[str, Any]: + output: dict[str, Any] = {} + for key, value in pairs: + if key in output: + raise ReleaseReachabilityError( + f"duplicate JSON key in reachability manifest: {key}" + ) + output[key] = value + return output + + try: + return json.loads( + path.read_text(encoding="utf-8"), + parse_constant=reject_constant, + object_pairs_hook=reject_duplicate_keys, + ) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ReleaseReachabilityError( + f"reachability manifest is not valid UTF-8 JSON: {exc}" + ) from exc + + +def _manifest_body(payload: Mapping[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in payload.items() + if key != "manifest_sha256" + } + + +def release_reachability_sha256(payload: Mapping[str, Any]) -> str: + """Return the canonical self-hash, excluding ``manifest_sha256``.""" + + return hashlib.sha256( + canonical_json(_manifest_body(payload)).encode("utf-8") + ).hexdigest() + + +def _canonical_utc(value: Any, field: str) -> datetime: + if not isinstance(value, str): + raise ReleaseReachabilityError(f"{field} must be an ISO-8601 string") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ReleaseReachabilityError( + f"{field} must be a valid ISO-8601 timestamp" + ) from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ReleaseReachabilityError(f"{field} must include a timezone") + normalized = ( + parsed.astimezone(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + if value != normalized: + raise ReleaseReachabilityError( + f"{field} must use canonical UTC YYYY-MM-DDTHH:MM:SSZ" + ) + return parsed.astimezone(timezone.utc) + + +def _identifier(value: Any, field: str) -> str: + if not isinstance(value, str) or IDENTIFIER_PATTERN.fullmatch(value) is None: + raise ReleaseReachabilityError( + f"{field} must be a non-empty canonical identifier" + ) + return value + + +def _sha256(value: Any, field: str) -> str: + if not isinstance(value, str) or SHA256_PATTERN.fullmatch(value) is None: + raise ReleaseReachabilityError( + f"{field} must be a lowercase SHA-256 digest" + ) + if value == "0" * 64: + raise ReleaseReachabilityError(f"{field} cannot be an all-zero placeholder") + return value + + +def _repository_relative_path(value: Any, field: str) -> str: + if not isinstance(value, str) or not value: + raise ReleaseReachabilityError( + f"{field} must be a non-empty repository-relative path" + ) + if "\\" in value: + raise ReleaseReachabilityError(f"{field} must use POSIX separators") + candidate = PurePosixPath(value) + if ( + candidate.is_absolute() + or value != candidate.as_posix() + or any(part in {"", ".", ".."} for part in candidate.parts) + ): + raise ReleaseReachabilityError( + f"{field} must be a normalized repository-relative path" + ) + return value + + +def _artifact_file( + repository_root: Path, + relative_path: str, + *, + field: str, +) -> Path: + try: + root = repository_root.resolve(strict=True) + except OSError as exc: + raise ReleaseReachabilityError( + "repository_root must be an existing directory" + ) from exc + if not root.is_dir(): + raise ReleaseReachabilityError("repository_root must be a directory") + lexical = root.joinpath(*PurePosixPath(relative_path).parts) + if lexical.is_symlink(): + raise ReleaseReachabilityError(f"{field} cannot reference a symlink") + try: + resolved = lexical.resolve(strict=True) + except OSError as exc: + raise ReleaseReachabilityError( + f"{field} does not reference an existing artifact: {relative_path}" + ) from exc + if not resolved.is_relative_to(root) or not resolved.is_file(): + raise ReleaseReachabilityError( + f"{field} must reference a regular file inside repository_root" + ) + if resolved.stat().st_size <= 0: + raise ReleaseReachabilityError( + f"{field} cannot reference an empty artifact" + ) + return resolved + + +def _validate_artifact_path_class(kind: str, path: str, field: str) -> None: + root = PurePosixPath(path).parts[0] + if kind == "source" and root != "src": + raise ReleaseReachabilityError( + f"{field}: source evidence must live under src/" + ) + if kind == "test" and root != "tests": + raise ReleaseReachabilityError( + f"{field}: test evidence must live under tests/" + ) + if kind == "entrypoint" and root not in ENTRYPOINT_ROOTS: + raise ReleaseReachabilityError( + f"{field}: entrypoint evidence must live under " + "platforms/, src/, or tools/" + ) + if kind == "platform_run" and root not in RUN_EVIDENCE_ROOTS: + raise ReleaseReachabilityError( + f"{field}: real-platform evidence must live under " + "artifacts/, evidence/, or runs/" + ) + + +def build_evidence_artifact( + *, + artifact_id: str, + kind: str, + path: str, + platform: str, + repository_root: str | Path | None = None, + observed_at: str | None = None, + run_id: str | None = None, +) -> dict[str, Any]: + """Content-address one evidence file for use in a reachability claim.""" + + root = _project_root() if repository_root is None else Path(repository_root) + normalized_path = _repository_relative_path(path, "artifact.path") + if kind not in {"source", "test", "entrypoint", "platform_run"}: + raise ReleaseReachabilityError("artifact.kind is not supported") + if platform not in {"repository", "local", "joinquant", "qmt"}: + raise ReleaseReachabilityError("artifact.platform is not supported") + _validate_artifact_path_class(kind, normalized_path, "artifact.path") + if kind == "platform_run": + if platform not in {"joinquant", "qmt"}: + raise ReleaseReachabilityError( + "platform_run evidence requires joinquant or qmt" + ) + if observed_at is None or run_id is None: + raise ReleaseReachabilityError( + "platform_run evidence requires observed_at and run_id" + ) + _canonical_utc(observed_at, "artifact.observed_at") + _identifier(run_id, "artifact.run_id") + elif observed_at is not None or run_id is not None: + raise ReleaseReachabilityError( + "only platform_run evidence may carry observed_at and run_id" + ) + resolved = _artifact_file(root, normalized_path, field="artifact.path") + artifact: dict[str, Any] = { + "artifact_id": _identifier(artifact_id, "artifact.artifact_id"), + "kind": kind, + "path": normalized_path, + "sha256": hashlib.sha256(resolved.read_bytes()).hexdigest(), + "platform": platform, + } + if observed_at is not None: + artifact["observed_at"] = observed_at + if run_id is not None: + artifact["run_id"] = run_id + return artifact + + +def _expected_summary(layers: Mapping[str, Any]) -> dict[str, bool]: + if set(layers) != set(LAYERS): + raise ReleaseReachabilityError( + f"layers must be exactly {list(LAYERS)}" + ) + for layer in LAYERS: + claims = layers[layer] + if not isinstance(claims, Mapping) or set(claims) != set(CAPABILITIES): + raise ReleaseReachabilityError( + f"layers.{layer} must contain exactly {list(CAPABILITIES)}" + ) + for capability in CAPABILITIES: + claim = claims[capability] + if ( + not isinstance(claim, Mapping) + or set(claim) != {"status", "artifacts"} + or type(claim.get("status")) is not bool + or not isinstance(claim.get("artifacts"), list) + ): + raise ReleaseReachabilityError( + f"layers.{layer}.{capability} must contain a boolean " + "status and an artifacts array" + ) + return { + f"all_layers_{capability}": all( + bool(layers[layer][capability]["status"]) + for layer in LAYERS + ) + for capability in CAPABILITIES + } + + +def _validate_artifact( + artifact: Any, + *, + layer: str, + capability: str, + repository_root: Path, + generated_at: datetime, + seen_artifact_ids: set[str], +) -> dict[str, Any]: + field = f"layers.{layer}.{capability}.artifacts" + if not isinstance(artifact, Mapping): + raise ReleaseReachabilityError(f"{field} entries must be objects") + normalized = _json_copy(dict(artifact)) + base_fields = {"artifact_id", "kind", "path", "sha256", "platform"} + run_fields = base_fields | {"observed_at", "run_id"} + expected_fields = ( + run_fields + if normalized.get("kind") == "platform_run" + else base_fields + ) + if set(normalized) != expected_fields: + raise ReleaseReachabilityError( + f"{field} entry fields must be exactly {sorted(expected_fields)}" + ) + + artifact_id = _identifier( + normalized["artifact_id"], + f"{field}.artifact_id", + ) + if artifact_id in seen_artifact_ids: + raise ReleaseReachabilityError( + f"artifact_id {artifact_id!r} is reused across claims" + ) + seen_artifact_ids.add(artifact_id) + + kind = normalized["kind"] + platform = normalized["platform"] + if capability == "real_platform_observed": + if kind != "platform_run" or platform not in {"joinquant", "qmt"}: + raise ReleaseReachabilityError( + f"{field}: real_platform_observed requires a " + "joinquant/qmt platform_run artifact" + ) + observed_at = _canonical_utc( + normalized["observed_at"], + f"{field}.observed_at", + ) + if observed_at > generated_at: + raise ReleaseReachabilityError( + f"{field}.observed_at cannot be after generated_at" + ) + _identifier(normalized["run_id"], f"{field}.run_id") + else: + expected_kind, expected_platform = CAPABILITY_EVIDENCE[capability] + if (kind, platform) != (expected_kind, expected_platform): + raise ReleaseReachabilityError( + f"{field}: {capability} requires kind={expected_kind!r} " + f"and platform={expected_platform!r}" + ) + + path = _repository_relative_path(normalized["path"], f"{field}.path") + _validate_artifact_path_class(kind, path, field) + expected_hash = _sha256(normalized["sha256"], f"{field}.sha256") + actual_hash = hashlib.sha256( + _artifact_file( + repository_root, + path, + field=f"{field}.path", + ).read_bytes() + ).hexdigest() + if actual_hash != expected_hash: + raise ReleaseReachabilityError( + f"{field}: artifact hash mismatch for {path}; " + f"expected {expected_hash}, got {actual_hash}" + ) + return normalized + + +def validate_release_reachability( + payload: Mapping[str, Any], + *, + repository_root: str | Path | None = None, + expected_release_id: str | None = None, + expected_manifest_sha256: str | None = None, +) -> dict[str, Any]: + """Validate shape, status semantics, hashes, and every evidence file.""" + + if not isinstance(payload, Mapping): + raise ReleaseReachabilityError("reachability manifest must be an object") + normalized = _json_copy(dict(payload)) + try: + validate_contract( + normalized, + load_schema("release_reachability.schema.json"), + ) + except (ContractValidationError, FileNotFoundError, ValueError) as exc: + raise ReleaseReachabilityError( + f"reachability schema validation failed: {exc}" + ) from exc + + release_id = _identifier(normalized["release_id"], "release_id") + if expected_release_id is not None and release_id != expected_release_id: + raise ReleaseReachabilityError( + "release_id does not match expected identity" + ) + generated_at = _canonical_utc(normalized["generated_at"], "generated_at") + supplied_hash = _sha256( + normalized["manifest_sha256"], + "manifest_sha256", + ) + if ( + expected_manifest_sha256 is not None + and supplied_hash != _sha256( + expected_manifest_sha256, + "expected_manifest_sha256", + ) + ): + raise ReleaseReachabilityError( + "manifest_sha256 does not match expected identity" + ) + actual_manifest_hash = release_reachability_sha256(normalized) + if supplied_hash != actual_manifest_hash: + raise ReleaseReachabilityError( + "reachability manifest SHA-256 mismatch" + ) + + layers = normalized["layers"] + if set(layers) != set(LAYERS): + raise ReleaseReachabilityError( + f"layers must be exactly {list(LAYERS)}" + ) + root = _project_root() if repository_root is None else Path(repository_root) + seen_artifact_ids: set[str] = set() + for layer in LAYERS: + claims = layers[layer] + if set(claims) != set(CAPABILITIES): + raise ReleaseReachabilityError( + f"layers.{layer} must contain exactly {list(CAPABILITIES)}" + ) + for capability in CAPABILITIES: + claim = claims[capability] + if not isinstance(claim, Mapping) or set(claim) != { + "status", + "artifacts", + }: + raise ReleaseReachabilityError( + f"layers.{layer}.{capability} must contain only " + "status and artifacts" + ) + status = claim["status"] + artifacts = claim["artifacts"] + if type(status) is not bool or not isinstance(artifacts, list): + raise ReleaseReachabilityError( + f"layers.{layer}.{capability} has invalid status/artifacts" + ) + if status != bool(artifacts): + raise ReleaseReachabilityError( + f"layers.{layer}.{capability}: status must be true " + "iff at least one evidence artifact exists" + ) + for artifact in artifacts: + _validate_artifact( + artifact, + layer=layer, + capability=capability, + repository_root=root, + generated_at=generated_at, + seen_artifact_ids=seen_artifact_ids, + ) + + if claims["unit_tested"]["status"] and not claims["implemented"]["status"]: + raise ReleaseReachabilityError( + f"layers.{layer}: unit_tested requires implemented" + ) + for capability in ( + "local_entrypoint_reachable", + "jq_entrypoint_reachable", + "qmt_entrypoint_reachable", + ): + if ( + claims[capability]["status"] + and not claims["implemented"]["status"] + ): + raise ReleaseReachabilityError( + f"layers.{layer}: {capability} requires implemented" + ) + for artifact in claims["real_platform_observed"]["artifacts"]: + platform_capability = { + "joinquant": "jq_entrypoint_reachable", + "qmt": "qmt_entrypoint_reachable", + }[artifact["platform"]] + if not claims[platform_capability]["status"]: + raise ReleaseReachabilityError( + f"layers.{layer}: real {artifact['platform']} observation " + f"requires {platform_capability}" + ) + + expected_summary = _expected_summary(layers) + if normalized["summary"] != expected_summary: + raise ReleaseReachabilityError( + "summary is not the exact derived all-layer status" + ) + return normalized + + +def seal_release_reachability( + body: Mapping[str, Any], + *, + repository_root: str | Path | None = None, +) -> dict[str, Any]: + """Add derived summary/self-hash and perform full fail-closed validation.""" + + if not isinstance(body, Mapping): + raise ReleaseReachabilityError("reachability body must be an object") + normalized = _json_copy(dict(body)) + if "manifest_sha256" in normalized: + raise ReleaseReachabilityError( + "unsealed reachability body cannot contain manifest_sha256" + ) + if "summary" in normalized: + raise ReleaseReachabilityError( + "unsealed reachability body cannot contain derived summary" + ) + layers = normalized.get("layers") + if not isinstance(layers, Mapping): + raise ReleaseReachabilityError("layers must be an object") + normalized["summary"] = _expected_summary(layers) + normalized["manifest_sha256"] = release_reachability_sha256(normalized) + return validate_release_reachability( + normalized, + repository_root=repository_root, + ) + + +def build_release_reachability( + *, + release_id: str, + generated_at: str, + layers: Mapping[str, Any], + repository_root: str | Path | None = None, +) -> dict[str, Any]: + """Construct and seal one complete five-layer reachability manifest.""" + + return seal_release_reachability( + { + "schema_version": SCHEMA_VERSION, + "artifact_type": ARTIFACT_TYPE, + "release_id": release_id, + "generated_at": generated_at, + "layers": copy.deepcopy(dict(layers)), + }, + repository_root=repository_root, + ) + + +def write_release_reachability( + payload: Mapping[str, Any], + path: str | Path, + *, + repository_root: str | Path | None = None, +) -> Path: + """Atomically write a fully verified canonical manifest.""" + + verified = validate_release_reachability( + payload, + repository_root=repository_root, + ) + destination = Path(path) + destination.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{destination.name}.", + suffix=".tmp", + dir=str(destination.parent), + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(canonical_json(verified)) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_name, destination) + except BaseException: + try: + os.unlink(temporary_name) + except FileNotFoundError: + pass + raise + return destination + + +def load_verified_release_reachability( + path: str | Path, + *, + repository_root: str | Path | None = None, + expected_release_id: str | None = None, + expected_manifest_sha256: str | None = None, +) -> dict[str, Any]: + """Strictly load a manifest and re-check both it and all artifacts.""" + + raw = _strict_json(Path(path)) + return validate_release_reachability( + raw, + repository_root=repository_root, + expected_release_id=expected_release_id, + expected_manifest_sha256=expected_manifest_sha256, + ) diff --git a/src/quant60/target_package.py b/src/quant60/target_package.py new file mode 100644 index 0000000..5fe0d1c --- /dev/null +++ b/src/quant60/target_package.py @@ -0,0 +1,667 @@ +"""Canonical, quantity-free target-package contract. + +``TargetPackageV1`` is the boundary between research/portfolio construction +and a platform execution adapter. It deliberately contains post-risk target +*weights*, but never share quantities, orders, fills, cash or account state. +Those values belong to a later, account-bound execution plan. + +The validator is intentionally fail closed. JSON Schema provides the wire +shape while this module enforces cross-field invariants that JSON Schema +cannot express compactly: the China-close decision clock, canonical symbols, +gross exposure, content hashing and unambiguous tape lookup. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +import math +from dataclasses import dataclass +from datetime import date, datetime, time, timedelta, timezone +from pathlib import Path +from typing import Any, Iterable, Iterator, Mapping + +from .contracts import ContractValidationError, load_schema, validate_contract +from .ledger import canonical_json +from .portable_core import normalize_symbol + + +PACKAGE_TYPE = "TargetPackageV1" +SCHEMA_VERSION = "1.0" +SHANGHAI_OFFSET = timedelta(hours=8) +SHA256_FIELDS = ( + "model_sha256", + "data_sha256", + "feature_sha256", + "risk_sha256", + "cost_sha256", + "optimizer_sha256", + "config_sha256", + "source_sha256", +) +UNIVERSE_STATES = { + "openable", + "hold_only", + "sell_only", + "frozen", + "excluded", +} +WEIGHT_TOLERANCE = 1e-12 + + +class TargetPackageError(ValueError): + """A target package is incomplete, ambiguous or internally inconsistent.""" + + +class DuplicateTargetDecisionError(TargetPackageError): + """A tape contains more than one representation of a decision identity.""" + + +class TargetPackageNotFoundError(TargetPackageError, LookupError): + """An exact signal/session pair is absent from a target-package tape.""" + + +def _json_copy(value: Any) -> Any: + """Return a detached JSON value and reject non-JSON/non-finite input.""" + + try: + return json.loads(canonical_json(value)) + except (TypeError, ValueError) as exc: + raise TargetPackageError( + "target package must contain finite JSON values only" + ) from exc + + +def _strict_json_text(value: str, *, source: str) -> Any: + def reject_constant(constant: str) -> None: + raise TargetPackageError( + f"{source} contains a non-finite JSON constant: {constant}" + ) + + def reject_duplicate_keys( + pairs: list[tuple[str, Any]], + ) -> dict[str, Any]: + output: dict[str, Any] = {} + for key, child in pairs: + if key in output: + raise TargetPackageError( + f"{source} contains a duplicate JSON key: {key}" + ) + output[key] = child + return output + + try: + return json.loads( + value, + parse_constant=reject_constant, + object_pairs_hook=reject_duplicate_keys, + ) + except json.JSONDecodeError as exc: + raise TargetPackageError( + f"{source} is not valid JSON: {exc}" + ) from exc + + +def _reject_quantity_fields(value: Any, path: str = "$") -> None: + if isinstance(value, Mapping): + for raw_key, child in value.items(): + key = str(raw_key) + normalized = key.lower().replace("-", "_") + if "quantity" in normalized or "quantities" in normalized: + raise TargetPackageError( + f"{path}.{key}: quantities do not belong in TargetPackageV1" + ) + _reject_quantity_fields(child, f"{path}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + _reject_quantity_fields(child, f"{path}[{index}]") + + +def _parse_signal_as_of(value: Any) -> datetime: + if not isinstance(value, str): + raise TargetPackageError("signal_as_of must be an ISO-8601 string") + try: + parsed = datetime.fromisoformat(value) + except ValueError as exc: + raise TargetPackageError( + "signal_as_of must be a valid ISO-8601 timestamp" + ) from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise TargetPackageError("signal_as_of must include a timezone offset") + if parsed.utcoffset() != SHANGHAI_OFFSET: + raise TargetPackageError( + "signal_as_of must use the Asia/Shanghai +08:00 offset" + ) + if parsed.timetz().replace(tzinfo=None) != time(15, 0): + raise TargetPackageError( + "signal_as_of must be the completed 15:00 China-market close" + ) + canonical = ( + f"{parsed.date().isoformat()}T15:00:00+08:00" + ) + if value != canonical: + raise TargetPackageError( + "signal_as_of must use canonical " + "YYYY-MM-DDT15:00:00+08:00 serialization" + ) + return parsed + + +def _parse_next_session(value: Any, signal_as_of: datetime) -> date: + if not isinstance(value, str): + raise TargetPackageError("next_session must be an ISO date string") + try: + parsed = date.fromisoformat(value) + except ValueError as exc: + raise TargetPackageError("next_session must be a valid ISO date") from exc + if value != parsed.isoformat(): + raise TargetPackageError("next_session must use canonical YYYY-MM-DD") + if parsed <= signal_as_of.date(): + raise TargetPackageError("next_session must be after the signal session") + if parsed.weekday() >= 5: + raise TargetPackageError("next_session cannot be a weekend") + return parsed + + +def _validate_sha256(value: Any, name: str) -> str: + if ( + not isinstance(value, str) + or len(value) != 64 + or any(character not in "0123456789abcdef" for character in value) + ): + raise TargetPackageError( + f"{name} must be a 64-character lowercase SHA-256 hex digest" + ) + if value == "0" * 64: + raise TargetPackageError(f"{name} cannot be an all-zero placeholder") + return value + + +def _canonical_symbol(value: Any, name: str) -> str: + if not isinstance(value, str): + raise TargetPackageError(f"{name} must be a canonical symbol string") + try: + canonical = normalize_symbol(value) + except (TypeError, ValueError) as exc: + raise TargetPackageError(f"{name} is not a supported A-share symbol") from exc + if value != canonical: + raise TargetPackageError( + f"{name} must be canonical; expected {canonical!r}" + ) + return canonical + + +def _finite_weight(value: Any, name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TargetPackageError(f"{name} must be a finite JSON number") + numeric = float(value) + if not math.isfinite(numeric): + raise TargetPackageError(f"{name} must be finite") + if numeric < 0 or numeric > 1: + raise TargetPackageError(f"{name} must lie in [0, 1]") + return numeric + + +def _validate_weights(value: Any) -> dict[str, float]: + if not isinstance(value, Mapping) or not value: + raise TargetPackageError("target_weights must be a non-empty object") + output: dict[str, float] = {} + for raw_symbol, raw_weight in value.items(): + symbol = _canonical_symbol(raw_symbol, "target_weights symbol") + if symbol in output: + raise TargetPackageError(f"duplicate target weight for {symbol}") + output[symbol] = _finite_weight( + raw_weight, + f"target_weights.{symbol}", + ) + if list(value) != sorted(value): + raise TargetPackageError( + "target_weights keys must be in canonical symbol order" + ) + return output + + +def _validate_reasons(value: Any, symbol: str) -> tuple[str, ...]: + if not isinstance(value, list) or not value: + raise TargetPackageError( + f"universe.{symbol}.reasons must be a non-empty array" + ) + reasons: list[str] = [] + for reason in value: + if ( + not isinstance(reason, str) + or not reason + or any( + character not in "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" + for character in reason + ) + ): + raise TargetPackageError( + f"universe.{symbol}.reasons must contain uppercase reason codes" + ) + reasons.append(reason) + if reasons != sorted(set(reasons)): + raise TargetPackageError( + f"universe.{symbol}.reasons must be unique and sorted" + ) + return tuple(reasons) + + +def _validate_universe(value: Any) -> dict[str, dict[str, Any]]: + if not isinstance(value, Mapping) or not value: + raise TargetPackageError("universe must be a non-empty object") + output: dict[str, dict[str, Any]] = {} + for raw_symbol, raw_state in value.items(): + symbol = _canonical_symbol(raw_symbol, "universe symbol") + if symbol in output: + raise TargetPackageError(f"duplicate universe symbol {symbol}") + if not isinstance(raw_state, Mapping): + raise TargetPackageError(f"universe.{symbol} must be an object") + if set(raw_state) != {"state", "reasons"}: + raise TargetPackageError( + f"universe.{symbol} must contain only state and reasons" + ) + state = raw_state["state"] + if state not in UNIVERSE_STATES: + raise TargetPackageError( + f"universe.{symbol}.state is not a canonical universe state" + ) + reasons = _validate_reasons(raw_state["reasons"], symbol) + if state == "openable" and reasons != ("ELIGIBLE",): + raise TargetPackageError( + f"openable universe member {symbol} must have reason ELIGIBLE" + ) + if state != "openable" and "ELIGIBLE" in reasons: + raise TargetPackageError( + f"non-openable universe member {symbol} cannot be ELIGIBLE" + ) + output[symbol] = {"state": state, "reasons": list(reasons)} + if list(value) != sorted(value): + raise TargetPackageError("universe keys must be in canonical symbol order") + return output + + +def _validate_constraints( + value: Any, + weights: Mapping[str, float], +) -> dict[str, Any]: + required = { + "constraint_state", + "long_only", + "max_single_weight", + "max_gross_weight", + "max_one_way_turnover", + "actual_gross_weight", + } + if not isinstance(value, Mapping) or set(value) != required: + missing = sorted(required - set(value or {})) if isinstance(value, Mapping) else sorted(required) + unknown = sorted(set(value or {}) - required) if isinstance(value, Mapping) else [] + raise TargetPackageError( + f"constraints fields mismatch; missing={missing}, unknown={unknown}" + ) + if value["constraint_state"] not in { + "FEASIBLE", + "REPAIRED", + "RISK_REDUCTION_ONLY", + "NO_TRADE", + }: + raise TargetPackageError("constraints.constraint_state is unsupported") + if value["long_only"] is not True: + raise TargetPackageError("TargetPackageV1 is strictly long-only") + max_single = _finite_weight( + value["max_single_weight"], + "constraints.max_single_weight", + ) + if max_single <= 0: + raise TargetPackageError( + "constraints.max_single_weight must be positive" + ) + max_gross = _finite_weight( + value["max_gross_weight"], + "constraints.max_gross_weight", + ) + max_turnover = _finite_weight( + value["max_one_way_turnover"], + "constraints.max_one_way_turnover", + ) + declared_gross = _finite_weight( + value["actual_gross_weight"], + "constraints.actual_gross_weight", + ) + actual_gross = math.fsum(weights.values()) + if abs(declared_gross - actual_gross) > WEIGHT_TOLERANCE: + raise TargetPackageError( + "constraints.actual_gross_weight does not equal target_weights gross" + ) + if actual_gross > max_gross + WEIGHT_TOLERANCE: + raise TargetPackageError("target_weights exceed max_gross_weight") + breaches = [ + symbol + for symbol, weight in weights.items() + if weight > max_single + WEIGHT_TOLERANCE + ] + if breaches: + raise TargetPackageError( + f"target_weights exceed max_single_weight for {breaches}" + ) + return { + "constraint_state": value["constraint_state"], + "long_only": True, + "max_single_weight": max_single, + "max_gross_weight": max_gross, + "max_one_way_turnover": max_turnover, + "actual_gross_weight": declared_gross, + } + + +def _claims_baseline(package: Mapping[str, Any]) -> bool: + claim = " ".join( + str(package.get(name, "")) + for name in ("release", "evidence_class") + ).lower() + return "baseline" in claim + + +def _validate_release_claim(package: Mapping[str, Any]) -> None: + model_id = str(package.get("model_id", "")).strip().lower() + if _claims_baseline(package) and "portable-momentum" in model_id: + raise TargetPackageError( + "portable-momentum is a platform smoke strategy and cannot claim " + "a baseline release" + ) + + +def target_package_sha256(package: Mapping[str, Any]) -> str: + """Compute the canonical content digest, excluding only its digest field.""" + + if not isinstance(package, Mapping): + raise TargetPackageError("target package must be an object") + body = dict(package) + body.pop("package_sha256", None) + _reject_quantity_fields(body) + return hashlib.sha256(canonical_json(body).encode("utf-8")).hexdigest() + + +def seal_target_package(package: Mapping[str, Any]) -> dict[str, Any]: + """Attach ``package_sha256`` to an unsealed package and validate it.""" + + if not isinstance(package, Mapping): + raise TargetPackageError("target package must be an object") + if "package_sha256" in package: + raise TargetPackageError("refusing to reseal an already sealed package") + sealed = _json_copy(package) + sealed["package_sha256"] = target_package_sha256(sealed) + return validate_target_package(sealed) + + +def validate_target_package( + package: Mapping[str, Any], + *, + expected_release: str | None = None, + expected_signal_as_of: str | None = None, + expected_next_session: str | None = None, +) -> dict[str, Any]: + """Validate and return a detached canonical ``TargetPackageV1`` mapping.""" + + if not isinstance(package, Mapping): + raise TargetPackageError("target package must be an object") + candidate = _json_copy(package) + _reject_quantity_fields(candidate) + try: + validate_contract( + candidate, + load_schema("target_package.schema.json"), + ) + except (ContractValidationError, FileNotFoundError) as exc: + raise TargetPackageError(f"target package schema rejected: {exc}") from exc + + signal = _parse_signal_as_of(candidate["signal_as_of"]) + _parse_next_session(candidate["next_session"], signal) + for name in SHA256_FIELDS: + _validate_sha256(candidate[name], name) + _validate_sha256(candidate["package_sha256"], "package_sha256") + + if not str(candidate["package_id"]).strip(): + raise TargetPackageError("package_id must be non-empty") + if not str(candidate["decision_id"]).strip(): + raise TargetPackageError("decision_id must be non-empty") + if not str(candidate["release"]).strip(): + raise TargetPackageError("release must be non-empty") + if not str(candidate["evidence_class"]).strip(): + raise TargetPackageError("evidence_class must be non-empty") + if not str(candidate["model_id"]).strip(): + raise TargetPackageError("model_id must be non-empty") + + weights = _validate_weights(candidate["target_weights"]) + universe = _validate_universe(candidate["universe"]) + missing_universe = sorted(set(weights) - set(universe)) + if missing_universe: + raise TargetPackageError( + f"target symbols are absent from universe: {missing_universe}" + ) + invalid_excluded = sorted( + symbol + for symbol, weight in weights.items() + if universe[symbol]["state"] == "excluded" + and weight > WEIGHT_TOLERANCE + ) + if invalid_excluded: + raise TargetPackageError( + f"excluded symbols have positive target weights: {invalid_excluded}" + ) + _validate_constraints(candidate["constraints"], weights) + _validate_release_claim(candidate) + + expected_hash = target_package_sha256(candidate) + if candidate["package_sha256"] != expected_hash: + raise TargetPackageError("package_sha256 content hash mismatch") + if expected_release is not None and candidate["release"] != expected_release: + raise TargetPackageError("target package release does not match request") + if ( + expected_signal_as_of is not None + and candidate["signal_as_of"] != expected_signal_as_of + ): + raise TargetPackageError( + "target package signal_as_of does not match request" + ) + if ( + expected_next_session is not None + and candidate["next_session"] != expected_next_session + ): + raise TargetPackageError( + "target package next_session does not match request" + ) + return candidate + + +@dataclass(frozen=True, slots=True) +class TargetPackageV1: + """Immutable value wrapper around a validated package payload.""" + + _canonical_payload: str + + @classmethod + def from_mapping( + cls, + package: Mapping[str, Any], + **expected: str, + ) -> "TargetPackageV1": + validated = validate_target_package(package, **expected) + return cls(canonical_json(validated)) + + @classmethod + def from_dict( + cls, + package: Mapping[str, Any], + **expected: str, + ) -> "TargetPackageV1": + return cls.from_mapping(package, **expected) + + def as_dict(self) -> dict[str, Any]: + return json.loads(self._canonical_payload) + + @property + def package_id(self) -> str: + return self.as_dict()["package_id"] + + @property + def decision_id(self) -> str: + return self.as_dict()["decision_id"] + + @property + def signal_as_of(self) -> str: + return self.as_dict()["signal_as_of"] + + @property + def next_session(self) -> str: + return self.as_dict()["next_session"] + + +class TargetPackageTape: + """Validated multi-decision tape with fail-closed exact lookup.""" + + def __init__( + self, + packages: Iterable[Mapping[str, Any] | TargetPackageV1], + ) -> None: + items: list[TargetPackageV1] = [] + package_ids: set[str] = set() + decision_ids: set[str] = set() + clock_keys: set[tuple[str, str]] = set() + for raw in packages: + item = ( + raw + if isinstance(raw, TargetPackageV1) + else TargetPackageV1.from_mapping(raw) + ) + key = (item.signal_as_of, item.next_session) + if item.package_id in package_ids: + raise DuplicateTargetDecisionError( + f"duplicate package_id {item.package_id!r}" + ) + if item.decision_id in decision_ids: + raise DuplicateTargetDecisionError( + f"duplicate decision_id {item.decision_id!r}" + ) + if key in clock_keys: + raise DuplicateTargetDecisionError( + "duplicate decision for signal_as_of/next_session " + f"{key!r}" + ) + package_ids.add(item.package_id) + decision_ids.add(item.decision_id) + clock_keys.add(key) + items.append(item) + if not items: + raise TargetPackageError("target-package tape is empty") + self._items = tuple( + sorted( + items, + key=lambda item: ( + item.signal_as_of, + item.next_session, + item.decision_id, + ), + ) + ) + self._by_clock = { + (item.signal_as_of, item.next_session): item + for item in self._items + } + + def __len__(self) -> int: + return len(self._items) + + def __iter__(self) -> Iterator[dict[str, Any]]: + for item in self._items: + yield item.as_dict() + + def lookup_exact( + self, + *, + signal_as_of: str, + next_session: str, + decision_id: str | None = None, + ) -> dict[str, Any]: + signal = _parse_signal_as_of(signal_as_of) + _parse_next_session(next_session, signal) + item = self._by_clock.get((signal_as_of, next_session)) + if item is None: + raise TargetPackageNotFoundError( + "no target package for exact signal_as_of/next_session pair" + ) + if decision_id is not None and item.decision_id != decision_id: + raise TargetPackageNotFoundError( + "exact clock pair exists but decision_id does not match" + ) + return item.as_dict() + + def lookup( + self, + *, + signal_as_of: str, + next_session: str, + decision_id: str | None = None, + ) -> dict[str, Any]: + return self.lookup_exact( + signal_as_of=signal_as_of, + next_session=next_session, + decision_id=decision_id, + ) + + +def validate_target_package_tape( + packages: Iterable[Mapping[str, Any] | TargetPackageV1], +) -> TargetPackageTape: + return TargetPackageTape(packages) + + +def lookup_target_package( + packages: Iterable[Mapping[str, Any] | TargetPackageV1], + *, + signal_as_of: str, + next_session: str, + decision_id: str | None = None, +) -> dict[str, Any]: + return TargetPackageTape(packages).lookup_exact( + signal_as_of=signal_as_of, + next_session=next_session, + decision_id=decision_id, + ) + + +def load_target_package_tape(path: str | Path) -> TargetPackageTape: + """Strictly load one package, an array/``packages`` object, or JSONL.""" + + source = Path(path) + try: + if source.suffix.lower() == ".jsonl": + packages = [ + _strict_json_text( + line, + source=f"target-package tape line {line_number}", + ) + for line_number, line in enumerate( + source.read_text(encoding="utf-8").splitlines(), + 1, + ) + if line.strip() + ] + else: + raw = _strict_json_text( + source.read_text(encoding="utf-8"), + source="target-package tape", + ) + if isinstance(raw, dict) and raw.get("package_type") == PACKAGE_TYPE: + packages = [raw] + else: + packages = raw.get("packages") if isinstance(raw, dict) else raw + except (OSError, UnicodeError) as exc: + raise TargetPackageError(f"cannot load target-package tape: {exc}") from exc + if not isinstance(packages, list): + raise TargetPackageError( + "target-package tape must be one TargetPackageV1, a JSON array, " + "a packages object, or JSONL" + ) + return TargetPackageTape(packages) diff --git a/status/status-data.js b/status/status-data.js index 8a66aff..88ee135 100644 --- a/status/status-data.js +++ b/status/status-data.js @@ -5,25 +5,25 @@ window.QUANT_OS_PUBLIC_STATUS = { "artifacts": [ { "core": "src/quant60/portable_core.py", - "coreSha256": "af29e4e5e10705cf6aff89874394c1e80d3780f093c19d90ed53ef9d0188a934", - "effectiveConfigSha256": "3ef9b9efda9ec6cc5f57a5daca57cab3b68242e0368f18fdaaf9e249bdaa7873", + "coreSha256": "f9de9f18e4008bb0fc80edb0b21cae6ea06a61828d89e9b7c11057f98225fc19", + "effectiveConfigSha256": "d4ae2882e9b5d8d673e7c62c0dc54def7c20221c358ac091ee6529bf0413df0b", "id": "joinquant", "integrity": "manifest_hashes_match_repository", "output": "dist/joinquant_strategy.py", - "outputSha256": "22ddb0546beb3bd4e4bbedecb271452111324c3903e45be1f4ac3d39f38cce4f", + "outputSha256": "be4ca1d5764416b7c8e6c206121c0f0bd8219e7317679d79bc72a95492286b1a", "wrapper": "platforms/joinquant_strategy.py", - "wrapperSha256": "b5a8e3b0a774a0f45f52f6bcccbe786e234913a1a4cf5b68b85e2bd989ee9294" + "wrapperSha256": "0c672b64665b7c1d4409ba288b00d1608e976800af3a4f9a6467767ce4def592" }, { "core": "src/quant60/portable_core.py", - "coreSha256": "af29e4e5e10705cf6aff89874394c1e80d3780f093c19d90ed53ef9d0188a934", - "effectiveConfigSha256": "11f189ea5c9d4eaddeb6394dc342abc871941cb3395d7ff79aae1275ce2ad98f", + "coreSha256": "f9de9f18e4008bb0fc80edb0b21cae6ea06a61828d89e9b7c11057f98225fc19", + "effectiveConfigSha256": "7e4739937e4e99e5b58154f53271d8d3bfaed47d8d1d5adc747101babde827f5", "id": "qmt_builtin", "integrity": "manifest_hashes_match_repository", "output": "dist/qmt_builtin_strategy.py", - "outputSha256": "ad503e5ebde60a5deb0ec86334c04d4be2d77ab91d9607d4fe88e2119f22d5dc", + "outputSha256": "483877b7ea55c8e0f5e4e47b48a156b3add6cf0a8085011414c5c600913939b5", "wrapper": "platforms/qmt_builtin_strategy.py", - "wrapperSha256": "efb931bbd04d0abf0a576f2422a13403fd4d659dccf59f8170e8b13b1bafa640" + "wrapperSha256": "a1e53dc22ebd9bb01be43675786151606bbed273d8ab883fb933461e379d2727" } ], "baselineConfig": "configs/baseline.json", @@ -40,21 +40,91 @@ window.QUANT_OS_PUBLIC_STATUS = { "topN": 20, "universeMode": "pit_index" }, - "portableCoreSha256": "af29e4e5e10705cf6aff89874394c1e80d3780f093c19d90ed53ef9d0188a934", + "portableCoreSha256": "f9de9f18e4008bb0fc80edb0b21cae6ea06a61828d89e9b7c11057f98225fc19", "schemaVersion": 1 }, + "candidateBoundary": { + "allFiveLayersRealPlatformObserved": false, + "connectedExecution": "reference one-shot quantity/order-delta planner", + "connectedOptimizer": "deterministic constrained optimizer", + "connectedRiskModel": "60-session diagonal realized variance", + "decisionMode": "target_package", + "historicalJoinQuantCountsAsBaseline": false, + "historicalJoinQuantMode": "portable_momentum_smoke", + "joinQuantTargetPackageCountsAsBaseline": false, + "joinQuantTargetPackageCountsAsG9": false, + "joinQuantTargetPackageRun": { + "allFiveLayersObserved": false, + "baseline60Passed": false, + "bundleSha256": "0586930ddc483f4489f09b562f4d4232b458b703e6a1194af809b707211be8c4", + "evidenceClass": "synthetic-research", + "fills": 3, + "g9Passed": false, + "investmentValueClaim": false, + "marketDataFrom": "2024-03-11", + "marketDataThrough": "2024-03-13", + "observedAt": "2026-07-26T08:50:15Z", + "observedLayers": [ + "execution" + ], + "orders": 3, + "packageId": "TP-20240311-795dcfba", + "qmtPeerObserved": false, + "runId": "2457a7c39a276e09e0fabf99e28978e1" + }, + "localEvidenceClass": "synthetic_research", + "reachability": { + "manifest": "releases/quant60-research-candidate-v1/reachability.json", + "manifestSha256": "bf3379b1d8d73ea64d73db8317cfa27307b5dc218884ea2710ffe71dd116c017", + "realPlatformObservedLayers": [ + "execution" + ], + "realPlatformsObserved": [ + "joinquant" + ], + "releaseId": "quant60-research-candidate-v1", + "summary": { + "all_layers_implemented": true, + "all_layers_jq_entrypoint_reachable": true, + "all_layers_local_entrypoint_reachable": true, + "all_layers_qmt_entrypoint_reachable": true, + "all_layers_real_platform_observed": false, + "all_layers_unit_tested": true + } + }, + "realJoinQuantObserved": true, + "realPlatformObservedLayers": [ + "execution" + ], + "realQmtObserved": false, + "releaseVerified": false + }, "claim": { "allGatesRequired": true, "baselineThreshold": 60, "gatesPassed": 0, "gatesTotal": 10, "liveReady": false, - "reason": "Quant OS 已有可执行本地流水线、真实 Qlib fixture smoke、带校验但不完整的 Tushare→Qlib 技术 smoke,以及一次完成的真实聚宽 hosted smoke;但仍缺完整复权 PIT 数据、同输入聚宽比较、真实 QMT、券商影子盘、连续对账与合规证据,因此不能计入 Baseline 60。", + "reason": "Quant OS 已接通本地 synthetic 五层候选链,能冻结 Ridge ModelBundle、逐层 trace 和无股数 TargetPackageV1;一个 synthetic-research TargetPackage 已在真实 JoinQuant hosted 环境完成消费、账户/开盘价绑定、3 笔委托和 3 笔成交,因此 execution 层已有真实平台观察。但真实 provider-data 长样本/OOS 尚未接入,JoinQuant 没有重跑上游 Universe/Alpha/Portfolio/Risk,QMT peer 与逐层真实数据 parity 仍缺失,factor risk、CVXPY 和 guarded TWAP/POV 也尚未进入当前主链。因此它仍是 NOT_BASELINE_60,G9 未通过。", "releaseVerified": false, "score": null, "scoreStatus": "not_scored", "status": "NOT_BASELINE_60" }, + "deliveryGaps": { + "externalEvidence": [ + "authorized JQData market snapshot, licence/lineage record and a frozen provider-data candidate tape", + "authorized real-data multi-period JoinQuant TargetPackage run plus same-input local/JoinQuant layer-by-layer reconciliation", + "real QMT TargetPackage built-in and qmttools backtest exports", + "real QMT historical-ST positive control, terminal/xtquant build record, broker query contract and trade-amount semantic probes, callback/restart and at least 20 trading days of shadow evidence", + "continuous reconciliation, recovery drills and broker compliance confirmation" + ], + "internal": [ + "The authorized provider-data path has not yet been connected end to end to the frozen Ridge ModelBundle, calibrated risk/cost inputs, five-layer Baseline candidate and TargetPackage tape; the current connected candidate remains synthetic.", + "factor_risk.py, the optional CVXPY optimizer mode and guarded TWAP/POV exist as components or optional paths but are not selected by the current Baseline main slice; the connected risk model is 60-session diagonal realized variance and execution is the reference one-shot planner.", + "One synthetic-research TargetPackageV1 has been observed at the real JoinQuant execution boundary, but JoinQuant did not recompute the upstream four layers; there is no authorized real-data multi-period tape, same-input layer-by-layer parity, or real QMT peer." + ] + }, "evidence": [ { "id": "scorecard", @@ -68,6 +138,12 @@ window.QUANT_OS_PUBLIC_STATUS = { "label": "Portable bundle manifest", "path": "dist/bundle_manifest.json" }, + { + "id": "release-reachability", + "kind": "machine_record", + "label": "Five-layer release reachability manifest", + "path": "releases/quant60-research-candidate-v1/reachability.json" + }, { "id": "readme", "kind": "documentation", @@ -101,9 +177,21 @@ window.QUANT_OS_PUBLIC_STATUS = { { "id": "joinquant-hosted-evidence", "kind": "runtime_evidence", - "label": "JoinQuant hosted smoke boundary", + "label": "JoinQuant hosted momentum smoke boundary", "path": "docs/JOINQUANT_HOSTED_EVIDENCE_2026-07-26.md" }, + { + "id": "joinquant-target-package-evidence", + "kind": "runtime_evidence", + "label": "JoinQuant TargetPackage execution evidence boundary", + "path": "docs/JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md" + }, + { + "id": "joinquant-target-package-run", + "kind": "machine_record", + "label": "Redacted JoinQuant TargetPackage hosted run", + "path": "evidence/joinquant/TP-20240311-795dcfba/2457a7c39a276e09e0fabf99e28978e1/run.json" + }, { "id": "tushare-data", "kind": "runtime_evidence", @@ -136,14 +224,32 @@ window.QUANT_OS_PUBLIC_STATUS = { "scorecard" ], "id": "joinquant-hosted-market-window", - "label": "JoinQuant hosted smoke 行情区间", + "label": "JoinQuant hosted momentum smoke 行情区间", "marketDataFrom": "2024-01-02", "marketDataThrough": "2024-12-31", "productionReady": false, - "semantics": "单次真实 hosted runtime smoke 的回测区间;不代表同输入跨引擎通过。", + "semantics": "旧 momentum hosted runtime smoke 的回测区间;不是 TargetPackage 候选或同输入跨引擎证据。", "status": "runtime_smoke", "value": "2024-01-02 — 2024-12-31" }, + { + "evidenceLinks": [ + "joinquant-target-package-evidence", + "joinquant-target-package-run", + "release-reachability", + "scorecard" + ], + "id": "joinquant-target-package-execution-smoke", + "investmentValueClaim": false, + "label": "JoinQuant TargetPackage execution smoke", + "marketDataFrom": "2024-03-11", + "marketDataThrough": "2024-03-13", + "observedAt": "2026-07-26T08:50:15Z", + "productionReady": false, + "semantics": "真实平台只观察 synthetic-research TargetPackage 的 execution consumer;不代表上游四层真实平台执行、长样本 OOS 或 G9。", + "status": "runtime_smoke", + "value": "2024-03-11 — 2024-03-13; 3 orders / 3 fills" + }, { "completedPartitions": 35, "completedRows": 28731, @@ -422,7 +528,7 @@ window.QUANT_OS_PUBLIC_STATUS = { "Tushare completed-Parquet inventory and immutable Qlib provider", "semantic manifest and data-version verification" ], - "claim": "不可变快照、PIT 字段合同和 Tushare→Qlib 技术链路已实现并有本地合同/运行证据。", + "claim": "不可变快照、PIT 字段合同和 Tushare→Qlib 技术链路已有本地合同/运行证据;这些 provider-data 路径尚未接成当前 Ridge 五层候选。", "evidenceLinks": [ "architecture", "tushare-data", @@ -430,7 +536,7 @@ window.QUANT_OS_PUBLIC_STATUS = { ], "id": "data", "limitations": [ - "尚无授权 JQData 真实快照与许可 lineage;Tushare 镜像仅部分完成且缺生产关键字段。" + "尚无授权 JQData 真实快照与许可 lineage;Tushare 镜像仅部分完成且缺生产关键字段;当前五层候选仍使用 synthetic 数据。" ], "name": "数据与时点", "releaseVerified": false, @@ -438,19 +544,21 @@ window.QUANT_OS_PUBLIC_STATUS = { }, { "capabilities": [ - "PIT features, purged walk-forward and deterministic Ridge", + "train-only preprocessing, purged walk-forward and deterministic Ridge", + "replayable frozen ModelBundle and synthetic TargetPackageV1", "Qlib native momentum", "Qlib Alpha158, LightGBM and Recorder workflow" ], - "claim": "synthetic research pipeline 已通过测试;Qlib 0.9.7 的 momentum 与 Alpha158/LightGBM fixture 已在真实本地运行时完成。", + "claim": "synthetic 五层研究入口能冻结 train-only Ridge ModelBundle、逐层 trace 与 TargetPackageV1;Qlib momentum 和 Alpha158/LightGBM fixture 是另外的受限研究 smoke。", "evidenceLinks": [ "architecture", "platform-matrix", + "release-reachability", "scorecard" ], "id": "research", "limitations": [ - "fixture 仅两只 synthetic 股票;没有授权长样本 OOS、冻结模型包或投资价值证据。" + "候选和 Qlib fixture 均是 synthetic;尚无授权长样本 OOS、真实数据校准、平台候选观察或投资价值证据。" ], "name": "研究与模型", "releaseVerified": false, @@ -458,18 +566,20 @@ window.QUANT_OS_PUBLIC_STATUS = { }, { "capabilities": [ - "portable momentum scoring and capped target weights", - "lot-rounded target quantities and T+1 sellable deltas", - "factor risk, dated cost and constrained target research" + "explicit fee/spread/square-root-impact cost forecast", + "diagonal realized-variance risk input and deterministic constrained target", + "single-name, industry, gross, capacity and turnover constraints", + "post-risk weights with hash-linked lineage" ], - "claim": "Signal→Target→Order Delta 合同、成本、风险与约束目标在本地测试路径可重放。", + "claim": "当前 synthetic 主链把 Ridge Alpha、显式成本、60 日对角 realized variance、deterministic optimizer 和 post-risk gate 接成可重放 TargetPackage。", "evidenceLinks": [ "architecture", + "release-reachability", "scorecard" ], "id": "signal-portfolio", "limitations": [ - "风险、冲击和容量尚未用授权真实数据校准;G4/G5 均未通过。" + "factor_risk.py 与 optional CVXPY solver 未被当前主链选择;风险、冲击和容量尚未用授权真实数据校准,G4/G5 均未通过。" ], "name": "信号、组合与事前风险", "releaseVerified": false, @@ -478,20 +588,22 @@ window.QUANT_OS_PUBLIC_STATUS = { { "capabilities": [ "local event and snapshot backtests", - "JoinQuant hosted single-file bundle", - "QMT built-in/qmttools backtest-only bundle", + "fail-closed JoinQuant TargetPackage single-file consumer", + "fail-closed QMT built-in TargetPackage backtest consumer", "Qlib research runners" ], - "claim": "本地 synthetic、Qlib fixture 与一次真实 JoinQuant hosted smoke 已运行;QMT 仍只有 bundle/harness。", + "claim": "本地 synthetic 五层候选、平台 harness 和一次真实 JoinQuant TargetPackage execution smoke 已运行;QMT 尚无真实 peer run。", "evidenceLinks": [ "platform-matrix", - "joinquant-hosted-evidence", + "joinquant-target-package-evidence", + "joinquant-target-package-run", "cross-engine", + "release-reachability", "scorecard" ], "id": "backtest", "limitations": [ - "没有冻结同输入的本地/聚宽/QMT 逐层对比;真实 QMT 尚未运行,G9 未通过。" + "聚宽只观察到一个 synthetic-research TargetPackage 的消费、账户/开盘价绑定和三笔成交;无真实长样本 OOS、QMT peer 或逐层真实数据 parity,G9 未通过。" ], "name": "回测与跨引擎", "releaseVerified": false, @@ -501,17 +613,20 @@ window.QUANT_OS_PUBLIC_STATUS = { "capabilities": [ "local and Colab zero-account smoke", "JQData snapshot entrypoint", - "JoinQuant upload bundle", - "QMT built-in and qmttools entrypoints" + "JoinQuant momentum or explicit TargetPackage upload bundle", + "QMT built-in TargetPackage and qmttools entrypoints" ], - "claim": "Local、Colab、JoinQuant、Qlib 有可执行入口;JoinQuant/Qlib 有受限真实运行时 smoke。", + "claim": "Local、Colab、JoinQuant、QMT、Qlib 有入口;JoinQuant 已真实观察 synthetic-research TargetPackage 的 execution consumer,QMT 候选仍未实跑。", "evidenceLinks": [ "platform-matrix", - "platform-deployment" + "joinquant-target-package-evidence", + "joinquant-target-package-run", + "platform-deployment", + "release-reachability" ], "id": "platforms", "limitations": [ - "JQData 真实账号、QMT 客户端/数据权限及平台完整导出仍是外部前置条件。" + "JoinQuant 没有重跑上游四层;JQData 授权真实候选、QMT 客户端/数据权限、QMT peer 导出与逐层真实数据 parity 仍是外部前置条件。" ], "name": "平台适配", "releaseVerified": false, @@ -560,7 +675,7 @@ window.QUANT_OS_PUBLIC_STATUS = { ], "models": [ { - "claim": "共享 symbol normalization、momentum、target weight/quantity 与 order-delta 计算;已进入 JoinQuant hosted smoke 和本地 Qlib momentum 路径。", + "claim": "共享 symbol normalization、momentum、target weight/quantity 与 order-delta 计算;2024 JoinQuant hosted run 只证明这一 smoke 路径。", "evidenceLinks": [ "architecture", "bundle-manifest", @@ -569,27 +684,29 @@ window.QUANT_OS_PUBLIC_STATUS = { "id": "portable-momentum-v1", "investmentValueClaim": false, "limitations": [ - "尚无同输入 L1–L4 真实跨引擎通过证据,不是已验证的生产 champion。" + "不是 Ridge/TargetPackage Baseline 候选;旧 hosted run 不得用于候选或 G9 计分。" ], "name": "portable-momentum-v1", "releaseVerified": false, - "role": "跨引擎冻结计算基线", + "role": "平台连通性 smoke 策略", "status": "real_runtime" }, { - "claim": "purged walk-forward research pipeline 中可确定性训练和评估。", + "claim": "train-only 预处理、系数、截距、训练截止和 lineage 可冻结为可重放 ModelBundle;其 synthetic TargetPackageV1 已被真实 JoinQuant execution consumer 消费并成交。", "evidenceLinks": [ "architecture", - "platform-matrix" + "platform-matrix", + "joinquant-target-package-evidence", + "joinquant-target-package-run" ], "id": "ridge-challenger", "investmentValueClaim": false, "limitations": [ - "只在 synthetic 研究路径验证;无真实 OOS、versioned model bundle 或跨平台推理验证。" + "JoinQuant 只观察执行边界,未重跑上游四层;无授权真实 OOS、真实校准、QMT peer 或投资价值证据。" ], "name": "Deterministic Ridge", "releaseVerified": false, - "role": "synthetic 研究候选", + "role": "synthetic 五层 Baseline 研究候选", "status": "tested" }, { @@ -613,31 +730,35 @@ window.QUANT_OS_PUBLIC_STATUS = { { "gateIds": [ "G1", + "G2", "G3", + "G5", "G7" ], "priority": 1, - "title": "生成授权 JQData 不可变真实快照", - "why": "authorized JQData market snapshot and licence/lineage record" + "title": "接通真实数据五层候选", + "why": "The authorized provider-data path has not yet been connected end to end to the frozen Ridge ModelBundle, calibrated risk/cost inputs, five-layer Baseline candidate and TargetPackage tape; the current connected candidate remains synthetic. External prerequisite: authorized JQData market snapshot, licence/lineage record and a frozen provider-data candidate tape" }, { "gateIds": [ "G3", + "G5", + "G7", "G9" ], "priority": 2, - "title": "冻结聚宽同输入并完成逐层本地比较", - "why": "complete JoinQuant hosted input/plan/order/fill export and same-input local comparison" + "title": "冻结主链风险、优化与执行选择", + "why": "factor_risk.py, the optional CVXPY optimizer mode and guarded TWAP/POV exist as components or optional paths but are not selected by the current Baseline main slice; the connected risk model is 60-session diagonal realized variance and execution is the reference one-shot planner." }, { "gateIds": [ "G3", - "G6", + "G7", "G9" ], "priority": 3, - "title": "取得真实 QMT built-in 与 qmttools 导出", - "why": "real QMT built-in and qmttools backtest exports" + "title": "扩展 JoinQuant 多期对账与逐层 parity", + "why": "One synthetic-research TargetPackageV1 has been observed at the real JoinQuant execution boundary, but JoinQuant did not recompute the upstream four layers; there is no authorized real-data multi-period tape, same-input layer-by-layer parity, or real QMT peer. Required evidence: authorized real-data multi-period JoinQuant TargetPackage run plus same-input local/JoinQuant layer-by-layer reconciliation" }, { "gateIds": [ @@ -646,6 +767,16 @@ window.QUANT_OS_PUBLIC_STATUS = { "G9" ], "priority": 4, + "title": "运行真实 QMT TargetPackage 候选", + "why": "real QMT TargetPackage built-in and qmttools backtest exports" + }, + { + "gateIds": [ + "G6", + "G8", + "G9" + ], + "priority": 5, "title": "完成 QMT 券商合同探针与至少 20 日影子盘", "why": "real QMT historical-ST positive control, terminal/xtquant build record, broker query contract and trade-amount semantic probes, callback/restart and at least 20 trading days of shadow evidence" }, @@ -654,14 +785,14 @@ window.QUANT_OS_PUBLIC_STATUS = { "G8", "G10" ], - "priority": 5, + "priority": 6, "title": "完成连续对账、恢复演练与券商合规确认", "why": "continuous reconciliation, recovery drills and broker compliance confirmation" } ], "platforms": [ { - "claim": "标准库套件最近记录为 224 tests、OK、6 skip;smoke + exact manifest verifier 可运行", + "claim": "标准库套件、smoke + exact manifest verifier 可运行", "evidenceLinks": [ "platform-matrix" ], @@ -677,14 +808,14 @@ window.QUANT_OS_PUBLIC_STATUS = { "status": "tested" }, { - "claim": "research smoke + manifest verifier 可运行", + "claim": "冻结 model bundle、TargetPackageV1、layer trace + manifest verifier 可运行;风险/成本扰动必须改变 target", "evidenceLinks": [ "platform-matrix" ], "id": "local-synthetic-research", - "intendedUse": "PIT/features/walk-forward/Ridge/risk/cost/target", + "intendedUse": "五层 vertical slice:Universe/Ridge Alpha/risk+cost Portfolio/post-risk/reference Execution", "limitations": [ - "Ridge 未冻结部署,未做授权长样本 OOS" + "仍是 synthetic;当前只接对角风险与 deterministic optimizer,未做授权长样本 OOS" ], "liveReady": false, "name": "Local synthetic research", @@ -757,10 +888,12 @@ window.QUANT_OS_PUBLIC_STATUS = { "status": "tested" }, { - "claim": "fake lifecycle 与 mock parity 可运行", + "claim": "动量 smoke 与 TargetPackage 消费模式均有 fake lifecycle;目标包缺失/篡改不回退", "evidenceLinks": [ "platform-matrix", - "joinquant-hosted-evidence" + "joinquant-hosted-evidence", + "joinquant-target-package-evidence", + "joinquant-target-package-run" ], "id": "joinquant-local-harness", "intendedUse": "wrapper API 合同", @@ -774,15 +907,17 @@ window.QUANT_OS_PUBLIC_STATUS = { "status": "tested" }, { - "claim": "真实 2024 全年 run 已完成且可见日志 0 ERROR;单文件、PIT index/ST、批量 history、费率/滑点/10% 参与率、科创板保护价/200 股约束已运行", + "claim": "动量 smoke 的真实 2024 全年 run 已完成;FINAL4 的单个 synthetic-research TargetPackage 已在真实 hosted 环境按 day_open 命中并产生 3 单/3 fill", "evidenceLinks": [ "platform-matrix", - "joinquant-hosted-evidence" + "joinquant-hosted-evidence", + "joinquant-target-package-evidence", + "joinquant-target-package-run" ], "id": "joinquant-hosted-bundle", "intendedUse": "hosted backtest", "limitations": [ - "尚无完整输入/plan/orders/fills 导出和同输入本地比较" + "只观察 execution consumer;无授权真实长样本/OOS、上游四层 hosted 执行、QMT peer 或逐层真实数据 parity" ], "liveReady": false, "name": "JoinQuant hosted bundle", @@ -791,7 +926,7 @@ window.QUANT_OS_PUBLIC_STATUS = { "status": "real_runtime" }, { - "claim": "Python 3.6 source、ContextInfo 回滚模拟和 mock parity", + "claim": "Python 3.6 source、ContextInfo 回滚模拟、动量 smoke 与 TargetPackage 合同", "evidenceLinks": [ "platform-matrix", "platform-deployment" @@ -808,7 +943,7 @@ window.QUANT_OS_PUBLIC_STATUS = { "status": "tested" }, { - "claim": "bundle 硬限制 backtest;毫秒 timetag PIT 成分/ST;固定中证 500、费率/滑点;模块全局 g", + "claim": "两种模式都硬限制 backtest;TargetPackage 模式不重算上游四层;模块全局 g", "evidenceLinks": [ "platform-matrix", "platform-deployment" @@ -816,11 +951,11 @@ window.QUANT_OS_PUBLIC_STATUS = { "id": "qmt-built-in-bundle", "intendedUse": "GUI backtest-only", "limitations": [ - "尚无真实 QMT run/export;10% GUI 配置与历史 ST 正例探针待取证" + "尚无 TargetPackage 真实 QMT run/export;10% GUI 配置和执行时钟待取证" ], "liveReady": false, "name": "QMT built-in bundle", - "prerequisite": "授权 QMT 客户端/板块与历史 ST 数据", + "prerequisite": "授权 QMT 客户端/历史数据", "releaseVerified": false, "status": "implemented" }, @@ -928,19 +1063,28 @@ window.QUANT_OS_PUBLIC_STATUS = { "provenance": { "baselineConfig": "configs/baseline.json", "bundleManifest": "dist/bundle_manifest.json", - "payloadSha256": "1243b3d6821fd7cc24d8473b094dbeb1f1791cc1931f25e5fb683489bbd0bd98", + "payloadSha256": "76aad7a50088b9949b9c2549a4da568d14c6bc2ff4acc7df6419989637386bc3", "payloadSha256Semantics": "SHA-256 of canonical payload before payloadSha256 is added.", "platformMatrix": "docs/PLATFORM_MATRIX.md", + "releaseReachability": "releases/quant60-research-candidate-v1/reachability.json", "scorecard": "gate_scorecard.json", - "sourceSetSha256": "e49692c14e142b3bab524763243c16ae4964eb5933d35d867cf89ed52dbcc90c", + "sourceSetSha256": "e1b8bb8827c6c051a73872f2b0ac46f18dcf705fb50768eb98090bbbe08f863c", "sources": [ { "path": "gate_scorecard.json", - "sha256": "59e5db5c57d1665ee4a2a2f4cf0ef93a1e7a17e1ef7aa62d0fb64da4ad637c6e" + "sha256": "db9ed17d1b56452f973db23461e8298b4f43f319b04be373adb383133500c2b6" }, { "path": "dist/bundle_manifest.json", - "sha256": "7290480dbe9c5ec7453896f0ffa6d5cec2f72284cb0f723902d29b37d912bdee" + "sha256": "3323a07d0c93c6a9dcf8ead2760025ee8f78136bf9334ca5bd4fd836fbbedb54" + }, + { + "path": "releases/quant60-research-candidate-v1/reachability.json", + "sha256": "2a506c2a4a9f4d813c34be4b3d02acdef6a2aa4c6eeff304be5065f1d3d34cbb" + }, + { + "path": "evidence/joinquant/TP-20240311-795dcfba/2457a7c39a276e09e0fabf99e28978e1/run.json", + "sha256": "9956c5e61bdc1c82bc24f20f24c1d65a201bab2f71c2c9e4e23eb5bd40eca6ce" }, { "path": "configs/baseline.json", @@ -948,19 +1092,19 @@ window.QUANT_OS_PUBLIC_STATUS = { }, { "path": "README.md", - "sha256": "3bbd5821ad0641ad9bd6b6dd72a8dcebcddf8655fb0d5fa10ca16a46eaf35a62" + "sha256": "d5593bdc4e535502ba817636053dc8ac38f8594be6f9e61f0fe2d11cf883a985" }, { "path": "docs/ARCHITECTURE.md", - "sha256": "b22fc952a260d0201d3b1a6bf221628c5dd341391b4e814261413e27ab181fd1" + "sha256": "93b1981168084f40c6d127dc534ff2e60eb4cd0703c8ef446c6df58b351dfbaa" }, { "path": "docs/AUDIT_2026-07-25.md", - "sha256": "39731b2c828c01f83c5b8626f2ca02762c376da2cfcd62f2e2c4d8a2f32f780e" + "sha256": "c070b49dfc2805c338400697369d886a6dc47b53af4a1834d0dfd383431e74d0" }, { "path": "docs/PLATFORM_MATRIX.md", - "sha256": "5a084e99df1cd31f89859b390c6cb0e79a68a0f34514bf8a30b3cc1a0c64f1eb" + "sha256": "4880b17e54257b794398bf734d80ea40d1180e52845762987443a9c61d562a23" }, { "path": "docs/CROSS_ENGINE_CONSISTENCY.md", @@ -970,6 +1114,10 @@ window.QUANT_OS_PUBLIC_STATUS = { "path": "docs/JOINQUANT_HOSTED_EVIDENCE_2026-07-26.md", "sha256": "03df249829b3ad9b4fe33c5d93afbd842561458f4ff2fe10e80e4432934eb2d2" }, + { + "path": "docs/JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md", + "sha256": "99b62e06b843e9866dc88b0479ce474232a5cae84c8595ad5adb2bf707b3d205" + }, { "path": "docs/TUSHARE_LOCAL_DATA.md", "sha256": "fd6cfe6cf45dbdd82e6aa88e80b119e05a888c560b79907370c6e023b5e00e82" @@ -978,26 +1126,34 @@ window.QUANT_OS_PUBLIC_STATUS = { "path": "runbooks/PLATFORM_DEPLOYMENT.md", "sha256": "f6bb1eda335c0d05f96d39b4d4167ee623fe6cffeef75015ba7985638fa8f635" }, + { + "path": "tools/build_release_reachability.py", + "sha256": "fecee5980917f86440e96ccfe488e52111db5715b6f3847b87cb98f9a895b4be" + }, { "path": "tools/build_public_status.py", - "sha256": "329332a09238499cf63530d011d4181d26edf506a23d665165e4b3ff49f796ca" + "sha256": "38347c2238a38fc603703c7265fd1ca54480b3f3f8c453cde9f21056f207a4ec" } ] }, "schemaVersion": 1, "summary": { + "allFiveLayersRealPlatformObserved": false, + "candidateRealPlatformObservationScope": "target_package_execution_consumer_only", + "candidateRealPlatformObserved": true, "gateCoverageLabel": "0/10 passed", "investmentValueClaim": false, "optionalSkipsRecorded": 6, "productionReady": false, "realRuntimeSmokes": [ - "JoinQuant hosted daily backtest (runtime/path only)", + "JoinQuant TargetPackage execution consumer: one synthetic-research package, 3 orders / 3 fills (not Baseline/G9 evidence)", + "JoinQuant portable_momentum_smoke hosted daily backtest (not Baseline/TargetPackage evidence)", "Qlib native momentum fixture", "Qlib Alpha158 + LightGBM fixture", "Tushare completed-partition Qlib momentum smoke" ], "testEvidenceQualifier": "Scorecard-recorded run; tests do not prove real-platform or broker gates.", - "testsRecorded": 224 + "testsRecorded": 285 }, "system": "Quant OS / quant60 baseline kernel" }; diff --git a/tests/platform_bundle_probe_test.py b/tests/platform_bundle_probe_test.py index f6ef411..7236086 100644 --- a/tests/platform_bundle_probe_test.py +++ b/tests/platform_bundle_probe_test.py @@ -11,10 +11,57 @@ sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "src")) from platforms import joinquant_strategy, qmt_builtin_strategy +from quant60.target_package import seal_target_package from tools.bundle_platforms import bundle_all, effective_platform_config from tools.capability_probe import probe_capabilities +def target_package(): + return seal_target_package( + { + "schema_version": "1.0", + "package_type": "TargetPackageV1", + "package_id": "TP-20260724-bundle", + "decision_id": "D-20260724-bundle", + "release": "quant60-research-candidate-v1", + "evidence_class": "synthetic-research", + "model_id": "deterministic-ridge-alpha-5", + "signal_as_of": "2026-07-24T15:00:00+08:00", + "next_session": "2026-07-27", + "model_sha256": "1" * 64, + "data_sha256": "2" * 64, + "feature_sha256": "3" * 64, + "risk_sha256": "4" * 64, + "cost_sha256": "5" * 64, + "optimizer_sha256": "6" * 64, + "config_sha256": "7" * 64, + "source_sha256": "8" * 64, + "target_weights": { + "000001.XSHE": 0.30, + "600000.XSHG": 0.20, + }, + "universe": { + "000001.XSHE": { + "state": "openable", + "reasons": ["ELIGIBLE"], + }, + "600000.XSHG": { + "state": "openable", + "reasons": ["ELIGIBLE"], + }, + }, + "constraints": { + "constraint_state": "FEASIBLE", + "long_only": True, + "max_single_weight": 0.40, + "max_gross_weight": 0.80, + "max_one_way_turnover": 0.50, + "actual_gross_weight": 0.50, + }, + } + ) + + class BundleTest(unittest.TestCase): def test_source_wrappers_match_canonical_baseline_config(self): baseline = json.loads( @@ -66,6 +113,35 @@ class BundleTest(unittest.TestCase): (ROOT / "configs/baseline.json").read_bytes() ).hexdigest(), ) + self.assertEqual( + stored["decision_mode"], + "portable_momentum_smoke", + ) + self.assertEqual(stored["target_package_count"], 0) + self.assertIsNone(stored["target_package_tape_sha256"]) + self.assertEqual( + stored["joinquant_observation"]["prefix"], + "QUANT60_JOINQUANT_EVIDENCE_V1 ", + ) + self.assertTrue( + stored["joinquant_observation"]["fail_soft"] + ) + self.assertEqual( + stored["joinquant_observation"]["target_price_source"], + "CURRENT_DATA_DAY_OPEN", + ) + self.assertEqual( + stored["joinquant_observation"]["eod_chunk_encoding"], + "base64-canonical-json", + ) + self.assertEqual( + stored["joinquant_observation"]["eod_chunk_characters"], + 1800, + ) + self.assertIn( + "EOD_STATUS", + stored["joinquant_observation"]["events"], + ) def test_committed_dist_is_byte_for_byte_fresh(self): with tempfile.TemporaryDirectory() as temp: @@ -112,6 +188,65 @@ class BundleTest(unittest.TestCase): ) self.assertTrue(all(value >= 0 for value in quantities.values())) + def test_target_package_bundle_is_explicit_pinned_and_no_fallback(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + package_path = root / "target_package.json" + package = target_package() + package_path.write_text( + json.dumps(package, ensure_ascii=False), + encoding="utf-8", + ) + output = root / "bundle" + manifest = bundle_all(ROOT, output, package_path) + self.assertEqual(manifest["decision_mode"], "target_package") + self.assertEqual(manifest["target_package_count"], 1) + self.assertEqual( + manifest["target_package_ids"], + [package["package_id"]], + ) + self.assertEqual( + manifest["target_package_sha256"], + [package["package_sha256"]], + ) + self.assertNotIn( + str(root), + json.dumps(manifest, ensure_ascii=False), + ) + for filename in ( + "joinquant_strategy.py", + "qmt_builtin_strategy.py", + ): + source = (output / filename).read_text(encoding="utf-8") + self.assertIn( + "'decision_mode': 'target_package'", + source, + ) + self.assertNotIn("TARGET_PACKAGES = []", source) + self.assertIn(package["package_sha256"], source) + self.assertIn("build_target_weight_plan", source) + joinquant_source = ( + output / "joinquant_strategy.py" + ).read_text(encoding="utf-8") + self.assertIn( + "QUANT60_JOINQUANT_EVIDENCE_V1 ", + joinquant_source, + ) + self.assertIn("CURRENT_DATA_DAY_OPEN", joinquant_source) + self.assertIn("day_open", joinquant_source) + self.assertNotIn("CURRENT_DATA_LAST_PRICE", joinquant_source) + for event in manifest["joinquant_observation"]["events"]: + self.assertIn(event, joinquant_source) + + tampered = dict(package) + tampered["release"] = "tampered" + package_path.write_text( + json.dumps(tampered), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "hash mismatch"): + bundle_all(ROOT, root / "tampered", package_path) + class CapabilityProbeTest(unittest.TestCase): def test_shallow_probe_does_not_claim_external_verification(self): diff --git a/tests/platform_target_package_execution_test.py b/tests/platform_target_package_execution_test.py new file mode 100644 index 0000000..883a659 --- /dev/null +++ b/tests/platform_target_package_execution_test.py @@ -0,0 +1,687 @@ +import base64 +import copy +import contextlib +import datetime +import io +import json +import sys +import types +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "src")) + +from platforms import joinquant_strategy, qmt_builtin_strategy +from platforms.fake_joinquant_harness import ( + FakeJoinQuantHarness, + FakePosition, +) +from platforms.fake_qmt_harness import FakeQmtContext, FakeQmtHarness +from quant60 import portable_core +from quant60.portable_core import target_package_tape_sha256 +from quant60.target_package import seal_target_package + + +def _package( + signal_as_of="2026-07-17T15:00:00+08:00", + next_session="2026-07-20", + package_id="TP-20260717-A", + decision_id="D-20260717-A", +): + return seal_target_package( + { + "schema_version": "1.0", + "package_type": "TargetPackageV1", + "package_id": package_id, + "decision_id": decision_id, + "release": "quant60-baseline-60-v1", + "evidence_class": "provider-snapshot-local", + "model_id": "ridge-alpha-v1", + "signal_as_of": signal_as_of, + "next_session": next_session, + "model_sha256": "1" * 64, + "data_sha256": "2" * 64, + "feature_sha256": "3" * 64, + "risk_sha256": "4" * 64, + "cost_sha256": "5" * 64, + "optimizer_sha256": "6" * 64, + "config_sha256": "7" * 64, + "source_sha256": "8" * 64, + "target_weights": { + "000001.XSHE": 0.30, + "600000.XSHG": 0.0, + }, + "universe": { + "000001.XSHE": { + "state": "openable", + "reasons": ["ELIGIBLE"], + }, + "600000.XSHG": { + "state": "sell_only", + "reasons": ["RISK_REDUCTION"], + }, + }, + "constraints": { + "constraint_state": "FEASIBLE", + "long_only": True, + "max_single_weight": 0.40, + "max_gross_weight": 0.80, + "max_one_way_turnover": 0.80, + "actual_gross_weight": 0.30, + }, + } + ) + + +def _jq_histories(): + return { + "000001.XSHE": [ + 10.0 + 0.1 * index for index in range(21) + ], + "600000.XSHG": [ + 15.0 - 0.02 * index for index in range(21) + ], + } + + +def _qmt_histories(): + return { + symbol.replace(".XSHE", ".SZ").replace(".XSHG", ".SH"): values + for symbol, values in _jq_histories().items() + } + + +def _target_config(base, packages): + config = dict(base) + config.update( + { + "decision_mode": "target_package", + "target_package_count": len(packages), + "target_package_tape_sha256": target_package_tape_sha256( + packages + ), + "universe_mode": "fixed", + "universe": sorted( + { + symbol + for package in packages + for symbol in package["universe"] + } + ), + } + ) + return config + + +def _evidence_records(output): + prefix = joinquant_strategy.EVIDENCE_PREFIX + records = [] + for line in output.getvalue().splitlines(): + if not line.startswith(prefix): + continue + encoded = line[len(prefix) :] + record = json.loads(encoded) + canonical = json.dumps( + record, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + if canonical != encoded: + raise AssertionError("JoinQuant evidence is not canonical JSON") + records.append(record) + return records + + +class PlatformTargetPackageExecutionTest(unittest.TestCase): + def setUp(self): + self.jq_config = joinquant_strategy.CONFIG + self.jq_packages = joinquant_strategy.TARGET_PACKAGES + self.qmt_config = qmt_builtin_strategy.CONFIG + self.qmt_packages = qmt_builtin_strategy.TARGET_PACKAGES + + def tearDown(self): + joinquant_strategy.CONFIG = self.jq_config + joinquant_strategy.TARGET_PACKAGES = self.jq_packages + qmt_builtin_strategy.CONFIG = self.qmt_config + qmt_builtin_strategy.TARGET_PACKAGES = self.qmt_packages + + def _install_package(self, package): + self._install_packages([package]) + + def _install_packages(self, packages): + packages = copy.deepcopy(packages) + joinquant_strategy.TARGET_PACKAGES = copy.deepcopy(packages) + joinquant_strategy.CONFIG = _target_config( + self.jq_config, + packages, + ) + qmt_builtin_strategy.TARGET_PACKAGES = copy.deepcopy(packages) + qmt_config = _target_config(self.qmt_config, packages) + qmt_config["universe"] = [ + symbol.replace(".XSHE", ".SZ").replace(".XSHG", ".SH") + for symbol in qmt_config["universe"] + ] + qmt_builtin_strategy.CONFIG = qmt_config + + def test_same_package_binds_same_weights_orders_and_lineage(self): + package = _package() + self._install_package(package) + jq = FakeJoinQuantHarness( + _jq_histories(), + positions={ + "600000.XSHG": FakePosition( + total_amount=1050, + closeable_amount=200, + ) + }, + ) + jq.initialize(joinquant_strategy) + jq_plan = jq.run_scheduled() + + qmt_position = SimpleNamespace( + stock_code="600000.SH", + volume=1050, + can_use_volume=200, + ) + context = FakeQmtContext( + _qmt_histories(), + positions=[qmt_position], + bar_time=datetime.datetime(2026, 7, 17, 15, 0), + ) + qmt = FakeQmtHarness(context) + qmt_plan = qmt.run(qmt_builtin_strategy) + + self.assertIsNotNone(jq_plan) + self.assertIsNotNone(qmt_plan) + for field in ( + "weights", + "targets", + "orders", + "lineage", + "universe", + ): + self.assertEqual(jq_plan[field], qmt_plan[field]) + self.assertEqual( + jq_plan["lineage"], + { + "package_id": package["package_id"], + "package_sha256": package["package_sha256"], + "decision_id": package["decision_id"], + "signal_as_of": package["signal_as_of"], + "next_session": package["next_session"], + "release": package["release"], + "model_id": package["model_id"], + }, + ) + self.assertEqual( + jq_plan["execution_contract"], + { + "signal_price_source": "CURRENT_DATA_DAY_OPEN", + "submit_trigger": "DECLARED_NEXT_SESSION_OPEN_CALLBACK", + "declared_next_session": package["next_session"], + "real_platform_observed": False, + }, + ) + self.assertEqual( + qmt_plan["execution_contract"], + { + "signal_price_source": "COMPLETED_DAILY_CLOSE", + "submit_trigger": "NEXT_BAR_FIRST_TICK", + "quick_trade": 0, + "declared_next_session": package["next_session"], + "real_platform_observed": False, + }, + ) + self.assertEqual(jq_plan["orders"]["600000.XSHG"], -200) + self.assertEqual(dict(jq.orders)["600000.XSHG"], 850) + self.assertEqual(qmt_plan["orders"]["600000.XSHG"], -200) + sell_order = [ + order + for order in qmt.orders + if order["order_code"] == "600000.SH" + ][0] + self.assertEqual(sell_order["volume"], 200) + self.assertEqual(sell_order["quick_trade"], 0) + self.assertTrue( + all( + int(quantity) % 100 == 0 + for quantity in jq_plan["targets"].values() + ) + ) + + def test_missing_exact_clock_is_noop_without_momentum_fallback(self): + package = _package( + signal_as_of="2026-07-10T15:00:00+08:00", + next_session="2026-07-13", + ) + self._install_package(package) + jq = FakeJoinQuantHarness(_jq_histories()) + jq.initialize(joinquant_strategy) + self.assertIsNone(jq.run_scheduled()) + self.assertEqual(jq.orders, []) + self.assertIsNone(jq.last_index_request) + self.assertIsNone(jq.last_extras_request) + self.assertIsNone(jq.last_history_request) + + context = FakeQmtContext( + _qmt_histories(), + bar_time=datetime.datetime(2026, 7, 17, 15, 0), + ) + qmt = FakeQmtHarness(context) + self.assertIsNone(qmt.run(qmt_builtin_strategy)) + self.assertEqual(qmt.orders, []) + self.assertIsNone(context.last_sector_request) + self.assertIsNone(context.last_market_request) + + def test_tampered_package_fails_closed_during_initialization(self): + package = _package() + tampered = copy.deepcopy(package) + tampered["target_weights"]["000001.XSHE"] = 0.25 + tampered["constraints"]["actual_gross_weight"] = 0.25 + self._install_package(tampered) + + jq = FakeJoinQuantHarness(_jq_histories()) + with self.assertRaises(ValueError): + jq.initialize(joinquant_strategy) + self.assertEqual(jq.orders, []) + + context = FakeQmtContext(_qmt_histories()) + qmt = FakeQmtHarness(context) + with self.assertRaises(ValueError): + qmt.run(qmt_builtin_strategy) + self.assertEqual(qmt.orders, []) + + def test_duplicate_package_id_fails_closed_during_initialization(self): + first = _package() + second = _package( + signal_as_of="2026-07-24T15:00:00+08:00", + next_session="2026-07-27", + package_id=first["package_id"], + decision_id="D-20260724-B", + ) + self._install_packages([first, second]) + + jq = FakeJoinQuantHarness(_jq_histories()) + with self.assertRaisesRegex(ValueError, "duplicate"): + jq.initialize(joinquant_strategy) + + context = FakeQmtContext(_qmt_histories()) + qmt = FakeQmtHarness(context) + with self.assertRaisesRegex( + qmt_builtin_strategy.UnsupportedStrategyPeriod, + "duplicate", + ): + qmt.run(qmt_builtin_strategy) + + def test_joinquant_emits_canonical_observation_stream_and_fail_soft_eod(self): + package = _package() + self._install_package(package) + jq = FakeJoinQuantHarness( + _jq_histories(), + positions={ + "600000.XSHG": FakePosition( + total_amount=1050, + closeable_amount=200, + ) + }, + ) + output = io.StringIO() + with contextlib.redirect_stdout(output): + jq.initialize(joinquant_strategy) + plan = jq.run_scheduled() + joinquant_strategy.after_trading_end(jq.context) + self.assertIsNotNone(plan) + + records = _evidence_records(output) + events = [record["event"] for record in records] + for event in ( + "INIT", + "TARGET_PACKAGE_HIT", + "PLATFORM_BINDING_INPUT", + "TARGET_PACKAGE_PLAN", + "ORDER_REQUEST", + "ORDER_RETURN", + "EOD_STATUS", + ): + self.assertIn(event, events) + self.assertEqual( + [record["sequence"] for record in records], + list(range(1, len(records) + 1)), + ) + + binding = [ + record["payload"] + for record in records + if record["event"] == "PLATFORM_BINDING_INPUT" + ][0] + self.assertEqual(binding["equity"], 1_000_000.0) + self.assertEqual(binding["current"], {"600000.XSHG": 1050}) + self.assertEqual(binding["sellable"], {"600000.XSHG": 200}) + self.assertEqual( + binding["price_source"], + "CURRENT_DATA_DAY_OPEN", + ) + self.assertEqual( + binding["lineage"]["package_sha256"], + package["package_sha256"], + ) + + plan_record = [ + record["payload"] + for record in records + if record["event"] == "TARGET_PACKAGE_PLAN" + ][0] + self.assertEqual(plan_record["weights"], package["target_weights"]) + self.assertEqual(plan_record["orders"], plan["orders"]) + + eod_records = [ + record + for record in records + if record["event"] == "EOD_STATUS" + ] + self.assertTrue(eod_records) + chunk_count = eod_records[0]["chunk_count"] + self.assertEqual(len(eod_records), chunk_count) + self.assertEqual( + [record["chunk_index"] for record in eod_records], + list(range(1, chunk_count + 1)), + ) + self.assertTrue( + all( + record["chunk_count"] == chunk_count + for record in eod_records + ) + ) + self.assertTrue( + all( + record["payload"]["chunk_encoding"] + == "base64-canonical-json" + for record in eod_records + ) + ) + encoded = "".join( + record["payload"]["content"] + for record in eod_records + ) + self.assertTrue( + all( + record["payload"]["encoded_length"] == len(encoded) + for record in eod_records + ) + ) + for record in eod_records: + line = joinquant_strategy.EVIDENCE_PREFIX + json.dumps( + record, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + self.assertLess(len(line.encode("utf-8")), 3 * 1024) + canonical = base64.b64decode( + encoded.encode("ascii"), + validate=True, + ) + eod = json.loads(canonical.decode("ascii")) + self.assertEqual( + base64.b64encode( + json.dumps( + eod, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("ascii") + ).decode("ascii"), + encoded, + ) + self.assertFalse(eod["orders_api"]["available"]) + self.assertFalse(eod["trades_api"]["available"]) + self.assertTrue(eod["portfolio"]["available"]) + self.assertEqual( + eod["last_plan"]["lineage"]["package_id"], + package["package_id"], + ) + + def test_joinquant_target_package_binds_day_open_not_last_price(self): + package = _package() + self._install_package(package) + jq = FakeJoinQuantHarness(_jq_histories()) + jq.current_data["000001.XSHE"].day_open = 20.0 + jq.current_data["000001.XSHE"].last_price = 999.0 + output = io.StringIO() + with contextlib.redirect_stdout(output): + jq.initialize(joinquant_strategy) + plan = jq.run_scheduled() + + self.assertIsNotNone(plan) + self.assertEqual(plan["targets"]["000001.XSHE"], 15_000) + self.assertEqual(jq.orders, [("000001.XSHE", 15_000)]) + binding = [ + record["payload"] + for record in _evidence_records(output) + if record["event"] == "PLATFORM_BINDING_INPUT" + ][0] + self.assertEqual(binding["price_source"], "CURRENT_DATA_DAY_OPEN") + self.assertEqual(binding["prices"]["000001.XSHE"], 20.0) + self.assertIsNone(jq.last_history_request) + + def test_joinquant_missing_day_open_fails_closed_without_fallback(self): + package = _package() + self._install_package(package) + jq = FakeJoinQuantHarness(_jq_histories()) + del jq.current_data["000001.XSHE"].day_open + output = io.StringIO() + with contextlib.redirect_stdout(output): + jq.initialize(joinquant_strategy) + with self.assertRaisesRegex( + joinquant_strategy.PriceHistoryUnavailable, + "day_open", + ): + jq.run_scheduled() + + self.assertEqual(jq.orders, []) + self.assertIsNone(jq.last_index_request) + self.assertIsNone(jq.last_extras_request) + self.assertIsNone(jq.last_history_request) + events = [ + record["event"] for record in _evidence_records(output) + ] + self.assertIn("TARGET_PACKAGE_HIT", events) + self.assertNotIn("PLATFORM_BINDING_INPUT", events) + self.assertNotIn("TARGET_PACKAGE_PLAN", events) + self.assertNotIn("ORDER_REQUEST", events) + + def test_joinquant_noop_eod_clears_prior_session_evidence(self): + package = _package() + self._install_package(package) + jq = FakeJoinQuantHarness( + _jq_histories(), + positions={ + "600000.XSHG": FakePosition( + total_amount=1050, + closeable_amount=200, + ) + }, + ) + jq.current_data["000001.XSHE"].paused = True + + def eod_payload(output): + chunks = [ + record + for record in _evidence_records(output) + if record["event"] == "EOD_STATUS" + ] + self.assertTrue(chunks) + self.assertEqual( + [record["chunk_index"] for record in chunks], + list(range(1, chunks[0]["chunk_count"] + 1)), + ) + encoded = "".join( + record["payload"]["content"] for record in chunks + ) + return json.loads( + base64.b64decode( + encoded.encode("ascii"), + validate=True, + ).decode("ascii") + ) + + hit_output = io.StringIO() + with contextlib.redirect_stdout(hit_output): + jq.initialize(joinquant_strategy) + self.assertIsNotNone(jq.run_scheduled()) + joinquant_strategy.after_trading_end(jq.context) + hit_eod = eod_payload(hit_output) + self.assertIsNotNone(hit_eod["last_plan"]) + self.assertTrue(hit_eod["order_returns"]) + self.assertTrue(hit_eod["skipped_orders"]) + orders_after_hit = list(jq.orders) + self.assertTrue(orders_after_hit) + + for unused_day in range(2): + del unused_day + jq.advance_session() + noop_output = io.StringIO() + with contextlib.redirect_stdout(noop_output): + self.assertIsNone(jq.run_scheduled()) + joinquant_strategy.after_trading_end(jq.context) + noop_records = _evidence_records(noop_output) + self.assertIn( + "TARGET_PACKAGE_NOOP", + [record["event"] for record in noop_records], + ) + noop_eod = eod_payload(noop_output) + self.assertIsNone(noop_eod["last_plan"]) + self.assertEqual(noop_eod["order_returns"], []) + self.assertEqual(noop_eod["skipped_orders"], []) + self.assertEqual(jq.orders, orders_after_hit) + + def test_joinquant_hostile_any_still_runs_final_three_day_tape(self): + bundled = types.ModuleType("hostile_joinquant_bundle") + source = ( + ROOT / "dist/target-package/joinquant_strategy.py" + ).read_bytes() + exec( + compile( + source, + "dist/target-package/joinquant_strategy.py", + "exec", + ), + bundled.__dict__, + ) + package = copy.deepcopy(bundled.TARGET_PACKAGES[0]) + histories = { + symbol: [10.0] * 21 for symbol in package["universe"] + } + hostile_any = lambda values: True + + def run_three_days(module): + jq = FakeJoinQuantHarness(histories) + jq.context.current_dt = datetime.datetime( + 2024, + 3, + 11, + 9, + 30, + ) + jq.context.previous_date = datetime.date(2024, 3, 8) + daily_orders = [] + output = io.StringIO() + with contextlib.redirect_stdout(output): + jq.initialize(module) + for day in range(3): + before = len(jq.orders) + jq.run_scheduled() + daily_orders.append(len(jq.orders) - before) + if day < 2: + jq.advance_session() + self.assertEqual(daily_orders, [0, 4, 0]) + + with self.subTest(artifact="source-wrapper"): + self._install_package(package) + with ( + mock.patch.object( + portable_core, + "any", + hostile_any, + create=True, + ), + mock.patch.object( + joinquant_strategy, + "any", + hostile_any, + create=True, + ), + ): + run_three_days(joinquant_strategy) + + with self.subTest(artifact="final-target-bundle"): + bundled.any = hostile_any + self.assertEqual( + bundled.TARGET_PACKAGES[0]["package_sha256"], + package["package_sha256"], + ) + run_three_days(bundled) + + def test_joinquant_emits_noop_and_skipped_order(self): + missing = _package( + signal_as_of="2026-07-10T15:00:00+08:00", + next_session="2026-07-13", + ) + self._install_package(missing) + jq = FakeJoinQuantHarness(_jq_histories()) + output = io.StringIO() + with contextlib.redirect_stdout(output): + jq.initialize(joinquant_strategy) + self.assertIsNone(jq.run_scheduled()) + events = [ + record["event"] for record in _evidence_records(output) + ] + self.assertIn("TARGET_PACKAGE_NOOP", events) + self.assertNotIn("TARGET_PACKAGE_PLAN", events) + self.assertNotIn("ORDER_REQUEST", events) + + package = _package() + self._install_package(package) + jq = FakeJoinQuantHarness(_jq_histories()) + jq.current_data["000001.XSHE"].paused = True + output = io.StringIO() + with contextlib.redirect_stdout(output): + jq.initialize(joinquant_strategy) + plan = jq.run_scheduled() + self.assertIsNotNone(plan) + records = _evidence_records(output) + skipped = [ + record["payload"] + for record in records + if record["event"] == "SKIPPED_ORDER" + ] + self.assertEqual(len(skipped), 1) + self.assertEqual(skipped[0]["skip"]["reason"], "paused") + self.assertEqual(jq.orders, []) + + def test_joinquant_observation_failure_does_not_change_order_path(self): + package = _package() + self._install_package(package) + jq = FakeJoinQuantHarness(_jq_histories()) + with mock.patch("builtins.print", side_effect=RuntimeError("log down")): + jq.initialize(joinquant_strategy) + plan = jq.run_scheduled() + joinquant_strategy.after_trading_end(jq.context) + self.assertIsNotNone(plan) + self.assertEqual( + sorted(jq.orders), + [("000001.XSHE", 25000)], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_baseline_pipeline.py b/tests/test_baseline_pipeline.py new file mode 100644 index 0000000..1e9cc21 --- /dev/null +++ b/tests/test_baseline_pipeline.py @@ -0,0 +1,228 @@ +import copy +import unittest + +from quant60.baseline_pipeline import ( + AlphaForecastInput, + BaselinePipelineError, + BaselinePipelinePolicy, + CostForecastInput, + FIVE_LAYER_ORDER, + RiskForecastInput, + SecurityStateInput, + build_baseline_pipeline, + verify_layer_trace, +) +from quant60.optimizer import PortfolioConstraints +from quant60.universe import UniverseState + + +SYMBOLS = ( + "600000.XSHG", + "000001.XSHE", + "000333.XSHE", +) + + +def inputs(*, first_variance=0.03, first_cost_bps=8.0): + alpha = [ + AlphaForecastInput( + SYMBOLS[0], + expected_excess_return=0.030, + model_version="ridge-frozen-fixture-v1", + feature_version="transparent-price-v1", + ), + AlphaForecastInput( + SYMBOLS[1], + expected_excess_return=0.020, + model_version="ridge-frozen-fixture-v1", + feature_version="transparent-price-v1", + ), + AlphaForecastInput( + SYMBOLS[2], + expected_excess_return=0.010, + model_version="ridge-frozen-fixture-v1", + feature_version="transparent-price-v1", + ), + ] + risk = [ + RiskForecastInput( + SYMBOLS[0], + first_variance, + "diagonal-risk-fixture-v1", + ), + RiskForecastInput(SYMBOLS[1], 0.03, "diagonal-risk-fixture-v1"), + RiskForecastInput(SYMBOLS[2], 0.03, "diagonal-risk-fixture-v1"), + ] + cost = [ + CostForecastInput( + SYMBOLS[0], + first_cost_bps, + 1.0, + "dated-cost-fixture-v1", + ), + CostForecastInput(SYMBOLS[1], 8.0, 1.0, "dated-cost-fixture-v1"), + CostForecastInput(SYMBOLS[2], 8.0, 1.0, "dated-cost-fixture-v1"), + ] + securities = [ + SecurityStateInput( + SYMBOLS[0], + UniverseState.OPENABLE, + "bank", + 10.0, + ), + SecurityStateInput( + SYMBOLS[1], + UniverseState.OPENABLE, + "bank", + 20.0, + ), + SecurityStateInput( + SYMBOLS[2], + UniverseState.OPENABLE, + "consumer", + 50.0, + ), + ] + return alpha, risk, cost, securities + + +def policy(): + return BaselinePipelinePolicy( + constraints=PortfolioConstraints( + gross_target=0.80, + single_name_cap=0.80, + industry_cap=0.80, + one_way_turnover_cap=0.80, + risk_aversion=1.0, + cost_aversion=1.0, + turnover_aversion=0.0, + ), + cash_buffer=0.10, + ) + + +class BaselinePipelineTests(unittest.TestCase): + def test_canonical_path_emits_exact_five_layer_hash_chain(self): + alpha, risk, cost, securities = inputs() + result = build_baseline_pipeline( + alpha=alpha, + risk=risk, + cost=cost, + securities=securities, + equity=1_000_000, + policy=policy(), + ) + self.assertEqual( + tuple(event["layer"] for event in result.layer_trace), + FIVE_LAYER_ORDER, + ) + self.assertTrue(result.risk_decision.approved) + self.assertTrue(result.order_deltas) + verify_layer_trace(result.layer_trace, result.layer_payloads) + + def test_risk_and_cost_are_causal_inputs_to_portfolio(self): + alpha, risk, cost, securities = inputs() + base = build_baseline_pipeline( + alpha=alpha, + risk=risk, + cost=cost, + securities=securities, + equity=1_000_000, + policy=policy(), + ) + alpha, risk, cost, securities = inputs(first_variance=0.30) + high_risk = build_baseline_pipeline( + alpha=alpha, + risk=risk, + cost=cost, + securities=securities, + equity=1_000_000, + policy=policy(), + ) + alpha, risk, cost, securities = inputs(first_cost_bps=250.0) + high_cost = build_baseline_pipeline( + alpha=alpha, + risk=risk, + cost=cost, + securities=securities, + equity=1_000_000, + policy=policy(), + ) + symbol = SYMBOLS[0] + self.assertLess( + high_risk.solution.weights[symbol], + base.solution.weights[symbol], + ) + self.assertLess( + high_cost.solution.weights[symbol], + base.solution.weights[symbol], + ) + self.assertNotEqual( + base.layer_trace[2]["event_sha256"], + high_risk.layer_trace[2]["event_sha256"], + ) + self.assertNotEqual( + base.layer_trace[2]["event_sha256"], + high_cost.layer_trace[2]["event_sha256"], + ) + + def test_missing_layer_input_fails_closed(self): + alpha, risk, cost, securities = inputs() + with self.assertRaisesRegex( + BaselinePipelineError, + "symbol sets must match exactly", + ): + build_baseline_pipeline( + alpha=alpha, + risk=risk[:-1], + cost=cost, + securities=securities, + equity=1_000_000, + policy=policy(), + ) + + def test_smoke_model_cannot_claim_baseline_candidate(self): + alpha, risk, cost, securities = inputs() + alpha = [ + AlphaForecastInput( + item.symbol, + item.expected_excess_return, + "portable-momentum-v1", + item.feature_version, + ) + for item in alpha + ] + with self.assertRaisesRegex( + BaselinePipelineError, + "platform smoke strategy", + ): + build_baseline_pipeline( + alpha=alpha, + risk=risk, + cost=cost, + securities=securities, + equity=1_000_000, + policy=policy(), + ) + + def test_layer_trace_tampering_is_rejected(self): + alpha, risk, cost, securities = inputs() + result = build_baseline_pipeline( + alpha=alpha, + risk=risk, + cost=cost, + securities=securities, + equity=1_000_000, + policy=policy(), + ) + trace = copy.deepcopy(list(result.layer_trace)) + trace[2]["output_sha256"] = "0" * 64 + with self.assertRaisesRegex( + BaselinePipelineError, + "output hash mismatch", + ): + verify_layer_trace(trace, result.layer_payloads) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_build_release_reachability.py b/tests/test_build_release_reachability.py new file mode 100644 index 0000000..6eb7ff5 --- /dev/null +++ b/tests/test_build_release_reachability.py @@ -0,0 +1,134 @@ +import json +import subprocess +import sys +import unittest +from pathlib import Path + +from quant60.ledger import canonical_json +from quant60.release_reachability import LAYERS + + +PROJECT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT)) + +from tools.build_release_reachability import ( # noqa: E402 + DEFAULT_OUTPUT, + JOINQUANT_TARGET_PACKAGE_OBSERVED_AT, + JOINQUANT_TARGET_PACKAGE_RUN_ID, + JOINQUANT_TARGET_PACKAGE_RUN_PATH, + PLATFORM_ENTRYPOINTS, + RELEASE_ID, + build_manifest, +) + + +class BuildReleaseReachabilityTests(unittest.TestCase): + def test_checked_in_manifest_exactly_matches_generator(self): + expected = build_manifest(repository_root=PROJECT) + observed = json.loads(DEFAULT_OUTPUT.read_text(encoding="utf-8")) + self.assertEqual(observed, expected) + self.assertEqual( + DEFAULT_OUTPUT.read_bytes(), + (canonical_json(expected) + "\n").encode("utf-8"), + ) + + def test_only_execution_has_real_joinquant_observation(self): + manifest = build_manifest(repository_root=PROJECT) + self.assertEqual(manifest["release_id"], RELEASE_ID) + for layer in LAYERS: + claims = manifest["layers"][layer] + for declaration in PLATFORM_ENTRYPOINTS.values(): + claim = claims[declaration["capability"]] + self.assertTrue(claim["status"]) + self.assertEqual(len(claim["artifacts"]), 1) + artifact = claim["artifacts"][0] + self.assertIn("targetpackagev1", artifact["artifact_id"]) + self.assertEqual(artifact["path"], declaration["path"]) + self.assertEqual(artifact["platform"], declaration["platform"]) + observed = claims["real_platform_observed"] + if layer != "execution": + self.assertEqual( + observed, + {"status": False, "artifacts": []}, + ) + continue + self.assertTrue(observed["status"]) + self.assertEqual(len(observed["artifacts"]), 1) + artifact = observed["artifacts"][0] + self.assertEqual(artifact["kind"], "platform_run") + self.assertEqual(artifact["platform"], "joinquant") + self.assertEqual( + artifact["path"], + JOINQUANT_TARGET_PACKAGE_RUN_PATH, + ) + self.assertEqual( + artifact["observed_at"], + JOINQUANT_TARGET_PACKAGE_OBSERVED_AT, + ) + self.assertEqual( + artifact["run_id"], + JOINQUANT_TARGET_PACKAGE_RUN_ID, + ) + self.assertFalse( + manifest["summary"]["all_layers_real_platform_observed"] + ) + + def test_tracked_joinquant_run_preserves_conservative_boundary(self): + run = json.loads( + (PROJECT / JOINQUANT_TARGET_PACKAGE_RUN_PATH).read_text( + encoding="utf-8" + ) + ) + self.assertEqual(run["run_id"], JOINQUANT_TARGET_PACKAGE_RUN_ID) + self.assertEqual(run["observed_at"], JOINQUANT_TARGET_PACKAGE_OBSERVED_AT) + self.assertEqual( + run["platform_evidence_class"], + "real_platform_runtime", + ) + self.assertEqual( + run["input_evidence_class"], + "synthetic-research", + ) + self.assertFalse(run["performance_claim"]) + self.assertEqual( + run["target_package"]["evidence_class"], + "synthetic-research", + ) + self.assertEqual(run["counts"], { + "orders": 3, + "fills": 3, + "skipped_orders": 0, + }) + boundary = run["claim_boundary"] + self.assertEqual( + boundary["real_platform_observed_layers"], + ["execution"], + ) + self.assertFalse(boundary["upstream_layers_real_platform_observed"]) + self.assertFalse(boundary["qmt_peer_observed"]) + self.assertFalse(boundary["same_input_cross_engine_parity_observed"]) + self.assertFalse(boundary["all_five_layers_real_platform_observed"]) + self.assertFalse(boundary["g9_passed"]) + self.assertFalse(boundary["baseline_60_passed"]) + self.assertFalse(boundary["investment_value_claim"]) + + def test_check_mode_verifies_current_file(self): + completed = subprocess.run( + [ + sys.executable, + str(PROJECT / "tools/build_release_reachability.py"), + "--check", + ], + cwd=PROJECT, + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + result = json.loads(completed.stdout) + self.assertTrue(result["ok"]) + self.assertTrue(result["checked"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_experiment.py b/tests/test_experiment.py index 7ef41c2..1fa4f8a 100644 --- a/tests/test_experiment.py +++ b/tests/test_experiment.py @@ -19,6 +19,22 @@ class ResearchExperimentTests(unittest.TestCase): self.assertGreater(first["report"]["fold_count"], 0) self.assertFalse(first["report"]["investment_value_claim"]) self.assertTrue(first["targets"]) + self.assertLess( + first["report"]["final_training_max_label_exit"], + first["report"]["target_date"], + ) + self.assertEqual( + first["target_package"]["model_sha256"], + first["model_bundle"]["bundle_sha256"], + ) + self.assertEqual( + [item["layer"] for item in first["pipeline"]["layer_trace"]], + ["universe", "alpha", "portfolio", "risk", "execution"], + ) + self.assertEqual( + first["target_package"]["target_weights"], + first["pipeline"]["weights"], + ) with tempfile.TemporaryDirectory() as left_dir, tempfile.TemporaryDirectory() as right_dir: left = write_research_artifacts(first, left_dir) diff --git a/tests/test_model_bundle.py b/tests/test_model_bundle.py new file mode 100644 index 0000000..a07f36b --- /dev/null +++ b/tests/test_model_bundle.py @@ -0,0 +1,191 @@ +import copy +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from quant60.features import TrainOnlyFeaturePreprocessor +from quant60.ledger import canonical_json +from quant60.model_bundle import ( + ModelBundleIntegrityError, + create_model_bundle, + load_verified_model_bundle, + model_bundle_sha256, + verify_model_bundle, + write_model_bundle, +) +from quant60.research import DeterministicRidge + + +HASHES = { + "data_sha256": hashlib.sha256(b"training-data").hexdigest(), + "source_sha256": hashlib.sha256(b"source-tree").hexdigest(), + "config_sha256": hashlib.sha256(b"training-config").hexdigest(), +} + + +class ModelBundleTests(unittest.TestCase): + def setUp(self): + self.training_rows = [ + {"momentum": -0.2, "volatility": 0.10}, + {"momentum": -0.1, "volatility": None}, + {"momentum": 0.0, "volatility": 0.20}, + {"momentum": 0.1, "volatility": 0.25}, + {"momentum": 0.2, "volatility": 0.30}, + ] + self.processor = TrainOnlyFeaturePreprocessor( + winsor_fraction=0.1 + ).fit(self.training_rows) + transformed = self.processor.transform(self.training_rows) + matrix = [ + [row[name] for name in self.processor.output_names] + for row in transformed + ] + self.model = DeterministicRidge(alpha=0.5).fit( + matrix, + [-0.03, -0.01, 0.0, 0.02, 0.04], + ) + self.bundle = create_model_bundle( + self.processor, + self.model, + trained_through="2024-12-31", + label_name="t_plus_1_open_to_t_plus_6_open_excess", + horizon_trading_days=5, + **HASHES, + ) + + def test_freezes_preprocessor_model_label_and_lineage(self): + verified = verify_model_bundle(self.bundle) + self.assertEqual( + verified.feature_order, + tuple(sorted(self.training_rows[0])), + ) + self.assertEqual( + verified.output_feature_order, + self.processor.output_names, + ) + self.assertEqual( + verified.coefficients, + self.model.coef_, + ) + self.assertEqual(verified.intercept, self.model.intercept_) + self.assertEqual(verified.trained_through.isoformat(), "2024-12-31") + self.assertEqual(verified.horizon_trading_days, 5) + self.assertEqual(verified.data_sha256, HASHES["data_sha256"]) + + def test_write_read_and_prediction_replay_are_deterministic(self): + replay_rows = [ + {"volatility": None, "momentum": 0.07}, + {"momentum": -100.0, "volatility": 100.0}, + ] + transformed = self.processor.transform(replay_rows) + matrix = [ + [row[name] for name in self.processor.output_names] + for row in transformed + ] + expected = self.model.predict(matrix) + + with tempfile.TemporaryDirectory() as directory: + left = write_model_bundle( + self.bundle, + Path(directory) / "left.json", + ) + right = write_model_bundle( + copy.deepcopy(self.bundle), + Path(directory) / "right.json", + ) + self.assertEqual(left.read_bytes(), right.read_bytes()) + loaded = load_verified_model_bundle( + left, + expected_bundle_sha256=self.bundle["bundle_sha256"], + **{ + f"expected_{key}": value + for key, value in HASHES.items() + }, + ) + self.assertEqual(loaded.predict(replay_rows), expected) + independently_loaded = load_verified_model_bundle(right) + self.assertEqual( + loaded.prediction_bytes(replay_rows), + independently_loaded.prediction_bytes(replay_rows), + ) + self.assertEqual(loaded.canonical_bytes, left.read_bytes()) + + def test_content_tampering_fails_closed(self): + with tempfile.TemporaryDirectory() as directory: + path = write_model_bundle( + self.bundle, + Path(directory) / "bundle.json", + ) + tampered = json.loads(path.read_text(encoding="utf-8")) + tampered["model"]["coefficients"][0] += 0.01 + path.write_text( + canonical_json(tampered) + "\n", + encoding="utf-8", + ) + with self.assertRaisesRegex( + ModelBundleIntegrityError, + "SHA-256 mismatch", + ): + load_verified_model_bundle(path) + + def test_resealed_semantic_tampering_fails_closed(self): + tampered = copy.deepcopy(self.bundle) + tampered["preprocessor"]["output_feature_order"] = list( + reversed(tampered["preprocessor"]["output_feature_order"]) + ) + tampered["bundle_sha256"] = model_bundle_sha256(tampered) + with self.assertRaisesRegex( + ModelBundleIntegrityError, + "output_feature_order is inconsistent", + ): + verify_model_bundle(tampered) + + def test_expected_identity_rejects_a_valid_but_wrong_bundle(self): + with self.assertRaisesRegex( + ModelBundleIntegrityError, + "data_sha256 does not match expected identity", + ): + verify_model_bundle( + self.bundle, + expected_data_sha256="f" * 64, + ) + + def test_placeholder_lineage_hash_is_rejected(self): + with self.assertRaisesRegex( + ModelBundleIntegrityError, + "all-zero placeholder", + ): + create_model_bundle( + self.processor, + self.model, + trained_through="2024-12-31", + label_name="label", + horizon_trading_days=5, + data_sha256="0" * 64, + source_sha256=HASHES["source_sha256"], + config_sha256=HASHES["config_sha256"], + ) + + def test_unfitted_components_and_non_finite_state_are_rejected(self): + with self.assertRaisesRegex(ValueError, "preprocessor is not fitted"): + create_model_bundle( + TrainOnlyFeaturePreprocessor(), + self.model, + trained_through="2024-12-31", + label_name="label", + horizon_trading_days=5, + **HASHES, + ) + tampered = copy.deepcopy(self.bundle) + tampered["model"]["intercept"] = float("nan") + with self.assertRaisesRegex( + ModelBundleIntegrityError, + "canonical JSON", + ): + verify_model_bundle(tampered) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_portable_core.py b/tests/test_portable_core.py index bbc929c..11f023b 100644 --- a/tests/test_portable_core.py +++ b/tests/test_portable_core.py @@ -1,16 +1,99 @@ +import ast +import copy +import hashlib import unittest +from pathlib import Path +from quant60.ledger import canonical_json from quant60.portable_core import ( + TargetPackageLookupMiss, + _any_true, build_rebalance_plan, + build_target_weight_plan, capped_target_weights, + lookup_target_package_exact, momentum_score, normalize_symbol, order_deltas, + target_package_tape_sha256, target_quantities, + verify_target_package, ) +from quant60.target_package import seal_target_package + + +def sealed_target_package( + package_id="TP-20260724-A", + decision_id="D-20260724-A", + signal_as_of="2026-07-24T15:00:00+08:00", + next_session="2026-07-27", +): + return seal_target_package( + { + "schema_version": "1.0", + "package_type": "TargetPackageV1", + "package_id": package_id, + "decision_id": decision_id, + "release": "quant60-baseline-60-v1", + "evidence_class": "provider-snapshot-local", + "model_id": "alpha158-lightgbm-v1", + "signal_as_of": signal_as_of, + "next_session": next_session, + "model_sha256": "1" * 64, + "data_sha256": "2" * 64, + "feature_sha256": "3" * 64, + "risk_sha256": "4" * 64, + "cost_sha256": "5" * 64, + "optimizer_sha256": "6" * 64, + "config_sha256": "7" * 64, + "source_sha256": "8" * 64, + "target_weights": { + "000001.XSHE": 0.30, + "600000.XSHG": 0.20, + }, + "universe": { + "000001.XSHE": { + "state": "openable", + "reasons": ["ELIGIBLE"], + }, + "600000.XSHG": { + "state": "hold_only", + "reasons": ["LIQUIDITY_BELOW_FLOOR"], + }, + }, + "constraints": { + "constraint_state": "FEASIBLE", + "long_only": True, + "max_single_weight": 0.40, + "max_gross_weight": 0.80, + "max_one_way_turnover": 0.20, + "actual_gross_weight": 0.50, + }, + } + ) class PortableCoreTests(unittest.TestCase): + def test_private_any_true_preserves_builtin_semantics(self): + for values in ( + [], + [False, 0, None, ""], + [False, 0, "truthy"], + [1], + ): + with self.subTest(values=values): + self.assertEqual(_any_true(iter(values)), any(values)) + + visited = [] + + def values(): + for value in (False, True, "must-not-be-read"): + visited.append(value) + yield value + + self.assertTrue(_any_true(values())) + self.assertEqual(visited, [False, True]) + def test_symbol_round_trip(self): inputs = [ ("600000.XSHG", "600000.SH", "SH600000"), @@ -166,6 +249,255 @@ class PortableCoreTests(unittest.TestCase): build_rebalance_plan(**arguments), ) + def test_target_weight_plan_is_causal_and_preserves_lineage(self): + common = { + "prices": { + "600000.SH": 10.0, + "000001.SZ": 10.0, + }, + "current": {}, + "sellable": {}, + "equity": 100_000, + "cash_buffer": 0.02, + "lot_size": 100, + "decision_id": "D-20260724-A", + "package_sha256": "a" * 64, + } + first = build_target_weight_plan( + { + "600000.XSHG": 0.60, + "000001.XSHE": 0.20, + }, + **common + ) + second = build_target_weight_plan( + { + "600000.XSHG": 0.20, + "000001.XSHE": 0.60, + }, + **common + ) + self.assertNotEqual(first["targets"], second["targets"]) + self.assertNotEqual(first["orders"], second["orders"]) + self.assertEqual(first["decision_id"], "D-20260724-A") + self.assertEqual(first["package_sha256"], "a" * 64) + self.assertNotIn("scores", first) + + def test_target_weight_plan_rejects_alias_duplicates_and_bad_weights(self): + common = { + "prices": {"600000.XSHG": 10.0}, + "current": {}, + "sellable": {}, + "equity": 100_000, + } + with self.assertRaisesRegex(ValueError, "duplicate normalized"): + build_target_weight_plan( + { + "600000.SH": 0.20, + "600000.XSHG": 0.20, + }, + **common + ) + for weights in ( + {"600000.XSHG": -0.1}, + {"600000.XSHG": float("nan")}, + {"600000.XSHG": 1.01}, + ): + with self.subTest(weights=weights): + with self.assertRaises(ValueError): + build_target_weight_plan(weights, **common) + with self.assertRaisesRegex(ValueError, "gross"): + build_target_weight_plan( + { + "600000.XSHG": 0.60, + "000001.XSHE": 0.50, + }, + prices={ + "600000.XSHG": 10.0, + "000001.XSHE": 10.0, + }, + current={}, + sellable={}, + equity=100_000, + ) + + def test_target_weight_plan_respects_t1_and_odd_lot_liquidation(self): + t1_limited = build_target_weight_plan( + {}, + {"600000.XSHG": 10.0}, + {"600000.XSHG": 1050}, + {"600000.XSHG": 250}, + equity=100_000, + ) + self.assertEqual(t1_limited["weights"], {"600000.XSHG": 0.0}) + self.assertEqual(t1_limited["targets"], {"600000.XSHG": 0}) + self.assertEqual(t1_limited["orders"], {"600000.XSHG": -200}) + + complete = build_target_weight_plan( + {}, + {"600000.XSHG": 10.0}, + {"600000.XSHG": 1050}, + {"600000.XSHG": 1050}, + equity=100_000, + ) + self.assertEqual(complete["orders"], {"600000.XSHG": -1050}) + + unsellable = build_target_weight_plan( + {}, + {"600000.XSHG": 10.0}, + {"600000.XSHG": 1050}, + {}, + equity=100_000, + ) + self.assertEqual(unsellable["orders"], {}) + + def test_target_weight_plan_requires_exact_managed_price_coverage(self): + with self.assertRaisesRegex(KeyError, "missing"): + build_target_weight_plan( + {"600000.XSHG": 0.20}, + {"600000.XSHG": 10.0}, + {"000001.XSHE": 100}, + {"000001.XSHE": 100}, + equity=100_000, + ) + with self.assertRaisesRegex(ValueError, "unmanaged"): + build_target_weight_plan( + {"600000.XSHG": 0.20}, + { + "600000.XSHG": 10.0, + "000001.XSHE": 10.0, + }, + {}, + {}, + equity=100_000, + ) + + def test_portable_target_package_verifier_matches_local_seal(self): + package = sealed_target_package() + self.assertEqual(verify_target_package(package), package) + self.assertEqual( + verify_target_package( + package, + expected_signal_as_of=package["signal_as_of"], + expected_next_session=package["next_session"], + expected_decision_id=package["decision_id"], + expected_package_sha256=package["package_sha256"], + ), + package, + ) + + tampered = copy.deepcopy(package) + tampered["target_weights"]["000001.XSHE"] = 0.29 + tampered["constraints"]["actual_gross_weight"] = 0.49 + with self.assertRaisesRegex(ValueError, "hash mismatch"): + verify_target_package(tampered) + + quantity_injected = copy.deepcopy(package) + quantity_injected["target_quantity"] = 100 + with self.assertRaisesRegex(ValueError, "quantities do not belong"): + verify_target_package(quantity_injected) + + noncanonical = copy.deepcopy(package) + noncanonical["target_weights"]["000001.SZ"] = noncanonical[ + "target_weights" + ].pop("000001.XSHE") + with self.assertRaisesRegex(ValueError, "must be canonical"): + verify_target_package(noncanonical) + + def test_target_package_tape_is_pinned_and_exact_only(self): + first = sealed_target_package() + second = sealed_target_package( + package_id="TP-20260731-B", + decision_id="D-20260731-B", + signal_as_of="2026-07-31T15:00:00+08:00", + next_session="2026-08-03", + ) + packages = [first, second] + tape_hash = target_package_tape_sha256(packages) + self.assertEqual( + tape_hash, + hashlib.sha256( + canonical_json(packages).encode("utf-8") + ).hexdigest(), + ) + found = lookup_target_package_exact( + packages, + signal_as_of=second["signal_as_of"], + next_session=second["next_session"], + decision_id=second["decision_id"], + expected_tape_sha256=tape_hash, + expected_count=2, + ) + self.assertEqual(found, second) + found["release"] = "mutated" + self.assertEqual(packages[1]["release"], second["release"]) + + with self.assertRaises(TargetPackageLookupMiss): + lookup_target_package_exact( + packages, + signal_as_of="2026-07-30T15:00:00+08:00", + next_session="2026-07-31", + expected_tape_sha256=tape_hash, + expected_count=2, + ) + with self.assertRaisesRegex(ValueError, "decision_id mismatch"): + lookup_target_package_exact( + packages, + signal_as_of=first["signal_as_of"], + next_session=first["next_session"], + decision_id="D-WRONG", + ) + with self.assertRaisesRegex(ValueError, "count mismatch"): + lookup_target_package_exact( + packages[:1], + signal_as_of=first["signal_as_of"], + next_session=first["next_session"], + expected_count=2, + ) + with self.assertRaisesRegex(ValueError, "digest mismatch"): + lookup_target_package_exact( + packages[:1], + signal_as_of=first["signal_as_of"], + next_session=first["next_session"], + expected_tape_sha256=tape_hash, + ) + + def test_target_package_tape_rejects_duplicates_and_unmatched_tamper(self): + first = sealed_target_package() + duplicate_clock = sealed_target_package( + package_id="TP-20260724-B", + decision_id="D-20260724-B", + ) + with self.assertRaisesRegex(ValueError, "duplicate target package clock"): + lookup_target_package_exact( + [first, duplicate_clock], + signal_as_of=first["signal_as_of"], + next_session=first["next_session"], + ) + + later = sealed_target_package( + package_id="TP-20260731-B", + decision_id="D-20260731-B", + signal_as_of="2026-07-31T15:00:00+08:00", + next_session="2026-08-03", + ) + later["release"] = "tampered" + with self.assertRaisesRegex(ValueError, "hash mismatch"): + lookup_target_package_exact( + [first, later], + signal_as_of=first["signal_as_of"], + next_session=first["next_session"], + ) + + def test_portable_core_parses_as_python_36(self): + source = ( + Path(__file__).parents[1] + / "src" + / "quant60" + / "portable_core.py" + ).read_text(encoding="utf-8") + ast.parse(source, feature_version=(3, 6)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_public_status.py b/tests/test_public_status.py index d1d84d4..eb0333c 100644 --- a/tests/test_public_status.py +++ b/tests/test_public_status.py @@ -1,4 +1,5 @@ import importlib.util +import hashlib import json import os import re @@ -112,10 +113,191 @@ class PublicStatusTests(unittest.TestCase): platforms = {platform["name"]: platform for platform in payload["platforms"]} self.assertEqual(platforms["JoinQuant hosted bundle"]["status"], "real_runtime") + self.assertIn( + "3", + platforms["JoinQuant hosted bundle"]["claim"], + ) + self.assertIn( + "execution", + platforms["JoinQuant hosted bundle"]["limitations"][0], + ) self.assertEqual(platforms["QMT built-in bundle"]["status"], "implemented") self.assertFalse(platforms["XtTrader read-only shadow"]["liveReady"]) self.assertIn("fake", platforms["XtTrader read-only shadow"]["claim"].lower()) + def test_candidate_records_only_real_joinquant_execution_observation(self): + payload = _read_payload() + candidate = payload["candidateBoundary"] + self.assertEqual(candidate["decisionMode"], "target_package") + self.assertEqual(candidate["localEvidenceClass"], "synthetic_research") + self.assertTrue(candidate["realJoinQuantObserved"]) + self.assertFalse(candidate["realQmtObserved"]) + self.assertEqual( + candidate["realPlatformObservedLayers"], + ["execution"], + ) + self.assertFalse(candidate["allFiveLayersRealPlatformObserved"]) + run = candidate["joinQuantTargetPackageRun"] + self.assertEqual( + run["runId"], + "2457a7c39a276e09e0fabf99e28978e1", + ) + self.assertEqual(run["evidenceClass"], "synthetic-research") + self.assertEqual(run["observedLayers"], ["execution"]) + self.assertEqual((run["orders"], run["fills"]), (3, 3)) + self.assertFalse(run["allFiveLayersObserved"]) + self.assertFalse(run["qmtPeerObserved"]) + self.assertFalse(run["g9Passed"]) + self.assertFalse(run["baseline60Passed"]) + self.assertFalse(run["investmentValueClaim"]) + self.assertFalse(candidate["joinQuantTargetPackageCountsAsBaseline"]) + self.assertFalse(candidate["joinQuantTargetPackageCountsAsG9"]) + self.assertEqual( + candidate["historicalJoinQuantMode"], "portable_momentum_smoke" + ) + self.assertFalse(candidate["historicalJoinQuantCountsAsBaseline"]) + self.assertTrue(payload["summary"]["candidateRealPlatformObserved"]) + self.assertEqual( + payload["summary"]["candidateRealPlatformObservationScope"], + "target_package_execution_consumer_only", + ) + self.assertFalse( + payload["summary"]["allFiveLayersRealPlatformObserved"] + ) + reachability = candidate["reachability"] + self.assertEqual( + reachability["releaseId"], "quant60-research-candidate-v1" + ) + self.assertTrue( + reachability["summary"]["all_layers_implemented"] + ) + self.assertTrue( + reachability["summary"]["all_layers_local_entrypoint_reachable"] + ) + self.assertFalse( + reachability["summary"]["all_layers_real_platform_observed"] + ) + self.assertEqual( + reachability["realPlatformsObserved"], + ["joinquant"], + ) + self.assertEqual( + reachability["realPlatformObservedLayers"], + ["execution"], + ) + evidence = {item["id"]: item for item in payload["evidence"]} + self.assertEqual( + evidence["release-reachability"]["path"], + "releases/quant60-research-candidate-v1/reachability.json", + ) + self.assertEqual( + evidence["joinquant-target-package-run"]["path"], + "evidence/joinquant/TP-20240311-795dcfba/" + "2457a7c39a276e09e0fabf99e28978e1/run.json", + ) + self.assertEqual( + evidence["joinquant-target-package-evidence"]["path"], + "docs/JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md", + ) + + gaps = payload["deliveryGaps"] + self.assertGreaterEqual(len(gaps["internal"]), 3) + self.assertGreaterEqual(len(gaps["externalEvidence"]), 5) + internal = " ".join(gaps["internal"]) + self.assertIn("provider-data", internal) + self.assertIn("factor_risk.py", internal) + self.assertIn("CVXPY", internal) + self.assertIn("TWAP/POV", internal) + self.assertIn("synthetic-research", internal) + self.assertIn("TargetPackageV1", internal) + self.assertIn("real JoinQuant", internal) + self.assertIn("real QMT", internal) + + def test_public_builder_rejects_wrong_reachability_evidence_class(self): + module = _load_builder() + path = ( + PROJECT + / "releases" + / "quant60-research-candidate-v1" + / "reachability.json" + ) + manifest = json.loads(path.read_text(encoding="utf-8")) + artifact = manifest["layers"]["alpha"]["jq_entrypoint_reachable"][ + "artifacts" + ][0] + artifact["kind"] = "source" + body = { + key: value + for key, value in manifest.items() + if key != "manifest_sha256" + } + manifest["manifest_sha256"] = hashlib.sha256( + json.dumps( + body, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + with self.assertRaises(module.PublicStatusError): + module._validate_reachability_manifest(PROJECT, manifest) + + def test_public_builder_rejects_upstream_real_platform_overclaim(self): + module = _load_builder() + path = ( + PROJECT + / "releases" + / "quant60-research-candidate-v1" + / "reachability.json" + ) + manifest = json.loads(path.read_text(encoding="utf-8")) + artifact = dict( + manifest["layers"]["execution"]["real_platform_observed"][ + "artifacts" + ][0] + ) + artifact["artifact_id"] = "alpha-real-platform-overclaim" + manifest["layers"]["alpha"]["real_platform_observed"] = { + "status": True, + "artifacts": [artifact], + } + body = { + key: value + for key, value in manifest.items() + if key != "manifest_sha256" + } + manifest["manifest_sha256"] = hashlib.sha256( + json.dumps( + body, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + with self.assertRaisesRegex( + module.PublicStatusError, + "only JoinQuant execution", + ): + module._validate_reachability_manifest(PROJECT, manifest) + + def test_public_builder_rejects_target_package_performance_claim(self): + module = _load_builder() + path = ( + PROJECT + / "evidence" + / "joinquant" + / "TP-20240311-795dcfba" + / "2457a7c39a276e09e0fabf99e28978e1" + / "run.json" + ) + run = json.loads(path.read_text(encoding="utf-8")) + run["performance_claim"] = True + with self.assertRaisesRegex( + module.PublicStatusError, + "evidence classes are unsafe", + ): + module._validate_joinquant_target_package_run(PROJECT, run) + def test_freshness_uses_daily_bars_not_the_later_trade_calendar(self): payload = _read_payload() freshness = {item["id"]: item for item in payload["freshness"]} @@ -124,6 +306,19 @@ class PublicStatusTests(unittest.TestCase): freshness["joinquant-hosted-market-window"]["marketDataThrough"], "2024-12-31", ) + target_package = freshness[ + "joinquant-target-package-execution-smoke" + ] + self.assertEqual(target_package["marketDataFrom"], "2024-03-11") + self.assertEqual(target_package["marketDataThrough"], "2024-03-13") + self.assertEqual( + target_package["observedAt"], + "2026-07-26T08:50:15Z", + ) + self.assertFalse(target_package["productionReady"]) + self.assertFalse(target_package["investmentValueClaim"]) + self.assertIn("execution", target_package["semantics"]) + self.assertIn("G9", target_package["semantics"]) tushare = freshness["tushare-daily-market-data"] self.assertEqual(tushare["completedPartitions"], 35) diff --git a/tests/test_release_reachability.py b/tests/test_release_reachability.py new file mode 100644 index 0000000..ae31c7d --- /dev/null +++ b/tests/test_release_reachability.py @@ -0,0 +1,405 @@ +import copy +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from quant60.ledger import canonical_json +from quant60.release_reachability import ( + CAPABILITIES, + LAYERS, + ReleaseReachabilityError, + build_evidence_artifact, + build_release_reachability, + load_verified_release_reachability, + release_reachability_sha256, + seal_release_reachability, + validate_release_reachability, + write_release_reachability, +) + + +GENERATED_AT = "2026-07-26T12:00:00Z" + + +class ReleaseReachabilityTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + for directory in ("src", "tests", "tools", "platforms", "evidence"): + (self.root / directory).mkdir() + for layer in LAYERS: + (self.root / "src" / f"{layer}.py").write_text( + f"LAYER = {layer!r}\n", + encoding="utf-8", + ) + (self.root / "tests" / f"test_{layer}.py").write_text( + f"def test_{layer}():\n assert True\n", + encoding="utf-8", + ) + (self.root / "tools" / f"{layer}_entry.py").write_text( + f"from src import {layer}\n", + encoding="utf-8", + ) + (self.root / "platforms" / "joinquant_entry.py").write_text( + "def execute():\n return 'submitted'\n", + encoding="utf-8", + ) + (self.root / "platforms" / "qmt_entry.py").write_text( + "def execute():\n return 'submitted'\n", + encoding="utf-8", + ) + (self.root / "evidence" / "jq-execution.json").write_text( + canonical_json( + { + "platform": "joinquant", + "layer": "execution", + "release_id": "quant60-baseline-v1", + "result": "passed", + } + ) + + "\n", + encoding="utf-8", + ) + + @staticmethod + def _false_claim(): + return {"status": False, "artifacts": []} + + def _artifact( + self, + *, + artifact_id, + kind, + path, + platform, + observed_at=None, + run_id=None, + ): + return build_evidence_artifact( + artifact_id=artifact_id, + kind=kind, + path=path, + platform=platform, + repository_root=self.root, + observed_at=observed_at, + run_id=run_id, + ) + + def _layers(self): + layers = {} + for layer in LAYERS: + layers[layer] = { + "implemented": { + "status": True, + "artifacts": [ + self._artifact( + artifact_id=f"{layer}-source", + kind="source", + path=f"src/{layer}.py", + platform="repository", + ) + ], + }, + "unit_tested": { + "status": True, + "artifacts": [ + self._artifact( + artifact_id=f"{layer}-test", + kind="test", + path=f"tests/test_{layer}.py", + platform="repository", + ) + ], + }, + "local_entrypoint_reachable": { + "status": True, + "artifacts": [ + self._artifact( + artifact_id=f"{layer}-local-entry", + kind="entrypoint", + path=f"tools/{layer}_entry.py", + platform="local", + ) + ], + }, + "jq_entrypoint_reachable": self._false_claim(), + "qmt_entrypoint_reachable": self._false_claim(), + "real_platform_observed": self._false_claim(), + } + layers["execution"]["jq_entrypoint_reachable"] = { + "status": True, + "artifacts": [ + self._artifact( + artifact_id="execution-jq-entry", + kind="entrypoint", + path="platforms/joinquant_entry.py", + platform="joinquant", + ) + ], + } + layers["execution"]["real_platform_observed"] = { + "status": True, + "artifacts": [ + self._artifact( + artifact_id="execution-jq-run", + kind="platform_run", + path="evidence/jq-execution.json", + platform="joinquant", + observed_at="2026-07-26T11:30:00Z", + run_id="jq-backtest-20260726-01", + ) + ], + } + return layers + + def _manifest(self): + return build_release_reachability( + release_id="quant60-baseline-v1", + generated_at=GENERATED_AT, + layers=self._layers(), + repository_root=self.root, + ) + + def test_build_write_load_preserves_exact_six_claims_per_layer(self): + manifest = self._manifest() + self.assertEqual( + manifest["manifest_sha256"], + release_reachability_sha256(manifest), + ) + for layer in LAYERS: + self.assertEqual( + set(manifest["layers"][layer]), + set(CAPABILITIES), + ) + self.assertTrue(manifest["summary"]["all_layers_implemented"]) + self.assertTrue(manifest["summary"]["all_layers_unit_tested"]) + self.assertTrue( + manifest["summary"]["all_layers_local_entrypoint_reachable"] + ) + self.assertFalse( + manifest["summary"]["all_layers_jq_entrypoint_reachable"] + ) + self.assertFalse( + manifest["summary"]["all_layers_qmt_entrypoint_reachable"] + ) + self.assertFalse( + manifest["summary"]["all_layers_real_platform_observed"] + ) + + path = write_release_reachability( + manifest, + self.root / "reachability.json", + repository_root=self.root, + ) + loaded = load_verified_release_reachability( + path, + repository_root=self.root, + expected_release_id="quant60-baseline-v1", + expected_manifest_sha256=manifest["manifest_sha256"], + ) + self.assertEqual(loaded, manifest) + self.assertEqual( + path.read_bytes(), + (canonical_json(manifest) + "\n").encode("utf-8"), + ) + + def test_manifest_and_artifact_tampering_both_fail_closed(self): + manifest = self._manifest() + status_tamper = copy.deepcopy(manifest) + status_tamper["layers"]["alpha"]["implemented"]["status"] = False + with self.assertRaisesRegex( + ReleaseReachabilityError, + "SHA-256 mismatch", + ): + validate_release_reachability( + status_tamper, + repository_root=self.root, + ) + + path = write_release_reachability( + manifest, + self.root / "reachability.json", + repository_root=self.root, + ) + (self.root / "src" / "alpha.py").write_text( + "LAYER = 'tampered'\n", + encoding="utf-8", + ) + with self.assertRaisesRegex( + ReleaseReachabilityError, + "artifact hash mismatch", + ): + load_verified_release_reachability( + path, + repository_root=self.root, + ) + + def test_source_hash_cannot_masquerade_as_real_platform_observation(self): + layers = self._layers() + source = self.root / "src" / "execution.py" + layers["execution"]["real_platform_observed"] = { + "status": True, + "artifacts": [ + { + "artifact_id": "fake-platform-run", + "kind": "platform_run", + "path": "src/execution.py", + "sha256": hashlib.sha256(source.read_bytes()).hexdigest(), + "platform": "joinquant", + "observed_at": "2026-07-26T11:30:00Z", + "run_id": "fake-run", + } + ], + } + with self.assertRaisesRegex( + ReleaseReachabilityError, + "real-platform evidence must live under", + ): + build_release_reachability( + release_id="quant60-baseline-v1", + generated_at=GENERATED_AT, + layers=layers, + repository_root=self.root, + ) + + def test_true_status_requires_typed_evidence_and_false_forbids_it(self): + true_without_evidence = self._layers() + true_without_evidence["risk"]["unit_tested"] = { + "status": True, + "artifacts": [], + } + with self.assertRaisesRegex( + ReleaseReachabilityError, + "iff at least one evidence", + ): + build_release_reachability( + release_id="quant60-baseline-v1", + generated_at=GENERATED_AT, + layers=true_without_evidence, + repository_root=self.root, + ) + + wrong_evidence_type = self._layers() + wrong_evidence_type["risk"]["unit_tested"]["artifacts"][0].update( + {"kind": "source", "platform": "repository"} + ) + with self.assertRaisesRegex( + ReleaseReachabilityError, + "requires kind='test'", + ): + build_release_reachability( + release_id="quant60-baseline-v1", + generated_at=GENERATED_AT, + layers=wrong_evidence_type, + repository_root=self.root, + ) + + def test_real_observation_requires_corresponding_platform_entrypoint(self): + layers = self._layers() + layers["execution"]["jq_entrypoint_reachable"] = self._false_claim() + with self.assertRaisesRegex( + ReleaseReachabilityError, + "requires jq_entrypoint_reachable", + ): + build_release_reachability( + release_id="quant60-baseline-v1", + generated_at=GENERATED_AT, + layers=layers, + repository_root=self.root, + ) + + def test_path_hash_id_and_clock_validation_are_strict(self): + cases = [] + + absolute = self._layers() + absolute["alpha"]["implemented"]["artifacts"][0]["path"] = str( + self.root / "src" / "alpha.py" + ) + cases.append((absolute, "repository-relative path")) + + traversal = self._layers() + traversal["alpha"]["implemented"]["artifacts"][0]["path"] = ( + "src/../tests/test_alpha.py" + ) + cases.append((traversal, "normalized repository-relative path")) + + placeholder = self._layers() + placeholder["alpha"]["implemented"]["artifacts"][0]["sha256"] = ( + "0" * 64 + ) + cases.append((placeholder, "all-zero placeholder")) + + duplicate = self._layers() + duplicate["alpha"]["implemented"]["artifacts"][0]["artifact_id"] = ( + duplicate["universe"]["implemented"]["artifacts"][0]["artifact_id"] + ) + cases.append((duplicate, "reused across claims")) + + future_run = self._layers() + future_run["execution"]["real_platform_observed"]["artifacts"][0][ + "observed_at" + ] = "2026-07-26T12:00:01Z" + cases.append((future_run, "cannot be after generated_at")) + + for layers, message in cases: + with self.subTest(message=message): + with self.assertRaisesRegex( + ReleaseReachabilityError, + message, + ): + build_release_reachability( + release_id="quant60-baseline-v1", + generated_at=GENERATED_AT, + layers=layers, + repository_root=self.root, + ) + + def test_strict_loader_rejects_duplicate_json_keys(self): + path = self.root / "duplicate.json" + path.write_text( + '{"schema_version":"1.0","schema_version":"1.0"}', + encoding="utf-8", + ) + with self.assertRaisesRegex( + ReleaseReachabilityError, + "duplicate JSON key", + ): + load_verified_release_reachability( + path, + repository_root=self.root, + ) + + def test_resealed_derived_summary_tamper_is_rejected(self): + manifest = self._manifest() + manifest["summary"]["all_layers_qmt_entrypoint_reachable"] = True + manifest["manifest_sha256"] = release_reachability_sha256(manifest) + with self.assertRaisesRegex( + ReleaseReachabilityError, + "summary is not the exact derived", + ): + validate_release_reachability( + manifest, + repository_root=self.root, + ) + + def test_seal_rejects_prepopulated_derived_fields(self): + body = { + "schema_version": "1.0", + "artifact_type": "quant60-release-reachability", + "release_id": "quant60-baseline-v1", + "generated_at": GENERATED_AT, + "layers": self._layers(), + "summary": {}, + } + with self.assertRaisesRegex( + ReleaseReachabilityError, + "derived summary", + ): + seal_release_reachability(body, repository_root=self.root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_target_package.py b/tests/test_target_package.py new file mode 100644 index 0000000..66ad18c --- /dev/null +++ b/tests/test_target_package.py @@ -0,0 +1,314 @@ +import copy +import json +import tempfile +import unittest +from pathlib import Path + +from quant60.target_package import ( + DuplicateTargetDecisionError, + SHA256_FIELDS, + TargetPackageError, + TargetPackageNotFoundError, + TargetPackageTape, + TargetPackageV1, + load_target_package_tape, + seal_target_package, + target_package_sha256, + validate_target_package, +) + + +def package_body( + *, + package_id: str = "TP-20260724-A", + decision_id: str = "D-20260724-A", + signal_as_of: str = "2026-07-24T15:00:00+08:00", + next_session: str = "2026-07-27", + release: str = "quant60-baseline-60-v1", + model_id: str = "alpha158-lightgbm-v1", +) -> dict: + return { + "schema_version": "1.0", + "package_type": "TargetPackageV1", + "package_id": package_id, + "decision_id": decision_id, + "release": release, + "evidence_class": "provider-snapshot-local", + "model_id": model_id, + "signal_as_of": signal_as_of, + "next_session": next_session, + "model_sha256": "1" * 64, + "data_sha256": "2" * 64, + "feature_sha256": "3" * 64, + "risk_sha256": "4" * 64, + "cost_sha256": "5" * 64, + "optimizer_sha256": "6" * 64, + "config_sha256": "7" * 64, + "source_sha256": "8" * 64, + "target_weights": { + "000001.XSHE": 0.30, + "600000.XSHG": 0.20, + }, + "universe": { + "000001.XSHE": { + "state": "openable", + "reasons": ["ELIGIBLE"], + }, + "600000.XSHG": { + "state": "hold_only", + "reasons": ["LIQUIDITY_BELOW_FLOOR"], + }, + }, + "constraints": { + "constraint_state": "FEASIBLE", + "long_only": True, + "max_single_weight": 0.40, + "max_gross_weight": 0.80, + "max_one_way_turnover": 0.20, + "actual_gross_weight": 0.50, + }, + } + + +def sealed_package(**overrides) -> dict: + return seal_target_package(package_body(**overrides)) + + +class TargetPackageTests(unittest.TestCase): + def test_valid_package_is_deterministic_and_quantity_free(self): + package = sealed_package() + self.assertEqual( + package["package_sha256"], + target_package_sha256(package), + ) + self.assertEqual(len(package["package_sha256"]), 64) + self.assertNotIn("quantity", json.dumps(package)) + self.assertEqual(validate_target_package(package), package) + wrapped = TargetPackageV1.from_dict(package) + self.assertEqual(wrapped.package_id, package["package_id"]) + self.assertEqual(wrapped.as_dict(), package) + + def test_tamper_is_rejected(self): + package = sealed_package() + package["target_weights"]["000001.XSHE"] = 0.25 + package["constraints"]["actual_gross_weight"] = 0.45 + with self.assertRaisesRegex(TargetPackageError, "hash mismatch"): + validate_target_package(package) + + def test_every_layer_hash_is_required_and_strict(self): + package = sealed_package() + for field in SHA256_FIELDS: + with self.subTest(field=field): + missing = copy.deepcopy(package) + missing.pop(field) + with self.assertRaises(TargetPackageError): + validate_target_package(missing) + malformed = copy.deepcopy(package) + malformed[field] = "A" * 64 + with self.assertRaises(TargetPackageError): + validate_target_package(malformed) + placeholder = copy.deepcopy(package) + placeholder[field] = "0" * 64 + placeholder["package_sha256"] = target_package_sha256( + placeholder + ) + with self.assertRaisesRegex( + TargetPackageError, + "all-zero placeholder", + ): + validate_target_package(placeholder) + + def test_portable_momentum_cannot_masquerade_as_baseline(self): + body = package_body(model_id="portable-momentum-v1") + with self.assertRaisesRegex(TargetPackageError, "cannot claim"): + seal_target_package(body) + + smoke = package_body( + release="platform-smoke-v1", + model_id="portable-momentum-v1", + ) + self.assertEqual( + seal_target_package(smoke)["model_id"], + "portable-momentum-v1", + ) + + def test_quantities_are_forbidden_at_any_depth(self): + for mutation in ( + lambda body: body.update({"target_quantity": 100}), + lambda body: body["constraints"].update({"quantity": 100}), + lambda body: body["universe"]["000001.XSHE"].update( + {"sellable_quantity": 100} + ), + ): + with self.subTest(mutation=mutation): + body = package_body() + mutation(body) + with self.assertRaisesRegex( + TargetPackageError, + "do not belong", + ): + seal_target_package(body) + + def test_clock_must_be_canonical_close_and_future_weekday(self): + cases = ( + ("2026-07-24T14:59:59+08:00", "2026-07-27"), + ("2026-07-24T07:00:00+00:00", "2026-07-27"), + ("2026-07-24T15:00:00.000000+08:00", "2026-07-27"), + ("2026-07-24T15:00:00+08:00", "2026-07-24"), + ("2026-07-24T15:00:00+08:00", "2026-07-25"), + ) + for signal_as_of, next_session in cases: + with self.subTest( + signal_as_of=signal_as_of, + next_session=next_session, + ): + with self.assertRaises(TargetPackageError): + seal_target_package( + package_body( + signal_as_of=signal_as_of, + next_session=next_session, + ) + ) + + def test_symbols_weights_and_gross_fail_closed(self): + noncanonical = package_body() + noncanonical["target_weights"] = { + "000001.SZ": 0.30, + "600000.XSHG": 0.20, + } + with self.assertRaisesRegex(TargetPackageError, "canonical"): + seal_target_package(noncanonical) + + missing_universe = package_body() + missing_universe["universe"].pop("000001.XSHE") + with self.assertRaisesRegex(TargetPackageError, "absent from universe"): + seal_target_package(missing_universe) + + bad_gross = package_body() + bad_gross["constraints"]["actual_gross_weight"] = 0.49 + with self.assertRaisesRegex(TargetPackageError, "does not equal"): + seal_target_package(bad_gross) + + gross_breach = package_body() + gross_breach["constraints"]["max_gross_weight"] = 0.49 + with self.assertRaisesRegex(TargetPackageError, "max_gross"): + seal_target_package(gross_breach) + + single_breach = package_body() + single_breach["constraints"]["max_single_weight"] = 0.25 + with self.assertRaisesRegex(TargetPackageError, "max_single"): + seal_target_package(single_breach) + + excluded = package_body() + excluded["universe"]["000001.XSHE"] = { + "state": "excluded", + "reasons": ["NOT_PIT_INDEX_MEMBER"], + } + with self.assertRaisesRegex(TargetPackageError, "excluded"): + seal_target_package(excluded) + + def test_tape_supports_multiple_decisions_with_exact_lookup(self): + first = sealed_package() + second = sealed_package( + package_id="TP-20260731-B", + decision_id="D-20260731-B", + signal_as_of="2026-07-31T15:00:00+08:00", + next_session="2026-08-03", + ) + tape = TargetPackageTape([second, first]) + self.assertEqual(len(tape), 2) + found = tape.lookup_exact( + signal_as_of=first["signal_as_of"], + next_session=first["next_session"], + ) + self.assertEqual(found["decision_id"], first["decision_id"]) + found["release"] = "mutated" + self.assertEqual( + tape.lookup_exact( + signal_as_of=first["signal_as_of"], + next_session=first["next_session"], + )["release"], + first["release"], + ) + with self.assertRaises(TargetPackageNotFoundError): + tape.lookup_exact( + signal_as_of="2026-07-30T15:00:00+08:00", + next_session="2026-07-31", + ) + with self.assertRaises(TargetPackageNotFoundError): + tape.lookup_exact( + signal_as_of=first["signal_as_of"], + next_session=first["next_session"], + decision_id="D-wrong", + ) + + def test_duplicate_decision_identity_is_rejected(self): + first = sealed_package() + duplicate_id = sealed_package( + package_id="TP-20260731-B", + decision_id=first["decision_id"], + signal_as_of="2026-07-31T15:00:00+08:00", + next_session="2026-08-03", + ) + with self.assertRaises(DuplicateTargetDecisionError): + TargetPackageTape([first, duplicate_id]) + + duplicate_clock = sealed_package( + package_id="TP-20260724-B", + decision_id="D-20260724-B", + ) + with self.assertRaises(DuplicateTargetDecisionError): + TargetPackageTape([first, duplicate_clock]) + + def test_json_and_jsonl_tapes_load_multiple_decisions(self): + packages = [ + sealed_package(), + sealed_package( + package_id="TP-20260731-B", + decision_id="D-20260731-B", + signal_as_of="2026-07-31T15:00:00+08:00", + next_session="2026-08-03", + ), + ] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + json_path = root / "tape.json" + json_path.write_text( + json.dumps({"packages": packages}), + encoding="utf-8", + ) + jsonl_path = root / "tape.jsonl" + jsonl_path.write_text( + "".join(json.dumps(item) + "\n" for item in packages), + encoding="utf-8", + ) + self.assertEqual(len(load_target_package_tape(json_path)), 2) + self.assertEqual(len(load_target_package_tape(jsonl_path)), 2) + + def test_single_package_loads_and_strict_json_rejects_ambiguity(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + single = root / "single.json" + single.write_text( + json.dumps(sealed_package()), + encoding="utf-8", + ) + self.assertEqual(len(load_target_package_tape(single)), 1) + + duplicate = root / "duplicate.json" + duplicate.write_text( + '{"package_type":"TargetPackageV1",' + '"package_type":"TargetPackageV1"}', + encoding="utf-8", + ) + with self.assertRaisesRegex(TargetPackageError, "duplicate JSON key"): + load_target_package_tape(duplicate) + + non_finite = root / "nan.json" + non_finite.write_text("[NaN]", encoding="utf-8") + with self.assertRaisesRegex(TargetPackageError, "non-finite"): + load_target_package_tape(non_finite) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build_public_status.py b/tools/build_public_status.py index 35ba54f..7da12de 100644 --- a/tools/build_public_status.py +++ b/tools/build_public_status.py @@ -18,10 +18,27 @@ PROJECT = Path(__file__).resolve().parents[1] DEFAULT_OUTPUT = PROJECT / "status" / "status-data.js" GLOBAL_NAME = "QUANT_OS_PUBLIC_STATUS" SCHEMA_VERSION = 1 +REACHABILITY_PATH = ( + "releases/quant60-research-candidate-v1/reachability.json" +) +JOINQUANT_TARGET_PACKAGE_RUN_PATH = ( + "evidence/joinquant/TP-20240311-795dcfba/" + "2457a7c39a276e09e0fabf99e28978e1/run.json" +) +JOINQUANT_TARGET_PACKAGE_DOC_PATH = ( + "docs/JOINQUANT_TARGET_PACKAGE_EVIDENCE_2026-07-26.md" +) +JOINQUANT_TARGET_PACKAGE_RUN_ID = "2457a7c39a276e09e0fabf99e28978e1" +JOINQUANT_TARGET_PACKAGE_OBSERVED_AT = "2026-07-26T08:50:15Z" +JOINQUANT_TARGET_PACKAGE_BUNDLE_SHA256 = ( + "0586930ddc483f4489f09b562f4d4232b458b703e6a1194af809b707211be8c4" +) SOURCE_PATHS: Tuple[str, ...] = ( "gate_scorecard.json", "dist/bundle_manifest.json", + REACHABILITY_PATH, + JOINQUANT_TARGET_PACKAGE_RUN_PATH, "configs/baseline.json", "README.md", "docs/ARCHITECTURE.md", @@ -29,8 +46,10 @@ SOURCE_PATHS: Tuple[str, ...] = ( "docs/PLATFORM_MATRIX.md", "docs/CROSS_ENGINE_CONSISTENCY.md", "docs/JOINQUANT_HOSTED_EVIDENCE_2026-07-26.md", + JOINQUANT_TARGET_PACKAGE_DOC_PATH, "docs/TUSHARE_LOCAL_DATA.md", "runbooks/PLATFORM_DEPLOYMENT.md", + "tools/build_release_reachability.py", "tools/build_public_status.py", ) @@ -245,6 +264,287 @@ def _validate_bundle_manifest( return public_artifacts +def _validate_reachability_manifest( + project: Path, manifest: Mapping[str, Any] +) -> Dict[str, Any]: + source_root = project / "src" + inserted = str(source_root) not in sys.path + if inserted: + sys.path.insert(0, str(source_root)) + try: + from quant60.release_reachability import ( # pylint: disable=import-outside-toplevel + ReleaseReachabilityError, + validate_release_reachability, + ) + + try: + manifest = validate_release_reachability( + manifest, + repository_root=project, + expected_release_id="quant60-research-candidate-v1", + ) + except ReleaseReachabilityError as exc: + raise PublicStatusError( + f"release reachability manifest failed canonical verification: {exc}" + ) from exc + finally: + if inserted: + sys.path.remove(str(source_root)) + + expected_layers = {"universe", "alpha", "portfolio", "risk", "execution"} + expected_capabilities = { + "implemented", + "unit_tested", + "local_entrypoint_reachable", + "jq_entrypoint_reachable", + "qmt_entrypoint_reachable", + "real_platform_observed", + } + declared_sha = str(manifest.get("manifest_sha256", "")) + body = { + key: value + for key, value in manifest.items() + if key != "manifest_sha256" + } + body_bytes = json.dumps( + body, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + actual_sha = _sha256_bytes(body_bytes) + if not re.fullmatch(r"[0-9a-f]{64}", declared_sha) or declared_sha != actual_sha: + raise PublicStatusError("release reachability manifest self-hash is invalid") + + layers = manifest.get("layers") + if not isinstance(layers, dict) or set(layers) != expected_layers: + raise PublicStatusError( + "release reachability manifest must contain the canonical five layers" + ) + calculated_summary: Dict[str, bool] = {} + real_platforms: set[str] = set() + real_platform_observed_layers: set[str] = set() + for capability in sorted(expected_capabilities): + statuses: List[bool] = [] + for layer in sorted(expected_layers): + claims = layers[layer] + if not isinstance(claims, dict) or set(claims) != expected_capabilities: + raise PublicStatusError( + f"release reachability claims are incomplete for {layer}" + ) + claim = claims[capability] + if not isinstance(claim, dict) or not isinstance( + claim.get("status"), bool + ): + raise PublicStatusError( + f"release reachability status is invalid for {layer}.{capability}" + ) + artifacts = claim.get("artifacts") + if not isinstance(artifacts, list): + raise PublicStatusError( + f"release reachability artifacts are invalid for {layer}.{capability}" + ) + status = claim["status"] + if status != bool(artifacts): + raise PublicStatusError( + f"release reachability evidence/status mismatch for " + f"{layer}.{capability}" + ) + for index, artifact in enumerate(artifacts): + if not isinstance(artifact, dict): + raise PublicStatusError( + f"release reachability artifact is invalid for " + f"{layer}.{capability}[{index}]" + ) + path = _safe_repo_path( + artifact.get("path"), + label=f"{layer}.{capability}[{index}].path", + ) + _verify_file_hash(project, path, str(artifact.get("sha256", ""))) + if capability == "real_platform_observed": + real_platforms.add(str(artifact.get("platform", ""))) + real_platform_observed_layers.add(layer) + statuses.append(status) + calculated_summary[f"all_layers_{capability}"] = all(statuses) + + summary = manifest.get("summary") + if not isinstance(summary, dict) or summary != calculated_summary: + raise PublicStatusError( + "release reachability summary does not match per-layer claims" + ) + if ( + real_platforms != {"joinquant"} + or real_platform_observed_layers != {"execution"} + or calculated_summary["all_layers_real_platform_observed"] + ): + raise PublicStatusError( + "current release may claim only JoinQuant execution observation" + ) + release_id = str(manifest.get("release_id", "")) + if release_id != "quant60-research-candidate-v1": + raise PublicStatusError("unexpected release reachability release_id") + return { + "releaseId": release_id, + "manifest": REACHABILITY_PATH, + "manifestSha256": declared_sha, + "summary": calculated_summary, + "realPlatformsObserved": sorted( + platform for platform in real_platforms if platform + ), + "realPlatformObservedLayers": sorted(real_platform_observed_layers), + } + + +def _validate_joinquant_target_package_run( + project: Path, + run: Mapping[str, Any], +) -> Dict[str, Any]: + """Validate the narrow, redacted hosted execution observation.""" + + if run.get("record_type") != "joinquant_target_package_execution_smoke": + raise PublicStatusError("unexpected JoinQuant TargetPackage run type") + if run.get("platform") != "joinquant" or run.get("status") != "completed": + raise PublicStatusError("JoinQuant TargetPackage run is not completed") + if ( + run.get("platform_evidence_class") != "real_platform_runtime" + or run.get("input_evidence_class") != "synthetic-research" + or run.get("performance_claim") is not False + ): + raise PublicStatusError( + "JoinQuant TargetPackage evidence classes are unsafe" + ) + if run.get("run_id") != JOINQUANT_TARGET_PACKAGE_RUN_ID: + raise PublicStatusError("unexpected JoinQuant TargetPackage run identity") + if run.get("observed_at") != JOINQUANT_TARGET_PACKAGE_OBSERVED_AT: + raise PublicStatusError("unexpected JoinQuant TargetPackage observation time") + + backtest = run.get("backtest") + if not isinstance(backtest, dict): + raise PublicStatusError("JoinQuant TargetPackage backtest must be an object") + expected_url = ( + "https://www.joinquant.com/algorithm/backtest/detail?backtestId=" + + JOINQUANT_TARGET_PACKAGE_RUN_ID + ) + expected_backtest = { + "id": JOINQUANT_TARGET_PACKAGE_RUN_ID, + "url": expected_url, + "start_date": "2024-03-11", + "end_date": "2024-03-13", + "initial_capital_cny": 1_000_000, + "frequency": "day", + "python": "Python3", + "benchmark": "000905.XSHG", + "timezone": "Asia/Shanghai", + } + if backtest != expected_backtest: + raise PublicStatusError("JoinQuant TargetPackage settings drifted") + + identity = run.get("artifact_identity") + if not isinstance(identity, dict): + raise PublicStatusError("JoinQuant TargetPackage identity must be an object") + expected_identity = { + "bundle_sha256": JOINQUANT_TARGET_PACKAGE_BUNDLE_SHA256, + "browser_snapshot_sha256": JOINQUANT_TARGET_PACKAGE_BUNDLE_SHA256, + "exact_browser_match": True, + "bundle_manifest_sha256": ( + "91f4b4dc0bdd0d9f99b3466c830600e2678be78dd991d4499a4154bffec8597a" + ), + "portable_core_sha256": ( + "f9de9f18e4008bb0fc80edb0b21cae6ea06a61828d89e9b7c11057f98225fc19" + ), + "wrapper_sha256": ( + "0c672b64665b7c1d4409ba288b00d1608e976800af3a4f9a6467767ce4def592" + ), + } + if identity != expected_identity: + raise PublicStatusError("JoinQuant TargetPackage artifact identity drifted") + + target_package = run.get("target_package") + if not isinstance(target_package, dict): + raise PublicStatusError("JoinQuant TargetPackage identity is missing") + if ( + target_package.get("package_id") != "TP-20240311-795dcfba" + or target_package.get("evidence_class") != "synthetic-research" + or target_package.get("package_sha256") + != "baa15c8156aa6cb54d3775d0cd562613a9d0a4f15b0c39f2a60e0f8b8c6f1f3a" + or target_package.get("tape_sha256") + != "5383c376951637b8d4b1ca42fc6db80347abda74c0181c0f70d6c411fb97ea64" + ): + raise PublicStatusError("JoinQuant TargetPackage lineage drifted") + + sessions = run.get("session_observations") + if not isinstance(sessions, list) or [ + (item.get("date"), item.get("event"), item.get("orders"), item.get("fills")) + for item in sessions + if isinstance(item, dict) + ] != [ + ("2024-03-11", "TARGET_PACKAGE_NOOP", 0, 0), + ("2024-03-12", "TARGET_PACKAGE_HIT", 3, 3), + ("2024-03-13", "TARGET_PACKAGE_NOOP", 0, 0), + ]: + raise PublicStatusError("JoinQuant TargetPackage session evidence drifted") + if run.get("counts") != { + "orders": 3, + "fills": 3, + "skipped_orders": 0, + }: + raise PublicStatusError("JoinQuant TargetPackage order/fill counts drifted") + + boundary = run.get("claim_boundary") + if not isinstance(boundary, dict): + raise PublicStatusError("JoinQuant TargetPackage claim boundary is missing") + required_false = ( + "upstream_layers_real_platform_observed", + "authorized_real_data_oos_observed", + "qmt_peer_observed", + "same_input_cross_engine_parity_observed", + "all_five_layers_real_platform_observed", + "g9_passed", + "baseline_60_passed", + "release_verified", + "investment_value_claim", + ) + if boundary.get("real_platform_observed_layers") != ["execution"] or any( + boundary.get(field) is not False for field in required_false + ): + raise PublicStatusError( + "JoinQuant TargetPackage run overclaims its evidence boundary" + ) + performance = run.get("performance") + if ( + not isinstance(performance, dict) + or performance.get("investment_value_claim") is not False + or performance.get("scope") != "three-session execution wiring smoke only" + ): + raise PublicStatusError( + "JoinQuant TargetPackage performance boundary is unsafe" + ) + + path = _safe_repo_path( + JOINQUANT_TARGET_PACKAGE_RUN_PATH, + label="JoinQuant TargetPackage run", + ) + if not (project / path).is_file(): + raise PublicStatusError("tracked JoinQuant TargetPackage run is missing") + return { + "runId": JOINQUANT_TARGET_PACKAGE_RUN_ID, + "observedAt": JOINQUANT_TARGET_PACKAGE_OBSERVED_AT, + "marketDataFrom": backtest["start_date"], + "marketDataThrough": backtest["end_date"], + "bundleSha256": identity["bundle_sha256"], + "packageId": target_package["package_id"], + "evidenceClass": target_package["evidence_class"], + "orders": 3, + "fills": 3, + "observedLayers": ["execution"], + "allFiveLayersObserved": False, + "qmtPeerObserved": False, + "g9Passed": False, + "baseline60Passed": False, + "investmentValueClaim": False, + } + + def _extract_test_summary(facts: Sequence[Any]) -> Tuple[int | None, int | None]: for fact in facts: text = str(fact) @@ -288,7 +588,13 @@ def _platform_status(name: str) -> str: def _platform_evidence_links(name: str) -> List[str]: links = ["platform-matrix"] if "JoinQuant" in name: - links.append("joinquant-hosted-evidence") + links.extend( + ( + "joinquant-hosted-evidence", + "joinquant-target-package-evidence", + "joinquant-target-package-run", + ) + ) if "Qlib" in name or "Tushare" in name: links.append("tushare-data") if "QMT" in name or "XtTrader" in name: @@ -337,6 +643,12 @@ def _build_evidence(project: Path) -> List[Dict[str, Any]]: "dist/bundle_manifest.json", "machine_record", ), + ( + "release-reachability", + "Five-layer release reachability manifest", + REACHABILITY_PATH, + "machine_record", + ), ("readme", "Quant OS verification guide", "README.md", "documentation"), ( "architecture", @@ -364,10 +676,22 @@ def _build_evidence(project: Path) -> List[Dict[str, Any]]: ), ( "joinquant-hosted-evidence", - "JoinQuant hosted smoke boundary", + "JoinQuant hosted momentum smoke boundary", "docs/JOINQUANT_HOSTED_EVIDENCE_2026-07-26.md", "runtime_evidence", ), + ( + "joinquant-target-package-evidence", + "JoinQuant TargetPackage execution evidence boundary", + JOINQUANT_TARGET_PACKAGE_DOC_PATH, + "runtime_evidence", + ), + ( + "joinquant-target-package-run", + "Redacted JoinQuant TargetPackage hosted run", + JOINQUANT_TARGET_PACKAGE_RUN_PATH, + "machine_record", + ), ( "tushare-data", "Tushare inventory and Qlib bridge record", @@ -403,14 +727,14 @@ def _build_layers() -> List[Dict[str, Any]]: "id": "data", "name": "数据与时点", "status": "tested", - "claim": "不可变快照、PIT 字段合同和 Tushare→Qlib 技术链路已实现并有本地合同/运行证据。", + "claim": "不可变快照、PIT 字段合同和 Tushare→Qlib 技术链路已有本地合同/运行证据;这些 provider-data 路径尚未接成当前 Ridge 五层候选。", "capabilities": [ "JQData raw/factor/limits/paused/PIT membership/ST snapshot", "Tushare completed-Parquet inventory and immutable Qlib provider", "semantic manifest and data-version verification", ], "limitations": [ - "尚无授权 JQData 真实快照与许可 lineage;Tushare 镜像仅部分完成且缺生产关键字段。" + "尚无授权 JQData 真实快照与许可 lineage;Tushare 镜像仅部分完成且缺生产关键字段;当前五层候选仍使用 synthetic 数据。" ], "evidenceLinks": ["architecture", "tushare-data", "scorecard"], "releaseVerified": False, @@ -419,52 +743,65 @@ def _build_layers() -> List[Dict[str, Any]]: "id": "research", "name": "研究与模型", "status": "real_runtime", - "claim": "synthetic research pipeline 已通过测试;Qlib 0.9.7 的 momentum 与 Alpha158/LightGBM fixture 已在真实本地运行时完成。", + "claim": "synthetic 五层研究入口能冻结 train-only Ridge ModelBundle、逐层 trace 与 TargetPackageV1;Qlib momentum 和 Alpha158/LightGBM fixture 是另外的受限研究 smoke。", "capabilities": [ - "PIT features, purged walk-forward and deterministic Ridge", + "train-only preprocessing, purged walk-forward and deterministic Ridge", + "replayable frozen ModelBundle and synthetic TargetPackageV1", "Qlib native momentum", "Qlib Alpha158, LightGBM and Recorder workflow", ], "limitations": [ - "fixture 仅两只 synthetic 股票;没有授权长样本 OOS、冻结模型包或投资价值证据。" + "候选和 Qlib fixture 均是 synthetic;尚无授权长样本 OOS、真实数据校准、平台候选观察或投资价值证据。" + ], + "evidenceLinks": [ + "architecture", + "platform-matrix", + "release-reachability", + "scorecard", ], - "evidenceLinks": ["architecture", "platform-matrix", "scorecard"], "releaseVerified": False, }, { "id": "signal-portfolio", "name": "信号、组合与事前风险", "status": "tested", - "claim": "Signal→Target→Order Delta 合同、成本、风险与约束目标在本地测试路径可重放。", + "claim": "当前 synthetic 主链把 Ridge Alpha、显式成本、60 日对角 realized variance、deterministic optimizer 和 post-risk gate 接成可重放 TargetPackage。", "capabilities": [ - "portable momentum scoring and capped target weights", - "lot-rounded target quantities and T+1 sellable deltas", - "factor risk, dated cost and constrained target research", + "explicit fee/spread/square-root-impact cost forecast", + "diagonal realized-variance risk input and deterministic constrained target", + "single-name, industry, gross, capacity and turnover constraints", + "post-risk weights with hash-linked lineage", ], "limitations": [ - "风险、冲击和容量尚未用授权真实数据校准;G4/G5 均未通过。" + "factor_risk.py 与 optional CVXPY solver 未被当前主链选择;风险、冲击和容量尚未用授权真实数据校准,G4/G5 均未通过。" + ], + "evidenceLinks": [ + "architecture", + "release-reachability", + "scorecard", ], - "evidenceLinks": ["architecture", "scorecard"], "releaseVerified": False, }, { "id": "backtest", "name": "回测与跨引擎", "status": "real_runtime", - "claim": "本地 synthetic、Qlib fixture 与一次真实 JoinQuant hosted smoke 已运行;QMT 仍只有 bundle/harness。", + "claim": "本地 synthetic 五层候选、平台 harness 和一次真实 JoinQuant TargetPackage execution smoke 已运行;QMT 尚无真实 peer run。", "capabilities": [ "local event and snapshot backtests", - "JoinQuant hosted single-file bundle", - "QMT built-in/qmttools backtest-only bundle", + "fail-closed JoinQuant TargetPackage single-file consumer", + "fail-closed QMT built-in TargetPackage backtest consumer", "Qlib research runners", ], "limitations": [ - "没有冻结同输入的本地/聚宽/QMT 逐层对比;真实 QMT 尚未运行,G9 未通过。" + "聚宽只观察到一个 synthetic-research TargetPackage 的消费、账户/开盘价绑定和三笔成交;无真实长样本 OOS、QMT peer 或逐层真实数据 parity,G9 未通过。" ], "evidenceLinks": [ "platform-matrix", - "joinquant-hosted-evidence", + "joinquant-target-package-evidence", + "joinquant-target-package-run", "cross-engine", + "release-reachability", "scorecard", ], "releaseVerified": False, @@ -473,17 +810,23 @@ def _build_layers() -> List[Dict[str, Any]]: "id": "platforms", "name": "平台适配", "status": "real_runtime", - "claim": "Local、Colab、JoinQuant、Qlib 有可执行入口;JoinQuant/Qlib 有受限真实运行时 smoke。", + "claim": "Local、Colab、JoinQuant、QMT、Qlib 有入口;JoinQuant 已真实观察 synthetic-research TargetPackage 的 execution consumer,QMT 候选仍未实跑。", "capabilities": [ "local and Colab zero-account smoke", "JQData snapshot entrypoint", - "JoinQuant upload bundle", - "QMT built-in and qmttools entrypoints", + "JoinQuant momentum or explicit TargetPackage upload bundle", + "QMT built-in TargetPackage and qmttools entrypoints", ], "limitations": [ - "JQData 真实账号、QMT 客户端/数据权限及平台完整导出仍是外部前置条件。" + "JoinQuant 没有重跑上游四层;JQData 授权真实候选、QMT 客户端/数据权限、QMT peer 导出与逐层真实数据 parity 仍是外部前置条件。" + ], + "evidenceLinks": [ + "platform-matrix", + "joinquant-target-package-evidence", + "joinquant-target-package-run", + "platform-deployment", + "release-reachability", ], - "evidenceLinks": ["platform-matrix", "platform-deployment"], "releaseVerified": False, }, { @@ -526,11 +869,11 @@ def _build_models() -> List[Dict[str, Any]]: { "id": "portable-momentum-v1", "name": "portable-momentum-v1", - "role": "跨引擎冻结计算基线", + "role": "平台连通性 smoke 策略", "status": "real_runtime", - "claim": "共享 symbol normalization、momentum、target weight/quantity 与 order-delta 计算;已进入 JoinQuant hosted smoke 和本地 Qlib momentum 路径。", + "claim": "共享 symbol normalization、momentum、target weight/quantity 与 order-delta 计算;2024 JoinQuant hosted run 只证明这一 smoke 路径。", "limitations": [ - "尚无同输入 L1–L4 真实跨引擎通过证据,不是已验证的生产 champion。" + "不是 Ridge/TargetPackage Baseline 候选;旧 hosted run 不得用于候选或 G9 计分。" ], "evidenceLinks": [ "architecture", @@ -543,13 +886,18 @@ def _build_models() -> List[Dict[str, Any]]: { "id": "ridge-challenger", "name": "Deterministic Ridge", - "role": "synthetic 研究候选", + "role": "synthetic 五层 Baseline 研究候选", "status": "tested", - "claim": "purged walk-forward research pipeline 中可确定性训练和评估。", + "claim": "train-only 预处理、系数、截距、训练截止和 lineage 可冻结为可重放 ModelBundle;其 synthetic TargetPackageV1 已被真实 JoinQuant execution consumer 消费并成交。", "limitations": [ - "只在 synthetic 研究路径验证;无真实 OOS、versioned model bundle 或跨平台推理验证。" + "JoinQuant 只观察执行边界,未重跑上游四层;无授权真实 OOS、真实校准、QMT peer 或投资价值证据。" + ], + "evidenceLinks": [ + "architecture", + "platform-matrix", + "joinquant-target-package-evidence", + "joinquant-target-package-run", ], - "evidenceLinks": ["architecture", "platform-matrix"], "investmentValueClaim": False, "releaseVerified": False, }, @@ -570,9 +918,15 @@ def _build_models() -> List[Dict[str, Any]]: def _build_next_actions(scorecard: Mapping[str, Any]) -> List[Dict[str, Any]]: - missing = scorecard.get("current_delivery", {}).get( - "external_evidence_missing", [] - ) + delivery = scorecard.get("current_delivery", {}) + if not isinstance(delivery, dict): + raise PublicStatusError("scorecard current_delivery must be an object") + internal = delivery.get("internal_delivery_gaps", []) + missing = delivery.get("external_evidence_missing", []) + if not isinstance(internal, list) or len(internal) < 3: + raise PublicStatusError( + "scorecard must list the internal delivery gaps blocking Baseline" + ) if not isinstance(missing, list) or len(missing) < 5: raise PublicStatusError( "scorecard must list the external evidence blocking release" @@ -580,30 +934,36 @@ def _build_next_actions(scorecard: Mapping[str, Any]) -> List[Dict[str, Any]]: return [ { "priority": 1, - "title": "生成授权 JQData 不可变真实快照", - "why": str(missing[0]), - "gateIds": ["G1", "G3", "G7"], + "title": "接通真实数据五层候选", + "why": f"{internal[0]} External prerequisite: {missing[0]}", + "gateIds": ["G1", "G2", "G3", "G5", "G7"], }, { "priority": 2, - "title": "冻结聚宽同输入并完成逐层本地比较", - "why": str(missing[1]), - "gateIds": ["G3", "G9"], + "title": "冻结主链风险、优化与执行选择", + "why": str(internal[1]), + "gateIds": ["G3", "G5", "G7", "G9"], }, { "priority": 3, - "title": "取得真实 QMT built-in 与 qmttools 导出", - "why": str(missing[2]), - "gateIds": ["G3", "G6", "G9"], + "title": "扩展 JoinQuant 多期对账与逐层 parity", + "why": f"{internal[2]} Required evidence: {missing[1]}", + "gateIds": ["G3", "G7", "G9"], }, { "priority": 4, + "title": "运行真实 QMT TargetPackage 候选", + "why": str(missing[2]), + "gateIds": ["G6", "G8", "G9"], + }, + { + "priority": 5, "title": "完成 QMT 券商合同探针与至少 20 日影子盘", "why": str(missing[3]), "gateIds": ["G6", "G8", "G9"], }, { - "priority": 5, + "priority": 6, "title": "完成连续对账、恢复演练与券商合规确认", "why": str(missing[4]), "gateIds": ["G8", "G10"], @@ -615,6 +975,7 @@ def _build_freshness( *, scorecard: Mapping[str, Any], joinquant_markdown: str, + joinquant_target_package_run: Mapping[str, Any], tushare_markdown: str, ) -> List[Dict[str, Any]]: period_match = re.search( @@ -686,15 +1047,44 @@ def _build_freshness( }, { "id": "joinquant-hosted-market-window", - "label": "JoinQuant hosted smoke 行情区间", + "label": "JoinQuant hosted momentum smoke 行情区间", "status": "runtime_smoke", "marketDataFrom": joinquant_start, "marketDataThrough": joinquant_end, "value": f"{joinquant_start} — {joinquant_end}", "productionReady": False, - "semantics": "单次真实 hosted runtime smoke 的回测区间;不代表同输入跨引擎通过。", + "semantics": "旧 momentum hosted runtime smoke 的回测区间;不是 TargetPackage 候选或同输入跨引擎证据。", "evidenceLinks": ["joinquant-hosted-evidence", "scorecard"], }, + { + "id": "joinquant-target-package-execution-smoke", + "label": "JoinQuant TargetPackage execution smoke", + "status": "runtime_smoke", + "observedAt": str(joinquant_target_package_run["observedAt"]), + "marketDataFrom": str( + joinquant_target_package_run["marketDataFrom"] + ), + "marketDataThrough": str( + joinquant_target_package_run["marketDataThrough"] + ), + "value": ( + f"{joinquant_target_package_run['marketDataFrom']} — " + f"{joinquant_target_package_run['marketDataThrough']}; " + "3 orders / 3 fills" + ), + "productionReady": False, + "investmentValueClaim": False, + "semantics": ( + "真实平台只观察 synthetic-research TargetPackage 的 execution " + "consumer;不代表上游四层真实平台执行、长样本 OOS 或 G9。" + ), + "evidenceLinks": [ + "joinquant-target-package-evidence", + "joinquant-target-package-run", + "release-reachability", + "scorecard", + ], + }, { "id": "tushare-daily-market-data", "label": "Tushare daily 行情覆盖", @@ -806,6 +1196,10 @@ def build_public_status( project = project.resolve() scorecard = _load_json(project / "gate_scorecard.json") manifest = _load_json(project / "dist" / "bundle_manifest.json") + reachability_manifest = _load_json(project / REACHABILITY_PATH) + joinquant_target_package_run_raw = _load_json( + project / JOINQUANT_TARGET_PACKAGE_RUN_PATH + ) baseline = _load_json(project / "configs" / "baseline.json") platform_markdown = (project / "docs" / "PLATFORM_MATRIX.md").read_text( encoding="utf-8" @@ -870,6 +1264,13 @@ def build_public_status( test_count, optional_skips = _extract_test_summary(facts) artifacts = _validate_bundle_manifest(project, manifest) + reachability = _validate_reachability_manifest( + project, reachability_manifest + ) + joinquant_target_package_run = _validate_joinquant_target_package_run( + project, + joinquant_target_package_run_raw, + ) source_records: List[Dict[str, str]] = [] for relative in SOURCE_PATHS: safe_path = _safe_repo_path(relative, label="provenance source") @@ -883,6 +1284,19 @@ def build_public_status( claim_policy = scorecard["claim_policy"] delivery = scorecard["current_delivery"] + internal_delivery_gaps = delivery.get("internal_delivery_gaps", []) + external_evidence_missing = delivery.get("external_evidence_missing", []) + if not isinstance(internal_delivery_gaps, list) or len(internal_delivery_gaps) < 3: + raise PublicStatusError( + "scorecard must list the internal delivery gaps blocking Baseline" + ) + if ( + not isinstance(external_evidence_missing, list) + or len(external_evidence_missing) < 5 + ): + raise PublicStatusError( + "scorecard must list the external evidence blocking release" + ) payload: Dict[str, Any] = { "schemaVersion": SCHEMA_VERSION, "generatedAt": generated_at_value, @@ -905,8 +1319,20 @@ def build_public_status( "testsRecorded": test_count, "optionalSkipsRecorded": optional_skips, "testEvidenceQualifier": "Scorecard-recorded run; tests do not prove real-platform or broker gates.", + "candidateRealPlatformObserved": bool( + reachability["realPlatformsObserved"] + ), + "candidateRealPlatformObservationScope": ( + "target_package_execution_consumer_only" + ), + "allFiveLayersRealPlatformObserved": bool( + reachability["summary"][ + "all_layers_real_platform_observed" + ] + ), "realRuntimeSmokes": [ - "JoinQuant hosted daily backtest (runtime/path only)", + "JoinQuant TargetPackage execution consumer: one synthetic-research package, 3 orders / 3 fills (not Baseline/G9 evidence)", + "JoinQuant portable_momentum_smoke hosted daily backtest (not Baseline/TargetPackage evidence)", "Qlib native momentum fixture", "Qlib Alpha158 + LightGBM fixture", "Tushare completed-partition Qlib momentum smoke", @@ -915,12 +1341,45 @@ def build_public_status( "productionReady": False, "investmentValueClaim": False, }, + "candidateBoundary": { + "decisionMode": "target_package", + "localEvidenceClass": "synthetic_research", + "connectedRiskModel": "60-session diagonal realized variance", + "connectedOptimizer": "deterministic constrained optimizer", + "connectedExecution": "reference one-shot quantity/order-delta planner", + "realJoinQuantObserved": ( + "joinquant" in reachability["realPlatformsObserved"] + ), + "realQmtObserved": ( + "qmt" in reachability["realPlatformsObserved"] + ), + "realPlatformObservedLayers": reachability[ + "realPlatformObservedLayers" + ], + "allFiveLayersRealPlatformObserved": bool( + reachability["summary"][ + "all_layers_real_platform_observed" + ] + ), + "joinQuantTargetPackageRun": joinquant_target_package_run, + "joinQuantTargetPackageCountsAsBaseline": False, + "joinQuantTargetPackageCountsAsG9": False, + "historicalJoinQuantMode": "portable_momentum_smoke", + "historicalJoinQuantCountsAsBaseline": False, + "reachability": reachability, + "releaseVerified": False, + }, + "deliveryGaps": { + "internal": [str(item) for item in internal_delivery_gaps], + "externalEvidence": [str(item) for item in external_evidence_missing], + }, "layers": _build_layers(), "models": _build_models(), "platforms": _build_platforms(platform_markdown), "freshness": _build_freshness( scorecard=scorecard, joinquant_markdown=joinquant_markdown, + joinquant_target_package_run=joinquant_target_package_run, tushare_markdown=tushare_markdown, ), "gates": gates, @@ -954,6 +1413,7 @@ def build_public_status( "provenance": { "scorecard": "gate_scorecard.json", "bundleManifest": "dist/bundle_manifest.json", + "releaseReachability": REACHABILITY_PATH, "platformMatrix": "docs/PLATFORM_MATRIX.md", "baselineConfig": "configs/baseline.json", "sourceSetSha256": source_set_sha, diff --git a/tools/build_release_reachability.py b/tools/build_release_reachability.py new file mode 100644 index 0000000..38ab49d --- /dev/null +++ b/tools/build_release_reachability.py @@ -0,0 +1,308 @@ +"""Build the conservative Quant60 research-candidate reachability manifest. + +The hosted-platform claims in this manifest have deliberately narrow +semantics. For Universe, Alpha, Portfolio, and Risk, ``*_entrypoint_reachable`` +means that the locally computed output and its lineage reach the hosted +consumer through a verified ``TargetPackageV1``. It does *not* mean that +JoinQuant or QMT execute those upstream Python implementations. Execution is +the only layer implemented inside the hosted wrappers. + +One real JoinQuant TargetPackage smoke observes the execution consumer, +account/price binding, orders, and fills. It therefore supports only +``execution.real_platform_observed``. The other four layer observations and +the derived all-layer observation remain false. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + + +PROJECT = Path(__file__).resolve().parents[1] +SOURCE_ROOT = PROJECT / "src" +if str(SOURCE_ROOT) not in sys.path: + sys.path.insert(0, str(SOURCE_ROOT)) + +from quant60.ledger import canonical_json # noqa: E402 +from quant60.release_reachability import ( # noqa: E402 + LAYERS, + build_evidence_artifact, + build_release_reachability, + load_verified_release_reachability, + write_release_reachability, +) + + +RELEASE_ID = "quant60-research-candidate-v1" +RELEASE_DECLARED_AT = "2026-07-26T12:00:00Z" +DEFAULT_OUTPUT = ( + PROJECT / "releases" / RELEASE_ID / "reachability.json" +) +JOINQUANT_TARGET_PACKAGE_RUN_PATH = ( + "evidence/joinquant/TP-20240311-795dcfba/" + "2457a7c39a276e09e0fabf99e28978e1/run.json" +) +JOINQUANT_TARGET_PACKAGE_RUN_ID = "2457a7c39a276e09e0fabf99e28978e1" +JOINQUANT_TARGET_PACKAGE_OBSERVED_AT = "2026-07-26T08:50:15Z" +PLATFORM_CLAIM_NOTE = ( + "Upstream layer status means its locally computed output/lineage is " + "carried through TargetPackageV1 to the hosted consumer; it does not " + "claim JoinQuant/QMT executes the upstream Python. Execution alone runs " + "inside the hosted wrapper. One JoinQuant smoke observes only that " + "execution boundary; it does not observe all five layers." +) + + +# Each path names code used by the connected local five-layer decision path. +# A shared composition module appears in several claims by design; artifact IDs +# remain unique, as required by the reachability contract. +IMPLEMENTED_EVIDENCE: dict[str, tuple[tuple[str, str], ...]] = { + "universe": ( + ("baseline-pipeline", "src/quant60/baseline_pipeline.py"), + ), + "alpha": ( + ("frozen-model-bundle", "src/quant60/model_bundle.py"), + ("baseline-pipeline", "src/quant60/baseline_pipeline.py"), + ), + "portfolio": ( + ("optimizer", "src/quant60/optimizer.py"), + ("baseline-pipeline", "src/quant60/baseline_pipeline.py"), + ), + "risk": ( + ("risk-gate", "src/quant60/risk.py"), + ("baseline-pipeline", "src/quant60/baseline_pipeline.py"), + ), + "execution": ( + ("portable-execution-core", "src/quant60/portable_core.py"), + ("baseline-pipeline", "src/quant60/baseline_pipeline.py"), + ), +} + +UNIT_TEST_EVIDENCE: dict[str, tuple[tuple[str, str], ...]] = { + "universe": ( + ("five-layer-causality", "tests/test_baseline_pipeline.py"), + ), + "alpha": ( + ("five-layer-causality", "tests/test_baseline_pipeline.py"), + ("frozen-model-replay", "tests/test_model_bundle.py"), + ), + "portfolio": ( + ("five-layer-causality", "tests/test_baseline_pipeline.py"), + ), + "risk": ( + ("five-layer-causality", "tests/test_baseline_pipeline.py"), + ), + "execution": ( + ("five-layer-causality", "tests/test_baseline_pipeline.py"), + ("portable-order-plan", "tests/test_portable_core.py"), + ), +} + +LOCAL_ENTRYPOINT_EVIDENCE: tuple[tuple[str, str], ...] = ( + ("research-smoke-cli", "src/quant60/cli.py"), + ("research-experiment", "src/quant60/experiment.py"), +) + +PLATFORM_ENTRYPOINTS = { + "jq": { + "capability": "jq_entrypoint_reachable", + "platform": "joinquant", + "path": "platforms/joinquant_strategy.py", + }, + "qmt": { + "capability": "qmt_entrypoint_reachable", + "platform": "qmt", + "path": "platforms/qmt_builtin_strategy.py", + }, +} + +# These labels are part of the signed artifact IDs. They keep the narrow +# meaning visible even though the v1 schema intentionally has no free-text +# claim field. +PLATFORM_OUTPUT_LABELS = { + "universe": "universe-state-via-targetpackagev1-consumer", + "alpha": "alpha-lineage-derived-target-via-targetpackagev1-consumer", + "portfolio": "portfolio-weight-via-targetpackagev1-consumer", + "risk": "post-risk-weight-constraints-via-targetpackagev1-consumer", + "execution": "targetpackagev1-hosted-execution-entrypoint", +} + + +def _false_claim() -> dict[str, Any]: + return {"status": False, "artifacts": []} + + +def _evidence( + *, + repository_root: Path, + artifact_id: str, + kind: str, + path: str, + platform: str, + observed_at: str | None = None, + run_id: str | None = None, +) -> dict[str, Any]: + return build_evidence_artifact( + artifact_id=artifact_id, + kind=kind, + path=path, + platform=platform, + repository_root=repository_root, + observed_at=observed_at, + run_id=run_id, + ) + + +def build_manifest( + *, + repository_root: str | Path = PROJECT, + generated_at: str = RELEASE_DECLARED_AT, +) -> dict[str, Any]: + """Build a fully content-addressed manifest from the current repository.""" + + root = Path(repository_root) + layers: dict[str, dict[str, Any]] = {} + for layer in LAYERS: + implemented = [ + _evidence( + repository_root=root, + artifact_id=f"{layer}-implemented-{label}", + kind="source", + path=path, + platform="repository", + ) + for label, path in IMPLEMENTED_EVIDENCE[layer] + ] + unit_tested = [ + _evidence( + repository_root=root, + artifact_id=f"{layer}-unit-tested-{label}", + kind="test", + path=path, + platform="repository", + ) + for label, path in UNIT_TEST_EVIDENCE[layer] + ] + local_entrypoint = [ + _evidence( + repository_root=root, + artifact_id=f"{layer}-local-{label}", + kind="entrypoint", + path=path, + platform="local", + ) + for label, path in LOCAL_ENTRYPOINT_EVIDENCE + ] + claims: dict[str, Any] = { + "implemented": {"status": True, "artifacts": implemented}, + "unit_tested": {"status": True, "artifacts": unit_tested}, + "local_entrypoint_reachable": { + "status": True, + "artifacts": local_entrypoint, + }, + "real_platform_observed": _false_claim(), + } + if layer == "execution": + claims["real_platform_observed"] = { + "status": True, + "artifacts": [ + _evidence( + repository_root=root, + artifact_id=( + "execution-real-platform-targetpackagev1-smoke-jq" + ), + kind="platform_run", + path=JOINQUANT_TARGET_PACKAGE_RUN_PATH, + platform="joinquant", + observed_at=JOINQUANT_TARGET_PACKAGE_OBSERVED_AT, + run_id=JOINQUANT_TARGET_PACKAGE_RUN_ID, + ) + ], + } + for name, declaration in PLATFORM_ENTRYPOINTS.items(): + capability = declaration["capability"] + claims[capability] = { + "status": True, + "artifacts": [ + _evidence( + repository_root=root, + artifact_id=( + f"{layer}-{PLATFORM_OUTPUT_LABELS[layer]}-{name}" + ), + kind="entrypoint", + path=declaration["path"], + platform=declaration["platform"], + ) + ], + } + layers[layer] = claims + + return build_release_reachability( + release_id=RELEASE_ID, + generated_at=generated_at, + layers=layers, + repository_root=root, + ) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help="manifest path (default: releases//reachability.json)", + ) + parser.add_argument( + "--generated-at", + default=RELEASE_DECLARED_AT, + help="canonical UTC release declaration time", + ) + parser.add_argument( + "--check", + action="store_true", + help="verify that the existing manifest exactly matches regeneration", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + expected = build_manifest(generated_at=args.generated_at) + output = args.output.expanduser().resolve() + if args.check: + observed = load_verified_release_reachability( + output, + repository_root=PROJECT, + expected_release_id=RELEASE_ID, + expected_manifest_sha256=expected["manifest_sha256"], + ) + expected_bytes = (canonical_json(expected) + "\n").encode("utf-8") + if observed != expected or output.read_bytes() != expected_bytes: + raise ValueError( + "reachability manifest does not exactly match regeneration" + ) + else: + write_release_reachability( + expected, + output, + repository_root=PROJECT, + ) + summary = { + "ok": True, + "checked": bool(args.check), + "output": str(output), + "release_id": RELEASE_ID, + "manifest_sha256": expected["manifest_sha256"], + "platform_claim_semantics": PLATFORM_CLAIM_NOTE, + } + print(json.dumps(summary, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bundle_platforms.py b/tools/bundle_platforms.py index e875a81..0d40cf7 100644 --- a/tools/bundle_platforms.py +++ b/tools/bundle_platforms.py @@ -7,14 +7,33 @@ import ast import hashlib import json import pprint +import sys from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional START = "# __PORTABLE_CORE_BUNDLE_START__" END = "# __PORTABLE_CORE_BUNDLE_END__" CONFIG_START = "# __BASELINE_CONFIG_START__" CONFIG_END = "# __BASELINE_CONFIG_END__" +TARGET_START = "# __TARGET_PACKAGE_TAPE_START__" +TARGET_END = "# __TARGET_PACKAGE_TAPE_END__" +MOMENTUM_SMOKE_MODE = "portable_momentum_smoke" +TARGET_PACKAGE_MODE = "target_package" +JOINQUANT_EVIDENCE_PREFIX = "QUANT60_JOINQUANT_EVIDENCE_V1 " +JOINQUANT_EVIDENCE_CHUNK_CHARACTERS = 1800 +JOINQUANT_TARGET_PRICE_SOURCE = "CURRENT_DATA_DAY_OPEN" +JOINQUANT_EVIDENCE_EVENTS = [ + "INIT", + "TARGET_PACKAGE_NOOP", + "TARGET_PACKAGE_HIT", + "PLATFORM_BINDING_INPUT", + "TARGET_PACKAGE_PLAN", + "SKIPPED_ORDER", + "ORDER_REQUEST", + "ORDER_RETURN", + "EOD_STATUS", +] def sha256_bytes(value: bytes) -> str: @@ -56,6 +75,73 @@ def inline_core(wrapper_source: str, core_source: str) -> str: return wrapper_source[:start] + replacement + wrapper_source[end:] +def _canonical_target_tape_bytes( + target_packages: List[Dict[str, Any]], +) -> bytes: + return json.dumps( + target_packages, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def inline_target_packages( + wrapper_source: str, + target_packages: List[Dict[str, Any]], +) -> str: + """Inline a pre-validated, quantity-free decision tape.""" + + if ( + wrapper_source.count(TARGET_START) != 1 + or wrapper_source.count(TARGET_END) != 1 + ): + raise ValueError( + "wrapper must contain exactly one target-package marker pair" + ) + start = wrapper_source.index(TARGET_START) + end = wrapper_source.index(TARGET_END, start) + len(TARGET_END) + body = pprint.pformat( + target_packages, + width=88, + sort_dicts=False, + ) + body = body.encode("ascii", "backslashreplace").decode("ascii") + replacement = ( + TARGET_START + + "\n# Generated by tools/bundle_platforms.py; do not edit this region.\n" + + "TARGET_PACKAGES = " + + body + + "\n" + + TARGET_END + ) + return wrapper_source[:start] + replacement + wrapper_source[end:] + + +def load_verified_target_packages( + project_root: Path, + path: Path, +) -> List[Dict[str, Any]]: + """Load one package or a multi-decision tape through the canonical verifier.""" + + root = project_root.expanduser().resolve() + source = path.expanduser().resolve() + src_path = str(root / "src") + inserted = src_path not in sys.path + if inserted: + sys.path.insert(0, src_path) + try: + from quant60.target_package import ( # pylint: disable=import-outside-toplevel + load_target_package_tape, + ) + + return list(load_target_package_tape(source)) + finally: + if inserted: + sys.path.remove(src_path) + + def _qmt_symbol(symbol: str) -> str: suffixes = { ".XSHG": ".SH", @@ -71,20 +157,52 @@ def _qmt_symbol(symbol: str) -> str: def effective_platform_config( baseline: Dict[str, Any], platform: str, + *, + decision_mode: str = MOMENTUM_SMOKE_MODE, + target_packages: Optional[List[Dict[str, Any]]] = None, ) -> Dict[str, Any]: + packages = list(target_packages or []) + if decision_mode not in {MOMENTUM_SMOKE_MODE, TARGET_PACKAGE_MODE}: + raise ValueError(f"unsupported decision_mode: {decision_mode}") + if decision_mode == TARGET_PACKAGE_MODE and not packages: + raise ValueError("target_package mode requires a non-empty package tape") + if decision_mode == MOMENTUM_SMOKE_MODE and packages: + raise ValueError("momentum smoke mode cannot embed target packages") strategy = baseline["strategy"] execution = baseline["execution"] fees = baseline["fees"] universe_config = baseline["universe"] - symbols = [str(symbol) for symbol in baseline["symbols"]] + symbols = ( + sorted( + { + str(symbol) + for package in packages + for symbol in package["universe"] + } + ) + if packages + else [str(symbol) for symbol in baseline["symbols"]] + ) if platform == "qmt_builtin": universe = [_qmt_symbol(symbol) for symbol in symbols] elif platform == "joinquant": universe = symbols else: raise ValueError(f"unsupported platform config: {platform}") + tape_sha256 = ( + sha256_bytes(_canonical_target_tape_bytes(packages)) + if packages + else None + ) common: Dict[str, Any] = { - "universe_mode": str(universe_config["mode"]), + "decision_mode": decision_mode, + "target_package_count": len(packages), + "target_package_tape_sha256": tape_sha256, + "universe_mode": ( + "fixed" + if decision_mode == TARGET_PACKAGE_MODE + else str(universe_config["mode"]) + ), "index_symbol": str(universe_config["index_symbol"]), "qmt_sector_name": str(universe_config["qmt_sector_name"]), "dedicated_account_required": bool( @@ -145,15 +263,19 @@ def bundle_one( wrapper_path: Path, output_path: Path, effective_config: Dict[str, Any], + target_packages: Optional[List[Dict[str, Any]]] = None, require_ascii: bool = False, ) -> Dict[str, str]: core_bytes = core_path.read_bytes() wrapper_bytes = wrapper_path.read_bytes() core_source = core_bytes.decode("utf-8") wrapper_source = wrapper_bytes.decode("utf-8") - bundled = inline_config( - inline_core(wrapper_source, core_source), - effective_config, + bundled = inline_target_packages( + inline_config( + inline_core(wrapper_source, core_source), + effective_config, + ), + list(target_packages or []), ) output_bytes = bundled.encode("utf-8") if require_ascii: @@ -194,6 +316,7 @@ def bundle_one( def bundle_all( project_root: Path, output_dir: Optional[Path] = None, + target_package_path: Optional[Path] = None, ) -> Dict[str, object]: root = project_root.expanduser().resolve() output = ( @@ -209,43 +332,90 @@ def bundle_all( baseline = json.loads(baseline_bytes.decode("utf-8")) if not isinstance(baseline, dict): raise ValueError("configs/baseline.json must contain an object") + target_packages = ( + load_verified_target_packages(root, target_package_path) + if target_package_path is not None + else [] + ) + decision_mode = ( + TARGET_PACKAGE_MODE if target_packages else MOMENTUM_SMOKE_MODE + ) entries = { "joinquant": bundle_one( core_path=core, wrapper_path=root / "platforms" / "joinquant_strategy.py", output_path=output / "joinquant_strategy.py", - effective_config=effective_platform_config(baseline, "joinquant"), + effective_config=effective_platform_config( + baseline, + "joinquant", + decision_mode=decision_mode, + target_packages=target_packages, + ), + target_packages=target_packages, ), "qmt_builtin": bundle_one( core_path=core, wrapper_path=root / "platforms" / "qmt_builtin_strategy.py", output_path=output / "qmt_builtin_strategy.py", - effective_config=effective_platform_config(baseline, "qmt_builtin"), + effective_config=effective_platform_config( + baseline, + "qmt_builtin", + decision_mode=decision_mode, + target_packages=target_packages, + ), + target_packages=target_packages, require_ascii=True, ), } - # Paths in a committed manifest must be portable and must not expose the - # local account/home directory. Hashes remain byte-for-byte identities. - logical_outputs = { + # Paths in a manifest must be portable and must not expose the local + # account/home directory. Preserve a real project-relative location for + # outputs under the project (for example dist/target-package/...). A + # clean-room output outside the project keeps the committed logical names + # so its manifest remains byte-for-byte comparable with committed dist. + default_logical_outputs = { "joinquant": "dist/joinquant_strategy.py", "qmt_builtin": "dist/qmt_builtin_strategy.py", } for platform_name, entry in entries.items(): + physical_output = Path(entry["output"]) entry["core"] = "src/quant60/portable_core.py" entry["wrapper"] = ( "platforms/joinquant_strategy.py" if "joinquant" in entry["wrapper"] else "platforms/qmt_builtin_strategy.py" ) - # The manifest describes the committed logical artifact, not the - # caller's temporary build directory. This keeps clean-room builds - # byte-for-byte comparable with committed ``dist``. - entry["output"] = logical_outputs[platform_name] + try: + entry["output"] = physical_output.relative_to(root).as_posix() + except ValueError: + entry["output"] = default_logical_outputs[platform_name] manifest = { "schema_version": 1, "baseline_config": "configs/baseline.json", "baseline_config_sha256": sha256_bytes(baseline_bytes), "portable_core_sha256": entries["joinquant"]["core_sha256"], + "decision_mode": decision_mode, + "target_package_count": len(target_packages), + "target_package_tape_sha256": ( + sha256_bytes(_canonical_target_tape_bytes(target_packages)) + if target_packages + else None + ), + "target_package_ids": [ + package["package_id"] for package in target_packages + ], + "target_package_sha256": [ + package["package_sha256"] for package in target_packages + ], + "joinquant_observation": { + "eod_chunk_characters": JOINQUANT_EVIDENCE_CHUNK_CHARACTERS, + "eod_chunk_encoding": "base64-canonical-json", + "eod_api_policy": "best_effort_get_orders_get_trades", + "events": JOINQUANT_EVIDENCE_EVENTS, + "fail_soft": True, + "prefix": JOINQUANT_EVIDENCE_PREFIX, + "schema_version": "1.0", + "target_price_source": JOINQUANT_TARGET_PRICE_SOURCE, + }, "artifacts": entries, } manifest_path = output / "bundle_manifest.json" @@ -266,8 +436,20 @@ def _main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--project-root", type=Path, default=default_root) parser.add_argument("--output-dir", type=Path) + parser.add_argument( + "--target-package", + type=Path, + help=( + "verified TargetPackageV1 JSON/JSONL tape; switches both hosted " + "artifacts from momentum smoke to target-package execution" + ), + ) args = parser.parse_args(argv) - manifest = bundle_all(args.project_root, args.output_dir) + manifest = bundle_all( + args.project_root, + args.output_dir, + args.target_package, + ) print(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True)) return 0