diff --git a/.agents/skills/run-local-quant-research/SKILL.md b/.agents/skills/run-local-quant-research/SKILL.md index c32d9a8..156e098 100644 --- a/.agents/skills/run-local-quant-research/SKILL.md +++ b/.agents/skills/run-local-quant-research/SKILL.md @@ -5,32 +5,38 @@ description: Use when 用户或 Agent 需要基于仓库项目配置与共享行 # 本地量化研究流程 -只编排一次通用本地研究调用。共享脚本负责校验配置、行情身份、单场景结果和摘要;Skill(技能)不解释策略字段,不复制项目逻辑,不读取复数分析计划。 +只编排一次通用本地研究调用。共享脚本负责校验配置、行情身份、单场景结果和摘要;Skill(技能)不解释策略字段,不复制项目逻辑,不读取复数分析计划。输出固定为唯一 archive-ready(可归档)结果包,不由配置声明,无需二次重算或改写。 ## 执行前提 - 从仓库根目录运行项目 `.venv`(虚拟环境),不回退系统 Python(编程语言),不静默安装依赖。 -- 配置必须声明项目入口、共享 `snapshot_id`(快照标识)、一个场景、仓库内路径和一份必需结果。 +- 配置必须声明共享 `snapshot_id`(快照标识)、一个场景和仓库内路径。 - 配置或快照缺失时停止,不猜测路径、不改用旧快照、不缩减资产范围。 ## 固定流程 1. 校验行情快照:只读取配置声明的 `snapshot_id`,验证共享行情中心的 `market-data.parquet`、来源、范围、字段、价格口径和内容摘要;查询只使用内存 DuckDB(嵌入式分析数据库),不创建持久数据库。 -2. 校验单场景配置:拒绝候选数组、场景数组、分析计划和流程内循环,只接受一个项目场景及其必需输出声明。 -3. 运行项目入口:由通用 CLI(命令行接口)调用配置声明的项目进程;项目自行解释策略参数,并在同一全新进程内分别完成一次冷启动和一次预热执行。 +2. 校验单场景配置:拒绝候选数组、场景数组、分析计划和流程内循环,只接受一个项目场景。 +3. 运行共享场景:由通用 CLI(命令行接口)生成私有子进程协议;共享运行时在同一全新进程内分别完成一次冷启动和一次预热执行。 4. 校验单场景结果:重新读取唯一结果目录,核对状态、运行身份、输入摘要、性能证据、临时产物清理和输出摘要。 5. 固化运行证据:通过后原子写入不可变清单;相同输入和相同输出复用既有运行,摘要冲突则失败。 -6. 返回调用者:完整运行固定输出 `next_action=return_to_caller`。复数场景由主 agent(代理)多次调用本 Skill,不在 Skill 内聚合、分析、报告或推荐。 +6. 可选晋升:公开 `promote` 只把完整结果包逐字节复制到 `joinquant/strategies//research/archives//`;它不重算结果,也不写入聚宽正式 `backtests/` 或 `simulations/`。 +7. 返回调用者:完整运行固定输出 `next_action=return_to_caller`。复数场景由主 agent(代理)多次调用本 Skill,不在 Skill 内聚合、分析、报告或推荐。结果包内报告只复述可复核的机械执行事实,不承担策略分析、推荐或人工批准。 统一公开命令: ```powershell .\.venv\Scripts\python.exe scripts\research\local_quant_research\cli.py run --config + +.\.venv\Scripts\python.exe scripts\research\local_quant_research\cli.py promote ` + --strategy-id ` + --run-id ` + --analysis-id ``` ## 结果判断 -- `complete`(完整):配置、快照、项目进程、单场景性能门禁、唯一必需结果和证据摘要全部通过,然后返回调用者。 +- `complete`(完整):配置、快照、项目进程、单场景性能门禁、固定结果包和证据摘要全部通过,然后返回调用者。 - `evidence_insufficient`(证据不足):执行前缺少身份、快照、范围或声明输入,包括缺失单场景配置。 - `failed`(失败):既有证据被篡改或摘要不一致,或执行、输出、180 秒门槛、确定性、清理、原子固化任一环节失败。 diff --git a/.agents/skills/run-local-quant-research/agents/openai.yaml b/.agents/skills/run-local-quant-research/agents/openai.yaml index 9b0c4f7..e4ad43d 100644 --- a/.agents/skills/run-local-quant-research/agents/openai.yaml +++ b/.agents/skills/run-local-quant-research/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "本地量化研究流程" - short_description: "编排通用本地量化研究流程并固化可复算证据与三态结论" - default_prompt: "使用 $run-local-quant-research 执行项目配置指定的本地量化研究并核验完整证据。" + short_description: "执行单场景本地量化研究并生成可归档证据包与三态结论" + default_prompt: "使用 $run-local-quant-research 执行配置指定的单场景本地量化研究并核验可归档证据包。" diff --git a/.build-and-verify/config.json b/.build-and-verify/config.json index 1a69314..3b694e6 100644 --- a/.build-and-verify/config.json +++ b/.build-and-verify/config.json @@ -297,8 +297,10 @@ "-m", "pytest", "tests\\local_quant_research", - "-k", - "not test_non_strategy_project_completes_through_shared_market_and_runner" + "tests\\quant_analysis", + "--ignore=tests\\local_quant_research\\test_generic_e2e.py", + "--ignore=tests\\local_quant_research\\test_turtle_e2e.py", + "--ignore=tests\\local_quant_research\\test_local_research_v2_e2e.py" ], "paths": [ ".agents/skills/run-local-quant-research/**", @@ -328,7 +330,8 @@ "requirements-dev.txt" ], "checkParallel": true, - "pytestXdistWorkers": 4 + "pytestXdistWorkers": 4, + "timeoutSeconds": 300 }, { "id": "verify.local-quant-research-e2e", @@ -337,7 +340,8 @@ "-m", "pytest", "tests\\local_quant_research\\test_generic_e2e.py", - "tests\\local_quant_research\\test_turtle_e2e.py" + "tests\\local_quant_research\\test_turtle_e2e.py", + "tests\\local_quant_research\\test_local_research_v2_e2e.py" ], "paths": [ ".agents/skills/run-local-quant-research/**", diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md new file mode 100644 index 0000000..30ced2e --- /dev/null +++ b/.superpowers/sdd/task-5-report.md @@ -0,0 +1,230 @@ +# Task 5 实施报告 + +STATUS: DONE_WITH_CONCERNS + +## 实施结果 + +- 配置固定为 v2,拒绝 `command/project_entry/code_identity/required_outputs/output_root/stop_states`,错误码为 `legacy_run_field`。 +- 公开 CLI 仅保留 `run --config` 与 `promote`;私有 `_execute` 仅由父 runner 生成,公开帮助不显示。 +- 新增共享 `vectorbt_runtime.py`,Task 5 最小边界只支持 1 列、2 日、无订单,并实际调用 `Portfolio.from_order_func()`;原始 Portfolio 不公开,账本数组只读。 +- 新增 `performance.py` 与 `scenario.py`:每个日常 run 恰好一次 cold 和一次 warm,比较完整 execution digest,各自严格小于 180 秒;结果包只物化一次。 +- 父 runner 保留输入冻结、摘要复核、清洁环境、外写检测、失败 attempt、完成复用、冲突失败与原子发布;最终目录直接是 archive-ready package。 +- Skill 保持单次调用、单场景、返回调用者边界,并公开 run/promote 两条固定命令。 + +## RED 证据 + +命令: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_runner.py tests\local_quant_research\test_evidence.py tests\local_quant_research\test_skill_contract.py tests\local_quant_research\test_generic_e2e.py tests\local_quant_research\test_vectorbt_runtime.py -q +``` + +结果:`17 failed, 37 passed in 8.76s`。 + +预期失败原因:旧 runner 仍要求 v1 字段;`legacy_run_field`、固定 `_execute`、performance/vectorbt runtime、Skill promote/archive-ready 与 v2 E2E 尚未实现。 + +补充 RED: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_runner.py::test_same_size_input_digest_change_is_rejected_after_shared_execution -q +``` + +结果:`1 failed in 8.69s`;同长度并恢复 mtime 的输入篡改曾被仅大小/mtime 检查漏过。 + +## GREEN 证据 + +指定 Task 5 集合:`56 passed in 55.83s`。 + +摘要复核补强:`2 passed in 8.19s`,同时覆盖永久摘要变化失败与临时变化后恢复成功。 + +## 验证 + +- 相关回归:`test_strategy_contract.py test_result_package.py test_archive_promotion.py test_analysis_data_views.py` 共 `193 passed`。 +- Ruff:允许的 Python 文件 `All checks passed!`。 +- `git diff --check`:通过。 +- 共享 `local_quant_research` 中仅 `vectorbt_runtime.py` 导入 vectorbt。 +- E2E 明确断言 cold/warm 摘要相同、各小于 180 秒、固定十阶段、四表、自包含代码/配置/证据/报告、复用、摘要冲突与无 DuckDB 残留。 +- 未触碰、未暂存、未提交 `joinquant/strategies/` 并行修改或未跟踪归档文件;未触碰协调文件。 + +## 改动文件 + +- `.agents/skills/run-local-quant-research/SKILL.md` +- `.agents/skills/run-local-quant-research/agents/openai.yaml` +- `scripts/research/local_quant_research/contracts.py` +- `scripts/research/local_quant_research/vectorbt_runtime.py` +- `scripts/research/local_quant_research/scenario.py` +- `scripts/research/local_quant_research/performance.py` +- `scripts/research/local_quant_research/runner.py` +- `scripts/research/local_quant_research/evidence.py` +- `scripts/research/local_quant_research/cli.py` +- `tests/local_quant_research/test_runner.py` +- `tests/local_quant_research/test_skill_contract.py` +- `tests/local_quant_research/test_generic_e2e.py` +- `tests/local_quant_research/test_vectorbt_runtime.py` + +## 风险与疑问 + +- 已知旧 `tests/local_quant_research/test_turtle_e2e.py` 仍提交 v1 `command/project_entry/...` 配置,单独运行时按新合同得到 `evidence_insufficient`。协调者已确认这是 Task 7/8 的预期迁移项;本任务受边界限制未修改该测试、海龟策略或生产配置。 +- Task 5 的 vectorbt runtime 仅实现明确验收的最小无订单 seam;稳定优先级、订单转换、成交回调、惰性缓存与延迟程序留给 Task 6。 + +## Fix Round 1 + +### 关闭情况与 RED/GREEN + +1. 执行隔离与路径绑定 + - RED:`test_fixed_output_root_cannot_escape_repository_through_directory_link` 与 `test_private_execute_rejects_staging_not_bound_to_frozen_request` 为 `2 failed`;旧实现缺少固定输出根链接拒绝,并把任意 `--staging` 只归为普通 frozen request 错误。 + - GREEN:同组 `2 passed`。live `cli.py` 只做 stdlib(标准库)校验/引导;request 精确绑定 live repository、frozen repository、market data、runtime cache 与 staging。真实 `_execute` 从冻结 `adapter_guard.py` 安装 audit guard(审计守卫),只允许本次 staging 和精确 runtime cache 写入,禁止 live repo 读取和子进程。 + - 补充 GREEN:adapter guard 外写/live-read/process 回归 `1 passed`;真实公开 CLI E2E `1 passed`。 +2. 首次身份冻结与冻结 runtime 执行 + - RED:runtime 未复制、scenario 在身份捕获后变化仍会进入子进程,针对性 `2 failed`;冻结 bootstrap 仍加载 live runner,另有 `1 failed`;守卫未在真实 bootstrap 安装,另有 `1 failed`。 + - GREEN:runtime/source 首次捕获摘要驱动复制 `2 passed`;冻结 runner/bootstrap/guard `1 passed`。冻结集合包含 `scripts/__init__.py`、`scripts/research/__init__.py`、全部共享 local runtime、market data runtime 与策略 source files。 +3. 完成包强身份复用 + - RED:`test_completed_package_reuse_binds_all_frozen_identity_documents` 为 `1 failed`,旧 `_package_identity()` 不接收完整 expected identity。 + - GREEN:包内 `project-run/scenario/code-identity/market-snapshot/runtime-lock` 与本次冻结身份逐项相等,并从包内证据机械重算 config/code/snapshot digest 与 run_id;针对性 `3 passed`。 +4. 完整性能门禁与 writer 四阶段证据 + - RED:writer 没有阶段返回,且共享 writer 耗时无法加入 cold/warm 门禁,针对性 `2 failed`。 + - GREEN:writer 持久化 `core_facts/parquet_materialize/readback_validate/report_and_manifest` 四个真实阶段,单次 writer wall time(墙钟时间)同时加入 cold/warm 的 180 秒门禁,针对性 `2 passed`。 + - 单次物化设计:四张核心表和扩展只写一轮 Parquet、只回读一轮;首轮 report/manifest 完成后,仅轻量重写 `performance.json`、两份 report 和 manifest 以解决因果计时,不重算 core facts、不重写或重读 Parquet。记录写入测试仍严格为 5 次 Parquet 写入。 +5. cold/warm 生命周期与流式摘要 + - RED:cold outcome 在 warm 开始时仍存活、NumPy 摘要整块复制、extension 调用 `to_pylist()`,针对性 `3 failed`。 + - GREEN:cold 摘要后立即释放;即时 `final is primary` 用稳定引用且只扫描一次;相同 ledger array 用引用表示;NumPy 使用 buffer/分块摘要,Arrow extension 使用 schema + record batch buffers 流式摘要,针对性 `3 passed`。 +6. `scenario_id` 前置 + - RED:缺失与空值均未在配置加载阶段拒绝,`2 failed`。 + - GREEN:`load_run_config()` 在任何策略 prepare/vectorbt 前抛稳定 `ConfigurationError("missing_scenario_id")`,`2 passed`。 +7. Skill 输出合同 + - RED:Skill 契约缺少“固定唯一 archive-ready package、不由配置声明”语义,`1 failed`。 + - GREEN:Skill 改为正向固定输出合同,移除“唯一必需输出/必需输出声明”,`1 passed`。 +8. ledger/trace/Portfolio 覆盖 + - 审查已确认生产实现静态合规,本项缺陷是测试覆盖不足,无法诚实制造生产 RED。新增测试遍历 `orders/assets/cash/value/trades/positions/returns`,验证只读、缓存身份、trace 只读缓存及无公开 Portfolio;直接 `1 passed`。 + +### 验证 + +- Task 5 指定集合加新增 result package 回归:`137 passed in 62.76s`。 +- Task 3/4 结果包、晋升、查询相关回归:`193 passed in 15.21s`。 +- 真实公开 CLI E2E:`1 passed in 38.75s`。 +- Ruff:`All checks passed!`。 +- `git diff --check`:通过。 +- 旧 `test_turtle_e2e.py` 的 v1 配置失败仍是 Task 7/8 已知项,本轮未扩围。 + +### Fix Round 1 变更文件 + +- `.agents/skills/run-local-quant-research/SKILL.md` +- `scripts/research/local_quant_research/adapter_guard.py` +- `scripts/research/local_quant_research/cli.py` +- `scripts/research/local_quant_research/evidence.py` +- `scripts/research/local_quant_research/performance.py` +- `scripts/research/local_quant_research/result_package.py` +- `scripts/research/local_quant_research/runner.py` +- `scripts/research/local_quant_research/scenario.py` +- `tests/local_quant_research/test_evidence.py` +- `tests/local_quant_research/test_result_package.py` +- `tests/local_quant_research/test_runner.py` +- `tests/local_quant_research/test_skill_contract.py` +- `tests/local_quant_research/test_vectorbt_runtime.py` + +### 遗留顾虑 + +- `report_and_manifest` 的最终真实时长存在因果自引用:最终值写入文件本身会继续消耗时间。本实现只做一次轻量 finalize(收尾重写);完整 wall time 仍参与 180 秒失败门禁,而持久化阶段值记录 finalize 前的真实 report/manifest pass。该 finalize 不重写 Parquet、不重复 core facts 或全表扫描。 +- Task 5 runtime 仍只覆盖既定最小无订单 seam;Task 6 的完整订单生命周期不在本轮范围。 + +## Fix Round 2 + +### 关闭情况与 RED/GREEN + +1. execution digest(执行摘要)计时 + - RED:慢摘要用例 `1 failed`,摘要发生在 `_sample()` 计时结束之后。 + - GREEN:摘要完成后才停止计时;慢摘要计入 cold/warm(冷/热)及 180 秒门禁,`1 passed`。 +2. writer(写入器)单次全表工作 + - RED:事实访问器与完整 validator(校验器)在 finalize(收尾)再次运行,相关用例 `1 failed`。 + - GREEN:报告事实、表条目与已验证结果在首个完整 pass(轮次)缓存,收尾只重写固定小型证据、报告和清单;完整 validator 只调用一次,`1 passed`。 +3. 性能证据测量范围 + - RED:持久化证据不能区分首个完整报告/清单耗时、持久化门禁测量值和 writer 返回前实际门禁,`1 failed`。 + - GREEN:新增 `writer.first_full_pass_seconds`、`writer.gate_measured_seconds` 与 `measurement_scope`;实际 complete/failed 仍以 writer 返回后的完整墙钟时间判断,`1 passed`。 +4. frozen bootstrap(冻结引导)路径封闭 + - RED:repository/market-data/runtime-cache 的 symlink/junction(符号链接/目录联接)矩阵 `6 failed`;request 的 project/run/attempt/output 绑定 `1 failed`。 + - GREEN:live CLI 逐组件 `lstat` 校验普通目录与固定命名/父子关系,guard 文件也必须是普通文件;同组 `7 passed`。 +5. 输出根、run 与 attempt 安全 + - RED:已链接 `.attempts` 仍可能写入,`1 failed`;run-dir(运行目录)缺少安全解析入口,`1 failed`。 + - GREEN:project/run/.attempts/staging 都复用普通输出目录解析;不安全时保持稳定状态、`attempt_id=None` 且不写盘,`2 passed`。 +6. Arrow extension digest(Arrow 扩展摘要) + - RED:slice offset(切片偏移)、dictionary values(字典值)与 nested/chunk(嵌套/分块)布局用例 `3 failed`。 + - GREEN:固定 65,536 行窗口内 `combine_chunks()`,再用 Arrow IPC(进程间格式)规范流摘要;不调用 `to_pylist()`,不整表合并;原有 null/NaN、字段顺序与全值覆盖继续通过,相关文件 `10 passed`。 +7. `scenario_id` 完成包交叉绑定 + - RED:内部重签且自洽、但 manifest object 与冻结 scenario 不同的包,通用 validator 和 runner reuse 均接受,`2 failed`。 + - GREEN:两层都机械交叉比较 `scenario_id`,`2 passed`。 +8. 父进程零策略执行 + - RED:父进程身份捕获会执行策略顶层,`1 failed`;静态源发现缺失与链接源封闭补充用例 `2 failed`。 + - GREEN:父进程只用 `PathFinder`(路径查找器)静态解析并冻结策略根全部普通 Python 源;真正 `load_strategy()` 仅在 guard 安装后的冻结子进程执行,同组 `3 passed`。真实子进程外写/启进程验证 `2 passed`,均固定返回 `access_guard_violation` 且无外部副作用。 +9. bootstrap/import(引导/导入)稳定错误 + - RED:guard 导入错误输出 traceback(堆栈),链接 guard 可触发顶层,`2 failed`。 + - GREEN:引导错误固定为 `frozen_bootstrap_failed`,不输出异常细节;链接 guard 在执行前拒绝,同组 `2 passed`。同时将 TEMP/TMP、Numba/Matplotlib 缓存固定到本次 runtime-cache,避免标准库临时目录探测吞掉底层权限错误。 +10. `scenario_id` 前置完整性 + - RED:纯空白值未被拒绝,`1 failed`;writer 对纯空白值仍可生成包,`1 failed`。 + - GREEN:缺失、空字符串、纯空白和非字符串都在策略执行前返回 `missing_scenario_id -> evidence_insufficient`,writer/validator 同样拒绝空白身份,`2 passed`。 + +### 验证 + +- Task 5 全集及 Task 2/3/4/5 相关回归:`289 passed in 82.84s`,`0 failed`。 +- 真实冻结子进程安全定向回归:`2 passed in 10.55s`。 +- Ruff(代码规范检查):允许的 11 个 Python 文件 `All checks passed!`。 +- `git diff --check`:通过。 +- 允许文件清单:7 个共享运行模块、4 个对应测试和本报告;未触碰、未暂存、未提交 `joinquant/strategies/` 与协调文件的并行修改。 + +### Fix Round 2 变更文件 + +- `scripts/research/local_quant_research/cli.py` +- `scripts/research/local_quant_research/evidence.py` +- `scripts/research/local_quant_research/performance.py` +- `scripts/research/local_quant_research/result_package.py` +- `scripts/research/local_quant_research/runner.py` +- `scripts/research/local_quant_research/scenario.py` +- `scripts/research/local_quant_research/strategy_loader.py` +- `tests/local_quant_research/test_evidence.py` +- `tests/local_quant_research/test_result_package.py` +- `tests/local_quant_research/test_runner.py` +- `tests/local_quant_research/test_strategy_contract.py` +- `.superpowers/sdd/task-5-report.md` + +### 遗留顾虑 + +- 旧 `test_turtle_e2e.py` 的 v1 配置仍属于 Task 7/8 迁移项,本轮没有扩围。 +- 最终性能数字的小型元数据写回仍是明确记录的 observer overhead(观测开销);摘要、事实转换、Parquet、回读、报告与完整校验均已计入门禁,writer 返回前的实际墙钟时间仍决定最终状态。 + +## Fix Round 3(用户授权例外) + +### RED/GREEN 证据 + +RED(失败)命令: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_evidence.py::test_arrow_digest_uses_decoded_values_across_dictionary_chunk_encodings tests\local_quant_research\test_evidence.py::test_arrow_digest_normalizes_dictionary_inside_nested_struct -q +``` + +结果:`2 failed in 0.83s`。两个失败都是预期断言失败:逻辑值相同的顶层 dictionary(字典)和 nested struct child dictionary(嵌套结构体子字典)使用不同 dictionary order/indices(字典顺序/索引)及 chunk split(分块方式)时,旧摘要仍不同。 + +GREEN(通过)命令同上,结果:`2 passed in 0.42s`。同一测试同时确认不同解码逻辑值仍产生不同摘要。 + +### 实现说明 + +- 根因是固定窗口 `combine_chunks()` 后直接写 Arrow IPC(进程间格式),IPC 仍绑定由首个 chunk(分块)决定的物理字典顺序和索引。 +- 现在先递归生成去 dictionary(字典)的逻辑 schema(结构),再仅对当前固定 65,536 行窗口使用 PyArrow(列式库)向量化 `cast()` 解码,之后才合并该窗口的 chunk 并写入 canonical IPC(规范进程间格式)。 +- 递归覆盖顶层 dictionary 以及 list/large-list/fixed-size-list/struct/map(列表/大列表/定长列表/结构体/映射)中的 dictionary;普通无字典 schema 保留原对象并跳过 cast。 +- 没有整表 `combine_chunks()`、没有任何 `to_pylist()`、没有 Python(编程语言)逐值循环,也没有新增第二套摘要或账本路径。 + +### 完整验证 + +- `tests/local_quant_research/test_evidence.py`:`12 passed in 0.46s`。 +- Task 5 七文件新鲜完整回归:`193 passed in 72.38s`。 +- Ruff(代码检查)覆盖两个允许 Python 文件:`All checks passed!`。 +- `git diff --check`:通过。 + +### Fix Round 3 变更文件 + +- `scripts/research/local_quant_research/evidence.py` +- `tests/local_quant_research/test_evidence.py` +- `.superpowers/sdd/task-5-report.md` + +### 风险信号 + +- 固定 65,536 行窗口约束保持不变;dictionary 解码只在含 dictionary 的 schema 上执行,普通扩展表不增加 cast 工作。 +- 未触碰 vectorbt runtime(向量化回测运行时)、结果包协议、性能门禁、JoinQuant(聚宽)并行状态或协调文件;旧 turtle E2E(海龟端到端)v1 迁移仍属于 Task 7/8。 diff --git a/docs/research/2026-07-13-turtle-etf-system-final-plan.md b/docs/research/2026-07-13-turtle-etf-system-final-plan.md index d555a85..d13c60b 100644 --- a/docs/research/2026-07-13-turtle-etf-system-final-plan.md +++ b/docs/research/2026-07-13-turtle-etf-system-final-plan.md @@ -152,10 +152,10 @@ portfolio_scale = min(1, 12 / effective_units) - 本地模拟使用 vectorbt(向量化回测框架)官方 `Portfolio.from_order_func()`; - Numba(即时编译)回调只负责策略状态和订单决策;vectorbt 负责时间遍历、共享现金、成交、费用、持仓和权益; -- 额外延迟研究继续使用冻结订单的 `Portfolio.from_orders()` 路径,基线 `additional_delay_days=0` 不进入额外延迟; +- 即时和额外延迟研究都使用共享 `Portfolio.from_order_func()`;延迟路径只冻结计划与执行日调整,不维护第二套账户账本; - 旧自研逐日引擎、旧分配函数和旧风险路径不保留兼容层。 -本地结果尽量对齐聚宽现有目录,物理输出 `results`、`balances`、`positions`、`orders` 四类共同事实及海龟归因扩展。聚宽现有归档 0 改动直读。归因必须包含逻辑单位、候选基础数量、冻结 N、实际成交价、共同止损、资产组比例、组合比例、现金比例和再分配状态隔离证据。 +本地运行直接生成 `.local/quant-research///` 的 `local-research-package/2` 自包含结果包,物理输出 `results`、`balances`、`positions`、`orders` 四类共同事实、完整策略源码/配置/环境/证据、机械执行报告及海龟归因扩展。通过 `promote` 可逐字节晋升到 `research/archives//`;该档案不是聚宽正式 `backtests/` 或 `simulations/`,聚宽现有归档保持 0 改动直读。归因必须包含逻辑单位、候选基础数量、冻结 N、实际成交价、共同止损、资产组比例、组合比例、现金比例和再分配状态隔离证据。 ## 十一、独立策略分析 @@ -178,7 +178,7 @@ Vibe-Trading(AI 研究助理)只允许使用无已知缺陷的单体公开 - 不运行 7 个场景或稳健性矩阵; - `run-local-quant-research` Skill 仍只执行一个场景; - 单次冷启动和预热都必须小于 180 秒,规范化结果摘要一致; -- 最终生成本地研究报告、明确推荐结论并等待人工确认; +- 运行结果包只生成机械执行报告;完整策略分析、推荐结论和人工确认属于结果包之外的独立流程; - 结果是本地探索性证据,不代替聚宽正式裁决。 ## 十三、完成标准 diff --git a/docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md b/docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md new file mode 100644 index 0000000..40c5a50 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md @@ -0,0 +1,1133 @@ +--- +change: refactor-local-research-three-layer-architecture +design-doc: docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md +base-ref: ea195d36501848d3ba677b1e97c1aba667da7e1e +archived-with: 2026-07-17-refactor-local-research-three-layer-architecture +--- + +# 本地研究三层架构实施计划 + +> **执行约束:** 后续实现由主 agent(代理)使用 superpowers:executing-plans(按计划执行)逐 Task 完成;不使用 subagent-driven-development(子代理驱动开发)分派实现、调试或修复。独立只读审查可在用户明确要求时执行。步骤使用复选框(`- [ ]`)跟踪。 + +**Goal:** 把本地量化研究重构为 vectorbt(向量化回测库)唯一账本底层、共享 Skill(技能)能力层和单一公开 Strategy Module(策略模块),并让每次运行结果可原样晋升为策略目录内的自包含档案。 + +**Architecture:** 共享 `vectorbt_runtime.py` 独占 vectorbt 导入并把 `Portfolio.from_order_func()` 包装为只读惰性 `ExecutionLedger`;共享 runner(运行器)、结果包和晋升模块只依赖项目自有 contracts(契约)。策略只通过 `strategy.py:MODULE` 暴露准备、后续订单程序和扩展表,私有 Numba(即时编译器)内核不接触 vectorbt 上下文;生产入口在一个提交中切换到配置 v2 并删除旧策略 CLI(命令行接口)与手工延迟账本。 + +**Tech Stack:** Python 3.12、vectorbt 1.1.0、Numba 0.66.0、NumPy、Pandas、PyArrow(列式内存)、DuckDB(内存分析数据库)、Pytest(测试框架)、OpenSpec(开放规格)、Build and Verify(构建与验证) + +## Global Constraints + +- 正式回测和模拟交易仍只在 JoinQuant(聚宽)云端运行;本地结果只能表述为研究执行事实,不得声称推荐、稳健性通过或实盘准入。 +- 本 change(变更)不修改聚宽定时归档、归档格式、同步逻辑、`backtests/` 或 `simulations/` 数据模型,也不实现定时任务运行目录隔离;该运维问题另行处理。 +- Strategy Module 是仓库内受审查的可信代码;共享层使用受限源码路径、冻结输入、清理环境、全新子进程和超时边界,不安装 audit hook(审计钩子)或自行模拟操作系统沙箱。 +- 最终态只有 `scripts/research/local_quant_research/vectorbt_runtime.py` 可导入 vectorbt 或其内部类型;策略、结果 writer(写入器)、archive(档案)和 Skill(技能)均不得导入 vectorbt。迁移期间新路径仅供测试,旧生产路径在 Task 11 的同一提交中切换并删除。 +- `Portfolio.from_order_func()` 是即时与延迟执行的唯一成交、费用、现金、持仓和净值账本;不得保留 Python 手工账本、`Portfolio.from_orders()` 重放或第二套生产路径。 +- 日常 `run` 只执行一个冷启动和一个预热,每次执行的超时均为 180 秒。 +- 性能、内存和 Parquet(列式文件)数据载荷体积只在验证报告中列出,由用户人工确认,不建设发布性能命令或自动相对门禁。 +- 性能观测覆盖 3,432 日 × 11 ETF、3,432 日 × 17 ETF、以及 `additional_delay_days=1`;正确性要求成交、费用、现金、持仓、净值、策略状态和逻辑摘要零差异。 +- `.local/quant-research///` 必须直接满足 `local-research-package/2` 自包含布局;promotion(晋升)只能逐字节复制、校验并原子发布,不能重算。 +- `analysis_id` 只作为策略档案目录别名;包内 `run_id` 和所有文件字节保持不变,共享行情数据不得复制进档案。 +- 所有 Python 命令必须使用 `.\.venv\Scripts\python.exe`;不使用系统 Python,不安装或升级依赖。 +- 实现继续保留在当前 Comet(变更工作流)功能分支;不得在本地把功能分支合入 `main`。 +- 每个生产改动必须先出现对应失败测试,再写最小实现;每个 Task(任务)独立通过、独立审查、独立提交。 + +## File Map + +- `scripts/research/local_quant_research/contracts.py`:后端中立的策略、订单、账本、结果扩展和运行状态契约。 +- `scripts/research/local_quant_research/strategy_loader.py`:安全加载 `strategy_root/module/symbol`,验证 descriptor(描述符)和源码边界。 +- `scripts/research/local_quant_research/vectorbt_runtime.py`:唯一 vectorbt Adapter(适配器),包含共享回调、即时/后续运行和惰性账本。 +- `scripts/research/local_quant_research/scenario.py`、`performance.py`:单场景编排、冷热确定性和日常 180 秒超时。 +- `scripts/research/local_quant_research/result_package.py`:标准四表、扩展、机械报告、回读勾稽与原子物化。 +- `scripts/research/local_quant_research/archive.py`:只复制并校验 archive-ready package(可归档结果包)。 +- `scripts/research/local_quant_research/runner.py`、`evidence.py`、`cli.py`:配置 v2、输入冻结、固定子进程、运行复用、`run/promote` 公开入口。 +- `scripts/research/analysis_data/`:统一读取新本地结果、策略扩展和既有聚宽归档。 +- `joinquant/strategies/strategy-003/research/turtle_etf/strategy.py`:唯一公开 `MODULE`。 +- `joinquant/strategies/strategy-003/research/turtle_etf/_kernel.py`、`_attribution.py`、`_delayed.py`:策略私有内核、扩展和延迟计划。 +- `joinquant/strategies/strategy-003/research/project-run.json`:配置 v2,只声明策略入口、快照、场景配置和输入。 +- `.agents/skills/run-local-quant-research/SKILL.md`:只编排共享 `run` 与 `promote`。 +- `tests/local_quant_research/`:契约、runtime(运行时)、结果包、档案、双策略入口、E2E(端到端)和性能观测契约。 + +--- + +### Task 1: 冻结旧路径的等价性与性能观测基线 + +**Files:** +- Create: `tests/local_quant_research/test_local_research_equivalence.py` +- Create: `tests/local_quant_research/fixtures/local-research-v1-baseline.json` +- Create: `tests/local_quant_research/fixtures/performance-baseline.json` +- Modify: `tests/local_quant_research/test_turtle_vectorbt_performance.py` +- Modify: `tests/local_quant_research/test_contract_fixtures.py` + +**Interfaces:** +- Consumes: 现有 `run_vectorbt_simulation()`、`to_joinquant_facts()`、11 ETF 基线、17 ETF 扩展和延迟 1 日配置。 +- Produces: `logic_digest(facts) -> str`、三个场景的行为基线与固定机器性能观测;行为 fixture(夹具)作为零差异裁判,性能 fixture 只保存历史观测。 + +- [x] **Step 1: 写失败的行为冻结测试** + +在新测试中对每个场景固定以下摘要,摘要输入必须包含成交数量/价格/费用、每日现金/持仓/净值、海龟单位/共同止损/原因码,不能只比较最终收益: + +```python +SCENARIOS = ( + "immediate-11-etf", + "immediate-17-etf", + "delayed-11-etf-1d", +) + +def assert_equivalent(actual: dict[str, object], expected: dict[str, object]) -> None: + assert actual["schema_version"] == 1 + assert actual["scenario"] == expected["scenario"] + for key in ("orders", "fees", "cash", "positions", "value", "state", "logic"): + assert actual[key] == expected[key] + +def test_all_reference_scenarios_have_complete_equivalence_fixtures(repo_root: Path) -> None: + fixture = json.loads((repo_root / "tests/local_quant_research/fixtures/local-research-v1-baseline.json").read_text(encoding="utf-8")) + assert tuple(item["scenario"] for item in fixture["scenarios"]) == SCENARIOS + assert all(set(item) == {"scenario", "orders", "fees", "cash", "positions", "value", "state", "logic"} for item in fixture["scenarios"]) +``` + +- [x] **Step 2: 运行测试并确认 fixture 尚未存在** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_local_research_equivalence.py tests\local_quant_research\test_contract_fixtures.py -q +``` + +Expected: FAIL,指出两份新 fixture 缺失或未包含三个完整场景。 + +- [x] **Step 3: 使用旧生产路径生成并固化基线** + +在测试辅助函数中从现有 `LocalExecutionFacts` 构造 canonical(规范化)摘要;对浮点数组统一编码为 little-endian float64(小端双精度),逻辑字段使用排序 JSON,再把真实摘要写入 `local-research-v1-baseline.json`。发布性能 fixture 固定以下结构并由现有三个真实场景采集值填充: + +```json +{ + "schema_version": 1, + "environment": {"python": "3.12", "vectorbt": "1.1.0"}, + "sampling": {"cold_processes": 3, "warm_runs": 5, "statistic": "median"}, + "scenarios": {} +} +``` + +采集命令必须从项目 `.venv` 启动旧公开入口;fixture 保存摘要、阶段时间、中位峰值内存和历史 `package_bytes` 观测。Task 10 在旧入口删除前补充可对齐的核心/扩展 Parquet 数据载荷与固定开销;不提交 `.local` 运行目录。 + +- [x] **Step 4: 复跑冻结测试和旧 E2E(端到端)测试** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_local_research_equivalence.py tests\local_quant_research\test_turtle_vectorbt_performance.py tests\local_quant_research\test_turtle_e2e.py -q +``` + +Expected: PASS;三个场景都有行为摘要,性能 fixture 明确记录 3/5 历史采样且不携带发布阈值。 + +- [x] **Step 5: 提交冻结证据** + +```powershell +git add -- tests/local_quant_research/test_local_research_equivalence.py tests/local_quant_research/fixtures/local-research-v1-baseline.json tests/local_quant_research/fixtures/performance-baseline.json tests/local_quant_research/test_turtle_vectorbt_performance.py tests/local_quant_research/test_contract_fixtures.py +git diff --cached --name-only +git commit -m "测试:冻结本地研究等价性与性能基线" +``` + +--- + +### Task 2: 建立后端中立 contracts 与安全 Strategy Module 加载器 + +**Files:** +- Modify: `scripts/research/local_quant_research/contracts.py` +- Create: `scripts/research/local_quant_research/strategy_loader.py` +- Create: `tests/local_quant_research/test_strategy_contract.py` +- Create: `tests/local_quant_research/fixtures/minimal_strategy/strategy.py` +- Create: `tests/local_quant_research/fixtures/minimal_strategy_b/strategy.py` + +**Interfaces:** +- Consumes: `SnapshotView`、仓库根目录、配置 v2 的 `strategy.root/module/symbol`。 +- Produces: `StrategyDescriptor`、`PreparedStrategy`、`LedgerInput`、`OrderBuffer`、`OrderProgram`、只读 `ExecutionLedger` Protocol(协议)、`ExecutionRun`、`ExecutionBundle`、`ResultExtension`、`load_strategy() -> LoadedStrategy`。 + +- [x] **Step 1: 写 contracts 和双策略加载失败测试** + +测试两个最小 fixture 模块可由同一加载器加载,并拒绝绝对路径、`..`、仓库外 module file、未知 symbol、重复/缺失 source file、旧 `command/project_entry` 字段: + +```python +@pytest.mark.parametrize("invalid", ("C:/outside", "../outside", "/outside")) +def test_loader_rejects_strategy_root_escape(repo_root: Path, invalid: str) -> None: + with pytest.raises(ConfigurationError, match="strategy_root"): + load_strategy(repo_root, {"root": invalid, "module": "strategy", "symbol": "MODULE"}) + +def test_shared_loader_accepts_two_strategy_modules(repo_root: Path) -> None: + first = load_strategy(repo_root, {"root": "tests/local_quant_research/fixtures/minimal_strategy", "module": "strategy", "symbol": "MODULE"}) + second = load_strategy(repo_root, {"root": "tests/local_quant_research/fixtures/minimal_strategy_b", "module": "strategy", "symbol": "MODULE"}) + assert (first.descriptor.strategy_id, second.descriptor.strategy_id) == ("minimal-fixture", "minimal-fixture-b") +``` + +- [x] **Step 2: 运行测试并确认共享接口尚不存在** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_strategy_contract.py -q +``` + +Expected: FAIL,导入 `strategy_loader` 或新 contract 类型失败。 + +- [x] **Step 3: 实现精确只读 contracts** + +按技术设计建立 `slots=True, frozen=True` 数据类、数值枚举和 `SegmentView/FillEvent` namedtuple(命名元组)。`OrderBuffer` 必须验证所有数组等长,写保护只在 runtime 完成后启用;`StrategyEvidenceError` 必须携带稳定 `code`: + +```python +SIDE_NONE, SIDE_BUY, SIDE_SELL = 0, 1, -1 +FILL_IGNORED, FILL_ACCEPTED, FILL_REJECTED = 0, 1, 2 + +class StrategyEvidenceError(RuntimeError): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + +@dataclass(frozen=True, slots=True) +class PreparedStrategy: + ledger_input: LedgerInput + primary_program: OrderProgram + context: object + +@dataclass(frozen=True, slots=True) +class ExecutionBundle: + primary: ExecutionRun + final: ExecutionRun + stages: tuple[str, ...] +``` + +其余字段逐字采用 Design Doc(技术设计)3.1–3.4;`StrategyModule` Protocol(协议)的方法名和返回类型不得变化。 + +- [x] **Step 4: 实现 loader 与最小测试策略** + +`load_strategy()` 只临时把已解析的 `strategy_root` 加入 `sys.path`;导入后验证 `module.__file__`、descriptor 的每个 POSIX(可移植路径)source file 都在 root 内,再恢复 `sys.path`。返回值必须同时保留模块对象、绝对源码路径和 descriptor,供 code identity(代码身份)与档案复制复用: + +```python +@dataclass(frozen=True, slots=True) +class LoadedStrategy: + module: StrategyModule + root: Path + source_paths: tuple[Path, ...] + descriptor: StrategyDescriptor +``` + +两个最小 fixture 的 `MODULE.prepare()` 都返回一列、两日、无订单 `OrderProgram`,但 descriptor identity(描述符身份)不同;它们只用于证明共享入口无需修改即可加载第二个策略,且不依赖海龟字段。 + +- [x] **Step 5: 运行 contract、runner 和共享去策略化测试** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_strategy_contract.py tests\local_quant_research\test_runner.py tests\local_quant_research\test_generic_e2e.py::test_shared_sources_do_not_depend_on_one_strategy -q +``` + +Expected: PASS;共享源码不包含 `turtle`、`strategy-003` 或具体证券代码。 + +- [x] **Step 6: 提交接口接缝** + +```powershell +git add -- scripts/research/local_quant_research/contracts.py scripts/research/local_quant_research/strategy_loader.py tests/local_quant_research/test_strategy_contract.py tests/local_quant_research/fixtures/minimal_strategy/strategy.py tests/local_quant_research/fixtures/minimal_strategy_b/strategy.py +git diff --cached --name-only +git commit -m "重构:建立策略模块共享契约" +``` + +--- + +### Task 3: 抽取标准结果包与统一分析视图 + +**Files:** +- Create: `scripts/research/local_quant_research/result_package.py` +- Modify: `scripts/research/analysis_data/manifest.py` +- Modify: `scripts/research/analysis_data/views.py` +- Modify: `scripts/research/analysis_data/__init__.py` +- Create: `tests/local_quant_research/test_result_package.py` +- Modify: `tests/local_quant_research/test_analysis_data_views.py` + +**Interfaces:** +- Consumes: `ExecutionBundle.final.ledger`、scenario/config/code/market/runtime evidence(证据)和 `tuple[ResultExtension, ...]`。 +- Produces: `write_result_package(request: ResultPackageRequest) -> ResultPackage`、`validate_result_package(path) -> Mapping[str, object]`,以及可查询新包、扩展和聚宽档案的 `open_analysis_source()`。 + +- [x] **Step 1: 写四表、扩展、报告和单次物化失败测试** + +用 fake ledger(伪账本)记录 `orders/assets/cash/value` 属性访问次数;断言四张核心表、扩展表、跨表键、SHA256、机械报告禁词和回读失败清理: + +```python +FORBIDDEN_REPORT_PHRASES = ("推荐", "稳健性通过", "适合实盘", "实盘准入") + +def test_writer_materializes_one_package_without_recomputing_ledger(tmp_path: Path, request: ResultPackageRequest, counting_ledger: CountingLedger) -> None: + package = write_result_package(replace(request, output_dir=tmp_path / "result")) + manifest = validate_result_package(package.path) + assert set(manifest["datasets"]) == {"results", "balances", "positions", "orders"} + assert counting_ledger.calls == {"orders": 1, "assets": 1, "cash": 1, "value": 1} + report = (package.path / "report/execution-summary.md").read_text(encoding="utf-8") + assert not any(phrase in report for phrase in FORBIDDEN_REPORT_PHRASES) +``` + +- [x] **Step 2: 运行测试并确认共享 writer 尚不存在** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_result_package.py tests\local_quant_research\test_analysis_data_views.py -q +``` + +Expected: FAIL,无法导入 `result_package` 或 analysis view(分析视图)不识别 `local-research-package/2`。 + +- [x] **Step 3: 实现核心 Schema、扩展约束与 package request** + +把现有 `result_adapter.py` 中四表 schema、公共勾稽、逻辑摘要、Arrow(列式内存)构造和 manifest 引用迁入共享模块;策略 action/reason code 不能进入共享文件。固定请求对象如下: + +```python +@dataclass(frozen=True, slots=True) +class ResultPackageRequest: + strategy_id: str + scenario_id: str + run_id: str + output_dir: Path + execution: ExecutionBundle + extensions: tuple[ResultExtension, ...] + code_files: Mapping[str, Path] + config_documents: Mapping[str, object] + evidence_documents: Mapping[str, object] + +@dataclass(frozen=True, slots=True) +class ResultPackage: + path: Path + manifest: Mapping[str, object] + package_sha256: str +``` + +每个 `ResultExtension` 的 name 必须唯一且匹配 `[a-z][a-z0-9_-]{0,63}`;writer 固定路径、Zstd(列式压缩)参数和文件名,策略无权直接写 Parquet(列式文件)。Zstd 延续迁移前同边界压缩协议,避免仅因压缩算法变化造成体积门禁失真。 + +- [x] **Step 4: 实现一次物化、回读验证与原子发布** + +先在同级 `...tmp` 写 `code/config/data/extensions/evidence/report`,每张表只调用一次 `pq.write_table()`;立刻回读 schema/行数/键/勾稽并生成 `local-research-package/2` 清单,最后 `os.replace()`。任何异常只删除本次暂存目录;若完成目录已存在,完整摘要相同则复用,冲突则失败。 + +- [x] **Step 5: 扩展 analysis_data 统一读取三类来源** + +为 `open_analysis_source()` 增加新包识别:返回 `authority=local_research`、`backend=vectorbt`、`formula_version`、四张核心表和策略扩展;既有聚宽归档读取分支保持原样。查询扩展时显式接收 extension name,不在共享层硬编码 `turtle_etf`。 + +- [x] **Step 6: 运行结果包、分析视图和旧适配测试** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_result_package.py tests\local_quant_research\test_analysis_data_views.py tests\local_quant_research\test_turtle_result_adapter.py -q +``` + +Expected: PASS;旧测试仍作为迁移期对照,标准四表与扩展读取一致。 + +- [x] **Step 7: 提交共享结果包** + +```powershell +git add -- scripts/research/local_quant_research/result_package.py scripts/research/analysis_data/manifest.py scripts/research/analysis_data/views.py scripts/research/analysis_data/__init__.py tests/local_quant_research/test_result_package.py tests/local_quant_research/test_analysis_data_views.py +git diff --cached --name-only +git commit -m "重构:抽取本地研究标准结果包" +``` + +--- + +### Task 4: 实现策略目录自包含档案的原样晋升 + +**Files:** +- Create: `scripts/research/local_quant_research/archive.py` +- Create: `tests/local_quant_research/test_archive_promotion.py` +- Modify: `scripts/research/local_quant_research/cli.py` + +**Interfaces:** +- Consumes: `.local/quant-research///manifest.json` 和 CLI(命令行接口)的 `strategy_id/run_id/analysis_id`。 +- Produces: `promote_archive(repo_root, strategy_id, run_id, analysis_id) -> ArchiveResult`;目标固定为 `joinquant/strategies//research/archives//`。 + +- [x] **Step 1: 写布局、字节相等、幂等和冲突失败测试** + +测试必须把策略加载、vectorbt、PyArrow writer(列式写入器)替换为调用即失败对象,证明 promotion(晋升)没有重算;覆盖非法 `analysis_id`、源包不完整、同内容复用、异内容冲突、复制中断清理和删除 `.local` 后仍可查询: + +```python +@pytest.mark.parametrize("analysis_id", ("Upper", "../escape", "a/b", "", "x" * 65)) +def test_promote_rejects_invalid_analysis_id(repo_root: Path, analysis_id: str) -> None: + result = promote_archive(repo_root, "strategy-003", "a" * 64, analysis_id) + assert result.status == "failed" + assert result.reasons == ("invalid_analysis_id",) + +def test_promote_preserves_every_source_byte(complete_package: Path, repo_root: Path) -> None: + result = promote_archive(repo_root, "strategy-003", complete_package.name, "baseline-v2") + assert result.status == "complete" + assert _tree_digests(result.source) == _tree_digests(result.target) + assert json.loads((result.target / "manifest.json").read_text(encoding="utf-8"))["run_id"] == complete_package.name +``` + +- [x] **Step 2: 运行测试并确认 archive 模块尚不存在** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_archive_promotion.py -q +``` + +Expected: FAIL,无法导入 `archive` 或 CLI 不支持 `promote`。 + +- [x] **Step 3: 实现独立结果和严格源/目标定位** + +使用后端中立返回对象,不复用 `RunResult` 的三种研究状态: + +```python +@dataclass(frozen=True, slots=True) +class ArchiveResult: + status: Literal["complete", "failed", "conflict"] + reused: bool + source: Path | None + target: Path | None + reasons: tuple[str, ...] +``` + +`strategy_id` 必须精确定位 `joinquant/strategies/`;`run_id` 只能是 64 位小写 SHA256;`analysis_id` 必须匹配 `[a-z0-9][a-z0-9._-]{0,63}`。目标路径不得由调用者传入,也不得从未验证配置推导。 + +- [x] **Step 4: 实现逐文件复制、复核和原子发布** + +源包先由 `validate_result_package()` 只读验证 complete(完整)状态;遍历普通文件并拒绝 symlink(符号链接)、hardlink(硬链接)和目录连接。目标不存在时写同级 `...tmp`,每个文件复制后立即核对长度与 SHA256,整包再次比较 tree digest(目录摘要)后 `os.replace()`;失败只删除本次暂存目录。 + +目标已存在时不写任何字节:tree digest 完全一致返回 `reused=True`,否则返回 `status="conflict"`。不得修改源 manifest,也不得把 `analysis_id` 注入包内文件。 + +- [x] **Step 5: 接入共享 promote 命令** + +扩展 `_parser()`,用户公开参数必须精确为: + +```python +promote = subparsers.add_parser("promote") +promote.add_argument("--strategy-id", required=True) +promote.add_argument("--run-id", required=True) +promote.add_argument("--analysis-id", required=True) +``` + +CLI 输出排序 JSON;complete(完整)为 0、conflict(冲突)为 1、failed(失败)为 2。`promote` 分支不得加载 `strategy_loader` 或 `vectorbt_runtime`。 + +- [x] **Step 6: 运行晋升、结果查询和 CLI 测试** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_archive_promotion.py tests\local_quant_research\test_analysis_data_views.py tests\local_quant_research\test_skill_contract.py -q +``` + +Expected: PASS;删除源 `.local` fixture 后,档案四表、扩展、backend(后端)和 formula version(公式版本)仍可查询。 + +- [x] **Step 7: 提交晋升能力** + +```powershell +git add -- scripts/research/local_quant_research/archive.py scripts/research/local_quant_research/cli.py tests/local_quant_research/test_archive_promotion.py +git diff --cached --name-only +git commit -m "功能:支持本地研究结果原样晋升" +``` + +--- + +### Task 5: 统一单场景、性能证据、runner 与固定 CLI + +**Files:** +- Modify: `scripts/research/local_quant_research/contracts.py` +- Create: `scripts/research/local_quant_research/vectorbt_runtime.py` +- Create: `scripts/research/local_quant_research/scenario.py` +- Create: `scripts/research/local_quant_research/performance.py` +- Modify: `scripts/research/local_quant_research/runner.py` +- Modify: `scripts/research/local_quant_research/evidence.py` +- Modify: `scripts/research/local_quant_research/cli.py` +- Modify: `.agents/skills/run-local-quant-research/SKILL.md` +- Modify: `.agents/skills/run-local-quant-research/agents/openai.yaml` +- Modify: `tests/local_quant_research/test_runner.py` +- Modify: `tests/local_quant_research/test_evidence.py` +- Modify: `tests/local_quant_research/test_skill_contract.py` +- Modify: `tests/local_quant_research/test_generic_e2e.py` +- Create: `tests/local_quant_research/test_vectorbt_runtime.py` + +**Interfaces:** +- Consumes: 配置 v2、`LoadedStrategy`、共享行情 `SnapshotView`、`run_vectorbt()`、`write_result_package()`。 +- Produces: `execute_scenario(request) -> ScenarioOutcome`、`run_project(config_path, repo_root) -> RunResult`、固定 `_execute` 子进程协议和稳定三状态语义。 + +- [x] **Step 1: 把 runner/Skill 改成配置 v2 并写最小 runtime 失败测试** + +新有效配置只包含以下顶层键;分别注入旧字段并断言 `ConfigurationError("legacy_run_field")`: + +```python +VALID_V2 = { + "schema_version": 2, + "project_id": "minimal-fixture", + "strategy": { + "root": "tests/local_quant_research/fixtures/minimal_strategy", + "module": "strategy", + "symbol": "MODULE", + }, + "snapshot_id": "a" * 64, + "snapshot_requirements": {}, + "scenario_config": "tests/local_quant_research/fixtures/minimal-scenario.json", + "declared_inputs": [], +} + +LEGACY_RUN_FIELDS = ("command", "project_entry", "code_identity", "required_outputs", "output_root", "stop_states") +``` + +Skill 测试断言只公开共享 `run --config` 和 `promote --strategy-id --run-id --analysis-id`,不出现策略字段解释、任意命令或项目入口。`test_vectorbt_runtime.py` 同时定义一列、两日、无订单 `OrderProgram`,断言 `run_vectorbt()` 返回 `ExecutionRun` 且 ledger value(账本净值)为只读数组。 + +- [x] **Step 2: 运行共享 runner、evidence 和 Skill 测试并确认失败** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_runner.py tests\local_quant_research\test_evidence.py tests\local_quant_research\test_skill_contract.py tests\local_quant_research\test_generic_e2e.py tests\local_quant_research\test_vectorbt_runtime.py -q +``` + +Expected: FAIL,旧配置仍要求 `project_entry/command/code_identity/required_outputs/output_root/stop_states`。 + +- [x] **Step 3: 重写 RunConfig 和 v2 配置加载** + +`RunConfig` 固定为以下输入;输出根和停止状态改为模块常量,不能由项目覆盖: + +```python +RUN_OUTPUT_ROOT = Path(".local/quant-research") +RUN_STATUSES = ("complete", "evidence_insufficient", "failed") + +@dataclass(frozen=True, slots=True) +class RunConfig: + project_id: str + strategy_root: Path + strategy_module: str + strategy_symbol: str + snapshot_id: str + snapshot_requirements: Mapping[str, object] + scenario_config: Path + declared_inputs: tuple[Path, ...] + document: Mapping[str, object] +``` + +配置加载先拒绝未知/旧字段,再解析仓库内路径;外部 JSON 只使用嵌套 `strategy.root/module/symbol`,加载后映射为 `RunConfig.strategy_root/strategy_module/strategy_symbol`。本任务保持当前运行身份边界:由冻结的配置、行情 manifest、descriptor source files、固定共享 runtime 文件集和已安装依赖共同生成,不再读取手工输入型 `code-identity.json`;源码身份的单一来源改造只属于 Task 7。 + +- [x] **Step 4: 先实现最小共享 vectorbt runtime 与日常一次冷/热执行** + +先在 `test_vectorbt_runtime.py` 写最小无订单 `OrderProgram` 的失败测试,再创建共享 `run_vectorbt()`:它必须调用 `Portfolio.from_order_func()`,隐藏原始 Portfolio(组合),并返回 `ExecutionRun`。本任务验收边界是两个最小 Strategy Module(策略模块)能通过共享 runtime 完成一列、两日、无订单运行;稳定优先级、完整成交回调、惰性缓存与后续程序在 Task 9 的独立失败测试中扩展。 + +`execute_scenario()` 必须按固定阶段计时:`strategy_load/strategy_prepare/primary_vectorbt/followup_prepare/followup_vectorbt/core_facts/strategy_extensions/parquet_materialize/readback_validate/report_and_manifest`。每个 `run` 在一个全新 `_execute` 子进程里执行一次 cold(冷)和一次 warm(热),比较完整 execution digest(执行摘要);两次任一超过 180 秒或摘要不同,返回稳定失败原因并清理暂存目录。 + +即时路径使用 `ExecutionBundle(primary, primary, ("primary",))`;策略返回后续程序时,运行第二次 `run_vectorbt()` 并使用 `ExecutionBundle(primary, final, ("primary", "followup"))`。结果 writer 只读取 `final`。 + +- [x] **Step 5: 固定父进程与私有 `_execute` 协议** + +父 runner 继续保留现有输入冻结、清洁环境、证据不足映射、失败 attempt(尝试)证据、完成目录复用和原子发布;子进程命令只能由共享代码生成: + +```python +command = ( + repo_root / ".venv/Scripts/python.exe", + repo_root / "scripts/research/local_quant_research/cli.py", + "_execute", + "--frozen-inputs", execution_root / "request.json", + "--staging", staging, +) +``` + +`_execute` 不写入公开帮助,不接受项目配置提供的命令。只有 `ConfigurationError`、`StrategyEvidenceError` 和行情缺失的显式 evidence(证据)分支映射为 `evidence_insufficient`;未知异常统一为 `failed`,不把 traceback(堆栈)或敏感环境写入 manifest。 + +- [x] **Step 6: 更新 Skill 与两个共享 E2E 测试** + +Skill 明确结果包本身 archive-ready(可归档),并保持“单次调用、单个场景、返回调用者”边界。`test_generic_e2e.py` 使用最小 Strategy Module 从公开 CLI 完成 v2 `run`,断言四表、自包含目录、机械报告、复用和无 DuckDB(数据库)残留;另加配置缺失与摘要冲突入口测试。 + +- [x] **Step 7: 运行共享全链路测试** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_runner.py tests\local_quant_research\test_evidence.py tests\local_quant_research\test_skill_contract.py tests\local_quant_research\test_generic_e2e.py tests\local_quant_research\test_vectorbt_runtime.py -q +``` + +Expected: PASS;最小策略不修改共享入口即可完成运行,状态仍严格为 `complete/evidence_insufficient/failed`。 + +- [x] **Step 8: 提交共享编排** + +```powershell +git add -- scripts/research/local_quant_research/contracts.py scripts/research/local_quant_research/vectorbt_runtime.py scripts/research/local_quant_research/scenario.py scripts/research/local_quant_research/performance.py scripts/research/local_quant_research/runner.py scripts/research/local_quant_research/evidence.py scripts/research/local_quant_research/cli.py .agents/skills/run-local-quant-research/SKILL.md .agents/skills/run-local-quant-research/agents/openai.yaml tests/local_quant_research/test_runner.py tests/local_quant_research/test_evidence.py tests/local_quant_research/test_skill_contract.py tests/local_quant_research/test_generic_e2e.py tests/local_quant_research/test_vectorbt_runtime.py +git diff --cached --name-only +git commit -m "重构:统一本地研究共享运行入口" +``` + +--- + +### Task 6: 收窄扩展表并收敛共享 writer + +**Files:** +- Modify: `scripts/research/local_quant_research/evidence.py` +- Modify: `scripts/research/local_quant_research/scenario.py` +- Modify: `scripts/research/local_quant_research/result_package.py` +- Modify: `tests/local_quant_research/test_evidence.py` +- Modify: `tests/local_quant_research/test_result_package.py` +- Modify: `tests/local_quant_research/test_analysis_data_views.py` +- Modify: `tests/local_quant_research/test_runner.py` +- Modify: `tests/local_quant_research/test_generic_e2e.py` + +**Interfaces:** +- Consumes: Task 5 已交付的 cold/warm(冷/热)执行结果、核心 NumPy 事实与 `tuple[ResultExtension, ...]`。 +- Produces: 只接受扁平 `string/bool/int64/float64` 的扩展契约,以及一次物化、一次回读的内部 writer;公开 validator 继续只读磁盘。 + +- [x] **Step 1: 写扁平扩展与单次 writer 失败测试** + +在 `test_evidence.py`、`test_result_package.py` 和 `test_runner.py` 增加边界测试:合法扩展只能包含扁平 `string/bool/int64/float64`;浮点缺失必须使用 Arrow null;NaN、dictionary、list、struct、map、union、run-end encoded 及其他类型在冷/热比较前固定返回 `failed/result_contract_failed`。另用 monkeypatch(运行期替换)证明内部 writer 不调用公开 validator,且一次运行只物化、回读每个数据集一次。 + +- [x] **Step 2: 运行结果包与 runner 测试并确认旧通用摘要仍存在** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_evidence.py tests\local_quant_research\test_result_package.py tests\local_quant_research\test_runner.py -q +``` + +Expected: FAIL,现有实现仍接受越界 Arrow 类型、使用递归逻辑摘要或维护内部预加载校验路径。 + +- [x] **Step 3: 复用 PyArrow 并收敛 writer** + +扩展先执行 `Table.validate(full=True)`、精确 Schema 比较和 `Table.equals(check_metadata=True)`;核心 execution digest(执行摘要)继续只流式读取 NumPy 数组。删除递归 Arrow 类型解码、任意类型逻辑哈希和对应越界类型测试。writer 内部复用唯一回读事实完成 Schema、唯一键、勾稽、报告和最终 manifest;公开 `validate_result_package()` 保持纯磁盘读取,删除全部 `preloaded_*` 参数和 provisional/final 两套完整包路径。完成文件完整性只使用实际 Parquet SHA256。包内性能字段只诚实记录最终元数据一次写入前的 `prefinalization_seconds`;日常绝对门禁使用 writer 返回耗时。父进程原子发布和发布后校验的完整 CLI 计时留在 Task 12 的外部发布验证报告,不为把自指总耗时写回结果包恢复双写、双包或旁路清单。 + +- [x] **Step 4: 运行结果包与共享入口回归** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_evidence.py tests\local_quant_research\test_result_package.py tests\local_quant_research\test_analysis_data_views.py tests\local_quant_research\test_runner.py tests\local_quant_research\test_generic_e2e.py -q +``` + +Expected: PASS;冷/热扩展使用 PyArrow(列式计算库)现成校验与判等,writer 不再存在第二条预加载校验路径;包内预最终化计时和 writer 返回门禁的边界真实且没有自指回写。 + +- [x] **Step 5: 提交共享结果精简** + +```powershell +git add -- scripts/research/local_quant_research/evidence.py scripts/research/local_quant_research/scenario.py scripts/research/local_quant_research/result_package.py tests/local_quant_research/test_evidence.py tests/local_quant_research/test_result_package.py tests/local_quant_research/test_analysis_data_views.py tests/local_quant_research/test_runner.py tests/local_quant_research/test_generic_e2e.py +git diff --cached --name-only +git commit -m "精简:收敛共享结果与列式契约" +``` + +--- + +### Task 7: 统一策略源码身份并简化加载 + +**Files:** +- Modify: `scripts/research/local_quant_research/contracts.py` +- Modify: `scripts/research/local_quant_research/cli.py` +- Modify: `scripts/research/local_quant_research/strategy_loader.py` +- Modify: `scripts/research/local_quant_research/runner.py` +- Modify: `scripts/research/local_quant_research/result_package.py` +- Modify: `tests/local_quant_research/fixtures/minimal_strategy/strategy.py` +- Create: `tests/local_quant_research/fixtures/minimal_strategy_b/__init__.py` +- Modify: `tests/local_quant_research/fixtures/minimal_strategy_b/strategy.py` +- Modify: `tests/local_quant_research/test_strategy_contract.py` +- Modify: `tests/local_quant_research/test_strategy_identity.py` +- Modify: `tests/local_quant_research/test_runner.py` +- Modify: `tests/local_quant_research/test_result_package.py` +- Modify: `tests/local_quant_research/test_vectorbt_runtime.py` +- Modify: `tests/local_quant_research/test_generic_e2e.py` + +**Interfaces:** +- Consumes: 仓库内策略根、module/symbol 和全新单策略 `_execute` 子进程。 +- Produces: `discover_strategy_sources(strategy_root, module) -> tuple[Path, ...]`,该排序集合只来自当前 module 顶层包并同时驱动运行身份和档案 `code/`;`StrategyDescriptor` 不再保存第二份源码清单。 + +- [x] **Step 1: 写单一源码身份与标准导入失败测试** + +测试断言 `StrategyDescriptor` 字段中不存在 `source_files`;当前 module 顶层包内全部普通 `.py` 文件按相对路径排序后同时进入 identity(身份)和结果包 `code/`,相邻目录与 `research/archives/` 不进入。第二个最小策略只保留公开 `MODULE`、一次相对导入和无扩展结果,证明标准包导入可用。源码边界测试同时禁止 UUID 私有模块名、全局导入锁、手工 `sys.modules` 清理器,以及 v2 `_execute` 注入 `adapter_guard` 或安装 audit hook。 + +- [x] **Step 2: 运行 loader、identity 和结果包测试并确认失败** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_strategy_contract.py tests\local_quant_research\test_strategy_identity.py tests\local_quant_research\test_runner.py tests\local_quant_research\test_result_package.py -q +``` + +Expected: FAIL,现有 descriptor、私有命名空间或档案源码路径仍维护第二份身份来源。 + +- [x] **Step 3: 实现静态发现唯一来源与标准 importlib** + +父进程先在受限策略根内解析 module,再以其顶层包目录(单文件 module 则用文件所在目录)为源码边界,发现全部普通 `.py` 文件并拒绝链接与路径逃逸;排序结果直接传给运行身份冻结和结果包代码复制。每个 `_execute` 都是全新、只加载一个策略的子进程:把冻结策略根放到 `sys.path` 首位,调用标准 `importlib.import_module()` 后读取 symbol;删除 UUID 命名空间、全局导入锁、手工模块缓存生命周期和 v2 audit hook 注入。Task 11 切断旧 command 路径前可暂留只被 v1 使用的 `adapter_guard.py`,但 v2 不再复制或加载它。 + +- [x] **Step 4: 收缩第二个 fixture 并运行共享 E2E 回归** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_strategy_contract.py tests\local_quant_research\test_strategy_identity.py tests\local_quant_research\test_runner.py tests\local_quant_research\test_result_package.py tests\local_quant_research\test_generic_e2e.py -q +``` + +Expected: PASS;新增或重组私有 `.py` 文件无需同步 descriptor,仍会自动进入 identity 与档案。 + +- [x] **Step 5: 提交源码身份精简** + +```powershell +git add -- scripts/research/local_quant_research/contracts.py scripts/research/local_quant_research/cli.py scripts/research/local_quant_research/strategy_loader.py scripts/research/local_quant_research/runner.py scripts/research/local_quant_research/result_package.py tests/local_quant_research/fixtures/minimal_strategy/strategy.py tests/local_quant_research/fixtures/minimal_strategy_b/__init__.py tests/local_quant_research/fixtures/minimal_strategy_b/strategy.py tests/local_quant_research/test_strategy_contract.py tests/local_quant_research/test_strategy_identity.py tests/local_quant_research/test_runner.py tests/local_quant_research/test_result_package.py tests/local_quant_research/test_vectorbt_runtime.py tests/local_quant_research/test_generic_e2e.py +git diff --cached --name-only +git commit -m "精简:统一策略源码身份与加载" +``` + +--- + +### Task 8: 简化可信工作区内的档案晋升 + +**Files:** +- Modify: `scripts/research/local_quant_research/archive.py` +- Modify: `tests/local_quant_research/test_archive_promotion.py` + +**Interfaces:** +- Consumes: 已完成并通过公开 validator 的本机同一用户可信结果包。 +- Produces: 预扫描 → `shutil.copy2` → 复制后长度/SHA256 复核 → `os.replace` 的幂等原子晋升;不承诺防御同一用户敌对并发替换源树。 + +- [x] **Step 1: 保留面向行为的晋升失败测试** + +先保留现有测试,并确保行为测试覆盖非法链接/目录连接/非普通文件/硬链接在复制前拒绝、源目标逐字节一致、同内容复用、异内容冲突、目标抢占和复制/校验/发布中断清理。再增加一个聚焦的 AST(语法树)边界测试:`archive.py` 不得定义 `_open_verified_descriptor/_descriptor_identity/_file_identity`,也不得调用 `os.open/dup/fdopen/fstat`;此时不得先删除旧描述符或 inode 竞态测试。 + +- [x] **Step 2: 运行 archive 测试并确认简化边界尚未满足** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_archive_promotion.py -q +``` + +Expected: FAIL,新增 AST 边界测试识别出现有描述符和 inode 状态机;现有行为测试继续通过。 + +- [x] **Step 3: 实现标准扫描、复制、复核和原子发布** + +晋升开始前用 `lstat/rglob` 扫描源树并拒绝链接、目录连接、非普通文件和硬链接;使用标准 `shutil.copy2` 复制到目标同级暂存目录,再逐文件复核长度和 SHA256。目标不存在时用 `os.replace` 发布;目标已存在时只做同内容复用或异内容冲突。任何失败只清理本次暂存和新建空父目录,不覆盖现有档案。最小实现通过 AST 边界测试后,再删除只验证旧描述符关闭、inode 替换和扫描后敌对换树内部机制的测试,保留全部外部行为回归。 + +- [x] **Step 4: 运行晋升与查询回归** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_archive_promotion.py tests\local_quant_research\test_analysis_data_views.py -q +``` + +Expected: PASS;删除 `.local` 后档案仍可查询,晋升不导入策略、vectorbt 或 Parquet writer。 + +- [x] **Step 5: 提交档案晋升精简** + +```powershell +git add -- scripts/research/local_quant_research/archive.py tests/local_quant_research/test_archive_promotion.py +git diff --cached --name-only +git commit -m "精简:收敛研究档案晋升" +``` + +--- + +### Task 9: 完善通用 vectorbt 唯一账本 + +**Files:** +- Modify: `scripts/research/local_quant_research/vectorbt_runtime.py` +- Modify: `tests/local_quant_research/test_vectorbt_runtime.py` + +**Interfaces:** +- Consumes: `LedgerInput`、预分配 `OrderBuffer`、策略 `OrderProgram` 的三个 callback(回调)和只读 trace(轨迹)。 +- Produces: 通用 `run_vectorbt(ledger_input, program) -> ExecutionRun`;两个最小 fixture 的 primary/follow-up 都走同一入口,`ExecutionLedger.orders/assets/cash/value/trades/positions/returns` 直接复用 vectorbt 访问器并惰性、只读、各计算一次。 + +- [x] **Step 1: 写共享回调、优先级、成交事件和惰性缓存失败测试** + +测试一组两列订单:同优先级按原 column(列)稳定排序,卖出优先买入;`order_func_nb` 只把启用槽转换为 `nb.order_nb`;`after_fill_nb` 只在真实成交后推进状态。另用 monkeypatch(运行期替换)统计 portfolio accessor(组合访问器)调用与内存共享: + +```python +def test_ledger_computes_and_freezes_each_view_once(portfolio: FakePortfolio) -> None: + ledger = ExecutionLedger(portfolio) + first = ledger.value + second = ledger.value + assert first is second + assert first.flags.writeable is False + assert portfolio.calls["value"] == 1 + +def test_priority_is_stable_for_equal_orders() -> None: + assert stable_call_sequence(np.array([2, 1, 1]), np.array([0, 1, 2])) == (1, 2, 0) +``` + +- [x] **Step 2: 运行 runtime 扩展测试并确认高级账本约束缺失** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_vectorbt_runtime.py -q +``` + +Expected: FAIL,最小 runtime 尚未满足稳定优先级、成交回调、惰性缓存、共享内存或 primary/follow-up(主/后续)双程序约束。 + +- [x] **Step 3: 实现唯一共享 `from_order_func()` 接线** + +`vectorbt_runtime.py` 是仓库唯一允许 `import vectorbt as vbt` 与 `from vectorbt.portfolio import nb` 的模块。`run_vectorbt()` 固定 `cash_sharing`、group、frequency 和 `max_logs=0`,默认保留 trades/positions 所需的 vectorbt 记录并直接使用其访问器;没有等价性与性能证据时不得关闭记录后自行重建。回调生命周期必须为:reset buffer → 构造 `SegmentView` → 策略 prepare → 原地稳定 call sequence → 转换订单 → 构造 `FillEvent` → 策略 after fill → 可选 after segment。 + +```python +@dataclass(frozen=True, slots=True) +class _SpecializedCallbacks: + order_func_nb: object + order_args: tuple[object, ...] + pre_sim_func_nb: object + pre_sim_args: tuple[object, ...] + pre_segment_func_nb: object + pre_segment_args: tuple[object, ...] + post_order_func_nb: object + post_order_args: tuple[object, ...] + post_segment_func_nb: object + post_segment_args: tuple[object, ...] + +def run_vectorbt(ledger_input: LedgerInput, program: OrderProgram) -> ExecutionRun: + callbacks = _specialize_program(program) + rows, columns = ledger_input.close.shape + portfolio = vbt.Portfolio.from_order_func( + _close_frame(ledger_input), + callbacks.order_func_nb, + *callbacks.order_args, + pre_sim_func_nb=callbacks.pre_sim_func_nb, + pre_sim_args=callbacks.pre_sim_args, + pre_segment_func_nb=callbacks.pre_segment_func_nb, + pre_segment_args=callbacks.pre_segment_args, + post_order_func_nb=callbacks.post_order_func_nb, + post_order_args=callbacks.post_order_args, + post_segment_func_nb=callbacks.post_segment_func_nb, + post_segment_args=callbacks.post_segment_args, + init_cash=ledger_input.initial_cash, + group_by=ledger_input.group_ids, + cash_sharing=ledger_input.cash_sharing, + call_pre_segment=True, + update_value=True, + ffill_val_price=True, + max_orders=rows * columns, + freq=ledger_input.frequency, + max_logs=0, + use_numba=True, + ) + return ExecutionRun(ledger=ExecutionLedger(portfolio), trace=_freeze_trace(program.trace)) +``` + +`_specialize_program()` 按 callback(回调)函数身份缓存 `_SpecializedCallbacks`,只把 Numba 可接受的数组/标量 tuple(元组)传给 vectorbt,不能把 Python dataclass(数据类)塞进已编译回调。实际参数位置必须以 vectorbt 1.1.0 已安装签名和现有 `vectorbt_engine.py::_run_immediate()` 为准;不能用兼容捕获隐藏签名错误。 + +- [x] **Step 4: 实现惰性只读 ExecutionLedger** + +ledger 私有保存 Portfolio(组合)并为七个公开属性各设单独 cache。首次访问若结果是独占连续 `ndarray` 只设置 `writeable=False`;临时或非连续视图只复制一次。测试必须同时记录 accessor 次数和 `np.shares_memory()`,防止无意深复制。 + +- [x] **Step 5: 证明即时和后续程序都经过同一 runtime** + +用两个通用 fixture `OrderProgram` 验证 primary(主运行)与 follow-up(后续运行)都调用同一个 `run_vectorbt()`,第二个运行的 ledger 可作为 final ledger。runtime 不解释冻结计划、执行日可交易性或策略原因码;这些海龟私有语义在 Task 10 组装为 follow-up program。runtime 不得调用 `from_orders()`,不得在 Python 循环中推进现金、仓位或净值。 + +- [x] **Step 6: 运行共享 runtime 与旧路径回归** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_vectorbt_runtime.py tests\local_quant_research\test_turtle_vectorbt_engine.py tests\local_quant_research\test_turtle_vectorbt_delayed.py tests\local_quant_research\test_turtle_vectorbt_callbacks.py -q +``` + +Expected: PASS;共享 fixture 覆盖即时与后续程序,每个 ledger 视图最多计算一次;尚未切换的旧海龟即时/延迟回归保持通过。 + +- [x] **Step 7: 提交唯一账本 runtime** + +```powershell +git add -- scripts/research/local_quant_research/vectorbt_runtime.py tests/local_quant_research/test_vectorbt_runtime.py +git diff --cached --name-only +git commit -m "重构:以vectorbt统一即时与延迟账本" +``` + +--- + +### Task 10: 收敛海龟为单一公开 Strategy Module 并迁移即时与延迟执行 + +**Files:** +- Modify: `joinquant/strategies/strategy-003/research/turtle_etf/__init__.py` +- Create: `joinquant/strategies/strategy-003/research/turtle_etf/strategy.py` +- Create: `joinquant/strategies/strategy-003/research/turtle_etf/_kernel.py` +- Create: `joinquant/strategies/strategy-003/research/turtle_etf/_attribution.py` +- Create: `joinquant/strategies/strategy-003/research/turtle_etf/_delayed.py` +- Modify: `tests/local_quant_research/test_turtle_indicators.py` +- Modify: `tests/local_quant_research/test_turtle_vectorbt_inputs.py` +- Modify: `tests/local_quant_research/test_turtle_vectorbt_engine.py` +- Modify: `tests/local_quant_research/test_turtle_vectorbt_delayed.py` +- Modify: `tests/local_quant_research/test_turtle_vectorbt_callbacks.py` +- Modify: `tests/local_quant_research/test_turtle_result_adapter.py` +- Modify: `tests/local_quant_research/test_turtle_vectorbt_performance.py` +- Modify: `tests/local_quant_research/test_local_research_equivalence.py` +- Modify: `tests/local_quant_research/test_contract_fixtures.py` +- Create: `tests/local_quant_research/fixtures/strategy-module-v1-baseline.json` +- Create: `tests/local_quant_research/test_turtle_strategy_module.py` +- Modify: `tests/local_quant_research/fixtures/performance-baseline.json` + +**Interfaces:** +- Consumes: 共享 `StrategyDescriptor/PreparedStrategy/OrderProgram/ExecutionBundle/ResultExtension` 与现有海龟 baseline(基线)语义。 +- Produces: `turtle_etf.strategy:MODULE` 唯一公开对象;私有 `_kernel/_attribution/_delayed` 不被共享层或外部测试直接导入。 + +- [x] **Step 1: 写唯一公开入口、静态源码发现与导入边界失败测试** + +测试三个公开方法、扩展名称、策略根边界和禁止导入;另断言父进程静态发现的普通 `.py` 文件集合包含四个私有实现并驱动代码身份,不在 descriptor 重复列出: + +```python +def test_turtle_package_has_one_public_strategy_symbol() -> None: + from turtle_etf.strategy import MODULE + assert MODULE.descriptor.strategy_id == "strategy-003" + assert MODULE.descriptor.extension_names == ("turtle_etf",) + assert set(turtle_etf.__all__) == {"MODULE"} + +def test_strategy_sources_do_not_import_vectorbt(repo_root: Path) -> None: + root = repo_root / "joinquant/strategies/strategy-003/research/turtle_etf" + for name in ("strategy.py", "_kernel.py", "_attribution.py", "_delayed.py"): + tree = ast.parse((root / name).read_text(encoding="utf-8")) + imports = {node.names[0].name for node in ast.walk(tree) if isinstance(node, ast.Import)} + imports |= {node.module for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) and node.module} + assert all(not item.startswith("vectorbt") for item in imports) +``` + +- [x] **Step 2: 运行策略模块测试并确认公开 MODULE 尚不存在** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_turtle_strategy_module.py tests\local_quant_research\test_turtle_vectorbt_inputs.py tests\local_quant_research\test_turtle_result_adapter.py -q +``` + +Expected: FAIL,`turtle_etf.strategy` 缺失或测试仍依赖旧公开内部模块。 + +- [x] **Step 3: 迁移输入、参数与订单内核到私有实现** + +把 `vectorbt_inputs.py` 的行情准备与 `vectorbt_callbacks.py` 的 Numba 内核按责任迁到 `_kernel.py`;动作/原因码、单位、共同止损、4/6/12 风险缩放和全量再分配保持策略私有。`prepare()` 必须先验证纯配置,再读取 snapshot(快照)和分配矩阵;缺少声明证据抛 `StrategyEvidenceError(code, message)`,算法错误保留为失败。即时程序与 follow-up(后续)延迟程序都只构造共享 `OrderProgram`,账户变化统一交给 Task 9 的 `run_vectorbt()`。 + +`OrderBuffer` 只在 prepare 阶段预分配一次;每个 segment(分段)原地清零并写入,不创建 vectorbt Order(订单),不保存 vectorbt context(上下文)。 + +- [x] **Step 4: 实现唯一 MODULE 组合对象** + +公开模块只组合私有函数与共享 contract: + +```python +@dataclass(frozen=True, slots=True) +class TurtleStrategyModule: + descriptor: StrategyDescriptor + + def prepare(self, snapshot: SnapshotView, config: Mapping[str, object]) -> PreparedStrategy: + return prepare_turtle_strategy(snapshot, config) + + def followup_program(self, prepared: PreparedStrategy, primary_run: ExecutionRun) -> OrderProgram | None: + return build_delayed_program(prepared, primary_run) + + def build_extensions(self, prepared: PreparedStrategy, execution: ExecutionBundle) -> tuple[ResultExtension, ...]: + return (build_turtle_attribution(prepared, execution),) + +MODULE = TurtleStrategyModule( + descriptor=StrategyDescriptor( + strategy_id="strategy-003", + contract_version="strategy-module/1", + extension_names=("turtle_etf",), + accounting={"lot_size": 100, "cash_sharing": True}, + ) +) +``` + +`__init__.py` 只 re-export(重新导出)`MODULE`。父进程只在 `turtle_etf/` 顶层包内静态发现并排序全部普通 `.py` 文件,同一集合驱动 code identity(代码身份)与档案 `code/`,不得纳入 `research/archives/`。 + +- [x] **Step 5: 迁移归因和延迟计划到私有模块** + +把 `result_adapter.py` 中海龟专属 attribution(归因)表构造移到 `_attribution.py`,只返回 `ResultExtension`;路径、压缩、清单和 Parquet 写入留在共享 writer。把冻结计划与执行日规则移到 `_delayed.py`,只返回后续 `OrderProgram`;不得维护现金、持仓、费用或净值数组。 + +- [x] **Step 6: 把测试改到公开 contract 和结果扩展** + +指标纯函数可通过 `MODULE.prepare()` 的 trace 或私有单元测试 fixture 验证;业务 E2E 只能导入 `MODULE`。删除测试对 `VectorbtSimulationResult`、`DelayedExecutionResult` 和 `LocalResultPackage` 等旧生产类型的依赖。保留 `local-research-v1-baseline.json` 作为历史来源证据,新增 `strategy-module-v1-baseline.json`,只从公开 `ExecutionLedger + ResultExtension` 计算成交、费用、现金、持仓、净值、状态和逻辑摘要,并用旧 fixture 的 SHA256 绑定迁移来源;不得为继续使用旧摘要而恢复生产兼容适配器。公司行动、延迟截断、加仓和清仓损益勾稽只补最小高价值边界测试。 + +- [x] **Step 7: 在旧生产文件删除前补齐可比体积与完整 CLI 基线** + +Task 10 只复制逻辑到新私有模块,不修改或删除旧 CLI、engine、writer、delayed 等被替代实现;`__init__.py` 直接重新导出公开 MODULE。先从项目 `.venv` 重跑旧公开入口的三个固定场景,保留引擎 3 个冷进程、同一进程 5 次预热和三个完整 CLI 冷样本的原时间/内存观测,并把旧 `package_bytes` 拆为“同逻辑核心/扩展 Parquet 数据载荷”与“其他固定文件”两项。fixture 保存场景、环境、进程、run_id、package_sha256、非复用与发布后校验状态,作为 Task 12 人工性能复核的历史数据,不携带自动门禁阈值。完成该步骤后 Task 11 才可删除旧入口。 + +先在 `test_turtle_vectorbt_performance.py` 写 baseline fixture 契约测试,要求三个场景同时具备引擎 3/5 样本、三个完整 CLI 冷样本、Parquet 载荷和固定开销;逐字段断言顶层协议/环境身份,以及每个完整 CLI 样本的 scenario/sample_type/sample_index/PID/run_id/package_sha256/reused/post_publish_validation/cold_cli_total_seconds。任一字段缺失或 `reused`/校验状态不符合即失败;对当前 v1 fixture 运行并确认 RED(失败),完成真实采集后重跑为 GREEN(通过): + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_turtle_vectorbt_performance.py -q -k "baseline_contract or full_cli_baseline" +``` + +Expected: 采集前 FAIL 且明确缺少 `local-research-release/2`/`cold_cli_total_seconds` 或最小绑定字段;采集后 PASS。Task 11 开始前必须再运行一次该命令并保存通过结果,禁止删除旧入口后补写或推算 baseline。 + +- [x] **Step 8: 运行海龟模块与等价性测试** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_turtle_strategy_module.py tests\local_quant_research\test_turtle_indicators.py tests\local_quant_research\test_turtle_vectorbt_inputs.py tests\local_quant_research\test_turtle_vectorbt_engine.py tests\local_quant_research\test_turtle_vectorbt_delayed.py tests\local_quant_research\test_turtle_vectorbt_callbacks.py tests\local_quant_research\test_turtle_result_adapter.py tests\local_quant_research\test_turtle_vectorbt_performance.py tests\local_quant_research\test_local_research_equivalence.py -q +``` + +Expected: PASS;公开 API(接口)只有 `MODULE`,共享目录不导入海龟,策略文件不导入 vectorbt。 + +- [x] **Step 9: 提交策略模块与可比基线** + +```powershell +git add -- joinquant/strategies/strategy-003/research/turtle_etf/__init__.py joinquant/strategies/strategy-003/research/turtle_etf/strategy.py joinquant/strategies/strategy-003/research/turtle_etf/_kernel.py joinquant/strategies/strategy-003/research/turtle_etf/_attribution.py joinquant/strategies/strategy-003/research/turtle_etf/_delayed.py tests/local_quant_research/fixtures/performance-baseline.json tests/local_quant_research/fixtures/strategy-module-v1-baseline.json tests/local_quant_research/test_turtle_strategy_module.py tests/local_quant_research/test_turtle_indicators.py tests/local_quant_research/test_turtle_vectorbt_inputs.py tests/local_quant_research/test_turtle_vectorbt_engine.py tests/local_quant_research/test_turtle_vectorbt_delayed.py tests/local_quant_research/test_turtle_vectorbt_callbacks.py tests/local_quant_research/test_turtle_result_adapter.py tests/local_quant_research/test_turtle_vectorbt_performance.py tests/local_quant_research/test_local_research_equivalence.py tests/local_quant_research/test_contract_fixtures.py docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md openspec/changes/refactor-local-research-three-layer-architecture/design.md openspec/changes/refactor-local-research-three-layer-architecture/tasks.md openspec/changes/refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md +git diff --cached --name-only +git commit -m "重构:收敛海龟策略公开模块" +``` + +--- + +### Task 11: 单次切换生产配置并完成 run → package → promote E2E + +**Files:** +- Modify: `scripts/research/local_quant_research/runner.py` +- Delete: `scripts/research/local_quant_research/adapter_guard.py` +- Modify: `joinquant/strategies/strategy-003/research/project-run.json` +- Delete: `joinquant/strategies/strategy-003/research/code-identity.json` +- Delete: `joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_inputs.py` +- Delete: `joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_callbacks.py` +- Delete: `joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_engine.py` +- Delete: `joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_delayed.py` +- Delete: `joinquant/strategies/strategy-003/research/turtle_etf/result_adapter.py` +- Delete: `joinquant/strategies/strategy-003/research/turtle_etf/single_scenario.py` +- Delete: `joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_benchmark.py` +- Delete: `joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_cli.py` +- Delete: `joinquant/strategies/strategy-003/research/turtle_etf/indicators.py` +- Modify: `tests/local_quant_research/test_contract_fixtures.py` +- Modify: `tests/local_quant_research/test_runner.py` +- Modify: `tests/local_quant_research/test_turtle_e2e.py` +- Modify: `tests/local_quant_research/test_turtle_vectorbt_engine.py` +- Delete: `tests/local_quant_research/test_turtle_single_scenario.py` +- Create: `tests/local_quant_research/test_local_research_v2_e2e.py` +- Modify: `.build-and-verify/config.json` +- Modify: `docs/research/2026-07-13-turtle-etf-system-final-plan.md` +- Modify: `openspec/changes/build-turtle-etf-local-research-workflow/specs/local-quant-research-workflow/spec.md` +- Modify: `openspec/changes/build-turtle-etf-local-research-workflow/specs/standard-strategy-analysis-data/spec.md` + +**Interfaces:** +- Consumes: 完成的共享 runtime、结果包、archive、海龟 `MODULE` 和项目 `baseline.json`。 +- Produces: 唯一生产命令 `cli.py run --config .../project-run.json` 与 `cli.py promote ...`;仓库中不存在旧策略入口或第二套生产账本。 + +- [x] **Step 1: 先把配置、源码扫描和公开 E2E 改成最终契约** + +`project-run.json` 的精确形状改为: + +```json +{ + "schema_version": 2, + "project_id": "strategy-003", + "strategy": { + "root": "joinquant/strategies/strategy-003/research", + "module": "turtle_etf.strategy", + "symbol": "MODULE" + }, + "snapshot_id": "e88238cca420a8ae66b90adb6cda4dd6c38a07390a13b8ac2f471e534742e33e", + "snapshot_requirements": {}, + "scenario_config": "joinquant/strategies/strategy-003/research/baseline.json", + "declared_inputs": ["joinquant/strategies/strategy-003/manifest.json"] +} +``` + +实际提交时保留旧文件中完整 `snapshot_requirements`,不得缩减为示例空对象。源码扫描测试断言:共享目录只有 `vectorbt_runtime.py` 可出现 vectorbt import;旧九个生产文件、共享 `adapter_guard.py` 和策略根手工输入型 `joinquant/strategies/strategy-003/research/code-identity.json` 均不存在;`runner.py` 不再定义 v1 `RunConfig` 字段、`_legacy_load_run_config`、`_legacy_run_project` 或任意 command 执行分支;生产配置中没有旧六字段。完成结果包内自动生成的 `config/code-identity.json` 必须保留。 + +- [x] **Step 2: 运行最终配置和 E2E 测试并确认旧生产路径仍存在** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_contract_fixtures.py tests\local_quant_research\test_turtle_e2e.py tests\local_quant_research\test_local_research_v2_e2e.py -q +``` + +Expected: FAIL,配置仍为 v1、旧文件仍存在或公开入口尚未完成 `promote`。 + +- [x] **Step 3: 在一个提交内切换配置并物理删除旧生产入口** + +先更新配置 v2,再删除列出的九个旧策略文件、共享 `adapter_guard.py` 和策略根手工输入型 `code-identity.json`;同时物理删除 `runner.py` 中 v1 配置/冻结/command 子进程路径,只保留配置 v2 与固定 `_execute`。不得留 re-export(重新导出)兼容模块、feature flag(功能开关)、旧命令分支或双写路径;测试迁移已在 Task 10 完成,因此删除后应无生产/测试导入残留。 + +- [x] **Step 4: 实现完整用户入口 E2E** + +新 E2E 从 `.venv` 子进程执行共享 `run`,验证 `.local/quant-research/strategy-003//` 的 `manifest/code/config/data/extensions/evidence/report`、四张核心表、海龟扩展、runtime lock(运行时锁定)、性能证据与机械报告;随后执行公开 `promote`,验证策略档案逐字节一致。 + +真实入口验证相同运行复用、相同晋升复用和删除 `.local` 后档案仍可查询;已有共享契约测试覆盖证据不足、执行失败、摘要冲突、性能超限、`analysis_id` 冲突和中途失败清理。无需在耗时 E2E 中重复构造相同失败分支。测试产生的 `.local/e2e-tests` 与策略 `archives/` 必须在 `finally` 清理。 + +- [x] **Step 5: 同步 Skill、研究说明、旧规格与 Build and Verify** + +文档明确本地 archive(档案)位于 `research/archives/`,语义上不是聚宽正式 `backtests/` 或 `simulations/`;机械执行报告不承担分析、推荐或人工批准。`.build-and-verify/config.json` 将所有新共享模块、策略私有文件、配置、Skill 与 E2E 测试纳入同一 local quant research(本地量化研究)检查,使用项目 `.venv`。 + +- [x] **Step 6: 运行公开 E2E 和源码边界检查** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_contract_fixtures.py tests\local_quant_research\test_strategy_contract.py tests\local_quant_research\test_turtle_e2e.py tests\local_quant_research\test_local_research_v2_e2e.py tests\local_quant_research\test_skill_contract.py -q +rg -n "import vectorbt|from vectorbt" scripts joinquant/strategies/strategy-003/research/turtle_etf +``` + +Expected: 全部 PASS;vectorbt import 的唯一命中是 `scripts/research/local_quant_research/vectorbt_runtime.py`;旧九个文件与策略根手工 `code-identity.json` 不存在,生成包的 `config/code-identity.json` 仍通过清单校验。 + +- [x] **Step 7: 提交单次生产切换** + +```powershell +git add -A -- joinquant/strategies/strategy-003/research scripts/research/local_quant_research tests/local_quant_research .agents/skills/run-local-quant-research .build-and-verify/config.json docs/research/2026-07-13-turtle-etf-system-final-plan.md openspec/changes/build-turtle-etf-local-research-workflow/specs/local-quant-research-workflow/spec.md openspec/changes/build-turtle-etf-local-research-workflow/specs/standard-strategy-analysis-data/spec.md +git diff --cached --name-only +git commit -m "重构:切换本地研究三层生产架构" +``` + +--- + +### Task 12: 人工确认性能并完成仓库级验证 + +**Files:** +- Modify: `.build-and-verify/config.json` +- Modify: `scripts/research/local_quant_research/result_package.py` +- Modify: `tests/local_quant_research/fixtures/performance-baseline.json` +- Modify: `tests/local_quant_research/test_contract_fixtures.py` +- Modify: `tests/local_quant_research/test_result_package.py` +- Modify: `tests/local_quant_research/test_runner.py` +- Modify: `tests/test_skill_layout.py` +- Modify: `docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md` +- Modify: `openspec/changes/refactor-local-research-three-layer-architecture/design.md` +- Modify: `openspec/changes/refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md` +- Modify: `openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-result-package/spec.md` +- Modify: `openspec/changes/refactor-local-research-three-layer-architecture/tasks.md` +- Create: `docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md` + +**Interfaces:** +- Consumes: Task 1/10 已冻结的三个场景历史性能观测、本次真实运行观测、零差异测试和完整 Build and Verify(构建与验证)结果。 +- Produces: 一份列出时间、内存、Parquet(列式文件)数据载荷差异及用户确认的验证报告。共享 CLI(命令行入口)仍只公开 `run` 与 `promote`。 + +- [x] **Step 1: 保存历史和当前性能观测** + +保留旧入口删除前已采集的三个场景时间、内存和数据体积观测,并把当前运行结果写入验证报告。性能 fixture 只保存观测数据,不携带自动门禁阈值。 + +- [x] **Step 2: 向用户列出现状并取得人工确认** + +列出三个场景的完整 CLI、引擎 cold/warm(冷/热)、峰值内存和 Parquet 数据载荷差异,同时说明零差异、180 秒超时、完整 E2E(端到端)和 Build and Verify 结果。用户于 2026-07-18 明确确认接受当前性能结果。 + +- [x] **Step 3: 删除自动发布性能门禁** + +先增加失败测试,断言公开 CLI 不包含 `performance release`;确认 RED(失败)后,删除发布采样器、Windows 进程内存轮询、策略性能 profile、自动 5% 判退、专用发布时间字段及其测试,恢复为既有日常 cold/warm 确定性和 180 秒超时。 + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_runner.py::test_public_cli_omits_release_performance_workflow -q +``` + +Expected: GREEN(通过),共享 CLI 只有 `run` 和 `promote`。 + +- [x] **Step 4: 保留有实测收益的 Zstd 写入** + +共享结果 writer(写入器)固定使用 PyArrow 已提供的 Zstd(列式压缩),不新增依赖或封装。结果包测试断言清单、回读验证和实际 Parquet 元数据均为 Zstd。 + +- [x] **Step 5: 运行完整验证与残留扫描** + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research -q +.\.venv\Scripts\python.exe .build-and-verify\runtime\build_and_verify.py verify --project . --full +rg -n "run_release|release_performance|performance release|performance-scenarios|finalize_publish_seconds" scripts tests joinquant .agents +rg -n "Portfolio\.from_orders|run_delayed_execution|vectorbt_cli|single_scenario|vectorbt_benchmark" scripts joinquant tests .agents +Test-Path joinquant\strategies\strategy-003\research\code-identity.json +git status --short +``` + +Expected: 测试和完整验证通过;自动发布性能门禁无生产残留;旧入口命中仅限冻结历史数据或删除断言;策略根身份文件和临时产物不存在。 + +- [x] **Step 6: 完成验证报告和 OpenSpec 任务** + +验证报告记录三份 capability(能力规格)覆盖、正确性与 E2E 结果、性能观测、用户确认、Zstd 数据体积和残留扫描。只按实际证据勾选 OpenSpec 任务。 + +- [x] **Step 7: 提交精简后的验证证据** + +```powershell +git add -- .build-and-verify/config.json scripts/research/local_quant_research/result_package.py tests/local_quant_research/fixtures/performance-baseline.json tests/local_quant_research/test_contract_fixtures.py tests/local_quant_research/test_result_package.py tests/local_quant_research/test_runner.py tests/test_skill_layout.py docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md openspec/changes/refactor-local-research-three-layer-architecture/design.md openspec/changes/refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-result-package/spec.md openspec/changes/refactor-local-research-three-layer-architecture/tasks.md +git diff --cached --name-only +git commit -m "验证:人工确认本地研究性能" +``` +--- + +## Final Acceptance Checklist + +- [x] `scripts/research/local_quant_research/vectorbt_runtime.py` 是唯一 vectorbt 导入点,且即时/延迟都使用 `Portfolio.from_order_func()`。 +- [x] 海龟策略公开面只有 `turtle_etf.strategy:MODULE`,共享层无需修改即可运行最小策略与海龟策略。 +- [x] 配置 v2 不再声明 `command/project_entry/code_identity/required_outputs/output_root/stop_states`,代码身份由静态发现的策略源码集合、共享 runtime 和冻结依赖自动生成。 +- [x] 每个 `.local//` 包含 manifest、code、config、data、extensions、evidence、report,并能原样晋升到策略目录。 +- [x] promotion(晋升)幂等、冲突安全、失败清理、原子发布,删除 `.local` 后档案仍可独立查询且不复制共享行情。 +- [x] 机械执行报告只陈述可复核事实,没有推荐、稳健性或实盘批准判断。 +- [x] 三个固定场景结果零差异,性能、内存和 Parquet 数据载荷差异已列出并由用户确认,日常冷/热单次均小于 180 秒。 +- [x] 公开 `run → package → promote` E2E(端到端)与完整 Build and Verify(构建与验证)通过,没有旧生产入口、双账本或临时产物。 diff --git a/docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md b/docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md new file mode 100644 index 0000000..3e7e3c7 --- /dev/null +++ b/docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md @@ -0,0 +1,88 @@ +# 本地研究三层架构验证报告 + +## 结论 + +状态:**通过**。 + +三层架构、结果包自包含、档案晋升、唯一 vectorbt(向量化回测)账本和公开 CLI(命令行入口)均通过完整验证。性能差异已如实列出,用户于 2026-07-18 明确确认接受;不建设自动相对门禁或专用发布性能命令。 + +## OpenSpec 验证评分 + +| 维度 | 结果 | +|---|---| +| 完整性 | 47/47 项任务完成;18/18 项 Requirement(要求)有实现证据 | +| 正确性 | 40/40 个 Scenario(场景)有自动化测试或端到端证据 | +| 一致性 | OpenSpec(开放规格)、技术设计和实现方向一致,无规格漂移 | + +未发现 CRITICAL(必须修复)、WARNING(警告)或 SUGGESTION(建议)项。 + +## 能力规格覆盖 + +### local-quant-research-runtime(本地量化研究运行时) + +- 配置 v2、共享 CLI、`turtle_etf.strategy:MODULE` 和即时/延迟统一 vectorbt 账本通过单元与 E2E(端到端)验证。 +- 三个冻结场景的成交、费用、现金、持仓、净值、状态和逻辑摘要零差异。 +- 日常 cold/warm(冷/热)确定性检查和各 180 秒超时保留。 +- 公开 CLI 只包含 `run` 与 `promote`,没有发布性能工作流。 + +### local-research-result-package(本地研究结果包) + +- 核心表、策略扩展、清单、逐文件 SHA256、原子发布和发布后校验通过。 +- 共享 writer(写入器)直接使用 PyArrow(列式计算库)提供的 Zstd(列式压缩),没有新增依赖或封装。 +- 三个场景的 Parquet(列式文件)数据载荷相对历史观测分别为 0.9910、0.9919、0.9982。 + +### local-research-archive-promotion(本地研究档案晋升) + +- `run → package → promote`、同内容幂等、异内容冲突、中断清理、删除 `.local` 源后查询和共享行情不复制均通过。 +- 本次变更没有修改 JoinQuant(聚宽)模拟交易同步、定时归档或运行目录隔离。 + +## 性能人工确认 + +下表格式为“当前观测 / 历史观测比值”;它用于说明现状,不作为自动判退条件。 + +| 场景 | 完整 CLI 时间 | 引擎 cold | 引擎 warm | 峰值内存 | Parquet 载荷 | +|---|---:|---:|---:|---:|---:| +| immediate-11-etf | 79.988 秒 / 1.1727 | 20.960 秒 / 0.7664 | 3.153 秒 / 0.6760 | 682,061,824 / 0.8760 | 1,269,788 / 0.9910 | +| immediate-17-etf | 74.767 秒 / 0.9925 | 36.314 秒 / 1.0933 | 4.899 秒 / 0.6669 | 718,110,720 / 0.8356 | 2,161,984 / 0.9919 | +| delayed-11-etf-1d | 78.296 秒 / 1.1326 | 29.027 秒 / 1.0457 | 3.435 秒 / 0.7298 | 728,416,256 / 0.9298 | 1,250,489 / 0.9982 | + +观测结果不呈现统一退化:17 ETF 完整 CLI 优于历史观测;两个 11 ETF 完整 CLI 分别增加 17.3% 和 13.3%,但引擎、内存和数据体积多数改善。所有采集样本均小于 180 秒,最大完整 CLI 样本为 102.656 秒。用户已确认接受当前结果。 + +## 验证结果 + +TDD(测试驱动开发)防回归: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_runner.py::test_public_cli_omits_release_performance_workflow -q +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_contract_fixtures.py::test_local_research_performance_baseline_freezes_observations -q +``` + +两项测试均先按旧行为失败,删除自动门禁与 fixture 阈值后通过。 + +相关回归: + +```powershell +.\.venv\Scripts\python.exe -m pytest tests\local_quant_research\test_contract_fixtures.py tests\local_quant_research\test_runner.py tests\local_quant_research\test_result_package.py tests\test_skill_layout.py -q +``` + +结果:126 项通过。 + +完整 Build and Verify(构建与验证): + +```powershell +.\.venv\Scripts\python.exe .build-and-verify\runtime\build_and_verify.py verify --project . --full +``` + +2026-07-18 进入 Comet verify(变更验证)阶段后重新完整运行。结果:通过,耗时 191.7 秒,11/11 检查项完成;本地研究单元测试 404 项通过,4 条来自 vectorbt 依赖的 Pandas(数据分析库)弃用警告;真实 E2E 8 项通过;OpenSpec(开放规格)严格校验 6 项通过。 + +随后复核 Build and Verify(构建与验证)配置覆盖率,发现 `tests/quant_analysis/` 的 47 项测试只在触发范围内、未进入实际命令。现已将其加入既有 unit(单元测试)命令:配置回归测试按 TDD(测试驱动开发)先失败后通过,修改后的 unit 命令 451 项通过,Skill(技能)配置测试 9 项通过。自动 Pytest(测试框架)覆盖率由 645/693 提升至 692/693(99.86%);剩余 1 项会操作真实 Windows Task Scheduler(Windows 任务计划程序),继续保留为人工系统测试。 + +提交 `eb7d12f` 后再次运行完整 Build and Verify。结果:通过,耗时 202.2 秒,11/11 检查项完成;统一 unit 命令 451 项通过,真实 E2E 8 项通过,自动 Pytest 合计 692 项通过,OpenSpec 严格校验 6 项通过。仍只有 4 条来自 vectorbt 依赖的 Pandas 弃用警告。 + +## 残留扫描 + +- 生产代码中 vectorbt import(导入)的唯一位置为 `scripts/research/local_quant_research/vectorbt_runtime.py`;测试 fixture 中的独立导入不属于生产路径。 +- 自动发布性能命令、采样器、策略性能 profile、专用发布时间字段和临时目录均不存在。 +- 旧入口字符串只存在于冻结历史观测、删除断言或测试名称,不存在生产导入。 +- 策略根 `code-identity.json` 不存在;测试档案、PID 文件和性能工作目录无残留。 +- `.attempts` 是失败运行证据,不按临时产物删除。 diff --git a/docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md b/docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md new file mode 100644 index 0000000..de29cb2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md @@ -0,0 +1,601 @@ +--- +comet_change: refactor-local-research-three-layer-architecture +role: technical-design +canonical_spec: openspec +archived-with: 2026-07-17-refactor-local-research-three-layer-architecture +status: final +--- + +# 本地研究三层架构技术设计 + +## 1. 设计目的 + +本设计深化 OpenSpec(开放规格)已经确认的三个 capability(能力):共享本地研究运行时、后端中立结果包、自包含档案晋升。OpenSpec delta spec(增量规格)仍是行为要求的唯一事实源;本文只定义如何实现这些要求。 + +当前摩擦来自三个位置: + +- 海龟策略目录同时实现策略规则、vectorbt(向量化回测框架)接线、单场景、性能、结果格式和 CLI(命令行入口)。 +- 即时路径把 vectorbt 作为账本,延迟路径却先维护 Python(编程语言)手工账本,再用 `from_orders()` 复核。 +- `.local` 运行包缺少完整策略源码、统一结果契约和可直接晋升的档案结构。 + +重构后只有 Strategy Module(策略模块)随策略变化;共享能力和 vectorbt Adapter(适配器)对新策略保持稳定。 + +## 2. 依赖方向与 Module + +依赖方向固定为: + +```text +joinquant/.../research//strategy.py + │ + │ StrategyModule Interface + ▼ +scripts/research/local_quant_research/ + │ + │ OrderProgram Interface + ▼ +scripts/research/local_quant_research/vectorbt_runtime.py + │ + ▼ +vectorbt.Portfolio.from_order_func +``` + +规则如下: + +1. 只有 `vectorbt_runtime.py` 可以导入 vectorbt 的 `Portfolio`、callback context(回调上下文)、订单枚举和 record dtype(记录结构)。 +2. Strategy Module 只依赖 `local_quant_research.contracts`、共享行情 `SnapshotView`、NumPy(数值数组)和实现策略所需的通用数值库。 +3. `result_package.py` 依赖 `ExecutionLedger` 和 `ResultExtension`,不得导入具体策略。 +4. `analysis_data` 继续拥有后端中立的 manifest(清单)读取、校验和查询视图;本地 writer(写入器)不反向进入读取层。 +5. `.agents/skills/run-local-quant-research/SKILL.md` 只描述公开命令、前提和停止状态,不保存生产代码、行情或结果。 + +删除任一共享 Module 后,其复杂度都会重新出现在所有策略调用方,因而这些 Module 具有足够 Depth(深度);策略私有文件则通过唯一公开 Module 保持 Locality(局部性)。 + +## 3. 共享 Python Interface + +以下定义是实施时必须保持的公开形状;具体私有字段可在不影响调用者的前提下调整。 + +### 3.1 Strategy Module + +```python +from dataclasses import dataclass +from typing import Mapping, Protocol + + +@dataclass(frozen=True, slots=True) +class StrategyDescriptor: + strategy_id: str + contract_version: str + extension_names: tuple[str, ...] + accounting: Mapping[str, object] + + +@dataclass(frozen=True, slots=True) +class PreparedStrategy: + ledger_input: "LedgerInput" + primary_program: "OrderProgram" + context: object + + +class StrategyModule(Protocol): + descriptor: StrategyDescriptor + + def prepare( + self, + snapshot: "SnapshotView", + config: Mapping[str, object], + ) -> PreparedStrategy: ... + + def followup_program( + self, + prepared: PreparedStrategy, + primary_run: "ExecutionRun", + ) -> "OrderProgram | None": ... + + def build_extensions( + self, + prepared: PreparedStrategy, + execution: "ExecutionBundle", + ) -> tuple["ResultExtension", ...]: ... +``` + +不提供独立 `validate_config()`。`prepare()` 必须先完成纯配置校验,只有通过后才能读取行情数组或分配大型矩阵。缺少声明证据时抛出显式 `StrategyEvidenceError(code, message)`;普通 `ValueError` 不得自动映射为证据不足,以免掩盖实现缺陷。 + +父进程先在受限 `strategy_root` 内解析声明的 module,再以该 module 的顶层包目录(单文件 module 则为文件所在目录)作为源码边界,静态发现其中全部普通 `.py` 文件;该排序后的源码集合是唯一权威来源,同时驱动: + +- 运行身份摘要; +- 档案 `code/` 源码快照; +- Strategy Module 代码变更检测。 + +`StrategyDescriptor` 不再维护第二份 `source_files` 清单。源码发现不得越过当前 module 包去扫描 `research/archives/` 或其他相邻目录。每个 `_execute` 都是只加载一个策略的全新子进程,子进程把已冻结的策略根放到 `sys.path` 首位并使用标准 `importlib.import_module()`;不建立 UUID(唯一标识)私有命名空间、全局导入锁或手工 `sys.modules`(模块缓存)生命周期。 + +Strategy Module 是仓库内受版本管理和代码审查的可信代码,不是可上传的敌对插件。运行边界由受限源码路径、冻结输入、清理后的环境、全新子进程和 180 秒超时组成;共享层不安装 Python audit hook(审计钩子),也不自行实现操作系统沙箱。若未来要运行第三方不可信策略,必须作为独立 capability(能力)使用进程级权限隔离设计,不能把该需求塞回当前加载器。 + +### 3.2 执行 contracts + +```python +@dataclass(frozen=True, slots=True) +class LedgerInput: + dates: np.ndarray + symbols: tuple[str, ...] + close: np.ndarray + initial_cash: float + group_ids: np.ndarray + cash_sharing: bool + frequency: str + + +@dataclass(frozen=True, slots=True) +class OrderBuffer: + enabled: np.ndarray + side: np.ndarray + size: np.ndarray + price: np.ndarray + fixed_fees: np.ndarray + size_granularity: np.ndarray + allow_partial: np.ndarray + priority: np.ndarray + + +@dataclass(frozen=True, slots=True) +class OrderProgram: + program_id: str + prepare_segment_nb: object + after_fill_nb: object + after_segment_nb: object | None + inputs: tuple[object, ...] + params: tuple[object, ...] + state: tuple[object, ...] + trace: Mapping[str, np.ndarray] + orders: OrderBuffer +``` + +`OrderBuffer` 是 structure-of-arrays(列式数组集合),长度等于证券列数。它在模拟前分配一次,每个 segment(分段)原地重置和写入。策略不创建 vectorbt Order;共享 callback 读取当前列并调用 `nb.order_nb`。 + +项目自有数值枚举固定在 contracts: + +```text +SIDE_NONE = 0 +SIDE_BUY = 1 +SIDE_SELL = -1 + +FILL_IGNORED = 0 +FILL_ACCEPTED = 1 +FILL_REJECTED = 2 +``` + +策略 action/reason code(动作与原因码)不进入共享枚举,继续保留在策略私有 `_kernel.py`。 + +### 3.3 Numba 视图 + +共享 runtime 向策略函数传递项目自有 `namedtuple`(命名元组): + +```python +SegmentView( + row, + group, + from_col, + to_col, + cash, + value, + positions, + valuation_prices, +) + +FillEvent( + row, + column, + status, + side, + size, + price, + fees, + cash_after, + position_after, +) +``` + +这些结构只引用 vectorbt 当前上下文已有的标量和数组,不复制持仓或估值矩阵。策略 kernel 不得访问 `call_seq_now`、`last_cash`、`last_position`、`OrderStatus` 或 vectorbt record dtype。 + +### 3.4 执行结果 + +```python +@dataclass(frozen=True, slots=True) +class ExecutionRun: + ledger: "ExecutionLedger" + trace: Mapping[str, np.ndarray] + + +@dataclass(frozen=True, slots=True) +class ExecutionBundle: + primary: ExecutionRun + final: ExecutionRun + stages: tuple[str, ...] +``` + +`ExecutionLedger` 私有持有原始 Portfolio,只公开: + +```text +orders +assets +cash +value +trades +positions +returns +``` + +每个属性第一次访问时计算并缓存,之后返回同一个只读对象。若 vectorbt 已返回独占、连续数组,只设置只读标志;只有 vectorbt 返回临时或非连续视图时允许一次明确复制。测试必须记录每个属性的计算次数和 `np.shares_memory` 证据。 + +## 4. vectorbt Adapter 生命周期 + +共享 runtime 提供唯一入口: + +```python +run_vectorbt(ledger_input: LedgerInput, program: OrderProgram) -> ExecutionRun +``` + +生命周期为: + +1. 校验全部轴、shape(形状)、dtype(数据类型)、只读输入和现金分组。 +2. 根据固定 hook identity(回调身份)获取或创建一次 callback binding;同一进程内相同策略的不同参数不得产生新 wrapper。 +3. `pre_sim_func_nb` 只初始化策略 state、trace 和订单缓冲。 +4. `pre_segment_func_nb` 构造 `SegmentView`,调用策略 `prepare_segment_nb`,然后按 `(priority, original_column)` 原地生成稳定 `call_seq_now`。 +5. `order_func_nb` 从 `OrderBuffer` 读取订单意图并转换为 `nb.order_nb`;禁用订单返回 `nb.NoOrder`。 +6. `post_order_func_nb` 把 vectorbt 成交结果转换为 `FillEvent`,调用策略 `after_fill_nb`。策略只能在真实成交后推进单位、止损和其他成交状态。 +7. `post_segment_func_nb` 调用可选策略 hook,完成该日审计快照。 +8. 返回私有 Portfolio 包装后的 `ExecutionLedger` 和策略 trace。 + +runtime 固定配置: + +```text +cash_sharing=True +group_by=True +call_pre_segment=True +update_value=True +ffill_val_price=True +use_numba=True +max_logs=0 +``` + +保留 vectorbt(向量化回测框架)默认的持仓记录能力,`trades/positions` 必须直接复用 Portfolio(组合)的公开 accessor(访问器),不得为了关闭记录而在共享层重建交易或持仓。`max_logs=0` 与缓冲预分配只有在结果等价且人工性能复核接受后保留;没有实测瓶颈时不增加其他性能开关。 + +`pre_segment_func_nb` 不得创建行级临时数组。稳定排序使用预分配索引缓冲或原地插入排序;证券数当前为 11–17,禁止为此引入并行分块。共享现金和跨资产风险也禁止按证券独立分块。 + +## 5. 即时与延迟两阶段执行 + +共享 scenario(场景)编排固定如下: + +```python +prepared = module.prepare(snapshot, config) +primary = run_vectorbt(prepared.ledger_input, prepared.primary_program) +followup = module.followup_program(prepared, primary) + +if followup is None: + execution = ExecutionBundle(primary, primary, ("primary",)) +else: + final = run_vectorbt(prepared.ledger_input, followup) + execution = ExecutionBundle(primary, final, ("primary", "followup")) +``` + +不设计通用 N 阶段状态机:当前真实需求只有无延迟的一阶段和延迟的两阶段,更多阶段属于 YAGNI(暂不需要)。 + +海龟 `additional_delay_days=0` 时,primary 即最终账本。延迟为正时: + +1. primary 运行保持当前即时语义,产生完整计划和策略 trace; +2. `_delayed.py` 从 primary trace 冻结计划日、执行日、动作、目标数量、原因、信号 N 和稳定顺序; +3. followup program 在执行日只复核可交易性,并按实际 final ledger 做现金和持仓机械截断; +4. 到样本末尾仍未执行的订单进入 horizon-expired(期限到期)证据; +5. 核心四表只读取 final ledger;归因扩展同时连接 primary 计划和 final 成交。 + +followup 不重新计算突破、退出、加仓、资产组风险或组合风险,也不得根据延迟日行情重新分配目标。 + +## 6. Strategy Module 内部结构 + +海龟目录收敛为: + +```text +turtle_etf/ +├── __init__.py +├── strategy.py +├── _kernel.py +├── _attribution.py +└── _delayed.py +``` + +- `strategy.py`:唯一公开 `MODULE`,组合配置、行情准备、primary/followup program 和扩展。 +- `_kernel.py`:指标、海龟状态、Numba hooks、动作码和原因码。 +- `_attribution.py`:海龟扩展 Schema、事实构造、覆盖率和盈亏勾稽。 +- `_delayed.py`:冻结计划、执行日调整和到期证据,不维护账户。 +- `__init__.py`:只直接重新导出 `MODULE`,不导入 vectorbt 或执行大数组准备;PyArrow 只通过共享 contract 和 `_attribution.py` 使用。 + +迁移映射: + +| 当前文件 | 目标 | +|---|---| +| `indicators.py` | `_kernel.py` | +| `vectorbt_inputs.py` 的策略信号与公司行动证据 | `strategy.py` / `_kernel.py` | +| `vectorbt_callbacks.py` 的策略规则 | `_kernel.py` | +| `vectorbt_callbacks.py` 的 vectorbt 接线 | 共享 `vectorbt_runtime.py` | +| `vectorbt_engine.py` | 共享 runtime + `strategy.py` | +| `vectorbt_delayed.py` 的冻结语义 | `_delayed.py` | +| `vectorbt_delayed.py` 的手工账本 | 删除 | +| `result_adapter.py` 的四表/清单/文件逻辑 | 共享 `result_package.py` | +| `result_adapter.py` 的海龟归因 | `_attribution.py` | +| `single_scenario.py` / `vectorbt_benchmark.py` / `vectorbt_cli.py` | 共享 scenario/performance/cli | + +旧文件只在测试迁移期间存在;生产 `project-run.json` 在一个切换提交中改到共享入口,并在同一提交删除旧生产入口,不提供 feature flag(功能开关)或兼容分支。 + +## 7. 固定共享 CLI 与配置 v2 + +公开命令: + +```powershell +.\.venv\Scripts\python.exe scripts\research\local_quant_research\cli.py run --config + +.\.venv\Scripts\python.exe scripts\research\local_quant_research\cli.py promote ` + --strategy-id ` + --run-id ` + --analysis-id +``` + +`project-run.json` v2 示例: + +```json +{ + "schema_version": 2, + "project_id": "strategy-003", + "strategy": { + "root": "joinquant/strategies/strategy-003/research", + "module": "turtle_etf.strategy", + "symbol": "MODULE" + }, + "snapshot_id": "", + "snapshot_requirements": {}, + "scenario_config": "joinquant/strategies/strategy-003/research/baseline.json", + "declared_inputs": [ + "joinquant/strategies/strategy-003/manifest.json" + ] +} +``` + +以下字段不再由项目配置:`command`、`project_entry`、`code_identity`、`required_outputs`、`output_root` 和 `stop_states`。它们改为共享运行时固定合同: + +- Python 必须是仓库 `.venv`; +- 子进程入口固定为共享 CLI 的私有 `_execute` 动作; +- 唯一输出是 archive-ready package; +- 输出根固定为 `.local/quant-research`; +- 停止状态固定为 `complete/evidence_insufficient/failed`。 + +runner 使用当前 CLI 文件重新启动同一 `.venv` 子进程: + +```text +cli.py run + → 校验并冻结配置、策略源码和行情输入 + → .venv python cli.py _execute <保留参数> + → 子进程完成一次冷启动和一次预热 + → 父进程验证输出、补全来源证据并原子发布 +``` + +`_execute` 不公开给用户配置,所有参数均由 runner 生成。父进程静态发现并冻结策略根下全部普通 `.py` 文件;子进程临时把冻结策略 root 放到 import path 首位,并验证实际 module file 位于 root 内。 + +仓库不再手工维护逐文件 hash 的输入型 `code-identity.json`。父进程根据唯一静态发现源码集合、固定共享运行时文件集和已安装依赖生成档案中的 `config/code-identity.json` 与 `evidence/runtime-lock.json`,减少每次策略改动后的机械维护。 + +## 8. Archive-ready Result Package + +`.local/quant-research///` 本身就是可晋升包: + +```text +/ +├── manifest.json +├── code/ +│ └── +├── config/ +│ ├── scenario.json +│ ├── project-run.json +│ └── code-identity.json +├── data/ +│ ├── results.parquet +│ ├── balances.parquet +│ ├── positions.parquet +│ └── orders.parquet +├── extensions/ +│ └── /... +├── evidence/ +│ ├── market-snapshot.json +│ ├── runtime-lock.json +│ ├── performance.json +│ └── environment.json +└── report/ + ├── execution-summary.md + └── metrics.json +``` + +`manifest.json` 使用 `local-research-package/2`,至少声明: + +- `object.kind=local_research`、status、strategy_id、run_id、scenario_id; +- authority=`local_research`,backend=`vectorbt`; +- 四张核心数据集和全部扩展的 Schema、行数、时间范围、状态、文件与 SHA256; +- 策略代码、配置、运行时、行情、性能、环境和报告引用; +- gate checks 与 exceptions。 + +核心事实构造只读取 final ledger: + +- `orders` 从 vectorbt filled records 加策略计划/拒单 trace 构造; +- `positions` 从 `assets()`、估值价格和一次共享成本基础 pass 构造; +- `balances` 从 `cash()`、`value()` 构造; +- `results` 从 value returns 和显式 benchmark 状态构造。 + +策略扩展通过以下对象交给 writer: + +```python +@dataclass(frozen=True, slots=True) +class ResultExtension: + name: str + schema_version: str + table: pa.Table + unique_key: tuple[str, ...] + evidence: Mapping[str, object] +``` + +策略可以构造 Arrow(列式内存)表,但公共契约只接受扁平 `string/bool/int64/float64`(字符串/布尔/整数/浮点)列。浮点缺失值必须使用 Arrow null(空值),不得使用 NaN(非数值);dictionary/list/struct/map/union/run-end encoded(字典/列表/结构/映射/联合/游程编码)及其他类型统一在冷/热比较前以 `ResultContractError` 拒绝。策略不得选择路径、压缩、文件名或直接写 Parquet。 + +共享层使用 PyArrow(列式计算库)现成的 `Table.validate(full=True)` 校验表结构,先比较精确 Schema,再用 `Table.equals(check_metadata=True)` 比较冷/热扩展表;核心 vectorbt 事实继续使用 NumPy(数值数组)摘要。共享层不实现递归 Arrow 类型解码或任意类型逻辑哈希。完成包写入后,完整性只使用实际 Parquet 文件 SHA256。 + +writer 只执行一次核心/扩展 Parquet 物化和一次回读。内部写入路径复用该次回读事实完成 Schema、唯一键、勾稽、报告和 manifest(清单),并在观测边界一次写入性能证据与清单;不得调用带 `preloaded_*` 逃生参数的外部 validator(校验器),也不得构造 provisional/final(临时/最终)两套完整包。包内性能证据只记录从 writer 启动到最终 evidence/report/manifest 写入前的 `prefinalization_seconds`,并明确排除项;writer 返回耗时覆盖最终元数据写入。父进程原子发布与发布后校验由 Task 12 的完整 CLI 发布基准测量并写入外部验证报告,不把自指的最终耗时二次写回不可重写结果包。公开 `validate_result_package()` 保持纯文件读取,只供复用、晋升和外部查询。 + +机械执行报告只呈现包内可复核事实:身份、参数、范围、表行数、成交/持仓摘要、净值摘要、性能和完整性门禁。它不得使用“推荐”“稳健性通过”“适合实盘”等判断语句。 + +## 9. 晋升协议 + +`promote` 按下列顺序执行: + +1. 由 strategy_id 和 run_id 定位 `.local` 源包,验证 `manifest.json` 为 complete。 +2. 从源 manifest 取得策略目录;拒绝调用者提供任意目标路径。 +3. 校验 analysis_id 满足 `[a-z0-9][a-z0-9._-]{0,63}`。 +4. 目标固定为 `joinquant/strategies//research/archives//`。 +5. 若目标存在,逐文件验证:完全一致返回 `reused=true`;任一差异返回 conflict,不修改目标。 +6. 若目标不存在,在 `archives/` 下创建 `...tmp`。 +7. 晋升开始前用一次 `lstat/rglob` 扫描拒绝 hardlink(硬链接)、symlink(符号链接)、目录连接和非普通文件;使用标准 `shutil.copy2` 复制,再对目标树逐文件复核长度和 SHA256。 +8. 复核目标暂存包清单和分析视图;调用 `os.replace` 原子发布。本地仓库与同一用户进程属于可信边界,不实现针对扫描后敌对并发替换的文件描述符/inode(文件节点)状态机。 +9. 失败时只删除本次暂存目录,源包和既有档案保持不变。 + +analysis_id 只存在于目标目录名,包内 run_id 和所有字节不改变。查询返回二者:analysis_id 是人选档案别名,run_id 是内容身份。 + +晋升模块不得导入策略、vectorbt 或 PyArrow writer;测试必须把这些调用替换为“调用即失败”,证明晋升没有重算。 + +## 10. 错误模型 + +| 阶段 | 错误类型 | 最终状态 | +|---|---|---| +| 通用配置/路径/身份缺失 | `ConfigurationError` | `evidence_insufficient` | +| 策略声明输入缺失 | `StrategyEvidenceError` | `evidence_insufficient` | +| 行情快照缺失或不匹配 | `MarketDataError` 的证据分支 | `evidence_insufficient` | +| 策略、Numba、vectorbt 执行异常 | `ExecutionError` | `failed` | +| 结果 Schema、勾稽、摘要不一致 | `ResultContractError` | `failed` | +| 性能超限 | `PerformanceGateError` | `failed` | +| 原子发布或清理失败 | `EvidenceError` | `failed` | +| vectorbt 正常订单拒绝 | 执行事实 | 不改变运行状态 | + +只允许显式错误类型映射为 `evidence_insufficient`。未知异常统一进入 `failed`,输出稳定 reason code,不把 traceback(堆栈)或敏感环境写入清单。 + +不支持的策略扩展类型和扩展中的 NaN 必须在 cold/warm digest(冷/热摘要)前固定映射为 `failed + result_contract_failed`,不得退化为 `execution_digest_mismatch` 或未知异常。 + +晋升返回独立 `ArchiveResult(status, reused, source, target, reasons)`,不修改研究运行的三种停止状态。 + +## 11. 性能与内存设计 + +正常一次 `run` 只在一个全新子进程中执行一次冷启动和一次预热。Schema、行数、成交、费用、现金、持仓、净值和逻辑摘要继续自动校验零差异,单次冷、热仍不得超过 180 秒。 + +发布级性能不进入共享 CLI,也不实现重复进程采样器。验证报告直接列出已采集的三个场景与历史观测之间的时间、内存和 Parquet payload(列式数据载荷)体积差异,由用户人工确认。历史 baseline 只保存观测值,不携带自动门禁阈值。 + +阶段计时固定为: + +```text +strategy_load +strategy_prepare +primary_vectorbt +followup_prepare +followup_vectorbt +core_facts +strategy_extensions +parquet_materialize +readback_validate +report_and_manifest +``` + +包内阶段计时覆盖上述全部可在最终元数据一次写入前确定的阶段;`performance.json` 使用 `prefinalization_seconds` 明确该边界。日常 cold/warm 绝对门禁使用 writer 返回耗时,因此继续覆盖最终 evidence/report/manifest 写入。不得为获得发布总耗时而恢复双包、二次元数据写入或旁路清单。 + +## 12. 测试结构 + +### 12.1 Interface 测试 + +- `test_strategy_contract.py`:标准 importlib loader(导入加载器)、唯一静态源码身份、路径安全、两个策略 Adapter。 +- `test_vectorbt_runtime.py`:稳定优先级、订单转换、成交回调、惰性缓存、共享内存和 callback 特化数量。 +- `test_result_package.py`:四表、扩展、清单、单次物化、回读、跨表勾稽和机械报告措辞。 +- `test_archive.py`:完整源、失败源、幂等、冲突、逐字节一致、无行情复制和失败清理。 + +测试只通过公开 Interface 验证共享 Module。策略私有 kernel 的纯数值边界可以有私有测试,但外部测试不得导入旧公开文件。 + +### 12.2 Characterization 测试 + +迁移前冻结: + +- 所有订单 action/reason、计划量、成交量、价格和费用; +- 每日 cash/assets/value; +- 单位数、冻结 N、共同止损、再分配 scale; +- 延迟计划日、执行日、调整码、稳定顺序和到期订单; +- 四表行数、逻辑摘要、归因覆盖及逐证券损益勾稽。 + +历史 v1 fixture 保留为来源证据;迁移任务另从公开 `ExecutionLedger + ResultExtension` 生成 v2 摘要,并绑定 v1 fixture SHA256。这样裁判覆盖相同行为,但不需要保留旧核心结果适配器。 + +### 12.3 E2E(端到端) + +必须从发布形态运行: + +```text +.venv python shared-cli run + → minimal strategy complete + → turtle strategy complete + → analysis_data open + → shared-cli promote + → 删除 .local 源副本后的 archive query +``` + +另外覆盖 evidence_insufficient、执行失败、摘要冲突、性能超限、重复运行复用、重复晋升复用、analysis_id 冲突和中途失败。 + +## 13. TDD 迁移顺序 + +1. 建立现有行为和性能基线,不修改生产入口。 +2. 用失败测试定义 contracts 和最小测试策略。 +3. 抽取共享结果包并让旧海龟执行在测试中写新包。 +4. 完成 archive-ready package 和 promote E2E。 +5. 抽取共享 CLI、scenario 和 performance;新路径仍只由测试调用。 +6. 收窄扩展表并把 writer 收敛为单次回读事实链,删除递归 Arrow 解释器和内部 validator 双路径。 +7. 统一静态源码身份并改用标准 importlib,删除 descriptor 源码清单、UUID 命名空间、v2 audit hook 沙箱和重复 fixture 内容。 +8. 把晋升收敛为扫描、标准复制、摘要复核和原子发布,删除敌对并发树状态机。 +9. 建立通用 vectorbt 唯一账本 runtime,只用通用 primary/follow-up fixture 证明共享接线。 +10. 在海龟 Strategy Module 内同时迁移即时与延迟 OrderProgram,逐笔一致后删除手工账本。 +11. 单次切换生产配置和旧入口,不保留兼容分支或第二套生产路径。 +12. 执行完整 E2E、真实性能和 Build and Verify(构建与验证)。 + +每一步遵循 RED → GREEN → REFACTOR(失败测试→最小通过→整理),并形成可独立审查的提交。若某一步不能保持共享 CLI 可运行,则缩小该任务,不通过双生产 feature flag 绕过。 + +## 14. 被拒绝方案 + +### 策略直接暴露 vectorbt callback + +迁移最少,但会把 vectorbt context、订单枚举和内部数组 shape 泄漏给所有策略,无法形成稳定 Seam,拒绝。 + +### 使用 `from_signals()` + +不能完整表达共享现金、跨资产风险、全量再分配、确定性订单顺序、每单位冻结状态和实际成交后止损推进,拒绝。 + +### 预先生成完整订单矩阵后 `from_orders()` + +无法在执行过程中根据实际现金、持仓和成交反馈推进策略状态,也无法正确表达最低佣金和延迟机械截断,拒绝。 + +### 把生产实现放进 Skill 目录 + +会把 Agent 发现资产与生产依赖、数据和测试绑定,并违反现有薄 Skill 契约,拒绝。 + +### 保留旧入口作为兼容模式 + +会形成两套账本、writer 和代码身份,继续制造原问题,拒绝。回滚依赖 Git 提交,不依赖运行时兼容开关。 + +### 用 Python audit hook 把仓库内策略当作敌对插件 + +不能形成可靠的操作系统安全边界,却增加冻结文件、导入注入和平台分支。当前策略是仓库内受审查代码,使用既有子进程与输入边界即可;真正的不可信策略执行另立能力,拒绝在本变更中模拟沙箱。 + +## 15. 完成判据 + +实现只有同时满足以下条件才能进入验证阶段: + +- 三个 OpenSpec capability 的所有场景有对应自动化证据; +- 海龟和最小策略都只通过共享 CLI 运行; +- 策略源码不导入 vectorbt;共享层不导入海龟私有文件; +- 即时和延迟最终账户变化都只来自 vectorbt; +- `.local` 包可逐字节晋升且删除源缓存后仍可查询; +- 结果零差异,性能、内存和 Parquet payload 体积差异已在验证报告中列出并由用户确认; +- 策略扩展只使用扁平标量 Arrow 类型,冷/热比较和 Parquet 完整性复用 PyArrow/DuckDB(列式计算库/分析数据库)现成能力,不存在递归 Arrow 类型解释器; +- 共享 writer 只有一次回读事实链,策略加载只有标准 importlib 和一份静态源码身份,晋升只有扫描、标准复制、摘要复核和原子发布; +- 旧 CLI、旧引擎、旧 writer、延迟手工账本和双生产路径已删除; +- 完整 Build and Verify 与发布形态 E2E 通过。 diff --git a/joinquant/strategies/strategy-003/research/code-identity.json b/joinquant/strategies/strategy-003/research/code-identity.json deleted file mode 100644 index 54f823c..0000000 --- a/joinquant/strategies/strategy-003/research/code-identity.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "schema_version": 1, - "execution": { - "backend": "vectorbt.Portfolio.from_order_func", - "delayed_backend": "vectorbt.Portfolio.from_orders", - "adapter_version": "local-vectorbt-adapter/2", - "dependencies": { - "vectorbt": "1.1.0", - "numba": "0.66.0", - "numpy": "2.4.6", - "pandas": "3.0.3" - }, - "callbacks_sha256": "585246dea896f4353c3074df52fa358bc0ed73f5af22e1a217e1b8e80e63efc1", - "accounting": { - "version": "turtle-etf-corporate-actions/1", - "corporate_action_mode": "point_in_time_total_return_approximation", - "continuity_factor_basis": "raw_previous_close_over_current_pre_close", - "corporate_action_metadata_timing": "audit_only_may_be_retrospective", - "price_basis": "continuous_economic_price", - "quantity_basis": "economic_units", - "cash_dividend_mode": "implicit_reinvestment_on_ex_date", - "pay_date_cash_supported": false, - "exact_joinquant_reconciliation": false - }, - "license": { - "expression": "Apache-2.0 WITH Commons-Clause", - "usage": "internal_research_only", - "resale_prohibited": true - } - }, - "files": [ - { - "path": ".agents/skills/joinquant-archive-sync/references/manifest.schema.json", - "sha256": "4600fb1f5a1ba8a2a7f0e0080af55d2fb85a17ac524ba86e1b75770f830d05fc" - }, - { - "path": "joinquant/strategies/strategy-003/research/turtle_etf/__init__.py", - "sha256": "57f64f5c7258a78bd721335e1b18ffa9c9de6f8c04634b055b0e4461640b2f0c" - }, - { - "path": "joinquant/strategies/strategy-003/research/turtle_etf/indicators.py", - "sha256": "4e83a58a80550b31395a98b983389582a3fcf001f692b2ac860445b899fe7bb6" - }, - { - "path": "joinquant/strategies/strategy-003/research/turtle_etf/result_adapter.py", - "sha256": "41f2013ea3b7cbab10167147bea9b60ccea41e63a5c6bb354338d48b9d2c6b5b" - }, - { - "path": "joinquant/strategies/strategy-003/research/turtle_etf/single_scenario.py", - "sha256": "eab9c339d12c3aabf093bb22b98ef51ed5637b4187c98295b18c6e8d2cbd9264" - }, - { - "path": "joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_benchmark.py", - "sha256": "8734280e8285e0b7df12146f7db3571d5e1e96c91adfe1f6f3717ef9c06f2842" - }, - { - "path": "joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_callbacks.py", - "sha256": "585246dea896f4353c3074df52fa358bc0ed73f5af22e1a217e1b8e80e63efc1" - }, - { - "path": "joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_cli.py", - "sha256": "f09e51da53d6ffab74efed93bf8ffd7e3e1bd153e68e7992f09b20b631c3caf1" - }, - { - "path": "joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_delayed.py", - "sha256": "1298a8d914eec26224d08f01d1cd6e348495ce0f44ecfdf327912abbf624b49c" - }, - { - "path": "joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_engine.py", - "sha256": "557d3832725fc4cb0175d822e8d7fd08e620f61992bf217558004327a8356e5a" - }, - { - "path": "joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_inputs.py", - "sha256": "92b0673856408f3ac23eb2236c71c8ab9afa70bfdc550b1e13bfe739765b558f" - }, - { - "path": "scripts/__init__.py", - "sha256": "773263311748e959d61b507193feead29f7e5b306194facd198a3515c06780b1" - }, - { - "path": "scripts/research/__init__.py", - "sha256": "db573e2ee117f1994a4b7ce24e91147d71bcc9a9597e89ecae4b8b90af350bc9" - }, - { - "path": "scripts/research/analysis_data/__init__.py", - "sha256": "f6e40eceacd8589ae642ed19aa97ca05d41ca397d58a3d35dba1fed33546aa64" - }, - { - "path": "scripts/research/analysis_data/derived.py", - "sha256": "02eec15326539a96319c8d346d3df9c4a9f5a805ca18f88ae1091e32ef1a91b9" - }, - { - "path": "scripts/research/analysis_data/manifest.py", - "sha256": "0c402c0b253c9b9834d7cacdc7e8d3fd448d86d9bbfdfab9ca9577a93a762d62" - }, - { - "path": "scripts/research/analysis_data/schemas/local-backtest-manifest.schema.json", - "sha256": "65cf1556a702173cb20101a713ec27b61d32ca9ea47d77b5a984312fed9c66fb" - }, - { - "path": "scripts/research/analysis_data/views.py", - "sha256": "b561963a55389042a2a11b9e65f997dabeb646878be6ba22616f116a0a60bce3" - }, - { - "path": "scripts/research/market_data/__init__.py", - "sha256": "80a69e67a47749f1fa8eeb536056499eb1f3805cedfacbf78952291d50f7693f" - }, - { - "path": "scripts/research/market_data/contracts.py", - "sha256": "50d5c2fb015b3c6a5e5bb800df99345de67e60c677a6e65202f5676974c54204" - }, - { - "path": "scripts/research/market_data/economic_returns.py", - "sha256": "13969fc9ee6bbec08eff096bd53709c54e4007aa1a5f960c977d923c3e2883e3" - }, - { - "path": "scripts/research/market_data/query.py", - "sha256": "87fb5bad20cdce7719434a45a1373b89196e1d1963c2161d01b5193fd7d864e4" - }, - { - "path": "scripts/research/market_data/storage.py", - "sha256": "09bfb3ee07594d6899f065268d06a0fe33f60c852128a7c67fb392e4eb3c34b9" - } - ] -} diff --git a/joinquant/strategies/strategy-003/research/project-run.json b/joinquant/strategies/strategy-003/research/project-run.json index bfe6d2b..7ea3572 100644 --- a/joinquant/strategies/strategy-003/research/project-run.json +++ b/joinquant/strategies/strategy-003/research/project-run.json @@ -1,6 +1,11 @@ { - "schema_version": 1, + "schema_version": 2, "project_id": "strategy-003", + "strategy": { + "root": "joinquant/strategies/strategy-003/research", + "module": "turtle_etf.strategy", + "symbol": "MODULE" + }, "snapshot_id": "e88238cca420a8ae66b90adb6cda4dd6c38a07390a13b8ac2f471e534742e33e", "snapshot_requirements": { "source": { @@ -44,26 +49,8 @@ "skip_paused": false } }, - "project_entry": "joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_cli.py", - "command": [ - ".venv/Scripts/python.exe", - "joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_cli.py" - ], - "project_config": "joinquant/strategies/strategy-003/research/baseline.json", - "code_identity": "joinquant/strategies/strategy-003/research/code-identity.json", + "scenario_config": "joinquant/strategies/strategy-003/research/baseline.json", "declared_inputs": [ "joinquant/strategies/strategy-003/manifest.json" - ], - "required_outputs": [ - { - "path": "backtests/local-baseline", - "format": "directory" - } - ], - "output_root": ".local/quant-research", - "stop_states": [ - "complete", - "evidence_insufficient", - "failed" ] } diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/__init__.py b/joinquant/strategies/strategy-003/research/turtle_etf/__init__.py index ee49162..994b266 100644 --- a/joinquant/strategies/strategy-003/research/turtle_etf/__init__.py +++ b/joinquant/strategies/strategy-003/research/turtle_etf/__init__.py @@ -1,9 +1,6 @@ -"""Unadjusted-price Turtle ETF vectorbt research entry points.""" +"""Turtle ETF local-research strategy module.""" -from .indicators import breakout_levels, true_range, turtle_n +from .strategy import MODULE -__all__ = [ - "breakout_levels", - "true_range", - "turtle_n", -] + +__all__ = ["MODULE"] diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/_attribution.py b/joinquant/strategies/strategy-003/research/turtle_etf/_attribution.py new file mode 100644 index 0000000..bd77f37 --- /dev/null +++ b/joinquant/strategies/strategy-003/research/turtle_etf/_attribution.py @@ -0,0 +1,862 @@ +from __future__ import annotations + +import hashlib +import json + +import numpy as np +import pyarrow as pa + +from scripts.research.local_quant_research.contracts import ( + ExecutionBundle, + PreparedStrategy, + ResultExtension, +) + +from ._delayed import ( + ADJUST_CASH_TRUNCATED, + ADJUST_HOLDING_TRUNCATED, + ADJUST_HORIZON_EXPIRED, + ADJUST_NONE, + ADJUST_UNTRADABLE, + _filled_matrix, +) +from ._kernel import ( + ACTION_ADDITION, + ACTION_ENTRY, + ACTION_FULL_EXIT, + ACTION_NONE, + ACTION_REDISTRIBUTION_BUY, + ACTION_REDISTRIBUTION_SELL, + REASON_ALLOCATION_CONSTRAINT, + REASON_ENTRY_BREAKOUT, + REASON_FIXED_ADDITION_LEVEL, + REASON_FULL_POSITION_REDISTRIBUTION, + REASON_HIGH_LIMIT, + REASON_LOW_LIMIT, + REASON_MISSING_OPEN, + REASON_NONE, + REASON_ORDER_REJECTED, + REASON_PAUSED, + REASON_PROTECTIVE_STOP, + REASON_TREND_EXIT, + TurtleContext, +) + +ATTRIBUTION_SCHEMA_VERSION = "turtle-etf-attribution/2" +ATTRIBUTION_FIELDS = ( + "time", + "event_id", + "scope", + "security", + "event_type", + "reason_code", + "requested_amount", + "executed_amount", + "reference_price", + "risk_before", + "risk_after", + "details_json", +) + +_ACCOUNTING_CONTRACT = { + "version": "turtle-etf-corporate-actions/1", + "corporate_action_mode": "point_in_time_total_return_approximation", + "continuity_factor_basis": "raw_previous_close_over_current_pre_close", + "corporate_action_metadata_timing": "audit_only_may_be_retrospective", + "price_basis": "continuous_economic_price", + "quantity_basis": "economic_units", + "cash_dividend_mode": "implicit_reinvestment_on_ex_date", + "pay_date_cash_supported": False, + "exact_joinquant_reconciliation": False, +} + +_ACTION_NAMES = { + ACTION_NONE: "none", + ACTION_FULL_EXIT: "full_exit", + ACTION_REDISTRIBUTION_SELL: "redistribution_sell", + ACTION_ENTRY: "entry", + ACTION_ADDITION: "addition", + ACTION_REDISTRIBUTION_BUY: "redistribution_buy", +} +_REASON_NAMES = { + REASON_NONE: "none", + REASON_ENTRY_BREAKOUT: "entry_breakout", + REASON_FIXED_ADDITION_LEVEL: "fixed_addition_level", + REASON_PROTECTIVE_STOP: "protective_stop", + REASON_TREND_EXIT: "trend_exit", + REASON_FULL_POSITION_REDISTRIBUTION: "full_position_redistribution", + REASON_MISSING_OPEN: "missing_open", + REASON_PAUSED: "paused", + REASON_HIGH_LIMIT: "high_limit", + REASON_LOW_LIMIT: "low_limit", + REASON_ALLOCATION_CONSTRAINT: "allocation_constraint", + REASON_ORDER_REJECTED: "order_rejected", +} +_SELL_ACTIONS = {"full_exit", "redistribution_sell"} +_ADJUSTMENT_NAMES = { + ADJUST_NONE: "none", + ADJUST_CASH_TRUNCATED: "cash_truncated", + ADJUST_HOLDING_TRUNCATED: "holding_truncated", + ADJUST_UNTRADABLE: "untradable", + ADJUST_HORIZON_EXPIRED: "horizon_expired", +} + +_ATTRIBUTION_SCHEMA = pa.schema( + [ + pa.field("time", pa.string(), nullable=False), + pa.field("event_id", pa.string(), nullable=False), + pa.field("scope", pa.string(), nullable=False), + pa.field("security", pa.string()), + pa.field("event_type", pa.string(), nullable=False), + pa.field("reason_code", pa.string(), nullable=False), + pa.field("requested_amount", pa.float64()), + pa.field("executed_amount", pa.float64()), + pa.field("reference_price", pa.float64()), + pa.field("risk_before", pa.float64()), + pa.field("risk_after", pa.float64()), + pa.field("details_json", pa.string(), nullable=False), + ] +) + + +class ResultContractError(ValueError): + """Raised when vectorbt facts cannot prove the local result contract.""" + + + +def _table(rows: list[dict[str, object]], schema: pa.Schema) -> pa.Table: + if not rows: + return pa.Table.from_pylist([], schema=schema) + return pa.Table.from_pylist(rows, schema=schema) + + +def _vector(value: object, rows: int, field: str) -> np.ndarray: + array = np.asarray(value, dtype=np.float64).reshape(-1) + if array.shape != (rows,) or not np.all(np.isfinite(array)): + raise ResultContractError(f"portfolio {field} is invalid") + return array + + +def _matrix(value: object, shape: tuple[int, int], field: str) -> np.ndarray: + array = np.asarray(value) + if array.shape != shape: + raise ResultContractError(f"simulation {field} shape is invalid") + return array + + +def _date_text(value: object) -> str: + return str(np.datetime64(value, "D")) + + +def _nullable(value: float) -> float | None: + return float(value) if np.isfinite(value) else None + + +def _safe_simulation_number(value: object) -> float | None: + if value is None: + return None + try: + numeric = float(value) + except (TypeError, ValueError): + return None + return numeric if np.isfinite(numeric) else None + + +def _changed(left: float, right: float) -> bool: + if np.isnan(left) and np.isnan(right): + return False + return bool(left != right) + + +def _event_id( + scenario_id: str, + date_text: str, + security: str, + event_type: str, + reason_code: str, +) -> str: + identity = "|".join( + ( + ATTRIBUTION_SCHEMA_VERSION, + scenario_id, + date_text, + security, + event_type, + reason_code, + ) + ) + return hashlib.sha256(identity.encode("utf-8")).hexdigest() + + +def _reason_code(action: str, source_reason: str) -> str: + if source_reason == "order_rejected": + return "order_rejected" + if source_reason in {"missing_open", "paused", "high_limit", "low_limit"}: + return "untradeable" + if source_reason == "allocation_constraint": + return "allocation_constraint" + if source_reason == "protective_stop": + return "protective_stop" + if action in {"redistribution_sell", "redistribution_buy"} or ( + source_reason == "full_position_redistribution" + ): + return "full_position_redistribution" + if action == "full_exit" or source_reason == "trend_exit": + return "signal_exit" + if action == "entry": + return "signal_entry" + if action == "addition": + return "signal_add" + return "state_update" + + +def _planned_risk(quantity: int, average_cost: float, common_stop: float) -> float | None: + if quantity <= 0: + return 0.0 + if not np.isfinite(average_cost) or not np.isfinite(common_stop): + return None + return max(float(average_cost) - float(common_stop), 0.0) * int(quantity) + + +def _json_value(value: object) -> object: + if isinstance(value, (np.bool_, bool)): + return bool(value) + if isinstance(value, (np.integer, int)): + return int(value) + if isinstance(value, (np.floating, float)): + return float(value) if np.isfinite(value) else None + return value + + +def _details(**values: object) -> str: + return json.dumps( + {key: _json_value(value) for key, value in values.items()}, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def _ledger_matrices( + context: TurtleContext, + execution: ExecutionBundle, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + inputs = context.inputs + shape = inputs.close.shape + rows = { + np.datetime_as_string(value, unit="D"): index + for index, value in enumerate(np.asarray(inputs.dates, dtype="datetime64[D]")) + } + columns = {security: index for index, security in enumerate(inputs.securities)} + filled = np.zeros(shape, dtype=np.int64) + fill_prices = np.full(shape, np.nan, dtype=np.float64) + fees = np.zeros(shape, dtype=np.float64) + for order in execution.final.ledger.orders: + row = rows.get(str(order["time"])[:10]) + column = columns.get(str(order["security"])) + if row is None or column is None or filled[row, column] != 0: + raise ResultContractError("vectorbt order identity is invalid") + filled[row, column] = int(order["filled"]) + fill_prices[row, column] = float(order["price"]) + fees[row, column] = float(order["commission"]) + quantities = np.zeros(shape, dtype=np.int64) + for asset in execution.final.ledger.assets: + row = rows.get(str(asset["time"])[:10]) + column = columns.get(str(asset["security"])) + if row is None or column is None or quantities[row, column] != 0: + raise ResultContractError("vectorbt position identity is invalid") + quantities[row, column] = int(round(float(asset["amount"]))) + values = np.asarray( + execution.final.ledger.value["total_value"], + dtype=np.float64, + ) + return filled, fill_prices, fees, quantities, values + + +def _strategy_evidence( + context: TurtleContext, + execution: ExecutionBundle, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + rows, columns = context.inputs.close.shape + final_trace = execution.final.trace + if context.delay_days == 0: + return ( + _matrix( + final_trace["candidate_base_quantities"], + (rows, columns), + "candidate_base_quantities", + ), + _matrix( + final_trace["event_group_scales"], + (rows, columns), + "event_group_scales", + ), + _vector( + final_trace["event_portfolio_scales"], + rows, + "event_portfolio_scales", + ), + _vector( + final_trace["event_cash_scales"], + rows, + "event_cash_scales", + ), + ) + primary_trace = execution.primary.trace + candidate = np.zeros((rows, columns), dtype=np.int64) + group_scales = np.ones((rows, columns), dtype=np.float64) + portfolio_scales = np.ones(rows, dtype=np.float64) + cash_scales = np.ones(rows, dtype=np.float64) + for row in range(context.delay_days, rows): + source = row - context.delay_days + candidate[row] = primary_trace["candidate_base_quantities"][source] + group_scales[row] = primary_trace["event_group_scales"][source] + portfolio_scales[row] = primary_trace["event_portfolio_scales"][source] + cash_scales[row] = primary_trace["event_cash_scales"][source] + return candidate, group_scales, portfolio_scales, cash_scales + + +def _build_attribution_table( + context: TurtleContext, + execution: ExecutionBundle, +) -> pa.Table: + scenario_id = context.scenario_id + if not scenario_id: + raise ResultContractError("scenario_id is required") + inputs = context.inputs + dates = np.asarray(inputs.dates, dtype="datetime64[D]") + securities = inputs.securities + close = np.asarray(inputs.close, dtype=np.float64) + rows, columns = close.shape + if dates.shape != (rows,) or len(securities) != columns: + raise ResultContractError("input identities are inconsistent") + shape = (rows, columns) + trace = execution.final.trace + actions = _matrix(trace["action_codes"], shape, "action_codes") + reasons = _matrix(trace["reason_codes"], shape, "reason_codes") + requested = _matrix(trace["requested_quantities"], shape, "requested_quantities") + planned = _matrix(trace["planned_quantities"], shape, "planned_quantities") + filled, fill_prices, fees, state_quantities, values = _ledger_matrices( + context, + execution, + ) + common_stops = _matrix( + trace["state_common_stop"], shape, "state_common_stop" + ) + next_add = _matrix( + trace["state_next_add_index"], shape, "state_next_add_index" + ) + unit_counts = _matrix( + trace["state_unit_counts"], + shape, + "state_unit_counts", + ) + ( + candidate_base_quantities, + event_group_scales, + event_portfolio_scales, + event_cash_scales, + ) = _strategy_evidence(context, execution) + portfolio_unit_cap = _safe_simulation_number( + context.params.portfolio_unit_cap + ) + signal_n = _matrix(inputs.signal_n, shape, "signal_n") + planned_row_indices = _matrix( + trace["planned_row_indices"], + shape, + "planned_row_indices", + ) + adjustments = _matrix( + trace["execution_adjustment_codes"], + shape, + "execution_adjustment_codes", + ) + frozen_signal_n = _matrix( + trace["frozen_signal_n"], + shape, + "frozen_signal_n", + ) + execution_delay_days = context.delay_days + if np.any(requested < 0) or np.any(planned < 0) or np.any(filled < 0): + raise ResultContractError("simulation quantities must be non-negative") + if not np.all(np.isfinite(fees)) or np.any(fees < 0.0): + raise ResultContractError("simulation commissions must be finite and non-negative") + if any(int(value) not in _ACTION_NAMES for value in np.unique(actions)): + raise ResultContractError("simulation action code is unknown") + if any(int(value) not in _REASON_NAMES for value in np.unique(reasons)): + raise ResultContractError("simulation reason code is unknown") + if any(int(value) not in _ADJUSTMENT_NAMES for value in np.unique(adjustments)): + raise ResultContractError("simulation execution adjustment code is unknown") + if execution_delay_days < 0: + raise ResultContractError("simulation execution delay is invalid") + active_plan_rows = planned_row_indices[actions != ACTION_NONE] + if np.any(active_plan_rows < 0) or np.any(active_plan_rows >= rows): + raise ResultContractError("simulation planned row is invalid") + + values = _vector(values, rows, "value") + initial_cash = context.initial_cash + if initial_cash <= 0.0 or not np.isfinite(initial_cash): + raise ResultContractError("initial cash could not be reconciled") + average_cost = np.zeros(columns, dtype=np.float64) + previous_quantity = np.zeros(columns, dtype=np.int64) + previous_close = np.full(columns, np.nan, dtype=np.float64) + previous_stop = np.full(columns, np.nan, dtype=np.float64) + previous_next_add = np.zeros(columns, dtype=np.int64) + previous_unit_count = np.zeros(columns, dtype=np.int64) + attribution_rows: list[dict[str, object]] = [] + valid_dates = {_date_text(value) for value in dates} + for application in getattr(inputs, "corporate_action_applications", ()): + effective_date = str(getattr(application, "effective_date", "")) + application_date = str( + getattr(application, "application_date", effective_date) + ) + security = str(getattr(application, "security", "")) + source_event_id = str(getattr(application, "source_event_id", "")) + if ( + effective_date not in valid_dates + or application_date not in valid_dates + or security not in securities + or not source_event_id + ): + raise ResultContractError( + "corporate-action attribution identity is invalid" + ) + attribution_rows.append( + { + "time": f"{application_date} 00:00:00", + "event_id": _event_id( + scenario_id, + application_date, + security, + "corporate_action", + f"corporate_action_applied:{source_event_id}", + ), + "scope": "security", + "security": security, + "event_type": "corporate_action", + "reason_code": "corporate_action_applied", + "requested_amount": None, + "executed_amount": None, + "reference_price": None, + "risk_before": None, + "risk_after": None, + "details_json": _details( + source_event_id=source_event_id, + event_type=str(getattr(application, "event_type", "")), + effective_date=effective_date, + application_date=application_date, + announcement_date=str( + getattr(application, "announcement_date", "") + ), + knowledge_cutoff_date=str( + getattr(application, "knowledge_cutoff_date", "") + ), + evidence_timing=str( + getattr( + application, + "evidence_timing", + "retrospective_reconciliation" + if str(getattr(application, "announcement_date", "")) + > effective_date + else "point_in_time", + ) + ), + split_ratio=getattr(application, "split_ratio", None), + cash_per_share=getattr(application, "cash_per_share", None), + cumulative_factor=float( + getattr(application, "cumulative_factor", np.nan) + ), + price_basis_changed=bool( + getattr(application, "price_basis_changed", True) + ), + source=str(getattr(application, "source", "")), + source_record_sha256=str( + getattr(application, "source_record_sha256", "") + ), + corporate_action_mode=( + "point_in_time_total_return_approximation" + ), + ), + } + ) + + for expired in _horizon_expired(context, execution): + planned_row = int(expired["planned_row_index"]) + column = int(expired["column"]) + if not 0 <= planned_row < rows or not 0 <= column < columns: + raise ResultContractError("horizon-expired order identity is invalid") + date_text = _date_text(dates[planned_row]) + security = securities[column] + action = _ACTION_NAMES[int(expired["action_code"])] + source_reason = _REASON_NAMES[int(expired["reason_code"])] + reason_code = _reason_code(action, source_reason) + target = int(expired["target_quantity"]) + requested_quantity = int(expired["requested_quantity"]) + delay_days = int(expired["delay_days"]) + attribution_rows.append( + { + "time": f"{date_text} 09:30:00", + "event_id": _event_id( + scenario_id, + date_text, + security, + "decision", + reason_code + ":horizon_expired", + ), + "scope": "security", + "security": security, + "event_type": "decision", + "reason_code": reason_code, + "requested_amount": float(requested_quantity), + "executed_amount": 0.0, + "reference_price": _nullable(float(close[planned_row, column])), + "risk_before": None, + "risk_after": None, + "details_json": _details( + action=action, + source_reason=source_reason, + planned_date=date_text, + execution_date=None, + delay_days=delay_days, + frozen_reason=source_reason, + frozen_target_amount=target, + frozen_signal_n=float(expired["signal_n"]), + execution_adjustment="horizon_expired", + planned_amount=target, + state_changed=False, + ), + } + ) + + for row in range(rows): + date_text = _date_text(dates[row]) + execution_time = f"{date_text} 09:30:00" + balance_time = f"{date_text} 16:00:00" + valuation_rows: list[tuple[dict[str, object], dict[str, object]]] = [] + daily_security_pnl_total = 0.0 + for column, security in enumerate(securities): + action = _ACTION_NAMES[int(actions[row, column])] + source_reason = _REASON_NAMES[int(reasons[row, column])] + reason_code = _reason_code(action, source_reason) + adjustment = _ADJUSTMENT_NAMES[int(adjustments[row, column])] + planned_row = int(planned_row_indices[row, column]) + planned_date = ( + _date_text(dates[planned_row]) if planned_row >= 0 else date_text + ) + quantity = int(filled[row, column]) + frozen_target = max(int(planned[row, column]), quantity) + before = int(previous_quantity[column]) + before_cost = float(average_cost[column]) + fee = float(fees[row, column]) + close_price = float(close[row, column]) + if quantity > 0 and action == "none": + raise ResultContractError("filled order has no action") + is_sell = action in _SELL_ACTIONS + if quantity > 0: + price = float(fill_prices[row, column]) + if not np.isfinite(price) or price <= 0.0 or fee < 0.0: + raise ResultContractError("filled order price or commission is invalid") + if is_sell: + if quantity > before: + raise ResultContractError("sell order exceeds the held position") + expected_after = before - quantity + if expected_after == 0: + average_cost[column] = 0.0 + else: + expected_after = before + quantity + average_cost[column] = ( + before * before_cost + quantity * price + ) / expected_after + if int(state_quantities[row, column]) != expected_after: + raise ResultContractError("filled order and position state do not reconcile") + elif int(state_quantities[row, column]) != before: + raise ResultContractError("position changed without a filled order") + + after = int(state_quantities[row, column]) + stop_after = float(common_stops[row, column]) + next_after = int(next_add[row, column]) + units_after = int(unit_counts[row, column]) + state_changed = ( + before != after + or _changed(float(previous_stop[column]), stop_after) + or int(previous_next_add[column]) != next_after + ) + logical_state_changed = ( + _changed(float(previous_stop[column]), stop_after) + or int(previous_next_add[column]) != next_after + or int(previous_unit_count[column]) != units_after + ) + effective_risk_units = float( + np.sum( + unit_counts[row].astype(np.float64) + * event_group_scales[row] + ) + * event_portfolio_scales[row] + ) + risk_before = _planned_risk( + before, before_cost, float(previous_stop[column]) + ) + risk_after = _planned_risk( + after, float(average_cost[column]), stop_after + ) + if action != "none" or source_reason != "none" or state_changed: + event_type = ( + "decision" + if action != "none" or source_reason != "none" + else "state" + ) + attribution_rows.append( + { + "time": execution_time, + "event_id": _event_id( + scenario_id, date_text, security, event_type, reason_code + ), + "scope": "security", + "security": security, + "event_type": event_type, + "reason_code": reason_code, + "requested_amount": float(requested[row, column]), + "executed_amount": float(quantity), + "reference_price": _nullable( + float(fill_prices[row, column]) + if quantity > 0 + else close_price + ), + "risk_before": risk_before, + "risk_after": risk_after, + "details_json": _details( + action=action, + source_reason=source_reason, + planned_amount=int(planned[row, column]), + commission=fee, + position_before=before, + position_after=after, + average_cost_before=before_cost, + average_cost_after=float(average_cost[column]), + common_stop_before=float(previous_stop[column]), + common_stop_after=stop_after, + next_add_before=int(previous_next_add[column]), + next_add_after=next_after, + unit_count_before=int(previous_unit_count[column]), + unit_count_after=units_after, + candidate_base_quantity=int( + candidate_base_quantities[row, column] + ), + frozen_signal_n=float( + frozen_signal_n[row, column] + ), + actual_fill_price=( + float(fill_prices[row, column]) + if quantity > 0 + else np.nan + ), + group_scale=float( + event_group_scales[row, column] + ), + portfolio_scale=float( + event_portfolio_scales[row] + ), + cash_scale=float(event_cash_scales[row]), + effective_risk_units=effective_risk_units, + portfolio_unit_cap=portfolio_unit_cap, + redistribution_state_changed=( + logical_state_changed + if action + in { + "redistribution_sell", + "redistribution_buy", + } + else None + ), + state_changed=state_changed, + **( + { + "planned_date": planned_date, + "execution_date": date_text, + "delay_days": execution_delay_days, + "frozen_reason": source_reason, + "frozen_target_amount": frozen_target, + "execution_adjustment": adjustment, + } + if execution_delay_days > 0 + or adjustment != "none" + else {} + ), + ), + } + ) + + active = before > 0 or after > 0 or quantity > 0 or fee > 0.0 + security_daily_pnl = 0.0 + if active: + if not np.isfinite(close_price) or close_price <= 0.0: + raise ResultContractError("active position has no valid close price") + if before > 0 and ( + not np.isfinite(previous_close[column]) + or float(previous_close[column]) <= 0.0 + ): + raise ResultContractError("held position has no previous close price") + previous_price = ( + float(previous_close[column]) if before > 0 else close_price + ) + if quantity > 0 and is_sell: + security_daily_pnl = ( + after * (close_price - previous_price) + + quantity + * (float(fill_prices[row, column]) - previous_price) + - fee + ) + elif quantity > 0: + security_daily_pnl = ( + before * (close_price - previous_price) + + quantity + * (close_price - float(fill_prices[row, column])) + - fee + ) + else: + security_daily_pnl = before * (close_price - previous_price) - fee + daily_security_pnl_total += security_daily_pnl + + n_value = float(signal_n[row, column]) + stop_failure_price = ( + stop_after - 2.0 * n_value + if after > 0 and np.isfinite(stop_after) and np.isfinite(n_value) + else np.nan + ) + stop_failure_loss = ( + max(close_price - stop_failure_price, 0.0) * after + if np.isfinite(stop_failure_price) + else np.nan + ) + valuation_rows.append( + ( + { + "time": balance_time, + "event_id": _event_id( + scenario_id, + date_text, + security, + "valuation", + reason_code, + ), + "scope": "security", + "security": security, + "event_type": "valuation", + "reason_code": reason_code, + "requested_amount": None, + "executed_amount": None, + "reference_price": close_price, + "risk_before": risk_before, + "risk_after": risk_after, + }, + { + "source_reason": source_reason, + "action": action, + "position_before": before, + "position_after": after, + "previous_close": previous_price, + "close": close_price, + "fill_price": ( + float(fill_prices[row, column]) + if quantity > 0 + else np.nan + ), + "filled_amount": quantity, + "commission": fee, + "average_cost_before": before_cost, + "average_cost_after": float(average_cost[column]), + "common_stop_before": float(previous_stop[column]), + "common_stop_after": stop_after, + "n": n_value, + "stop_failure_price": stop_failure_price, + "stop_failure_loss": stop_failure_loss, + "security_daily_pnl": security_daily_pnl, + }, + ) + ) + + previous_quantity[column] = after + previous_stop[column] = stop_after + previous_next_add[column] = next_after + previous_unit_count[column] = units_after + + portfolio_daily_pnl = float(values[row]) - ( + initial_cash if row == 0 else float(values[row - 1]) + ) + reconciliation_difference = daily_security_pnl_total - portfolio_daily_pnl + if abs(reconciliation_difference) > 0.02: + raise ResultContractError( + "security daily PnL does not reconcile with portfolio change" + ) + for event, valuation in valuation_rows: + event["details_json"] = _details( + **valuation, + daily_security_pnl_total=daily_security_pnl_total, + portfolio_daily_pnl=portfolio_daily_pnl, + reconciliation_difference=reconciliation_difference, + ) + attribution_rows.append(event) + previous_close = close[row].copy() + + return _table(attribution_rows, _ATTRIBUTION_SCHEMA) + + +def _horizon_expired( + context: TurtleContext, + execution: ExecutionBundle, +) -> tuple[dict[str, object], ...]: + delay_days = context.delay_days + if delay_days <= 0: + return () + trace = execution.primary.trace + actions = np.asarray(trace["action_codes"]) + filled = _filled_matrix( + execution.primary, + context.inputs.dates, + context.inputs.securities, + ) + planned = np.asarray(trace["planned_quantities"]) + rows = actions.shape[0] + expired: list[dict[str, object]] = [] + for row, column in zip(*np.nonzero((actions != ACTION_NONE) & (filled > 0))): + if row + delay_days < rows: + continue + target = int(planned[row, column]) + if target <= 0: + target = int(filled[row, column]) + expired.append( + { + "planned_row_index": int(row), + "column": int(column), + "action_code": int(actions[row, column]), + "reason_code": int(trace["reason_codes"][row, column]), + "requested_quantity": int( + trace["requested_quantities"][row, column] + ), + "target_quantity": target, + "signal_n": float(context.inputs.signal_n[row, column]), + "delay_days": delay_days, + } + ) + return tuple(expired) + + +def build_turtle_attribution( + prepared: PreparedStrategy, + execution: ExecutionBundle, +) -> ResultExtension: + context = prepared.context + if not isinstance(context, TurtleContext): + raise TypeError("prepared turtle context is invalid") + table = _build_attribution_table(context, execution) + return ResultExtension( + name="turtle_etf", + schema_version=ATTRIBUTION_SCHEMA_VERSION, + table=table, + unique_key=("event_id",), + evidence={ + "accounting": dict(_ACCOUNTING_CONTRACT), + "execution_stages": list(execution.stages), + }, + ) diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/_delayed.py b/joinquant/strategies/strategy-003/research/turtle_etf/_delayed.py new file mode 100644 index 0000000..2a8a2dd --- /dev/null +++ b/joinquant/strategies/strategy-003/research/turtle_etf/_delayed.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +from collections import namedtuple + +import numpy as np +from numba import njit + +from scripts.research.local_quant_research.contracts import ( + FILL_ACCEPTED, + FILL_REJECTED, + SIDE_BUY, + SIDE_SELL, + ExecutionRun, + OrderProgram, + PreparedStrategy, +) + +from ._kernel import ( + ACTION_ADDITION, + ACTION_ENTRY, + ACTION_FULL_EXIT, + ACTION_NONE, + ACTION_REDISTRIBUTION_SELL, + REASON_NONE, + REASON_ORDER_REJECTED, + TurtleContext, + _action_priority, + _buy_price, + _commission, + _finite_positive, + _order_buffer, + _sell_price, +) + + +ADJUST_NONE = 0 +ADJUST_CASH_TRUNCATED = 1 +ADJUST_HOLDING_TRUNCATED = 2 +ADJUST_UNTRADABLE = 3 +ADJUST_HORIZON_EXPIRED = 4 + + +DelayedInputs = namedtuple( + "DelayedInputs", + ( + "execution_open", + "paused", + "high_limit", + "low_limit", + "plan_actions", + "plan_reasons", + "plan_requested", + "plan_targets", + "plan_signal_n", + "delay_days", + ), +) + +DelayedState = namedtuple( + "DelayedState", + ( + "common_stop", + "next_add_index", + "unit_count", + "action_codes", + "reason_codes", + "requested_quantities", + "planned_quantities", + "state_common_stop", + "state_next_add_index", + "state_unit_counts", + "planned_row_indices", + "execution_adjustment_codes", + "frozen_signal_n", + ), +) + + +@njit +def _tradable(inputs: DelayedInputs, row: int, column: int, action: int) -> bool: + open_price = inputs.execution_open[row, column] + if not _finite_positive(open_price) or inputs.paused[row, column]: + return False + if action == ACTION_FULL_EXIT or action == ACTION_REDISTRIBUTION_SELL: + low_limit = inputs.low_limit[row, column] + return not np.isfinite(low_limit) or open_price > low_limit + high_limit = inputs.high_limit[row, column] + return not np.isfinite(high_limit) or open_price < high_limit + + +@njit +def _affordable_quantity( + cash: float, + target: int, + lot_size: int, + price: float, + commission_multiplier: float, +) -> int: + quantity = (target // lot_size) * lot_size + while quantity > 0: + fee = _commission(price, quantity, commission_multiplier) + if price * quantity + fee <= cash + 1e-9: + return quantity + quantity -= lot_size + return 0 + + +@njit +def prepare_delayed_segment_nb(view, inputs, params, state, trace, orders) -> None: + row = view.row + source_row = row - inputs.delay_days + if source_row < 0: + return + projected_cash = view.cash + for category in range(4): + for offset in range(view.to_col - view.from_col): + column = view.from_col + offset + action = inputs.plan_actions[source_row, column] + if action == ACTION_NONE or _action_priority(action) != category: + continue + target = inputs.plan_targets[source_row, column] + state.action_codes[row, column] = action + state.reason_codes[row, column] = inputs.plan_reasons[source_row, column] + state.requested_quantities[row, column] = inputs.plan_requested[ + source_row, column + ] + state.planned_quantities[row, column] = target + state.planned_row_indices[row, column] = source_row + state.frozen_signal_n[row, column] = inputs.plan_signal_n[ + source_row, column + ] + orders[7][column] = category + if not _tradable(inputs, row, column, action): + state.execution_adjustment_codes[row, column] = ADJUST_UNTRADABLE + continue + open_price = inputs.execution_open[row, column] + quantity = target + if action == ACTION_FULL_EXIT or action == ACTION_REDISTRIBUTION_SELL: + held = int(round(view.positions[offset])) + quantity = min(target, held) + if quantity < target: + state.execution_adjustment_codes[row, column] = ( + ADJUST_HOLDING_TRUNCATED + ) + if quantity <= 0: + continue + price = _sell_price(open_price, params.one_way_slippage) + fee = _commission(price, quantity, params.commission_multiplier) + projected_cash += price * quantity - fee + orders[1][column] = SIDE_SELL + orders[5][column] = np.nan + else: + price = _buy_price(open_price, params.one_way_slippage) + quantity = _affordable_quantity( + projected_cash, + target, + params.lot_size, + price, + params.commission_multiplier, + ) + if quantity < target: + state.execution_adjustment_codes[row, column] = ADJUST_CASH_TRUNCATED + if quantity <= 0: + continue + fee = _commission(price, quantity, params.commission_multiplier) + projected_cash -= price * quantity + fee + orders[1][column] = SIDE_BUY + orders[5][column] = float(params.lot_size) + orders[0][column] = True + orders[2][column] = float(quantity) + orders[3][column] = price + orders[4][column] = fee + orders[6][column] = False + + +@njit +def after_delayed_fill_nb(event, inputs, params, state, trace, orders) -> None: + row = event.row + column = event.column + action = state.action_codes[row, column] + if event.status == FILL_REJECTED: + state.reason_codes[row, column] = REASON_ORDER_REJECTED + return + if event.status != FILL_ACCEPTED: + return + if event.side == SIDE_SELL: + if action == ACTION_FULL_EXIT and event.position_after <= 1e-9: + state.common_stop[column] = np.nan + state.next_add_index[column] = 0 + state.unit_count[column] = 0 + return + if action != ACTION_ENTRY and action != ACTION_ADDITION: + return + signal_n = state.frozen_signal_n[row, column] + candidate_stop = event.price - params.stop_n * signal_n + if action == ACTION_ENTRY or not np.isfinite(state.common_stop[column]): + state.common_stop[column] = candidate_stop + state.next_add_index[column] = 1 + state.unit_count[column] = 1 + else: + state.common_stop[column] = max(state.common_stop[column], candidate_stop) + state.next_add_index[column] += 1 + state.unit_count[column] += 1 + + +@njit +def after_delayed_segment_nb(view, inputs, params, state, trace, orders) -> None: + row = view.row + for offset in range(view.to_col - view.from_col): + column = view.from_col + offset + state.state_common_stop[row, column] = state.common_stop[column] + state.state_next_add_index[row, column] = state.next_add_index[column] + state.state_unit_counts[row, column] = state.unit_count[column] + + +def _readonly_matrix(value: object, shape: tuple[int, int], name: str) -> np.ndarray: + result = np.asarray(value) + if result.shape != shape: + raise ValueError(f"primary trace shape is invalid: {name}") + return result + + +def _state( + rows: int, + columns: int, +) -> DelayedState: + return DelayedState( + common_stop=np.full(columns, np.nan, dtype=np.float64), + next_add_index=np.zeros(columns, dtype=np.int64), + unit_count=np.zeros(columns, dtype=np.int64), + action_codes=np.zeros((rows, columns), dtype=np.int16), + reason_codes=np.zeros((rows, columns), dtype=np.int16), + requested_quantities=np.zeros((rows, columns), dtype=np.int64), + planned_quantities=np.zeros((rows, columns), dtype=np.int64), + state_common_stop=np.full((rows, columns), np.nan, dtype=np.float64), + state_next_add_index=np.zeros((rows, columns), dtype=np.int64), + state_unit_counts=np.zeros((rows, columns), dtype=np.int64), + planned_row_indices=np.full((rows, columns), -1, dtype=np.int64), + execution_adjustment_codes=np.zeros((rows, columns), dtype=np.int16), + frozen_signal_n=np.full((rows, columns), np.nan, dtype=np.float64), + ) + + +def _trace(state: DelayedState) -> dict[str, np.ndarray]: + return { + name: getattr(state, name) + for name in DelayedState._fields + if name not in {"common_stop", "next_add_index", "unit_count"} + } + + +def _filled_matrix( + primary_run: ExecutionRun, + dates: np.ndarray, + securities: tuple[str, ...], +) -> np.ndarray: + rows = { + np.datetime_as_string(value, unit="D"): index + for index, value in enumerate(np.asarray(dates, dtype="datetime64[D]")) + } + columns = {security: index for index, security in enumerate(securities)} + filled = np.zeros((len(rows), len(columns)), dtype=np.int64) + for order in primary_run.ledger.orders: + row = rows.get(str(order["time"])[:10]) + column = columns.get(str(order["security"])) + if row is None or column is None: + raise ValueError("primary order identity is absent from turtle inputs") + filled[row, column] += int(order["filled"]) + filled.setflags(write=False) + return filled + + +def build_delayed_program( + prepared: PreparedStrategy, + primary_run: ExecutionRun, +) -> OrderProgram | None: + context = prepared.context + if not isinstance(context, TurtleContext): + raise TypeError("prepared turtle context is invalid") + if context.delay_days == 0: + return None + inputs = context.inputs + rows, columns = inputs.close.shape + trace = primary_run.trace + shape = (rows, columns) + actions = _readonly_matrix(trace["action_codes"], shape, "action_codes") + filled = _filled_matrix(primary_run, inputs.dates, inputs.securities) + planned = _readonly_matrix(trace["planned_quantities"], shape, "planned_quantities") + valid = filled > 0 + plan_actions = np.where(valid, actions, ACTION_NONE).astype(np.int16) + plan_reasons = np.where(valid, trace["reason_codes"], REASON_NONE).astype(np.int16) + plan_requested = np.where(valid, trace["requested_quantities"], 0).astype( + np.int64 + ) + plan_targets = np.where(valid, np.where(planned > 0, planned, filled), 0).astype( + np.int64 + ) + plan_signal_n = np.where(valid, inputs.signal_n, np.nan).astype(np.float64) + state = _state(rows, columns) + delayed_inputs = DelayedInputs( + inputs.execution_open, + inputs.paused, + inputs.high_limit, + inputs.low_limit, + plan_actions, + plan_reasons, + plan_requested, + plan_targets, + plan_signal_n, + context.delay_days, + ) + return OrderProgram( + program_id=f"turtle-etf-delayed/{context.delay_days}", + prepare_segment_nb=prepare_delayed_segment_nb, + after_fill_nb=after_delayed_fill_nb, + after_segment_nb=after_delayed_segment_nb, + inputs=delayed_inputs, + params=context.params, + state=state, + trace=_trace(state), + orders=_order_buffer(columns), + ) diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/_kernel.py b/joinquant/strategies/strategy-003/research/turtle_etf/_kernel.py new file mode 100644 index 0000000..6b8f40d --- /dev/null +++ b/joinquant/strategies/strategy-003/research/turtle_etf/_kernel.py @@ -0,0 +1,1349 @@ +from __future__ import annotations + +from collections import namedtuple +from dataclasses import dataclass +from typing import Mapping, Sequence + +import numpy as np +import pandas as pd +from numba import njit + +from scripts.research.local_quant_research.contracts import ( + FILL_ACCEPTED, + FILL_REJECTED, + SIDE_BUY, + SIDE_SELL, + LedgerInput, + OrderBuffer, + OrderProgram, + PreparedStrategy, + StrategyEvidenceError, +) + +from scripts.research.market_data.economic_returns import ( + CorporateActionApplication, + canonical_corporate_actions_digest, + derive_continuous_prices, +) + +import math + + +def _numeric_columns( + frame: pd.DataFrame, + fields: tuple[str, ...], +) -> dict[str, pd.Series]: + missing = [field for field in fields if field not in frame.columns] + if missing: + raise ValueError(f"missing price fields: {', '.join(missing)}") + return { + field: pd.to_numeric(frame[field], errors="coerce").astype(float) + for field in fields + } + + +def true_range(frame: pd.DataFrame) -> pd.Series: + columns = _numeric_columns(frame, ("high", "low", "pre_close")) + components = pd.concat( + ( + columns["high"] - columns["low"], + (columns["high"] - columns["pre_close"]).abs(), + (columns["low"] - columns["pre_close"]).abs(), + ), + axis=1, + ) + result = components.max(axis=1, skipna=False) + result.name = "tr" + return result + + +def turtle_n(frame: pd.DataFrame, days: int = 20) -> pd.Series: + if not isinstance(days, int) or days < 1: + raise ValueError("days must be a positive integer") + values: list[float] = [] + warmup: list[float] = [] + current: float | None = None + for raw_value in true_range(frame): + value = float(raw_value) if pd.notna(raw_value) else math.nan + if not math.isfinite(value): + warmup.clear() + current = None + values.append(math.nan) + continue + if current is None: + warmup.append(value) + if len(warmup) < days: + values.append(math.nan) + continue + current = sum(warmup[-days:]) / days + else: + current = ((current * (days - 1)) + value) / days + values.append(current) + return pd.Series(values, index=frame.index, name="n", dtype=float) + + +def breakout_levels( + frame: pd.DataFrame, + entry_days: int, + exit_days: int, +) -> pd.DataFrame: + if not isinstance(entry_days, int) or entry_days < 1: + raise ValueError("entry_days must be a positive integer") + if not isinstance(exit_days, int) or exit_days < 1: + raise ValueError("exit_days must be a positive integer") + columns = _numeric_columns(frame, ("high", "low")) + return pd.DataFrame( + { + "entry_high": columns["high"] + .shift(1) + .rolling(entry_days, min_periods=entry_days) + .max(), + "exit_low": columns["low"] + .shift(1) + .rolling(exit_days, min_periods=exit_days) + .min(), + }, + index=frame.index, + ) + + +@dataclass(frozen=True) +class SimulationInputs: + dates: np.ndarray + securities: tuple[str, ...] + asset_groups: tuple[str, ...] + asset_group_ids: np.ndarray + raw_open: np.ndarray + raw_high: np.ndarray + raw_low: np.ndarray + raw_close: np.ndarray + raw_pre_close: np.ndarray + continuous_open: np.ndarray + continuous_high: np.ndarray + continuous_low: np.ndarray + continuous_close: np.ndarray + continuous_pre_close: np.ndarray + continuity_factor: np.ndarray + corporate_action_applied: np.ndarray + corporate_actions_digest: str + corporate_action_applications: tuple[CorporateActionApplication, ...] + paused: np.ndarray + high_limit: np.ndarray + low_limit: np.ndarray + signal_source_index: np.ndarray + signal_close: np.ndarray + signal_entry_high: np.ndarray + signal_exit_low: np.ndarray + signal_n: np.ndarray + + @property + def execution_open(self) -> np.ndarray: + return self.continuous_open + + @property + def close(self) -> np.ndarray: + return self.continuous_close + + +def _section(config: Mapping[str, object], name: str) -> Mapping[str, object]: + value = config.get(name) + if not isinstance(value, Mapping): + raise ValueError(f"{name} config must be an object") + return value + + +def _positive_int(value: object, name: str, *, minimum: int = 1) -> int: + if isinstance(value, bool): + raise ValueError(f"{name} must be an integer") + try: + result = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be an integer") from exc + if result < minimum or result != value: + raise ValueError(f"{name} must be at least {minimum}") + return result + + +def _readonly(values: object, dtype: np.dtype[object] | str) -> np.ndarray: + result = np.ascontiguousarray(values, dtype=dtype) + result.setflags(write=False) + return result + + +def _evidence_insufficient(message: str) -> ValueError: + return ValueError(f"evidence_insufficient: {message}") + + +def _normalized_frame( + frame: pd.DataFrame, + *, + security: str, + signal: Mapping[str, object], + actions: Sequence[Mapping[str, object]], +) -> tuple[pd.DataFrame, tuple[CorporateActionApplication, ...]]: + continuous = derive_continuous_prices( + frame, + security=security, + corporate_actions=actions, + ) + result = continuous.frame + result["n"] = turtle_n(result, days=_positive_int(signal.get("n_days"), "n_days")) + levels = breakout_levels( + result, + entry_days=_positive_int(signal.get("entry_days"), "entry_days"), + exit_days=_positive_int(signal.get("exit_days"), "exit_days"), + ) + result["entry_high"] = levels["entry_high"] + result["exit_low"] = levels["exit_low"] + return result.set_index("date", drop=False), continuous.applications + + +def prepare_simulation_inputs( + frames: Mapping[str, pd.DataFrame], + config: Mapping[str, object], + *, + corporate_actions: Sequence[Mapping[str, object]] = (), + corporate_actions_digest: str | None = None, +) -> SimulationInputs: + universe_value = config.get("universe") + if not isinstance(universe_value, list) or not universe_value: + raise ValueError("universe must be a non-empty list") + universe: dict[str, str] = {} + for item in universe_value: + if not isinstance(item, Mapping): + raise ValueError("universe entries must be objects") + security = str(item.get("security", "")) + asset_group = str(item.get("asset_group", "")) + if not security or not asset_group or security in universe: + raise ValueError("universe identities must be non-empty and unique") + universe[security] = asset_group + if set(frames) != set(universe): + raise ValueError("market frames must exactly match the configured universe") + action_securities = { + str(action.get("security", "")) for action in corporate_actions + } + unknown_action_securities = sorted(action_securities - set(universe)) + if unknown_action_securities: + raise _evidence_insufficient( + "corporate actions are outside the configured universe: " + + ", ".join(unknown_action_securities) + ) + computed_action_digest = canonical_corporate_actions_digest(corporate_actions) + if corporate_actions_digest is None: + corporate_actions_digest = computed_action_digest + elif ( + not isinstance(corporate_actions_digest, str) + or len(corporate_actions_digest) != 64 + or any(character not in "0123456789abcdef" for character in corporate_actions_digest) + ): + raise _evidence_insufficient("invalid corporate-actions digest") + elif corporate_actions_digest != computed_action_digest: + raise _evidence_insufficient("corporate-actions digest mismatch") + + securities = tuple(sorted(universe)) + asset_groups = tuple(universe[security] for security in securities) + group_labels = {name: index for index, name in enumerate(sorted(set(asset_groups)))} + asset_group_ids = [group_labels[name] for name in asset_groups] + signal = _section(config, "signal") + normalized: dict[str, pd.DataFrame] = {} + action_applications: list[CorporateActionApplication] = [] + for security in securities: + normalized_frame, applications = _normalized_frame( + frames[security], + security=security, + signal=signal, + actions=corporate_actions, + ) + normalized[security] = normalized_frame + action_applications.extend(applications) + calendar = pd.DatetimeIndex( + sorted({date for frame in normalized.values() for date in frame.index}) + ) + if calendar.empty: + raise ValueError("market calendar must not be empty") + + row_count = len(calendar) + column_count = len(securities) + shape = (row_count, column_count) + raw_open = np.full(shape, np.nan, dtype=np.float64) + raw_high = np.full(shape, np.nan, dtype=np.float64) + raw_low = np.full(shape, np.nan, dtype=np.float64) + raw_close = np.full(shape, np.nan, dtype=np.float64) + raw_pre_close = np.full(shape, np.nan, dtype=np.float64) + continuous_open = np.full(shape, np.nan, dtype=np.float64) + continuous_high = np.full(shape, np.nan, dtype=np.float64) + continuous_low = np.full(shape, np.nan, dtype=np.float64) + continuous_close = np.full(shape, np.nan, dtype=np.float64) + continuous_pre_close = np.full(shape, np.nan, dtype=np.float64) + continuity_factor = np.full(shape, np.nan, dtype=np.float64) + corporate_action_applied = np.zeros(shape, dtype=np.bool_) + paused = np.ones(shape, dtype=np.bool_) + high_limit = np.full(shape, np.nan, dtype=np.float64) + low_limit = np.full(shape, np.nan, dtype=np.float64) + raw_entry_high = np.full(shape, np.nan, dtype=np.float64) + raw_exit_low = np.full(shape, np.nan, dtype=np.float64) + raw_n = np.full(shape, np.nan, dtype=np.float64) + for column, security in enumerate(securities): + aligned = normalized[security].reindex(calendar) + raw_open[:, column] = aligned["raw_open"].to_numpy(dtype=np.float64) + raw_high[:, column] = aligned["raw_high"].to_numpy(dtype=np.float64) + raw_low[:, column] = aligned["raw_low"].to_numpy(dtype=np.float64) + raw_close[:, column] = aligned["raw_close"].to_numpy(dtype=np.float64) + raw_pre_close[:, column] = aligned["raw_pre_close"].to_numpy(dtype=np.float64) + continuous_open[:, column] = aligned["open"].to_numpy(dtype=np.float64) + continuous_high[:, column] = aligned["high"].to_numpy(dtype=np.float64) + continuous_low[:, column] = aligned["low"].to_numpy(dtype=np.float64) + continuous_close[:, column] = aligned["close"].to_numpy(dtype=np.float64) + continuous_pre_close[:, column] = aligned["pre_close"].to_numpy( + dtype=np.float64 + ) + continuity_factor[:, column] = aligned["continuity_factor"].to_numpy( + dtype=np.float64 + ) + corporate_action_applied[:, column] = aligned[ + "corporate_action_applied" + ].fillna(False).to_numpy(dtype=np.bool_) + paused[:, column] = aligned["paused"].fillna(True).to_numpy(dtype=np.bool_) + high_limit[:, column] = aligned["high_limit"].to_numpy(dtype=np.float64) + low_limit[:, column] = aligned["low_limit"].to_numpy(dtype=np.float64) + raw_entry_high[:, column] = aligned["entry_high"].to_numpy(dtype=np.float64) + raw_exit_low[:, column] = aligned["exit_low"].to_numpy(dtype=np.float64) + raw_n[:, column] = aligned["n"].to_numpy(dtype=np.float64) + + shift = 1 + signal_source_index = np.full(row_count, -1, dtype=np.int64) + signal_close = np.full(shape, np.nan, dtype=np.float64) + signal_entry_high = np.full(shape, np.nan, dtype=np.float64) + signal_exit_low = np.full(shape, np.nan, dtype=np.float64) + signal_n = np.full(shape, np.nan, dtype=np.float64) + for execution_row in range(shift, row_count): + source_row = execution_row - shift + signal_source_index[execution_row] = source_row + signal_close[execution_row] = continuous_close[source_row] + signal_entry_high[execution_row] = raw_entry_high[source_row] + signal_exit_low[execution_row] = raw_exit_low[source_row] + signal_n[execution_row] = raw_n[source_row] + + return SimulationInputs( + dates=_readonly(calendar.to_numpy(dtype="datetime64[D]"), "datetime64[D]"), + securities=securities, + asset_groups=asset_groups, + asset_group_ids=_readonly(asset_group_ids, "int64"), + raw_open=_readonly(raw_open, "float64"), + raw_high=_readonly(raw_high, "float64"), + raw_low=_readonly(raw_low, "float64"), + raw_close=_readonly(raw_close, "float64"), + raw_pre_close=_readonly(raw_pre_close, "float64"), + continuous_open=_readonly(continuous_open, "float64"), + continuous_high=_readonly(continuous_high, "float64"), + continuous_low=_readonly(continuous_low, "float64"), + continuous_close=_readonly(continuous_close, "float64"), + continuous_pre_close=_readonly(continuous_pre_close, "float64"), + continuity_factor=_readonly(continuity_factor, "float64"), + corporate_action_applied=_readonly(corporate_action_applied, "bool"), + corporate_actions_digest=corporate_actions_digest, + corporate_action_applications=tuple( + sorted( + action_applications, + key=lambda item: ( + item.effective_date, + item.security, + item.source_event_id, + ), + ) + ), + paused=_readonly(paused, "bool"), + high_limit=_readonly(high_limit, "float64"), + low_limit=_readonly(low_limit, "float64"), + signal_source_index=_readonly(signal_source_index, "int64"), + signal_close=_readonly(signal_close, "float64"), + signal_entry_high=_readonly(signal_entry_high, "float64"), + signal_exit_low=_readonly(signal_exit_low, "float64"), + signal_n=_readonly(signal_n, "float64"), + ) + + +ACTION_NONE = 0 +ACTION_FULL_EXIT = 1 +ACTION_REDISTRIBUTION_SELL = 2 +ACTION_ENTRY = 3 +ACTION_ADDITION = 4 +ACTION_REDISTRIBUTION_BUY = 5 + +REASON_NONE = 0 +REASON_ENTRY_BREAKOUT = 1 +REASON_FIXED_ADDITION_LEVEL = 2 +REASON_PROTECTIVE_STOP = 3 +REASON_TREND_EXIT = 4 +REASON_MISSING_OPEN = 5 +REASON_PAUSED = 6 +REASON_HIGH_LIMIT = 7 +REASON_LOW_LIMIT = 8 +REASON_ALLOCATION_CONSTRAINT = 9 +REASON_ORDER_REJECTED = 10 +REASON_FULL_POSITION_REDISTRIBUTION = 11 + + +CallbackInputs = namedtuple( + "CallbackInputs", + ( + "execution_open", + "signal_close", + "signal_entry_high", + "signal_exit_low", + "signal_n", + "paused", + "high_limit", + "low_limit", + "asset_group_ids", + ), +) + +CallbackParams = namedtuple( + "CallbackParams", + ( + "lot_size", + "unit_risk_per_n", + "add_step_n", + "stop_n", + "max_units", + "asset_group_unit_cap", + "portfolio_unit_cap", + "commission_multiplier", + "one_way_slippage", + ), +) + +CallbackState = namedtuple( + "CallbackState", + ( + "unit_count", + "unit_signal_n", + "unit_base_quantities", + "initial_fill_price", + "initial_signal_n", + "common_stop", + "next_add_index", + "candidate_signal_n", + "candidate_base_quantity", + "action_codes", + "reason_codes", + "requested_quantities", + "planned_quantities", + "state_common_stop", + "state_next_add_index", + "state_unit_counts", + "event_group_scales", + "event_portfolio_scales", + "event_cash_scales", + "planned_row_indices", + "execution_adjustment_codes", + "frozen_signal_n", + "scratch_exit_active", + "scratch_candidate_active", + "scratch_locked_quantities", + "scratch_targets", + "scratch_candidate_targets", + "scratch_unit_counts", + "scratch_unit_bases", + "scratch_group_units", + "scratch_group_scales", + ), +) + + +@njit +def _finite_positive(value: float) -> bool: + return np.isfinite(value) and value > 0.0 + + +@njit +def _commission(price: float, quantity: int, multiplier: float) -> float: + return max(5.0, price * quantity * 0.000085) * multiplier + + +@njit +def _buy_price(open_price: float, slippage: float) -> float: + return open_price * (1.0 + slippage) + + +@njit +def _sell_price(open_price: float, slippage: float) -> float: + return open_price * (1.0 - slippage) + + +@njit +def _buy_tradability_reason_nb( + row: int, column: int, inputs: CallbackInputs +) -> int: + open_price = inputs.execution_open[row, column] + if not _finite_positive(open_price): + return REASON_MISSING_OPEN + if inputs.paused[row, column]: + return REASON_PAUSED + high_limit = inputs.high_limit[row, column] + if np.isfinite(high_limit) and open_price >= high_limit: + return REASON_HIGH_LIMIT + return REASON_NONE + + +@njit +def _sell_tradability_reason_nb( + row: int, column: int, inputs: CallbackInputs +) -> int: + open_price = inputs.execution_open[row, column] + if not _finite_positive(open_price): + return REASON_MISSING_OPEN + if inputs.paused[row, column]: + return REASON_PAUSED + low_limit = inputs.low_limit[row, column] + if np.isfinite(low_limit) and open_price <= low_limit: + return REASON_LOW_LIMIT + return REASON_NONE + + +@njit +def _risk_scales_into_nb( + unit_counts: np.ndarray, + asset_group_ids: np.ndarray, + from_column: int, + group_count: int, + asset_group_unit_cap: float, + portfolio_unit_cap: float, + group_units: np.ndarray, + group_scales: np.ndarray, +) -> float: + for group in range(group_count): + group_units[group] = 0.0 + group_scales[group] = 1.0 + for column in range(unit_counts.shape[0]): + group_units[asset_group_ids[from_column + column]] += unit_counts[column] + for group in range(group_count): + if group_units[group] > asset_group_unit_cap: + group_scales[group] = asset_group_unit_cap / group_units[group] + effective_units = 0.0 + for column in range(unit_counts.shape[0]): + effective_units += ( + unit_counts[column] + * group_scales[asset_group_ids[from_column + column]] + ) + portfolio_scale = 1.0 + if effective_units > portfolio_unit_cap: + portfolio_scale = portfolio_unit_cap / effective_units + return portfolio_scale + + +@njit +def _risk_scales_nb( + unit_counts: np.ndarray, + asset_group_ids: np.ndarray, + group_count: int, + asset_group_unit_cap: float, + portfolio_unit_cap: float, +): + group_units = np.zeros(group_count, dtype=np.float64) + group_scales = np.ones(group_count, dtype=np.float64) + portfolio_scale = _risk_scales_into_nb( + unit_counts, + asset_group_ids, + 0, + group_count, + asset_group_unit_cap, + portfolio_unit_cap, + group_units, + group_scales, + ) + return group_scales, portfolio_scale + + +@njit +def _targets_for_scale_into_nb( + unit_base_quantities: np.ndarray, + unit_counts: np.ndarray, + asset_group_ids: np.ndarray, + from_column: int, + group_scales: np.ndarray, + portfolio_scale: float, + cash_scale: float, + locked_quantities: np.ndarray, + lot_size: int, + targets: np.ndarray, +) -> None: + for column in range(unit_counts.shape[0]): + if locked_quantities[column] >= 0: + targets[column] = locked_quantities[column] + continue + raw_quantity = 0 + for unit in range(unit_counts[column]): + raw_quantity += unit_base_quantities[column, unit] + scaled = ( + raw_quantity + * group_scales[asset_group_ids[from_column + column]] + * portfolio_scale + * cash_scale + ) + targets[column] = int(scaled // lot_size) * lot_size + + +@njit +def _targets_for_scale_nb( + unit_base_quantities: np.ndarray, + unit_counts: np.ndarray, + asset_group_ids: np.ndarray, + group_scales: np.ndarray, + portfolio_scale: float, + cash_scale: float, + locked_quantities: np.ndarray, + lot_size: int, +) -> np.ndarray: + targets = np.zeros(unit_counts.shape[0], dtype=np.int64) + _targets_for_scale_into_nb( + unit_base_quantities, + unit_counts, + asset_group_ids, + 0, + group_scales, + portfolio_scale, + cash_scale, + locked_quantities, + lot_size, + targets, + ) + return targets + + +@njit +def _cash_after_targets_nb( + row: int, + from_column: int, + targets: np.ndarray, + positions: np.ndarray, + cash: float, + inputs: CallbackInputs, + params: CallbackParams, +) -> float: + projected_cash = cash + for offset in range(targets.shape[0]): + column = from_column + offset + current = int(round(positions[offset])) + if targets[offset] >= current: + continue + quantity = current - targets[offset] + price = _sell_price( + inputs.execution_open[row, column], params.one_way_slippage + ) + projected_cash += price * quantity - _commission( + price, quantity, params.commission_multiplier + ) + for offset in range(targets.shape[0]): + column = from_column + offset + current = int(round(positions[offset])) + if targets[offset] <= current: + continue + quantity = targets[offset] - current + price = _buy_price( + inputs.execution_open[row, column], params.one_way_slippage + ) + projected_cash -= price * quantity + _commission( + price, quantity, params.commission_multiplier + ) + return projected_cash + + +@njit +def _cash_feasible_targets_nb( + row: int, + from_column: int, + unit_base_quantities: np.ndarray, + unit_counts: np.ndarray, + positions: np.ndarray, + cash: float, + group_scales: np.ndarray, + portfolio_scale: float, + locked_quantities: np.ndarray, + inputs: CallbackInputs, + params: CallbackParams, + targets: np.ndarray, + candidate_targets: np.ndarray, +) -> float: + _targets_for_scale_into_nb( + unit_base_quantities, + unit_counts, + inputs.asset_group_ids, + from_column, + group_scales, + portfolio_scale, + 1.0, + locked_quantities, + params.lot_size, + targets, + ) + if _cash_after_targets_nb( + row, from_column, targets, positions, cash, inputs, params + ) >= -1e-9: + return 1.0 + lower = 0.0 + upper = 1.0 + _targets_for_scale_into_nb( + unit_base_quantities, + unit_counts, + inputs.asset_group_ids, + from_column, + group_scales, + portfolio_scale, + lower, + locked_quantities, + params.lot_size, + targets, + ) + for _ in range(64): + candidate_scale = (lower + upper) / 2.0 + _targets_for_scale_into_nb( + unit_base_quantities, + unit_counts, + inputs.asset_group_ids, + from_column, + group_scales, + portfolio_scale, + candidate_scale, + locked_quantities, + params.lot_size, + candidate_targets, + ) + if _cash_after_targets_nb( + row, + from_column, + candidate_targets, + positions, + cash, + inputs, + params, + ) >= -1e-9: + lower = candidate_scale + for column in range(targets.shape[0]): + targets[column] = candidate_targets[column] + else: + upper = candidate_scale + return lower + + +@njit +def _clear_position_state_nb(column: int, state: CallbackState) -> None: + state.unit_count[column] = 0 + for unit in range(state.unit_signal_n.shape[1]): + state.unit_signal_n[column, unit] = np.nan + state.unit_base_quantities[column, unit] = 0 + state.initial_fill_price[column] = np.nan + state.initial_signal_n[column] = np.nan + state.common_stop[column] = np.nan + state.next_add_index[column] = 0 + + +@njit +def _action_priority(action: int) -> int: + if action == ACTION_FULL_EXIT: + return 0 + if action == ACTION_REDISTRIBUTION_SELL: + return 1 + if action == ACTION_ENTRY or action == ACTION_ADDITION: + return 2 + if action == ACTION_REDISTRIBUTION_BUY: + return 3 + return 4 + + +@njit +def prepare_segment_nb(view, inputs, params, state, trace, orders) -> None: + row = view.row + column_count = view.to_col - view.from_col + equity = view.value + for offset in range(column_count): + column = view.from_col + offset + state.action_codes[row, column] = ACTION_NONE + state.reason_codes[row, column] = REASON_NONE + state.requested_quantities[row, column] = 0 + state.planned_quantities[row, column] = 0 + state.candidate_signal_n[row, column] = np.nan + state.candidate_base_quantity[row, column] = 0 + + exit_active = state.scratch_exit_active[:column_count] + candidate_active = state.scratch_candidate_active[:column_count] + for offset in range(column_count): + exit_active[offset] = False + candidate_active[offset] = False + any_decision = False + + for offset in range(column_count): + column = view.from_col + offset + position = view.positions[offset] + close = inputs.signal_close[row, column] + if position <= 0.0 or not _finite_positive(close): + continue + reason = REASON_NONE + if np.isfinite(state.common_stop[column]) and close <= state.common_stop[column]: + reason = REASON_PROTECTIVE_STOP + elif ( + np.isfinite(inputs.signal_exit_low[row, column]) + and close < inputs.signal_exit_low[row, column] + ): + reason = REASON_TREND_EXIT + if reason != REASON_NONE: + state.action_codes[row, column] = ACTION_FULL_EXIT + state.reason_codes[row, column] = reason + state.requested_quantities[row, column] = int(round(position)) + tradability = _sell_tradability_reason_nb(row, column, inputs) + if tradability == REASON_NONE: + exit_active[offset] = True + else: + state.reason_codes[row, column] = tradability + any_decision = True + + for offset in range(column_count): + column = view.from_col + offset + if state.action_codes[row, column] == ACTION_FULL_EXIT: + continue + close = inputs.signal_close[row, column] + signal_n = inputs.signal_n[row, column] + if not _finite_positive(close) or not _finite_positive(signal_n): + continue + position = view.positions[offset] + action = ACTION_NONE + reason = REASON_NONE + if position > 0.0 and state.unit_count[column] > 0: + next_index = state.next_add_index[column] + if ( + next_index < params.max_units + and close + >= state.initial_fill_price[column] + + next_index * params.add_step_n * state.initial_signal_n[column] + ): + action = ACTION_ADDITION + reason = REASON_FIXED_ADDITION_LEVEL + elif ( + position <= 0.0 + and np.isfinite(inputs.signal_entry_high[row, column]) + and close > inputs.signal_entry_high[row, column] + ): + action = ACTION_ENTRY + reason = REASON_ENTRY_BREAKOUT + if action == ACTION_NONE: + continue + quantity = int(equity * params.unit_risk_per_n / signal_n) + quantity = (quantity // params.lot_size) * params.lot_size + if quantity <= 0: + continue + state.action_codes[row, column] = action + state.reason_codes[row, column] = reason + state.requested_quantities[row, column] = quantity + state.candidate_signal_n[row, column] = signal_n + state.candidate_base_quantity[row, column] = quantity + tradability = _buy_tradability_reason_nb(row, column, inputs) + if tradability == REASON_NONE: + candidate_active[offset] = True + else: + state.reason_codes[row, column] = tradability + any_decision = True + + if any_decision: + locked = state.scratch_locked_quantities[:column_count] + targets = state.scratch_targets[:column_count] + candidate_targets = state.scratch_candidate_targets[:column_count] + counts = state.scratch_unit_counts[:column_count] + bases = state.scratch_unit_bases[:column_count] + group_units = state.scratch_group_units + group_scales = state.scratch_group_scales + for offset in range(column_count): + column = view.from_col + offset + locked[offset] = -1 + targets[offset] = int(round(view.positions[offset])) + if ( + not _finite_positive(inputs.execution_open[row, column]) + or inputs.paused[row, column] + or ( + state.action_codes[row, column] == ACTION_FULL_EXIT + and not exit_active[offset] + ) + ): + locked[offset] = int(round(view.positions[offset])) + + group_count = group_scales.shape[0] + portfolio_scale = 1.0 + cash_scale = 1.0 + for _ in range(column_count * 3 + 1): + for offset in range(column_count): + column = view.from_col + offset + counts[offset] = state.unit_count[column] + for unit in range(params.max_units): + bases[offset, unit] = state.unit_base_quantities[column, unit] + if exit_active[offset]: + counts[offset] = 0 + for unit in range(params.max_units): + bases[offset, unit] = 0 + elif candidate_active[offset]: + slot = counts[offset] + bases[offset, slot] = state.candidate_base_quantity[row, column] + counts[offset] = slot + 1 + portfolio_scale = _risk_scales_into_nb( + counts, + inputs.asset_group_ids, + view.from_col, + group_count, + params.asset_group_unit_cap, + params.portfolio_unit_cap, + group_units, + group_scales, + ) + cash_scale = _cash_feasible_targets_nb( + row, + view.from_col, + bases, + counts, + view.positions, + view.cash, + group_scales, + portfolio_scale, + locked, + inputs, + params, + targets, + candidate_targets, + ) + changed = False + for offset in range(column_count): + column = view.from_col + offset + current = int(round(view.positions[offset])) + if locked[offset] < 0 and targets[offset] < current: + if _sell_tradability_reason_nb(row, column, inputs) != REASON_NONE: + locked[offset] = current + changed = True + elif locked[offset] < 0 and targets[offset] > current: + if _buy_tradability_reason_nb(row, column, inputs) != REASON_NONE: + locked[offset] = current + changed = True + if ( + candidate_active[offset] + and targets[offset] - current < params.lot_size + ): + candidate_active[offset] = False + state.reason_codes[row, column] = REASON_ALLOCATION_CONSTRAINT + changed = True + if not changed: + break + + has_effective_event = False + for offset in range(column_count): + if exit_active[offset] or candidate_active[offset]: + has_effective_event = True + if has_effective_event: + for offset in range(column_count): + column = view.from_col + offset + group = inputs.asset_group_ids[column] + state.event_group_scales[row, column] = group_scales[group] + state.event_portfolio_scales[row] = portfolio_scale + state.event_cash_scales[row] = cash_scale + for offset in range(column_count): + column = view.from_col + offset + current = int(round(view.positions[offset])) + action = state.action_codes[row, column] + if action == ACTION_FULL_EXIT: + if exit_active[offset]: + state.planned_quantities[row, column] = current + continue + delta = targets[offset] - current + if action == ACTION_ENTRY or action == ACTION_ADDITION: + if candidate_active[offset] and delta >= params.lot_size: + state.planned_quantities[row, column] = delta + continue + if delta <= -params.lot_size: + state.action_codes[row, column] = ACTION_REDISTRIBUTION_SELL + state.reason_codes[row, column] = REASON_FULL_POSITION_REDISTRIBUTION + state.requested_quantities[row, column] = -delta + state.planned_quantities[row, column] = -delta + elif delta >= params.lot_size: + state.action_codes[row, column] = ACTION_REDISTRIBUTION_BUY + state.reason_codes[row, column] = REASON_FULL_POSITION_REDISTRIBUTION + state.requested_quantities[row, column] = delta + state.planned_quantities[row, column] = delta + + for offset in range(column_count): + column = view.from_col + offset + action = state.action_codes[row, column] + reason = state.reason_codes[row, column] + quantity = state.planned_quantities[row, column] + open_price = inputs.execution_open[row, column] + if action != ACTION_NONE: + state.planned_row_indices[row, column] = row + state.frozen_signal_n[row, column] = inputs.signal_n[row, column] + if _finite_positive(open_price): + view.valuation_prices[offset] = open_price + orders[7][column] = _action_priority(action) + if ( + action == ACTION_NONE + or reason == REASON_MISSING_OPEN + or reason == REASON_PAUSED + or reason == REASON_HIGH_LIMIT + or reason == REASON_LOW_LIMIT + or reason == REASON_ALLOCATION_CONSTRAINT + or quantity <= 0 + ): + continue + if action == ACTION_FULL_EXIT or action == ACTION_REDISTRIBUTION_SELL: + quantity = min(quantity, int(round(view.positions[offset]))) + if quantity <= 0: + continue + price = _sell_price(open_price, params.one_way_slippage) + orders[2][column] = float(quantity) + orders[1][column] = SIDE_SELL + orders[5][column] = np.nan + else: + price = _buy_price(open_price, params.one_way_slippage) + orders[2][column] = float(quantity) + orders[1][column] = SIDE_BUY + orders[5][column] = float(params.lot_size) + orders[0][column] = True + orders[3][column] = price + orders[4][column] = _commission( + price, + quantity, + params.commission_multiplier, + ) + orders[6][column] = False + + +@njit +def _record_candidate_unit_nb( + row: int, + column: int, + fill_price: float, + state: CallbackState, + params: CallbackParams, +) -> None: + slot = state.unit_count[column] + if slot >= params.max_units: + return + signal_n = state.candidate_signal_n[row, column] + state.unit_signal_n[column, slot] = signal_n + state.unit_base_quantities[column, slot] = state.candidate_base_quantity[ + row, column + ] + if slot == 0: + state.initial_fill_price[column] = fill_price + state.initial_signal_n[column] = signal_n + state.common_stop[column] = fill_price - params.stop_n * signal_n + else: + candidate_stop = fill_price - params.stop_n * signal_n + state.common_stop[column] = max(state.common_stop[column], candidate_stop) + state.unit_count[column] = slot + 1 + state.next_add_index[column] = slot + 1 + + +@njit +def after_fill_nb(event, inputs, params, state, trace, orders) -> None: + row = event.row + column = event.column + action = state.action_codes[row, column] + if event.status == FILL_ACCEPTED: + if event.side == SIDE_SELL: + if action == ACTION_FULL_EXIT and event.position_after <= 1e-9: + _clear_position_state_nb(column, state) + elif action == ACTION_ENTRY: + _clear_position_state_nb(column, state) + _record_candidate_unit_nb(row, column, event.price, state, params) + elif action == ACTION_ADDITION: + _record_candidate_unit_nb(row, column, event.price, state, params) + elif event.status == FILL_REJECTED: + state.reason_codes[row, column] = REASON_ORDER_REJECTED + + +@njit +def after_segment_nb(view, inputs, params, state, trace, orders) -> None: + row = view.row + for offset in range(view.to_col - view.from_col): + column = view.from_col + offset + state.state_common_stop[row, column] = state.common_stop[column] + state.state_next_add_index[row, column] = state.next_add_index[column] + state.state_unit_counts[row, column] = state.unit_count[column] + + +_LEGACY_RISK_FIELDS = frozenset( + { + "security_risk_cap", + "security_value_cap", + "asset_group_risk_cap", + "asset_group_value_cap", + "portfolio_risk_cap", + "portfolio_value_cap", + "covariance", + "target_volatility", + "risk_reduction_target_volatility", + "minimum_aligned_samples", + } +) + + +def _number( + section: Mapping[str, object], + name: str, + default: float | None = None, + *, + positive: bool = True, +) -> float: + value = section.get(name, default) + if value is None: + raise ValueError(f"missing config value: {name}") + try: + result = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be numeric") from exc + if not np.isfinite(result) or (positive and result <= 0.0): + raise ValueError(f"{name} must be finite and positive") + return result + + +def _params(config: Mapping[str, object]) -> tuple[float, CallbackParams]: + research = _section(config, "research") + signal = _section(config, "signal") + risk = _section(config, "risk") + costs_value = config.get("costs", {}) + if not isinstance(costs_value, Mapping): + raise ValueError("costs config must be an object") + initial_cash = _number(research, "initial_cash") + legacy = sorted(set(risk) & _LEGACY_RISK_FIELDS) + if legacy: + raise ValueError("legacy risk fields are not supported: " + ", ".join(legacy)) + lot_size_value = risk.get("lot_size", 100) + if isinstance(lot_size_value, bool) or int(lot_size_value) != lot_size_value: + raise ValueError("lot_size must be a positive integer") + lot_size = int(lot_size_value) + if lot_size <= 0: + raise ValueError("lot_size must be a positive integer") + slippage = _number(costs_value, "one_way_slippage", 0.0, positive=False) + if slippage < 0.0 or slippage >= 1.0: + raise ValueError("one_way_slippage must be between zero and one") + if "max_units" not in signal: + raise ValueError("missing config value: max_units") + max_units_value = signal["max_units"] + if ( + isinstance(max_units_value, bool) + or not isinstance(max_units_value, int) + or max_units_value != 4 + ): + raise ValueError("max_units must equal four") + return initial_cash, CallbackParams( + lot_size=lot_size, + unit_risk_per_n=_number(risk, "unit_risk_per_n"), + add_step_n=_number(signal, "add_step_n"), + stop_n=_number(signal, "stop_n"), + max_units=max_units_value, + asset_group_unit_cap=_number(risk, "asset_group_unit_cap"), + portfolio_unit_cap=_number(risk, "portfolio_unit_cap"), + commission_multiplier=_number(costs_value, "commission_multiplier", 1.0), + one_way_slippage=slippage, + ) + + +def _delay_days(config: Mapping[str, object]) -> int: + execution = config.get("execution", {}) + if not isinstance(execution, Mapping): + raise ValueError("execution config must be an object") + value = execution.get("additional_delay_days", 0) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError("additional_delay_days must be a non-negative integer") + return value + + +def _mutable_state( + rows: int, + columns: int, + group_count: int, + max_units: int, +) -> CallbackState: + if rows <= 0 or columns <= 0 or group_count <= 0 or max_units != 4: + raise ValueError("invalid callback state dimensions") + return CallbackState( + unit_count=np.zeros(columns, dtype=np.int64), + unit_signal_n=np.full((columns, max_units), np.nan, dtype=np.float64), + unit_base_quantities=np.zeros((columns, max_units), dtype=np.int64), + initial_fill_price=np.full(columns, np.nan, dtype=np.float64), + initial_signal_n=np.full(columns, np.nan, dtype=np.float64), + common_stop=np.full(columns, np.nan, dtype=np.float64), + next_add_index=np.zeros(columns, dtype=np.int64), + candidate_signal_n=np.full((rows, columns), np.nan, dtype=np.float64), + candidate_base_quantity=np.zeros((rows, columns), dtype=np.int64), + action_codes=np.zeros((rows, columns), dtype=np.int16), + reason_codes=np.zeros((rows, columns), dtype=np.int16), + requested_quantities=np.zeros((rows, columns), dtype=np.int64), + planned_quantities=np.zeros((rows, columns), dtype=np.int64), + state_common_stop=np.full((rows, columns), np.nan, dtype=np.float64), + state_next_add_index=np.zeros((rows, columns), dtype=np.int64), + state_unit_counts=np.zeros((rows, columns), dtype=np.int64), + event_group_scales=np.ones((rows, columns), dtype=np.float64), + event_portfolio_scales=np.ones(rows, dtype=np.float64), + event_cash_scales=np.ones(rows, dtype=np.float64), + planned_row_indices=np.full((rows, columns), -1, dtype=np.int64), + execution_adjustment_codes=np.zeros((rows, columns), dtype=np.int16), + frozen_signal_n=np.full((rows, columns), np.nan, dtype=np.float64), + scratch_exit_active=np.zeros(columns, dtype=np.bool_), + scratch_candidate_active=np.zeros(columns, dtype=np.bool_), + scratch_locked_quantities=np.full(columns, -1, dtype=np.int64), + scratch_targets=np.zeros(columns, dtype=np.int64), + scratch_candidate_targets=np.zeros(columns, dtype=np.int64), + scratch_unit_counts=np.zeros(columns, dtype=np.int64), + scratch_unit_bases=np.zeros((columns, max_units), dtype=np.int64), + scratch_group_units=np.zeros(group_count, dtype=np.float64), + scratch_group_scales=np.ones(group_count, dtype=np.float64), + ) + + +def _order_buffer(columns: int) -> OrderBuffer: + return OrderBuffer( + enabled=np.zeros(columns, dtype=np.bool_), + side=np.zeros(columns, dtype=np.int8), + size=np.zeros(columns, dtype=np.float64), + price=np.full(columns, np.nan, dtype=np.float64), + fixed_fees=np.zeros(columns, dtype=np.float64), + size_granularity=np.full(columns, np.nan, dtype=np.float64), + allow_partial=np.zeros(columns, dtype=np.bool_), + priority=np.zeros(columns, dtype=np.int64), + ) + + +def _trace(state: CallbackState, rows: int, columns: int) -> dict[str, np.ndarray]: + return { + "action_codes": state.action_codes, + "reason_codes": state.reason_codes, + "requested_quantities": state.requested_quantities, + "planned_quantities": state.planned_quantities, + "state_common_stop": state.state_common_stop, + "state_next_add_index": state.state_next_add_index, + "state_unit_counts": state.state_unit_counts, + "candidate_base_quantities": state.candidate_base_quantity, + "event_group_scales": state.event_group_scales, + "event_portfolio_scales": state.event_portfolio_scales, + "event_cash_scales": state.event_cash_scales, + "planned_row_indices": state.planned_row_indices, + "execution_adjustment_codes": state.execution_adjustment_codes, + "frozen_signal_n": state.frozen_signal_n, + } + + +@dataclass(frozen=True, slots=True) +class TurtleContext: + inputs: SimulationInputs + params: CallbackParams + scenario_id: str + delay_days: int + initial_cash: float + + +def _market_frames( + rows: Sequence[Mapping[str, object]], + config: Mapping[str, object], +) -> dict[str, pd.DataFrame]: + universe = config.get("universe") + if not isinstance(universe, list) or not universe: + raise ValueError("universe must be a non-empty list") + securities = tuple( + str(item.get("security", "")) + for item in universe + if isinstance(item, Mapping) + ) + if len(securities) != len(universe) or any(not security for security in securities): + raise ValueError("universe identities must be non-empty and unique") + frame = pd.DataFrame([dict(row) for row in rows]) + if frame.empty or "security" not in frame: + raise StrategyEvidenceError("snapshot_universe_mismatch", "snapshot has no market rows") + if set(frame["security"].astype(str)) != set(securities): + raise StrategyEvidenceError( + "snapshot_universe_mismatch", + "snapshot universe does not match the scenario", + ) + return { + security: frame.loc[frame["security"].astype(str) == security].copy() + for security in securities + } + + +def prepare_turtle_strategy(snapshot: object, config: Mapping[str, object]) -> PreparedStrategy: + initial_cash, params = _params(config) + delay_days = _delay_days(config) + frames = _market_frames(getattr(snapshot, "rows"), config) + try: + simulation_inputs = prepare_simulation_inputs( + frames, + config, + corporate_actions=getattr(snapshot, "corporate_actions"), + corporate_actions_digest=getattr(snapshot, "corporate_actions_digest"), + ) + except ValueError as exc: + if str(exc).startswith("evidence_insufficient:"): + raise StrategyEvidenceError("market_evidence_insufficient", str(exc)) from exc + raise + return _prepare_turtle_inputs( + simulation_inputs, + config, + initial_cash=initial_cash, + params=params, + delay_days=delay_days, + ) + + +def _prepare_turtle_inputs( + simulation_inputs: SimulationInputs, + config: Mapping[str, object], + *, + initial_cash: float | None = None, + params: CallbackParams | None = None, + delay_days: int | None = None, +) -> PreparedStrategy: + if initial_cash is None or params is None: + initial_cash, params = _params(config) + if delay_days is None: + delay_days = _delay_days(config) + rows, columns = simulation_inputs.close.shape + group_count = int(np.max(simulation_inputs.asset_group_ids)) + 1 + state = _mutable_state(rows, columns, group_count, params.max_units) + callback_inputs = CallbackInputs( + simulation_inputs.execution_open, + simulation_inputs.signal_close, + simulation_inputs.signal_entry_high, + simulation_inputs.signal_exit_low, + simulation_inputs.signal_n, + simulation_inputs.paused, + simulation_inputs.high_limit, + simulation_inputs.low_limit, + simulation_inputs.asset_group_ids, + ) + program = OrderProgram( + program_id="turtle-etf-immediate/1", + prepare_segment_nb=prepare_segment_nb, + after_fill_nb=after_fill_nb, + after_segment_nb=after_segment_nb, + inputs=callback_inputs, + params=params, + state=state, + trace=_trace(state, rows, columns), + orders=_order_buffer(columns), + ) + scenario_id = config.get("scenario_id") + if not isinstance(scenario_id, str) or not scenario_id.strip(): + raise ValueError("scenario_id is missing or invalid") + return PreparedStrategy( + ledger_input=LedgerInput( + dates=simulation_inputs.dates, + symbols=simulation_inputs.securities, + close=simulation_inputs.close, + initial_cash=initial_cash, + group_ids=np.zeros(columns, dtype=np.int64), + cash_sharing=True, + frequency="1D", + ), + primary_program=program, + context=TurtleContext( + simulation_inputs, + params, + scenario_id, + delay_days, + initial_cash, + ), + ) diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/indicators.py b/joinquant/strategies/strategy-003/research/turtle_etf/indicators.py deleted file mode 100644 index 65189fc..0000000 --- a/joinquant/strategies/strategy-003/research/turtle_etf/indicators.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import math - -import pandas as pd - - -def _numeric_columns(frame: pd.DataFrame, fields: tuple[str, ...]) -> dict[str, pd.Series]: - missing = [field for field in fields if field not in frame.columns] - if missing: - raise ValueError(f"missing price fields: {', '.join(missing)}") - return { - field: pd.to_numeric(frame[field], errors="coerce").astype(float) - for field in fields - } - - -def true_range(frame: pd.DataFrame) -> pd.Series: - columns = _numeric_columns(frame, ("high", "low", "pre_close")) - components = pd.concat( - ( - columns["high"] - columns["low"], - (columns["high"] - columns["pre_close"]).abs(), - (columns["low"] - columns["pre_close"]).abs(), - ), - axis=1, - ) - result = components.max(axis=1, skipna=False) - result.name = "tr" - return result - - -def turtle_n(frame: pd.DataFrame, days: int = 20) -> pd.Series: - if not isinstance(days, int) or days < 1: - raise ValueError("days must be a positive integer") - tr = true_range(frame) - values: list[float] = [] - warmup: list[float] = [] - current: float | None = None - for raw_value in tr: - value = float(raw_value) if pd.notna(raw_value) else math.nan - if not math.isfinite(value): - warmup.clear() - current = None - values.append(math.nan) - continue - if current is None: - warmup.append(value) - if len(warmup) < days: - values.append(math.nan) - continue - current = sum(warmup[-days:]) / days - else: - current = ((current * (days - 1)) + value) / days - values.append(current) - return pd.Series(values, index=frame.index, name="n", dtype=float) - - -def breakout_levels( - frame: pd.DataFrame, - entry_days: int, - exit_days: int, -) -> pd.DataFrame: - if not isinstance(entry_days, int) or entry_days < 1: - raise ValueError("entry_days must be a positive integer") - if not isinstance(exit_days, int) or exit_days < 1: - raise ValueError("exit_days must be a positive integer") - columns = _numeric_columns(frame, ("high", "low")) - return pd.DataFrame( - { - "entry_high": columns["high"] - .shift(1) - .rolling(entry_days, min_periods=entry_days) - .max(), - "exit_low": columns["low"] - .shift(1) - .rolling(exit_days, min_periods=exit_days) - .min(), - }, - index=frame.index, - ) diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/result_adapter.py b/joinquant/strategies/strategy-003/research/turtle_etf/result_adapter.py deleted file mode 100644 index 1d86a8e..0000000 --- a/joinquant/strategies/strategy-003/research/turtle_etf/result_adapter.py +++ /dev/null @@ -1,1650 +0,0 @@ -from __future__ import annotations - -import hashlib -import importlib.metadata -import json -import os -import shutil -import uuid -from dataclasses import dataclass, replace -from pathlib import Path -from typing import Mapping - -import numpy as np -import pyarrow as pa -import pyarrow.parquet as pq - -from scripts.research.analysis_data import open_analysis_source - -from .vectorbt_callbacks import ( - ACTION_ADDITION, - ACTION_ENTRY, - ACTION_FULL_EXIT, - ACTION_NONE, - ACTION_REDISTRIBUTION_BUY, - ACTION_REDISTRIBUTION_SELL, - REASON_ALLOCATION_CONSTRAINT, - REASON_ENTRY_BREAKOUT, - REASON_FIXED_ADDITION_LEVEL, - REASON_FULL_POSITION_REDISTRIBUTION, - REASON_HIGH_LIMIT, - REASON_LOW_LIMIT, - REASON_MISSING_OPEN, - REASON_NONE, - REASON_ORDER_REJECTED, - REASON_PAUSED, - REASON_PROTECTIVE_STOP, - REASON_TREND_EXIT, -) -from .vectorbt_delayed import ( - ADJUST_CASH_TRUNCATED, - ADJUST_HOLDING_TRUNCATED, - ADJUST_HORIZON_EXPIRED, - ADJUST_NONE, - ADJUST_UNTRADABLE, -) - - -ATTRIBUTION_SCHEMA_VERSION = "turtle-etf-attribution/2" -ATTRIBUTION_FIELDS = ( - "time", - "event_id", - "scope", - "security", - "event_type", - "reason_code", - "requested_amount", - "executed_amount", - "reference_price", - "risk_before", - "risk_after", - "details_json", -) - -_REASON_CODES = { - "signal_entry", - "signal_add", - "signal_exit", - "protective_stop", - "full_position_redistribution", - "allocation_constraint", - "untradeable", - "order_rejected", - "state_update", - "corporate_action_applied", -} -_EVENT_TYPES = {"decision", "state", "valuation", "corporate_action"} - -_ACCOUNTING_CONTRACT = { - "version": "turtle-etf-corporate-actions/1", - "corporate_action_mode": "point_in_time_total_return_approximation", - "continuity_factor_basis": "raw_previous_close_over_current_pre_close", - "corporate_action_metadata_timing": "audit_only_may_be_retrospective", - "price_basis": "continuous_economic_price", - "quantity_basis": "economic_units", - "cash_dividend_mode": "implicit_reinvestment_on_ex_date", - "pay_date_cash_supported": False, - "exact_joinquant_reconciliation": False, -} - -_ACTION_NAMES = { - ACTION_NONE: "none", - ACTION_FULL_EXIT: "full_exit", - ACTION_REDISTRIBUTION_SELL: "redistribution_sell", - ACTION_ENTRY: "entry", - ACTION_ADDITION: "addition", - ACTION_REDISTRIBUTION_BUY: "redistribution_buy", -} -_REASON_NAMES = { - REASON_NONE: "none", - REASON_ENTRY_BREAKOUT: "entry_breakout", - REASON_FIXED_ADDITION_LEVEL: "fixed_addition_level", - REASON_PROTECTIVE_STOP: "protective_stop", - REASON_TREND_EXIT: "trend_exit", - REASON_FULL_POSITION_REDISTRIBUTION: "full_position_redistribution", - REASON_MISSING_OPEN: "missing_open", - REASON_PAUSED: "paused", - REASON_HIGH_LIMIT: "high_limit", - REASON_LOW_LIMIT: "low_limit", - REASON_ALLOCATION_CONSTRAINT: "allocation_constraint", - REASON_ORDER_REJECTED: "order_rejected", -} -_SELL_ACTIONS = {"full_exit", "redistribution_sell"} -_ADJUSTMENT_NAMES = { - ADJUST_NONE: "none", - ADJUST_CASH_TRUNCATED: "cash_truncated", - ADJUST_HOLDING_TRUNCATED: "holding_truncated", - ADJUST_UNTRADABLE: "untradable", - ADJUST_HORIZON_EXPIRED: "horizon_expired", -} - -_RESULTS_SCHEMA = pa.schema( - [ - pa.field("benchmark_returns", pa.float64()), - pa.field("returns", pa.float64(), nullable=False), - pa.field("time", pa.string(), nullable=False), - ] -) -_BALANCES_SCHEMA = pa.schema( - [ - pa.field("total_value", pa.float64(), nullable=False), - pa.field("net_value", pa.float64(), nullable=False), - pa.field("cash", pa.float64(), nullable=False), - pa.field("aval_cash", pa.float64(), nullable=False), - pa.field("time", pa.string(), nullable=False), - ] -) -_POSITIONS_SCHEMA = pa.schema( - [ - pa.field("pindex", pa.int64(), nullable=False), - pa.field("avg_cost", pa.float64(), nullable=False), - pa.field("margin", pa.float64(), nullable=False), - pa.field("amount", pa.float64(), nullable=False), - pa.field("today_amount", pa.int64(), nullable=False), - pa.field("hold_cost", pa.float64(), nullable=False), - pa.field("side", pa.string(), nullable=False), - pa.field("price", pa.float64(), nullable=False), - pa.field("gains", pa.float64(), nullable=False), - pa.field("daily_gains", pa.float64(), nullable=False), - pa.field("closeable_amount", pa.int64(), nullable=False), - pa.field("time", pa.string(), nullable=False), - pa.field("security_name", pa.string(), nullable=False), - pa.field("security", pa.string(), nullable=False), - ] -) -_ORDERS_SCHEMA = pa.schema( - [ - pa.field("match_time", pa.string()), - pa.field("pindex", pa.int64(), nullable=False), - pa.field("cancel_time", pa.string()), - pa.field("action", pa.string(), nullable=False), - pa.field("limit_price", pa.float64(), nullable=False), - pa.field("comment", pa.string(), nullable=False), - pa.field("entrust_time", pa.string(), nullable=False), - pa.field("finish_time", pa.string()), - pa.field("side", pa.string(), nullable=False), - pa.field("price", pa.float64(), nullable=False), - pa.field("commission", pa.float64(), nullable=False), - pa.field("gains", pa.float64(), nullable=False), - pa.field("type", pa.string(), nullable=False), - pa.field("time", pa.string(), nullable=False), - pa.field("security_name", pa.string(), nullable=False), - pa.field("security", pa.string(), nullable=False), - pa.field("filled", pa.int64(), nullable=False), - pa.field("amount", pa.int64(), nullable=False), - pa.field("status", pa.string(), nullable=False), - ] -) -_ATTRIBUTION_SCHEMA = pa.schema( - [ - pa.field("time", pa.string(), nullable=False), - pa.field("event_id", pa.string(), nullable=False), - pa.field("scope", pa.string(), nullable=False), - pa.field("security", pa.string()), - pa.field("event_type", pa.string(), nullable=False), - pa.field("reason_code", pa.string(), nullable=False), - pa.field("requested_amount", pa.float64()), - pa.field("executed_amount", pa.float64()), - pa.field("reference_price", pa.float64()), - pa.field("risk_before", pa.float64()), - pa.field("risk_after", pa.float64()), - pa.field("details_json", pa.string(), nullable=False), - ] -) - - -class ResultContractError(ValueError): - """Raised when vectorbt facts cannot prove the local result contract.""" - - -@dataclass(frozen=True) -class LocalExecutionFacts: - results: pa.Table - balances: pa.Table - positions: pa.Table - orders: pa.Table - attribution: pa.Table - - def with_attribution(self, document: Mapping[str, object]) -> LocalExecutionFacts: - return replace( - self, - attribution=pa.Table.from_pydict(dict(document), schema=_ATTRIBUTION_SCHEMA), - ) - - -@dataclass(frozen=True) -class LocalResultPackage: - root: Path - params_sha256: str - attribution_sha256: str - - -def _table(rows: list[dict[str, object]], schema: pa.Schema) -> pa.Table: - if not rows: - return pa.Table.from_pylist([], schema=schema) - return pa.Table.from_pylist(rows, schema=schema) - - -def _vector(value: object, rows: int, field: str) -> np.ndarray: - array = np.asarray(value, dtype=np.float64).reshape(-1) - if array.shape != (rows,) or not np.all(np.isfinite(array)): - raise ResultContractError(f"portfolio {field} is invalid") - return array - - -def _matrix(value: object, shape: tuple[int, int], field: str) -> np.ndarray: - array = np.asarray(value) - if array.shape != shape: - raise ResultContractError(f"simulation {field} shape is invalid") - return array - - -def _date_text(value: object) -> str: - return str(np.datetime64(value, "D")) - - -def _nullable(value: float) -> float | None: - return float(value) if np.isfinite(value) else None - - -def _safe_simulation_number(value: object) -> float | None: - if value is None: - return None - try: - numeric = float(value) - except (TypeError, ValueError): - return None - return numeric if np.isfinite(numeric) else None - - -def _changed(left: float, right: float) -> bool: - if np.isnan(left) and np.isnan(right): - return False - return bool(left != right) - - -def _event_id( - scenario_id: str, - date_text: str, - security: str, - event_type: str, - reason_code: str, -) -> str: - identity = "|".join( - ( - ATTRIBUTION_SCHEMA_VERSION, - scenario_id, - date_text, - security, - event_type, - reason_code, - ) - ) - return hashlib.sha256(identity.encode("utf-8")).hexdigest() - - -def _reason_code(action: str, source_reason: str) -> str: - if source_reason == "order_rejected": - return "order_rejected" - if source_reason in {"missing_open", "paused", "high_limit", "low_limit"}: - return "untradeable" - if source_reason == "allocation_constraint": - return "allocation_constraint" - if source_reason == "protective_stop": - return "protective_stop" - if action in {"redistribution_sell", "redistribution_buy"} or ( - source_reason == "full_position_redistribution" - ): - return "full_position_redistribution" - if action == "full_exit" or source_reason == "trend_exit": - return "signal_exit" - if action == "entry": - return "signal_entry" - if action == "addition": - return "signal_add" - return "state_update" - - -def _planned_risk(quantity: int, average_cost: float, common_stop: float) -> float | None: - if quantity <= 0: - return 0.0 - if not np.isfinite(average_cost) or not np.isfinite(common_stop): - return None - return max(float(average_cost) - float(common_stop), 0.0) * int(quantity) - - -def _json_value(value: object) -> object: - if isinstance(value, (np.bool_, bool)): - return bool(value) - if isinstance(value, (np.integer, int)): - return int(value) - if isinstance(value, (np.floating, float)): - return float(value) if np.isfinite(value) else None - return value - - -def _details(**values: object) -> str: - return json.dumps( - {key: _json_value(value) for key, value in values.items()}, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ) - - -def to_joinquant_facts( - inputs: object, - simulation: object, - scenario_id: str, -) -> LocalExecutionFacts: - if not isinstance(scenario_id, str) or not scenario_id: - raise ResultContractError("scenario_id is required") - dates = np.asarray(getattr(inputs, "dates"), dtype="datetime64[D]") - securities = tuple(str(item) for item in getattr(inputs, "securities")) - close = np.asarray(getattr(inputs, "close"), dtype=np.float64) - rows, columns = close.shape - if dates.shape != (rows,) or len(securities) != columns: - raise ResultContractError("input identities are inconsistent") - shape = (rows, columns) - actions = _matrix(simulation.action_codes, shape, "action_codes").astype(np.int64) - reasons = _matrix(simulation.reason_codes, shape, "reason_codes").astype(np.int64) - requested = _matrix( - simulation.requested_quantities, shape, "requested_quantities" - ).astype(np.int64) - planned = _matrix(simulation.planned_quantities, shape, "planned_quantities").astype( - np.int64 - ) - filled = _matrix(simulation.filled_quantities, shape, "filled_quantities").astype( - np.int64 - ) - fill_prices = _matrix(simulation.fill_prices, shape, "fill_prices").astype( - np.float64 - ) - fees = _matrix(simulation.fees, shape, "fees").astype(np.float64) - state_quantities = _matrix( - simulation.state_quantities, shape, "state_quantities" - ).astype(np.int64) - common_stops = _matrix( - simulation.state_common_stop, shape, "state_common_stop" - ).astype(np.float64) - next_add = _matrix( - simulation.state_next_add_index, shape, "state_next_add_index" - ).astype(np.int64) - unit_counts = _matrix( - getattr(simulation, "state_unit_counts", next_add), - shape, - "state_unit_counts", - ).astype(np.int64) - candidate_base_quantities = _matrix( - getattr( - simulation, - "candidate_base_quantities", - np.where( - np.isin(actions, [ACTION_ENTRY, ACTION_ADDITION]), - requested, - 0, - ), - ), - shape, - "candidate_base_quantities", - ).astype(np.int64) - event_group_scales = _matrix( - getattr( - simulation, - "event_group_scales", - np.ones(shape, dtype=np.float64), - ), - shape, - "event_group_scales", - ).astype(np.float64) - event_portfolio_scales = _vector( - getattr( - simulation, - "event_portfolio_scales", - np.ones(rows, dtype=np.float64), - ), - rows, - "event_portfolio_scales", - ) - event_cash_scales = _vector( - getattr( - simulation, - "event_cash_scales", - np.ones(rows, dtype=np.float64), - ), - rows, - "event_cash_scales", - ) - portfolio_unit_cap = _safe_simulation_number( - getattr(simulation, "portfolio_unit_cap", None) - ) - raw_signal_n = getattr(inputs, "signal_n", None) - signal_n = ( - np.full(shape, np.nan, dtype=np.float64) - if raw_signal_n is None - else _matrix(raw_signal_n, shape, "signal_n").astype(np.float64) - ) - row_indices = np.broadcast_to( - np.arange(rows, dtype=np.int64)[:, None], shape - ) - planned_row_indices = _matrix( - getattr( - simulation, - "planned_row_indices", - np.where(actions != ACTION_NONE, row_indices, -1), - ), - shape, - "planned_row_indices", - ).astype(np.int64) - adjustments = _matrix( - getattr( - simulation, - "execution_adjustment_codes", - np.zeros(shape, dtype=np.int16), - ), - shape, - "execution_adjustment_codes", - ).astype(np.int64) - frozen_signal_n = _matrix( - getattr(simulation, "frozen_signal_n", signal_n), - shape, - "frozen_signal_n", - ).astype(np.float64) - execution_delay_days = int(getattr(simulation, "execution_delay_days", 0)) - if np.any(requested < 0) or np.any(planned < 0) or np.any(filled < 0): - raise ResultContractError("simulation quantities must be non-negative") - if not np.all(np.isfinite(fees)) or np.any(fees < 0.0): - raise ResultContractError("simulation commissions must be finite and non-negative") - if any(int(value) not in _ACTION_NAMES for value in np.unique(actions)): - raise ResultContractError("simulation action code is unknown") - if any(int(value) not in _REASON_NAMES for value in np.unique(reasons)): - raise ResultContractError("simulation reason code is unknown") - if any(int(value) not in _ADJUSTMENT_NAMES for value in np.unique(adjustments)): - raise ResultContractError("simulation execution adjustment code is unknown") - if execution_delay_days < 0: - raise ResultContractError("simulation execution delay is invalid") - active_plan_rows = planned_row_indices[actions != ACTION_NONE] - if np.any(active_plan_rows < 0) or np.any(active_plan_rows >= rows): - raise ResultContractError("simulation planned row is invalid") - - values = _vector(simulation.portfolio.value(), rows, "value") - cash = _vector(simulation.portfolio.cash(), rows, "cash") - initial_cash = float(getattr(simulation, "initial_cash", np.nan)) - if initial_cash <= 0.0 or not np.isfinite(initial_cash): - raise ResultContractError("initial cash could not be reconciled") - average_cost = np.zeros(columns, dtype=np.float64) - previous_quantity = np.zeros(columns, dtype=np.int64) - previous_close = np.full(columns, np.nan, dtype=np.float64) - previous_stop = np.full(columns, np.nan, dtype=np.float64) - previous_next_add = np.zeros(columns, dtype=np.int64) - previous_unit_count = np.zeros(columns, dtype=np.int64) - order_rows: list[dict[str, object]] = [] - position_rows: list[dict[str, object]] = [] - attribution_rows: list[dict[str, object]] = [] - valid_dates = {_date_text(value) for value in dates} - for application in getattr(inputs, "corporate_action_applications", ()): - effective_date = str(getattr(application, "effective_date", "")) - application_date = str( - getattr(application, "application_date", effective_date) - ) - security = str(getattr(application, "security", "")) - source_event_id = str(getattr(application, "source_event_id", "")) - if ( - effective_date not in valid_dates - or application_date not in valid_dates - or security not in securities - or not source_event_id - ): - raise ResultContractError( - "corporate-action attribution identity is invalid" - ) - attribution_rows.append( - { - "time": f"{application_date} 00:00:00", - "event_id": _event_id( - scenario_id, - application_date, - security, - "corporate_action", - f"corporate_action_applied:{source_event_id}", - ), - "scope": "security", - "security": security, - "event_type": "corporate_action", - "reason_code": "corporate_action_applied", - "requested_amount": None, - "executed_amount": None, - "reference_price": None, - "risk_before": None, - "risk_after": None, - "details_json": _details( - source_event_id=source_event_id, - event_type=str(getattr(application, "event_type", "")), - effective_date=effective_date, - application_date=application_date, - announcement_date=str( - getattr(application, "announcement_date", "") - ), - knowledge_cutoff_date=str( - getattr(application, "knowledge_cutoff_date", "") - ), - evidence_timing=str( - getattr( - application, - "evidence_timing", - "retrospective_reconciliation" - if str(getattr(application, "announcement_date", "")) - > effective_date - else "point_in_time", - ) - ), - split_ratio=getattr(application, "split_ratio", None), - cash_per_share=getattr(application, "cash_per_share", None), - cumulative_factor=float( - getattr(application, "cumulative_factor", np.nan) - ), - price_basis_changed=bool( - getattr(application, "price_basis_changed", True) - ), - source=str(getattr(application, "source", "")), - source_record_sha256=str( - getattr(application, "source_record_sha256", "") - ), - corporate_action_mode=( - "point_in_time_total_return_approximation" - ), - ), - } - ) - - for expired in getattr(simulation, "horizon_expired_orders", ()): - planned_row = int(getattr(expired, "planned_row_index")) - column = int(getattr(expired, "column")) - if not 0 <= planned_row < rows or not 0 <= column < columns: - raise ResultContractError("horizon-expired order identity is invalid") - date_text = _date_text(dates[planned_row]) - security = securities[column] - action = _ACTION_NAMES[int(getattr(expired, "action_code"))] - source_reason = _REASON_NAMES[int(getattr(expired, "reason_code"))] - reason_code = _reason_code(action, source_reason) - target = int(getattr(expired, "target_quantity")) - requested_quantity = int(getattr(expired, "requested_quantity")) - delay_days = int(getattr(expired, "delay_days")) - attribution_rows.append( - { - "time": f"{date_text} 09:30:00", - "event_id": _event_id( - scenario_id, - date_text, - security, - "decision", - reason_code + ":horizon_expired", - ), - "scope": "security", - "security": security, - "event_type": "decision", - "reason_code": reason_code, - "requested_amount": float(requested_quantity), - "executed_amount": 0.0, - "reference_price": _nullable(float(close[planned_row, column])), - "risk_before": None, - "risk_after": None, - "details_json": _details( - action=action, - source_reason=source_reason, - planned_date=date_text, - execution_date=None, - delay_days=delay_days, - frozen_reason=source_reason, - frozen_target_amount=target, - frozen_signal_n=float(getattr(expired, "signal_n")), - execution_adjustment="horizon_expired", - planned_amount=target, - state_changed=False, - ), - } - ) - - for row in range(rows): - date_text = _date_text(dates[row]) - execution_time = f"{date_text} 09:30:00" - balance_time = f"{date_text} 16:00:00" - today_buys = np.zeros(columns, dtype=np.int64) - valuation_rows: list[tuple[dict[str, object], dict[str, object]]] = [] - daily_security_pnl_total = 0.0 - for column, security in enumerate(securities): - action = _ACTION_NAMES[int(actions[row, column])] - source_reason = _REASON_NAMES[int(reasons[row, column])] - reason_code = _reason_code(action, source_reason) - adjustment = _ADJUSTMENT_NAMES[int(adjustments[row, column])] - planned_row = int(planned_row_indices[row, column]) - planned_date = ( - _date_text(dates[planned_row]) if planned_row >= 0 else date_text - ) - entrust_time = f"{planned_date} 09:30:00" - quantity = int(filled[row, column]) - frozen_target = max(int(planned[row, column]), quantity) - before = int(previous_quantity[column]) - before_cost = float(average_cost[column]) - fee = float(fees[row, column]) - close_price = float(close[row, column]) - if quantity > 0 and action == "none": - raise ResultContractError("filled order has no action") - is_sell = action in _SELL_ACTIONS - realized_gains = 0.0 - if quantity > 0: - price = float(fill_prices[row, column]) - if not np.isfinite(price) or price <= 0.0 or fee < 0.0: - raise ResultContractError("filled order price or commission is invalid") - if is_sell: - if quantity > before: - raise ResultContractError("sell order exceeds the held position") - realized_gains = (price - before_cost) * quantity - fee - expected_after = before - quantity - if expected_after == 0: - average_cost[column] = 0.0 - else: - expected_after = before + quantity - average_cost[column] = ( - before * before_cost + quantity * price - ) / expected_after - today_buys[column] = quantity - if int(state_quantities[row, column]) != expected_after: - raise ResultContractError("filled order and position state do not reconcile") - order_rows.append( - { - "match_time": execution_time, - "pindex": 0, - "cancel_time": None, - "action": "close" if is_sell else "open", - "limit_price": 0.0, - "comment": "" if adjustment == "none" else adjustment, - "entrust_time": entrust_time, - "finish_time": execution_time, - "side": "long", - "price": price, - "commission": fee, - "gains": realized_gains, - "type": "market", - "time": execution_time, - "security_name": security, - "security": security, - "filled": quantity, - "amount": frozen_target, - "status": "done", - } - ) - elif int(state_quantities[row, column]) != before: - raise ResultContractError("position changed without a filled order") - elif action != "none" and ( - source_reason == "order_rejected" or adjustment != "none" - ): - amount = max( - int(requested[row, column]), int(planned[row, column]) - ) - if amount <= 0: - raise ResultContractError("rejected order has no requested amount") - order_rows.append( - { - "match_time": None, - "pindex": 0, - "cancel_time": execution_time, - "action": "close" if is_sell else "open", - "limit_price": 0.0, - "comment": ( - source_reason - if adjustment == "none" - else adjustment - ), - "entrust_time": entrust_time, - "finish_time": None, - "side": "long", - "price": 0.0, - "commission": 0.0, - "gains": 0.0, - "type": "market", - "time": execution_time, - "security_name": security, - "security": security, - "filled": 0, - "amount": amount, - "status": "canceled", - } - ) - - after = int(state_quantities[row, column]) - stop_after = float(common_stops[row, column]) - next_after = int(next_add[row, column]) - units_after = int(unit_counts[row, column]) - state_changed = ( - before != after - or _changed(float(previous_stop[column]), stop_after) - or int(previous_next_add[column]) != next_after - ) - logical_state_changed = ( - _changed(float(previous_stop[column]), stop_after) - or int(previous_next_add[column]) != next_after - or int(previous_unit_count[column]) != units_after - ) - effective_risk_units = float( - np.sum( - unit_counts[row].astype(np.float64) - * event_group_scales[row] - ) - * event_portfolio_scales[row] - ) - risk_before = _planned_risk( - before, before_cost, float(previous_stop[column]) - ) - risk_after = _planned_risk( - after, float(average_cost[column]), stop_after - ) - if action != "none" or source_reason != "none" or state_changed: - event_type = ( - "decision" - if action != "none" or source_reason != "none" - else "state" - ) - attribution_rows.append( - { - "time": execution_time, - "event_id": _event_id( - scenario_id, date_text, security, event_type, reason_code - ), - "scope": "security", - "security": security, - "event_type": event_type, - "reason_code": reason_code, - "requested_amount": float(requested[row, column]), - "executed_amount": float(quantity), - "reference_price": _nullable( - float(fill_prices[row, column]) - if quantity > 0 - else close_price - ), - "risk_before": risk_before, - "risk_after": risk_after, - "details_json": _details( - action=action, - source_reason=source_reason, - planned_amount=int(planned[row, column]), - commission=fee, - position_before=before, - position_after=after, - average_cost_before=before_cost, - average_cost_after=float(average_cost[column]), - common_stop_before=float(previous_stop[column]), - common_stop_after=stop_after, - next_add_before=int(previous_next_add[column]), - next_add_after=next_after, - unit_count_before=int(previous_unit_count[column]), - unit_count_after=units_after, - candidate_base_quantity=int( - candidate_base_quantities[row, column] - ), - frozen_signal_n=float( - frozen_signal_n[row, column] - ), - actual_fill_price=( - float(fill_prices[row, column]) - if quantity > 0 - else np.nan - ), - group_scale=float( - event_group_scales[row, column] - ), - portfolio_scale=float( - event_portfolio_scales[row] - ), - cash_scale=float(event_cash_scales[row]), - effective_risk_units=effective_risk_units, - portfolio_unit_cap=portfolio_unit_cap, - redistribution_state_changed=( - logical_state_changed - if action - in { - "redistribution_sell", - "redistribution_buy", - } - else None - ), - state_changed=state_changed, - **( - { - "planned_date": planned_date, - "execution_date": date_text, - "delay_days": execution_delay_days, - "frozen_reason": source_reason, - "frozen_target_amount": frozen_target, - "execution_adjustment": adjustment, - } - if execution_delay_days > 0 - or adjustment != "none" - else {} - ), - ), - } - ) - - active = before > 0 or after > 0 or quantity > 0 or fee > 0.0 - security_daily_pnl = 0.0 - if active: - if not np.isfinite(close_price) or close_price <= 0.0: - raise ResultContractError("active position has no valid close price") - if before > 0 and ( - not np.isfinite(previous_close[column]) - or float(previous_close[column]) <= 0.0 - ): - raise ResultContractError("held position has no previous close price") - previous_price = ( - float(previous_close[column]) if before > 0 else close_price - ) - if quantity > 0 and is_sell: - security_daily_pnl = ( - after * (close_price - previous_price) - + quantity - * (float(fill_prices[row, column]) - previous_price) - - fee - ) - elif quantity > 0: - security_daily_pnl = ( - before * (close_price - previous_price) - + quantity - * (close_price - float(fill_prices[row, column])) - - fee - ) - else: - security_daily_pnl = before * (close_price - previous_price) - fee - daily_security_pnl_total += security_daily_pnl - - n_value = float(signal_n[row, column]) - stop_failure_price = ( - stop_after - 2.0 * n_value - if after > 0 and np.isfinite(stop_after) and np.isfinite(n_value) - else np.nan - ) - stop_failure_loss = ( - max(close_price - stop_failure_price, 0.0) * after - if np.isfinite(stop_failure_price) - else np.nan - ) - valuation_rows.append( - ( - { - "time": balance_time, - "event_id": _event_id( - scenario_id, - date_text, - security, - "valuation", - reason_code, - ), - "scope": "security", - "security": security, - "event_type": "valuation", - "reason_code": reason_code, - "requested_amount": None, - "executed_amount": None, - "reference_price": close_price, - "risk_before": risk_before, - "risk_after": risk_after, - }, - { - "source_reason": source_reason, - "action": action, - "position_before": before, - "position_after": after, - "previous_close": previous_price, - "close": close_price, - "fill_price": ( - float(fill_prices[row, column]) - if quantity > 0 - else np.nan - ), - "filled_amount": quantity, - "commission": fee, - "average_cost_before": before_cost, - "average_cost_after": float(average_cost[column]), - "common_stop_before": float(previous_stop[column]), - "common_stop_after": stop_after, - "n": n_value, - "stop_failure_price": stop_failure_price, - "stop_failure_loss": stop_failure_loss, - "security_daily_pnl": security_daily_pnl, - }, - ) - ) - - if after > 0: - position_rows.append( - { - "pindex": 0, - "avg_cost": float(average_cost[column]), - "margin": 0.0, - "amount": float(after), - "today_amount": int(today_buys[column]), - "hold_cost": float(average_cost[column]), - "side": "long", - "price": close_price, - "gains": (close_price - float(average_cost[column])) * after, - "daily_gains": security_daily_pnl, - "closeable_amount": max( - after - int(today_buys[column]), 0 - ), - "time": balance_time, - "security_name": security, - "security": security, - } - ) - previous_quantity[column] = after - previous_stop[column] = stop_after - previous_next_add[column] = next_after - previous_unit_count[column] = units_after - - portfolio_daily_pnl = float(values[row]) - ( - initial_cash if row == 0 else float(values[row - 1]) - ) - reconciliation_difference = daily_security_pnl_total - portfolio_daily_pnl - if abs(reconciliation_difference) > 0.02: - raise ResultContractError( - "security daily PnL does not reconcile with portfolio change" - ) - for event, valuation in valuation_rows: - event["details_json"] = _details( - **valuation, - daily_security_pnl_total=daily_security_pnl_total, - portfolio_daily_pnl=portfolio_daily_pnl, - reconciliation_difference=reconciliation_difference, - ) - attribution_rows.append(event) - previous_close = close[row].copy() - - result_rows = [] - balance_rows = [] - for row in range(rows): - time_text = f"{_date_text(dates[row])} 16:00:00" - result_rows.append( - { - "benchmark_returns": None, - "returns": float(values[row] / initial_cash - 1.0), - "time": time_text, - } - ) - balance_rows.append( - { - "total_value": float(values[row]), - "net_value": float(values[row]), - "cash": float(cash[row]), - "aval_cash": float(cash[row]), - "time": time_text, - } - ) - - facts = LocalExecutionFacts( - results=_table(result_rows, _RESULTS_SCHEMA), - balances=_table(balance_rows, _BALANCES_SCHEMA), - positions=_table(position_rows, _POSITIONS_SCHEMA), - orders=_table(order_rows, _ORDERS_SCHEMA), - attribution=_table(attribution_rows, _ATTRIBUTION_SCHEMA), - ) - validate_turtle_attribution(facts) - _validate_common_facts(facts) - return facts - - -def _validate_common_facts(facts: LocalExecutionFacts) -> None: - expected = { - "results": _RESULTS_SCHEMA, - "balances": _BALANCES_SCHEMA, - "positions": _POSITIONS_SCHEMA, - "orders": _ORDERS_SCHEMA, - } - for name, schema in expected.items(): - table = getattr(facts, name) - if table.schema != schema: - raise ResultContractError(f"{name} fields do not match the contract") - if facts.results.num_rows != facts.balances.num_rows: - raise ResultContractError("results and balances do not reconcile") - if facts.results["benchmark_returns"].null_count != facts.results.num_rows: - raise ResultContractError("source benchmark returns must remain null") - result_rows = facts.results.to_pylist() - balance_rows = facts.balances.to_pylist() - result_times = [str(item["time"]) for item in result_rows] - if result_times != [str(item["time"]) for item in balance_rows]: - raise ResultContractError("results and balances times do not reconcile") - implied_initial_cash = [] - for result, balance in zip(result_rows, balance_rows, strict=True): - denominator = 1.0 + float(result["returns"]) - if denominator <= 0.0: - raise ResultContractError("cumulative return is invalid") - implied_initial_cash.append(float(balance["total_value"]) / denominator) - if implied_initial_cash and max(implied_initial_cash) - min(implied_initial_cash) > 0.01: - raise ResultContractError("returns do not use one configured initial cash value") - - position_rows = facts.positions.to_pylist() - position_keys = [ - (item["time"], item["pindex"], item["security"], item["side"]) - for item in position_rows - ] - if len(position_keys) != len(set(position_keys)): - raise ResultContractError("position identity is not unique") - if any(str(item["time"]) not in set(result_times) for item in position_rows): - raise ResultContractError("position time is absent from results") - position_value_by_time: dict[str, float] = {} - for item in position_rows: - time_text = str(item["time"]) - position_value_by_time[time_text] = position_value_by_time.get( - time_text, 0.0 - ) + float(item["amount"]) * float(item["price"]) - for balance in balance_rows: - time_text = str(balance["time"]) - reconciled = float(balance["cash"]) + position_value_by_time.get(time_text, 0.0) - if abs(float(balance["total_value"]) - reconciled) > 0.02: - raise ResultContractError("balance does not reconcile with cash and positions") - - result_dates = {time_text[:10] for time_text in result_times} - for item in facts.orders.to_pylist(): - if str(item["time"])[:10] not in result_dates: - raise ResultContractError("order date is absent from results") - if not 0 <= int(item["filled"]) <= int(item["amount"]): - raise ResultContractError("order filled amount is invalid") - order_keys = list( - zip( - facts.orders["time"].to_pylist(), - facts.orders["pindex"].to_pylist(), - facts.orders["security"].to_pylist(), - ) - ) - if len(order_keys) != len(set(order_keys)): - raise ResultContractError("each security may have at most one order per day") - - -def validate_turtle_attribution(facts: LocalExecutionFacts) -> None: - table = facts.attribution - if table.schema != _ATTRIBUTION_SCHEMA: - raise ResultContractError("attribution fields do not match the contract") - rows = table.to_pylist() - event_ids = [item["event_id"] for item in rows] - if any(not isinstance(value, str) or not value for value in event_ids): - raise ResultContractError("attribution event_id must be non-empty") - if len(event_ids) != len(set(event_ids)): - raise ResultContractError("attribution event_id must be unique") - parsed_details: dict[str, dict[str, object]] = {} - for item in rows: - if item["scope"] != "security": - raise ResultContractError("attribution scope is unknown") - if item["event_type"] not in _EVENT_TYPES: - raise ResultContractError("attribution event type is unknown") - if item["reason_code"] not in _REASON_CODES: - raise ResultContractError("attribution reason code is unknown") - for name in ( - "requested_amount", - "executed_amount", - "reference_price", - "risk_before", - "risk_after", - ): - value = item[name] - if value is not None and not np.isfinite(float(value)): - raise ResultContractError(f"attribution {name} is invalid") - for name in ("requested_amount", "executed_amount", "risk_before", "risk_after"): - value = item[name] - if value is not None and float(value) < 0.0: - raise ResultContractError(f"attribution {name} is negative") - try: - details = json.loads(str(item["details_json"])) - except (TypeError, ValueError, json.JSONDecodeError) as exc: - raise ResultContractError("attribution details_json is invalid") from exc - if not isinstance(details, dict): - raise ResultContractError("attribution details_json must be an object") - parsed_details[str(item["event_id"])] = details - if item["event_type"] == "valuation": - required = { - "source_reason", - "position_before", - "position_after", - "previous_close", - "close", - "filled_amount", - "commission", - "average_cost_before", - "average_cost_after", - "common_stop_before", - "common_stop_after", - "n", - "stop_failure_price", - "stop_failure_loss", - "security_daily_pnl", - "daily_security_pnl_total", - "portfolio_daily_pnl", - "reconciliation_difference", - } - if not required.issubset(details): - raise ResultContractError("valuation attribution evidence is incomplete") - if item["event_type"] == "corporate_action": - required = { - "source_event_id", - "event_type", - "effective_date", - "application_date", - "announcement_date", - "knowledge_cutoff_date", - "evidence_timing", - "split_ratio", - "cash_per_share", - "cumulative_factor", - "price_basis_changed", - "source", - "source_record_sha256", - "corporate_action_mode", - } - if not required.issubset(details): - raise ResultContractError( - "corporate-action attribution evidence is incomplete" - ) - coverage = { - (str(item["time"])[:10], str(item["security"])) - for item in rows - if item["event_type"] == "decision" - and item["executed_amount"] is not None - and float(item["executed_amount"]) > 0.0 - } - filled_orders = { - (str(item["time"])[:10], str(item["security"])) - for item in facts.orders.to_pylist() - if int(item["filled"]) > 0 - } - if coverage != filled_orders: - raise ResultContractError("attribution does not cover every order") - rejected_coverage = set() - for item in rows: - if item["event_type"] != "decision": - continue - details = parsed_details[str(item["event_id"])] - adjustment = details.get("execution_adjustment", "none") - is_canceled_adjustment = ( - adjustment in {"cash_truncated", "holding_truncated", "untradable"} - and float(item["executed_amount"] or 0.0) == 0.0 - ) - if item["reason_code"] == "order_rejected" or is_canceled_adjustment: - rejected_coverage.add( - (str(item["time"])[:10], str(item["security"])) - ) - canceled_orders = { - (str(item["time"])[:10], str(item["security"])) - for item in facts.orders.to_pylist() - if item["status"] == "canceled" - } - if rejected_coverage != canceled_orders: - raise ResultContractError("attribution does not cover every canceled order") - - valuation_totals: dict[str, float] = {} - for item in rows: - if item["event_type"] != "valuation": - continue - date_text = str(item["time"])[:10] - details = parsed_details[str(item["event_id"])] - try: - pnl = float(details["security_daily_pnl"]) - declared_total = float(details["daily_security_pnl_total"]) - declared_portfolio = float(details["portfolio_daily_pnl"]) - declared_difference = float(details["reconciliation_difference"]) - except (TypeError, ValueError) as exc: - raise ResultContractError("valuation daily PnL evidence is invalid") from exc - if not all( - np.isfinite(value) - for value in (pnl, declared_total, declared_portfolio, declared_difference) - ): - raise ResultContractError("valuation daily PnL evidence is invalid") - if abs((declared_total - declared_portfolio) - declared_difference) > 1e-9: - raise ResultContractError("valuation daily PnL evidence is inconsistent") - valuation_totals[date_text] = valuation_totals.get(date_text, 0.0) + pnl - - result_rows = facts.results.to_pylist() - balance_rows = facts.balances.to_pylist() - if result_rows and len(result_rows) != len(balance_rows): - raise ResultContractError("daily PnL cannot reconcile unmatched result rows") - initial_cash = None - if result_rows: - denominator = 1.0 + float(result_rows[0]["returns"]) - if denominator <= 0.0: - raise ResultContractError("daily PnL initial equity is invalid") - initial_cash = float(balance_rows[0]["total_value"]) / denominator - previous_value = initial_cash - for balance in balance_rows: - date_text = str(balance["time"])[:10] - current_value = float(balance["total_value"]) - portfolio_pnl = current_value - float(previous_value) - if abs(valuation_totals.get(date_text, 0.0) - portfolio_pnl) > 0.02: - raise ResultContractError( - "attribution security daily PnL does not reconcile with portfolio change" - ) - previous_value = current_value - - -def validate_turtle_result(result_dir: Path) -> None: - root = Path(result_dir).resolve() - try: - source = open_analysis_source(root) - except Exception as exc: - raise ResultContractError("local result failed the common contract") from exc - if source.kind != "local_backtest": - raise ResultContractError("turtle result must be a local backtest") - try: - extensions = source.manifest["extensions"] - turtle = extensions["turtle_etf"] - entry = turtle["attribution_log"] - reference = entry["files"][0] - except (KeyError, IndexError, TypeError) as exc: - raise ResultContractError("turtle attribution declaration is missing") from exc - expected_entry_fields = { - "required", - "status", - "schema_version", - "reason_code_version", - "rows", - "verified_empty", - "time_range", - "files", - "evidence", - } - if ( - not isinstance(extensions, Mapping) - or set(extensions) != {"turtle_etf"} - or not isinstance(turtle, Mapping) - or set(turtle) != {"attribution_log"} - or not isinstance(entry, Mapping) - or set(entry) != expected_entry_fields - or entry["required"] is not True - or entry["status"] != "complete" - or entry["schema_version"] != ATTRIBUTION_SCHEMA_VERSION - or entry["reason_code_version"] != ATTRIBUTION_SCHEMA_VERSION - or not isinstance(reference, Mapping) - ): - raise ResultContractError("turtle attribution declaration is invalid") - path_text = reference.get("path") - digest = reference.get("sha256") - if ( - not isinstance(path_text, str) - or not isinstance(digest, str) - or path_text != f"data/attribution_log-{digest}.parquet" - or len(digest) != 64 - or any(character not in "0123456789abcdef" for character in digest) - ): - raise ResultContractError("turtle attribution file identity is invalid") - path = root / path_text - if ( - not path.is_file() - or path.stat().st_size != reference.get("bytes") - or _sha256_file(path) != digest - or reference.get("rows") != entry["rows"] - or reference.get("format") != "parquet" - or reference.get("compression") != "zstd" - ): - raise ResultContractError("turtle attribution file evidence is invalid") - evidence = entry.get("evidence") - if ( - not isinstance(evidence, Mapping) - or evidence.get("fields") != list(ATTRIBUTION_FIELDS) - or evidence.get("unique_key") != ["event_id"] - or evidence.get("reason_codes") != sorted(_REASON_CODES) - ): - raise ResultContractError("turtle attribution evidence is invalid") - try: - facts = LocalExecutionFacts( - results=pq.read_table(root / "data/results.parquet"), - balances=pq.read_table(root / "data/balances.parquet"), - positions=pq.read_table(root / "data/positions.parquet"), - orders=pq.read_table(root / "data/orders.parquet"), - attribution=pq.read_table(path), - ) - except Exception as exc: - raise ResultContractError("turtle result Parquet is unreadable") from exc - if facts.attribution.num_rows != entry["rows"]: - raise ResultContractError("turtle attribution row count is invalid") - if entry["verified_empty"] is not (facts.attribution.num_rows == 0): - raise ResultContractError("turtle attribution empty evidence is invalid") - if entry["time_range"] != _time_range(facts.attribution): - raise ResultContractError("turtle attribution time range is invalid") - _validate_common_facts(facts) - validate_turtle_attribution(facts) - - -def _json_bytes(document: Mapping[str, object]) -> bytes: - return ( - json.dumps( - dict(document), ensure_ascii=False, sort_keys=True, indent=2, allow_nan=False - ) - + "\n" - ).encode("utf-8") - - -def _sha256_bytes(value: bytes) -> str: - return hashlib.sha256(value).hexdigest() - - -def parameter_document_digest(document: Mapping[str, object]) -> str: - return _sha256_bytes(_json_bytes(document)) - - -def _sha256_file(path: Path) -> str: - return _sha256_bytes(path.read_bytes()) - - -def _file_ref(root: Path, path: Path) -> dict[str, object]: - return { - "path": path.relative_to(root).as_posix(), - "sha256": _sha256_file(path), - "bytes": path.stat().st_size, - } - - -def _parquet_ref(root: Path, path: Path, rows: int) -> dict[str, object]: - return { - **_file_ref(root, path), - "rows": rows, - "format": "parquet", - "compression": "zstd", - } - - -def _time_range(table: pa.Table) -> dict[str, str | None]: - if table.num_rows == 0: - return {"start": None, "end": None} - dates = [str(value)[:10] for value in table["time"].to_pylist()] - return {"start": min(dates), "end": max(dates)} - - -def _dataset_entry( - root: Path, - name: str, - table: pa.Table, - path: Path, - unique_key: list[str], -) -> dict[str, object]: - return { - "required": True, - "status": "complete", - "rows": table.num_rows, - "verified_empty": table.num_rows == 0, - "time_range": _time_range(table), - "files": [_parquet_ref(root, path, table.num_rows)], - "evidence": {"fields": table.schema.names, "unique_key": unique_key}, - } - - -def _engine() -> dict[str, str]: - return { - "backend": "vectorbt.Portfolio.from_order_func", - "adapter_version": "local-vectorbt-adapter/1", - "vectorbt": importlib.metadata.version("vectorbt"), - "numba": importlib.metadata.version("numba"), - "numpy": importlib.metadata.version("numpy"), - "pandas": importlib.metadata.version("pandas"), - } - - -def execution_facts_digest(facts: LocalExecutionFacts) -> str: - document: dict[str, object] = {} - for name in ("results", "balances", "positions", "orders", "attribution"): - table = getattr(facts, name) - document[name] = { - "fields": [ - { - "name": field.name, - "type": str(field.type), - "nullable": field.nullable, - } - for field in table.schema - ], - "rows": table.to_pylist(), - } - return hashlib.sha256( - json.dumps( - document, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ).encode("utf-8") - ).hexdigest() - - -def _read_materialized_facts(data_dir: Path) -> LocalExecutionFacts: - attribution_paths = sorted(Path(data_dir).glob("attribution_log-*.parquet")) - expected_files = { - "results.parquet", - "balances.parquet", - "positions.parquet", - "orders.parquet", - } - actual_files = {path.name for path in Path(data_dir).iterdir() if path.is_file()} - if len(attribution_paths) != 1 or actual_files != { - *expected_files, - attribution_paths[0].name, - }: - raise ResultContractError("materialized execution fact file set is invalid") - attribution_path = attribution_paths[0] - digest = _sha256_file(attribution_path) - if attribution_path.name != f"attribution_log-{digest}.parquet": - raise ResultContractError("materialized attribution filename is invalid") - try: - return LocalExecutionFacts( - results=pq.read_table(Path(data_dir) / "results.parquet"), - balances=pq.read_table(Path(data_dir) / "balances.parquet"), - positions=pq.read_table(Path(data_dir) / "positions.parquet"), - orders=pq.read_table(Path(data_dir) / "orders.parquet"), - attribution=pq.read_table(attribution_path), - ) - except Exception as exc: - raise ResultContractError("materialized execution facts are unreadable") from exc - - -def materialize_execution_facts(data_dir: Path, facts: LocalExecutionFacts) -> str: - target = Path(data_dir) - if target.exists(): - raise ResultContractError("execution fact directory already exists") - _validate_common_facts(facts) - validate_turtle_attribution(facts) - try: - target.mkdir(parents=True) - for name in ("results", "balances", "positions", "orders"): - pq.write_table( - getattr(facts, name), target / f"{name}.parquet", compression="zstd" - ) - temporary = target / ".attribution.parquet" - pq.write_table(facts.attribution, temporary, compression="zstd") - attribution_digest = _sha256_file(temporary) - os.replace( - temporary, - target / f"attribution_log-{attribution_digest}.parquet", - ) - written = _read_materialized_facts(target) - _validate_common_facts(written) - validate_turtle_attribution(written) - expected_digest = execution_facts_digest(facts) - if execution_facts_digest(written) != expected_digest: - raise ResultContractError("materialized execution fact digest changed") - return expected_digest - except Exception: - if target.exists(): - shutil.rmtree(target) - raise - - -def write_local_result( - backtest_dir: Path, - *, - facts: LocalExecutionFacts, - run_id: str, - local_backtest_id: str, - scenario_id: str, - snapshot_id: str, - corporate_actions_sha256: str, - code_path: Path, - params: Mapping[str, object], - performance: Mapping[str, object], -) -> LocalResultPackage: - target = Path(backtest_dir).resolve() - if target.exists(): - raise ResultContractError("local backtest directory already exists") - if not all(isinstance(value, str) and value for value in (run_id, local_backtest_id, scenario_id)): - raise ResultContractError("local result identity is incomplete") - if len(snapshot_id) != 64 or any(character not in "0123456789abcdef" for character in snapshot_id): - raise ResultContractError("snapshot_id must be a lowercase SHA256") - if len(corporate_actions_sha256) != 64 or any( - character not in "0123456789abcdef" - for character in corporate_actions_sha256 - ): - raise ResultContractError( - "corporate_actions_sha256 must be a lowercase SHA256" - ) - source_code = Path(code_path) - if not source_code.is_file(): - raise ResultContractError("code source is missing") - _validate_common_facts(facts) - validate_turtle_attribution(facts) - - target.parent.mkdir(parents=True, exist_ok=True) - staging = target.parent / f".{target.name}.{uuid.uuid4().hex}.tmp" - try: - data_dir = staging / "data" - params_dir = staging / "params_versions" - staging.mkdir() - params_dir.mkdir() - (staging / "code.py").write_bytes(source_code.read_bytes()) - params_bytes = _json_bytes(params) - params_sha256 = parameter_document_digest(params) - (staging / "params.json").write_bytes(params_bytes) - (params_dir / f"{params_sha256}.json").write_bytes(params_bytes) - (staging / "performance.json").write_bytes(_json_bytes(performance)) - - materialize_execution_facts(data_dir, facts) - paths = { - name: data_dir / f"{name}.parquet" - for name in ("results", "balances", "positions", "orders") - } - attribution_path = next(data_dir.glob("attribution_log-*.parquet")) - attribution_sha256 = _sha256_file(attribution_path) - - datasets = { - "results": _dataset_entry( - staging, "results", facts.results, paths["results"], ["time"] - ), - "balances": _dataset_entry( - staging, "balances", facts.balances, paths["balances"], ["time"] - ), - "positions": _dataset_entry( - staging, - "positions", - facts.positions, - paths["positions"], - ["time", "pindex", "security", "side"], - ), - "orders": _dataset_entry( - staging, - "orders", - facts.orders, - paths["orders"], - ["time", "pindex", "security"], - ), - "risk": { - "required": False, - "status": "missing_at_source", - "reason": "computed_by_strategy_analysis", - "rows": 0, - "verified_empty": True, - "files": [], - }, - "period_risks": { - "required": False, - "status": "missing_at_source", - "reason": "computed_by_strategy_analysis", - "rows": 0, - "verified_empty": True, - "files": [], - }, - } - attribution_entry = { - "required": True, - "status": "complete", - "schema_version": ATTRIBUTION_SCHEMA_VERSION, - "reason_code_version": ATTRIBUTION_SCHEMA_VERSION, - "rows": facts.attribution.num_rows, - "verified_empty": facts.attribution.num_rows == 0, - "time_range": _time_range(facts.attribution), - "files": [ - _parquet_ref( - staging, - attribution_path, - facts.attribution.num_rows, - ) - ], - "evidence": { - "fields": list(ATTRIBUTION_FIELDS), - "unique_key": ["event_id"], - "reason_codes": sorted(_REASON_CODES), - }, - } - code_ref = _file_ref(staging, staging / "code.py") - current_params_ref = _file_ref(staging, staging / "params.json") - version_params_ref = _file_ref( - staging, params_dir / f"{params_sha256}.json" - ) - manifest = { - "schema_version": "local-backtest/1", - "object": { - "kind": "local_backtest", - "local_id": local_backtest_id, - "status": "complete", - }, - "source": { - "kind": "local_vectorbt", - "engine": _engine(), - "accounting": { - **_ACCOUNTING_CONTRACT, - "corporate_actions_sha256": corporate_actions_sha256, - }, - }, - "authority": "local_research", - "run": { - "run_id": run_id, - "scenario_id": scenario_id, - "snapshot_id": snapshot_id, - }, - "code": code_ref, - "params": {"current": current_params_ref, "version": version_params_ref}, - "performance": _file_ref(staging, staging / "performance.json"), - "datasets": datasets, - "source_benchmark_returns": { - "status": "missing_at_source", - "reason": "independent_benchmark_set", - "null_rows": facts.results.num_rows, - }, - "gate": { - "status": "pass", - "exceptions": [], - "checks": [ - "local_schema", - "common_fact_fields", - "cross_table_reconciliation", - "turtle_attribution_coverage", - ], - }, - "extensions": {"turtle_etf": {"attribution_log": attribution_entry}}, - } - (staging / "manifest.json").write_bytes(_json_bytes(manifest)) - validate_turtle_result(staging) - if _sha256_file(attribution_path) != attribution_sha256: - raise ResultContractError("attribution digest changed after writing") - os.replace(staging, target) - return LocalResultPackage( - root=target, - params_sha256=params_sha256, - attribution_sha256=attribution_sha256, - ) - except Exception: - if staging.exists(): - shutil.rmtree(staging) - raise diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/single_scenario.py b/joinquant/strategies/strategy-003/research/turtle_etf/single_scenario.py deleted file mode 100644 index 8295011..0000000 --- a/joinquant/strategies/strategy-003/research/turtle_etf/single_scenario.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -import json -import re -from dataclasses import dataclass -from pathlib import Path -from typing import Mapping, Sequence - -from .result_adapter import LocalResultPackage, write_local_result -from .vectorbt_benchmark import benchmark_scenario - - -_FORBIDDEN_FLOW_FIELDS = {"candidates", "scenarios", "analysis_plan"} -_SCENARIO_ID = re.compile(r"[a-z0-9][a-z0-9-]{0,63}") - - -class SingleScenarioError(ValueError): - """Raised when a project request attempts more than one local scenario.""" - - -@dataclass(frozen=True) -class SingleScenarioOutcome: - scenario_id: str - local_backtest_id: str - result_path: Path - next_action: str = "return_to_caller" - - -def validate_single_scenario_config(config: Mapping[str, object]) -> str: - if not isinstance(config, Mapping): - raise SingleScenarioError("single scenario config must be an object") - if set(config) & _FORBIDDEN_FLOW_FIELDS: - raise SingleScenarioError("single scenario config cannot contain batch or analysis inputs") - scenario_id = config.get("scenario_id") - if not isinstance(scenario_id, str) or _SCENARIO_ID.fullmatch(scenario_id) is None: - raise SingleScenarioError("single scenario config requires scenario_id") - if config.get("project_id") != "strategy-003" or config.get("schema_version") != 1: - raise SingleScenarioError("single scenario project identity is invalid") - return scenario_id - - -def execute_prepared_scenario( - *, - prepared_inputs: object, - config: Mapping[str, object], - output_dir: Path, - run_id: str, - snapshot_id: str, - code_sha256: str, - config_sha256: str, - code_path: Path, -) -> SingleScenarioOutcome: - scenario_id = validate_single_scenario_config(config) - if len(config_sha256) != 64: - raise SingleScenarioError("config_sha256 is invalid") - local_backtest_id = f"local-{scenario_id}" - output_root = Path(output_dir) - benchmark = benchmark_scenario( - prepared_inputs=prepared_inputs, - config=config, - scenario_id=scenario_id, - work_dir=output_root / ".benchmark-work", - code_sha256=code_sha256, - config_sha256=config_sha256, - ) - target = Path(output_dir) / "backtests" / local_backtest_id - package: LocalResultPackage = write_local_result( - target, - facts=benchmark.facts, - run_id=run_id, - local_backtest_id=local_backtest_id, - scenario_id=scenario_id, - snapshot_id=snapshot_id, - corporate_actions_sha256=str( - getattr(prepared_inputs, "corporate_actions_digest", "") - ), - code_path=code_path, - params=config, - performance=benchmark.performance, - ) - return SingleScenarioOutcome( - scenario_id=scenario_id, - local_backtest_id=local_backtest_id, - result_path=package.root, - ) - - -def write_project_status( - output_dir: Path, - *, - status: str, - reason_codes: Sequence[str], - next_action: str | None = None, -) -> Path: - if status not in {"complete", "evidence_insufficient", "failed"}: - raise SingleScenarioError("project status is invalid") - if status == "complete" and reason_codes: - raise SingleScenarioError("complete project status cannot contain reasons") - if next_action is not None and ( - status != "complete" or next_action != "return_to_caller" - ): - raise SingleScenarioError("single scenario next action is invalid") - document: dict[str, object] = { - "schema_version": 1, - "status": status, - "reason_codes": list(reason_codes), - } - if next_action is not None: - document["next_action"] = next_action - root = Path(output_dir) - root.mkdir(parents=True, exist_ok=True) - path = root / "project-status.json" - path.write_text( - json.dumps(document, ensure_ascii=False, sort_keys=True, indent=2) + "\n", - encoding="utf-8", - ) - return path diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/strategy.py b/joinquant/strategies/strategy-003/research/turtle_etf/strategy.py new file mode 100644 index 0000000..e79a3eb --- /dev/null +++ b/joinquant/strategies/strategy-003/research/turtle_etf/strategy.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Mapping + +from scripts.research.local_quant_research.contracts import ( + ExecutionBundle, + ExecutionRun, + OrderProgram, + PreparedStrategy, + ResultExtension, + StrategyDescriptor, +) + +from ._attribution import build_turtle_attribution +from ._delayed import build_delayed_program +from ._kernel import prepare_turtle_strategy + +if TYPE_CHECKING: + from scripts.research.market_data.query import SnapshotView + + +@dataclass(frozen=True, slots=True) +class TurtleStrategyModule: + descriptor: StrategyDescriptor + + def prepare( + self, + snapshot: SnapshotView, + config: Mapping[str, object], + ) -> PreparedStrategy: + return prepare_turtle_strategy(snapshot, config) + + def followup_program( + self, + prepared: PreparedStrategy, + primary_run: ExecutionRun, + ) -> OrderProgram | None: + return build_delayed_program(prepared, primary_run) + + def build_extensions( + self, + prepared: PreparedStrategy, + execution: ExecutionBundle, + ) -> tuple[ResultExtension, ...]: + return (build_turtle_attribution(prepared, execution),) + + +MODULE = TurtleStrategyModule( + descriptor=StrategyDescriptor( + strategy_id="strategy-003", + contract_version="strategy-module/1", + extension_names=("turtle_etf",), + accounting={"lot_size": 100, "cash_sharing": True}, + ) +) diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_benchmark.py b/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_benchmark.py deleted file mode 100644 index 02da53a..0000000 --- a/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_benchmark.py +++ /dev/null @@ -1,178 +0,0 @@ -from __future__ import annotations - -import hashlib -import importlib.metadata -import json -import platform -import shutil -import sys -import time -from dataclasses import asdict, dataclass, is_dataclass -from pathlib import Path -from typing import Mapping - -import numpy as np - -from .result_adapter import ( - LocalExecutionFacts, - materialize_execution_facts, - parameter_document_digest, - to_joinquant_facts, -) -from .vectorbt_engine import run_vectorbt_simulation - - -class PerformanceGateError(RuntimeError): - """Raised when a single local scenario misses its execution gate.""" - - -@dataclass(frozen=True) -class BenchmarkResult: - facts: LocalExecutionFacts - performance: Mapping[str, object] - - -def _canonical_bytes(value: object) -> bytes: - def default(item: object) -> object: - if is_dataclass(item) and not isinstance(item, type): - return asdict(item) - if isinstance(item, np.generic): - return item.item() - raise TypeError(f"unsupported prepared input identity: {type(item).__name__}") - - return json.dumps( - value, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - default=default, - ).encode("utf-8") - - -def _prepared_inputs_digest(prepared_inputs: object) -> str: - digest = hashlib.sha256() - try: - fields = vars(prepared_inputs) - except TypeError as exc: - raise PerformanceGateError("prepared inputs do not expose an identity") from exc - for name in sorted(fields): - value = fields[name] - digest.update(name.encode("utf-8")) - digest.update(b"\0") - if isinstance(value, np.ndarray): - contiguous = np.ascontiguousarray(value) - digest.update(str(contiguous.dtype).encode("ascii")) - digest.update(_canonical_bytes(list(contiguous.shape))) - digest.update(contiguous.tobytes()) - else: - digest.update(_canonical_bytes(value)) - return digest.hexdigest() - - -def _environment() -> dict[str, object]: - return { - "python": platform.python_version(), - "implementation": platform.python_implementation(), - "platform": sys.platform, - "dependencies": { - name: importlib.metadata.version(name) - for name in ("vectorbt", "numba", "numpy", "pandas", "pyarrow") - }, - } - - -def _run_once( - *, - prepared_inputs: object, - config: Mapping[str, object], - scenario_id: str, - data_dir: Path, -) -> tuple[LocalExecutionFacts, str, float]: - started = time.perf_counter() - simulation = run_vectorbt_simulation(prepared_inputs, config) - facts = to_joinquant_facts(prepared_inputs, simulation, scenario_id) - result_digest = materialize_execution_facts(data_dir, facts) - elapsed = time.perf_counter() - started - return facts, result_digest, elapsed - - -def benchmark_scenario( - *, - prepared_inputs: object, - config: Mapping[str, object], - scenario_id: str, - work_dir: Path, - code_sha256: str, - config_sha256: str, - limit_seconds: float = 180.0, -) -> BenchmarkResult: - if not isinstance(scenario_id, str) or not scenario_id: - raise PerformanceGateError("scenario identity is missing") - for value in (code_sha256, config_sha256): - if len(value) != 64 or any(character not in "0123456789abcdef" for character in value): - raise PerformanceGateError("execution identity is invalid") - if not np.isfinite(limit_seconds) or limit_seconds <= 0.0: - raise PerformanceGateError("performance limit is invalid") - root = Path(work_dir) - if root.exists(): - raise PerformanceGateError("benchmark work directory already exists") - root.mkdir(parents=True) - cold_dir = root / "cold-data" - warm_dir = root / "warm-data" - try: - cold_facts, cold_digest, cold_seconds = _run_once( - prepared_inputs=prepared_inputs, - config=config, - scenario_id=scenario_id, - data_dir=cold_dir, - ) - _, warm_digest, warm_seconds = _run_once( - prepared_inputs=prepared_inputs, - config=config, - scenario_id=scenario_id, - data_dir=warm_dir, - ) - if cold_digest != warm_digest: - raise PerformanceGateError("cold and warm results are not deterministic") - if cold_seconds > limit_seconds or warm_seconds > limit_seconds: - raise PerformanceGateError( - f"single scenario execution exceeded {limit_seconds:g} seconds" - ) - except Exception: - shutil.rmtree(root, ignore_errors=True) - raise - - shutil.rmtree(cold_dir) - shutil.rmtree(warm_dir) - root.rmdir() - cleanup = { - "cold_temporary_result_removed": not cold_dir.exists(), - "warm_temporary_result_removed": not warm_dir.exists(), - "work_directory_removed": not root.exists(), - "verified": not root.exists() and not cold_dir.exists() and not warm_dir.exists(), - } - if not cleanup["verified"]: - raise PerformanceGateError("benchmark temporary artifacts could not be cleaned") - params_sha256 = parameter_document_digest(config) - scenario_sha256 = hashlib.sha256( - _canonical_bytes({"scenario_id": scenario_id, "params_sha256": params_sha256}) - ).hexdigest() - performance = { - "schema_version": "local-backtest-performance/1", - "status": "pass", - "limit_seconds": float(limit_seconds), - "environment": _environment(), - "prepared_inputs_sha256": _prepared_inputs_digest(prepared_inputs), - "code_sha256": code_sha256, - "config_sha256": config_sha256, - "params_sha256": params_sha256, - "scenario": {"scenario_id": scenario_id, "sha256": scenario_sha256}, - "cold_seconds": float(cold_seconds), - "warm_seconds": float(warm_seconds), - "cold_result_sha256": cold_digest, - "warm_result_sha256": warm_digest, - "result_match": True, - "cleanup": cleanup, - } - return BenchmarkResult(facts=cold_facts, performance=performance) diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_callbacks.py b/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_callbacks.py deleted file mode 100644 index a3e0df1..0000000 --- a/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_callbacks.py +++ /dev/null @@ -1,728 +0,0 @@ -from __future__ import annotations - -from collections import namedtuple - -import numpy as np -from numba import njit -from vectorbt.portfolio import nb -from vectorbt.portfolio.enums import Direction, OrderSide, OrderStatus - - -ACTION_NONE = 0 -ACTION_FULL_EXIT = 1 -ACTION_REDISTRIBUTION_SELL = 2 -ACTION_ENTRY = 3 -ACTION_ADDITION = 4 -ACTION_REDISTRIBUTION_BUY = 5 - -REASON_NONE = 0 -REASON_ENTRY_BREAKOUT = 1 -REASON_FIXED_ADDITION_LEVEL = 2 -REASON_PROTECTIVE_STOP = 3 -REASON_TREND_EXIT = 4 -REASON_MISSING_OPEN = 5 -REASON_PAUSED = 6 -REASON_HIGH_LIMIT = 7 -REASON_LOW_LIMIT = 8 -REASON_ALLOCATION_CONSTRAINT = 9 -REASON_ORDER_REJECTED = 10 -REASON_FULL_POSITION_REDISTRIBUTION = 11 - - -CallbackInputs = namedtuple( - "CallbackInputs", - ( - "execution_open", - "signal_close", - "signal_entry_high", - "signal_exit_low", - "signal_n", - "paused", - "high_limit", - "low_limit", - "asset_group_ids", - ), -) - -CallbackParams = namedtuple( - "CallbackParams", - ( - "lot_size", - "unit_risk_per_n", - "add_step_n", - "stop_n", - "max_units", - "asset_group_unit_cap", - "portfolio_unit_cap", - "commission_multiplier", - "one_way_slippage", - ), -) - -CallbackState = namedtuple( - "CallbackState", - ( - "unit_count", - "unit_signal_n", - "unit_base_quantities", - "unit_fill_prices", - "initial_fill_price", - "initial_signal_n", - "common_stop", - "next_add_index", - "candidate_signal_n", - "candidate_base_quantity", - "action_codes", - "reason_codes", - "requested_quantities", - "planned_quantities", - "filled_quantities", - "fill_prices", - "fees", - "state_quantities", - "state_common_stop", - "state_next_add_index", - "state_unit_counts", - "event_group_scales", - "event_portfolio_scales", - "event_cash_scales", - "day_equity", - "allocation_ready", - ), -) - - -@njit -def _finite_positive(value: float) -> bool: - return np.isfinite(value) and value > 0.0 - - -@njit -def _commission(price: float, quantity: int, multiplier: float) -> float: - return max(5.0, price * quantity * 0.000085) * multiplier - - -@njit -def _buy_price(open_price: float, slippage: float) -> float: - return open_price * (1.0 + slippage) - - -@njit -def _sell_price(open_price: float, slippage: float) -> float: - return open_price * (1.0 - slippage) - - -@njit -def _buy_tradability_reason_nb( - row: int, column: int, inputs: CallbackInputs -) -> int: - open_price = inputs.execution_open[row, column] - if not _finite_positive(open_price): - return REASON_MISSING_OPEN - if inputs.paused[row, column]: - return REASON_PAUSED - high_limit = inputs.high_limit[row, column] - if np.isfinite(high_limit) and open_price >= high_limit: - return REASON_HIGH_LIMIT - return REASON_NONE - - -@njit -def _sell_tradability_reason_nb( - row: int, column: int, inputs: CallbackInputs -) -> int: - open_price = inputs.execution_open[row, column] - if not _finite_positive(open_price): - return REASON_MISSING_OPEN - if inputs.paused[row, column]: - return REASON_PAUSED - low_limit = inputs.low_limit[row, column] - if np.isfinite(low_limit) and open_price <= low_limit: - return REASON_LOW_LIMIT - return REASON_NONE - - -@njit -def _risk_scales_nb( - unit_counts: np.ndarray, - asset_group_ids: np.ndarray, - group_count: int, - asset_group_unit_cap: float, - portfolio_unit_cap: float, -): - group_units = np.zeros(group_count, dtype=np.float64) - for column in range(unit_counts.shape[0]): - group_units[asset_group_ids[column]] += unit_counts[column] - group_scales = np.ones(group_count, dtype=np.float64) - for group in range(group_count): - if group_units[group] > asset_group_unit_cap: - group_scales[group] = asset_group_unit_cap / group_units[group] - effective_units = 0.0 - for column in range(unit_counts.shape[0]): - effective_units += ( - unit_counts[column] * group_scales[asset_group_ids[column]] - ) - portfolio_scale = 1.0 - if effective_units > portfolio_unit_cap: - portfolio_scale = portfolio_unit_cap / effective_units - return group_scales, portfolio_scale - - -@njit -def _targets_for_scale_nb( - unit_base_quantities: np.ndarray, - unit_counts: np.ndarray, - asset_group_ids: np.ndarray, - group_scales: np.ndarray, - portfolio_scale: float, - cash_scale: float, - locked_quantities: np.ndarray, - lot_size: int, -) -> np.ndarray: - targets = np.zeros(unit_counts.shape[0], dtype=np.int64) - for column in range(unit_counts.shape[0]): - if locked_quantities[column] >= 0: - targets[column] = locked_quantities[column] - continue - raw_quantity = 0 - for unit in range(unit_counts[column]): - raw_quantity += unit_base_quantities[column, unit] - scaled = ( - raw_quantity - * group_scales[asset_group_ids[column]] - * portfolio_scale - * cash_scale - ) - targets[column] = int(scaled // lot_size) * lot_size - return targets - - -@njit -def _cash_after_targets_nb( - row: int, - targets: np.ndarray, - positions: np.ndarray, - cash: float, - inputs: CallbackInputs, - params: CallbackParams, -) -> float: - projected_cash = cash - for column in range(targets.shape[0]): - current = int(round(positions[column])) - if targets[column] >= current: - continue - quantity = current - targets[column] - price = _sell_price( - inputs.execution_open[row, column], params.one_way_slippage - ) - projected_cash += price * quantity - _commission( - price, quantity, params.commission_multiplier - ) - for column in range(targets.shape[0]): - current = int(round(positions[column])) - if targets[column] <= current: - continue - quantity = targets[column] - current - price = _buy_price( - inputs.execution_open[row, column], params.one_way_slippage - ) - projected_cash -= price * quantity + _commission( - price, quantity, params.commission_multiplier - ) - return projected_cash - - -@njit -def _cash_feasible_targets_nb( - row: int, - unit_base_quantities: np.ndarray, - unit_counts: np.ndarray, - positions: np.ndarray, - cash: float, - group_scales: np.ndarray, - portfolio_scale: float, - locked_quantities: np.ndarray, - inputs: CallbackInputs, - params: CallbackParams, -): - full_targets = _targets_for_scale_nb( - unit_base_quantities, - unit_counts, - inputs.asset_group_ids, - group_scales, - portfolio_scale, - 1.0, - locked_quantities, - params.lot_size, - ) - if _cash_after_targets_nb( - row, full_targets, positions, cash, inputs, params - ) >= -1e-9: - return full_targets, 1.0 - lower = 0.0 - upper = 1.0 - best = _targets_for_scale_nb( - unit_base_quantities, - unit_counts, - inputs.asset_group_ids, - group_scales, - portfolio_scale, - lower, - locked_quantities, - params.lot_size, - ) - for _ in range(64): - candidate_scale = (lower + upper) / 2.0 - candidate = _targets_for_scale_nb( - unit_base_quantities, - unit_counts, - inputs.asset_group_ids, - group_scales, - portfolio_scale, - candidate_scale, - locked_quantities, - params.lot_size, - ) - if _cash_after_targets_nb( - row, candidate, positions, cash, inputs, params - ) >= -1e-9: - lower = candidate_scale - best = candidate - else: - upper = candidate_scale - return best, lower - - -@njit -def _clear_position_state_nb(column: int, state: CallbackState) -> None: - state.unit_count[column] = 0 - for unit in range(state.unit_signal_n.shape[1]): - state.unit_signal_n[column, unit] = np.nan - state.unit_base_quantities[column, unit] = 0 - state.unit_fill_prices[column, unit] = np.nan - state.initial_fill_price[column] = np.nan - state.initial_signal_n[column] = np.nan - state.common_stop[column] = np.nan - state.next_add_index[column] = 0 - - -@njit -def pre_sim_func_nb( - c, - state: CallbackState, - inputs: CallbackInputs, - params: CallbackParams, -): - state.unit_count[:] = 0 - state.unit_signal_n[:, :] = np.nan - state.unit_base_quantities[:, :] = 0 - state.unit_fill_prices[:, :] = np.nan - state.initial_fill_price[:] = np.nan - state.initial_signal_n[:] = np.nan - state.common_stop[:] = np.nan - state.next_add_index[:] = 0 - state.candidate_signal_n[:, :] = np.nan - state.candidate_base_quantity[:, :] = 0 - state.action_codes[:, :] = ACTION_NONE - state.reason_codes[:, :] = REASON_NONE - state.requested_quantities[:, :] = 0 - state.planned_quantities[:, :] = 0 - state.filled_quantities[:, :] = 0 - state.fill_prices[:, :] = np.nan - state.fees[:, :] = 0.0 - state.state_quantities[:, :] = 0 - state.state_common_stop[:, :] = np.nan - state.state_next_add_index[:, :] = 0 - state.state_unit_counts[:, :] = 0 - state.event_group_scales[:, :] = 1.0 - state.event_portfolio_scales[:] = 1.0 - state.event_cash_scales[:] = 1.0 - state.day_equity[:] = np.nan - state.allocation_ready[:] = False - return state, inputs, params - - -@njit -def _set_call_sequence_nb(c, state: CallbackState) -> None: - row = c.i - call_index = 0 - for category in range(5): - for column in range(c.from_col, c.to_col): - action = state.action_codes[row, column] - actual_category = 4 - if action == ACTION_FULL_EXIT: - actual_category = 0 - elif action == ACTION_REDISTRIBUTION_SELL: - actual_category = 1 - elif action == ACTION_ENTRY or action == ACTION_ADDITION: - actual_category = 2 - elif action == ACTION_REDISTRIBUTION_BUY: - actual_category = 3 - if actual_category == category: - c.call_seq_now[call_index] = column - c.from_col - call_index += 1 - - -@njit -def pre_segment_func_nb( - c, - state: CallbackState, - inputs: CallbackInputs, - params: CallbackParams, -): - row = c.i - column_count = c.to_col - c.from_col - equity = c.last_value[c.group] - state.day_equity[row] = equity - state.allocation_ready[row] = False - for column in range(c.from_col, c.to_col): - state.action_codes[row, column] = ACTION_NONE - state.reason_codes[row, column] = REASON_NONE - state.requested_quantities[row, column] = 0 - state.planned_quantities[row, column] = 0 - state.candidate_signal_n[row, column] = np.nan - state.candidate_base_quantity[row, column] = 0 - - exit_active = np.zeros(column_count, dtype=np.bool_) - candidate_active = np.zeros(column_count, dtype=np.bool_) - any_decision = False - - for offset in range(column_count): - column = c.from_col + offset - position = c.last_position[column] - close = inputs.signal_close[row, column] - if position <= 0.0 or not _finite_positive(close): - continue - reason = REASON_NONE - if np.isfinite(state.common_stop[column]) and close <= state.common_stop[column]: - reason = REASON_PROTECTIVE_STOP - elif ( - np.isfinite(inputs.signal_exit_low[row, column]) - and close < inputs.signal_exit_low[row, column] - ): - reason = REASON_TREND_EXIT - if reason != REASON_NONE: - state.action_codes[row, column] = ACTION_FULL_EXIT - state.reason_codes[row, column] = reason - state.requested_quantities[row, column] = int(round(position)) - tradability = _sell_tradability_reason_nb(row, column, inputs) - if tradability == REASON_NONE: - exit_active[offset] = True - else: - state.reason_codes[row, column] = tradability - any_decision = True - - for offset in range(column_count): - column = c.from_col + offset - if state.action_codes[row, column] == ACTION_FULL_EXIT: - continue - close = inputs.signal_close[row, column] - signal_n = inputs.signal_n[row, column] - if not _finite_positive(close) or not _finite_positive(signal_n): - continue - position = c.last_position[column] - action = ACTION_NONE - reason = REASON_NONE - if position > 0.0 and state.unit_count[column] > 0: - next_index = state.next_add_index[column] - if ( - next_index < params.max_units - and close - >= state.initial_fill_price[column] - + next_index * params.add_step_n * state.initial_signal_n[column] - ): - action = ACTION_ADDITION - reason = REASON_FIXED_ADDITION_LEVEL - elif ( - position <= 0.0 - and np.isfinite(inputs.signal_entry_high[row, column]) - and close > inputs.signal_entry_high[row, column] - ): - action = ACTION_ENTRY - reason = REASON_ENTRY_BREAKOUT - if action == ACTION_NONE: - continue - quantity = int(equity * params.unit_risk_per_n / signal_n) - quantity = (quantity // params.lot_size) * params.lot_size - if quantity <= 0: - continue - state.action_codes[row, column] = action - state.reason_codes[row, column] = reason - state.requested_quantities[row, column] = quantity - state.candidate_signal_n[row, column] = signal_n - state.candidate_base_quantity[row, column] = quantity - tradability = _buy_tradability_reason_nb(row, column, inputs) - if tradability == REASON_NONE: - candidate_active[offset] = True - else: - state.reason_codes[row, column] = tradability - any_decision = True - - if not any_decision: - _set_call_sequence_nb(c, state) - for column in range(c.from_col, c.to_col): - open_price = inputs.execution_open[row, column] - if _finite_positive(open_price): - c.last_val_price[column] = open_price - state.allocation_ready[row] = True - return state, inputs, params - - locked = np.full(column_count, -1, dtype=np.int64) - for offset in range(column_count): - column = c.from_col + offset - if ( - not _finite_positive(inputs.execution_open[row, column]) - or inputs.paused[row, column] - or ( - state.action_codes[row, column] == ACTION_FULL_EXIT - and not exit_active[offset] - ) - ): - locked[offset] = int(round(c.last_position[column])) - - targets = np.asarray(c.last_position[c.from_col : c.to_col], dtype=np.int64) - group_count = 1 - for offset in range(column_count): - group_count = max( - group_count, int(inputs.asset_group_ids[c.from_col + offset]) + 1 - ) - group_scales = np.ones(group_count, dtype=np.float64) - portfolio_scale = 1.0 - cash_scale = 1.0 - for _ in range(column_count * 3 + 1): - counts = state.unit_count[c.from_col : c.to_col].copy() - bases = state.unit_base_quantities[c.from_col : c.to_col].copy() - for offset in range(column_count): - column = c.from_col + offset - if exit_active[offset]: - counts[offset] = 0 - for unit in range(params.max_units): - bases[offset, unit] = 0 - elif candidate_active[offset]: - slot = counts[offset] - bases[offset, slot] = state.candidate_base_quantity[row, column] - counts[offset] = slot + 1 - group_scales, portfolio_scale = _risk_scales_nb( - counts, - inputs.asset_group_ids[c.from_col : c.to_col], - group_count, - params.asset_group_unit_cap, - params.portfolio_unit_cap, - ) - local_inputs = CallbackInputs( - inputs.execution_open[:, c.from_col : c.to_col], - inputs.signal_close[:, c.from_col : c.to_col], - inputs.signal_entry_high[:, c.from_col : c.to_col], - inputs.signal_exit_low[:, c.from_col : c.to_col], - inputs.signal_n[:, c.from_col : c.to_col], - inputs.paused[:, c.from_col : c.to_col], - inputs.high_limit[:, c.from_col : c.to_col], - inputs.low_limit[:, c.from_col : c.to_col], - inputs.asset_group_ids[c.from_col : c.to_col], - ) - targets, cash_scale = _cash_feasible_targets_nb( - row, - bases, - counts, - c.last_position[c.from_col : c.to_col], - c.last_cash[c.group], - group_scales, - portfolio_scale, - locked, - local_inputs, - params, - ) - changed = False - for offset in range(column_count): - column = c.from_col + offset - current = int(round(c.last_position[column])) - if locked[offset] < 0 and targets[offset] < current: - if _sell_tradability_reason_nb(row, column, inputs) != REASON_NONE: - locked[offset] = current - changed = True - elif locked[offset] < 0 and targets[offset] > current: - if _buy_tradability_reason_nb(row, column, inputs) != REASON_NONE: - locked[offset] = current - changed = True - if ( - candidate_active[offset] - and targets[offset] - current < params.lot_size - ): - candidate_active[offset] = False - state.reason_codes[row, column] = REASON_ALLOCATION_CONSTRAINT - changed = True - if not changed: - break - - has_effective_event = False - for offset in range(column_count): - if exit_active[offset] or candidate_active[offset]: - has_effective_event = True - if has_effective_event: - for offset in range(column_count): - column = c.from_col + offset - group = inputs.asset_group_ids[column] - state.event_group_scales[row, column] = group_scales[group] - state.event_portfolio_scales[row] = portfolio_scale - state.event_cash_scales[row] = cash_scale - for offset in range(column_count): - column = c.from_col + offset - current = int(round(c.last_position[column])) - action = state.action_codes[row, column] - if action == ACTION_FULL_EXIT: - if exit_active[offset]: - state.planned_quantities[row, column] = current - continue - delta = targets[offset] - current - if action == ACTION_ENTRY or action == ACTION_ADDITION: - if candidate_active[offset] and delta >= params.lot_size: - state.planned_quantities[row, column] = delta - continue - if delta <= -params.lot_size: - state.action_codes[row, column] = ACTION_REDISTRIBUTION_SELL - state.reason_codes[row, column] = ( - REASON_FULL_POSITION_REDISTRIBUTION - ) - state.requested_quantities[row, column] = -delta - state.planned_quantities[row, column] = -delta - elif delta >= params.lot_size: - state.action_codes[row, column] = ACTION_REDISTRIBUTION_BUY - state.reason_codes[row, column] = ( - REASON_FULL_POSITION_REDISTRIBUTION - ) - state.requested_quantities[row, column] = delta - state.planned_quantities[row, column] = delta - - _set_call_sequence_nb(c, state) - for column in range(c.from_col, c.to_col): - open_price = inputs.execution_open[row, column] - if _finite_positive(open_price): - c.last_val_price[column] = open_price - state.allocation_ready[row] = True - return state, inputs, params - - -@njit -def order_func_nb( - c, - state: CallbackState, - inputs: CallbackInputs, - params: CallbackParams, -): - row = c.i - column = c.col - action = state.action_codes[row, column] - reason = state.reason_codes[row, column] - if action == ACTION_NONE or reason in ( - REASON_MISSING_OPEN, - REASON_PAUSED, - REASON_HIGH_LIMIT, - REASON_LOW_LIMIT, - REASON_ALLOCATION_CONSTRAINT, - ): - return nb.NoOrder - quantity = state.planned_quantities[row, column] - if quantity <= 0: - return nb.NoOrder - open_price = inputs.execution_open[row, column] - if action == ACTION_FULL_EXIT or action == ACTION_REDISTRIBUTION_SELL: - quantity = min(quantity, int(round(c.position_now))) - if quantity <= 0: - return nb.NoOrder - price = _sell_price(open_price, params.one_way_slippage) - return nb.order_nb( - size=-float(quantity), - price=price, - direction=Direction.LongOnly, - fixed_fees=_commission(price, quantity, params.commission_multiplier), - allow_partial=False, - ) - price = _buy_price(open_price, params.one_way_slippage) - return nb.order_nb( - size=float(quantity), - price=price, - direction=Direction.LongOnly, - fixed_fees=_commission(price, quantity, params.commission_multiplier), - size_granularity=float(params.lot_size), - allow_partial=False, - ) - - -@njit -def _record_candidate_unit_nb( - row: int, - column: int, - fill_price: float, - state: CallbackState, - params: CallbackParams, -) -> None: - slot = state.unit_count[column] - if slot >= params.max_units: - return - signal_n = state.candidate_signal_n[row, column] - state.unit_signal_n[column, slot] = signal_n - state.unit_base_quantities[column, slot] = state.candidate_base_quantity[ - row, column - ] - state.unit_fill_prices[column, slot] = fill_price - if slot == 0: - state.initial_fill_price[column] = fill_price - state.initial_signal_n[column] = signal_n - state.common_stop[column] = fill_price - params.stop_n * signal_n - else: - candidate_stop = fill_price - params.stop_n * signal_n - state.common_stop[column] = max( - state.common_stop[column], candidate_stop - ) - state.unit_count[column] = slot + 1 - state.next_add_index[column] = slot + 1 - - -@njit -def post_order_func_nb( - c, - state: CallbackState, - inputs: CallbackInputs, - params: CallbackParams, -) -> None: - row = c.i - column = c.col - action = state.action_codes[row, column] - result = c.order_result - if result.status == OrderStatus.Filled: - quantity = int(round(result.size)) - state.filled_quantities[row, column] = quantity - state.fill_prices[row, column] = result.price - state.fees[row, column] = result.fees - if result.side == OrderSide.Sell: - if action == ACTION_FULL_EXIT and c.position_now <= 1e-9: - _clear_position_state_nb(column, state) - elif action == ACTION_ENTRY: - _clear_position_state_nb(column, state) - _record_candidate_unit_nb( - row, column, result.price, state, params - ) - elif action == ACTION_ADDITION: - _record_candidate_unit_nb( - row, column, result.price, state, params - ) - elif result.status == OrderStatus.Rejected: - state.reason_codes[row, column] = REASON_ORDER_REJECTED - - - -@njit -def post_segment_func_nb( - c, - state: CallbackState, - inputs: CallbackInputs, - params: CallbackParams, -) -> None: - row = c.i - for column in range(c.from_col, c.to_col): - state.state_quantities[row, column] = int( - round(c.last_position[column]) - ) - state.state_common_stop[row, column] = state.common_stop[column] - state.state_next_add_index[row, column] = state.next_add_index[column] - state.state_unit_counts[row, column] = state.unit_count[column] diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_cli.py b/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_cli.py deleted file mode 100644 index 32d1da6..0000000 --- a/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_cli.py +++ /dev/null @@ -1,142 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import Mapping - -import pandas as pd - -if __package__ in {None, ""}: - REPOSITORY_ROOT = Path(__file__).resolve().parents[5] - sys.path.insert(0, str(REPOSITORY_ROOT)) -else: - REPOSITORY_ROOT = Path(__file__).resolve().parents[5] - -from scripts.research.market_data.query import open_snapshot -from scripts.research.market_data.storage import MarketDataError - -if __package__ in {None, ""}: - RESEARCH_ROOT = Path(__file__).resolve().parent.parent - sys.path.insert(0, str(RESEARCH_ROOT)) - from turtle_etf.result_adapter import ResultContractError - from turtle_etf.single_scenario import ( - SingleScenarioError, - execute_prepared_scenario, - validate_single_scenario_config, - write_project_status, - ) - from turtle_etf.vectorbt_benchmark import PerformanceGateError - from turtle_etf.vectorbt_inputs import prepare_simulation_inputs -else: - from .result_adapter import ResultContractError - from .single_scenario import ( - SingleScenarioError, - execute_prepared_scenario, - validate_single_scenario_config, - write_project_status, - ) - from .vectorbt_benchmark import PerformanceGateError - from .vectorbt_inputs import prepare_simulation_inputs - - -class ProjectInputError(ValueError): - """Raised when frozen project inputs cannot identify one scenario.""" - - -def _load_json(path: Path, name: str) -> dict[str, object]: - try: - value = json.loads(Path(path).read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: - raise ProjectInputError(f"invalid_{name}") from exc - if not isinstance(value, dict): - raise ProjectInputError(f"invalid_{name}") - return value - - -def _market_frames( - rows: tuple[Mapping[str, object], ...], - config: Mapping[str, object], -) -> dict[str, pd.DataFrame]: - universe = config.get("universe") - if not isinstance(universe, list) or not universe: - raise ProjectInputError("invalid_universe") - securities = tuple(str(item.get("security", "")) for item in universe if isinstance(item, Mapping)) - if len(securities) != len(universe) or any(not security for security in securities): - raise ProjectInputError("invalid_universe") - frame = pd.DataFrame([dict(row) for row in rows]) - if frame.empty or set(frame["security"].astype(str)) != set(securities): - raise ProjectInputError("snapshot_universe_mismatch") - return { - security: frame.loc[frame["security"].astype(str) == security].copy() - for security in securities - } - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Run one local vectorbt scenario") - parser.add_argument("--snapshot-manifest", type=Path, required=True) - parser.add_argument("--market-data-root", type=Path, required=True) - parser.add_argument("--project-config", type=Path, required=True) - parser.add_argument("--output-dir", type=Path, required=True) - parser.add_argument("--run-id", required=True) - parser.add_argument("--snapshot-id", required=True) - parser.add_argument("--code-sha256", required=True) - parser.add_argument("--config-sha256", required=True) - return parser - - -def main(argv: list[str] | None = None) -> int: - args = _parser().parse_args(argv) - try: - expected_manifest = ( - args.market_data_root / "snapshots" / f"{args.snapshot_id}.json" - ).resolve() - if args.snapshot_manifest.resolve() != expected_manifest: - raise ProjectInputError("snapshot_manifest_identity_mismatch") - config = _load_json(args.project_config, "project_config") - validate_single_scenario_config(config) - snapshot = open_snapshot(args.snapshot_id, root=args.market_data_root) - frames = _market_frames(snapshot.rows, config) - prepared = prepare_simulation_inputs( - frames, - config, - corporate_actions=snapshot.corporate_actions, - corporate_actions_digest=snapshot.corporate_actions_digest, - ) - execute_prepared_scenario( - prepared_inputs=prepared, - config=config, - output_dir=args.output_dir, - run_id=args.run_id, - snapshot_id=args.snapshot_id, - code_sha256=args.code_sha256, - config_sha256=args.config_sha256, - code_path=Path(__file__), - ) - write_project_status( - args.output_dir, - status="complete", - reason_codes=(), - next_action="return_to_caller", - ) - return 0 - except (PerformanceGateError, ResultContractError, OSError): - write_project_status( - args.output_dir, - status="failed", - reason_codes=("local_vectorbt_execution_failed",), - ) - return 1 - except (ProjectInputError, SingleScenarioError, MarketDataError, ValueError): - write_project_status( - args.output_dir, - status="evidence_insufficient", - reason_codes=("project_input_invalid",), - ) - return 2 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_delayed.py b/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_delayed.py deleted file mode 100644 index b2d8f95..0000000 --- a/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_delayed.py +++ /dev/null @@ -1,390 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np -import pandas as pd -import vectorbt as vbt - -from .vectorbt_callbacks import ( - ACTION_ADDITION, - ACTION_ENTRY, - ACTION_FULL_EXIT, - ACTION_NONE, - ACTION_REDISTRIBUTION_BUY, - ACTION_REDISTRIBUTION_SELL, - REASON_NONE, -) - - -ADJUST_NONE = 0 -ADJUST_CASH_TRUNCATED = 1 -ADJUST_HOLDING_TRUNCATED = 2 -ADJUST_UNTRADABLE = 3 -ADJUST_HORIZON_EXPIRED = 4 - - -@dataclass(frozen=True) -class FrozenOrderPlan: - planned_row_indices: np.ndarray - action_codes: np.ndarray - reason_codes: np.ndarray - requested_quantities: np.ndarray - target_quantities: np.ndarray - signal_n: np.ndarray - - -@dataclass(frozen=True) -class HorizonExpiredOrder: - planned_row_index: int - column: int - action_code: int - reason_code: int - requested_quantity: int - target_quantity: int - signal_n: float - delay_days: int - - -@dataclass(frozen=True) -class DelayedExecutionResult: - portfolio: object - action_codes: np.ndarray - reason_codes: np.ndarray - requested_quantities: np.ndarray - planned_quantities: np.ndarray - filled_quantities: np.ndarray - fill_prices: np.ndarray - fees: np.ndarray - state_quantities: np.ndarray - state_common_stop: np.ndarray - state_next_add_index: np.ndarray - state_unit_counts: np.ndarray - day_equity: np.ndarray - planned_row_indices: np.ndarray - execution_adjustment_codes: np.ndarray - frozen_signal_n: np.ndarray - execution_sequence: tuple[tuple[str, ...], ...] - horizon_expired_orders: tuple[HorizonExpiredOrder, ...] - - -def _readonly(values: np.ndarray, dtype: str) -> np.ndarray: - result = np.ascontiguousarray(values, dtype=dtype) - result.setflags(write=False) - return result - - -def _matrix(value: object, shape: tuple[int, int], name: str) -> np.ndarray: - result = np.asarray(value) - if result.shape != shape: - raise ValueError(f"invalid frozen order source shape: {name}") - return result - - -def freeze_order_plan(inputs: object, immediate: object) -> FrozenOrderPlan: - signal_n = np.asarray(getattr(inputs, "signal_n"), dtype=np.float64) - shape = signal_n.shape - actions = _matrix(immediate.action_codes, shape, "action_codes") - reasons = _matrix(immediate.reason_codes, shape, "reason_codes") - requested = _matrix( - immediate.requested_quantities, shape, "requested_quantities" - ) - planned = _matrix(immediate.planned_quantities, shape, "planned_quantities") - filled = _matrix(immediate.filled_quantities, shape, "filled_quantities") - valid = filled > 0 - row_indices = np.broadcast_to( - np.arange(shape[0], dtype=np.int64)[:, None], shape - ) - targets = np.where(valid, np.where(planned > 0, planned, filled), 0) - return FrozenOrderPlan( - planned_row_indices=_readonly( - np.where(valid, row_indices, -1), "int64" - ), - action_codes=_readonly(np.where(valid, actions, ACTION_NONE), "int16"), - reason_codes=_readonly(np.where(valid, reasons, REASON_NONE), "int16"), - requested_quantities=_readonly(np.where(valid, requested, 0), "int64"), - target_quantities=_readonly(targets, "int64"), - signal_n=_readonly(np.where(valid, signal_n, np.nan), "float64"), - ) - - -def _commission(price: float, quantity: int, multiplier: float) -> float: - return max(5.0, price * quantity * 0.000085) * multiplier - - -def _priority(action: int) -> int: - if action == ACTION_FULL_EXIT: - return 0 - if action == ACTION_REDISTRIBUTION_SELL: - return 1 - if action in (ACTION_ENTRY, ACTION_ADDITION): - return 2 - if action == ACTION_REDISTRIBUTION_BUY: - return 3 - return 4 - - -def _is_tradable(inputs: object, row: int, column: int, action: int) -> bool: - open_price = float(inputs.execution_open[row, column]) - if not np.isfinite(open_price) or open_price <= 0.0: - return False - if bool(inputs.paused[row, column]): - return False - if action in (ACTION_FULL_EXIT, ACTION_REDISTRIBUTION_SELL): - low_limit = float(inputs.low_limit[row, column]) - return not np.isfinite(low_limit) or open_price > low_limit - high_limit = float(inputs.high_limit[row, column]) - return not np.isfinite(high_limit) or open_price < high_limit - - -def _affordable_quantity( - *, - cash: float, - target: int, - lot_size: int, - price: float, - commission_multiplier: float, -) -> int: - quantity = (target // lot_size) * lot_size - while quantity > 0: - fee = _commission(price, quantity, commission_multiplier) - if price * quantity + fee <= cash + 1e-9: - return quantity - quantity -= lot_size - return 0 - - -def run_delayed_execution( - inputs: object, - plan: FrozenOrderPlan, - *, - initial_cash: float, - lot_size: int, - stop_n: float, - commission_multiplier: float, - one_way_slippage: float, - delay_days: int, -) -> DelayedExecutionResult: - if delay_days <= 0: - raise ValueError("delayed execution requires a positive delay") - if lot_size <= 0 or initial_cash <= 0.0: - raise ValueError("delayed execution cash and lot size must be positive") - opens = np.asarray(inputs.execution_open, dtype=np.float64) - close = np.asarray(inputs.close, dtype=np.float64) - if opens.shape != close.shape or opens.shape != plan.action_codes.shape: - raise ValueError("delayed execution input shapes differ") - rows, columns = opens.shape - securities = tuple(str(value) for value in inputs.securities) - if len(securities) != columns: - raise ValueError("delayed execution securities do not match columns") - - actions = np.zeros((rows, columns), dtype=np.int16) - reasons = np.zeros((rows, columns), dtype=np.int16) - requested = np.zeros((rows, columns), dtype=np.int64) - planned = np.zeros((rows, columns), dtype=np.int64) - filled = np.zeros((rows, columns), dtype=np.int64) - fill_prices = np.full((rows, columns), np.nan, dtype=np.float64) - fees = np.zeros((rows, columns), dtype=np.float64) - state_quantities = np.zeros((rows, columns), dtype=np.int64) - state_common_stop = np.full((rows, columns), np.nan, dtype=np.float64) - state_next_add = np.zeros((rows, columns), dtype=np.int64) - state_unit_counts = np.zeros((rows, columns), dtype=np.int64) - planned_rows = np.full((rows, columns), -1, dtype=np.int64) - adjustments = np.zeros((rows, columns), dtype=np.int16) - frozen_n = np.full((rows, columns), np.nan, dtype=np.float64) - sequences: list[tuple[str, ...]] = [] - expired: list[HorizonExpiredOrder] = [] - - cash_now = float(initial_cash) - positions = np.zeros(columns, dtype=np.int64) - common_stop = np.full(columns, np.nan, dtype=np.float64) - next_add = np.zeros(columns, dtype=np.int64) - unit_counts = np.zeros(columns, dtype=np.int64) - last_close = np.full(columns, np.nan, dtype=np.float64) - values = np.full(rows, np.nan, dtype=np.float64) - cash_values = np.full(rows, np.nan, dtype=np.float64) - call_seq = np.empty((rows, columns), dtype=np.int64) - - for planned_row, column in zip(*np.nonzero(plan.action_codes != ACTION_NONE)): - if planned_row + delay_days >= rows: - expired.append( - HorizonExpiredOrder( - planned_row_index=int(planned_row), - column=int(column), - action_code=int(plan.action_codes[planned_row, column]), - reason_code=int(plan.reason_codes[planned_row, column]), - requested_quantity=int( - plan.requested_quantities[planned_row, column] - ), - target_quantity=int(plan.target_quantities[planned_row, column]), - signal_n=float(plan.signal_n[planned_row, column]), - delay_days=delay_days, - ) - ) - - for execution_row in range(rows): - source_row = execution_row - delay_days - queue: list[int] = [] - if source_row >= 0: - queue = [ - column - for column in range(columns) - if plan.action_codes[source_row, column] != ACTION_NONE - ] - queue.sort( - key=lambda column: ( - _priority(int(plan.action_codes[source_row, column])), - securities[column], - ) - ) - sequences.append( - tuple( - f"queued-from-row-{source_row}:{securities[column]}" - for column in queue - ) - ) - ordered_columns = queue + sorted( - (column for column in range(columns) if column not in queue), - key=lambda column: securities[column], - ) - for rank, column in enumerate(ordered_columns): - call_seq[execution_row, rank] = column - for column in queue: - action = int(plan.action_codes[source_row, column]) - target = int(plan.target_quantities[source_row, column]) - actions[execution_row, column] = action - reasons[execution_row, column] = int( - plan.reason_codes[source_row, column] - ) - requested[execution_row, column] = int( - plan.requested_quantities[source_row, column] - ) - planned[execution_row, column] = target - planned_rows[execution_row, column] = source_row - frozen_n[execution_row, column] = float(plan.signal_n[source_row, column]) - if not _is_tradable(inputs, execution_row, column, action): - adjustments[execution_row, column] = ADJUST_UNTRADABLE - continue - - open_price = float(opens[execution_row, column]) - if action in (ACTION_FULL_EXIT, ACTION_REDISTRIBUTION_SELL): - quantity = min(target, int(positions[column])) - if quantity < target: - adjustments[execution_row, column] = ADJUST_HOLDING_TRUNCATED - if quantity <= 0: - continue - price = open_price * (1.0 - one_way_slippage) - fee = _commission(price, quantity, commission_multiplier) - cash_now += price * quantity - fee - positions[column] -= quantity - if positions[column] == 0: - common_stop[column] = np.nan - next_add[column] = 0 - if action == ACTION_FULL_EXIT: - unit_counts[column] = 0 - else: - price = open_price * (1.0 + one_way_slippage) - quantity = _affordable_quantity( - cash=cash_now, - target=target, - lot_size=lot_size, - price=price, - commission_multiplier=commission_multiplier, - ) - if quantity < target: - adjustments[execution_row, column] = ADJUST_CASH_TRUNCATED - if quantity <= 0: - continue - fee = _commission(price, quantity, commission_multiplier) - cash_now -= price * quantity + fee - positions[column] += quantity - if action in (ACTION_ENTRY, ACTION_ADDITION): - signal_n = float(plan.signal_n[source_row, column]) - candidate_stop = price - stop_n * signal_n - if action == ACTION_ENTRY or not np.isfinite(common_stop[column]): - common_stop[column] = candidate_stop - next_add[column] = 1 - unit_counts[column] = 1 - else: - common_stop[column] = max(common_stop[column], candidate_stop) - next_add[column] += 1 - unit_counts[column] += 1 - filled[execution_row, column] = quantity - fill_prices[execution_row, column] = price - fees[execution_row, column] = fee - - state_quantities[execution_row] = positions - state_common_stop[execution_row] = common_stop - state_next_add[execution_row] = next_add - state_unit_counts[execution_row] = unit_counts - for column in range(columns): - price = float(close[execution_row, column]) - if np.isfinite(price) and price > 0.0: - last_close[column] = price - held_values = np.where(positions > 0, positions * last_close, 0.0) - if np.any(~np.isfinite(held_values)): - raise ValueError("delayed position has no valid valuation price") - values[execution_row] = cash_now + float(held_values.sum()) - cash_values[execution_row] = cash_now - - order_sizes = np.full((rows, columns), np.nan, dtype=np.float64) - for row, column in zip(*np.nonzero(filled > 0)): - direction = -1.0 if actions[row, column] in ( - ACTION_FULL_EXIT, - ACTION_REDISTRIBUTION_SELL, - ) else 1.0 - order_sizes[row, column] = direction * float(filled[row, column]) - close_frame = pd.DataFrame( - close, - index=pd.DatetimeIndex(np.asarray(inputs.dates, dtype="datetime64[ns]")), - columns=securities, - ) - portfolio = vbt.Portfolio.from_orders( - close_frame, - size=order_sizes, - price=fill_prices, - fixed_fees=fees, - direction="longonly", - init_cash=initial_cash, - cash_sharing=True, - group_by=True, - call_seq=call_seq, - update_value=True, - ffill_val_price=True, - max_orders=rows * columns, - freq="1D", - ) - vectorbt_values = np.asarray(portfolio.value(), dtype=np.float64).reshape(-1) - vectorbt_cash = np.asarray(portfolio.cash(), dtype=np.float64).reshape(-1) - if not np.allclose(vectorbt_values, values, rtol=0.0, atol=0.02): - differences = np.abs(vectorbt_values - values) - row = int(np.nanargmax(differences)) - raise ValueError( - "vectorbt delayed portfolio value does not reconcile: " - f"row={row} expected={values[row]} actual={vectorbt_values[row]} " - f"difference={differences[row]} " - f"expected_orders={int(np.count_nonzero(filled))} " - f"actual_orders={int(portfolio.orders.count())}" - ) - if not np.allclose(vectorbt_cash, cash_values, rtol=0.0, atol=0.02): - raise ValueError("vectorbt delayed portfolio cash does not reconcile") - return DelayedExecutionResult( - portfolio=portfolio, - action_codes=_readonly(actions, "int16"), - reason_codes=_readonly(reasons, "int16"), - requested_quantities=_readonly(requested, "int64"), - planned_quantities=_readonly(planned, "int64"), - filled_quantities=_readonly(filled, "int64"), - fill_prices=_readonly(fill_prices, "float64"), - fees=_readonly(fees, "float64"), - state_quantities=_readonly(state_quantities, "int64"), - state_common_stop=_readonly(state_common_stop, "float64"), - state_next_add_index=_readonly(state_next_add, "int64"), - state_unit_counts=_readonly(state_unit_counts, "int64"), - day_equity=_readonly(values, "float64"), - planned_row_indices=_readonly(planned_rows, "int64"), - execution_adjustment_codes=_readonly(adjustments, "int16"), - frozen_signal_n=_readonly(frozen_n, "float64"), - execution_sequence=tuple(sequences), - horizon_expired_orders=tuple(expired), - ) diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_engine.py b/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_engine.py deleted file mode 100644 index 64e7996..0000000 --- a/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_engine.py +++ /dev/null @@ -1,388 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Mapping - -import numpy as np -import pandas as pd -import vectorbt as vbt - -from .vectorbt_callbacks import ( - ACTION_NONE, - CallbackInputs, - CallbackParams, - CallbackState, - order_func_nb, - post_order_func_nb, - post_segment_func_nb, - pre_segment_func_nb, - pre_sim_func_nb, -) -from .vectorbt_delayed import freeze_order_plan, run_delayed_execution -from .vectorbt_inputs import SimulationInputs - - -@dataclass(frozen=True) -class VectorbtSimulationResult: - initial_cash: float - asset_group_unit_cap: float - portfolio_unit_cap: float - portfolio: object - action_codes: np.ndarray - reason_codes: np.ndarray - requested_quantities: np.ndarray - planned_quantities: np.ndarray - filled_quantities: np.ndarray - fill_prices: np.ndarray - fees: np.ndarray - state_quantities: np.ndarray - state_common_stop: np.ndarray - state_next_add_index: np.ndarray - state_unit_counts: np.ndarray - candidate_base_quantities: np.ndarray - event_group_scales: np.ndarray - event_portfolio_scales: np.ndarray - event_cash_scales: np.ndarray - day_equity: np.ndarray - planned_row_indices: np.ndarray - execution_adjustment_codes: np.ndarray - frozen_signal_n: np.ndarray - execution_delay_days: int - execution_sequence: tuple[tuple[str, ...], ...] - horizon_expired_orders: tuple[object, ...] - - -def _section(config: Mapping[str, object], name: str) -> Mapping[str, object]: - value = config.get(name) - if not isinstance(value, Mapping): - raise ValueError(f"{name} config must be an object") - return value - - -def _number( - section: Mapping[str, object], - name: str, - default: float | None = None, - *, - positive: bool = True, -) -> float: - value = section.get(name, default) - if value is None: - raise ValueError(f"missing config value: {name}") - try: - result = float(value) - except (TypeError, ValueError) as exc: - raise ValueError(f"{name} must be numeric") from exc - if not np.isfinite(result) or (positive and result <= 0.0): - raise ValueError(f"{name} must be finite and positive") - return result - - -_LEGACY_RISK_FIELDS = frozenset( - { - "security_risk_cap", - "security_value_cap", - "asset_group_risk_cap", - "asset_group_value_cap", - "portfolio_risk_cap", - "portfolio_value_cap", - "covariance", - "target_volatility", - "risk_reduction_target_volatility", - "minimum_aligned_samples", - } -) - - -def _reject_legacy_risk_fields(risk: Mapping[str, object]) -> None: - found = sorted(set(risk) & _LEGACY_RISK_FIELDS) - if found: - raise ValueError( - "legacy risk fields are not supported: " + ", ".join(found) - ) - - -def _params(config: Mapping[str, object]) -> tuple[float, CallbackParams]: - research = _section(config, "research") - signal = _section(config, "signal") - risk = _section(config, "risk") - costs_value = config.get("costs", {}) - if not isinstance(costs_value, Mapping): - raise ValueError("costs config must be an object") - initial_cash = _number(research, "initial_cash") - _reject_legacy_risk_fields(risk) - lot_size_value = risk.get("lot_size", 100) - if isinstance(lot_size_value, bool) or int(lot_size_value) != lot_size_value: - raise ValueError("lot_size must be a positive integer") - lot_size = int(lot_size_value) - if lot_size <= 0: - raise ValueError("lot_size must be a positive integer") - slippage = _number( - costs_value, "one_way_slippage", 0.0, positive=False - ) - if slippage < 0.0 or slippage >= 1.0: - raise ValueError("one_way_slippage must be between zero and one") - if "max_units" not in signal: - raise ValueError("missing config value: max_units") - max_units_value = signal["max_units"] - if ( - isinstance(max_units_value, bool) - or not isinstance(max_units_value, int) - or max_units_value != 4 - ): - raise ValueError("max_units must equal four") - max_units = max_units_value - return initial_cash, CallbackParams( - lot_size=lot_size, - unit_risk_per_n=_number(risk, "unit_risk_per_n"), - add_step_n=_number(signal, "add_step_n"), - stop_n=_number(signal, "stop_n"), - max_units=max_units, - asset_group_unit_cap=_number(risk, "asset_group_unit_cap"), - portfolio_unit_cap=_number(risk, "portfolio_unit_cap"), - commission_multiplier=_number( - costs_value, "commission_multiplier", 1.0 - ), - one_way_slippage=slippage, - ) - - -def _mutable_state( - rows: int, columns: int, group_count: int, max_units: int -) -> CallbackState: - if rows <= 0 or columns <= 0 or group_count <= 0 or max_units != 4: - raise ValueError("invalid callback state dimensions") - return CallbackState( - unit_count=np.zeros(columns, dtype=np.int64), - unit_signal_n=np.full( - (columns, max_units), np.nan, dtype=np.float64 - ), - unit_base_quantities=np.zeros( - (columns, max_units), dtype=np.int64 - ), - unit_fill_prices=np.full( - (columns, max_units), np.nan, dtype=np.float64 - ), - initial_fill_price=np.full(columns, np.nan, dtype=np.float64), - initial_signal_n=np.full(columns, np.nan, dtype=np.float64), - common_stop=np.full(columns, np.nan, dtype=np.float64), - next_add_index=np.zeros(columns, dtype=np.int64), - candidate_signal_n=np.full( - (rows, columns), np.nan, dtype=np.float64 - ), - candidate_base_quantity=np.zeros((rows, columns), dtype=np.int64), - action_codes=np.zeros((rows, columns), dtype=np.int16), - reason_codes=np.zeros((rows, columns), dtype=np.int16), - requested_quantities=np.zeros((rows, columns), dtype=np.int64), - planned_quantities=np.zeros((rows, columns), dtype=np.int64), - filled_quantities=np.zeros((rows, columns), dtype=np.int64), - fill_prices=np.full((rows, columns), np.nan, dtype=np.float64), - fees=np.zeros((rows, columns), dtype=np.float64), - state_quantities=np.zeros((rows, columns), dtype=np.int64), - state_common_stop=np.full((rows, columns), np.nan, dtype=np.float64), - state_next_add_index=np.zeros((rows, columns), dtype=np.int64), - state_unit_counts=np.zeros((rows, columns), dtype=np.int64), - event_group_scales=np.ones((rows, columns), dtype=np.float64), - event_portfolio_scales=np.ones(rows, dtype=np.float64), - event_cash_scales=np.ones(rows, dtype=np.float64), - day_equity=np.full(rows, np.nan, dtype=np.float64), - allocation_ready=np.zeros(rows, dtype=np.bool_), - ) - - -def _readonly_copy(values: np.ndarray) -> np.ndarray: - result = np.ascontiguousarray(values.copy()) - result.setflags(write=False) - return result - - -def _delay_days(config: Mapping[str, object]) -> int: - execution = config.get("execution", {}) - if not isinstance(execution, Mapping): - raise ValueError("execution config must be an object") - value = execution.get("additional_delay_days", 0) - if isinstance(value, bool) or not isinstance(value, int) or value < 0: - raise ValueError("additional_delay_days must be a non-negative integer") - return value - - -def _run_immediate( - inputs: SimulationInputs, - config: Mapping[str, object], -) -> VectorbtSimulationResult: - rows, columns = inputs.close.shape - if rows == 0 or columns == 0: - raise ValueError("simulation inputs must not be empty") - expected_shape = (rows, columns) - for name in ( - "execution_open", - "paused", - "high_limit", - "low_limit", - "signal_close", - "signal_entry_high", - "signal_exit_low", - "signal_n", - ): - if getattr(inputs, name).shape != expected_shape: - raise ValueError(f"invalid simulation input shape: {name}") - if len(inputs.securities) != columns or len(inputs.asset_groups) != columns: - raise ValueError("simulation input identities do not match columns") - - initial_cash, params = _params(config) - group_count = int(np.max(inputs.asset_group_ids)) + 1 - state = _mutable_state(rows, columns, group_count, params.max_units) - callback_inputs = CallbackInputs( - execution_open=inputs.execution_open, - signal_close=inputs.signal_close, - signal_entry_high=inputs.signal_entry_high, - signal_exit_low=inputs.signal_exit_low, - signal_n=inputs.signal_n, - paused=inputs.paused, - high_limit=inputs.high_limit, - low_limit=inputs.low_limit, - asset_group_ids=inputs.asset_group_ids, - ) - close = pd.DataFrame( - inputs.close, - index=pd.DatetimeIndex(inputs.dates.astype("datetime64[ns]")), - columns=inputs.securities, - ) - portfolio = vbt.Portfolio.from_order_func( - close, - order_func_nb, - pre_sim_func_nb=pre_sim_func_nb, - pre_sim_args=(state, callback_inputs, params), - pre_segment_func_nb=pre_segment_func_nb, - post_segment_func_nb=post_segment_func_nb, - post_order_func_nb=post_order_func_nb, - init_cash=initial_cash, - cash_sharing=True, - group_by=True, - call_pre_segment=True, - update_value=True, - ffill_val_price=True, - max_orders=rows * columns, - use_numba=True, - freq="1D", - ) - valid_orders = state.action_codes != ACTION_NONE - row_indices = np.broadcast_to( - np.arange(rows, dtype=np.int64)[:, None], (rows, columns) - ) - return VectorbtSimulationResult( - initial_cash=initial_cash, - asset_group_unit_cap=params.asset_group_unit_cap, - portfolio_unit_cap=params.portfolio_unit_cap, - portfolio=portfolio, - action_codes=_readonly_copy(state.action_codes), - reason_codes=_readonly_copy(state.reason_codes), - requested_quantities=_readonly_copy(state.requested_quantities), - planned_quantities=_readonly_copy(state.planned_quantities), - filled_quantities=_readonly_copy(state.filled_quantities), - fill_prices=_readonly_copy(state.fill_prices), - fees=_readonly_copy(state.fees), - state_quantities=_readonly_copy(state.state_quantities), - state_common_stop=_readonly_copy(state.state_common_stop), - state_next_add_index=_readonly_copy(state.state_next_add_index), - state_unit_counts=_readonly_copy(state.state_unit_counts), - candidate_base_quantities=_readonly_copy( - state.candidate_base_quantity - ), - event_group_scales=_readonly_copy(state.event_group_scales), - event_portfolio_scales=_readonly_copy( - state.event_portfolio_scales - ), - event_cash_scales=_readonly_copy(state.event_cash_scales), - day_equity=_readonly_copy(state.day_equity), - planned_row_indices=_readonly_copy( - np.where(valid_orders, row_indices, -1).astype(np.int64) - ), - execution_adjustment_codes=_readonly_copy( - np.zeros((rows, columns), dtype=np.int16) - ), - frozen_signal_n=_readonly_copy( - np.where(valid_orders, inputs.signal_n, np.nan).astype(np.float64) - ), - execution_delay_days=0, - execution_sequence=tuple( - tuple( - f"immediate-row-{row}:{inputs.securities[column]}" - for column in range(columns) - if state.action_codes[row, column] != ACTION_NONE - ) - for row in range(rows) - ), - horizon_expired_orders=(), - ) - - -def run_vectorbt_simulation( - inputs: SimulationInputs, - config: Mapping[str, object], -) -> VectorbtSimulationResult: - delay_days = _delay_days(config) - immediate = _run_immediate(inputs, config) - if delay_days == 0: - return immediate - initial_cash, params = _params(config) - plan = freeze_order_plan(inputs, immediate) - delayed = run_delayed_execution( - inputs, - plan, - initial_cash=initial_cash, - lot_size=params.lot_size, - stop_n=params.stop_n, - commission_multiplier=params.commission_multiplier, - one_way_slippage=params.one_way_slippage, - delay_days=delay_days, - ) - rows, columns = inputs.close.shape - delayed_candidate_bases = np.zeros((rows, columns), dtype=np.int64) - delayed_group_scales = np.ones((rows, columns), dtype=np.float64) - delayed_portfolio_scales = np.ones(rows, dtype=np.float64) - delayed_cash_scales = np.ones(rows, dtype=np.float64) - for execution_row in range(delay_days, rows): - source_row = execution_row - delay_days - delayed_candidate_bases[execution_row] = ( - immediate.candidate_base_quantities[source_row] - ) - delayed_group_scales[execution_row] = ( - immediate.event_group_scales[source_row] - ) - delayed_portfolio_scales[execution_row] = ( - immediate.event_portfolio_scales[source_row] - ) - delayed_cash_scales[execution_row] = ( - immediate.event_cash_scales[source_row] - ) - return VectorbtSimulationResult( - initial_cash=initial_cash, - asset_group_unit_cap=params.asset_group_unit_cap, - portfolio_unit_cap=params.portfolio_unit_cap, - portfolio=delayed.portfolio, - action_codes=delayed.action_codes, - reason_codes=delayed.reason_codes, - requested_quantities=delayed.requested_quantities, - planned_quantities=delayed.planned_quantities, - filled_quantities=delayed.filled_quantities, - fill_prices=delayed.fill_prices, - fees=delayed.fees, - state_quantities=delayed.state_quantities, - state_common_stop=delayed.state_common_stop, - state_next_add_index=delayed.state_next_add_index, - state_unit_counts=delayed.state_unit_counts, - candidate_base_quantities=_readonly_copy(delayed_candidate_bases), - event_group_scales=_readonly_copy(delayed_group_scales), - event_portfolio_scales=_readonly_copy( - delayed_portfolio_scales - ), - event_cash_scales=_readonly_copy(delayed_cash_scales), - day_equity=delayed.day_equity, - planned_row_indices=delayed.planned_row_indices, - execution_adjustment_codes=delayed.execution_adjustment_codes, - frozen_signal_n=delayed.frozen_signal_n, - execution_delay_days=delay_days, - execution_sequence=delayed.execution_sequence, - horizon_expired_orders=delayed.horizon_expired_orders, - ) diff --git a/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_inputs.py b/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_inputs.py deleted file mode 100644 index 043c021..0000000 --- a/joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_inputs.py +++ /dev/null @@ -1,271 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Mapping, Sequence - -import numpy as np -import pandas as pd - -from scripts.research.market_data.economic_returns import ( - CorporateActionApplication, - canonical_corporate_actions_digest, - derive_continuous_prices, -) - -from .indicators import breakout_levels, turtle_n - - -@dataclass(frozen=True) -class SimulationInputs: - dates: np.ndarray - securities: tuple[str, ...] - asset_groups: tuple[str, ...] - asset_group_ids: np.ndarray - raw_open: np.ndarray - raw_high: np.ndarray - raw_low: np.ndarray - raw_close: np.ndarray - raw_pre_close: np.ndarray - continuous_open: np.ndarray - continuous_high: np.ndarray - continuous_low: np.ndarray - continuous_close: np.ndarray - continuous_pre_close: np.ndarray - continuity_factor: np.ndarray - corporate_action_applied: np.ndarray - corporate_actions_digest: str - corporate_action_applications: tuple[CorporateActionApplication, ...] - paused: np.ndarray - high_limit: np.ndarray - low_limit: np.ndarray - signal_source_index: np.ndarray - signal_close: np.ndarray - signal_entry_high: np.ndarray - signal_exit_low: np.ndarray - signal_n: np.ndarray - - @property - def execution_open(self) -> np.ndarray: - return self.continuous_open - - @property - def close(self) -> np.ndarray: - return self.continuous_close - - -def _section(config: Mapping[str, object], name: str) -> Mapping[str, object]: - value = config.get(name) - if not isinstance(value, Mapping): - raise ValueError(f"{name} config must be an object") - return value - - -def _positive_int(value: object, name: str, *, minimum: int = 1) -> int: - if isinstance(value, bool): - raise ValueError(f"{name} must be an integer") - try: - result = int(value) - except (TypeError, ValueError) as exc: - raise ValueError(f"{name} must be an integer") from exc - if result < minimum or result != value: - raise ValueError(f"{name} must be at least {minimum}") - return result - - -def _readonly(values: object, dtype: np.dtype[object] | str) -> np.ndarray: - result = np.ascontiguousarray(values, dtype=dtype) - result.setflags(write=False) - return result - - -def _evidence_insufficient(message: str) -> ValueError: - return ValueError(f"evidence_insufficient: {message}") - - -def _normalized_frame( - frame: pd.DataFrame, - *, - security: str, - signal: Mapping[str, object], - actions: Sequence[Mapping[str, object]], -) -> tuple[pd.DataFrame, tuple[CorporateActionApplication, ...]]: - continuous = derive_continuous_prices( - frame, - security=security, - corporate_actions=actions, - ) - result = continuous.frame - result["n"] = turtle_n(result, days=_positive_int(signal.get("n_days"), "n_days")) - levels = breakout_levels( - result, - entry_days=_positive_int(signal.get("entry_days"), "entry_days"), - exit_days=_positive_int(signal.get("exit_days"), "exit_days"), - ) - result["entry_high"] = levels["entry_high"] - result["exit_low"] = levels["exit_low"] - return result.set_index("date", drop=False), continuous.applications - - -def prepare_simulation_inputs( - frames: Mapping[str, pd.DataFrame], - config: Mapping[str, object], - *, - corporate_actions: Sequence[Mapping[str, object]] = (), - corporate_actions_digest: str | None = None, -) -> SimulationInputs: - universe_value = config.get("universe") - if not isinstance(universe_value, list) or not universe_value: - raise ValueError("universe must be a non-empty list") - universe: dict[str, str] = {} - for item in universe_value: - if not isinstance(item, Mapping): - raise ValueError("universe entries must be objects") - security = str(item.get("security", "")) - asset_group = str(item.get("asset_group", "")) - if not security or not asset_group or security in universe: - raise ValueError("universe identities must be non-empty and unique") - universe[security] = asset_group - if set(frames) != set(universe): - raise ValueError("market frames must exactly match the configured universe") - action_securities = { - str(action.get("security", "")) for action in corporate_actions - } - unknown_action_securities = sorted(action_securities - set(universe)) - if unknown_action_securities: - raise _evidence_insufficient( - "corporate actions are outside the configured universe: " - + ", ".join(unknown_action_securities) - ) - computed_action_digest = canonical_corporate_actions_digest(corporate_actions) - if corporate_actions_digest is None: - corporate_actions_digest = computed_action_digest - elif ( - not isinstance(corporate_actions_digest, str) - or len(corporate_actions_digest) != 64 - or any(character not in "0123456789abcdef" for character in corporate_actions_digest) - ): - raise _evidence_insufficient("invalid corporate-actions digest") - elif corporate_actions_digest != computed_action_digest: - raise _evidence_insufficient("corporate-actions digest mismatch") - - securities = tuple(sorted(universe)) - asset_groups = tuple(universe[security] for security in securities) - group_labels = {name: index for index, name in enumerate(sorted(set(asset_groups)))} - asset_group_ids = [group_labels[name] for name in asset_groups] - signal = _section(config, "signal") - normalized: dict[str, pd.DataFrame] = {} - action_applications: list[CorporateActionApplication] = [] - for security in securities: - normalized_frame, applications = _normalized_frame( - frames[security], - security=security, - signal=signal, - actions=corporate_actions, - ) - normalized[security] = normalized_frame - action_applications.extend(applications) - calendar = pd.DatetimeIndex( - sorted({date for frame in normalized.values() for date in frame.index}) - ) - if calendar.empty: - raise ValueError("market calendar must not be empty") - - row_count = len(calendar) - column_count = len(securities) - shape = (row_count, column_count) - raw_open = np.full(shape, np.nan, dtype=np.float64) - raw_high = np.full(shape, np.nan, dtype=np.float64) - raw_low = np.full(shape, np.nan, dtype=np.float64) - raw_close = np.full(shape, np.nan, dtype=np.float64) - raw_pre_close = np.full(shape, np.nan, dtype=np.float64) - continuous_open = np.full(shape, np.nan, dtype=np.float64) - continuous_high = np.full(shape, np.nan, dtype=np.float64) - continuous_low = np.full(shape, np.nan, dtype=np.float64) - continuous_close = np.full(shape, np.nan, dtype=np.float64) - continuous_pre_close = np.full(shape, np.nan, dtype=np.float64) - continuity_factor = np.full(shape, np.nan, dtype=np.float64) - corporate_action_applied = np.zeros(shape, dtype=np.bool_) - paused = np.ones(shape, dtype=np.bool_) - high_limit = np.full(shape, np.nan, dtype=np.float64) - low_limit = np.full(shape, np.nan, dtype=np.float64) - raw_entry_high = np.full(shape, np.nan, dtype=np.float64) - raw_exit_low = np.full(shape, np.nan, dtype=np.float64) - raw_n = np.full(shape, np.nan, dtype=np.float64) - for column, security in enumerate(securities): - aligned = normalized[security].reindex(calendar) - raw_open[:, column] = aligned["raw_open"].to_numpy(dtype=np.float64) - raw_high[:, column] = aligned["raw_high"].to_numpy(dtype=np.float64) - raw_low[:, column] = aligned["raw_low"].to_numpy(dtype=np.float64) - raw_close[:, column] = aligned["raw_close"].to_numpy(dtype=np.float64) - raw_pre_close[:, column] = aligned["raw_pre_close"].to_numpy(dtype=np.float64) - continuous_open[:, column] = aligned["open"].to_numpy(dtype=np.float64) - continuous_high[:, column] = aligned["high"].to_numpy(dtype=np.float64) - continuous_low[:, column] = aligned["low"].to_numpy(dtype=np.float64) - continuous_close[:, column] = aligned["close"].to_numpy(dtype=np.float64) - continuous_pre_close[:, column] = aligned["pre_close"].to_numpy( - dtype=np.float64 - ) - continuity_factor[:, column] = aligned["continuity_factor"].to_numpy( - dtype=np.float64 - ) - corporate_action_applied[:, column] = aligned[ - "corporate_action_applied" - ].fillna(False).to_numpy(dtype=np.bool_) - paused[:, column] = aligned["paused"].fillna(True).to_numpy(dtype=np.bool_) - high_limit[:, column] = aligned["high_limit"].to_numpy(dtype=np.float64) - low_limit[:, column] = aligned["low_limit"].to_numpy(dtype=np.float64) - raw_entry_high[:, column] = aligned["entry_high"].to_numpy(dtype=np.float64) - raw_exit_low[:, column] = aligned["exit_low"].to_numpy(dtype=np.float64) - raw_n[:, column] = aligned["n"].to_numpy(dtype=np.float64) - - shift = 1 - signal_source_index = np.full(row_count, -1, dtype=np.int64) - signal_close = np.full(shape, np.nan, dtype=np.float64) - signal_entry_high = np.full(shape, np.nan, dtype=np.float64) - signal_exit_low = np.full(shape, np.nan, dtype=np.float64) - signal_n = np.full(shape, np.nan, dtype=np.float64) - for execution_row in range(shift, row_count): - source_row = execution_row - shift - signal_source_index[execution_row] = source_row - signal_close[execution_row] = continuous_close[source_row] - signal_entry_high[execution_row] = raw_entry_high[source_row] - signal_exit_low[execution_row] = raw_exit_low[source_row] - signal_n[execution_row] = raw_n[source_row] - - return SimulationInputs( - dates=_readonly(calendar.to_numpy(dtype="datetime64[D]"), "datetime64[D]"), - securities=securities, - asset_groups=asset_groups, - asset_group_ids=_readonly(asset_group_ids, "int64"), - raw_open=_readonly(raw_open, "float64"), - raw_high=_readonly(raw_high, "float64"), - raw_low=_readonly(raw_low, "float64"), - raw_close=_readonly(raw_close, "float64"), - raw_pre_close=_readonly(raw_pre_close, "float64"), - continuous_open=_readonly(continuous_open, "float64"), - continuous_high=_readonly(continuous_high, "float64"), - continuous_low=_readonly(continuous_low, "float64"), - continuous_close=_readonly(continuous_close, "float64"), - continuous_pre_close=_readonly(continuous_pre_close, "float64"), - continuity_factor=_readonly(continuity_factor, "float64"), - corporate_action_applied=_readonly(corporate_action_applied, "bool"), - corporate_actions_digest=corporate_actions_digest, - corporate_action_applications=tuple( - sorted( - action_applications, - key=lambda item: ( - item.effective_date, - item.security, - item.source_event_id, - ), - ) - ), - paused=_readonly(paused, "bool"), - high_limit=_readonly(high_limit, "float64"), - low_limit=_readonly(low_limit, "float64"), - signal_source_index=_readonly(signal_source_index, "int64"), - signal_close=_readonly(signal_close, "float64"), - signal_entry_high=_readonly(signal_entry_high, "float64"), - signal_exit_low=_readonly(signal_exit_low, "float64"), - signal_n=_readonly(signal_n, "float64"), - ) diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet.yaml b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet.yaml new file mode 100644 index 0000000..146c53b --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet.yaml @@ -0,0 +1,27 @@ +workflow: full +language: zh-CN +phase: archive +context_compression: beta +build_mode: executing-plans +build_pause: null +subagent_dispatch: null +tdd_mode: tdd +review_mode: thorough +isolation: branch +verify_mode: full +auto_transition: true +base_ref: ea195d36501848d3ba677b1e97c1aba667da7e1e +design_doc: docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md +plan: docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md +verify_result: pass +verification_report: docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md +branch_status: handled +created_at: 2026-07-16 +verified_at: 2026-07-17 +archived: true +direct_override: null +handoff_context: openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json +handoff_hash: 9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313 +classic_profile: full +classic_migration: 1 +run_id: e6b0e4a7-959a-48c5-8fd1-de37aacca3ed diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/artifacts.json b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/artifacts.json new file mode 100644 index 0000000..e9aac54 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/artifacts.json @@ -0,0 +1,9 @@ +{ + "openspec_proposal": "openspec/changes/refactor-local-research-three-layer-architecture/proposal.md", + "openspec_design": "openspec/changes/refactor-local-research-three-layer-architecture/design.md", + "openspec_tasks": "openspec/changes/refactor-local-research-three-layer-architecture/tasks.md", + "openspec_delta_spec": "openspec/changes/refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md", + "handoff_context": "openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json", + "handoff_markdown": "openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.md", + "archive_directory": "openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture" +} diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/checkpoint.json b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/checkpoint.json new file mode 100644 index 0000000..b9ba35f --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/checkpoint.json @@ -0,0 +1,8 @@ +{ + "runId": "e6b0e4a7-959a-48c5-8fd1-de37aacca3ed", + "stateVersion": 12, + "trajectoryOffset": 14, + "contextHash": "a7e66b83b5c81752d22747dc40f957f97e5bfdc1e46d635363e4bfb23eb8c4a0", + "artifactsHash": "846c6050c797acb31499823676679605d3a6da0eab6c9d10b60e9745cf1e2bb9", + "createdAt": "2026-07-17T19:13:06.575Z" +} diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/context.md b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/context.md new file mode 100644 index 0000000..97bc8a3 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/context.md @@ -0,0 +1,242 @@ +# Comet Spec Context + +- Change: refactor-local-research-three-layer-architecture +- Phase: design +- Mode: beta +- Context hash: 9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313 + +Generated-by: comet-handoff.sh + +OpenSpec remains the canonical capability spec. This beta context pack verbatim-projects spec files and references supporting artifacts by hash, not an agent-authored summary. + +## Source References + +- Source: openspec/changes/refactor-local-research-three-layer-architecture/proposal.md +- SHA256: d1572168930634731ac7d0794d728c6675718b33b17fb223aab4a484e2d9a3f1 +- Source: openspec/changes/refactor-local-research-three-layer-architecture/design.md +- SHA256: b30b85223a9d9b6445dd8896f5af5d99390818df3d55797feceee5a48386061e +- Source: openspec/changes/refactor-local-research-three-layer-architecture/tasks.md +- SHA256: fe435a57fee8a18c82692445b58e22cc2dfe4027f6fb7e26c90048aa020ada95 +- Source: openspec/changes/refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md +- SHA256: 4943b07f8f5449c33d44f9625cb5280e555514232b392daac2be0a0edc490bae +- Source: openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-archive-promotion/spec.md +- SHA256: 5e43587054511b5a1f1b0093735b2744475aa3879c3c3a8522ba88d402934c30 +- Source: openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-result-package/spec.md +- SHA256: 3454fb5e60c70901b99b11692441e86127c0838e780b3de95cc6e03f52eb9fb7 + +## Acceptance Projection + +## openspec/changes/refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md + +- Source: openspec/changes/refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md +- Lines: 1-71 +- SHA256: 4943b07f8f5449c33d44f9625cb5280e555514232b392daac2be0a0edc490bae + +```md +## ADDED Requirements + +### Requirement: 本地研究必须使用三层单向架构 +系统 MUST 将本地研究组织为 Strategy Module、Skill 通用能力层和 vectorbt 执行底层,并且依赖只能从 Strategy Module 经共享 contracts 进入 Skill 能力和执行底层。具体策略不得导入 vectorbt 上下文、订单枚举、记录结构或原始 Portfolio;共享能力不得导入具体策略的私有实现。 + +#### Scenario: 加载海龟策略 +- **WHEN** 共享入口加载海龟研究项目 +- **THEN** 它只通过 `turtle_etf.strategy:MODULE` 使用策略,并通过共享执行 Interface 调用 vectorbt,不直接导入海龟私有文件 + +#### Scenario: 第二个策略复用共享能力 +- **WHEN** 仓库提供一个实现同一 Strategy Module Interface 的最小测试策略 +- **THEN** 共享 CLI、性能门禁、结果包和停止状态无需修改即可运行该策略 + +### Requirement: vectorbt 必须成为唯一账户账本 +系统 MUST 使用 vectorbt `Portfolio.from_order_func()` 处理即时和延迟场景的订单接受、拒绝、部分成交、费用、共享现金、持仓和组合估值。策略只保存决策所需的单位、止损、冻结计划、原因和归因轨迹,不得维护第二套成交、现金、持仓、费用或净值账本。 + +#### Scenario: 即时执行 +- **WHEN** 策略在同一交易日生成并执行目标订单 +- **THEN** 实际成交、费用、现金、持仓和净值全部来自单一 vectorbt Portfolio + +#### Scenario: 延迟执行 +- **WHEN** 场景声明正数 `additional_delay_days` +- **THEN** 策略冻结原计划并在执行日复核可交易性和机械约束,但实际账户变化仍由第二个 `from_order_func()` 程序完成 + +#### Scenario: vectorbt 拒绝订单 +- **WHEN** vectorbt 因现金、持仓或订单约束拒绝一笔请求 +- **THEN** 系统把拒绝状态和策略原因保存为执行事实,不把正常拒单误报为框架失败 + +### Requirement: 每个策略必须只有一个公开 Strategy Module +每个本地研究策略 MUST 暴露一个版本化公开 Strategy Module,负责配置校验、输入准备、订单程序和策略结果扩展。策略 MAY 使用私有实现文件,但外部调用和测试不得把这些私有文件当作稳定 Interface。 + +#### Scenario: 策略内部重组 +- **WHEN** 策略维护者在不改变公开 Module 和行为的前提下拆分或合并私有 Numba 内核文件 +- **THEN** 共享层、其他策略和 Interface 级测试无需修改 + +#### Scenario: 禁止动态回调 +- **WHEN** Strategy Module 根据场景参数构造订单程序 +- **THEN** 它使用模块级固定 Numba 函数和稳定数组类型,不为每个配置动态创建闭包或 lambda + +### Requirement: 所有项目必须通过固定共享入口运行 +系统 MUST 从项目 `.venv` 调用共享本地研究 CLI。项目配置 MUST 声明仓库内 strategy root、module 和 symbol,不得声明策略专属 project entry、任意命令、系统 Python 或隐式依赖安装。 + +#### Scenario: 运行合法项目配置 +- **WHEN** 配置声明有效的仓库内 Strategy Module、单一行情快照和一个场景 +- **THEN** runner 在清理后的子进程中调用固定共享 CLI 并冻结全部声明输入 + +#### Scenario: 配置尝试执行任意命令 +- **WHEN** 配置包含旧 `command`、策略专属 `project_entry`、仓库外模块路径或安装命令 +- **THEN** 系统在启动项目进程前拒绝配置并返回 `evidence_insufficient` + +### Requirement: 单场景停止状态必须保持固定 +共享运行 MUST 每次只接受一个场景,并且只返回 `complete`、`evidence_insufficient` 或 `failed`。完整运行 MUST 返回 `next_action=return_to_caller`;Skill 不得循环多个场景、解释策略字段或自动给出研究推荐。 + +#### Scenario: 输入证据缺失 +- **WHEN** 策略身份、配置、行情快照、范围或必需字段缺失 +- **THEN** 系统返回 `evidence_insufficient`,不猜测替代输入且不执行策略 + +#### Scenario: 执行或证据失败 +- **WHEN** vectorbt 执行异常、输出缺失、摘要冲突、临时清理失败或性能超限 +- **THEN** 系统返回 `failed` 并保留紧凑失败证据,不发布完整运行 + +### Requirement: 重构必须保持结果一致且性能不实质退化 +系统 MUST 在固定环境比较重构前后的真实规模、扩展资产和延迟场景。Schema、行数、成交、费用、现金、持仓、净值和逻辑摘要 MUST 完全一致;冷启动、预热、峰值内存和结果体积的中位数变化不得超过 5% 测量噪声,并继续满足冷、热各 180 秒绝对门禁。 + +#### Scenario: 真实规模性能回归 +- **WHEN** 在相同机器和输入上运行 3,432 日 × 11 ETF 主场景,使用三个冷启动新进程和五次预热采样 +- **THEN** 结果证据零差异,时间、内存和结果体积均不超过基线 5% 噪声带 + +#### Scenario: 扩展和延迟场景 +- **WHEN** 运行 3,432 日 × 17 ETF 场景以及 `additional_delay_days=1` 场景 +- **THEN** 两者分别通过相同的正确性、确定性和性能门禁 + +``` + +## openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-archive-promotion/spec.md + +- Source: openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-archive-promotion/spec.md +- Lines: 1-60 +- SHA256: 5e43587054511b5a1f1b0093735b2744475aa3879c3c3a8522ba88d402934c30 + +```md +## ADDED Requirements + +### Requirement: 完成本地研究必须能够显式晋升为策略档案 +共享 CLI MUST 提供独立晋升动作,只接受已通过 `complete` 门禁且清单验证成功的 `.local/quant-research///`。目标 MUST 为 `joinquant/strategies//research/archives//`,并且 `analysis_id` 必须由调用者显式提供。 + +#### Scenario: 晋升一个完整运行 +- **WHEN** 调用者指定合法 strategy_id、run_id 和尚未使用的 analysis_id +- **THEN** 系统校验源运行并把完整档案原子发布到对应策略目录 + +#### Scenario: 尝试晋升失败运行 +- **WHEN** 源运行缺少 `complete` 状态、必需文件或有效摘要 +- **THEN** 系统拒绝晋升,不创建目标档案且不修改源运行 + +### Requirement: 策略档案必须对分析自包含 +每个档案 MUST 包含不可变清单、完整策略源码、运行配置、代码身份、四张核心事实表、全部声明策略扩展、性能证据、环境证据、行情快照身份和机械执行报告。调用者 MUST 能在不访问 `.local` 的情况下查询结果、比较参数、检查策略代码和重新生成机械执行报告。 + +#### Scenario: 删除运行缓存后复盘 +- **WHEN** 一个已晋升档案对应的 `.local` 运行缓存不可用 +- **THEN** analysis_data 仍能从档案清单打开全部已声明事实和策略扩展并生成分析视图 + +#### Scenario: 检查执行来源 +- **WHEN** 调用者检查档案的可重放证据 +- **THEN** 清单提供 Strategy Module、共享运行时、Git、第三方依赖、配置和 snapshot_id 的版本及摘要 + +#### Scenario: 机械执行报告保持事实边界 +- **WHEN** 共享流程为完成包生成 `report/` 内容 +- **THEN** 报告只包含可从包内复核的运行身份、参数、数据范围、成交与持仓统计、净值摘要、性能和完整性事实,不生成策略推荐、稳健性结论或实盘准入判断 + +### Requirement: 晋升不得重新计算研究结果 +晋升 MUST 只复制并校验完成运行中已经固化的字节。它不得加载 Strategy Module、调用 vectorbt、重新生成核心事实或扩展、重新序列化 Parquet、重新计算报告指标,且不得计入回测冷启动或预热耗时。 + +#### Scenario: 证明晋升没有执行引擎 +- **WHEN** 测试把策略和 vectorbt 调用替换为调用即失败后执行晋升 +- **THEN** 晋升仍成功,并且源、目标数据文件逐文件 SHA256 完全一致 + +#### Scenario: 共享行情保持单份 +- **WHEN** 晋升一个使用共享行情快照的运行 +- **THEN** 档案只复制快照身份、来源、范围、字段、价格口径和摘要,不复制共享 market-data.parquet + +### Requirement: 晋升必须不可变、幂等且冲突安全 +晋升 MUST 先在目标同级暂存目录复制全部文件并复核摘要,再以原子目录替换发布。相同 analysis_id 和相同内容 MUST 返回复用;相同 analysis_id 但内容不同 MUST 失败且不得覆盖、合并或生成隐式后缀。 + +#### Scenario: 重复晋升相同内容 +- **WHEN** 调用者以同一 analysis_id 再次晋升同一个完整运行 +- **THEN** 系统重新验证既有档案并返回幂等复用,不改写任何文件 + +#### Scenario: analysis_id 内容冲突 +- **WHEN** 目标 analysis_id 已绑定不同 run_id 或不同文件摘要 +- **THEN** 系统返回失败并保持既有档案和源运行不变 + +#### Scenario: 发布中途失败 +- **WHEN** 复制、摘要验证或原子发布任一步骤失败 +- **THEN** 系统删除暂存目录,不留下半成品目标,也不影响其他档案 + +### Requirement: 本地研究档案必须与聚宽正式运行隔离 +本地研究档案 MUST 只写入 `research/archives/`,其清单和报告 MUST 明确标记本地探索性 vectorbt 来源。正式聚宽回测和模拟交易 MUST 继续分别写入 `backtests/` 和 `simulations/`,分析层不得因目录位于同一策略下而混淆运行身份。 + +#### Scenario: 列出策略下的研究和正式回测 +- **WHEN** 查询入口同时发现 `research/archives/` 和 `backtests/` +- **THEN** 它分别返回本地研究与聚宽正式运行类型,并保留各自 backend 和来源身份 + +``` + +## openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-result-package/spec.md + +- Source: openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-result-package/spec.md +- Lines: 1-52 +- SHA256: 3454fb5e60c70901b99b11692441e86127c0838e780b3de95cc6e03f52eb9fb7 + +```md +## ADDED Requirements + +### Requirement: 共享结果包必须定义后端中立的核心事实 +系统 MUST 用版本化 Schema 定义结果、资金、持仓和订单四张核心事实表,并允许策略通过版本化扩展增加归因等证据。核心 writer 不得导入具体策略动作码,策略扩展不得改变核心表的字段含义。 + +#### Scenario: 海龟策略生成结果包 +- **WHEN** 海龟执行完成并提供策略轨迹 +- **THEN** 共享 writer 从 ExecutionLedger 生成四张核心表,并把海龟归因作为独立扩展写入清单 + +#### Scenario: 最小策略不提供归因 +- **WHEN** 第二个测试策略只提供核心执行账本而没有策略扩展 +- **THEN** 共享 writer 仍生成合法完整结果包,并明确声明没有对应扩展 + +### Requirement: 账本视图和事实数据只能物化一次 +ExecutionLedger MUST 对 orders、assets、cash 和 value 使用只读惰性缓存;共享 writer MUST 直接消费这些视图并只执行一次公共事实转换和一次最终 Parquet 固化。运行路径不得通过 JSON、字典或 Arrow 往返重新构造账本,也不得在多层重复复制完整矩阵。 + +#### Scenario: 多个消费者读取净值 +- **WHEN** 性能摘要、结果 writer 和策略归因都需要组合净值 +- **THEN** 它们复用同一个缓存视图,不分别调用 vectorbt 生成或复制完整净值数组 + +#### Scenario: 策略不需要某类轨迹 +- **WHEN** Strategy Module 未声明某个策略审计字段 +- **THEN** 执行底层不为该字段分配行数乘证券数的稠密矩阵 + +### Requirement: 结果包必须可验证并原子发布 +系统 MUST 为每个数据集记录路径、状态、行数、时间范围、Schema 版本和 SHA256,并在暂存目录完成写入、回读、跨表校验和逻辑摘要后原子发布。任何校验或发布失败 MUST 清理暂存目录且不得留下可被识别为完整的结果。 + +#### Scenario: 成功固化结果包 +- **WHEN** 四张核心表和全部声明扩展通过 Schema、摘要和跨表勾稽 +- **THEN** 系统原子发布数据文件和清单,并把运行标记为 `complete` + +#### Scenario: 回读摘要不一致 +- **WHEN** Parquet 回读后的逻辑摘要与内存事实不同 +- **THEN** 系统返回 `failed`,删除暂存结果且不覆盖既有完整运行 + +### Requirement: 共享分析必须读取本地和聚宽结果 +analysis_data MUST 通过后端中立清单读取新的本地结果包和既有聚宽归档,并为相同概念提供一致查询视图。任何来源差异、缺失数据集和公式版本 MUST 保留显式证据,不得伪造成相同来源或静默补全。 + +#### Scenario: 比较本地研究和聚宽回测 +- **WHEN** 分析流程同时打开一个本地 vectorbt 结果和一个聚宽正式回测 +- **THEN** 它通过统一视图查询共同事实,同时保留 backend、来源身份和数据集状态 + +#### Scenario: 使用 vectorbt 统计交叉校验 +- **WHEN** 分析流程调用 vectorbt returns 或 stats 复核收益指标 +- **THEN** 结果被标记为交叉校验,不静默替换现有 Alpha、Information Ratio、CVaR 或其他公式版本 + +### Requirement: 结果性能证据必须覆盖完整固化路径 +冷启动和预热计时 MUST 覆盖策略执行、核心事实、策略扩展、Parquet 写入、回读校验和逻辑摘要,并额外提供各阶段耗时。重构不得通过把工作移出计时范围来满足门禁。 + +#### Scenario: 抽取共享 writer +- **WHEN** 原策略 writer 迁移到共享结果包 +- **THEN** 重构前后基准都计入相同固化步骤,并能单独比较执行、事实转换、扩展和文件阶段 + +``` + +Full source files remain canonical. If a required heading or scenario is missing here, regenerate the handoff or read the source spec directly. Supporting files (proposal, design, tasks) are referenced by hash only. \ No newline at end of file diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/handoff/brainstorm-summary.md b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/handoff/brainstorm-summary.md new file mode 100644 index 0000000..9361990 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/handoff/brainstorm-summary.md @@ -0,0 +1,114 @@ +# Brainstorm Summary + +- Change: refactor-local-research-three-layer-architecture +- Date: 2026-07-17 + +## 确认的技术方案 + +采用 vectorbt 执行底层、Skill 通用能力层、唯一公开 Strategy Module 的三层单向架构: + +```text +Strategy Module + ↓ +Skill 通用能力层 + ↓ +vectorbt 执行底层 +``` + +- `.agents/skills/run-local-quant-research/SKILL.md` 保持薄编排,生产实现集中在 `scripts/research/local_quant_research/`。 +- 海龟策略只公开 `turtle_etf.strategy:MODULE`,Numba 内核、归因和延迟语义保留为私有文件。 +- vectorbt 是即时和延迟路径唯一账户账本;不使用 `from_signals()` 替代海龟自定义回调,不保留延迟 Python 手工账本。 +- 公共结果包保持后端中立,结果、资金、持仓、订单四张核心事实与策略扩展分离;共享分析同时读取本地结果和聚宽归档。 +- `.local` 完成结果直接形成 archive-ready package,`promote` 逐字节复制、复核 SHA256 并原子发布到 `research/archives//`。 +- 共享行情不复制,档案保存完整 snapshot identity、来源、范围、字段、价格口径和摘要。 +- `report/` 只包含机械执行报告,不生成策略推荐、稳健性结论或实盘准入判断。 + +Strategy Module Interface 收敛为: + +```python +class StrategyModule(Protocol): + descriptor: StrategyDescriptor + + def prepare(self, snapshot, config) -> PreparedStrategy: ... + def followup_program( + self, prepared, primary_run + ) -> OrderProgram | None: ... + def build_extensions( + self, prepared, execution_bundle + ) -> tuple[ResultExtension, ...]: ... +``` + +- `prepare` 校验策略配置并返回 `LedgerInput`、primary `OrderProgram` 和策略私有 context。 +- `followup_program` 是唯一可选第二阶段:普通策略返回 `None`;海龟延迟为正时根据 primary run 冻结计划并返回 delayed `OrderProgram`。共享 runner 最多执行两个阶段,不引入工作流 DSL 或无限循环。 +- `build_extensions` 只生成策略证据,不写文件。 +- `StrategyDescriptor` 声明 strategy id、contract version、源文件和扩展名,同时用于代码身份和档案源码快照。 + +vectorbt runtime 使用项目自有 Numba contracts: + +- `SegmentView`:row、group、cash、value、positions 和 valuation prices 等稳定只读账本视图。 +- `OrderBuffer`:按证券预分配 enabled、side、size、price、fixed fees、size granularity、allow partial 和 priority arrays。 +- `FillEvent`:status、side、size、price、fees、cash after 和 position after。 +- 共享 callback Adapter 把 vectorbt context 映射为项目结构,把 `OrderBuffer` 转换为 `nb.order_nb`,再把成交结果映射为 `FillEvent`。 +- runtime 按 `(priority class, original column)` 原地构造稳定 call sequence,不在逐日回调中新建数组。 +- callback binding 按固定 hook identity 在进程内缓存;同一策略的不同场景不得创建新 wrapper 身份。 + +执行结果为: + +```python +ExecutionBundle(primary: ExecutionRun, final: ExecutionRun, stages: tuple[str, ...]) +``` + +- 无延迟时 `primary is final`,不复制账本。 +- 有延迟时 primary vectorbt run 生成并冻结计划,final vectorbt run 执行延迟账本。 +- 核心事实只读取 final ledger;策略扩展可以读取两阶段轨迹。 +- `ExecutionLedger` 私有持有 Portfolio,orders/assets/cash/value 惰性计算一次并返回只读缓存。 + +共享文件形状: + +```text +scripts/research/local_quant_research/ +├── contracts.py +├── strategy_loader.py +├── vectorbt_runtime.py +├── scenario.py +├── performance.py +├── result_package.py +├── archive.py +├── runner.py +├── evidence.py +└── cli.py + +joinquant/strategies/strategy-003/research/turtle_etf/ +├── __init__.py +├── strategy.py +├── _kernel.py +├── _attribution.py +└── _delayed.py +``` + +`analysis_data` 继续拥有后端中立 manifest 读取、验证和查询视图;本地 writer 和 promote 保留在 `local_quant_research`。 + +## 关键取舍与风险 + +- 采用项目自有 OrderProgram 加共享 vectorbt 包装器,拒绝策略直接提供 vectorbt-native callback,也拒绝预生成完整订单矩阵。 +- 每个策略允许一个固定 Numba 包装特化,但不得按场景动态生成回调;先用第二个最小策略验证特化数量和冷热性能。 +- 延迟场景需要两次 vectorbt 执行:第一次生成和冻结计划,第二次维护最终延迟账本;两次都不得使用手工账户循环。 +- `ExecutionLedger` 实施惰性缓存和只读所有权移交,避免 orders/assets/cash/value 被多层重复生成或复制。 +- 新旧生产入口不并存;每次删除旧入口前必须先通过结果摘要和真实性能门禁。 +- `analysis_id` 是目标目录身份,不改写完成包内部 `run_id`,保证 archive-ready package 可逐字节复制。 +- 档案可独立分析但不 vendoring 共享运行时、第三方依赖或行情;通过 Git、依赖、运行时和 snapshot 摘要支持按锁定环境重放。 + +## 测试策略 + +- Characterization:冻结即时、17 ETF 和延迟场景的逐笔成交、费用、现金、持仓、净值、策略状态和逻辑摘要。 +- Contract:第二个最小策略通过同一 Strategy Module Interface、vectorbt runtime、结果包和停止状态执行。 +- TDD:每个新 Interface、结果包、晋升和迁移任务先观察失败测试,再写最小实现。 +- E2E:从固定共享 CLI 完整运行 Strategy Module → vectorbt → 结果包 → promote。 +- 性能:主场景使用 3 个冷启动新进程和 5 次预热,扩展到 17 ETF 并覆盖延迟场景;结果证据零差异,时间、峰值内存和体积不超过 5% 测量噪声。 +- 原子性:晋升时把引擎调用设为调用即失败,验证逐文件 SHA256、幂等复用、内容冲突和中途失败清理。 +- 错误映射:输入和身份缺失为 `evidence_insufficient`;执行、摘要、性能、结果或清理异常为 `failed`;正常订单拒绝保留为执行事实。 + +## Spec Patch + +- 将 `local-research-archive-promotion` 中的“研究报告”收敛为“机械执行报告”。 +- 新增验收场景:机械执行报告只包含可从完成包复核的运行身份、参数、数据范围、成交/持仓统计、净值摘要、性能和完整性事实,不得生成策略推荐、稳健性结论或实盘准入判断。 diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json new file mode 100644 index 0000000..bcf9611 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json @@ -0,0 +1,40 @@ +{ + "change": "refactor-local-research-three-layer-architecture", + "phase": "design", + "mode": "beta", + "canonical_spec": "openspec", + "generated_by": "comet-handoff.sh", + "context_hash": "9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313", + "files": [ + { + "path": "openspec/changes/refactor-local-research-three-layer-architecture/proposal.md", + "sha256": "d1572168930634731ac7d0794d728c6675718b33b17fb223aab4a484e2d9a3f1", + "role": "supporting" + }, + { + "path": "openspec/changes/refactor-local-research-three-layer-architecture/design.md", + "sha256": "b30b85223a9d9b6445dd8896f5af5d99390818df3d55797feceee5a48386061e", + "role": "supporting" + }, + { + "path": "openspec/changes/refactor-local-research-three-layer-architecture/tasks.md", + "sha256": "fe435a57fee8a18c82692445b58e22cc2dfe4027f6fb7e26c90048aa020ada95", + "role": "supporting" + }, + { + "path": "openspec/changes/refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md", + "sha256": "4943b07f8f5449c33d44f9625cb5280e555514232b392daac2be0a0edc490bae", + "role": "spec" + }, + { + "path": "openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-archive-promotion/spec.md", + "sha256": "5e43587054511b5a1f1b0093735b2744475aa3879c3c3a8522ba88d402934c30", + "role": "spec" + }, + { + "path": "openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-result-package/spec.md", + "sha256": "3454fb5e60c70901b99b11692441e86127c0838e780b3de95cc6e03f52eb9fb7", + "role": "spec" + } + ] +} diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.md b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.md new file mode 100644 index 0000000..97bc8a3 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.md @@ -0,0 +1,242 @@ +# Comet Spec Context + +- Change: refactor-local-research-three-layer-architecture +- Phase: design +- Mode: beta +- Context hash: 9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313 + +Generated-by: comet-handoff.sh + +OpenSpec remains the canonical capability spec. This beta context pack verbatim-projects spec files and references supporting artifacts by hash, not an agent-authored summary. + +## Source References + +- Source: openspec/changes/refactor-local-research-three-layer-architecture/proposal.md +- SHA256: d1572168930634731ac7d0794d728c6675718b33b17fb223aab4a484e2d9a3f1 +- Source: openspec/changes/refactor-local-research-three-layer-architecture/design.md +- SHA256: b30b85223a9d9b6445dd8896f5af5d99390818df3d55797feceee5a48386061e +- Source: openspec/changes/refactor-local-research-three-layer-architecture/tasks.md +- SHA256: fe435a57fee8a18c82692445b58e22cc2dfe4027f6fb7e26c90048aa020ada95 +- Source: openspec/changes/refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md +- SHA256: 4943b07f8f5449c33d44f9625cb5280e555514232b392daac2be0a0edc490bae +- Source: openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-archive-promotion/spec.md +- SHA256: 5e43587054511b5a1f1b0093735b2744475aa3879c3c3a8522ba88d402934c30 +- Source: openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-result-package/spec.md +- SHA256: 3454fb5e60c70901b99b11692441e86127c0838e780b3de95cc6e03f52eb9fb7 + +## Acceptance Projection + +## openspec/changes/refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md + +- Source: openspec/changes/refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md +- Lines: 1-71 +- SHA256: 4943b07f8f5449c33d44f9625cb5280e555514232b392daac2be0a0edc490bae + +```md +## ADDED Requirements + +### Requirement: 本地研究必须使用三层单向架构 +系统 MUST 将本地研究组织为 Strategy Module、Skill 通用能力层和 vectorbt 执行底层,并且依赖只能从 Strategy Module 经共享 contracts 进入 Skill 能力和执行底层。具体策略不得导入 vectorbt 上下文、订单枚举、记录结构或原始 Portfolio;共享能力不得导入具体策略的私有实现。 + +#### Scenario: 加载海龟策略 +- **WHEN** 共享入口加载海龟研究项目 +- **THEN** 它只通过 `turtle_etf.strategy:MODULE` 使用策略,并通过共享执行 Interface 调用 vectorbt,不直接导入海龟私有文件 + +#### Scenario: 第二个策略复用共享能力 +- **WHEN** 仓库提供一个实现同一 Strategy Module Interface 的最小测试策略 +- **THEN** 共享 CLI、性能门禁、结果包和停止状态无需修改即可运行该策略 + +### Requirement: vectorbt 必须成为唯一账户账本 +系统 MUST 使用 vectorbt `Portfolio.from_order_func()` 处理即时和延迟场景的订单接受、拒绝、部分成交、费用、共享现金、持仓和组合估值。策略只保存决策所需的单位、止损、冻结计划、原因和归因轨迹,不得维护第二套成交、现金、持仓、费用或净值账本。 + +#### Scenario: 即时执行 +- **WHEN** 策略在同一交易日生成并执行目标订单 +- **THEN** 实际成交、费用、现金、持仓和净值全部来自单一 vectorbt Portfolio + +#### Scenario: 延迟执行 +- **WHEN** 场景声明正数 `additional_delay_days` +- **THEN** 策略冻结原计划并在执行日复核可交易性和机械约束,但实际账户变化仍由第二个 `from_order_func()` 程序完成 + +#### Scenario: vectorbt 拒绝订单 +- **WHEN** vectorbt 因现金、持仓或订单约束拒绝一笔请求 +- **THEN** 系统把拒绝状态和策略原因保存为执行事实,不把正常拒单误报为框架失败 + +### Requirement: 每个策略必须只有一个公开 Strategy Module +每个本地研究策略 MUST 暴露一个版本化公开 Strategy Module,负责配置校验、输入准备、订单程序和策略结果扩展。策略 MAY 使用私有实现文件,但外部调用和测试不得把这些私有文件当作稳定 Interface。 + +#### Scenario: 策略内部重组 +- **WHEN** 策略维护者在不改变公开 Module 和行为的前提下拆分或合并私有 Numba 内核文件 +- **THEN** 共享层、其他策略和 Interface 级测试无需修改 + +#### Scenario: 禁止动态回调 +- **WHEN** Strategy Module 根据场景参数构造订单程序 +- **THEN** 它使用模块级固定 Numba 函数和稳定数组类型,不为每个配置动态创建闭包或 lambda + +### Requirement: 所有项目必须通过固定共享入口运行 +系统 MUST 从项目 `.venv` 调用共享本地研究 CLI。项目配置 MUST 声明仓库内 strategy root、module 和 symbol,不得声明策略专属 project entry、任意命令、系统 Python 或隐式依赖安装。 + +#### Scenario: 运行合法项目配置 +- **WHEN** 配置声明有效的仓库内 Strategy Module、单一行情快照和一个场景 +- **THEN** runner 在清理后的子进程中调用固定共享 CLI 并冻结全部声明输入 + +#### Scenario: 配置尝试执行任意命令 +- **WHEN** 配置包含旧 `command`、策略专属 `project_entry`、仓库外模块路径或安装命令 +- **THEN** 系统在启动项目进程前拒绝配置并返回 `evidence_insufficient` + +### Requirement: 单场景停止状态必须保持固定 +共享运行 MUST 每次只接受一个场景,并且只返回 `complete`、`evidence_insufficient` 或 `failed`。完整运行 MUST 返回 `next_action=return_to_caller`;Skill 不得循环多个场景、解释策略字段或自动给出研究推荐。 + +#### Scenario: 输入证据缺失 +- **WHEN** 策略身份、配置、行情快照、范围或必需字段缺失 +- **THEN** 系统返回 `evidence_insufficient`,不猜测替代输入且不执行策略 + +#### Scenario: 执行或证据失败 +- **WHEN** vectorbt 执行异常、输出缺失、摘要冲突、临时清理失败或性能超限 +- **THEN** 系统返回 `failed` 并保留紧凑失败证据,不发布完整运行 + +### Requirement: 重构必须保持结果一致且性能不实质退化 +系统 MUST 在固定环境比较重构前后的真实规模、扩展资产和延迟场景。Schema、行数、成交、费用、现金、持仓、净值和逻辑摘要 MUST 完全一致;冷启动、预热、峰值内存和结果体积的中位数变化不得超过 5% 测量噪声,并继续满足冷、热各 180 秒绝对门禁。 + +#### Scenario: 真实规模性能回归 +- **WHEN** 在相同机器和输入上运行 3,432 日 × 11 ETF 主场景,使用三个冷启动新进程和五次预热采样 +- **THEN** 结果证据零差异,时间、内存和结果体积均不超过基线 5% 噪声带 + +#### Scenario: 扩展和延迟场景 +- **WHEN** 运行 3,432 日 × 17 ETF 场景以及 `additional_delay_days=1` 场景 +- **THEN** 两者分别通过相同的正确性、确定性和性能门禁 + +``` + +## openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-archive-promotion/spec.md + +- Source: openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-archive-promotion/spec.md +- Lines: 1-60 +- SHA256: 5e43587054511b5a1f1b0093735b2744475aa3879c3c3a8522ba88d402934c30 + +```md +## ADDED Requirements + +### Requirement: 完成本地研究必须能够显式晋升为策略档案 +共享 CLI MUST 提供独立晋升动作,只接受已通过 `complete` 门禁且清单验证成功的 `.local/quant-research///`。目标 MUST 为 `joinquant/strategies//research/archives//`,并且 `analysis_id` 必须由调用者显式提供。 + +#### Scenario: 晋升一个完整运行 +- **WHEN** 调用者指定合法 strategy_id、run_id 和尚未使用的 analysis_id +- **THEN** 系统校验源运行并把完整档案原子发布到对应策略目录 + +#### Scenario: 尝试晋升失败运行 +- **WHEN** 源运行缺少 `complete` 状态、必需文件或有效摘要 +- **THEN** 系统拒绝晋升,不创建目标档案且不修改源运行 + +### Requirement: 策略档案必须对分析自包含 +每个档案 MUST 包含不可变清单、完整策略源码、运行配置、代码身份、四张核心事实表、全部声明策略扩展、性能证据、环境证据、行情快照身份和机械执行报告。调用者 MUST 能在不访问 `.local` 的情况下查询结果、比较参数、检查策略代码和重新生成机械执行报告。 + +#### Scenario: 删除运行缓存后复盘 +- **WHEN** 一个已晋升档案对应的 `.local` 运行缓存不可用 +- **THEN** analysis_data 仍能从档案清单打开全部已声明事实和策略扩展并生成分析视图 + +#### Scenario: 检查执行来源 +- **WHEN** 调用者检查档案的可重放证据 +- **THEN** 清单提供 Strategy Module、共享运行时、Git、第三方依赖、配置和 snapshot_id 的版本及摘要 + +#### Scenario: 机械执行报告保持事实边界 +- **WHEN** 共享流程为完成包生成 `report/` 内容 +- **THEN** 报告只包含可从包内复核的运行身份、参数、数据范围、成交与持仓统计、净值摘要、性能和完整性事实,不生成策略推荐、稳健性结论或实盘准入判断 + +### Requirement: 晋升不得重新计算研究结果 +晋升 MUST 只复制并校验完成运行中已经固化的字节。它不得加载 Strategy Module、调用 vectorbt、重新生成核心事实或扩展、重新序列化 Parquet、重新计算报告指标,且不得计入回测冷启动或预热耗时。 + +#### Scenario: 证明晋升没有执行引擎 +- **WHEN** 测试把策略和 vectorbt 调用替换为调用即失败后执行晋升 +- **THEN** 晋升仍成功,并且源、目标数据文件逐文件 SHA256 完全一致 + +#### Scenario: 共享行情保持单份 +- **WHEN** 晋升一个使用共享行情快照的运行 +- **THEN** 档案只复制快照身份、来源、范围、字段、价格口径和摘要,不复制共享 market-data.parquet + +### Requirement: 晋升必须不可变、幂等且冲突安全 +晋升 MUST 先在目标同级暂存目录复制全部文件并复核摘要,再以原子目录替换发布。相同 analysis_id 和相同内容 MUST 返回复用;相同 analysis_id 但内容不同 MUST 失败且不得覆盖、合并或生成隐式后缀。 + +#### Scenario: 重复晋升相同内容 +- **WHEN** 调用者以同一 analysis_id 再次晋升同一个完整运行 +- **THEN** 系统重新验证既有档案并返回幂等复用,不改写任何文件 + +#### Scenario: analysis_id 内容冲突 +- **WHEN** 目标 analysis_id 已绑定不同 run_id 或不同文件摘要 +- **THEN** 系统返回失败并保持既有档案和源运行不变 + +#### Scenario: 发布中途失败 +- **WHEN** 复制、摘要验证或原子发布任一步骤失败 +- **THEN** 系统删除暂存目录,不留下半成品目标,也不影响其他档案 + +### Requirement: 本地研究档案必须与聚宽正式运行隔离 +本地研究档案 MUST 只写入 `research/archives/`,其清单和报告 MUST 明确标记本地探索性 vectorbt 来源。正式聚宽回测和模拟交易 MUST 继续分别写入 `backtests/` 和 `simulations/`,分析层不得因目录位于同一策略下而混淆运行身份。 + +#### Scenario: 列出策略下的研究和正式回测 +- **WHEN** 查询入口同时发现 `research/archives/` 和 `backtests/` +- **THEN** 它分别返回本地研究与聚宽正式运行类型,并保留各自 backend 和来源身份 + +``` + +## openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-result-package/spec.md + +- Source: openspec/changes/refactor-local-research-three-layer-architecture/specs/local-research-result-package/spec.md +- Lines: 1-52 +- SHA256: 3454fb5e60c70901b99b11692441e86127c0838e780b3de95cc6e03f52eb9fb7 + +```md +## ADDED Requirements + +### Requirement: 共享结果包必须定义后端中立的核心事实 +系统 MUST 用版本化 Schema 定义结果、资金、持仓和订单四张核心事实表,并允许策略通过版本化扩展增加归因等证据。核心 writer 不得导入具体策略动作码,策略扩展不得改变核心表的字段含义。 + +#### Scenario: 海龟策略生成结果包 +- **WHEN** 海龟执行完成并提供策略轨迹 +- **THEN** 共享 writer 从 ExecutionLedger 生成四张核心表,并把海龟归因作为独立扩展写入清单 + +#### Scenario: 最小策略不提供归因 +- **WHEN** 第二个测试策略只提供核心执行账本而没有策略扩展 +- **THEN** 共享 writer 仍生成合法完整结果包,并明确声明没有对应扩展 + +### Requirement: 账本视图和事实数据只能物化一次 +ExecutionLedger MUST 对 orders、assets、cash 和 value 使用只读惰性缓存;共享 writer MUST 直接消费这些视图并只执行一次公共事实转换和一次最终 Parquet 固化。运行路径不得通过 JSON、字典或 Arrow 往返重新构造账本,也不得在多层重复复制完整矩阵。 + +#### Scenario: 多个消费者读取净值 +- **WHEN** 性能摘要、结果 writer 和策略归因都需要组合净值 +- **THEN** 它们复用同一个缓存视图,不分别调用 vectorbt 生成或复制完整净值数组 + +#### Scenario: 策略不需要某类轨迹 +- **WHEN** Strategy Module 未声明某个策略审计字段 +- **THEN** 执行底层不为该字段分配行数乘证券数的稠密矩阵 + +### Requirement: 结果包必须可验证并原子发布 +系统 MUST 为每个数据集记录路径、状态、行数、时间范围、Schema 版本和 SHA256,并在暂存目录完成写入、回读、跨表校验和逻辑摘要后原子发布。任何校验或发布失败 MUST 清理暂存目录且不得留下可被识别为完整的结果。 + +#### Scenario: 成功固化结果包 +- **WHEN** 四张核心表和全部声明扩展通过 Schema、摘要和跨表勾稽 +- **THEN** 系统原子发布数据文件和清单,并把运行标记为 `complete` + +#### Scenario: 回读摘要不一致 +- **WHEN** Parquet 回读后的逻辑摘要与内存事实不同 +- **THEN** 系统返回 `failed`,删除暂存结果且不覆盖既有完整运行 + +### Requirement: 共享分析必须读取本地和聚宽结果 +analysis_data MUST 通过后端中立清单读取新的本地结果包和既有聚宽归档,并为相同概念提供一致查询视图。任何来源差异、缺失数据集和公式版本 MUST 保留显式证据,不得伪造成相同来源或静默补全。 + +#### Scenario: 比较本地研究和聚宽回测 +- **WHEN** 分析流程同时打开一个本地 vectorbt 结果和一个聚宽正式回测 +- **THEN** 它通过统一视图查询共同事实,同时保留 backend、来源身份和数据集状态 + +#### Scenario: 使用 vectorbt 统计交叉校验 +- **WHEN** 分析流程调用 vectorbt returns 或 stats 复核收益指标 +- **THEN** 结果被标记为交叉校验,不静默替换现有 Alpha、Information Ratio、CVaR 或其他公式版本 + +### Requirement: 结果性能证据必须覆盖完整固化路径 +冷启动和预热计时 MUST 覆盖策略执行、核心事实、策略扩展、Parquet 写入、回读校验和逻辑摘要,并额外提供各阶段耗时。重构不得通过把工作移出计时范围来满足门禁。 + +#### Scenario: 抽取共享 writer +- **WHEN** 原策略 writer 迁移到共享结果包 +- **THEN** 重构前后基准都计入相同固化步骤,并能单独比较执行、事实转换、扩展和文件阶段 + +``` + +Full source files remain canonical. If a required heading or scenario is missing here, regenerate the handoff or read the source spec directly. Supporting files (proposal, design, tasks) are referenced by hash only. \ No newline at end of file diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/run-state.json b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/run-state.json new file mode 100644 index 0000000..b10e3a6 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/run-state.json @@ -0,0 +1,17 @@ +{ + "runId": "e6b0e4a7-959a-48c5-8fd1-de37aacca3ed", + "skill": "comet-classic", + "skillVersion": "1", + "skillHash": "e211dc93b4bbc218965022f6d64f54a26e2240bd45b8bf6e1321ceb96775a3a3", + "orchestration": "deterministic", + "currentStep": "completed", + "iteration": 12, + "pending": null, + "pendingRef": ".comet/pending-action.json", + "trajectoryRef": ".comet/trajectory.jsonl", + "contextRef": ".comet/context.md", + "artifactsRef": ".comet/artifacts.json", + "checkpointRef": ".comet/checkpoint.json", + "status": "completed", + "retries": {} +} \ No newline at end of file diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/skill-snapshots/e211dc93b4bbc218965022f6d64f54a26e2240bd45b8bf6e1321ceb96775a3a3/package.json b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/skill-snapshots/e211dc93b4bbc218965022f6d64f54a26e2240bd45b8bf6e1321ceb96775a3a3/package.json new file mode 100644 index 0000000..64a9a31 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/skill-snapshots/e211dc93b4bbc218965022f6d64f54a26e2240bd45b8bf6e1321ceb96775a3a3/package.json @@ -0,0 +1,320 @@ +{ + "definition": { + "agents": [], + "apiVersion": "comet/v1alpha1", + "goal": { + "inputs": [ + { + "description": "已校验且与 Run 投影一致的 ClassicState", + "name": "classic-state", + "required": true + }, + { + "description": "Classic Evidence 收集器生成的结构化证据", + "name": "evidence", + "required": true + } + ], + "outputs": [ + { + "description": "原子同步后的 Classic 与 Run 状态", + "name": "run-state", + "required": true + } + ], + "statement": "在不改变用户命令面的前提下稳定推进或恢复经典 Comet Run", + "success": [ + "旧字段与 Run 字段保持一致", + "每个步骤只调用声明的公开 Comet Skill", + "completed 状态通过 completion eval" + ] + }, + "kind": "Skill", + "metadata": { + "description": "Comet 经典 full、hotfix、tweak 工作流的内部兼容编排", + "name": "comet-classic", + "version": "1" + }, + "orchestration": { + "entry": "full.open", + "mode": "deterministic", + "steps": [ + { + "action": { + "ref": "comet-open", + "type": "invoke_skill" + }, + "id": "full.open", + "next": "full.design.handoff" + }, + { + "action": { + "ref": "comet-design", + "type": "invoke_skill" + }, + "id": "full.design.handoff", + "next": "full.design.document" + }, + { + "action": { + "ref": "comet-design", + "type": "invoke_skill" + }, + "id": "full.design.document", + "next": "full.build.plan" + }, + { + "action": { + "ref": "comet-build", + "type": "invoke_skill" + }, + "id": "full.build.plan", + "next": "full.build.plan-ready" + }, + { + "action": { + "ref": "comet-build", + "type": "invoke_skill" + }, + "id": "full.build.plan-ready", + "next": "full.build.configure" + }, + { + "action": { + "ref": "comet-build", + "type": "invoke_skill" + }, + "id": "full.build.configure", + "next": "full.build.execute" + }, + { + "action": { + "ref": "comet-build", + "type": "invoke_skill" + }, + "id": "full.build.execute", + "next": "full.build.complete" + }, + { + "action": { + "ref": "comet-build", + "type": "invoke_skill" + }, + "id": "full.build.complete", + "next": "full.verify.run" + }, + { + "action": { + "ref": "comet-build", + "type": "invoke_skill" + }, + "id": "full.build.fix", + "next": "full.build.execute" + }, + { + "action": { + "ref": "comet-verify", + "type": "invoke_skill" + }, + "id": "full.verify.run", + "next": "full.verify.branch" + }, + { + "action": { + "ref": "comet-verify", + "type": "invoke_skill" + }, + "id": "full.verify.branch", + "next": "full.archive.confirm" + }, + { + "action": { + "ref": "comet-archive", + "type": "invoke_skill" + }, + "id": "full.archive.confirm", + "next": "full.archive.execute" + }, + { + "action": { + "ref": "comet-archive", + "type": "invoke_skill" + }, + "id": "full.archive.execute", + "next": "completed" + }, + { + "action": { + "ref": "comet-hotfix", + "type": "invoke_skill" + }, + "id": "hotfix.open", + "next": "hotfix.build.execute" + }, + { + "action": { + "ref": "comet-build", + "type": "invoke_skill" + }, + "id": "hotfix.build.execute", + "next": "hotfix.build.complete" + }, + { + "action": { + "ref": "comet-build", + "type": "invoke_skill" + }, + "id": "hotfix.build.complete", + "next": "hotfix.verify.run" + }, + { + "action": { + "ref": "comet-verify", + "type": "invoke_skill" + }, + "id": "hotfix.verify.run", + "next": "hotfix.verify.branch" + }, + { + "action": { + "ref": "comet-verify", + "type": "invoke_skill" + }, + "id": "hotfix.verify.branch", + "next": "hotfix.archive.confirm" + }, + { + "action": { + "ref": "comet-archive", + "type": "invoke_skill" + }, + "id": "hotfix.archive.confirm", + "next": "hotfix.archive.execute" + }, + { + "action": { + "ref": "comet-archive", + "type": "invoke_skill" + }, + "id": "hotfix.archive.execute", + "next": "completed" + }, + { + "action": { + "ref": "comet-tweak", + "type": "invoke_skill" + }, + "id": "tweak.open", + "next": "tweak.build.execute" + }, + { + "action": { + "ref": "comet-build", + "type": "invoke_skill" + }, + "id": "tweak.build.execute", + "next": "tweak.build.complete" + }, + { + "action": { + "ref": "comet-build", + "type": "invoke_skill" + }, + "id": "tweak.build.complete", + "next": "tweak.verify.run" + }, + { + "action": { + "ref": "comet-verify", + "type": "invoke_skill" + }, + "id": "tweak.verify.run", + "next": "tweak.verify.branch" + }, + { + "action": { + "ref": "comet-verify", + "type": "invoke_skill" + }, + "id": "tweak.verify.branch", + "next": "tweak.archive.confirm" + }, + { + "action": { + "ref": "comet-archive", + "type": "invoke_skill" + }, + "id": "tweak.archive.confirm", + "next": "tweak.archive.execute" + }, + { + "action": { + "ref": "comet-archive", + "type": "invoke_skill" + }, + "id": "tweak.archive.execute", + "next": "completed" + }, + { + "action": { + "type": "checkpoint" + }, + "completionEvals": [ + "classic-completed" + ], + "id": "completed" + } + ] + }, + "skills": [ + { + "id": "comet-open" + }, + { + "id": "comet-design" + }, + { + "id": "comet-build" + }, + { + "id": "comet-verify" + }, + { + "id": "comet-archive" + }, + { + "id": "comet-hotfix" + }, + { + "id": "comet-tweak" + } + ], + "tools": [] + }, + "evals": [ + { + "equals": "completed", + "field": "status", + "id": "classic-completed", + "scope": "completion", + "type": "state_equals" + } + ], + "guardrails": { + "allowedAgents": [], + "allowedSkills": [ + "comet-open", + "comet-design", + "comet-build", + "comet-verify", + "comet-archive", + "comet-hotfix", + "comet-tweak" + ], + "allowedTools": [], + "confirmationRequiredFor": [], + "maxIterations": 500, + "maxRetriesPerAction": 3 + }, + "packageKind": "runtime" +} diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/skill-snapshots/e211dc93b4bbc218965022f6d64f54a26e2240bd45b8bf6e1321ceb96775a3a3/sha256 b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/skill-snapshots/e211dc93b4bbc218965022f6d64f54a26e2240bd45b8bf6e1321ceb96775a3a3/sha256 new file mode 100644 index 0000000..c4f67c0 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/skill-snapshots/e211dc93b4bbc218965022f6d64f54a26e2240bd45b8bf6e1321ceb96775a3a3/sha256 @@ -0,0 +1 @@ +e211dc93b4bbc218965022f6d64f54a26e2240bd45b8bf6e1321ceb96775a3a3 diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/state-events.jsonl b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/state-events.jsonl new file mode 100644 index 0000000..7c233c3 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/state-events.jsonl @@ -0,0 +1,7 @@ +{"schemaVersion":1,"timestamp":"2026-07-16T16:16:31.898Z","change":"refactor-local-research-three-layer-architecture","event":"open-complete","source":"comet-guard","from":{"workflow":"full","language":"zh-CN","phase":"open","contextCompression":"beta","buildMode":null,"buildPause":null,"subagentDispatch":null,"tddMode":null,"reviewMode":"standard","isolation":null,"verifyMode":null,"autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":null,"plan":null,"verifyResult":"pending","verificationReport":null,"branchStatus":"pending","createdAt":"2026-07-16","verifiedAt":null,"archived":false,"directOverride":null,"handoffContext":null,"handoffHash":null,"classicProfile":"full","classicMigration":1},"to":{"workflow":"full","language":"zh-CN","phase":"design","contextCompression":"beta","buildMode":null,"buildPause":null,"subagentDispatch":null,"tddMode":null,"reviewMode":"standard","isolation":null,"verifyMode":null,"autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":null,"plan":null,"verifyResult":"pending","verificationReport":null,"branchStatus":"pending","createdAt":"2026-07-16","verifiedAt":null,"archived":false,"directOverride":null,"handoffContext":null,"handoffHash":null,"classicProfile":"full","classicMigration":1},"effects":[{"field":"phase","from":"open","to":"design"}]} +{"schemaVersion":1,"timestamp":"2026-07-16T16:35:35.934Z","change":"refactor-local-research-three-layer-architecture","event":"design-complete","source":"comet-guard","from":{"workflow":"full","language":"zh-CN","phase":"design","contextCompression":"beta","buildMode":null,"buildPause":null,"subagentDispatch":null,"tddMode":null,"reviewMode":"standard","isolation":null,"verifyMode":null,"autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":"docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md","plan":null,"verifyResult":"pending","verificationReport":null,"branchStatus":"pending","createdAt":"2026-07-16","verifiedAt":null,"archived":false,"directOverride":null,"handoffContext":"openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json","handoffHash":"9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313","classicProfile":"full","classicMigration":1},"to":{"workflow":"full","language":"zh-CN","phase":"build","contextCompression":"beta","buildMode":null,"buildPause":null,"subagentDispatch":null,"tddMode":null,"reviewMode":"standard","isolation":null,"verifyMode":null,"autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":"docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md","plan":null,"verifyResult":"pending","verificationReport":null,"branchStatus":"pending","createdAt":"2026-07-16","verifiedAt":null,"archived":false,"directOverride":null,"handoffContext":"openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json","handoffHash":"9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313","classicProfile":"full","classicMigration":1},"effects":[{"field":"phase","from":"design","to":"build"}]} +{"schemaVersion":1,"timestamp":"2026-07-17T18:37:53.995Z","change":"refactor-local-research-three-layer-architecture","event":"build-complete","source":"comet-guard","from":{"workflow":"full","language":"zh-CN","phase":"build","contextCompression":"beta","buildMode":"executing-plans","buildPause":null,"subagentDispatch":null,"tddMode":"tdd","reviewMode":"thorough","isolation":"branch","verifyMode":"full","autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":"docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md","plan":"docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md","verifyResult":"pending","verificationReport":null,"branchStatus":"pending","createdAt":"2026-07-16","verifiedAt":null,"archived":false,"directOverride":null,"handoffContext":"openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json","handoffHash":"9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313","classicProfile":"full","classicMigration":1},"to":{"workflow":"full","language":"zh-CN","phase":"verify","contextCompression":"beta","buildMode":"executing-plans","buildPause":null,"subagentDispatch":null,"tddMode":"tdd","reviewMode":"thorough","isolation":"branch","verifyMode":"full","autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":"docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md","plan":"docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md","verifyResult":"pending","verificationReport":null,"branchStatus":"pending","createdAt":"2026-07-16","verifiedAt":null,"archived":false,"directOverride":null,"handoffContext":"openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json","handoffHash":"9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313","classicProfile":"full","classicMigration":1},"effects":[{"field":"phase","from":"build","to":"verify"}]} +{"schemaVersion":1,"timestamp":"2026-07-17T19:01:32.857Z","change":"refactor-local-research-three-layer-architecture","event":"verify-fail","source":"comet-state","from":{"workflow":"full","language":"zh-CN","phase":"verify","contextCompression":"beta","buildMode":"executing-plans","buildPause":null,"subagentDispatch":null,"tddMode":"tdd","reviewMode":"thorough","isolation":"branch","verifyMode":"full","autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":"docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md","plan":"docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md","verifyResult":"pending","verificationReport":"docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md","branchStatus":"handled","createdAt":"2026-07-16","verifiedAt":null,"archived":false,"directOverride":null,"handoffContext":"openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json","handoffHash":"9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313","classicProfile":"full","classicMigration":1},"to":{"workflow":"full","language":"zh-CN","phase":"build","contextCompression":"beta","buildMode":"executing-plans","buildPause":null,"subagentDispatch":null,"tddMode":"tdd","reviewMode":"thorough","isolation":"branch","verifyMode":"full","autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":"docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md","plan":"docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md","verifyResult":"fail","verificationReport":"docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md","branchStatus":"handled","createdAt":"2026-07-16","verifiedAt":null,"archived":false,"directOverride":null,"handoffContext":"openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json","handoffHash":"9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313","classicProfile":"full","classicMigration":1},"effects":[{"field":"verifyResult","from":"pending","to":"fail"},{"field":"phase","from":"verify","to":"build"}]} +{"schemaVersion":1,"timestamp":"2026-07-17T19:05:26.180Z","change":"refactor-local-research-three-layer-architecture","event":"build-complete","source":"comet-guard","from":{"workflow":"full","language":"zh-CN","phase":"build","contextCompression":"beta","buildMode":"executing-plans","buildPause":null,"subagentDispatch":null,"tddMode":"tdd","reviewMode":"thorough","isolation":"branch","verifyMode":"full","autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":"docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md","plan":"docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md","verifyResult":"fail","verificationReport":"docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md","branchStatus":"handled","createdAt":"2026-07-16","verifiedAt":null,"archived":false,"directOverride":null,"handoffContext":"openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json","handoffHash":"9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313","classicProfile":"full","classicMigration":1},"to":{"workflow":"full","language":"zh-CN","phase":"verify","contextCompression":"beta","buildMode":"executing-plans","buildPause":null,"subagentDispatch":null,"tddMode":"tdd","reviewMode":"thorough","isolation":"branch","verifyMode":"full","autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":"docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md","plan":"docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md","verifyResult":"pending","verificationReport":"docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md","branchStatus":"handled","createdAt":"2026-07-16","verifiedAt":null,"archived":false,"directOverride":null,"handoffContext":"openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json","handoffHash":"9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313","classicProfile":"full","classicMigration":1},"effects":[{"field":"phase","from":"build","to":"verify"},{"field":"verifyResult","from":"fail","to":"pending"}]} +{"schemaVersion":1,"timestamp":"2026-07-17T19:11:02.446Z","change":"refactor-local-research-three-layer-architecture","event":"verify-pass","source":"comet-guard","from":{"workflow":"full","language":"zh-CN","phase":"verify","contextCompression":"beta","buildMode":"executing-plans","buildPause":null,"subagentDispatch":null,"tddMode":"tdd","reviewMode":"thorough","isolation":"branch","verifyMode":"full","autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":"docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md","plan":"docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md","verifyResult":"pending","verificationReport":"docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md","branchStatus":"handled","createdAt":"2026-07-16","verifiedAt":null,"archived":false,"directOverride":null,"handoffContext":"openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json","handoffHash":"9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313","classicProfile":"full","classicMigration":1},"to":{"workflow":"full","language":"zh-CN","phase":"archive","contextCompression":"beta","buildMode":"executing-plans","buildPause":null,"subagentDispatch":null,"tddMode":"tdd","reviewMode":"thorough","isolation":"branch","verifyMode":"full","autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":"docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md","plan":"docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md","verifyResult":"pass","verificationReport":"docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md","branchStatus":"handled","createdAt":"2026-07-16","verifiedAt":"2026-07-17","archived":false,"directOverride":null,"handoffContext":"openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json","handoffHash":"9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313","classicProfile":"full","classicMigration":1},"effects":[{"field":"verifyResult","from":"pending","to":"pass"},{"field":"phase","from":"verify","to":"archive"},{"field":"verifiedAt","from":null,"to":"2026-07-17"}]} +{"schemaVersion":1,"timestamp":"2026-07-17T19:13:06.582Z","change":"2026-07-17-refactor-local-research-three-layer-architecture","event":"archived","source":"comet-archive","from":{"workflow":"full","language":"zh-CN","phase":"archive","contextCompression":"beta","buildMode":"executing-plans","buildPause":null,"subagentDispatch":null,"tddMode":"tdd","reviewMode":"thorough","isolation":"branch","verifyMode":"full","autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":"docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md","plan":"docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md","verifyResult":"pass","verificationReport":"docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md","branchStatus":"handled","createdAt":"2026-07-16","verifiedAt":"2026-07-17","archived":false,"directOverride":null,"handoffContext":"openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json","handoffHash":"9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313","classicProfile":"full","classicMigration":1},"to":{"workflow":"full","language":"zh-CN","phase":"archive","contextCompression":"beta","buildMode":"executing-plans","buildPause":null,"subagentDispatch":null,"tddMode":"tdd","reviewMode":"thorough","isolation":"branch","verifyMode":"full","autoTransition":true,"baseRef":"ea195d36501848d3ba677b1e97c1aba667da7e1e","designDoc":"docs/superpowers/specs/2026-07-17-local-research-three-layer-architecture-design.md","plan":"docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md","verifyResult":"pass","verificationReport":"docs/superpowers/reports/2026-07-17-local-research-three-layer-architecture-verify.md","branchStatus":"handled","createdAt":"2026-07-16","verifiedAt":"2026-07-17","archived":true,"directOverride":null,"handoffContext":"openspec/changes/refactor-local-research-three-layer-architecture/.comet/handoff/spec-context.json","handoffHash":"9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313","classicProfile":"full","classicMigration":1},"effects":[{"field":"archived","from":false,"to":true}]} diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/subagent-progress.md b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/subagent-progress.md new file mode 100644 index 0000000..fe2391e --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/subagent-progress.md @@ -0,0 +1,37 @@ +# Subagent Progress + +- Change: `refactor-local-research-three-layer-architecture` +- Plan: `docs/superpowers/plans/2026-07-17-local-research-three-layer-architecture.md` +- Review mode: `thorough` +- TDD mode: `tdd` +- Execution mode: `executing-plans`(从 Task 7 起由主 Agent 按计划实施;独立审查仍按 thorough 模式执行) +- Current plan task: `Task 6 complete; next Task 7: 统一策略源码身份并简化加载(主 Agent executing-plans)` +- OpenSpec mappings: + - `6.1 先编写失败测试,限定 ResultExtension 只接受扁平 string/bool/int64/float64、用 Arrow null 表示缺失并在冷/热比较前拒绝 NaN 与其他类型` + - `6.2 使用 PyArrow Table.validate、精确 Schema 和 Table.equals 比较扩展,核心事实继续使用 NumPy 摘要,删除递归 Arrow 类型解码和任意类型逻辑哈希` + - `6.3 将内部 writer 收敛为一次物化、一次回读事实链,公开 validator 保持纯磁盘读取,删除 preloaded_* 参数和 provisional/final 双包路径` + - `6.4 运行结果包、runner、双策略和公开 CLI 回归,确认越界扩展固定返回 failed/result_contract_failed` +- Stage: `task-complete` +- Task base: `8b953fe579a728d965f5a320cc814d516c45f28c` +- Implementer: `/root/task6_writer_simplification` +- Implementation commit: `2921397ceb7534f569e18b5559ffdb05770aca42` +- Implementation evidence: `RED confirmed unsupported list extension and preloaded validation path; focused GREEN 153 passed; complete Task 6 regression 173 passed` +- Review package: `.superpowers/sdd/task-6-review-package.md` +- Review round 1: `FINDINGS; Critical 0, Important 2, Minor 1; see .superpowers/sdd/task-6-review.md` +- Fix round 1 commit: `181ab51c7e928d813d4e26b4fccd3fc885b226d7` +- Fix round 1 evidence: `disk validator 2 passed; performance boundaries 2 passed; complex Arrow boundaries 8 passed; runner boundaries 2 passed; complete Task 6 regression 181 passed in 80.58s` +- Re-review package: `.superpowers/sdd/task-6-rereview-package.md` +- Re-review round 1: `FINDINGS; Critical 0, Important 1, Minor 0; Task 6 code boundaries passed, but the formal performance wording incorrectly required a self-referential final publish duration inside the immutable package` +- Coordinator resolution: `keep package prefinalization_seconds and daily returned-writer gate; move parent atomic publish and post-validation timing to Task 12 external release evidence; explicitly forbid double package, second metadata write, and sidecar manifest` +- Planning validation: `OpenSpec strict PASS; Comet build recovery PASS with build_mode=executing-plans` +- Performance-boundary planning review: `4 Important, 1 Warning in Task 12 protocol: incomparable old baseline, mixed engine/CLI sampling, unbound external evidence, incomplete commit/RED commands` +- Planning round 2 resolution: `engine 3-cold/5-warm and three independent full-CLI cold samples are separate; Task 10 captures old same-boundary baseline before deletion; external samples bind protocol/scenario/PID/run/package/baseline/environment; Task 12 RED and commit lists include runner and E2E files` +- Planning re-review: `1 Important, 1 Warning: canonical Task 10.4 omitted the full-CLI baseline contract and Task 12 RED filtering could skip publication tests` +- Planning round 3 resolution: `Task 10.4 now requires the full-CLI baseline and its contract test before Task 11 deletion; Task 12 RED runs the four declared test files without a name filter` +- Planning re-review 2: `1 Important: Task 10 baseline samples did not explicitly persist sample_type/reused/post-publish validation and other binding fields` +- Planning round 4 resolution: `canonical 10.4 and plan enumerate top-level protocol/environment identity plus every per-sample binding field; Task 12 computes baseline_sha256 from fixture bytes instead of creating another self-referential field` +- Final planning re-review: `PASS; findings=[]; Task 6 can close and remaining performance implementation belongs to Task 10/12` +- Coordinator completion verification: `2026-07-17 Task 6 five-file regression: 181 passed in 90.59s; OpenSpec strict PASS; git diff --check PASS` +- Previous task: `Task 5 complete; implementation 90bd254, fixes 29de3ca/85cac8c/7756849; final Arrow/writer feedback reclassified by user-approved planning revision into Task 6` +- Coordinator verification: `2026-07-17 fresh seven-file regression: 193 passed in 83.80s; revised Planning Review PASS at 6300c05; OpenSpec 5.1-5.4 and plan Task 5 checked off` +- Scope exclusions: `JoinQuant archive sync cursor bug tracked by #17 and scheduled-workspace isolation tracked by #11; neither belongs to this change` diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/trajectory.jsonl b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/trajectory.jsonl new file mode 100644 index 0000000..0635f23 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.comet/trajectory.jsonl @@ -0,0 +1,14 @@ +{"sequence":1,"timestamp":"2026-07-16T16:16:31.857Z","type":"run_started","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"skill":"comet-classic","skillVersion":"1","skillHash":"e211dc93b4bbc218965022f6d64f54a26e2240bd45b8bf6e1321ceb96775a3a3"}} +{"sequence":2,"timestamp":"2026-07-16T16:16:31.857Z","type":"state_migrated","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"kind":"classic","migrationVersion":1,"profile":"full","source":"pre-migration"}} +{"sequence":3,"timestamp":"2026-07-16T16:16:31.898Z","type":"state_transitioned","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"kind":"classic","fromStep":"full.open","toStep":"full.design.handoff","event":"open-complete","phase":"open","source":"comet-guard"}} +{"sequence":4,"timestamp":"2026-07-16T16:16:46.918Z","type":"state_transitioned","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"kind":"classic-handoff","fromStep":"full.design.handoff","toStep":"full.design.document","actionId":"classic-handoff:52de4d75f884144d548ecba2d4f1bd8329403493032061b23ac881ff7130112d"}} +{"sequence":5,"timestamp":"2026-07-16T16:34:57.053Z","type":"state_transitioned","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"kind":"classic-config","field":"handoff_context","fromStep":"full.design.document","toStep":"full.design.handoff"}} +{"sequence":6,"timestamp":"2026-07-16T16:35:35.660Z","type":"state_transitioned","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"kind":"classic-handoff","fromStep":"full.design.handoff","toStep":"full.design.document","actionId":"classic-handoff:9019bbd9b4e6090d0e7cd8bac63f5d774014ef46fe382907bc9acf1812428313"}} +{"sequence":7,"timestamp":"2026-07-16T16:35:35.933Z","type":"state_transitioned","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"kind":"classic","fromStep":"full.design.document","toStep":"full.build.plan","event":"design-complete","phase":"design","source":"comet-guard"}} +{"sequence":8,"timestamp":"2026-07-16T17:02:01.538Z","type":"state_transitioned","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"kind":"classic-config","field":"plan","fromStep":"full.build.plan","toStep":"full.build.configure"}} +{"sequence":9,"timestamp":"2026-07-17T18:36:55.294Z","type":"state_transitioned","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"kind":"classic-config","field":"verify_mode","fromStep":"full.build.configure","toStep":"full.build.complete"}} +{"sequence":10,"timestamp":"2026-07-17T18:37:53.994Z","type":"state_transitioned","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"kind":"classic","fromStep":"full.build.complete","toStep":"full.verify.run","event":"build-complete","phase":"build","source":"comet-guard"}} +{"sequence":11,"timestamp":"2026-07-17T19:01:32.856Z","type":"state_transitioned","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"kind":"classic","fromStep":"full.verify.run","toStep":"full.build.fix","event":"verify-fail","source":"comet-state"}} +{"sequence":12,"timestamp":"2026-07-17T19:05:26.179Z","type":"state_transitioned","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"kind":"classic","fromStep":"full.build.fix","toStep":"full.verify.run","event":"build-complete","phase":"build","source":"comet-guard"}} +{"sequence":13,"timestamp":"2026-07-17T19:11:02.445Z","type":"state_transitioned","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"kind":"classic","fromStep":"full.verify.run","toStep":"full.archive.confirm","event":"verify-pass","phase":"verify","source":"comet-guard"}} +{"sequence":14,"timestamp":"2026-07-17T19:13:06.573Z","type":"state_transitioned","runId":"e6b0e4a7-959a-48c5-8fd1-de37aacca3ed","data":{"kind":"classic","fromStep":"full.archive.confirm","toStep":"completed","actionId":"classic-archive:refactor-local-research-three-layer-architecture","archiveDirectory":"openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture","event":"archived","source":"comet-archive"}} diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.openspec.yaml b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.openspec.yaml new file mode 100644 index 0000000..cd2ce7e --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-16 diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/design.md b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/design.md new file mode 100644 index 0000000..4914a9f --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/design.md @@ -0,0 +1,163 @@ +## Context + +当前本地研究已经具有共享行情快照、通用 runner、后端中立分析视图和一套可运行的海龟 ETF vectorbt 实现,但职责分布不一致: + +- `.agents/skills/run-local-quant-research/SKILL.md` 已声明 Skill 只做薄编排,实际项目仍需提供自己的 CLI、单场景、性能和结果固化代码。 +- `turtle_etf` 目录包含指标、输入、vectorbt 回调、即时执行、延迟执行、性能、结果适配和 CLI 九类公开文件;共享能力与策略语义相互导入。 +- 即时路径正确使用 `Portfolio.from_order_func()`,延迟路径却先用 Python 手工维护现金、持仓、费用和权益,再通过 `Portfolio.from_orders()` 重放,形成双账本。 +- 标准结果完成后只发布到 `.local/quant-research`,策略目录没有可以脱离运行缓存直接复盘的不可变研究档案。 + +本变更由一个 OpenSpec change 完成三层重构和档案晋升。虽然两部分可独立描述,但它们共享同一 Strategy Module、结果包和清单契约;分开实施会引入中间兼容格式和双生产路径,因此用户明确选择单 change 原子迁移。 + +既有 `build-turtle-etf-local-research-workflow` 设计中“vectorbt 只属于 strategy-003”和“延迟使用 from_orders”的实现约束由本变更显式取代;聚宽正式回测和模拟交易仍只在云端运行。 + +## Goals / Non-Goals + +**Goals:** + +- 建立 vectorbt 执行底层、Skill 通用能力层、Strategy Module 三层单向依赖。 +- 让 vectorbt 成为即时和延迟路径唯一账户账本,不重复维护成交、现金、持仓、费用或净值。 +- 让所有策略复用同一单场景、性能、结果包和归档实现,并以第二个最小策略证明接缝真实存在。 +- 让每个策略只有一个公开入口,同时允许私有实现文件保持 Numba 编译内核和归因的局部性。 +- 在不重新回测和不重复复制共享行情的前提下,生成策略目录内可独立查询、比较和生成报告的不可变研究档案。 +- 保持现有策略语义、指标口径、结果摘要和性能不发生实质退化。 + +**Non-Goals:** + +- 不修改海龟突破、退出、加仓、风险单位、共同止损、全量再分配和延迟冻结规则。 +- 不把本地研究结果声明为聚宽正式回测或模拟交易结果。 +- 不修改聚宽定时归档、归档格式、同步逻辑、`backtests/` 或 `simulations/` 数据模型,也不在本 change(变更)实现定时任务运行目录隔离;该运维问题另行处理。 +- 不复制 vectorbt、共享 Skill 实现、Python 环境或共享行情文件到每个档案。 +- 不引入新的回测框架、策略 DSL、Rust 执行内核或多场景批处理器。 +- 不支持完整 Arrow 类型体系,不为 dictionary/nested/union/run-end encoded(字典/嵌套/联合/游程编码)编写规范化解释器。 +- 不把本地同一用户仓库当作敌对文件系统,不实现扫描后并发替换防御状态机。 +- 不保留旧 CLI、旧结果 writer 或旧延迟账本作为兼容生产路径。 + +## Decisions + +### 1. 三层依赖只允许自上而下 + +目标结构为: + +```text +Strategy Module + ↓ +Skill 通用能力层 + ↓ +vectorbt 执行底层 +``` + +vectorbt 底层是唯一允许导入 `Portfolio`、vectorbt 上下文、订单枚举和记录结构的 Module。Skill 层同时依赖稳定的策略 Interface 和底层执行 Interface,但不得导入具体策略内部实现。策略只依赖仓库自有 contracts,不得操作 vectorbt 内部数组。 + +备选方案是保留每个策略自己的 vectorbt 接线,仅抽公共文件 writer;这仍会让新策略复制执行、性能和错误处理,无法获得足够 Leverage,因此拒绝。 + +### 2. vectorbt 底层提供小而深的执行 Interface + +共享底层提供等价于以下形状的 Interface: + +```python +run_vectorbt(ledger_input: LedgerInput, program: OrderProgram) -> ExecutionRun +``` + +`LedgerInput` 保存日期、证券、估值价格、初始现金和现金分组;`OrderProgram` 保存项目自有的 Numba 决策函数、只读策略输入、可变策略状态和审计轨迹;`ExecutionRun` 通过只读 `ExecutionLedger` 暴露订单、持仓、现金、净值、交易和收益视图。原始 `Portfolio` 不向策略或结果层公开。 + +即时和延迟程序都由同一个 `from_order_func()` Adapter 执行。延迟策略保留信号冻结、执行日可交易性复核、现金/持仓机械截断、确定性优先级和到期证据,但删除手工账户循环。 + +备选方案 `from_signals()` 无法完整表达共享现金、跨资产再分配、共同止损和实际成交后状态推进;`from_orders()` 只能重放已确定订单,不能成为延迟执行的唯一账本,因此拒绝。 + +### 3. Skill 是公开入口,共享代码保留在仓库 scripts + +`.agents/skills/run-local-quant-research/SKILL.md` 继续只描述一次通用调用和停止状态。生产 Implementation 位于 `scripts/research/local_quant_research/`,统一提供 contracts、策略加载、共享 CLI、单场景、vectorbt runtime、性能、结果包和晋升。 + +项目运行配置不再接受策略专属 `project_entry` 和任意 `command`,改为声明仓库内 `strategy_root`、`strategy_module` 和 `strategy_symbol`。runner 始终使用项目 `.venv` 启动固定共享 CLI,并继续冻结输入、清理环境和隔离子进程。 + +把生产代码放进 Skill 目录会把发现说明、执行依赖和测试绑定在 Agent 资产中,也与现有 Skill 契约冲突,因此拒绝。 + +### 4. 每个策略只有一个公开 Strategy Module + +海龟策略公开 `turtle_etf.strategy:MODULE`,实现可按以下私有文件组织: + +```text +turtle_etf/ +├── __init__.py +├── strategy.py +├── _kernel.py +├── _attribution.py +└── _delayed.py +``` + +公开 Module 负责配置校验、输入准备、订单程序构造和归因扩展。私有 `_kernel.py` 保存模块级固定 Numba 函数,禁止根据配置动态创建闭包或 lambda,避免增加编译特化。`_delayed.py` 先保留海龟专属的冻结语义;出现第二个真实复用实例前不把它强行泛化。 + +父进程先在受限策略根内解析 module,再以其顶层包目录(单文件 module 则为文件所在目录)为源码边界,静态发现并排序全部普通 `.py` 文件。该集合同时驱动运行身份和档案 `code/`,不得越界扫描 `research/archives/` 或相邻目录,`StrategyDescriptor` 不再声明第二份 `source_files`。每次 `_execute` 是只加载一个策略的全新子进程,因此子进程只需把冻结策略根放到 `sys.path` 首位并调用标准 `importlib.import_module()`;不建立 UUID 命名空间、全局导入锁或手工模块缓存生命周期。 + +Strategy Module 是仓库内受版本管理和代码审查的可信代码。当前运行边界使用受限源码路径、冻结输入、清理环境、全新子进程和超时;不安装 Python audit hook(审计钩子),也不把加载器扩展成不可靠的应用层沙箱。第三方不可信策略执行不属于本 capability(能力)。 + +把全部策略逻辑合并为一个物理文件会形成约 150 KB 大文件、扩大导入和代码身份变化面,因此拒绝。 + +### 5. 公共结果包后端中立,策略证据作为扩展注入 + +共享结果包定义结果、资金、持仓和订单四张核心表,归因等策略证据通过版本化 `ResultExtension` 注入。公共 writer 负责 Schema、跨表校验、Parquet、逻辑摘要、清单、回读验证、暂存清理和原子发布;不得导入海龟动作码。 + +`ResultExtension.table` 只接受扁平 `string/bool/int64/float64` 列;浮点缺失使用 Arrow null,NaN 和任何其他类型在冷/热比较前固定返回 `result_contract_failed`。共享层使用 PyArrow `Table.validate(full=True)` 与 `Table.equals(check_metadata=True)`,完成文件使用 SHA256;不实现递归 Arrow 类型解码或任意类型逻辑摘要。 + +writer 只物化一次、回读一次,并直接复用该次回读事实完成内部校验、报告和最终清单。公开 validator 只从文件读取,供复用、晋升和外部查询;writer 不通过 `preloaded_*` 参数调用它,也不写 provisional/final 两套完整包。 + +`ExecutionLedger` 的 orders、assets、cash 和 value 按需生成一次并缓存;公共事实只物化一次。策略轨迹只分配其声明字段,不用全量 vectorbt logs 替代轻量拒单和归因状态。 + +共享分析继续读取本地结果和聚宽归档。vectorbt returns/stats 可以作为独立交叉校验,但不得静默替换现有 Alpha、Information Ratio、CVaR 等公式版本。 + +### 6. 自包含档案通过完成后独立晋升产生 + +共享 CLI 增加显式 `promote` 动作。它只接受已经通过 `complete` 门禁的不可变 `.local` 运行,目标为: + +```text +joinquant/strategies//research/archives// +├── manifest.json +├── code/ +├── config/ +├── data/ +├── evidence/ +└── report/ +``` + +档案包含完整策略源码、运行配置、代码身份、四张核心表、策略扩展、性能与环境证据、行情快照身份和报告。共享运行时代码、第三方依赖和行情文件只通过版本、Git 提交、文件摘要和 snapshot_id 锁定,不逐档案复制。 + +晋升先扫描源树并拒绝链接、目录连接和非普通文件,使用标准 `shutil.copy2` 复制到目标同级暂存目录,逐文件复核 SHA256 后使用 `os.replace` 原子发布。目标不存在时发布;目标内容完全相同时返回复用;同一 analysis_id 内容不同时失败且不覆盖。晋升不得调用策略、vectorbt、事实转换或 Parquet writer,也不防御同一用户在扫描后敌对替换源树。 + +### 7. 正确性自动校验,性能数据人工确认 + +重构前在固定环境建立 3,432 日 × 11 ETF 主场景基线,并覆盖 17 ETF 扩展场景和 `additional_delay_days=1` 延迟场景。重构后 Schema、行数、成交、净值和逻辑摘要必须完全一致。 + +性能不设置自动相对门禁。历史 baseline 与本次三个场景的时间、内存和 Parquet payload(列式数据载荷)只作为验证报告中的观测数据,由用户根据实际波动人工确认;共享 CLI 不提供专用发布性能命令,也不实现 Windows 进程采样器。日常 cold/warm(冷/热)确定性检查和各 180 秒超时继续保留。正确性迁移只保留历史 v1 fixture 作为来源证据,新基线直接摘要公开 `ExecutionLedger + ResultExtension` 并绑定 v1 fixture SHA256,不保留旧结果适配器。 + +`max_logs=0` 和回调缓冲预分配属于候选优化,只有结果摘要完全一致且真实基准不退化时才保留。保留 vectorbt 默认持仓记录并直接复用其 trades/positions accessor;没有实测瓶颈时不关闭记录,也不在共享层重建交易或持仓。 + +## Risks / Trade-offs + +- [Numba 高阶回调可能产生额外特化或冷启动编译] → 使用模块级固定函数和稳定数组类型;禁止动态回调;分别测量导入、准备、编译和预热阶段。 +- [抽取公共账本时可能无意复制大型矩阵] → `ExecutionLedger` 实行单次惰性缓存和只读所有权移交;测试验证共享内存或唯一明确复制。 +- [延迟路径迁移改变最低佣金、部分成交或冻结语义] → 先建立现有行为特征测试,再逐笔比较计划、成交、费用、现金、单位、共同止损和原因码。 +- [一次 change 范围较大] → 按可独立验收的迁移任务逐步替换,每一步保持共享 CLI 可运行;不在生产配置中同时开放新旧入口。 +- [旧 `.local` 结果不能写入新档案] → 旧结果保持只读;只有通过新清单校验的运行可晋升,不进行隐式升级或伪造缺失证据。 +- [策略源码自包含但运行环境未 vendoring] → 档案明确区分“可独立分析”与“按锁定环境可重放”,清单保存运行时、依赖、Git 和行情快照身份。 + +## Migration Plan + +1. 冻结当前主场景、扩展场景和延迟场景的结果、时间、内存及体积基线,增加分阶段计时。 +2. 定义 Strategy Module 与执行 contracts,并用一个最小测试策略证明第二个 Adapter。 +3. 抽取共享结果包,让现有海龟执行结果通过新 writer 和统一分析视图测试。 +4. 完成 archive-ready package 和 promote 端到端测试。 +5. 抽取共享单场景、性能和 CLI,仅让测试配置走新入口,生产配置留到单次切换任务。 +6. 收窄扩展表并把 writer 收敛为单次回读事实链,删除自定义递归 Arrow 解释器和内部 validator 双路径。 +7. 统一静态源码身份并改用标准 importlib,删除 descriptor 源码清单、UUID 命名空间、v2 audit hook 沙箱和重复测试夹具内容。 +8. 把晋升收敛为扫描、标准复制、摘要复核和原子发布,删除敌对并发树状态机。 +9. 建立通用 vectorbt 唯一账本 runtime,只用通用 primary/follow-up fixture 证明共享接线。 +10. 在海龟 Strategy Module 内同时迁移即时与延迟 OrderProgram,逐笔一致后删除手工账本。 +11. 单次切换生产配置,更新测试、代码身份、Skill 文档和旧 OpenSpec 约束,删除所有旧生产文件、共享 `adapter_guard.py` 和 runner v1 command 路径。 +12. 运行共享 CLI → vectorbt → 标准结果包 → 自包含档案的完整端到端回归和真实规模性能验证。 + +回滚以任务提交为单位进行:在旧生产入口尚未删除前,可回退最近迁移提交;删除旧入口后只允许整体回退到最后一个已验证提交,不提供运行时双路径开关。 + +## Open Questions + +无待用户决策的开放问题。`OrderProgram`、`ExecutionLedger` 和 Numba 上下文的精确字段将在 Superpowers Design Doc 中根据现有回调需要收敛,但不得改变本设计确定的依赖方向、唯一账本、唯一公开策略 Module、结果零差异和归档不重算约束。 diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/proposal.md b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/proposal.md new file mode 100644 index 0000000..88535bf --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/proposal.md @@ -0,0 +1,37 @@ +## Why + +当前本地研究把通用运行、vectorbt 执行、结果固化和海龟策略语义混在策略目录中,导致策略改动经常伴随框架开发,延迟执行还重复维护账户账本;同时 `.local` 中的完成结果不能在策略目录内独立复盘。现在需要用一个统一变更建立可复用的三层架构,并让完成研究形成不可变、自包含的策略档案,同时保持结果口径和性能不退化。 + +## What Changes + +- 建立三层单向依赖:vectorbt 执行底层负责唯一账本,Skill 通用能力层负责运行、性能、结果包和归档,每个策略只暴露一个公开 Strategy Module。 +- 将即时和延迟执行统一到 `Portfolio.from_order_func()`,删除延迟路径中手工维护的现金、持仓、费用和净值账本。 +- 把单场景编排、行情身份校验、冷热确定性检查、标准事实表、Parquet 固化、清单和原子发布从具体策略目录迁入共享能力层。 +- 让共享结果层继续兼容本地 vectorbt 结果和聚宽归档,策略专属归因通过扩展注入,避免共享层反向依赖海龟语义。 +- 将策略扩展收窄为扁平 `string/bool/int64/float64` Arrow 表,复用 PyArrow 的校验/判等与 Parquet SHA256,不实现完整 Arrow 类型解释器或自定义递归逻辑哈希。 +- 在迁移 vectorbt 账本前收敛已实现共享层:结果 writer 单次回读、策略源码单一静态身份、标准 importlib 加载和标准文件复制;删除只服务敌对并发或重复权威来源的分支。 +- 增加完成运行的独立晋升命令,把完整代码、配置、标准结果、归因、性能证据和报告原样发布到 `joinquant/strategies//research/archives//`;共享行情只保存快照身份和摘要,不重复复制。 +- 建立真实规模、扩展资产和延迟场景的结果一致性、时间、内存与同逻辑 Parquet 数据载荷体积门禁;固定自包含证据开销单独报告。 +- **BREAKING**:项目运行配置不再声明策略专属 `project_entry` 和任意 `command`,改为声明仓库内 Strategy Module;所有项目通过固定共享 CLI 执行。 +- **BREAKING**:删除海龟策略旧的公开 CLI、引擎、性能和结果适配入口,不保留双生产路径;外部只允许加载 `turtle_etf.strategy:MODULE`。 +- **BREAKING**:本地结果清单和代码身份升级为共享运行时、策略内核与归档可验证的新版本,不兼容写入旧格式。 + +## Capabilities + +### New Capabilities + +- `local-quant-research-runtime`: 定义 vectorbt 唯一账本、共享单场景运行、Strategy Module 接缝、停止状态、确定性和非退化性能要求。 +- `local-research-result-package`: 定义后端中立的标准事实表、策略扩展、清单、摘要、原子固化和统一分析读取要求。 +- `local-research-archive-promotion`: 定义完成运行向策略目录不可变自包含档案的显式晋升、校验、幂等和冲突处理要求。 + +### Modified Capabilities + +无。聚宽正式回测和模拟交易归档继续遵守既有 `joinquant-archive-sync` 规格,本地研究档案不得进入其正式运行目录或冒充云端结果。 + +## Impact + +- 共享能力:`.agents/skills/run-local-quant-research/`、`scripts/research/local_quant_research/`、`scripts/research/analysis_data/`。 +- 策略能力:`joinquant/strategies/strategy-003/research/` 下的配置、代码身份和 `turtle_etf` 实现。 +- 契约与数据:项目运行配置、本地结果清单、标准事实表扩展、策略档案清单和性能基线。 +- 测试与验证:现有本地研究测试、第二个最小策略 Adapter、共享 CLI 到结果包再到档案晋升的完整端到端回归。 +- 依赖保持现有 vectorbt 1.1.0、Numba、NumPy、Pandas、PyArrow 和 DuckDB,不新增回测框架、DSL 或 Rust 执行内核。 diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md new file mode 100644 index 0000000..52bef6f --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/specs/local-quant-research-runtime/spec.md @@ -0,0 +1,92 @@ +## ADDED Requirements + +### Requirement: 本地研究必须使用三层单向架构 +系统 MUST 将本地研究组织为 Strategy Module、Skill 通用能力层和 vectorbt 执行底层,并且依赖只能从 Strategy Module 经共享 contracts 进入 Skill 能力和执行底层。具体策略不得导入 vectorbt 上下文、订单枚举、记录结构或原始 Portfolio;共享能力不得导入具体策略的私有实现。 + +#### Scenario: 加载海龟策略 +- **WHEN** 共享入口加载海龟研究项目 +- **THEN** 它只通过 `turtle_etf.strategy:MODULE` 使用策略,并通过共享执行 Interface 调用 vectorbt,不直接导入海龟私有文件 + +#### Scenario: 第二个策略复用共享能力 +- **WHEN** 仓库提供一个实现同一 Strategy Module Interface 的最小测试策略 +- **THEN** 共享 CLI、日常超时检查、结果包和停止状态无需修改即可运行该策略 + +### Requirement: vectorbt 必须成为唯一账户账本 +系统 MUST 使用 vectorbt `Portfolio.from_order_func()` 处理即时和延迟场景的订单接受、拒绝、部分成交、费用、共享现金、持仓和组合估值。策略只保存决策所需的单位、止损、冻结计划、原因和归因轨迹,不得维护第二套成交、现金、持仓、费用或净值账本。 + +#### Scenario: 即时执行 +- **WHEN** 策略在同一交易日生成并执行目标订单 +- **THEN** 实际成交、费用、现金、持仓和净值全部来自单一 vectorbt Portfolio + +#### Scenario: 延迟执行 +- **WHEN** 场景声明正数 `additional_delay_days` +- **THEN** 策略冻结原计划并在执行日复核可交易性和机械约束,但实际账户变化仍由第二个 `from_order_func()` 程序完成 + +#### Scenario: vectorbt 拒绝订单 +- **WHEN** vectorbt 因现金、持仓或订单约束拒绝一笔请求 +- **THEN** 系统把拒绝状态和策略原因保存为执行事实,不把正常拒单误报为框架失败 + +#### Scenario: 复用 vectorbt 持仓与交易访问器 +- **WHEN** 共享账本需要 trades、positions 或 returns 视图 +- **THEN** 它直接惰性缓存 vectorbt 的现成访问器结果,并默认保留所需记录;没有等价性和性能证据时不得关闭记录后自行重建 + +### Requirement: 每个策略必须只有一个公开 Strategy Module +每个本地研究策略 MUST 暴露一个版本化公开 Strategy Module,负责配置校验、输入准备、订单程序和策略结果扩展。策略 MAY 使用私有实现文件,但外部调用和测试不得把这些私有文件当作稳定 Interface。 + +#### Scenario: 策略内部重组 +- **WHEN** 策略维护者在不改变公开 Module 和行为的前提下拆分或合并私有 Numba 内核文件 +- **THEN** 共享层、其他策略和 Interface 级测试无需修改 + +#### Scenario: 禁止动态回调 +- **WHEN** Strategy Module 根据场景参数构造订单程序 +- **THEN** 它使用模块级固定 Numba 函数和稳定数组类型,不为每个配置动态创建闭包或 lambda + +#### Scenario: 源码身份只有一个来源 +- **WHEN** 父进程冻结并加载一个仓库内策略 +- **THEN** 它从声明 module 定位当前顶层包目录并静态发现、排序其中全部普通 `.py` 文件,同一集合同时驱动运行身份和档案 `code/`;它不扫描 `research/archives/` 或相邻目录,descriptor 不再声明第二份源码清单 + +#### Scenario: 子进程加载策略 +- **WHEN** 全新 `_execute` 子进程加载唯一 Strategy Module +- **THEN** 它把冻结策略根放到 `sys.path` 首位并使用标准 `importlib.import_module()`,不建立 UUID 私有命名空间、全局导入锁或手工模块缓存生命周期 + +### Requirement: 仓库内策略加载不得模拟敌对插件沙箱 +Strategy Module MUST 是仓库内受版本管理和代码审查的可信代码。共享运行 MUST 使用受限源码路径、冻结输入、清理环境、全新子进程和超时限制执行策略;它 MUST NOT 安装 Python audit hook(审计钩子)或自行模拟操作系统沙箱。本 capability(能力)不得声称支持第三方不可信策略。 + +#### Scenario: 执行仓库内策略 +- **WHEN** 固定 `_execute` 子进程加载已通过源码边界校验的 Strategy Module +- **THEN** 它直接通过标准导入执行,不复制或注入 `adapter_guard`,并继续受输入冻结、环境清理和超时限制 + +### Requirement: 所有项目必须通过固定共享入口运行 +系统 MUST 从项目 `.venv` 调用共享本地研究 CLI。项目配置 MUST 声明仓库内 strategy root、module 和 symbol,不得声明策略专属 project entry、任意命令、系统 Python 或隐式依赖安装。 + +#### Scenario: 运行合法项目配置 +- **WHEN** 配置声明有效的仓库内 Strategy Module、单一行情快照和一个场景 +- **THEN** runner 在清理后的子进程中调用固定共享 CLI 并冻结全部声明输入 + +#### Scenario: 配置尝试执行任意命令 +- **WHEN** 配置包含旧 `command`、策略专属 `project_entry`、仓库外模块路径或安装命令 +- **THEN** 系统在启动项目进程前拒绝配置并返回 `evidence_insufficient` + +### Requirement: 单场景停止状态必须保持固定 +共享运行 MUST 每次只接受一个场景,并且只返回 `complete`、`evidence_insufficient` 或 `failed`。完整运行 MUST 返回 `next_action=return_to_caller`;Skill 不得循环多个场景、解释策略字段或自动给出研究推荐。 + +#### Scenario: 输入证据缺失 +- **WHEN** 策略身份、配置、行情快照、范围或必需字段缺失 +- **THEN** 系统返回 `evidence_insufficient`,不猜测替代输入且不执行策略 + +#### Scenario: 执行或证据失败 +- **WHEN** vectorbt 执行异常、输出缺失、摘要冲突、临时清理失败或性能超限 +- **THEN** 系统返回 `failed` 并保留紧凑失败证据,不发布完整运行 + +### Requirement: 重构必须保持结果一致并提供性能观测 +系统 MUST 在真实规模、扩展资产和延迟场景校验重构前后结果。Schema、行数、成交、费用、现金、持仓、净值和逻辑摘要 MUST 完全一致;时间、内存和同逻辑核心/扩展 Parquet 数据载荷体积 MUST 在验证报告中列出,由用户人工确认,不设置自动相对门禁。日常 cold/warm 执行 MUST 继续满足各 180 秒超时。 + +正确性基线 MUST 直接从公开 `ExecutionLedger` 和 `ResultExtension` 生成,并用历史 v1 fixture 的 SHA256 记录迁移来源;系统不得为继续执行旧基线测试而保留第二套核心结果适配器。 + +#### Scenario: 真实规模性能复核 +- **WHEN** 在相同机器和输入上运行 3,432 日 × 11 ETF 主场景 +- **THEN** 结果证据零差异,验证报告列出与历史观测的时间、内存和 Parquet 数据载荷体积差异,并记录用户确认 + +#### Scenario: 扩展和延迟场景 +- **WHEN** 运行 3,432 日 × 17 ETF 场景以及 `additional_delay_days=1` 场景 +- **THEN** 两者分别通过相同的正确性与确定性校验,性能差异一并交由用户确认 diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/specs/local-research-archive-promotion/spec.md b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/specs/local-research-archive-promotion/spec.md new file mode 100644 index 0000000..3bf7b2c --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/specs/local-research-archive-promotion/spec.md @@ -0,0 +1,66 @@ +## ADDED Requirements + +### Requirement: 完成本地研究必须能够显式晋升为策略档案 +共享 CLI MUST 提供独立晋升动作,只接受已通过 `complete` 门禁且清单验证成功的 `.local/quant-research///`。目标 MUST 为 `joinquant/strategies//research/archives//`,并且 `analysis_id` 必须由调用者显式提供。 + +#### Scenario: 晋升一个完整运行 +- **WHEN** 调用者指定合法 strategy_id、run_id 和尚未使用的 analysis_id +- **THEN** 系统校验源运行并把完整档案原子发布到对应策略目录 + +#### Scenario: 尝试晋升失败运行 +- **WHEN** 源运行缺少 `complete` 状态、必需文件或有效摘要 +- **THEN** 系统拒绝晋升,不创建目标档案且不修改源运行 + +### Requirement: 策略档案必须对分析自包含 +每个档案 MUST 包含不可变清单、完整策略源码、运行配置、代码身份、四张核心事实表、全部声明策略扩展、性能证据、环境证据、行情快照身份和机械执行报告。调用者 MUST 能在不访问 `.local` 的情况下查询结果、比较参数、检查策略代码和重新生成机械执行报告。 + +#### Scenario: 删除运行缓存后复盘 +- **WHEN** 一个已晋升档案对应的 `.local` 运行缓存不可用 +- **THEN** analysis_data 仍能从档案清单打开全部已声明事实和策略扩展并生成分析视图 + +#### Scenario: 检查执行来源 +- **WHEN** 调用者检查档案的可重放证据 +- **THEN** 清单提供 Strategy Module、共享运行时、Git、第三方依赖、配置和 snapshot_id 的版本及摘要 + +#### Scenario: 机械执行报告保持事实边界 +- **WHEN** 共享流程为完成包生成 `report/` 内容 +- **THEN** 报告只包含可从包内复核的运行身份、参数、数据范围、成交与持仓统计、净值摘要、性能和完整性事实,不生成策略推荐、稳健性结论或实盘准入判断 + +### Requirement: 晋升不得重新计算研究结果 +晋升 MUST 只复制并校验完成运行中已经固化的字节。它不得加载 Strategy Module、调用 vectorbt、重新生成核心事实或扩展、重新序列化 Parquet、重新计算报告指标,且不得计入回测冷启动或预热耗时。 + +#### Scenario: 证明晋升没有执行引擎 +- **WHEN** 测试把策略和 vectorbt 调用替换为调用即失败后执行晋升 +- **THEN** 晋升仍成功,并且源、目标数据文件逐文件 SHA256 完全一致 + +#### Scenario: 共享行情保持单份 +- **WHEN** 晋升一个使用共享行情快照的运行 +- **THEN** 档案只复制快照身份、来源、范围、字段、价格口径和摘要,不复制共享 market-data.parquet + +### Requirement: 晋升必须不可变、幂等且冲突安全 +晋升 MUST 先在目标同级暂存目录复制全部文件并复核摘要,再以原子目录替换发布。相同 analysis_id 和相同内容 MUST 返回复用;相同 analysis_id 但内容不同 MUST 失败且不得覆盖、合并或生成隐式后缀。 + +晋升的安全边界 MUST 是本机同一用户控制的可信研究工作区:开始前用标准路径扫描拒绝 symlink、junction、hardlink 和非普通文件,使用 `shutil.copy2` 复制,复制后逐文件复核长度与 SHA256,并用 `os.replace` 原子发布。它不承诺防御同一用户在扫描后敌对替换源树,不得为此维护文件描述符、inode 或平台专属竞态状态机。 + +#### Scenario: 重复晋升相同内容 +- **WHEN** 调用者以同一 analysis_id 再次晋升同一个完整运行 +- **THEN** 系统重新验证既有档案并返回幂等复用,不改写任何文件 + +#### Scenario: analysis_id 内容冲突 +- **WHEN** 目标 analysis_id 已绑定不同 run_id 或不同文件摘要 +- **THEN** 系统返回失败并保持既有档案和源运行不变 + +#### Scenario: 发布中途失败 +- **WHEN** 复制、摘要验证或原子发布任一步骤失败 +- **THEN** 系统删除暂存目录,不留下半成品目标,也不影响其他档案 + +#### Scenario: 源树包含不支持的文件对象 +- **WHEN** 晋升前扫描发现符号链接、目录连接、硬链接或非普通文件 +- **THEN** 系统在复制前拒绝晋升且不创建完成目标 + +### Requirement: 本地研究档案必须与聚宽正式运行隔离 +本地研究档案 MUST 只写入 `research/archives/`,其清单和报告 MUST 明确标记本地探索性 vectorbt 来源。正式聚宽回测和模拟交易 MUST 继续分别写入 `backtests/` 和 `simulations/`,分析层不得因目录位于同一策略下而混淆运行身份。 + +#### Scenario: 列出策略下的研究和正式回测 +- **WHEN** 查询入口同时发现 `research/archives/` 和 `backtests/` +- **THEN** 它分别返回本地研究与聚宽正式运行类型,并保留各自 backend 和来源身份 diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/specs/local-research-result-package/spec.md b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/specs/local-research-result-package/spec.md new file mode 100644 index 0000000..b120d20 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/specs/local-research-result-package/spec.md @@ -0,0 +1,63 @@ +## ADDED Requirements + +### Requirement: 共享结果包必须定义后端中立的核心事实 +系统 MUST 用版本化 Schema 定义结果、资金、持仓和订单四张核心事实表,并允许策略通过版本化扩展增加归因等证据。核心 writer 不得导入具体策略动作码,策略扩展不得改变核心表的字段含义。 + +#### Scenario: 海龟策略生成结果包 +- **WHEN** 海龟执行完成并提供策略轨迹 +- **THEN** 共享 writer 从 ExecutionLedger 生成四张核心表,并把海龟归因作为独立扩展写入清单 + +#### Scenario: 最小策略不提供归因 +- **WHEN** 第二个测试策略只提供核心执行账本而没有策略扩展 +- **THEN** 共享 writer 仍生成合法完整结果包,并明确声明没有对应扩展 + +### Requirement: 策略扩展必须使用有边界的 Arrow 契约 +每个 `ResultExtension.table` MUST 只包含扁平 `string`、`bool`、`int64` 或 `float64` 列。浮点缺失值 MUST 使用 Arrow null,不得使用 NaN。dictionary、list、struct、map、union、run-end encoded 及其他类型 MUST 在冷/热比较前以 `result_contract_failed` 拒绝;共享层不得实现递归 Arrow 类型解释器或任意类型逻辑哈希。 + +#### Scenario: 比较合法扩展 +- **WHEN** 冷启动和预热产生相同的扁平扩展表 +- **THEN** 共享层先调用 `Table.validate(full=True)`、比较精确 Schema,再用 `Table.equals(check_metadata=True)` 判断相等 + +#### Scenario: 拒绝越界扩展 +- **WHEN** 扩展包含 NaN、嵌套、字典编码或其他未允许类型 +- **THEN** 系统在冷/热确定性比较和 Parquet 写入前返回 `failed` 与 `result_contract_failed` + +### Requirement: 账本视图和事实数据只能物化一次 +ExecutionLedger MUST 对 orders、assets、cash 和 value 使用只读惰性缓存;共享 writer MUST 直接消费这些视图并只执行一次公共事实转换、一次 Parquet 固化和一次回读。内部 writer MUST 复用这次回读事实完成 Schema、唯一键、跨表勾稽、报告和最终清单;公开 validator MUST 只从磁盘读取并供复用、晋升和外部查询。运行路径不得通过 JSON、字典或 Arrow 往返重新构造账本,不得在多层重复复制完整矩阵,也不得通过 `preloaded_*` 参数或 provisional/final 两套完整包维护第二条校验路径。 + +#### Scenario: 多个消费者读取净值 +- **WHEN** 性能摘要、结果 writer 和策略归因都需要组合净值 +- **THEN** 它们复用同一个缓存视图,不分别调用 vectorbt 生成或复制完整净值数组 + +#### Scenario: 策略不需要某类轨迹 +- **WHEN** Strategy Module 未声明某个策略审计字段 +- **THEN** 执行底层不为该字段分配行数乘证券数的稠密矩阵 + +### Requirement: 结果包必须可验证并原子发布 +系统 MUST 为每个数据集记录路径、状态、行数、时间范围、Schema 版本和 SHA256,并在暂存目录完成写入、回读、跨表校验和核心事实摘要后原子发布。核心事实确定性使用 NumPy 数组摘要;扩展确定性使用精确 Schema 与 `Table.equals(check_metadata=True)`;文件完整性使用最终 Parquet 字节的 SHA256。任何校验或发布失败 MUST 清理暂存目录且不得留下可被识别为完整的结果。 + +#### Scenario: 成功固化结果包 +- **WHEN** 四张核心表和全部声明扩展通过 Schema、摘要和跨表勾稽 +- **THEN** 系统原子发布数据文件和清单,并把运行标记为 `complete` + +#### Scenario: 回读事实不一致 +- **WHEN** Parquet 回读后的核心事实摘要、扩展表或文件摘要与内存事实和最终清单不同 +- **THEN** 系统返回 `failed`,删除暂存结果且不覆盖既有完整运行 + +### Requirement: 共享分析必须读取本地和聚宽结果 +analysis_data MUST 通过后端中立清单读取新的本地结果包和既有聚宽归档,并为相同概念提供一致查询视图。任何来源差异、缺失数据集和公式版本 MUST 保留显式证据,不得伪造成相同来源或静默补全。 + +#### Scenario: 比较本地研究和聚宽回测 +- **WHEN** 分析流程同时打开一个本地 vectorbt 结果和一个聚宽正式回测 +- **THEN** 它通过统一视图查询共同事实,同时保留 backend、来源身份和数据集状态 + +#### Scenario: 使用 vectorbt 统计交叉校验 +- **WHEN** 分析流程调用 vectorbt returns 或 stats 复核收益指标 +- **THEN** 结果被标记为交叉校验,不静默替换现有 Alpha、Information Ratio、CVaR 或其他公式版本 + +### Requirement: 结果包必须记录诚实的日常性能证据 +结果包内 `performance.json` MUST 记录从 writer 启动到最终 evidence/report/manifest 写入前的 `prefinalization_seconds` 及各阶段耗时,不得宣称包含尚未发生的自身写入或父进程发布。日常 cold/warm 检查 MUST 使用 writer 返回时的完整耗时,覆盖策略执行、核心事实、策略扩展、Parquet 写入、回读校验、摘要和最终元数据写入。系统不得为发布性能比较恢复 provisional/final 双包、第二次元数据写入或旁路清单;发布级性能差异只在外部验证报告中列示并由用户人工确认。 + +#### Scenario: 抽取共享 writer +- **WHEN** 原策略 writer 迁移到共享结果包 +- **THEN** 包内预最终化和日常 writer 返回耗时使用明确边界,并能区分执行、事实转换、扩展和文件阶段 diff --git a/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/tasks.md b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/tasks.md new file mode 100644 index 0000000..1a97a74 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-refactor-local-research-three-layer-architecture/tasks.md @@ -0,0 +1,82 @@ +## 1. 冻结现有行为与性能证据 + +- [x] 1.1 为即时、17 ETF 扩展和 `additional_delay_days=1` 延迟场景补齐成交、费用、现金、持仓、净值、策略状态和逻辑摘要特征测试 +- [x] 1.2 在固定 `.venv` 环境采集三个冷启动新进程与五次预热的主场景观测,并记录分阶段时间、峰值内存和历史整包体积;性能数据只用于人工复核 +- [x] 1.3 固化旧配置、停止状态、清单、代码身份和分析视图契约,确保后续破坏性迁移只能通过新 Interface 完成 + +## 2. 建立共享 contracts 与真实 Strategy Module 接缝 + +- [x] 2.1 先编写失败测试,定义 Strategy Module、LedgerInput、OrderProgram、ExecutionLedger、ExecutionRun 和 ResultExtension 的最小只读 Interface +- [x] 2.2 实现仓库内 Strategy Module 安全加载与配置校验,拒绝仓库外路径、未知 symbol、旧任意 command 和策略专属 project entry +- [x] 2.3 增加第二个最小测试策略 Adapter,并证明共享入口无需修改即可加载两个策略 + +## 3. 抽取后端中立标准结果包 + +- [x] 3.1 先编写失败测试,把四张核心表 Schema、公共跨表校验、逻辑摘要和清单契约从海龟适配器迁入共享结果 Module +- [x] 3.2 实现版本化 ResultExtension 注入,让海龟归因保持策略私有而共享 writer 不导入海龟动作码 +- [x] 3.3 实现单次 Parquet 固化、回读验证、失败清理和原子发布,并证明账本视图只惰性生成和缓存一次 +- [x] 3.4 扩展 analysis_data,使新本地结果、策略扩展和既有聚宽归档通过统一视图查询且保留 backend 与公式版本 + +## 4. 实现策略目录自包含档案 + +- [x] 4.1 先编写失败测试,定义档案清单、目录布局、必需代码/配置/数据/证据/报告和 `analysis_id` 校验 +- [x] 4.2 实现独立 `promote` 动作,只复制已完成结果字节和策略源码,不加载策略、vectorbt 或 writer +- [x] 4.3 实现逐文件 SHA256 复核、同内容幂等复用、异内容冲突拒绝、同级暂存和原子发布 +- [x] 4.4 验证删除 `.local` 源运行后档案仍可查询和生成报告,同时确认共享行情文件没有被复制 + +## 5. 统一共享单场景、性能与 CLI + +- [x] 5.1 先编写失败测试,把项目 CLI、单场景编排、冷热确定性、阶段计时和停止状态迁入共享 Module +- [x] 5.2 将 runner 固定为项目 `.venv` 加共享 CLI;外部配置只声明嵌套 `strategy.root/module/symbol`,内部 `RunConfig` 再映射为 `strategy_root/strategy_module/strategy_symbol` +- [x] 5.3 保持输入冻结、清理环境、运行复用、失败证据和 `complete/evidence_insufficient/failed` 语义不变 +- [x] 5.4 更新仓库 Skill,使 Codex、其他 Agent 和人工命令只调用共享 `run` 与 `promote` 入口 + +## 6. 收窄扩展表并收敛共享 writer + +- [x] 6.1 先编写失败测试,限定 ResultExtension 只接受扁平 `string/bool/int64/float64`、用 Arrow null 表示缺失并在冷/热比较前拒绝 NaN 与其他类型 +- [x] 6.2 使用 PyArrow `Table.validate`、精确 Schema 和 `Table.equals` 比较扩展,核心事实继续使用 NumPy 摘要,删除递归 Arrow 类型解码和任意类型逻辑哈希 +- [x] 6.3 将内部 writer 收敛为一次物化、一次回读事实链,公开 validator 保持纯磁盘读取,删除 `preloaded_*` 参数和 provisional/final 双包路径 +- [x] 6.4 运行结果包、runner、双策略和公开 CLI 回归,确认越界扩展固定返回 `failed/result_contract_failed` + +## 7. 统一策略源码身份并简化加载 + +- [x] 7.1 先编写失败测试,证明当前 module 顶层包内静态发现并排序的普通 `.py` 文件集合同时驱动运行身份和档案 `code/`,相邻目录与 `research/archives/` 不进入,descriptor 不含第二份 `source_files` +- [x] 7.2 在每次全新单策略 `_execute` 子进程中使用标准 `importlib.import_module()`,删除 UUID 命名空间、全局导入锁和手工 `sys.modules` 生命周期 +- [x] 7.3 按仓库内可信策略边界删除 v2 `adapter_guard` 注入和 audit hook 沙箱,仅保留受限源码路径、冻结输入、清理环境、全新子进程和超时 +- [x] 7.4 把第二个最小策略 fixture 缩到验证公开 Module、相对导入和无扩展结果所需的最小代码,并运行 loader、contract 和 E2E 回归 + +## 8. 简化可信工作区内的档案晋升 + +- [x] 8.1 保留现有行为与内部测试,先新增禁止描述符/inode 状态机的 AST 边界失败测试,并补齐链接/非普通文件拒绝、硬链接拒绝、复制中断清理、同内容复用、异内容冲突和逐字节一致性行为测试 +- [x] 8.2 在本机同一用户可信工作区边界内,使用预扫描、`shutil.copy2`、复制后长度/SHA256 复核和 `os.replace` 原子发布 +- [x] 8.3 删除文件描述符、inode 与敌对并发换树状态机及其专属测试,运行完整 archive 与 analysis_data 回归 + +## 9. 完善通用 vectorbt 唯一账本 + +- [x] 9.1 先编写通用 primary/follow-up、稳定优先级、真实成交回调和惰性只读访问器失败测试,实现隐藏原始 Portfolio 的共享 vectorbt Adapter +- [x] 9.2 让两个最小 fixture 都通过同一 `run_vectorbt()` 完成 primary 与可选 follow-up,runtime 不解释海龟冻结计划、执行日规则或原因码 +- [x] 9.3 删除成交、费用、持仓和净值镜像,直接复用并缓存 vectorbt trades/positions/returns 等访问器;默认保留所需记录 +- [x] 9.4 等价性通过后仅评估有测量证据的 `max_logs=0` 和缓冲预分配,不通过关闭记录再自行重建统计来优化 + +## 10. 收敛海龟公开 Module 并迁移即时与延迟执行 + +- [x] 10.1 创建 `turtle_etf.strategy:MODULE` 唯一公开入口,把配置校验、输入准备、即时/后续 OrderProgram 和 ResultExtension 组合在公开 Interface 后 +- [x] 10.2 将 Numba 内核、海龟归因和延迟冻结计划收敛到私有文件;即时和延迟实际账户变化都交给共享 `from_order_func()` +- [x] 10.3 用现有特征逐笔验证计划、成交、费用、现金、持仓、共同止损和原因码,删除 Python 手工账本与 `from_orders()` 重放路径 +- [x] 10.4 在旧生产文件删除前重跑三个场景的引擎 3 冷/5 热与完整 CLI 采样,把历史整包体积拆为 `parquet_payload_bytes` 与单独固定开销,并保存环境和样本身份供人工性能复核 +- [x] 10.5 用公开 `ExecutionLedger + ResultExtension` 冻结三个场景的 v2 等价性摘要并绑定历史 v1 fixture SHA256;环境摘要覆盖操作系统、CPU、内存与关键依赖并由测试重算;只恢复公司行动、延迟截断和加仓/清仓损益勾稽的最小边界测试,不保留旧核心结果适配器 + +## 11. 单次切换生产配置与入口 + +- [x] 11.1 把生产 project-run 配置切换为 v2,并删除策略根下手工输入型 `code-identity.json` +- [x] 11.2 在同一提交物理删除旧策略 CLI、single_scenario、benchmark、vectorbt_engine、result_adapter、共享 `adapter_guard.py` 和 runner v1 command 路径,不保留兼容分支 +- [x] 11.3 从共享 CLI 完成海龟 `run → package → promote` E2E,覆盖复用、证据不足、失败、冲突、中途清理和删除 `.local` 后查询 +- [x] 11.4 同步 Skill、研究说明、旧 OpenSpec 约束和 Build and Verify 配置,保留生成包内 `config/code-identity.json` + +## 12. 人工确认性能与完整验证 + +- [x] 12.1 列出三个固定场景的正确性、时间、内存和 Parquet 数据载荷观测 +- [x] 12.2 记录用户于 2026-07-18 对当前性能差异的人工确认 +- [x] 12.3 删除自动发布性能命令、相对判退和专用采样器,只保留日常 cold/warm 确定性与 180 秒超时 +- [x] 12.4 运行仓库完整 Build and Verify,逐项核对三份 capability 规格并确认没有旧生产路径、第二套账本或临时产物 +- [x] 12.5 保存最终验证报告,并只在对应证据真实存在后逐项勾选本文件任务 diff --git a/openspec/changes/build-turtle-etf-local-research-workflow/specs/local-quant-research-workflow/spec.md b/openspec/changes/build-turtle-etf-local-research-workflow/specs/local-quant-research-workflow/spec.md index 3607eda..0d52960 100644 --- a/openspec/changes/build-turtle-etf-local-research-workflow/specs/local-quant-research-workflow/spec.md +++ b/openspec/changes/build-turtle-etf-local-research-workflow/specs/local-quant-research-workflow/spec.md @@ -3,16 +3,16 @@ ### Requirement: Skill 编排与共用脚本分层 系统 SHALL(必须)由 `run-local-quant-research` Skill(技能)只负责用户意图、流程顺序、输入输出、停止状态和安全边界;配置契约、运行身份、共享行情中心、项目安全调用和证据收口 SHALL(必须)由 `scripts/research/` 下与具体策略解耦的共用脚本实现。 -#### Scenario: 有效配置调用项目适配器 -- **WHEN** 调用者提供通过契约校验的 `snapshot_id`(快照标识)、项目配置、仓库内项目入口参数数组和必需输出 -- **THEN** Skill 调用共用运行器,运行器按“快照校验、配置校验、项目入口、输出校验、证据收口”的固定顺序执行并记录每一步状态 +#### Scenario: 有效配置调用策略模块 +- **WHEN** 调用者提供通过契约校验的 `snapshot_id`(快照标识)、单场景配置、仓库内 `strategy.root/module/symbol` 和声明输入 +- **THEN** Skill 调用共用运行器,运行器按“快照校验、配置校验、固定子进程执行、结果包校验、证据收口”的固定顺序执行并记录每一步状态 -#### Scenario: 拒绝不安全项目入口 -- **WHEN** 配置缺少必需字段、使用 Shell(命令解释器)字符串、引用仓库外入口或未声明必需输出 -- **THEN** 系统停止在项目代码执行前,不拼接命令、不扩大文件访问范围,并输出 `evidence_insufficient` +#### Scenario: 拒绝不安全策略入口 +- **WHEN** 配置缺少必需字段、包含旧 `command/project_entry` 字段、引用仓库外策略或未声明输入 +- **THEN** 系统停止在策略代码执行前,只允许固定 `_execute` 子进程命令,不拼接配置命令或扩大路径范围 #### Scenario: 通用层不解释策略语义 -- **WHEN** 使用非海龟项目适配器运行同一流程 +- **WHEN** 使用非海龟策略模块运行同一流程 - **THEN** Skill、共用脚本和共享行情中心无需海龟资产、参数、信号、风险或报告规则即可完成运行 ### Requirement: 共享日线行情中心 @@ -104,7 +104,7 @@ - **THEN** 系统输出 `complete` ### Requirement: 不可变且原子固化的研究证据 -系统 SHALL(必须)以快照摘要、项目配置摘要、规范化单场景配置摘要、代码摘要和执行后端身份生成 `run_id`,先在暂存位置生成产物,全部校验通过后一次性固化包含输入、命令、状态、输出路径和输出摘要的不可变证据索引;不同场景配置不得复用同一 `run_id`。 +系统 SHALL(必须)以快照摘要、生产配置摘要、规范化单场景配置摘要、自动发现的策略/共享运行时代码摘要和执行后端身份生成 `run_id`,先在暂存位置生成产物,全部校验通过后一次性固化包含输入、状态、结果包路径和输出摘要的不可变证据;不同场景配置不得复用同一 `run_id`。 #### Scenario: 首次成功运行 - **WHEN** 一个新 `run_id` 的全部输入、项目流程和输出校验通过 @@ -112,7 +112,7 @@ #### Scenario: 性能证据属于原子完成门禁 - **WHEN** 项目对同一单场景执行冷启动和预热性能复核 -- **THEN** 两次执行、结果摘要一致性比较和各自不超过项目声明上限的判断均在同一暂存区完成;比较通过后先删除预热副本和可丢弃暂存并确认清理,再写入 `performance.json`、生成最终清单并校验全部摘要,最后只原子发布已整理的一份权威结果。发布后不得依赖写入或清理,任一失败只保留失败尝试证据 +- **THEN** 两次执行、结果摘要一致性比较和各自不超过 180 秒上限的判断均在同一暂存区完成;比较通过后写入 `evidence/performance.json`、机械报告和最终清单,校验全部摘要后只原子发布一份权威结果包。任一失败不发布完成目录,只保留紧凑失败尝试证据 #### Scenario: 相同身份重复运行 - **WHEN** 已存在同一 `run_id` 的 `complete` 运行且全部产物重新校验通过 @@ -130,31 +130,35 @@ - **WHEN** 调用者重试一个 `failed` 或 `evidence_insufficient` 运行 - **THEN** 系统保留原尝试证据并创建新的尝试记录;只有新尝试全部通过才可固化为 `complete` -### Requirement: 项目执行后端身份与通用输出兼容 -通用本地研究流程 SHALL(必须)把交易执行后端视为项目自有实现,不硬编码 vectorbt(向量化回测框架)、海龟回调或任何特定回测引擎。项目 SHALL(必须)在代码身份中声明执行后端名称、版本、依赖摘要和输出适配器版本;无论项目使用何种后端,通用运行器只校验声明的标准分析数据包和证据契约。 +### Requirement: 共享 vectorbt 后端与策略模块分层 +通用本地研究流程 SHALL(必须)把 vectorbt(向量化回测框架)`Portfolio.from_order_func()` 作为即时和后续执行的唯一账户账本。共享 runtime 负责成交、费用、现金、持仓和净值;策略模块只提供准备、订单程序、后续计划和版本化结果扩展,不维护第二套账户事实。 #### Scenario: 项目声明 vectorbt 执行后端 - **WHEN** `strategy-003` 使用 vectorbt 官方 `Portfolio.from_order_func()`(自定义订单函数)运行本地交易路径 -- **THEN** 项目代码身份记录 vectorbt、Numba(即时编译)、NumPy(数组计算)、Pandas(数据处理)和输出适配器版本及摘要,通用运行器只按声明校验身份与产物 +- **THEN** 自动生成的代码身份与运行时锁记录 vectorbt、Numba(即时编译)、NumPy(数组计算)、Pandas(数据处理)和 PyArrow(列式计算)版本及摘要;外部配置不提供手工代码身份文件 -#### Scenario: 非海龟项目使用其他执行方式 -- **WHEN** 非海龟项目适配器不使用 vectorbt 或使用另一种项目内执行方式 -- **THEN** Skill、共享行情中心和通用运行器仍可完成快照校验、项目调用、标准输出校验和证据收口,不要求该项目安装 vectorbt +#### Scenario: 非海龟项目复用同一共享后端 +- **WHEN** 第二个策略模块提供不同策略逻辑且不提供海龟归因 +- **THEN** Skill、共享行情中心和通用运行器不修改即可通过同一 vectorbt runtime 完成执行和核心结果包,策略扩展可以为空 #### Scenario: 执行后端依赖缺失 - **WHEN** 项目声明的 vectorbt、Numba 或兼容依赖在项目 `.venv`(虚拟环境)中缺失或版本不匹配 -- **THEN** 通用运行器在项目执行前输出具体缺项并以 `evidence_insufficient` 收口,不静默安装、升级或回退到另一执行后端 +- **THEN** 通用运行器在项目执行前输出具体缺项并停止,不静默安装、升级或回退到另一执行后端 #### Scenario: 更换后端不改变通用分析契约 - **WHEN** 项目从旧逐日实现迁移到 vectorbt 后端 -- **THEN** 项目输出与聚宽现有归档同名同义的 `results`、`balances`、`positions`、`orders` 四类共同执行事实及本地兼容清单,并在清单中把来源未提供的 `risk`、`period_risks` 标记为由独立策略分析计算;既有聚宽归档无需改动,通用绩效、归因、稳健性和报告层无需解释 vectorbt 对象 +- **THEN** 共享 writer 输出与聚宽现有归档同名同义的 `results`、`balances`、`positions`、`orders` 四类共同执行事实和独立 `local-research-package/2` 清单;既有聚宽归档无需改动,分析层无需解释 vectorbt 对象 ### Requirement: 每次 Skill 调用只交付一个场景结果 本地研究流程 SHALL(必须)每次只接受一个策略项目、一个快照和一个场景配置,只编排一次项目执行、单份兼容结果校验、运行身份和证据收口,不接收候选数组、不循环多个场景,也不调用或包含策略分析 Skill(技能)。`scripts/research/local_quant_research/` 和策略项目不得导入绩效、归因、稳健性、压力、证据矩阵、报告或推荐算法。 #### Scenario: 成功交付单场景结果 - **WHEN** 调用者提交一个完整场景配置且项目执行与统一契约校验通过 -- **THEN** 本地研究流程在 `.local/quant-research///backtests//` 固化一份按聚宽内部目录结构保存的本地回测结果,记录场景身份、来源权限、代码/参数/行情摘要和结果摘要,并以 `next_action=return_to_caller` 停止 +- **THEN** 本地研究流程在 `.local/quant-research///` 直接固化一份 `local-research-package/2` 自包含结果包,记录场景身份、完整策略源码、配置、行情/环境/代码身份、四张核心表、策略扩展、性能证据和机械执行报告,并以 `next_action=return_to_caller` 停止 + +#### Scenario: 本地结果晋升且不混入聚宽正式归档 +- **WHEN** 调用者把完整运行晋升到 `joinquant/strategies//research/archives//` +- **THEN** 晋升只逐字节复制、摘要复核和原子发布,不重算结果;该目录明确属于本地探索性研究,不写入或改变聚宽正式 `backtests/`、`simulations/` 及其同步流程 #### Scenario: 拒绝批量候选输入 - **WHEN** 调用者把冻结基线、挑战数组、参数网格或稳健性场景列表作为一次 Skill 输入 @@ -162,7 +166,7 @@ #### Scenario: 不启动策略分析 - **WHEN** 单场景兼容结果已经完整 -- **THEN** 本地研究流程不计算绩效、Alpha/Beta(超额收益/市场暴露)、归因、稳健性、压力或推荐,不生成完整策略分析报告,也不调用后续策略分析 Skill +- **THEN** 本地研究流程不计算 Alpha/Beta(超额收益/市场暴露)、稳健性、压力或推荐,不生成完整策略分析报告,也不调用后续策略分析 Skill;包内机械执行报告只复述核心表、配置、性能门禁和来源身份 #### Scenario: 依赖方向检查 - **WHEN** 扫描本地研究运行器和策略项目的生产导入 @@ -205,8 +209,8 @@ - **THEN** 测试覆盖 CSV 暂存导入、Parquet 不可变批次、逻辑内容去重、追加新标的、旧快照复算不变、冲突重叠拒绝、字段能力、快照摘要、Parquet 到内存 DuckDB 一致性及暂存清理,并确认未生成持久 DuckDB 文件 #### Scenario: 非海龟完整 E2E -- **WHEN** 从 Skill 用户入口使用非海龟最小项目适配器和固定日线夹具运行 -- **THEN** 流程完整经过 Skill、共用运行器、共享行情中心、项目入口、声明输出和不可变证据收口 +- **WHEN** 从 Skill 用户入口使用非海龟最小策略模块和固定日线夹具运行 +- **THEN** 流程完整经过 Skill、共用运行器、共享行情中心、固定子进程、共享 vectorbt runtime、标准结果包和不可变证据收口 #### Scenario: 用户入口完整回归 - **WHEN** 从 Skill 文档公开的用户入口启动离线研究夹具 diff --git a/openspec/changes/build-turtle-etf-local-research-workflow/specs/standard-strategy-analysis-data/spec.md b/openspec/changes/build-turtle-etf-local-research-workflow/specs/standard-strategy-analysis-data/spec.md index ddc47ce..5806b8d 100644 --- a/openspec/changes/build-turtle-etf-local-research-workflow/specs/standard-strategy-analysis-data/spec.md +++ b/openspec/changes/build-turtle-etf-local-research-workflow/specs/standard-strategy-analysis-data/spec.md @@ -11,12 +11,12 @@ - **WHEN** 读取器处理聚宽回测结果 - **THEN** 它只按原清单定位文件、摘要、行数、合法空表、数据集状态和来源身份,不扫描“看起来最新”的文件;读取前后聚宽目录摘要必须一致 -### Requirement: 本地回测使用独立且可执行的清单 Schema -系统 SHALL(必须)提供 `local-backtest-manifest.schema.json`。本地结果从 `.local/quant-research///backtests//` 向内尽量镜像聚宽现有回测目录,但 SHALL(必须)使用 `schema_version=local-backtest/1`、`object.kind=local_backtest`、`source.kind=local_vectorbt` 和 `authority=local_research` 明确来源,不能声称符合聚宽远端归档 Schema。 +### Requirement: 本地研究使用独立且可执行的自包含清单 +本地结果 SHALL(必须)直接使用 `.local/quant-research///manifest.json` 的 `schema_version=local-research-package/2`、`object.kind=local_research`、`backend=vectorbt` 和 `authority=local_research` 明确来源。该清单不声称符合聚宽远端归档 Schema;晋升到 `research/archives//` 后包内 `run_id` 和所有文件字节保持不变。 #### Scenario: 本地清单必需证据 - **WHEN** 本地 vectorbt(向量化回测框架)完成一个场景 -- **THEN** 本地 Schema 顶层 `additionalProperties=false`,必需字段为 `schema_version`、`object`、`source`、`authority`、`run`、`code`、`params`、`datasets`、`performance`、`gate`;其中必须记录 `local_id`、`status`、`run_id`、`scenario_id`、`snapshot_id`、引擎/适配器版本、公司行动核算模式及精度边界、代码路径/字节数/SHA256、当前参数路径/版本路径/字节数/SHA256、数据集状态/文件摘要/行数/时间范围/空表、性能证据路径/字节数/SHA256,以及 `gate.status=pass|fail` 与 `exceptions`;`code.py`、`params.json`、`params_versions/.json`、`performance.json` 与 `data/` 的位置沿用同一回测根目录 +- **THEN** 清单必需记录 `object`、`authority`、`backend`、`formula_version`、`package_sha256`、`code`、`config`、`evidence`、`datasets`、`extensions`、`reports` 和 `gate`;包内固定包含完整 `code/`、`config/`、`evidence/`、`data/`、`extensions/` 与 `report/`,各文件以路径、字节数和 SHA256 绑定,四张核心表和全部声明扩展完成回读勾稽 #### Scenario: 本地公司行动近似口径显式可见 - **WHEN** 本地 vectorbt 不能原生同步拆分后的真实份额和派息日现金,而改用连续总回报经济价格 @@ -24,7 +24,7 @@ #### Scenario: 性能证据可独立复核 - **WHEN** 本地结果准备通过 `gate.status=pass` -- **THEN** `performance.json` 必须记录环境与依赖摘要、准备后输入摘要、代码/参数/场景摘要、`cold_seconds`、`warm_seconds`、两次规范化结果摘要、摘要一致性结论、性能上限和暂存清理结果;计时统一从准备后输入进入项目执行后端开始,到项目声明的执行事实和必需扩展完成结构/摘要/勾稽校验时停止。停止计时后先删除预热副本和可丢弃暂存并确认清理,再把清理结果写入 `performance.json`、生成最终清单并校验全部摘要;二者属于完成门禁但不计入冷/热耗时。最后只原子发布已整理的权威结果目录,发布后不得依赖写入或清理;任一门禁失败不得发布完成目录 +- **THEN** `evidence/performance.json` 必须记录环境与依赖摘要、冷/热执行秒数与规范化摘要、摘要一致性、阶段时间和 180 秒门禁;writer(写入器)返回耗时覆盖最终元数据写入,包内 `prefinalization_seconds` 不虚构尚未发生的自身写入或父进程发布,任一门禁失败不得发布完成目录 #### Scenario: 本地不伪造聚宽专属证据 - **WHEN** 本地来源没有聚宽详情页、远端原始响应、围栏、官方摘要或官方风险结果 @@ -32,14 +32,14 @@ #### Scenario: 读取器严格选择 Schema - **WHEN** 读取器打开来源清单 -- **THEN** 整数 `schema_version=1` 只使用聚宽 Schema,字符串 `schema_version=local-backtest/1` 只使用本地 Schema;未知版本、混合身份、越权字段或校验失败直接拒绝,不得尝试回退另一契约 +- **THEN** 整数 `schema_version=1` 只使用聚宽 Schema,字符串 `schema_version=local-research-package/2` 只使用本地研究 Schema;历史 `local-backtest/1` 结果保持只读兼容,未知版本、混合身份、越权字段或校验失败直接拒绝,不得尝试回退另一契约 ### Requirement: 六类逻辑模型与两种物理形态明确分离 -统一分析模型 SHALL(必须)沿用聚宽现有 `results`、`balances`、`positions`、`orders`、`risk`、`period_risks` 六类名称。聚宽来源物理提供六类数据;本地来源只物理提供四类共同执行事实,并在清单中声明两类官方风险参考缺失。读取器 SHALL(必须)为两种来源建立相同的六类逻辑视图。 +统一分析模型 SHALL(必须)沿用聚宽现有 `results`、`balances`、`positions`、`orders`、`risk`、`period_risks` 六类名称。聚宽来源物理提供六类数据;本地来源清单只声明四类共同执行事实,读取器为 `risk` 与 `period_risks` 派生显式 `missing_at_source` 逻辑视图,不要求结果包伪造两类物理文件或清单条目。 #### Scenario: 本地只落四类执行事实 - **WHEN** 本地结果通过门禁 -- **THEN** `results`、`balances`、`positions`、`orders` 必须为 `required=true`、`status=complete`;`risk` 与 `period_risks` 必须为 `required=false`、`status=missing_at_source`、`reason=computed_by_strategy_analysis`,本地研究不得计算 Alpha/Beta(超额收益/市场暴露)、Sharpe(夏普比率)、回撤或分期风险来填满六类物理文件 +- **THEN** `results`、`balances`、`positions`、`orders` 必须完成且通过清单摘要校验;查询 `risk` 与 `period_risks` 时读取器返回 `status=missing_at_source`、`reason=computed_by_strategy_analysis`,本地研究不得计算 Alpha/Beta(超额收益/市场暴露)、Sharpe(夏普比率)、回撤或分期风险来填满六类物理文件 #### Scenario: 收益与权益沿用聚宽字段 - **WHEN** 分析策略收益、权益、现金和仓位占用 @@ -47,7 +47,7 @@ #### Scenario: 本地 results 保留聚宽单基准字段但不伪造值 - **WHEN** 本地适配器生成 `data/results.parquet` -- **THEN** 文件固定包含 `time:string`、`returns:double`、`benchmark_returns:double nullable`;`returns` 表示从初始资金起算的累计净收益,`benchmark_returns` 全列为空值且物理类型仍为 `double`,本地清单记录 `source_benchmark_returns.status=missing_at_source`、`reason=independent_benchmark_set` 和空值行数;不得填零或任选一个独立基准冒充聚宽单基准 +- **THEN** 文件固定包含 `time:string`、`returns:double`、`benchmark_returns:double nullable`;`returns` 表示从初始资金起算的累计净收益,`benchmark_returns` 全列为空值且物理类型仍为 `double`,读取器据此暴露 `source_benchmark_returns.status=missing_at_source`、`reason=independent_benchmark_set` 和空值行数;不得填零或任选一个独立基准冒充聚宽单基准 #### Scenario: 累计收益只在查询期转换为单日收益 - **WHEN** 统一分析需要把来源策略收益与双基准的单日收益对齐 diff --git a/openspec/specs/local-quant-research-runtime/spec.md b/openspec/specs/local-quant-research-runtime/spec.md new file mode 100644 index 0000000..5111237 --- /dev/null +++ b/openspec/specs/local-quant-research-runtime/spec.md @@ -0,0 +1,96 @@ +# local-quant-research-runtime Specification + +## Purpose +TBD - created by archiving change refactor-local-research-three-layer-architecture. Update Purpose after archive. +## Requirements +### Requirement: 本地研究必须使用三层单向架构 +系统 MUST 将本地研究组织为 Strategy Module、Skill 通用能力层和 vectorbt 执行底层,并且依赖只能从 Strategy Module 经共享 contracts 进入 Skill 能力和执行底层。具体策略不得导入 vectorbt 上下文、订单枚举、记录结构或原始 Portfolio;共享能力不得导入具体策略的私有实现。 + +#### Scenario: 加载海龟策略 +- **WHEN** 共享入口加载海龟研究项目 +- **THEN** 它只通过 `turtle_etf.strategy:MODULE` 使用策略,并通过共享执行 Interface 调用 vectorbt,不直接导入海龟私有文件 + +#### Scenario: 第二个策略复用共享能力 +- **WHEN** 仓库提供一个实现同一 Strategy Module Interface 的最小测试策略 +- **THEN** 共享 CLI、日常超时检查、结果包和停止状态无需修改即可运行该策略 + +### Requirement: vectorbt 必须成为唯一账户账本 +系统 MUST 使用 vectorbt `Portfolio.from_order_func()` 处理即时和延迟场景的订单接受、拒绝、部分成交、费用、共享现金、持仓和组合估值。策略只保存决策所需的单位、止损、冻结计划、原因和归因轨迹,不得维护第二套成交、现金、持仓、费用或净值账本。 + +#### Scenario: 即时执行 +- **WHEN** 策略在同一交易日生成并执行目标订单 +- **THEN** 实际成交、费用、现金、持仓和净值全部来自单一 vectorbt Portfolio + +#### Scenario: 延迟执行 +- **WHEN** 场景声明正数 `additional_delay_days` +- **THEN** 策略冻结原计划并在执行日复核可交易性和机械约束,但实际账户变化仍由第二个 `from_order_func()` 程序完成 + +#### Scenario: vectorbt 拒绝订单 +- **WHEN** vectorbt 因现金、持仓或订单约束拒绝一笔请求 +- **THEN** 系统把拒绝状态和策略原因保存为执行事实,不把正常拒单误报为框架失败 + +#### Scenario: 复用 vectorbt 持仓与交易访问器 +- **WHEN** 共享账本需要 trades、positions 或 returns 视图 +- **THEN** 它直接惰性缓存 vectorbt 的现成访问器结果,并默认保留所需记录;没有等价性和性能证据时不得关闭记录后自行重建 + +### Requirement: 每个策略必须只有一个公开 Strategy Module +每个本地研究策略 MUST 暴露一个版本化公开 Strategy Module,负责配置校验、输入准备、订单程序和策略结果扩展。策略 MAY 使用私有实现文件,但外部调用和测试不得把这些私有文件当作稳定 Interface。 + +#### Scenario: 策略内部重组 +- **WHEN** 策略维护者在不改变公开 Module 和行为的前提下拆分或合并私有 Numba 内核文件 +- **THEN** 共享层、其他策略和 Interface 级测试无需修改 + +#### Scenario: 禁止动态回调 +- **WHEN** Strategy Module 根据场景参数构造订单程序 +- **THEN** 它使用模块级固定 Numba 函数和稳定数组类型,不为每个配置动态创建闭包或 lambda + +#### Scenario: 源码身份只有一个来源 +- **WHEN** 父进程冻结并加载一个仓库内策略 +- **THEN** 它从声明 module 定位当前顶层包目录并静态发现、排序其中全部普通 `.py` 文件,同一集合同时驱动运行身份和档案 `code/`;它不扫描 `research/archives/` 或相邻目录,descriptor 不再声明第二份源码清单 + +#### Scenario: 子进程加载策略 +- **WHEN** 全新 `_execute` 子进程加载唯一 Strategy Module +- **THEN** 它把冻结策略根放到 `sys.path` 首位并使用标准 `importlib.import_module()`,不建立 UUID 私有命名空间、全局导入锁或手工模块缓存生命周期 + +### Requirement: 仓库内策略加载不得模拟敌对插件沙箱 +Strategy Module MUST 是仓库内受版本管理和代码审查的可信代码。共享运行 MUST 使用受限源码路径、冻结输入、清理环境、全新子进程和超时限制执行策略;它 MUST NOT 安装 Python audit hook(审计钩子)或自行模拟操作系统沙箱。本 capability(能力)不得声称支持第三方不可信策略。 + +#### Scenario: 执行仓库内策略 +- **WHEN** 固定 `_execute` 子进程加载已通过源码边界校验的 Strategy Module +- **THEN** 它直接通过标准导入执行,不复制或注入 `adapter_guard`,并继续受输入冻结、环境清理和超时限制 + +### Requirement: 所有项目必须通过固定共享入口运行 +系统 MUST 从项目 `.venv` 调用共享本地研究 CLI。项目配置 MUST 声明仓库内 strategy root、module 和 symbol,不得声明策略专属 project entry、任意命令、系统 Python 或隐式依赖安装。 + +#### Scenario: 运行合法项目配置 +- **WHEN** 配置声明有效的仓库内 Strategy Module、单一行情快照和一个场景 +- **THEN** runner 在清理后的子进程中调用固定共享 CLI 并冻结全部声明输入 + +#### Scenario: 配置尝试执行任意命令 +- **WHEN** 配置包含旧 `command`、策略专属 `project_entry`、仓库外模块路径或安装命令 +- **THEN** 系统在启动项目进程前拒绝配置并返回 `evidence_insufficient` + +### Requirement: 单场景停止状态必须保持固定 +共享运行 MUST 每次只接受一个场景,并且只返回 `complete`、`evidence_insufficient` 或 `failed`。完整运行 MUST 返回 `next_action=return_to_caller`;Skill 不得循环多个场景、解释策略字段或自动给出研究推荐。 + +#### Scenario: 输入证据缺失 +- **WHEN** 策略身份、配置、行情快照、范围或必需字段缺失 +- **THEN** 系统返回 `evidence_insufficient`,不猜测替代输入且不执行策略 + +#### Scenario: 执行或证据失败 +- **WHEN** vectorbt 执行异常、输出缺失、摘要冲突、临时清理失败或性能超限 +- **THEN** 系统返回 `failed` 并保留紧凑失败证据,不发布完整运行 + +### Requirement: 重构必须保持结果一致并提供性能观测 +系统 MUST 在真实规模、扩展资产和延迟场景校验重构前后结果。Schema、行数、成交、费用、现金、持仓、净值和逻辑摘要 MUST 完全一致;时间、内存和同逻辑核心/扩展 Parquet 数据载荷体积 MUST 在验证报告中列出,由用户人工确认,不设置自动相对门禁。日常 cold/warm 执行 MUST 继续满足各 180 秒超时。 + +正确性基线 MUST 直接从公开 `ExecutionLedger` 和 `ResultExtension` 生成,并用历史 v1 fixture 的 SHA256 记录迁移来源;系统不得为继续执行旧基线测试而保留第二套核心结果适配器。 + +#### Scenario: 真实规模性能复核 +- **WHEN** 在相同机器和输入上运行 3,432 日 × 11 ETF 主场景 +- **THEN** 结果证据零差异,验证报告列出与历史观测的时间、内存和 Parquet 数据载荷体积差异,并记录用户确认 + +#### Scenario: 扩展和延迟场景 +- **WHEN** 运行 3,432 日 × 17 ETF 场景以及 `additional_delay_days=1` 场景 +- **THEN** 两者分别通过相同的正确性与确定性校验,性能差异一并交由用户确认 + diff --git a/openspec/specs/local-research-archive-promotion/spec.md b/openspec/specs/local-research-archive-promotion/spec.md new file mode 100644 index 0000000..6e37a61 --- /dev/null +++ b/openspec/specs/local-research-archive-promotion/spec.md @@ -0,0 +1,70 @@ +# local-research-archive-promotion Specification + +## Purpose +TBD - created by archiving change refactor-local-research-three-layer-architecture. Update Purpose after archive. +## Requirements +### Requirement: 完成本地研究必须能够显式晋升为策略档案 +共享 CLI MUST 提供独立晋升动作,只接受已通过 `complete` 门禁且清单验证成功的 `.local/quant-research///`。目标 MUST 为 `joinquant/strategies//research/archives//`,并且 `analysis_id` 必须由调用者显式提供。 + +#### Scenario: 晋升一个完整运行 +- **WHEN** 调用者指定合法 strategy_id、run_id 和尚未使用的 analysis_id +- **THEN** 系统校验源运行并把完整档案原子发布到对应策略目录 + +#### Scenario: 尝试晋升失败运行 +- **WHEN** 源运行缺少 `complete` 状态、必需文件或有效摘要 +- **THEN** 系统拒绝晋升,不创建目标档案且不修改源运行 + +### Requirement: 策略档案必须对分析自包含 +每个档案 MUST 包含不可变清单、完整策略源码、运行配置、代码身份、四张核心事实表、全部声明策略扩展、性能证据、环境证据、行情快照身份和机械执行报告。调用者 MUST 能在不访问 `.local` 的情况下查询结果、比较参数、检查策略代码和重新生成机械执行报告。 + +#### Scenario: 删除运行缓存后复盘 +- **WHEN** 一个已晋升档案对应的 `.local` 运行缓存不可用 +- **THEN** analysis_data 仍能从档案清单打开全部已声明事实和策略扩展并生成分析视图 + +#### Scenario: 检查执行来源 +- **WHEN** 调用者检查档案的可重放证据 +- **THEN** 清单提供 Strategy Module、共享运行时、Git、第三方依赖、配置和 snapshot_id 的版本及摘要 + +#### Scenario: 机械执行报告保持事实边界 +- **WHEN** 共享流程为完成包生成 `report/` 内容 +- **THEN** 报告只包含可从包内复核的运行身份、参数、数据范围、成交与持仓统计、净值摘要、性能和完整性事实,不生成策略推荐、稳健性结论或实盘准入判断 + +### Requirement: 晋升不得重新计算研究结果 +晋升 MUST 只复制并校验完成运行中已经固化的字节。它不得加载 Strategy Module、调用 vectorbt、重新生成核心事实或扩展、重新序列化 Parquet、重新计算报告指标,且不得计入回测冷启动或预热耗时。 + +#### Scenario: 证明晋升没有执行引擎 +- **WHEN** 测试把策略和 vectorbt 调用替换为调用即失败后执行晋升 +- **THEN** 晋升仍成功,并且源、目标数据文件逐文件 SHA256 完全一致 + +#### Scenario: 共享行情保持单份 +- **WHEN** 晋升一个使用共享行情快照的运行 +- **THEN** 档案只复制快照身份、来源、范围、字段、价格口径和摘要,不复制共享 market-data.parquet + +### Requirement: 晋升必须不可变、幂等且冲突安全 +晋升 MUST 先在目标同级暂存目录复制全部文件并复核摘要,再以原子目录替换发布。相同 analysis_id 和相同内容 MUST 返回复用;相同 analysis_id 但内容不同 MUST 失败且不得覆盖、合并或生成隐式后缀。 + +晋升的安全边界 MUST 是本机同一用户控制的可信研究工作区:开始前用标准路径扫描拒绝 symlink、junction、hardlink 和非普通文件,使用 `shutil.copy2` 复制,复制后逐文件复核长度与 SHA256,并用 `os.replace` 原子发布。它不承诺防御同一用户在扫描后敌对替换源树,不得为此维护文件描述符、inode 或平台专属竞态状态机。 + +#### Scenario: 重复晋升相同内容 +- **WHEN** 调用者以同一 analysis_id 再次晋升同一个完整运行 +- **THEN** 系统重新验证既有档案并返回幂等复用,不改写任何文件 + +#### Scenario: analysis_id 内容冲突 +- **WHEN** 目标 analysis_id 已绑定不同 run_id 或不同文件摘要 +- **THEN** 系统返回失败并保持既有档案和源运行不变 + +#### Scenario: 发布中途失败 +- **WHEN** 复制、摘要验证或原子发布任一步骤失败 +- **THEN** 系统删除暂存目录,不留下半成品目标,也不影响其他档案 + +#### Scenario: 源树包含不支持的文件对象 +- **WHEN** 晋升前扫描发现符号链接、目录连接、硬链接或非普通文件 +- **THEN** 系统在复制前拒绝晋升且不创建完成目标 + +### Requirement: 本地研究档案必须与聚宽正式运行隔离 +本地研究档案 MUST 只写入 `research/archives/`,其清单和报告 MUST 明确标记本地探索性 vectorbt 来源。正式聚宽回测和模拟交易 MUST 继续分别写入 `backtests/` 和 `simulations/`,分析层不得因目录位于同一策略下而混淆运行身份。 + +#### Scenario: 列出策略下的研究和正式回测 +- **WHEN** 查询入口同时发现 `research/archives/` 和 `backtests/` +- **THEN** 它分别返回本地研究与聚宽正式运行类型,并保留各自 backend 和来源身份 + diff --git a/openspec/specs/local-research-result-package/spec.md b/openspec/specs/local-research-result-package/spec.md new file mode 100644 index 0000000..4abe467 --- /dev/null +++ b/openspec/specs/local-research-result-package/spec.md @@ -0,0 +1,67 @@ +# local-research-result-package Specification + +## Purpose +TBD - created by archiving change refactor-local-research-three-layer-architecture. Update Purpose after archive. +## Requirements +### Requirement: 共享结果包必须定义后端中立的核心事实 +系统 MUST 用版本化 Schema 定义结果、资金、持仓和订单四张核心事实表,并允许策略通过版本化扩展增加归因等证据。核心 writer 不得导入具体策略动作码,策略扩展不得改变核心表的字段含义。 + +#### Scenario: 海龟策略生成结果包 +- **WHEN** 海龟执行完成并提供策略轨迹 +- **THEN** 共享 writer 从 ExecutionLedger 生成四张核心表,并把海龟归因作为独立扩展写入清单 + +#### Scenario: 最小策略不提供归因 +- **WHEN** 第二个测试策略只提供核心执行账本而没有策略扩展 +- **THEN** 共享 writer 仍生成合法完整结果包,并明确声明没有对应扩展 + +### Requirement: 策略扩展必须使用有边界的 Arrow 契约 +每个 `ResultExtension.table` MUST 只包含扁平 `string`、`bool`、`int64` 或 `float64` 列。浮点缺失值 MUST 使用 Arrow null,不得使用 NaN。dictionary、list、struct、map、union、run-end encoded 及其他类型 MUST 在冷/热比较前以 `result_contract_failed` 拒绝;共享层不得实现递归 Arrow 类型解释器或任意类型逻辑哈希。 + +#### Scenario: 比较合法扩展 +- **WHEN** 冷启动和预热产生相同的扁平扩展表 +- **THEN** 共享层先调用 `Table.validate(full=True)`、比较精确 Schema,再用 `Table.equals(check_metadata=True)` 判断相等 + +#### Scenario: 拒绝越界扩展 +- **WHEN** 扩展包含 NaN、嵌套、字典编码或其他未允许类型 +- **THEN** 系统在冷/热确定性比较和 Parquet 写入前返回 `failed` 与 `result_contract_failed` + +### Requirement: 账本视图和事实数据只能物化一次 +ExecutionLedger MUST 对 orders、assets、cash 和 value 使用只读惰性缓存;共享 writer MUST 直接消费这些视图并只执行一次公共事实转换、一次 Parquet 固化和一次回读。内部 writer MUST 复用这次回读事实完成 Schema、唯一键、跨表勾稽、报告和最终清单;公开 validator MUST 只从磁盘读取并供复用、晋升和外部查询。运行路径不得通过 JSON、字典或 Arrow 往返重新构造账本,不得在多层重复复制完整矩阵,也不得通过 `preloaded_*` 参数或 provisional/final 两套完整包维护第二条校验路径。 + +#### Scenario: 多个消费者读取净值 +- **WHEN** 性能摘要、结果 writer 和策略归因都需要组合净值 +- **THEN** 它们复用同一个缓存视图,不分别调用 vectorbt 生成或复制完整净值数组 + +#### Scenario: 策略不需要某类轨迹 +- **WHEN** Strategy Module 未声明某个策略审计字段 +- **THEN** 执行底层不为该字段分配行数乘证券数的稠密矩阵 + +### Requirement: 结果包必须可验证并原子发布 +系统 MUST 为每个数据集记录路径、状态、行数、时间范围、Schema 版本和 SHA256,并在暂存目录完成写入、回读、跨表校验和核心事实摘要后原子发布。核心事实确定性使用 NumPy 数组摘要;扩展确定性使用精确 Schema 与 `Table.equals(check_metadata=True)`;文件完整性使用最终 Parquet 字节的 SHA256。任何校验或发布失败 MUST 清理暂存目录且不得留下可被识别为完整的结果。 + +#### Scenario: 成功固化结果包 +- **WHEN** 四张核心表和全部声明扩展通过 Schema、摘要和跨表勾稽 +- **THEN** 系统原子发布数据文件和清单,并把运行标记为 `complete` + +#### Scenario: 回读事实不一致 +- **WHEN** Parquet 回读后的核心事实摘要、扩展表或文件摘要与内存事实和最终清单不同 +- **THEN** 系统返回 `failed`,删除暂存结果且不覆盖既有完整运行 + +### Requirement: 共享分析必须读取本地和聚宽结果 +analysis_data MUST 通过后端中立清单读取新的本地结果包和既有聚宽归档,并为相同概念提供一致查询视图。任何来源差异、缺失数据集和公式版本 MUST 保留显式证据,不得伪造成相同来源或静默补全。 + +#### Scenario: 比较本地研究和聚宽回测 +- **WHEN** 分析流程同时打开一个本地 vectorbt 结果和一个聚宽正式回测 +- **THEN** 它通过统一视图查询共同事实,同时保留 backend、来源身份和数据集状态 + +#### Scenario: 使用 vectorbt 统计交叉校验 +- **WHEN** 分析流程调用 vectorbt returns 或 stats 复核收益指标 +- **THEN** 结果被标记为交叉校验,不静默替换现有 Alpha、Information Ratio、CVaR 或其他公式版本 + +### Requirement: 结果包必须记录诚实的日常性能证据 +结果包内 `performance.json` MUST 记录从 writer 启动到最终 evidence/report/manifest 写入前的 `prefinalization_seconds` 及各阶段耗时,不得宣称包含尚未发生的自身写入或父进程发布。日常 cold/warm 检查 MUST 使用 writer 返回时的完整耗时,覆盖策略执行、核心事实、策略扩展、Parquet 写入、回读校验、摘要和最终元数据写入。系统不得为发布性能比较恢复 provisional/final 双包、第二次元数据写入或旁路清单;发布级性能差异只在外部验证报告中列示并由用户人工确认。 + +#### Scenario: 抽取共享 writer +- **WHEN** 原策略 writer 迁移到共享结果包 +- **THEN** 包内预最终化和日常 writer 返回耗时使用明确边界,并能区分执行、事实转换、扩展和文件阶段 + diff --git a/scripts/research/analysis_data/__init__.py b/scripts/research/analysis_data/__init__.py index 4c940f1..bbc855d 100644 --- a/scripts/research/analysis_data/__init__.py +++ b/scripts/research/analysis_data/__init__.py @@ -2,6 +2,7 @@ from .manifest import ( CORE_DATASETS, + LOCAL_PHYSICAL_DATASETS, AnalysisManifestError, AnalysisSource, ValidationResult, @@ -13,6 +14,7 @@ __all__ = [ "AnalysisDatabase", "CORE_DATASETS", + "LOCAL_PHYSICAL_DATASETS", "AnalysisManifestError", "AnalysisSource", "ValidationResult", diff --git a/scripts/research/analysis_data/manifest.py b/scripts/research/analysis_data/manifest.py index 52add6c..f9ca7e0 100644 --- a/scripts/research/analysis_data/manifest.py +++ b/scripts/research/analysis_data/manifest.py @@ -13,6 +13,11 @@ from jsonschema import Draft202012Validator, FormatChecker from jsonschema.exceptions import SchemaError, ValidationError +from scripts.research.local_quant_research.result_package import ( + ResultContractError, + validate_result_package, +) + CORE_DATASETS = ( "results", @@ -91,6 +96,9 @@ class AnalysisSource: kind: str schema_version: int | str manifest: Mapping[str, object] + authority: str | None = None + backend: str | None = None + formula_version: str | None = None def __post_init__(self) -> None: object.__setattr__(self, "root", Path(self.root).resolve()) @@ -549,6 +557,12 @@ def open_analysis_source(result_dir: Path) -> AnalysisSource: raise AnalysisManifestError("local archive gate did not pass") _validate_local_files(root, document) kind = "local_backtest" + elif version == "local-research-package/2": + try: + document = dict(validate_result_package(root)) + except ResultContractError as exc: + raise AnalysisManifestError(str(exc)) from exc + kind = "local_research" else: raise AnalysisManifestError("unsupported analysis manifest schema") return AnalysisSource( @@ -556,6 +570,21 @@ def open_analysis_source(result_dir: Path) -> AnalysisSource: kind=kind, schema_version=version, manifest=document, + authority=( + str(document["authority"]) + if isinstance(document.get("authority"), str) + else None + ), + backend=( + str(document["backend"]) + if isinstance(document.get("backend"), str) + else None + ), + formula_version=( + str(document["formula_version"]) + if isinstance(document.get("formula_version"), str) + else None + ), ) @@ -566,10 +595,16 @@ def validate_analysis_source(source: AnalysisSource) -> ValidationResult: elif source.kind == "local_backtest": validate_local_manifest_document(source.manifest) _validate_local_files(source.root, source.manifest) + elif source.kind == "local_research": + try: + validate_result_package(source.root) + except ResultContractError as exc: + raise AnalysisManifestError(str(exc)) from exc else: raise AnalysisManifestError("unsupported analysis source kind") datasets = _object(source.manifest["datasets"], "datasets") + names = LOCAL_PHYSICAL_DATASETS if source.kind == "local_research" else CORE_DATASETS return ValidationResult( status="pass", - datasets={name: str(_object(datasets[name], name)["status"]) for name in CORE_DATASETS}, + datasets={name: str(_object(datasets[name], name)["status"]) for name in names}, ) diff --git a/scripts/research/analysis_data/views.py b/scripts/research/analysis_data/views.py index 9c21354..e00f9b5 100644 --- a/scripts/research/analysis_data/views.py +++ b/scripts/research/analysis_data/views.py @@ -11,6 +11,7 @@ from scripts.research.analysis_data.derived import register_return_views from scripts.research.analysis_data.manifest import ( CORE_DATASETS, + LOCAL_PHYSICAL_DATASETS, AnalysisManifestError, AnalysisSource, open_analysis_source, @@ -144,14 +145,14 @@ def _validate_physical_fields( expected = tuple(field for field, _ in _SCHEMAS[name]) fields_match = ( actual == expected - if source.kind == "local_backtest" + if source.kind in {"local_backtest", "local_research"} else len(actual) == len(expected) and set(actual) == set(expected) ) if not fields_match: raise AnalysisManifestError( f"{source.kind} {name} fields do not match the observed contract" ) - if source.kind == "local_backtest" and name == "results": + if source.kind in {"local_backtest", "local_research"} and name == "results": schema = pq.read_schema(path) if ( str(schema.field("benchmark_returns").type) != "double" @@ -176,16 +177,38 @@ class AnalysisDatabase: @property def table_names(self) -> tuple[str, ...]: + if self.source.kind == "local_research": + return LOCAL_PHYSICAL_DATASETS return CORE_DATASETS def reference_status(self, name: str) -> tuple[str, str | None]: - if name not in CORE_DATASETS: + if name not in self.table_names: raise KeyError(name) entry = self.source.manifest["datasets"][name] return str(entry["status"]), ( None if "reason" not in entry else str(entry["reason"]) ) + def extension(self, name: str) -> duckdb.DuckDBPyRelation: + if self.source.kind != "local_research": + raise KeyError(name) + extensions = self.source.manifest["extensions"] + if not isinstance(extensions, Mapping) or name not in extensions: + raise KeyError(name) + entry = extensions[name] + if not isinstance(entry, Mapping): + raise AnalysisManifestError(f"extension {name} declaration is invalid") + files = entry.get("files") + if not isinstance(files, list) or len(files) != 1: + raise AnalysisManifestError(f"extension {name} file is missing") + reference = files[0] + if not isinstance(reference, Mapping) or not isinstance(reference.get("path"), str): + raise AnalysisManifestError(f"extension {name} file is invalid") + path = self.source.root / str(reference["path"]) + return self.connection.sql( + f"select * from read_parquet({_quote_literal(path.as_posix())})" + ) + def close(self) -> None: self.connection.close() @@ -205,7 +228,12 @@ def open_analysis_database(result_dir: Path) -> AnalysisDatabase: source = open_analysis_source(result_dir) connection = duckdb.connect(":memory:") try: - for name in CORE_DATASETS: + names = ( + LOCAL_PHYSICAL_DATASETS + if source.kind == "local_research" + else CORE_DATASETS + ) + for name in names: path = _declared_parquet_path(source, name) schema = _SCHEMAS[name] if path is None: diff --git a/scripts/research/local_quant_research/adapter_guard.py b/scripts/research/local_quant_research/adapter_guard.py deleted file mode 100644 index 9efe71c..0000000 --- a/scripts/research/local_quant_research/adapter_guard.py +++ /dev/null @@ -1,174 +0,0 @@ -from __future__ import annotations - -import argparse -import os -import runpy -import shutil -import sys -from pathlib import Path -from typing import Sequence - - -_WRITE_FLAGS = ( - os.O_WRONLY - | os.O_RDWR - | os.O_APPEND - | os.O_CREAT - | os.O_TRUNC - | getattr(os, "O_TEMPORARY", 0) -) -_SINGLE_PATH_MUTATIONS = { - "os.remove", - "os.rmdir", - "os.mkdir", - "os.chmod", - "os.chown", - "os.truncate", - "os.utime", -} -_TWO_PATH_MUTATIONS = {"os.rename", "os.replace", "os.link", "os.symlink"} -_PROCESS_EVENTS = { - "subprocess.Popen", - "os.system", - "os.posix_spawn", - "os.spawn", - "os.startfile", -} - - -def _path_from_event(value: object) -> Path | None: - if isinstance(value, int): - return None - try: - return Path(os.fsdecode(os.fspath(value))).resolve() - except (TypeError, ValueError, OSError): - return None - - -def _inside(path: Path, root: Path) -> bool: - try: - path.relative_to(root) - except ValueError: - return False - return True - - -def _require_staging_path(value: object, output_root: Path) -> None: - path = _path_from_event(value) - if path is not None and not _inside(path, output_root): - raise PermissionError("adapter write outside staging is forbidden") - - -def _open_is_write(args: Sequence[object]) -> bool: - mode = args[1] if len(args) > 1 else None - flags = args[2] if len(args) > 2 else 0 - mode_writes = isinstance(mode, str) and any(marker in mode for marker in "wax+") - flag_writes = isinstance(flags, int) and bool(flags & _WRITE_FLAGS) - return mode_writes or flag_writes - - -def _is_devnull(value: object) -> bool: - if isinstance(value, int): - return False - try: - return os.path.normcase(os.fsdecode(os.fspath(value))) == os.path.normcase( - os.devnull - ) - except (TypeError, ValueError, OSError): - return False - - -def install_access_guard( - output_dir: Path, - *, - execution_root: Path, - repository_root: Path, - venv_root: Path, -) -> None: - output_root = Path(output_dir).resolve() - execution_root = Path(execution_root).resolve() - repository_root = Path(repository_root).resolve() - venv_root = Path(venv_root).resolve() - - def audit(event: str, args: tuple[object, ...]) -> None: - if ( - event == "open" - and args - and _open_is_write(args) - and not _is_devnull(args[0]) - ): - _require_staging_path(args[0], output_root) - elif event == "open" and args: - path = _path_from_event(args[0]) - if ( - path is not None - and _inside(path, repository_root) - and not any( - _inside(path, allowed) - for allowed in (output_root, execution_root, venv_root) - ) - ): - raise PermissionError("adapter read from live repository is forbidden") - elif event in _SINGLE_PATH_MUTATIONS and args: - _require_staging_path(args[0], output_root) - elif event in _TWO_PATH_MUTATIONS and len(args) >= 2: - _require_staging_path(args[0], output_root) - _require_staging_path(args[1], output_root) - elif event in _PROCESS_EVENTS: - raise PermissionError("adapter child processes are forbidden") - - sys.addaudithook(audit) - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(add_help=False) - parser.add_argument("--staging-root", type=Path, required=True) - parser.add_argument("--execution-root", type=Path, required=True) - parser.add_argument("--repository-root", type=Path, required=True) - parser.add_argument("--venv-root", type=Path, required=True) - parser.add_argument("--entry", type=Path, required=True) - return parser - - -def main(argv: list[str] | None = None) -> int: - args, adapter_args = _parser().parse_known_args(argv) - if adapter_args and adapter_args[0] == "--": - adapter_args = adapter_args[1:] - output_dir = args.staging_root.resolve() - execution_root = args.execution_root.resolve() - entry = args.entry.resolve() - if ( - not output_dir.is_dir() - or not execution_root.is_dir() - or not entry.is_file() - or not _inside(entry, execution_root) - ): - return 2 - runtime_cache = output_dir / ".runtime-cache" - numba_cache = runtime_cache / "numba" - matplotlib_cache = runtime_cache / "matplotlib" - numba_cache.mkdir(parents=True) - matplotlib_cache.mkdir() - os.environ["NUMBA_CACHE_DIR"] = str(numba_cache) - os.environ["MPLCONFIGDIR"] = str(matplotlib_cache) - os.environ["XDG_CACHE_HOME"] = str(runtime_cache) - install_access_guard( - output_dir, - execution_root=execution_root, - repository_root=args.repository_root, - venv_root=args.venv_root, - ) - sys.path.insert(0, str(execution_root / "repository")) - sys.path.insert(0, str(entry.parent)) - sys.argv = [str(entry), *adapter_args] - try: - runpy.run_path(str(entry), run_name="__main__") - finally: - shutil.rmtree(runtime_cache) - if runtime_cache.exists(): - raise RuntimeError("adapter runtime cache cleanup failed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/research/local_quant_research/archive.py b/scripts/research/local_quant_research/archive.py new file mode 100644 index 0000000..abd74ca --- /dev/null +++ b/scripts/research/local_quant_research/archive.py @@ -0,0 +1,410 @@ +from __future__ import annotations + +import hashlib +import os +import re +import shutil +import stat +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from scripts.research import analysis_data + +from .result_package import ResultContractError, validate_result_package + + +_IDENTIFIER = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}") +_RUN_ID = re.compile(r"[0-9a-f]{64}") +_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x0400) +_WINDOWS_RESERVED_NAMES = { + "aux", + "con", + "nul", + "prn", + *(f"com{number}" for number in range(1, 10)), + *(f"lpt{number}" for number in range(1, 10)), +} + + +@dataclass(frozen=True, slots=True) +class ArchiveResult: + status: Literal["complete", "failed", "conflict"] + reused: bool + source: Path | None + target: Path | None + reasons: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class _FileEntry: + relative: Path + size: int + sha256: str + + +@dataclass(frozen=True, slots=True) +class _TreeSnapshot: + directories: tuple[Path, ...] + files: tuple[_FileEntry, ...] + digest: str + + +class _UnsafeTreeError(ValueError): + pass + + +def _failed( + reason: str, + *, + source: Path | None = None, + target: Path | None = None, +) -> ArchiveResult: + return ArchiveResult("failed", False, source, target, (reason,)) + + +def _path_exists(path: Path) -> bool: + return os.path.lexists(os.fspath(path)) + + +def _is_reparse_point(metadata: os.stat_result) -> bool: + attributes = getattr(metadata, "st_file_attributes", 0) + return bool(attributes & _REPARSE_POINT) + + +def _plain_directory(path: Path) -> bool: + try: + metadata = path.lstat() + except OSError: + return False + return stat.S_ISDIR(metadata.st_mode) and not _is_reparse_point(metadata) + + +def _safe_identifier(value: str) -> bool: + return ( + _IDENTIFIER.fullmatch(value) is not None + and not value.endswith((".", " ")) + and value.split(".", 1)[0] not in _WINDOWS_RESERVED_NAMES + ) + + +def _ordinary_child(parent: Path, name: str) -> Path | None: + if not _plain_directory(parent): + raise _UnsafeTreeError("path parent is not an ordinary directory") + try: + with os.scandir(parent) as iterator: + entry = next((item for item in iterator if item.name == name), None) + except OSError as exc: + raise _UnsafeTreeError("path parent is unreadable") from exc + if entry is None: + return None + child = Path(entry.path) + if not _plain_directory(child): + raise _UnsafeTreeError("path component is not an ordinary directory") + return child + + +def _existing_directory(root: Path, *parts: str) -> Path | None: + current = root + if not _plain_directory(current): + raise _UnsafeTreeError("repository root is not an ordinary directory") + for part in parts: + child = _ordinary_child(current, part) + if child is None: + return None + current = child + return current + + +def _optional_directory(root: Path, *parts: str) -> Path: + current = root + for index, part in enumerate(parts): + child = _ordinary_child(current, part) + if child is None: + return current.joinpath(*parts[index:]) + current = child + return current + + +def _file_digest(path: Path) -> tuple[int, str]: + digest = hashlib.sha256() + size = 0 + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + size += len(chunk) + digest.update(chunk) + return size, digest.hexdigest() + + +def _tree_digest( + directories: tuple[Path, ...], files: tuple[_FileEntry, ...] +) -> str: + digest = hashlib.sha256() + for relative in directories: + digest.update(b"directory\0") + digest.update(relative.as_posix().encode("utf-8")) + digest.update(b"\0") + for entry in files: + digest.update(b"file\0") + digest.update(entry.relative.as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(str(entry.size).encode("ascii")) + digest.update(b"\0") + digest.update(bytes.fromhex(entry.sha256)) + return digest.hexdigest() + + +def _scan_tree(root: Path) -> _TreeSnapshot: + try: + root_metadata = root.lstat() + except OSError as exc: + raise _UnsafeTreeError("tree root is unreadable") from exc + if not stat.S_ISDIR(root_metadata.st_mode) or _is_reparse_point(root_metadata): + raise _UnsafeTreeError("tree root is not an ordinary directory") + + directories: list[Path] = [] + files: list[_FileEntry] = [] + + def visit(directory: Path, relative_root: Path) -> None: + with os.scandir(directory) as iterator: + entries = sorted(iterator, key=lambda item: item.name) + for entry in entries: + relative = relative_root / entry.name + entry_path = Path(entry.path) + try: + metadata = entry_path.lstat() + except OSError as exc: + raise _UnsafeTreeError("tree entry is unreadable") from exc + if entry.is_symlink() or _is_reparse_point(metadata): + raise _UnsafeTreeError("tree contains a link or reparse point") + if stat.S_ISDIR(metadata.st_mode): + directories.append(relative) + visit(entry_path, relative) + continue + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise _UnsafeTreeError("tree contains a non-ordinary file") + try: + size, sha256 = _file_digest(entry_path) + except OSError as exc: + raise _UnsafeTreeError("tree file is unreadable") from exc + if size != metadata.st_size: + raise _UnsafeTreeError("tree file changed during inspection") + files.append(_FileEntry(relative, size, sha256)) + + visit(root, Path()) + directory_entries = tuple(directories) + file_entries = tuple(files) + return _TreeSnapshot( + directories=directory_entries, + files=file_entries, + digest=_tree_digest(directory_entries, file_entries), + ) + + +def _prepare_archives_directory( + strategy_root: Path, + created: list[Path], +) -> Path: + research = strategy_root / "research" + archives = research / "archives" + for directory in (research, archives): + if not _path_exists(directory): + directory.mkdir() + created.append(directory) + if not _plain_directory(directory): + raise _UnsafeTreeError("archive parent is not an ordinary directory") + return archives + + +def _cleanup_failed_promotion( + staging: Path | None, + created_parents: tuple[Path, ...], +) -> bool: + cleanup_failed = False + if staging is not None and _path_exists(staging): + try: + if not _plain_directory(staging): + raise _UnsafeTreeError( + "archive staging is not an ordinary directory" + ) + shutil.rmtree(staging) + except Exception: + cleanup_failed = True + for directory in reversed(created_parents): + if not _path_exists(directory): + continue + try: + if not _plain_directory(directory): + raise _UnsafeTreeError( + "created archive parent is not an ordinary directory" + ) + directory.rmdir() + except Exception: + cleanup_failed = True + return cleanup_failed + + +def _copy_tree(source: Path, staging: Path) -> None: + shutil.copytree(source, staging, copy_function=shutil.copy2) + + +def _existing_result( + source: Path, + target: Path, + source_snapshot: _TreeSnapshot, +) -> ArchiveResult: + try: + target_snapshot = _scan_tree(target) + except (OSError, _UnsafeTreeError): + target_snapshot = None + if target_snapshot is not None and target_snapshot.digest == source_snapshot.digest: + try: + _validate_analysis_views(target) + except Exception: + pass + else: + return ArchiveResult("complete", True, source, target, ()) + return ArchiveResult( + "conflict", + False, + source, + target, + ("target_conflict",), + ) + + +def _validate_analysis_views(staging: Path) -> None: + with analysis_data.open_analysis_database(staging) as database: + source = database.source + if ( + source.kind != "local_research" + or source.authority != "local_research" + or source.backend != "vectorbt" + or source.formula_version != "unified-strategy-analysis/1" + ): + raise ValueError("archive analysis identity is invalid") + if ( + database.table_names != analysis_data.LOCAL_PHYSICAL_DATASETS + or len(database.table_names) != 4 + ): + raise ValueError("archive core analysis tables are incomplete") + for name in database.table_names: + database.connection.table(name).limit(1).fetchall() + extensions = source.manifest.get("extensions") + if not isinstance(extensions, Mapping): + raise ValueError("archive extensions are invalid") + for name in sorted(extensions): + if not isinstance(name, str): + raise ValueError("archive extension name is invalid") + database.extension(name).limit(1).fetchall() + + +def promote_archive( + repo_root: Path, + strategy_id: str, + run_id: str, + analysis_id: str, +) -> ArchiveResult: + root = Path(repo_root).absolute() + if not _safe_identifier(strategy_id): + return _failed("invalid_strategy_id") + try: + strategy_root = _existing_directory( + root, "joinquant", "strategies", strategy_id + ) + except _UnsafeTreeError: + return _failed("invalid_strategy_id") + if strategy_root is None: + return _failed("invalid_strategy_id") + if _RUN_ID.fullmatch(run_id) is None: + return _failed("invalid_run_id") + if not _safe_identifier(analysis_id): + return _failed("invalid_analysis_id") + + try: + source = _existing_directory( + root, ".local", "quant-research", strategy_id, run_id + ) + except _UnsafeTreeError: + source = root / ".local" / "quant-research" / strategy_id / run_id + target = strategy_root / "research" / "archives" / analysis_id + return _failed("unsafe_source_entry", source=source, target=target) + try: + archives_path = _optional_directory(strategy_root, "research", "archives") + except _UnsafeTreeError: + source_path = ( + source + if source is not None + else root / ".local" / "quant-research" / strategy_id / run_id + ) + target = strategy_root / "research" / "archives" / analysis_id + return _failed("unsafe_archive_parent", source=source_path, target=target) + target = archives_path / analysis_id + if source is None: + source = root / ".local" / "quant-research" / strategy_id / run_id + return _failed("source_incomplete", source=source, target=target) + try: + manifest = validate_result_package(source) + except (OSError, ResultContractError): + return _failed("source_incomplete", source=source, target=target) + identity = manifest.get("object") + if not isinstance(identity, Mapping) or ( + identity.get("strategy_id") != strategy_id + or identity.get("run_id") != run_id + ): + return _failed("source_identity_mismatch", source=source, target=target) + try: + source_snapshot = _scan_tree(source) + except (OSError, _UnsafeTreeError): + return _failed("unsafe_source_entry", source=source, target=target) + + if _path_exists(target): + return _existing_result(source, target, source_snapshot) + + staging: Path | None = None + created_parents: list[Path] = [] + try: + archives = _prepare_archives_directory(strategy_root, created_parents) + if _path_exists(target): + return _existing_result(source, target, source_snapshot) + staging = archives / f".{analysis_id}.{uuid.uuid4().hex}.tmp" + _copy_tree(source, staging) + staging_snapshot = _scan_tree(staging) + if staging_snapshot.digest != source_snapshot.digest: + raise OSError("archive tree verification failed") + _validate_analysis_views(staging) + if _path_exists(target): + existing = _existing_result(source, target, source_snapshot) + if _cleanup_failed_promotion(staging, ()): + return _failed( + "cleanup_failed", + source=source, + target=target, + ) + return existing + try: + os.replace(staging, target) + except OSError: + if not _path_exists(target): + raise + existing = _existing_result(source, target, source_snapshot) + if _cleanup_failed_promotion(staging, ()): + return _failed( + "cleanup_failed", + source=source, + target=target, + ) + return existing + return ArchiveResult("complete", False, source, target, ()) + except Exception: + cleanup_failed = _cleanup_failed_promotion( + staging, + tuple(created_parents), + ) + return _failed( + "cleanup_failed" if cleanup_failed else "copy_failed", + source=source, + target=target, + ) diff --git a/scripts/research/local_quant_research/cli.py b/scripts/research/local_quant_research/cli.py index d9d7b26..167b5be 100644 --- a/scripts/research/local_quant_research/cli.py +++ b/scripts/research/local_quant_research/cli.py @@ -1,17 +1,152 @@ from __future__ import annotations import argparse +import importlib import json +import os +import re +import stat import sys +import tempfile from pathlib import Path +from typing import Mapping +REPO_ROOT = Path(__file__).resolve().parents[3] if __package__ in {None, ""}: - REPO_ROOT = Path(__file__).resolve().parents[3] sys.path.insert(0, str(REPO_ROOT)) - from scripts.research.local_quant_research.runner import run_project -else: - REPO_ROOT = Path(__file__).resolve().parents[3] - from .runner import run_project + + +class _BootstrapError(ValueError): + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +_PROJECT_ID = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}") +_RUN_ID = re.compile(r"[0-9a-f]{64}") +_ATTEMPT_ID = re.compile(r"[0-9a-f]{32}") +_FILE_ATTRIBUTE_REPARSE_POINT = 0x0400 + + +def _inside(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True + + +def _absolute_path(value: object) -> Path: + if not isinstance(value, (str, os.PathLike)): + raise _BootstrapError("unsafe_frozen_inputs") + path = Path(value) + if not path.is_absolute(): + raise _BootstrapError("unsafe_frozen_inputs") + return Path(os.path.abspath(path)) + + +def _same_path(left: Path, right: Path) -> bool: + return os.path.normcase(str(left)) == os.path.normcase(str(right)) + + +def _is_reparse(path: Path, details: os.stat_result) -> bool: + return ( + stat.S_ISLNK(details.st_mode) + or bool( + getattr(details, "st_file_attributes", 0) + & _FILE_ATTRIBUTE_REPARSE_POINT + ) + or bool(getattr(os.path, "isjunction", lambda _path: False)(path)) + ) + + +def _require_plain_path( + path: Path, + *, + kind: str, + allow_missing: bool = False, +) -> None: + current = Path(path.anchor) + parts = path.parts[1:] + for index, part in enumerate(parts): + current /= part + final = index == len(parts) - 1 + try: + details = os.lstat(current) + except FileNotFoundError: + if allow_missing and final: + return + raise _BootstrapError("unsafe_frozen_inputs") from None + except OSError as exc: + raise _BootstrapError("unsafe_frozen_inputs") from exc + if _is_reparse(current, details): + raise _BootstrapError("unsafe_frozen_inputs") + if not final and not stat.S_ISDIR(details.st_mode): + raise _BootstrapError("unsafe_frozen_inputs") + if final and ( + (kind == "directory" and not stat.S_ISDIR(details.st_mode)) + or (kind == "file" and not stat.S_ISREG(details.st_mode)) + ): + raise _BootstrapError("unsafe_frozen_inputs") + + +def _bootstrap_request(frozen_inputs: Path, staging: Path) -> Mapping[str, object]: + request_path = _absolute_path(frozen_inputs) + _require_plain_path(request_path, kind="file") + execution_root = request_path.parent + try: + request = json.loads(request_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise _BootstrapError("invalid_frozen_inputs") from exc + if not isinstance(request, Mapping) or request.get("schema_version") != 2: + raise _BootstrapError("invalid_frozen_inputs") + expected_staging = _absolute_path(request.get("staging", "")) + supplied_staging = _absolute_path(staging) + if not _same_path(supplied_staging, expected_staging): + raise _BootstrapError("staging_mismatch") + project_id = request.get("project_id") + run_id = request.get("run_id") + attempt_id = request.get("attempt_id") + if ( + not isinstance(project_id, str) + or _PROJECT_ID.fullmatch(project_id) is None + or not isinstance(run_id, str) + or _RUN_ID.fullmatch(run_id) is None + or not isinstance(attempt_id, str) + or _ATTEMPT_ID.fullmatch(attempt_id) is None + ): + raise _BootstrapError("unsafe_frozen_inputs") + output_root = Path(os.path.abspath(REPO_ROOT / ".local/quant-research")) + project_root = output_root / project_id + expected_execution = project_root / f".{run_id}.{attempt_id}.inputs" + expected_staging_name = project_root / f".{run_id}.{attempt_id}.tmp" + repository = _absolute_path(request.get("repository", "")) + market_data = _absolute_path(request.get("market_data", "")) + runtime_cache = _absolute_path(request.get("runtime_cache", "")) + live_repository = _absolute_path(request.get("live_repository", "")) + declared_output = _absolute_path(request.get("output_root", "")) + if ( + not _same_path(live_repository, REPO_ROOT) + or not _same_path(declared_output, output_root) + or not _same_path(execution_root, expected_execution) + or request_path.name != "request.json" + or not _same_path(expected_staging, expected_staging_name) + or not _same_path(repository, execution_root / "repository") + or not _same_path(market_data, execution_root / "market-data") + or not _same_path(runtime_cache, execution_root / "runtime-cache") + ): + raise _BootstrapError("unsafe_frozen_inputs") + for path in ( + output_root, + project_root, + execution_root, + repository, + market_data, + runtime_cache, + ): + _require_plain_path(path, kind="directory") + _require_plain_path(expected_staging, kind="directory", allow_missing=True) + return request def _parser() -> argparse.ArgumentParser: @@ -19,11 +154,137 @@ def _parser() -> argparse.ArgumentParser: subparsers = parser.add_subparsers(dest="action", required=True) run = subparsers.add_parser("run") run.add_argument("--config", type=Path, required=True) + promote = subparsers.add_parser("promote") + promote.add_argument("--strategy-id", required=True) + promote.add_argument("--run-id", required=True) + promote.add_argument("--analysis-id", required=True) return parser +def promote_archive( + repo_root: Path, + strategy_id: str, + run_id: str, + analysis_id: str, +): + if __package__ in {None, ""}: + from scripts.research.local_quant_research.archive import ( + promote_archive as implementation, + ) + else: + from .archive import promote_archive as implementation + + return implementation(repo_root, strategy_id, run_id, analysis_id) + + +def _private_execute(argv: list[str]) -> int: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--frozen-inputs", type=Path, required=True) + parser.add_argument("--staging", type=Path, required=True) + args = parser.parse_args(argv) + try: + bootstrap = _bootstrap_request(args.frozen_inputs, args.staging) + frozen_repository = _absolute_path(bootstrap["repository"]) + runtime_cache = _absolute_path(bootstrap["runtime_cache"]) + numba_cache = runtime_cache / "numba" + matplotlib_cache = runtime_cache / "matplotlib" + numba_cache.mkdir() + matplotlib_cache.mkdir() + for name in ("TMP", "TEMP", "TMPDIR"): + os.environ[name] = str(runtime_cache) + os.environ["NUMBA_CACHE_DIR"] = str(numba_cache) + os.environ["MPLCONFIGDIR"] = str(matplotlib_cache) + os.environ["XDG_CACHE_HOME"] = str(runtime_cache) + tempfile.tempdir = str(runtime_cache) + sys.path.insert(0, str(frozen_repository)) + importlib.invalidate_caches() + from scripts.research.local_quant_research.contracts import StrategyEvidenceError + from scripts.research.local_quant_research.performance import PerformanceGateError + from scripts.research.local_quant_research.result_package import ResultContractError + from scripts.research.local_quant_research.runner import ( + ConfigurationError, + execute_frozen_inputs, + ) + from scripts.research.local_quant_research.strategy_loader import ( + ConfigurationError as StrategyConfigurationError, + ) + from scripts.research.market_data.storage import MarketDataError + except _BootstrapError as exc: + print( + json.dumps( + {"status": "evidence_insufficient", "reasons": [exc.code]}, + ensure_ascii=False, + sort_keys=True, + ) + ) + return 2 + except Exception: + print( + json.dumps( + {"status": "failed", "reasons": ["frozen_bootstrap_failed"]}, + ensure_ascii=False, + sort_keys=True, + ) + ) + return 1 + + try: + document = execute_frozen_inputs(args.frozen_inputs, args.staging) + except (ConfigurationError, StrategyConfigurationError) as exc: + document = {"status": "evidence_insufficient", "reasons": [exc.code]} + except StrategyEvidenceError as exc: + document = {"status": "evidence_insufficient", "reasons": [exc.code]} + except MarketDataError as exc: + message = str(exc).lower() + if "missing" in message or "not found" in message: + document = { + "status": "evidence_insufficient", + "reasons": ["market_data_missing"], + } + else: + document = {"status": "failed", "reasons": ["market_data_failed"]} + except PerformanceGateError as exc: + document = {"status": "failed", "reasons": [exc.code]} + except ResultContractError: + document = {"status": "failed", "reasons": ["result_contract_failed"]} + except PermissionError: + document = {"status": "failed", "reasons": ["access_guard_violation"]} + except Exception: + document = {"status": "failed", "reasons": ["execution_failed"]} + print(json.dumps(document, ensure_ascii=False, sort_keys=True)) + return { + "complete": 0, + "failed": 1, + "evidence_insufficient": 2, + }[str(document["status"])] + + def main(argv: list[str] | None = None) -> int: - args = _parser().parse_args(argv) + values = list(sys.argv[1:] if argv is None else argv) + if values[:1] == ["_execute"]: + return _private_execute(values[1:]) + args = _parser().parse_args(values) + if args.action == "promote": + result = promote_archive( + REPO_ROOT, + args.strategy_id, + args.run_id, + args.analysis_id, + ) + document = { + "status": result.status, + "reused": result.reused, + "source": None if result.source is None else str(result.source), + "target": None if result.target is None else str(result.target), + "reasons": list(result.reasons), + } + print(json.dumps(document, ensure_ascii=False, sort_keys=True)) + return {"complete": 0, "conflict": 1, "failed": 2}[result.status] + if __package__ in {None, ""}: + from scripts.research.local_quant_research.runner import run_project + else: + from .runner import run_project + result = run_project(args.config, repo_root=REPO_ROOT) print(json.dumps(result.to_document(), ensure_ascii=False, sort_keys=True)) return {"complete": 0, "failed": 1, "evidence_insufficient": 2}[result.status] diff --git a/scripts/research/local_quant_research/contracts.py b/scripts/research/local_quant_research/contracts.py index f2142bd..9bb65ab 100644 --- a/scripts/research/local_quant_research/contracts.py +++ b/scripts/research/local_quant_research/contracts.py @@ -3,10 +3,207 @@ from dataclasses import dataclass from pathlib import Path from types import MappingProxyType -from typing import Literal, Mapping +from typing import TYPE_CHECKING, Literal, Mapping, NamedTuple, Protocol + +import numpy as np +import pyarrow as pa + +if TYPE_CHECKING: + from scripts.research.market_data.query import SnapshotView RunStatus = Literal["complete", "evidence_insufficient", "failed"] +RUN_OUTPUT_ROOT = Path(".local/quant-research") +RUN_STATUSES: tuple[RunStatus, ...] = ( + "complete", + "evidence_insufficient", + "failed", +) + +SIDE_NONE = 0 +SIDE_BUY = 1 +SIDE_SELL = -1 + +FILL_IGNORED = 0 +FILL_ACCEPTED = 1 +FILL_REJECTED = 2 + + +class StrategyEvidenceError(RuntimeError): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +class SegmentView(NamedTuple): + row: int + group: int + from_col: int + to_col: int + cash: float + value: float + positions: np.ndarray + valuation_prices: np.ndarray + + +class FillEvent(NamedTuple): + row: int + column: int + status: int + side: int + size: float + price: float + fees: float + cash_after: float + position_after: float + + +@dataclass(frozen=True, slots=True) +class StrategyDescriptor: + strategy_id: str + contract_version: str + extension_names: tuple[str, ...] + accounting: Mapping[str, object] + + def __post_init__(self) -> None: + object.__setattr__(self, "accounting", MappingProxyType(dict(self.accounting))) + + +@dataclass(frozen=True, slots=True) +class LedgerInput: + dates: np.ndarray + symbols: tuple[str, ...] + close: np.ndarray + initial_cash: float + group_ids: np.ndarray + cash_sharing: bool + frequency: str + + +@dataclass(frozen=True, slots=True) +class OrderBuffer: + enabled: np.ndarray + side: np.ndarray + size: np.ndarray + price: np.ndarray + fixed_fees: np.ndarray + size_granularity: np.ndarray + allow_partial: np.ndarray + priority: np.ndarray + + def __post_init__(self) -> None: + arrays = ( + self.enabled, + self.side, + self.size, + self.price, + self.fixed_fees, + self.size_granularity, + self.allow_partial, + self.priority, + ) + try: + lengths = {len(array) for array in arrays} + except TypeError as exc: + raise ValueError("OrderBuffer arrays must have the same length") from exc + if len(lengths) != 1: + raise ValueError("OrderBuffer arrays must have the same length") + + +@dataclass(frozen=True, slots=True) +class OrderProgram: + program_id: str + prepare_segment_nb: object + after_fill_nb: object + after_segment_nb: object | None + inputs: tuple[object, ...] + params: tuple[object, ...] + state: tuple[object, ...] + trace: Mapping[str, np.ndarray] + orders: OrderBuffer + + def __post_init__(self) -> None: + object.__setattr__(self, "trace", MappingProxyType(dict(self.trace))) + + +@dataclass(frozen=True, slots=True) +class PreparedStrategy: + ledger_input: LedgerInput + primary_program: OrderProgram + context: object + + +class ExecutionLedger(Protocol): + @property + def orders(self) -> np.ndarray: ... + + @property + def assets(self) -> np.ndarray: ... + + @property + def cash(self) -> np.ndarray: ... + + @property + def value(self) -> np.ndarray: ... + + @property + def trades(self) -> np.ndarray: ... + + @property + def positions(self) -> np.ndarray: ... + + @property + def returns(self) -> np.ndarray: ... + + +@dataclass(frozen=True, slots=True) +class ExecutionRun: + ledger: ExecutionLedger + trace: Mapping[str, np.ndarray] + + def __post_init__(self) -> None: + object.__setattr__(self, "trace", MappingProxyType(dict(self.trace))) + + +@dataclass(frozen=True, slots=True) +class ExecutionBundle: + primary: ExecutionRun + final: ExecutionRun + stages: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class ResultExtension: + name: str + schema_version: str + table: pa.Table + unique_key: tuple[str, ...] + evidence: Mapping[str, object] + + def __post_init__(self) -> None: + object.__setattr__(self, "evidence", MappingProxyType(dict(self.evidence))) + + +class StrategyModule(Protocol): + descriptor: StrategyDescriptor + + def prepare( + self, + snapshot: "SnapshotView", + config: Mapping[str, object], + ) -> PreparedStrategy: ... + + def followup_program( + self, + prepared: PreparedStrategy, + primary_run: ExecutionRun, + ) -> OrderProgram | None: ... + + def build_extensions( + self, + prepared: PreparedStrategy, + execution: ExecutionBundle, + ) -> tuple[ResultExtension, ...]: ... @dataclass(frozen=True) @@ -15,20 +212,16 @@ class OutputSpec: format: Literal["json", "csv", "markdown", "text", "parquet", "directory"] -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class RunConfig: project_id: str + strategy_root: Path + strategy_module: str + strategy_symbol: str snapshot_id: str snapshot_requirements: Mapping[str, object] - project_entry: Path - command: tuple[str, ...] - project_config: Path - code_identity: Path - benchmark_input: Path | None + scenario_config: Path declared_inputs: tuple[Path, ...] - required_outputs: tuple[OutputSpec, ...] - output_root: Path - stop_states: tuple[RunStatus, ...] document: Mapping[str, object] def __post_init__(self) -> None: diff --git a/scripts/research/local_quant_research/evidence.py b/scripts/research/local_quant_research/evidence.py index 5b50512..2887926 100644 --- a/scripts/research/local_quant_research/evidence.py +++ b/scripts/research/local_quant_research/evidence.py @@ -10,9 +10,11 @@ from typing import Iterable, Mapping, Sequence import pyarrow as pa +import pyarrow.compute as pc import pyarrow.parquet as pq +import numpy as np -from .contracts import OutputSpec +from .contracts import ExecutionBundle, OutputSpec _SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") @@ -44,6 +46,89 @@ def canonical_digest(value: object) -> str: return hashlib.sha256(canonical_bytes(value)).hexdigest() +def _array_digest(value: np.ndarray) -> dict[str, object]: + array = np.asarray(value) + if array.dtype.hasobject: + raise EvidenceError("execution digest does not accept object arrays") + digest = hashlib.sha256() + if array.flags.c_contiguous: + digest.update(memoryview(array).cast("B")) + else: + iterator = np.nditer( + array, + flags=("external_loop", "buffered"), + order="C", + buffersize=65_536, + ) + for chunk in iterator: + digest.update(memoryview(np.asarray(chunk)).cast("B")) + return { + "dtype": array.dtype.descr if array.dtype.names else array.dtype.str, + "shape": list(array.shape), + "sha256": digest.hexdigest(), + } + + +def validate_extension_table(table: object) -> None: + if not isinstance(table, pa.Table): + raise EvidenceError("extension table must be an Arrow table") + try: + table.validate(full=True) + except pa.ArrowException as exc: + raise EvidenceError("extension table is invalid") from exc + for field in table.schema: + if field.type not in (pa.string(), pa.bool_(), pa.int64(), pa.float64()): + raise EvidenceError("extension fields must use flat string/bool/int64/float64 types") + if pa.types.is_float64(field.type) and bool( + pc.any(pc.is_nan(table[field.name])).as_py() + ): + raise EvidenceError("extension float values must use Arrow null instead of NaN") + + +def _run_digest_document(run: object) -> dict[str, object]: + ledger_document: dict[str, object] = {} + seen_arrays: dict[int, str] = {} + for field in ( + "orders", + "assets", + "cash", + "value", + "trades", + "positions", + "returns", + ): + array = np.asarray(getattr(run.ledger, field)) + previous = seen_arrays.get(id(array)) + if previous is None: + ledger_document[field] = _array_digest(array) + seen_arrays[id(array)] = field + else: + ledger_document[field] = {"same_as": previous} + return { + "ledger": ledger_document, + "trace": { + key: _array_digest(np.asarray(value)) + for key, value in sorted(run.trace.items()) + }, + } + + +def execution_digest( + execution: ExecutionBundle, +) -> str: + primary_document = _run_digest_document(execution.primary) + run_documents: dict[str, object] = {"primary": primary_document} + if execution.final is execution.primary: + run_documents["final"] = {"same_as": "primary"} + else: + run_documents["final"] = _run_digest_document(execution.final) + document: dict[str, object] = { + "stages": list(execution.stages), + "runs": run_documents, + } + return canonical_digest(document) + + def file_digest(path: Path) -> str: return hashlib.sha256(Path(path).read_bytes()).hexdigest() diff --git a/scripts/research/local_quant_research/performance.py b/scripts/research/local_quant_research/performance.py new file mode 100644 index 0000000..370ef9d --- /dev/null +++ b/scripts/research/local_quant_research/performance.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass, replace +from typing import Callable, TypeVar + + +T = TypeVar("T") +PERFORMANCE_LIMIT_SECONDS = 180.0 + + +class PerformanceGateError(RuntimeError): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True, slots=True) +class PerformanceSample: + name: str + seconds: float + digest: str + + def to_document(self) -> dict[str, object]: + return { + "name": self.name, + "seconds": self.seconds, + "digest": self.digest, + } + + +@dataclass(frozen=True, slots=True) +class PerformanceEvidence: + cold: PerformanceSample + warm: PerformanceSample + + def to_document(self) -> dict[str, object]: + return { + "cold": self.cold.to_document(), + "warm": self.warm.to_document(), + } + + +def _sample( + name: str, + operation: Callable[[], T], + digest: Callable[[T], str], +) -> tuple[T, PerformanceSample]: + started = time.perf_counter() + outcome = operation() + outcome_digest = str(digest(outcome)) + seconds = time.perf_counter() - started + if seconds >= PERFORMANCE_LIMIT_SECONDS: + raise PerformanceGateError( + f"{name}_performance_limit", + f"{name} execution exceeded the 180 second limit", + ) + return outcome, PerformanceSample(name, seconds, outcome_digest) + + +def run_cold_warm( + operation: Callable[[], T], + *, + digest: Callable[[T], str], +) -> tuple[T, PerformanceEvidence]: + cold_outcome, cold = _sample("cold", operation, digest) + del cold_outcome + warm_outcome, warm = _sample("warm", operation, digest) + if cold.digest != warm.digest: + raise PerformanceGateError( + "execution_digest_mismatch", + "cold and warm execution digests differ", + ) + return warm_outcome, PerformanceEvidence(cold, warm) + + +def include_shared_work( + evidence: PerformanceEvidence, + seconds: float, +) -> PerformanceEvidence: + shared_seconds = float(seconds) + if shared_seconds < 0: + raise ValueError("shared performance duration must be non-negative") + cold = replace(evidence.cold, seconds=evidence.cold.seconds + shared_seconds) + warm = replace(evidence.warm, seconds=evidence.warm.seconds + shared_seconds) + for sample in (cold, warm): + if sample.seconds >= PERFORMANCE_LIMIT_SECONDS: + raise PerformanceGateError( + f"{sample.name}_performance_limit", + f"{sample.name} execution exceeded the 180 second limit", + ) + return PerformanceEvidence(cold, warm) diff --git a/scripts/research/local_quant_research/result_package.py b/scripts/research/local_quant_research/result_package.py new file mode 100644 index 0000000..c900646 --- /dev/null +++ b/scripts/research/local_quant_research/result_package.py @@ -0,0 +1,1602 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import time +import uuid +from dataclasses import dataclass, field as dataclass_field +from datetime import date +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Callable, Mapping + +import numpy as np +import pyarrow as pa +import pyarrow.compute as pc +import pyarrow.parquet as pq + +from scripts.research.local_quant_research.contracts import ( + ExecutionBundle, + ResultExtension, +) +from scripts.research.local_quant_research.evidence import ( + EvidenceError, + validate_extension_table, +) + + +PACKAGE_SCHEMA_VERSION = "local-research-package/2" +FORMULA_VERSION = "unified-strategy-analysis/1" +CORE_DATASETS = ("results", "balances", "positions", "orders") +FORBIDDEN_REPORT_PHRASES = ("推荐", "稳健性通过", "适合实盘", "实盘准入") +_INTEGRITY_CHECKS = ( + "schema", + "digests", + "unique_keys", + "cross_table_reconciliation", + "readback", +) + +_EXTENSION_NAME = re.compile(r"[a-z][a-z0-9_-]{0,63}") +_SHA256 = re.compile(r"[0-9a-f]{64}") +_WINDOWS_RESERVED_NAMES = { + "CON", + "PRN", + "AUX", + "NUL", + *(f"COM{index}" for index in range(1, 10)), + *(f"LPT{index}" for index in range(1, 10)), +} +_CONFIG_FILES = {"scenario.json", "project-run.json", "code-identity.json"} +_EVIDENCE_FILES = { + "market-snapshot.json", + "runtime-lock.json", + "performance.json", + "environment.json", +} +_RESULTS_SCHEMA = pa.schema( + [ + pa.field("benchmark_returns", pa.float64()), + pa.field("returns", pa.float64(), nullable=False), + pa.field("time", pa.string(), nullable=False), + ] +) +_BALANCES_SCHEMA = pa.schema( + [ + pa.field("total_value", pa.float64(), nullable=False), + pa.field("net_value", pa.float64(), nullable=False), + pa.field("cash", pa.float64(), nullable=False), + pa.field("aval_cash", pa.float64(), nullable=False), + pa.field("time", pa.string(), nullable=False), + ] +) +_POSITIONS_SCHEMA = pa.schema( + [ + pa.field("pindex", pa.int64(), nullable=False), + pa.field("avg_cost", pa.float64(), nullable=False), + pa.field("margin", pa.float64(), nullable=False), + pa.field("amount", pa.float64(), nullable=False), + pa.field("today_amount", pa.int64(), nullable=False), + pa.field("hold_cost", pa.float64(), nullable=False), + pa.field("side", pa.string(), nullable=False), + pa.field("price", pa.float64(), nullable=False), + pa.field("gains", pa.float64(), nullable=False), + pa.field("daily_gains", pa.float64(), nullable=False), + pa.field("closeable_amount", pa.int64(), nullable=False), + pa.field("time", pa.string(), nullable=False), + pa.field("security_name", pa.string(), nullable=False), + pa.field("security", pa.string(), nullable=False), + ] +) +_ORDERS_SCHEMA = pa.schema( + [ + pa.field("match_time", pa.string()), + pa.field("pindex", pa.int64(), nullable=False), + pa.field("cancel_time", pa.string()), + pa.field("action", pa.string(), nullable=False), + pa.field("limit_price", pa.float64(), nullable=False), + pa.field("comment", pa.string(), nullable=False), + pa.field("entrust_time", pa.string(), nullable=False), + pa.field("finish_time", pa.string()), + pa.field("side", pa.string(), nullable=False), + pa.field("price", pa.float64(), nullable=False), + pa.field("commission", pa.float64(), nullable=False), + pa.field("gains", pa.float64(), nullable=False), + pa.field("type", pa.string(), nullable=False), + pa.field("time", pa.string(), nullable=False), + pa.field("security_name", pa.string(), nullable=False), + pa.field("security", pa.string(), nullable=False), + pa.field("filled", pa.int64(), nullable=False), + pa.field("amount", pa.int64(), nullable=False), + pa.field("status", pa.string(), nullable=False), + ] +) +_SCHEMAS = { + "results": _RESULTS_SCHEMA, + "balances": _BALANCES_SCHEMA, + "positions": _POSITIONS_SCHEMA, + "orders": _ORDERS_SCHEMA, +} +_UNIQUE_KEYS = { + "results": ("time",), + "balances": ("time",), + "positions": ("time", "pindex", "security", "side"), + "orders": ("time", "pindex", "security"), +} + + +class ResultContractError(ValueError): + """Raised when a result package cannot prove the shared contract.""" + + +@dataclass(frozen=True, slots=True) +class ResultPackageRequest: + strategy_id: str + scenario_id: str + run_id: str + output_dir: Path + execution: ExecutionBundle + extensions: tuple[ResultExtension, ...] + code_files: Mapping[str, Path] + config_documents: Mapping[str, object] + evidence_documents: Mapping[str, object] + performance_finalizer: Callable[ + [Mapping[str, float], Mapping[str, float]], Mapping[str, object] + ] | None = None + atomic_publish: bool = True + + +@dataclass(frozen=True, slots=True) +class ResultPackage: + path: Path + manifest: Mapping[str, object] + package_sha256: str + writer_stages: Mapping[str, float] = dataclass_field(default_factory=dict) + writer_seconds: float = 0.0 + + +@dataclass(frozen=True, slots=True) +class _CoreFacts: + results: pa.Table + balances: pa.Table + positions: pa.Table + orders: pa.Table + + def tables(self) -> dict[str, pa.Table]: + return {name: getattr(self, name) for name in CORE_DATASETS} + + +@dataclass(frozen=True, slots=True) +class _FrozenInputs: + code: Mapping[str, bytes] + config: Mapping[str, object] + evidence: Mapping[str, object] + + +@dataclass(frozen=True, slots=True) +class _ReportFacts: + parameters: object + time_range: Mapping[str, str | None] + dataset_rows: Mapping[str, int] + extension_rows: Mapping[str, int] + orders: Mapping[str, int | float] + positions: Mapping[str, object] + net_value: Mapping[str, float] + + +def _with_writer_stages( + inputs: _FrozenInputs, + stages: Mapping[str, float], + finalizer: Callable[ + [Mapping[str, float], Mapping[str, float]], Mapping[str, object] + ] | None = None, + *, + writer_measurement: Mapping[str, float] | None = None, +) -> _FrozenInputs: + evidence = dict(inputs.evidence) + performance = evidence.get("performance.json") + if not isinstance(performance, Mapping): + raise ResultContractError("performance evidence must be an object") + measurement = { + "prefinalization_seconds": 0.0, + **({} if writer_measurement is None else writer_measurement), + } + performance_document = ( + dict(finalizer(stages, measurement)) + if finalizer is not None + else dict(performance) + ) + existing_stages = performance_document.get("stages") + stage_document = dict(existing_stages) if isinstance(existing_stages, Mapping) else {} + stage_document.update({name: float(seconds) for name, seconds in stages.items()}) + performance_document["stages"] = stage_document + performance_document["writer"] = { + "prefinalization_seconds": float(measurement["prefinalization_seconds"]) + } + performance_document["measurement_scope"] = { + "actual_gate_basis": "returned_writer_seconds_through_writer_return", + "persisted_writer_measurement": "writer_start_through_prefinalization_before_final_evidence_report_manifest_write", + "persisted_measurement_excludes": "final_evidence_report_manifest_write_and_atomic_publish", + } + evidence["performance.json"] = performance_document + return _FrozenInputs(inputs.code, inputs.config, evidence) + + +def _jsonable(value: object) -> object: + if isinstance(value, Mapping): + result: dict[str, object] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise ResultContractError("document keys must be strings") + result[key] = _jsonable(item) + return result + if isinstance(value, (list, tuple)): + return [_jsonable(item) for item in value] + if isinstance(value, np.generic): + return _jsonable(value.item()) + if isinstance(value, Path): + return value.as_posix() + if value is None or isinstance(value, (str, int, bool)): + return value + if isinstance(value, float): + if not np.isfinite(value): + raise ResultContractError("documents must contain finite numbers") + return value + raise ResultContractError(f"document value is not JSON-compatible: {type(value).__name__}") + + +def _json_bytes(value: object) -> bytes: + return ( + json.dumps( + _jsonable(value), + ensure_ascii=False, + sort_keys=True, + indent=2, + allow_nan=False, + ) + + "\n" + ).encode("utf-8") + + +def _canonical_digest(value: object) -> str: + payload = json.dumps( + _jsonable(value), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _validate_physical_zstd(path: Path, field: str) -> None: + try: + parquet = pq.ParquetFile(path) + compression = { + parquet.metadata.row_group(group).column(column).compression + for group in range(parquet.metadata.num_row_groups) + for column in range(parquet.metadata.num_columns) + } + except Exception as exc: + raise ResultContractError(f"{field} Parquet metadata is invalid") from exc + if compression and compression != {"ZSTD"}: + raise ResultContractError(f"{field} physical compression is invalid") + + +def _safe_segment(value: str, *, field: str) -> str: + if ( + not value + or value in {".", ".."} + or value.endswith((" ", ".")) + or any(ord(character) < 32 or character in '<>:"/\\|?*' for character in value) + or value.split(".", 1)[0].upper() in _WINDOWS_RESERVED_NAMES + ): + raise ResultContractError(f"{field} is unsafe") + return value + + +def _safe_relative(value: str, *, suffix: str | None = None) -> Path: + if not isinstance(value, str) or not value: + raise ResultContractError("package file name is required") + normalized = value.replace("\\", "/") + windows = PureWindowsPath(value) + posix = PurePosixPath(normalized) + if windows.anchor or windows.drive or posix.is_absolute(): + raise ResultContractError("package file name is unsafe") + raw_parts = normalized.split("/") + if any(not part for part in raw_parts): + raise ResultContractError("package file name is unsafe") + parts = [ + _safe_segment(part, field="package file name") + for part in raw_parts + ] + candidate = Path(*parts) + if suffix is not None and candidate.suffix != suffix: + candidate = candidate.with_name(candidate.name + suffix) + return candidate + + +def _freeze_request_inputs(request: ResultPackageRequest) -> _FrozenInputs: + _safe_segment(request.run_id, field="run_id") + if not request.code_files: + raise ResultContractError("archive-ready package requires strategy source code") + + code: dict[str, bytes] = {} + documents: dict[str, dict[str, object]] = {"config": {}, "evidence": {}} + for field, values, suffix in ( + ("code", request.code_files, None), + ("config", request.config_documents, ".json"), + ("evidence", request.evidence_documents, ".json"), + ): + destinations: set[str] = set() + for name, value in values.items(): + relative = _safe_relative(name) + if relative.as_posix() != name or ( + suffix is not None and relative.suffix != suffix + ): + raise ResultContractError(f"{field} package file identity is invalid") + identity = relative.as_posix().casefold() + if identity in destinations: + raise ResultContractError(f"{field} package destinations must be unique") + destinations.add(identity) + if field == "code": + source = Path(value) + if not source.is_file(): + raise ResultContractError(f"code file is missing: {name}") + try: + code[name] = source.read_bytes() + except OSError as exc: + raise ResultContractError(f"code file is unreadable: {name}") from exc + else: + documents[field][name] = _jsonable(value) + required = _CONFIG_FILES if field == "config" else _EVIDENCE_FILES + if field != "code" and not required.issubset(destinations): + raise ResultContractError(f"archive-ready package is missing {field} evidence") + return _FrozenInputs( + code=code, + config=documents["config"], + evidence=documents["evidence"], + ) + + +def _python_scalar(value: object) -> object: + if isinstance(value, np.generic): + return value.item() + return value + + +def _structured_array( + value: object, + *, + required: tuple[str, ...], + field: str, + exact: bool, +) -> np.ndarray: + array = np.asarray(value) + names = array.dtype.names + if array.ndim != 1 or names is None: + raise ResultContractError(f"ledger.{field} must be a one-dimensional structured array") + if not set(required).issubset(names) or (exact and set(names) != set(required)): + raise ResultContractError(f"ledger.{field} fields do not match the contract") + return array + + +def _record_rows(array: np.ndarray, schema: pa.Schema) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for record in array: + row = {name: _python_scalar(record[name]) for name in schema.names} + rows.append(row) + return rows + + +def _table(rows: list[dict[str, object]], schema: pa.Schema) -> pa.Table: + try: + return pa.Table.from_pylist(rows, schema=schema) + except (TypeError, ValueError) as exc: + raise ResultContractError("ledger values do not match the Arrow contract") from exc + + +def _materialize_core(execution: ExecutionBundle) -> _CoreFacts: + ledger = execution.final.ledger + orders_value = ledger.orders + assets_value = ledger.assets + cash_value = ledger.cash + value_value = ledger.value + + orders = _structured_array( + orders_value, + required=tuple(_ORDERS_SCHEMA.names), + field="orders", + exact=True, + ) + assets = _structured_array( + assets_value, + required=tuple(_POSITIONS_SCHEMA.names), + field="assets", + exact=True, + ) + cash = _structured_array( + cash_value, + required=("time", "cash"), + field="cash", + exact=True, + ) + values = _structured_array( + value_value, + required=("time", "total_value", "returns", "benchmark_returns"), + field="value", + exact=True, + ) + if len(cash) != len(values): + raise ResultContractError("ledger cash and value rows do not reconcile") + + result_rows: list[dict[str, object]] = [] + balance_rows: list[dict[str, object]] = [] + for value_record, cash_record in zip(values, cash, strict=True): + value_time = str(_python_scalar(value_record["time"])) + cash_time = str(_python_scalar(cash_record["time"])) + if value_time != cash_time: + raise ResultContractError("ledger cash and value times do not reconcile") + benchmark = float(value_record["benchmark_returns"]) + result_rows.append( + { + "benchmark_returns": None if np.isnan(benchmark) else benchmark, + "returns": float(value_record["returns"]), + "time": value_time, + } + ) + total_value = float(value_record["total_value"]) + cash_amount = float(cash_record["cash"]) + balance_rows.append( + { + "total_value": total_value, + "net_value": total_value, + "cash": cash_amount, + "aval_cash": cash_amount, + "time": value_time, + } + ) + + facts = _CoreFacts( + results=_table(result_rows, _RESULTS_SCHEMA), + balances=_table(balance_rows, _BALANCES_SCHEMA), + positions=_table(_record_rows(assets, _POSITIONS_SCHEMA), _POSITIONS_SCHEMA), + orders=_table(_record_rows(orders, _ORDERS_SCHEMA), _ORDERS_SCHEMA), + ) + return facts + + +def _validate_unique_key( + table: pa.Table, + key: tuple[str, ...], + field: str, + *, + rows: list[dict[str, object]] | None = None, +) -> None: + if not key or any(name not in table.schema.names for name in key): + raise ResultContractError(f"{field} unique key is invalid") + values = ( + [tuple(row[name] for name in key) for row in rows] + if rows is not None + else list(zip(*(table[name].to_pylist() for name in key), strict=True)) + ) + if any(any(item is None for item in row) for row in values): + raise ResultContractError(f"{field} unique key contains null") + if len(values) != len(set(values)): + raise ResultContractError(f"{field} unique key is not unique") + + +def _validate_common_facts( + facts: _CoreFacts, + summaries: Mapping[str, Mapping[str, object]] | None = None, +) -> dict[str, dict[str, object]]: + tables = facts.tables() + materialized_summaries = ( + {name: _table_summary(table) for name, table in tables.items()} + if summaries is None + else {name: dict(summary) for name, summary in summaries.items()} + ) + for name, schema in _SCHEMAS.items(): + if tables[name].schema != schema: + raise ResultContractError(f"{name} fields do not match the contract") + rows = materialized_summaries[name]["rows"] + if not isinstance(rows, list): + raise ResultContractError(f"{name} logical rows are invalid") + _validate_unique_key(tables[name], _UNIQUE_KEYS[name], name, rows=rows) + if facts.results.num_rows != facts.balances.num_rows: + raise ResultContractError("results and balances do not reconcile") + if facts.results["benchmark_returns"].null_count != facts.results.num_rows: + raise ResultContractError("source benchmark returns must remain null") + + result_rows = materialized_summaries["results"]["rows"] + balance_rows = materialized_summaries["balances"]["rows"] + position_rows = materialized_summaries["positions"]["rows"] + order_rows = materialized_summaries["orders"]["rows"] + if not all( + isinstance(rows, list) + for rows in (result_rows, balance_rows, position_rows, order_rows) + ): + raise ResultContractError("core logical rows are invalid") + result_times = [str(item["time"]) for item in result_rows] + if result_times != [str(item["time"]) for item in balance_rows]: + raise ResultContractError("results and balances times do not reconcile") + initial_cash: list[float] = [] + for result, balance in zip(result_rows, balance_rows, strict=True): + denominator = 1.0 + float(result["returns"]) + if denominator <= 0.0: + raise ResultContractError("cumulative return is invalid") + implied = float(balance["total_value"]) / denominator + if not np.isfinite(implied): + raise ResultContractError("implied initial cash is invalid") + initial_cash.append(implied) + if initial_cash and max(initial_cash) - min(initial_cash) > 0.01: + raise ResultContractError("returns do not use one configured initial cash value") + + result_time_set = set(result_times) + position_value_by_time: dict[str, float] = {} + for item in position_rows: + time_text = str(item["time"]) + if time_text not in result_time_set: + raise ResultContractError("position time is absent from results") + position_value_by_time[time_text] = position_value_by_time.get( + time_text, 0.0 + ) + float(item["amount"]) * float(item["price"]) + for balance in balance_rows: + time_text = str(balance["time"]) + reconciled = float(balance["cash"]) + position_value_by_time.get(time_text, 0.0) + if abs(float(balance["total_value"]) - reconciled) > 0.02: + raise ResultContractError("balance does not reconcile with cash and positions") + + result_dates = {time_text[:10] for time_text in result_times} + for item in order_rows: + if str(item["time"])[:10] not in result_dates: + raise ResultContractError("order date is absent from results") + if not 0 <= int(item["filled"]) <= int(item["amount"]): + raise ResultContractError("order filled amount is invalid") + return materialized_summaries + + +def _validate_extension_contracts( + extensions: tuple[ResultExtension, ...], +) -> dict[str, ResultExtension]: + result: dict[str, ResultExtension] = {} + for extension in extensions: + if _EXTENSION_NAME.fullmatch(extension.name) is None: + raise ResultContractError("extension name is invalid") + if extension.name in result: + raise ResultContractError("extension names must be unique") + if not isinstance(extension.schema_version, str) or not extension.schema_version: + raise ResultContractError("extension schema_version is required") + try: + validate_extension_table(extension.table) + except EvidenceError as exc: + raise ResultContractError(str(exc)) from exc + _validate_unique_key(extension.table, extension.unique_key, extension.name) + _jsonable(extension.evidence) + result[extension.name] = extension + return result + + +def _validate_extensions( + extensions: tuple[ResultExtension, ...], +) -> tuple[dict[str, ResultExtension], dict[str, dict[str, object]]]: + result = _validate_extension_contracts(extensions) + summaries: dict[str, dict[str, object]] = {} + for extension in result.values(): + summary = _table_summary(extension.table) + rows = summary["rows"] + if not isinstance(rows, list): + raise ResultContractError("extension logical rows are invalid") + _validate_unique_key( + extension.table, + extension.unique_key, + extension.name, + rows=rows, + ) + summaries[extension.name] = summary + return result, summaries + + +def _schema_document(schema: pa.Schema) -> list[dict[str, object]]: + return [ + {"name": field.name, "type": str(field.type), "nullable": field.nullable} + for field in schema + ] + + +def _table_summary(table: pa.Table) -> dict[str, object]: + return {"schema": _schema_document(table.schema), "rows": table.to_pylist()} + + +def _content_document( + request: ResultPackageRequest, + inputs: _FrozenInputs, + extensions: Mapping[str, ResultExtension], + core_summaries: Mapping[str, Mapping[str, object]], + extension_summaries: Mapping[str, Mapping[str, object]], +) -> dict[str, object]: + code = { + name: hashlib.sha256(payload).hexdigest() + for name, payload in sorted(inputs.code.items()) + } + return { + "schema_version": PACKAGE_SCHEMA_VERSION, + "object": { + "kind": "local_research", + "status": "complete", + "strategy_id": request.strategy_id, + "scenario_id": request.scenario_id, + "run_id": request.run_id, + }, + "authority": "local_research", + "backend": "vectorbt", + "formula_version": FORMULA_VERSION, + "code": code, + "config": dict(inputs.config), + "evidence": dict(inputs.evidence), + "datasets": { + name: dict(summary) for name, summary in core_summaries.items() + }, + "extensions": { + name: { + "schema_version": extension.schema_version, + "unique_key": list(extension.unique_key), + "evidence": dict(extension.evidence), + "table": dict(extension_summaries[name]), + } + for name, extension in sorted(extensions.items()) + }, + } + + +def _time_range(table: pa.Table) -> dict[str, str | None]: + if table.num_rows == 0 or "time" not in table.schema.names: + return {"start": None, "end": None} + values = [str(value)[:10] for value in table["time"].to_pylist()] + try: + for value in values: + date.fromisoformat(value) + except ValueError as exc: + raise ResultContractError("table time must start with YYYY-MM-DD") from exc + return {"start": min(values), "end": max(values)} + + +def _file_ref( + root: Path, + path: Path, + *, + rows: int | None = None, +) -> dict[str, object]: + result: dict[str, object] = { + "path": path.relative_to(root).as_posix(), + "sha256": _sha256_file(path), + "bytes": path.stat().st_size, + } + if rows is not None: + result.update({"rows": rows, "format": "parquet", "compression": "zstd"}) + return result + + +def _table_entry( + root: Path, + path: Path, + table: pa.Table, + unique_key: tuple[str, ...], +) -> dict[str, object]: + return { + "required": True, + "status": "complete", + "rows": table.num_rows, + "verified_empty": table.num_rows == 0, + "time_range": _time_range(table), + "schema": _schema_document(table.schema), + "files": [_file_ref(root, path, rows=table.num_rows)], + "evidence": {"fields": table.schema.names, "unique_key": list(unique_key)}, + } + + +def _write_code_files(root: Path, values: Mapping[str, bytes]) -> dict[str, dict[str, object]]: + references: dict[str, dict[str, object]] = {} + destinations: set[Path] = set() + for name, payload in sorted(values.items()): + relative = _safe_relative(name) + destination = root / "code" / relative + if destination in destinations: + raise ResultContractError("code file destinations must be unique") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(payload) + references[name] = _file_ref(root, destination) + destinations.add(destination) + return references + + +def _write_documents( + root: Path, + directory: str, + values: Mapping[str, object], +) -> dict[str, dict[str, object]]: + references: dict[str, dict[str, object]] = {} + destinations: set[Path] = set() + for name, document in sorted(values.items()): + relative = _safe_relative(name, suffix=".json") + destination = root / directory / relative + if destination in destinations: + raise ResultContractError(f"{directory} document destinations must be unique") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(_json_bytes(document)) + references[name] = _file_ref(root, destination) + destinations.add(destination) + return references + + +def _column_sum(table: pa.Table, name: str) -> int | float: + if table.num_rows == 0: + return 0 + value = pc.sum(table[name]).as_py() + return 0 if value is None else value + + +def _build_report_facts( + *, + config: Mapping[str, object], + facts: _CoreFacts, + extension_tables: Mapping[str, pa.Table], +) -> _ReportFacts: + scenario = config.get("scenario.json") + parameters = ( + scenario.get("parameters", {}) + if isinstance(scenario, Mapping) + else {} + ) + parameters = _jsonable(parameters) + time_range = _time_range(facts.results) + + position_times = [str(value) for value in facts.positions["time"].to_pylist()] + latest_position_time = max(position_times) if position_times else None + if latest_position_time is None: + latest_positions = facts.positions.slice(0, 0) + else: + latest_positions = facts.positions.filter( + pc.equal(facts.positions["time"], latest_position_time) + ) + latest_market_value = float( + _column_sum( + pa.table( + { + "market_value": pc.multiply( + latest_positions["amount"], latest_positions["price"] + ) + } + ), + "market_value", + ) + ) + + balance_times = [str(value) for value in facts.balances["time"].to_pylist()] + if balance_times: + start_index = min(range(len(balance_times)), key=balance_times.__getitem__) + end_index = max(range(len(balance_times)), key=balance_times.__getitem__) + start_total_value = float(facts.balances["total_value"][start_index].as_py()) + end_total_value = float(facts.balances["total_value"][end_index].as_py()) + start_net_value = float(facts.balances["net_value"][start_index].as_py()) + end_net_value = float(facts.balances["net_value"][end_index].as_py()) + cumulative_return = float(facts.results["returns"][end_index].as_py()) + else: + start_total_value = end_total_value = 0.0 + start_net_value = end_net_value = 0.0 + cumulative_return = 0.0 + return _ReportFacts( + parameters=parameters, + time_range=time_range, + dataset_rows={name: table.num_rows for name, table in facts.tables().items()}, + extension_rows={ + name: table.num_rows for name, table in sorted(extension_tables.items()) + }, + orders={ + "records": facts.orders.num_rows, + "requested_amount": int(_column_sum(facts.orders, "amount")), + "filled_amount": int(_column_sum(facts.orders, "filled")), + "commission": float(_column_sum(facts.orders, "commission")), + }, + positions={ + "records": facts.positions.num_rows, + "latest_time": latest_position_time, + "latest_records": latest_positions.num_rows, + "latest_market_value": latest_market_value, + }, + net_value={ + "start_total_value": start_total_value, + "end_total_value": end_total_value, + "start_net_value": start_net_value, + "end_net_value": end_net_value, + "cumulative_return": cumulative_return, + }, + ) + + +def _report_payloads( + *, + identity: Mapping[str, object], + evidence: Mapping[str, object], + report_facts: _ReportFacts, + package_sha256: str, +) -> tuple[bytes, bytes]: + parameters = report_facts.parameters + performance = _jsonable(evidence.get("performance.json")) + time_range = report_facts.time_range + orders = report_facts.orders + positions = report_facts.positions + net_value = report_facts.net_value + + integrity_gate = { + "status": "pass", + "exceptions": [], + "checks": list(_INTEGRITY_CHECKS), + } + metrics: dict[str, object] = { + "identity": dict(identity), + "package_sha256": package_sha256, + "parameters": parameters, + "time_range": time_range, + "datasets": dict(report_facts.dataset_rows), + "extensions": dict(report_facts.extension_rows), + "orders": dict(orders), + "positions": dict(positions), + "net_value": dict(net_value), + "performance": performance, + "integrity_gate": integrity_gate, + } + parameters_text = _json_bytes(parameters).decode("utf-8").rstrip() + performance_text = _json_bytes(performance).decode("utf-8").rstrip() + lines = [ + "# 本地研究执行摘要", + "", + f"- 策略:`{identity['strategy_id']}`", + f"- 场景:`{identity['scenario_id']}`", + f"- 运行:`{identity['run_id']}`", + f"- 内容摘要:`{package_sha256}`", + "", + "## 参数与配置", + "", + "```json", + parameters_text, + "```", + "", + "## 时间范围", + "", + f"- 开始:`{time_range['start']}`", + f"- 结束:`{time_range['end']}`", + "", + "## 核心事实行数", + "", + ] + lines.extend( + f"- `{name}`:{rows}" for name, rows in report_facts.dataset_rows.items() + ) + lines.extend( + [ + "", + "## 成交摘要", + "", + f"- 订单记录:{orders['records']}", + f"- 委托数量:{orders['requested_amount']}", + f"- 成交数量:{orders['filled_amount']}", + f"- 佣金:{float(orders['commission']):.6f}", + "", + "## 持仓摘要", + "", + f"- 持仓记录:{positions['records']}", + f"- 最新时点:`{positions['latest_time']}`", + f"- 最新持仓数量:{positions['latest_records']}", + f"- 最新持仓市值:{float(positions['latest_market_value']):.6f}", + "", + "## 净值摘要", + "", + f"- 起始总资产:{float(net_value['start_total_value']):.6f}", + f"- 结束总资产:{float(net_value['end_total_value']):.6f}", + f"- 起始净值:{float(net_value['start_net_value']):.6f}", + f"- 结束净值:{float(net_value['end_net_value']):.6f}", + f"- 累计收益:{float(net_value['cumulative_return']):.12f}", + "", + "## 性能", + "", + "```json", + performance_text, + "```", + "", + "## 完整性门禁", + "", + "- 状态:`pass`", + "- 例外:无", + ] + ) + lines.extend(f"- 检查:`{check}`" for check in _INTEGRITY_CHECKS) + lines.extend(["", "## 扩展行数", ""]) + if report_facts.extension_rows: + lines.extend( + f"- `{name}`:{rows}" + for name, rows in report_facts.extension_rows.items() + ) + else: + lines.append("- 无") + summary_bytes = ("\n".join(lines) + "\n").encode("utf-8") + return summary_bytes, _json_bytes(metrics) + + +def _write_report( + root: Path, + request: ResultPackageRequest, + inputs: _FrozenInputs, + report_facts: _ReportFacts, + package_sha256: str, +) -> tuple[dict[str, dict[str, object]], dict[str, bytes]]: + report_dir = root / "report" + report_dir.mkdir(parents=True, exist_ok=True) + metrics = report_dir / "metrics.json" + summary_bytes, metrics_bytes = _report_payloads( + identity={ + "kind": "local_research", + "status": "complete", + "strategy_id": request.strategy_id, + "scenario_id": request.scenario_id, + "run_id": request.run_id, + }, + evidence=inputs.evidence, + report_facts=report_facts, + package_sha256=package_sha256, + ) + if any(phrase.encode("utf-8") in summary_bytes for phrase in FORBIDDEN_REPORT_PHRASES): + raise ResultContractError("execution report contains forbidden judgment") + metrics.write_bytes(metrics_bytes) + summary = report_dir / "execution-summary.md" + summary.write_bytes(summary_bytes) + return ( + { + "execution-summary": _file_ref(root, summary), + "metrics": _file_ref(root, metrics), + }, + {"execution-summary": summary_bytes, "metrics": metrics_bytes}, + ) + + +def _readback_tables( + root: Path, + facts: _CoreFacts, + extensions: Mapping[str, ResultExtension], +) -> tuple[_CoreFacts, dict[str, pa.Table]]: + try: + for name in CORE_DATASETS: + _validate_physical_zstd( + root / "data" / f"{name}.parquet", name + ) + for name in extensions: + _validate_physical_zstd( + root / "extensions" / name / "data.parquet", name + ) + core = _CoreFacts( + **{ + name: pq.read_table(root / "data" / f"{name}.parquet") + for name in CORE_DATASETS + } + ) + extension_tables = { + name: pq.read_table(root / "extensions" / name / "data.parquet") + for name in extensions + } + except Exception as exc: + raise ResultContractError("result package readback failed") from exc + for name, expected in facts.tables().items(): + observed = core.tables()[name] + if not observed.equals(expected, check_metadata=True): + raise ResultContractError(f"{name} readback changed logical facts") + for name, extension in extensions.items(): + observed = extension_tables[name] + if not observed.equals(extension.table, check_metadata=True): + raise ResultContractError(f"extension {name} readback changed logical facts") + return core, extension_tables + + +def _manifest_table_entries( + root: Path, + facts: _CoreFacts, + extensions: Mapping[str, ResultExtension], + extension_tables: Mapping[str, pa.Table], +) -> tuple[dict[str, object], dict[str, object]]: + datasets: dict[str, object] = { + name: _table_entry( + root, + root / "data" / f"{name}.parquet", + table, + _UNIQUE_KEYS[name], + ) + for name, table in facts.tables().items() + } + extension_entries: dict[str, object] = { + name: { + **_table_entry( + root, + root / "extensions" / name / "data.parquet", + extension_tables[name], + extension.unique_key, + ), + "schema_version": extension.schema_version, + "strategy_evidence": _jsonable(extension.evidence), + } + for name, extension in sorted(extensions.items()) + } + return datasets, extension_entries + + +def _manifest( + request: ResultPackageRequest, + package_sha256: str, + code: Mapping[str, Mapping[str, object]], + config: Mapping[str, Mapping[str, object]], + evidence: Mapping[str, Mapping[str, object]], + reports: Mapping[str, Mapping[str, object]], + datasets: Mapping[str, object], + extension_entries: Mapping[str, object], +) -> dict[str, object]: + return { + "schema_version": PACKAGE_SCHEMA_VERSION, + "object": { + "kind": "local_research", + "status": "complete", + "strategy_id": request.strategy_id, + "scenario_id": request.scenario_id, + "run_id": request.run_id, + }, + "authority": "local_research", + "backend": "vectorbt", + "formula_version": FORMULA_VERSION, + "package_sha256": package_sha256, + "code": dict(code), + "config": dict(config), + "evidence": dict(evidence), + "datasets": dict(datasets), + "extensions": dict(extension_entries), + "reports": dict(reports), + "gate": { + "status": "pass", + "exceptions": [], + "checks": list(_INTEGRITY_CHECKS), + }, + } + + +def _read_json(path: Path, field: str) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ResultContractError(f"{field} is unreadable") from exc + + +def _resolve_declared( + root: Path, + reference: object, + field: str, + *, + expected_path: str, +) -> Path: + if not isinstance(reference, Mapping): + raise ResultContractError(f"{field} reference is invalid") + if set(reference) not in ({"path", "sha256", "bytes"}, {"path", "sha256", "bytes", "rows", "format", "compression"}): + raise ResultContractError(f"{field} reference fields are invalid") + relative = reference.get("path") + digest = reference.get("sha256") + size = reference.get("bytes") + if not isinstance(relative, str) or not isinstance(digest, str) or _SHA256.fullmatch(digest) is None: + raise ResultContractError(f"{field} reference identity is invalid") + if isinstance(size, bool) or not isinstance(size, int) or size < 0: + raise ResultContractError(f"{field} reference size is invalid") + try: + candidate = _safe_relative(relative) + except ResultContractError as exc: + raise ResultContractError(f"{field} reference path is unsafe") from exc + if candidate.as_posix() != relative: + raise ResultContractError(f"{field} reference path is unsafe") + if relative != expected_path: + raise ResultContractError(f"{field} file identity is invalid") + path = (root / candidate).resolve() + if root not in path.parents or not path.is_file(): + raise ResultContractError(f"{field} declared file is missing") + if path.stat().st_size != size or _sha256_file(path) != digest: + raise ResultContractError(f"{field} digest or size mismatch") + return path + + +def _validate_table_entry( + root: Path, + name: str, + entry_value: object, + *, + schema: pa.Schema | None, + expected_path: str, + extension: bool, +) -> tuple[pa.Table, tuple[str, ...], dict[str, object]]: + if not isinstance(entry_value, Mapping): + raise ResultContractError(f"{name} declaration is invalid") + required = { + "required", + "status", + "rows", + "verified_empty", + "time_range", + "schema", + "files", + "evidence", + } + if extension: + required |= {"schema_version", "strategy_evidence"} + if set(entry_value) != required: + raise ResultContractError(f"{name} declaration fields are invalid") + rows = entry_value["rows"] + if ( + entry_value["required"] is not True + or entry_value["status"] != "complete" + or isinstance(rows, bool) + or not isinstance(rows, int) + or rows < 0 + or entry_value["verified_empty"] is not (rows == 0) + ): + raise ResultContractError(f"{name} declaration status is invalid") + files = entry_value["files"] + if not isinstance(files, list) or len(files) != 1: + raise ResultContractError(f"{name} must declare one Parquet file") + reference = files[0] + path = _resolve_declared( + root, + reference, + name, + expected_path=expected_path, + ) + if ( + not isinstance(reference, Mapping) + or reference.get("rows") != rows + or reference.get("format") != "parquet" + or reference.get("compression") != "zstd" + ): + raise ResultContractError(f"{name} Parquet declaration is invalid") + _validate_physical_zstd(path, name) + try: + table = pq.read_table(path) + except Exception as exc: + raise ResultContractError(f"{name} Parquet readback failed") from exc + if extension: + try: + validate_extension_table(table) + except EvidenceError as exc: + raise ResultContractError(str(exc)) from exc + if table.num_rows != rows: + raise ResultContractError(f"{name} row count mismatch") + declared_schema = entry_value["schema"] + if declared_schema != _schema_document(table.schema): + raise ResultContractError(f"{name} schema declaration mismatch") + if schema is not None and table.schema != schema: + raise ResultContractError(f"{name} fields do not match the contract") + evidence = entry_value["evidence"] + if not isinstance(evidence, Mapping) or set(evidence) != {"fields", "unique_key"}: + raise ResultContractError(f"{name} evidence is invalid") + if evidence["fields"] != table.schema.names or not isinstance(evidence["unique_key"], list): + raise ResultContractError(f"{name} evidence fields are invalid") + unique_key = tuple(evidence["unique_key"]) + materialized_summary = _table_summary(table) + logical_rows = materialized_summary.get("rows") + if not isinstance(logical_rows, list): + raise ResultContractError(f"{name} logical rows are invalid") + _validate_unique_key(table, unique_key, name, rows=logical_rows) + if entry_value["time_range"] != _time_range(table): + raise ResultContractError(f"{name} time range mismatch") + return table, unique_key, materialized_summary + + +def _validate_result_package_document( + root: Path, + document: object, +) -> Mapping[str, object]: + if not isinstance(document, dict): + raise ResultContractError("manifest must be an object") + required = { + "schema_version", + "object", + "authority", + "backend", + "formula_version", + "package_sha256", + "code", + "config", + "evidence", + "datasets", + "extensions", + "reports", + "gate", + } + if set(document) != required or document["schema_version"] != PACKAGE_SCHEMA_VERSION: + raise ResultContractError("unsupported result package manifest") + identity = document["object"] + if ( + not isinstance(identity, Mapping) + or set(identity) != {"kind", "status", "strategy_id", "scenario_id", "run_id"} + or identity["kind"] != "local_research" + or identity["status"] != "complete" + or any( + not isinstance(identity[name], str) or not identity[name].strip() + for name in ("strategy_id", "scenario_id", "run_id") + ) + or document["authority"] != "local_research" + or document["backend"] != "vectorbt" + or document["formula_version"] != FORMULA_VERSION + or not isinstance(document["package_sha256"], str) + or _SHA256.fullmatch(document["package_sha256"]) is None + ): + raise ResultContractError("result package identity is invalid") + _safe_segment(str(identity["run_id"]), field="run_id") + gate = document["gate"] + if ( + not isinstance(gate, Mapping) + or set(gate) != {"status", "exceptions", "checks"} + or gate["status"] != "pass" + or gate["exceptions"] != [] + or not isinstance(gate["checks"], list) + or not gate["checks"] + ): + raise ResultContractError("result package gate did not pass") + + section_values: dict[str, Mapping[str, object]] = {} + resolved_sections: dict[str, dict[str, Path]] = {} + for section in ("code", "config", "evidence"): + values = document[section] + if not isinstance(values, Mapping) or any( + not isinstance(name, str) for name in values + ): + raise ResultContractError(f"{section} declaration is invalid") + if section == "code" and not values: + raise ResultContractError("archive-ready package requires strategy source code") + required_files = ( + _CONFIG_FILES + if section == "config" + else _EVIDENCE_FILES if section == "evidence" else set() + ) + if not required_files.issubset(values): + raise ResultContractError( + f"archive-ready package is missing {section} evidence" + ) + resolved: dict[str, Path] = {} + for name, reference in values.items(): + relative = _safe_relative(name) + if relative.as_posix() != name or ( + section in {"config", "evidence"} and relative.suffix != ".json" + ): + raise ResultContractError(f"{section} file identity is invalid") + resolved[name] = _resolve_declared( + root, + reference, + f"{section}.{name}", + expected_path=f"{section}/{name}", + ) + section_values[section] = values + resolved_sections[section] = resolved + + reports = document["reports"] + if not isinstance(reports, Mapping) or set(reports) != { + "execution-summary", + "metrics", + }: + raise ResultContractError("reports declaration is incomplete") + report_paths = { + "execution-summary": "report/execution-summary.md", + "metrics": "report/metrics.json", + } + resolved_sections["reports"] = { + name: _resolve_declared( + root, + reports[name], + f"reports.{name}", + expected_path=report_paths[name], + ) + for name in report_paths + } + + datasets = document["datasets"] + if not isinstance(datasets, Mapping) or set(datasets) != set(CORE_DATASETS): + raise ResultContractError("result package datasets are invalid") + core_tables: dict[str, pa.Table] = {} + core_summaries: dict[str, dict[str, object]] = {} + for name in CORE_DATASETS: + table, key, summary = _validate_table_entry( + root, + name, + datasets[name], + schema=_SCHEMAS[name], + expected_path=f"data/{name}.parquet", + extension=False, + ) + if key != _UNIQUE_KEYS[name]: + raise ResultContractError(f"{name} unique key does not match the contract") + core_tables[name] = table + core_summaries[name] = summary + facts = _CoreFacts(**core_tables) + core_summaries = _validate_common_facts(facts, core_summaries) + + extensions_value = document["extensions"] + if not isinstance(extensions_value, Mapping): + raise ResultContractError("extensions declaration is invalid") + extension_content: dict[str, object] = {} + extension_tables: dict[str, pa.Table] = {} + for name, entry_value in extensions_value.items(): + if not isinstance(name, str) or _EXTENSION_NAME.fullmatch(name) is None: + raise ResultContractError("extension name is invalid") + if not isinstance(entry_value, Mapping): + raise ResultContractError(f"extension {name} declaration is invalid") + schema_version = entry_value.get("schema_version") + if not isinstance(schema_version, str) or not schema_version: + raise ResultContractError(f"extension {name} schema_version is invalid") + strategy_evidence = entry_value.get("strategy_evidence") + if not isinstance(strategy_evidence, Mapping): + raise ResultContractError( + f"extension {name} strategy_evidence must be an object" + ) + table, key, summary = _validate_table_entry( + root, + name, + entry_value, + schema=None, + expected_path=f"extensions/{name}/data.parquet", + extension=True, + ) + extension_content[name] = { + "schema_version": schema_version, + "unique_key": list(key), + "evidence": _jsonable(strategy_evidence), + "table": summary, + } + extension_tables[name] = table + + content: dict[str, object] = { + "schema_version": PACKAGE_SCHEMA_VERSION, + "object": dict(identity), + "authority": document["authority"], + "backend": document["backend"], + "formula_version": document["formula_version"], + "datasets": core_summaries, + "extensions": extension_content, + } + content["code"] = { + name: hashlib.sha256(file.read_bytes()).hexdigest() + for name, file in resolved_sections["code"].items() + } + for section in ("config", "evidence"): + content[section] = { + name: _read_json(file, f"{section}.{name}") + for name, file in resolved_sections[section].items() + } + scenario_document = content["config"].get("scenario.json") + if ( + not isinstance(scenario_document, Mapping) + or scenario_document.get("scenario_id") != identity["scenario_id"] + ): + raise ResultContractError( + "result package scenario identity differs from frozen configuration" + ) + if _canonical_digest(content) != document["package_sha256"]: + raise ResultContractError("result package logical digest mismatch") + + report_payloads = { + name: path.read_bytes() + for name, path in resolved_sections["reports"].items() + } + try: + report_text = report_payloads["execution-summary"].decode("utf-8") + except UnicodeDecodeError as exc: + raise ResultContractError("execution report is unreadable") from exc + if any(phrase in report_text for phrase in FORBIDDEN_REPORT_PHRASES): + raise ResultContractError("execution report contains forbidden judgment") + report_facts = _build_report_facts( + config=content["config"], + facts=facts, + extension_tables=extension_tables, + ) + summary_bytes, metrics_bytes = _report_payloads( + identity=identity, + evidence=content["evidence"], + report_facts=report_facts, + package_sha256=document["package_sha256"], + ) + if ( + report_payloads["execution-summary"] != summary_bytes + or report_payloads["metrics"] != metrics_bytes + ): + raise ResultContractError("result package report does not match package facts") + return document + + +def validate_result_package(path: Path) -> Mapping[str, object]: + root = Path(path).resolve() + document = _read_json(root / "manifest.json", "manifest") + return _validate_result_package_document(root, document) + + +def write_result_package(request: ResultPackageRequest) -> ResultPackage: + writer_started = time.perf_counter() + if any( + not isinstance(value, str) or not value.strip() + for value in (request.strategy_id, request.scenario_id, request.run_id) + ): + raise ResultContractError("result package identity is incomplete") + inputs = _freeze_request_inputs(request) + core_started = time.perf_counter() + extensions = _validate_extension_contracts(request.extensions) + facts = _materialize_core(request.execution) + writer_stages = { + "core_facts": time.perf_counter() - core_started, + "parquet_materialize": 0.0, + "readback_validate": 0.0, + "report_and_manifest": 0.0, + } + target = Path(request.output_dir).resolve() + if target.exists(): + core_summaries = _validate_common_facts(facts) + _, extension_summaries = _validate_extensions(request.extensions) + existing = validate_result_package(target) + try: + existing_performance = _read_json( + target / "evidence/performance.json", + "performance", + ) + except ResultContractError: + raise + if not isinstance(existing_performance, Mapping): + raise ResultContractError("result package performance evidence is invalid") + existing_inputs = _FrozenInputs( + inputs.code, + inputs.config, + {**inputs.evidence, "performance.json": dict(existing_performance)}, + ) + package_sha256 = _canonical_digest( + _content_document( + request, + existing_inputs, + extensions, + core_summaries, + extension_summaries, + ) + ) + if existing["package_sha256"] == package_sha256: + existing_stages = existing_performance.get("stages") + return ResultPackage( + target, + existing, + package_sha256, + dict(existing_stages) if isinstance(existing_stages, Mapping) else {}, + time.perf_counter() - writer_started, + ) + raise ResultContractError("result package digest conflict") + + if request.atomic_publish: + target.parent.mkdir(parents=True, exist_ok=True) + elif not target.parent.is_dir(): + raise ResultContractError("pre-staged result parent is missing") + scenario_document = inputs.config.get("scenario.json") + if ( + not isinstance(scenario_document, Mapping) + or scenario_document.get("scenario_id") != request.scenario_id + ): + raise ResultContractError( + "result package scenario identity differs from frozen configuration" + ) + staging = ( + target.parent / f".{request.run_id}.{uuid.uuid4().hex}.tmp" + if request.atomic_publish + else target + ) + try: + staging.mkdir() + (staging / "data").mkdir() + (staging / "extensions").mkdir() + code = _write_code_files(staging, inputs.code) + config = _write_documents(staging, "config", inputs.config) + parquet_started = time.perf_counter() + for name, table in facts.tables().items(): + pq.write_table(table, staging / "data" / f"{name}.parquet", compression="zstd") + for name, extension in sorted(extensions.items()): + directory = staging / "extensions" / name + directory.mkdir() + pq.write_table(extension.table, directory / "data.parquet", compression="zstd") + writer_stages["parquet_materialize"] = time.perf_counter() - parquet_started + readback_started = time.perf_counter() + readback_facts, readback_extensions = _readback_tables( + staging, facts, extensions + ) + core_summaries = _validate_common_facts(readback_facts) + extension_summaries: dict[str, dict[str, object]] = {} + for name, extension in extensions.items(): + summary = _table_summary(readback_extensions[name]) + rows = summary["rows"] + if not isinstance(rows, list): + raise ResultContractError("extension logical rows are invalid") + _validate_unique_key( + readback_extensions[name], + extension.unique_key, + name, + rows=rows, + ) + extension_summaries[name] = summary + writer_stages["readback_validate"] = time.perf_counter() - readback_started + + report_started = time.perf_counter() + report_facts = _build_report_facts( + config=inputs.config, + facts=readback_facts, + extension_tables=readback_extensions, + ) + datasets, extension_entries = _manifest_table_entries( + staging, + readback_facts, + extensions, + readback_extensions, + ) + writer_stages["report_and_manifest"] = time.perf_counter() - report_started + prefinalization_seconds = time.perf_counter() - writer_started + final_inputs = _with_writer_stages( + inputs, + writer_stages, + request.performance_finalizer, + writer_measurement={ + "prefinalization_seconds": prefinalization_seconds, + }, + ) + package_sha256 = _canonical_digest( + _content_document( + request, + final_inputs, + extensions, + core_summaries, + extension_summaries, + ) + ) + evidence = _write_documents(staging, "evidence", final_inputs.evidence) + reports, _ = _write_report( + staging, + request, + final_inputs, + report_facts, + package_sha256, + ) + manifest = _manifest( + request, + package_sha256, + code, + config, + evidence, + reports, + datasets, + extension_entries, + ) + (staging / "manifest.json").write_bytes(_json_bytes(manifest)) + if request.atomic_publish: + os.replace(staging, target) + writer_finished = time.perf_counter() + writer_stages["report_and_manifest"] = writer_finished - report_started + return ResultPackage( + target, + manifest, + package_sha256, + dict(writer_stages), + writer_finished - writer_started, + ) + except Exception as exc: + if staging.exists(): + shutil.rmtree(staging) + if isinstance(exc, ResultContractError): + raise + raise ResultContractError("result package write or publish failed") from exc diff --git a/scripts/research/local_quant_research/runner.py b/scripts/research/local_quant_research/runner.py index 6dfee6c..84502b4 100644 --- a/scripts/research/local_quant_research/runner.py +++ b/scripts/research/local_quant_research/runner.py @@ -5,61 +5,33 @@ import os import re import shutil +import stat import subprocess import time import uuid -from dataclasses import dataclass -from pathlib import Path, PurePosixPath +from pathlib import Path from typing import Mapping, Sequence from scripts.research.market_data.query import open_snapshot from scripts.research.market_data.storage import MarketDataError -from .contracts import OutputSpec, RunConfig, RunResult, StageRecord +from .contracts import RunConfig, RunResult, StageRecord from .evidence import ( EvidenceError, canonical_digest, - collect_output_evidence, compute_run_id, file_digest, record_attempt, - validate_complete_run, write_manifest, ) -_CONFIG_FIELDS = { - "schema_version", - "project_id", - "snapshot_id", - "snapshot_requirements", - "project_entry", - "command", - "project_config", - "code_identity", - "declared_inputs", - "required_outputs", - "output_root", - "stop_states", -} -_OPTIONAL_CONFIG_FIELDS = {"benchmark_input"} _STOP_STATES = ("complete", "evidence_insufficient", "failed") _PROJECT_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}") _SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") _REASON_PATTERN = re.compile(r"[a-z][a-z0-9_]{0,63}") _SENSITIVE_KEYS = ("password", "token", "cookie", "secret", "credential", "api_key") _PROJECT_EXECUTION_TIMEOUT_SECONDS = 3_600 -_RESERVED_ARGUMENTS = { - "--snapshot-manifest", - "--market-data-root", - "--project-config", - "--output-dir", - "--run-id", - "--snapshot-id", - "--code-sha256", - "--config-sha256", - "--benchmark-input", -} _COMPLETE_STAGE_NAMES = ( "snapshot_validation", "config_validation", @@ -67,6 +39,7 @@ "output_validation", "evidence_finalization", ) +_FILE_ATTRIBUTE_REPARSE_POINT = 0x0400 class ConfigurationError(ValueError): @@ -94,16 +67,6 @@ def _publish_directory(source: Path, target: Path) -> None: os.replace(source, target) -@dataclass(frozen=True) -class _FrozenExecutionInputs: - root: Path - repository: Path - market_data: Path - project_entry: Path - project_config: Path - benchmark_input: Path | None - - def _inside(path: Path, root: Path) -> bool: try: path.resolve().relative_to(root.resolve()) @@ -152,158 +115,6 @@ def _load_raw_config(path: Path, *, repo_root: Path) -> dict[str, object]: return value -def _output_path(value: object) -> str: - if not isinstance(value, str) or not value: - raise ConfigurationError("invalid_output", "required output path is invalid") - if "\\" in value: - raise ConfigurationError("invalid_output", "required output must use forward slashes") - path = PurePosixPath(value) - if path.is_absolute() or ".." in path.parts or path.name in { - "run-manifest.json", - "project-status.json", - }: - raise ConfigurationError("unsafe_output", "required output escapes its contract") - return path.as_posix() - - -def load_run_config(path: Path, *, repo_root: Path) -> RunConfig: - repo_root = Path(repo_root).resolve() - document = _load_raw_config(path, repo_root=repo_root) - if _contains_sensitive_key(document): - raise ConfigurationError("credential_field", "credential fields are forbidden") - missing = sorted(_CONFIG_FIELDS - set(document)) - unknown = set(document) - (_CONFIG_FIELDS | _OPTIONAL_CONFIG_FIELDS) - if missing or unknown: - raise ConfigurationError("invalid_config", "run config fields are incomplete or unknown") - if document["schema_version"] != 1: - raise ConfigurationError("invalid_config", "schema_version must be 1") - - project_id = document["project_id"] - if not isinstance(project_id, str) or _PROJECT_ID_PATTERN.fullmatch(project_id) is None: - raise ConfigurationError("invalid_project_id", "project_id is invalid") - snapshot_id = document["snapshot_id"] - if not isinstance(snapshot_id, str) or _SHA256_PATTERN.fullmatch(snapshot_id) is None: - raise ConfigurationError("missing_snapshot", "snapshot_id is missing or invalid") - requirements = document["snapshot_requirements"] - if not isinstance(requirements, Mapping) or not requirements: - raise ConfigurationError("missing_snapshot_requirements", "snapshot requirements are missing") - - project_entry = _resolve_repo_path( - document["project_entry"], repo_root=repo_root, field="project_entry" - ) - if project_entry.suffix.lower() != ".py" or not project_entry.is_file(): - raise ConfigurationError("missing_project_entry", "project entry is missing") - project_config = _resolve_repo_path( - document["project_config"], repo_root=repo_root, field="project_config" - ) - code_identity = _resolve_repo_path( - document["code_identity"], repo_root=repo_root, field="code_identity" - ) - benchmark_input = ( - _resolve_repo_path( - document["benchmark_input"], - repo_root=repo_root, - field="benchmark_input", - ) - if "benchmark_input" in document - else None - ) - - command = document["command"] - if ( - not isinstance(command, list) - or len(command) < 2 - or any(not isinstance(item, str) or not item for item in command) - ): - raise ConfigurationError("unsafe_command", "command must be a non-empty argument array") - expected_python = (repo_root / ".venv" / "Scripts" / "python.exe").resolve() - command_python = _resolve_repo_path(command[0], repo_root=repo_root, field="command[0]") - command_entry = _resolve_repo_path(command[1], repo_root=repo_root, field="command[1]") - if command_python != expected_python or not expected_python.is_file(): - raise ConfigurationError("system_python", "command must use the project .venv Python") - if command_entry != project_entry: - raise ConfigurationError("unsafe_command", "command entry does not match project_entry") - lowered_args = [item.lower() for item in command[1:]] - if "-m" in lowered_args or "pip" in lowered_args or "install" in lowered_args: - raise ConfigurationError("implicit_install", "implicit dependency installation is forbidden") - if any(argument in _RESERVED_ARGUMENTS for argument in command[2:]): - raise ConfigurationError("reserved_argument", "runner arguments must not be supplied by config") - if any( - any(marker in argument.lower().replace("-", "_") for marker in _SENSITIVE_KEYS) - for argument in command[2:] - ): - raise ConfigurationError("credential_argument", "credential command arguments are forbidden") - - declared = document["declared_inputs"] - if not isinstance(declared, list) or any(not isinstance(item, str) for item in declared): - raise ConfigurationError("invalid_inputs", "declared_inputs must be a path array") - declared_inputs = tuple( - _resolve_repo_path(item, repo_root=repo_root, field="declared_inputs") - for item in declared - ) - if len(declared_inputs) != len(set(declared_inputs)): - raise ConfigurationError("invalid_inputs", "declared_inputs must be unique") - for input_path in ( - project_config, - code_identity, - *declared_inputs, - *((benchmark_input,) if benchmark_input is not None else ()), - ): - if not input_path.is_file(): - raise ConfigurationError("missing_declared_input", "a declared input is missing") - - try: - project_document = json.loads(project_config.read_text(encoding="utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise ConfigurationError("invalid_project_config", "project config is invalid") from exc - if not isinstance(project_document, dict) or _contains_sensitive_key(project_document): - raise ConfigurationError("invalid_project_config", "project config is unsafe or invalid") - - output_documents = document["required_outputs"] - if not isinstance(output_documents, list) or not output_documents: - raise ConfigurationError("missing_outputs", "required_outputs must be non-empty") - output_specs: list[OutputSpec] = [] - for item in output_documents: - if not isinstance(item, dict) or set(item) != {"path", "format"}: - raise ConfigurationError("invalid_output", "required output structure is invalid") - output_format = item["format"] - if output_format not in { - "json", - "csv", - "markdown", - "text", - "parquet", - "directory", - }: - raise ConfigurationError("invalid_output", "required output format is invalid") - output_specs.append(OutputSpec(path=_output_path(item["path"]), format=output_format)) - if len({spec.path for spec in output_specs}) != len(output_specs): - raise ConfigurationError("invalid_output", "required output paths must be unique") - - output_root = _resolve_repo_path( - document["output_root"], repo_root=repo_root, field="output_root" - ) - if output_root != (repo_root / ".local" / "quant-research").resolve(): - raise ConfigurationError("unsafe_output_root", "output_root must be .local/quant-research") - stop_states = document["stop_states"] - if stop_states != list(_STOP_STATES): - raise ConfigurationError("unknown_state", "stop_states must contain the fixed three states") - - return RunConfig( - project_id=project_id, - snapshot_id=snapshot_id, - snapshot_requirements=dict(requirements), - project_entry=project_entry, - command=tuple(command), - project_config=project_config, - code_identity=code_identity, - benchmark_input=benchmark_input, - declared_inputs=declared_inputs, - required_outputs=tuple(output_specs), - output_root=output_root, - stop_states=tuple(stop_states), - document=document, - ) def _safe_project_id(raw: Mapping[str, object]) -> str: @@ -325,14 +136,27 @@ def _attempt_result( stages: Sequence[StageRecord], staging: Path | None = None, ) -> RunResult: - if staging is not None and staging.exists(): - shutil.rmtree(staging) attempt_id = uuid.uuid4().hex - attempts_root = ( - repo_root / ".local" / "quant-research" / project_id / ".attempts" - ) final_status = status if status in {"evidence_insufficient", "failed"} else "failed" try: + project_root = _resolve_output_project_root(repo_root, project_id) + attempts_root = _resolve_plain_output_directory( + project_root / ".attempts", + repo_root=repo_root, + allow_missing=True, + ) + if staging is not None and staging.exists(): + safe_staging = _resolve_plain_output_directory( + staging, + repo_root=repo_root, + allow_missing=False, + ) + if safe_staging.parent != project_root: + raise ConfigurationError( + "unsafe_output_root", + "staging is outside the fixed project output root", + ) + shutil.rmtree(safe_staging) record_attempt( attempts_root=attempts_root, attempt_id=attempt_id, @@ -342,6 +166,8 @@ def _attempt_result( stage=stage, reason_codes=(code,), ) + except ConfigurationError: + attempt_id = None except (OSError, EvidenceError): final_status = "failed" code = "attempt_record_failed" @@ -363,174 +189,497 @@ def _snapshot_requirements_match(actual: object, expected: Mapping[str, object]) return actual == dict(expected) -def _code_identity(config: RunConfig, *, repo_root: Path) -> tuple[str, dict[str, object]]: + + +def _copy_verified_file(source: Path, target: Path, expected_sha256: str) -> None: + try: + content = source.read_bytes() + except OSError as exc: + raise InputIntegrityError( + "run_input_changed", + "a run input could not be frozen", + ) from exc + if hashlib.sha256(content).hexdigest() != expected_sha256: + raise InputIntegrityError( + "run_input_changed", + "a run input changed before it could be frozen", + ) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + + + + +def _sanitized_environment(python_path: Path) -> dict[str, str]: + allowed = ( + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "PATHEXT", + "PATH", + "TEMP", + "TMP", + "HOME", + "USERPROFILE", + "LOCALAPPDATA", + ) + environment = {key: os.environ[key] for key in allowed if key in os.environ} + environment.update( + { + "PYTHONPATH": str(python_path), + "PYTHONNOUSERSITE": "1", + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONUTF8": "1", + } + ) + return environment + + +def _repo_state( + repo_root: Path, + *, + ignored_roots: Sequence[Path], +) -> dict[str, tuple[int, int]]: + ignored_names = {".git", ".venv", ".pytest_cache", ".ruff_cache", "__pycache__"} + state: dict[str, tuple[int, int]] = {} + ignored = tuple(path.resolve() for path in ignored_roots) + for path in repo_root.rglob("*"): + if any(part in ignored_names for part in path.relative_to(repo_root).parts): + continue + if any(_inside(path, root) for root in ignored): + continue + if path.is_file() and not path.is_symlink(): + stat = path.stat() + state[path.relative_to(repo_root).as_posix()] = (stat.st_size, stat.st_mtime_ns) + return state + + +_V2_CONFIG_FIELDS = { + "schema_version", + "project_id", + "strategy", + "snapshot_id", + "snapshot_requirements", + "scenario_config", + "declared_inputs", +} +_LEGACY_RUN_FIELDS = { + "command", + "project_entry", + "code_identity", + "required_outputs", + "output_root", + "stop_states", +} + + +def _is_directory_link(path: Path) -> bool: try: - document = json.loads(config.code_identity.read_text(encoding="utf-8")) + details = os.lstat(path) + except OSError: + return False + return ( + stat.S_ISLNK(details.st_mode) + or bool( + getattr(details, "st_file_attributes", 0) + & _FILE_ATTRIBUTE_REPARSE_POINT + ) + or bool(getattr(os.path, "isjunction", lambda _value: False)(path)) + ) + + +def _resolve_plain_output_directory( + path: Path, + *, + repo_root: Path, + allow_missing: bool, +) -> Path: + repository = Path(repo_root).resolve() + unresolved = Path(os.path.abspath(path)) + try: + relative = unresolved.relative_to(repository) + except ValueError as exc: + raise ConfigurationError( + "unsafe_output_root", + "output path must remain inside the repository", + ) from exc + current = repository + for index, part in enumerate(relative.parts): + current /= part + final = index == len(relative.parts) - 1 + try: + details = os.lstat(current) + except FileNotFoundError: + if allow_missing: + continue + raise ConfigurationError( + "unsafe_output_root", + "required output directory is missing", + ) from None + except OSError as exc: + raise ConfigurationError( + "unsafe_output_root", + "output directory cannot be inspected", + ) from exc + if _is_directory_link(current) or not stat.S_ISDIR(details.st_mode): + raise ConfigurationError( + "unsafe_output_root", + "output path must contain only ordinary directories", + ) + if final and not stat.S_ISDIR(details.st_mode): + raise ConfigurationError( + "unsafe_output_root", + "output path must be an ordinary directory", + ) + return unresolved + + +def _resolve_output_project_root(repo_root: Path, project_id: str) -> Path: + from .contracts import RUN_OUTPUT_ROOT + + repository = Path(repo_root).resolve() + if project_id != "_invalid" and _PROJECT_ID_PATTERN.fullmatch(project_id) is None: + raise ConfigurationError( + "unsafe_output_root", + "project output identity is unsafe", + ) + output_root = _resolve_plain_output_directory( + repository / RUN_OUTPUT_ROOT, + repo_root=repository, + allow_missing=True, + ) + return _resolve_plain_output_directory( + output_root / project_id, + repo_root=repository, + allow_missing=True, + ) + + +def _resolve_output_run_dir( + repo_root: Path, + project_id: str, + run_id: str, +) -> Path: + if _SHA256_PATTERN.fullmatch(run_id) is None: + raise ConfigurationError("unsafe_output_root", "run output identity is unsafe") + project_root = _resolve_output_project_root(repo_root, project_id) + return _resolve_plain_output_directory( + project_root / run_id, + repo_root=repo_root, + allow_missing=True, + ) + + +def load_run_config(path: Path, *, repo_root: Path) -> RunConfig: + from .contracts import RUN_STATUSES + + repo_root = Path(repo_root).resolve() + document = _load_raw_config(path, repo_root=repo_root) + if _contains_sensitive_key(document): + raise ConfigurationError("credential_field", "credential fields are forbidden") + if set(document) & _LEGACY_RUN_FIELDS: + raise ConfigurationError( + "legacy_run_field", + "legacy command and output fields are forbidden in configuration v2", + ) + if set(document) != _V2_CONFIG_FIELDS or document.get("schema_version") != 2: + raise ConfigurationError( + "invalid_config", + "configuration v2 fields are incomplete or unknown", + ) + project_id = document["project_id"] + if not isinstance(project_id, str) or _PROJECT_ID_PATTERN.fullmatch(project_id) is None: + raise ConfigurationError("invalid_project_id", "project_id is invalid") + snapshot_id = document["snapshot_id"] + if not isinstance(snapshot_id, str) or _SHA256_PATTERN.fullmatch(snapshot_id) is None: + raise ConfigurationError("missing_snapshot", "snapshot_id is missing or invalid") + requirements = document["snapshot_requirements"] + if not isinstance(requirements, Mapping) or _contains_sensitive_key(requirements): + raise ConfigurationError( + "missing_snapshot_requirements", + "snapshot requirements are missing or invalid", + ) + strategy = document["strategy"] + if not isinstance(strategy, Mapping) or set(strategy) != {"root", "module", "symbol"}: + raise ConfigurationError("invalid_strategy_fields", "strategy fields are invalid") + strategy_root = _resolve_repo_path( + strategy["root"], repo_root=repo_root, field="strategy.root" + ) + if not strategy_root.is_dir(): + raise ConfigurationError("missing_strategy_root", "strategy root is missing") + strategy_module = strategy["module"] + strategy_symbol = strategy["symbol"] + if not isinstance(strategy_module, str) or not strategy_module: + raise ConfigurationError("invalid_strategy_module", "strategy module is invalid") + if not isinstance(strategy_symbol, str) or not strategy_symbol: + raise ConfigurationError("invalid_strategy_symbol", "strategy symbol is invalid") + scenario_config = _resolve_repo_path( + document["scenario_config"], + repo_root=repo_root, + field="scenario_config", + ) + if not scenario_config.is_file(): + raise ConfigurationError("missing_scenario_config", "scenario config is missing") + try: + scenario_document = json.loads(scenario_config.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: - raise InputIntegrityError("invalid_code_identity", "code identity is invalid") from exc - if ( - not isinstance(document, dict) - or not {"schema_version", "files"}.issubset(document) - or set(document) - {"schema_version", "files", "execution"} - ): - raise InputIntegrityError("invalid_code_identity", "code identity structure is invalid") - execution = document.get("execution") - if execution is not None and ( - not isinstance(execution, Mapping) - or not execution - or _contains_sensitive_key(execution) + raise ConfigurationError( + "invalid_scenario_config", + "scenario config is not valid UTF-8 JSON", + ) from exc + if not isinstance(scenario_document, Mapping) or _contains_sensitive_key( + scenario_document ): - raise InputIntegrityError("invalid_code_identity", "execution identity is invalid") - files = document["files"] - if document["schema_version"] != 1 or not isinstance(files, list) or not files: - raise InputIntegrityError("invalid_code_identity", "code identity files are invalid") - normalized: list[dict[str, str]] = [] - for item in files: - if not isinstance(item, dict) or set(item) != {"path", "sha256"}: - raise InputIntegrityError("invalid_code_identity", "code identity entry is invalid") + raise ConfigurationError("invalid_scenario_config", "scenario config is invalid") + scenario_id = scenario_document.get("scenario_id") + if not isinstance(scenario_id, str) or not scenario_id.strip(): + raise ConfigurationError( + "missing_scenario_id", + "scenario_id is missing or invalid", + ) + declared = document["declared_inputs"] + if not isinstance(declared, list) or any(not isinstance(item, str) for item in declared): + raise ConfigurationError("invalid_inputs", "declared_inputs must be a path array") + declared_inputs = tuple( + _resolve_repo_path(item, repo_root=repo_root, field="declared_inputs") + for item in declared + ) + if len(declared_inputs) != len(set(declared_inputs)): + raise ConfigurationError("invalid_inputs", "declared_inputs must be unique") + if any(not item.is_file() for item in declared_inputs): + raise ConfigurationError("missing_declared_input", "a declared input is missing") + if tuple(RUN_STATUSES) != _STOP_STATES: + raise RuntimeError("shared run statuses are inconsistent") + return RunConfig( + project_id=project_id, + strategy_root=strategy_root, + strategy_module=strategy_module, + strategy_symbol=strategy_symbol, + snapshot_id=snapshot_id, + snapshot_requirements=dict(requirements), + scenario_config=scenario_config, + declared_inputs=declared_inputs, + document=document, + ) + + +def _execute_command( + *, + repo_root: Path, + execution_root: Path, + staging: Path, +) -> tuple[Path | str, ...]: + return ( + Path(repo_root) / ".venv/Scripts/python.exe", + Path(repo_root) / "scripts/research/local_quant_research/cli.py", + "_execute", + "--frozen-inputs", + Path(execution_root) / "request.json", + "--staging", + Path(staging), + ) + + +def _runtime_lock(repo_root: Path) -> tuple[dict[str, object], tuple[Path, ...]]: + import importlib.metadata + import platform + import sys + + source_roots = ( + repo_root / "scripts/research/local_quant_research", + repo_root / "scripts/research/market_data", + ) + sources = tuple( + sorted( + ( + path + for path in ( + repo_root / "scripts/__init__.py", + repo_root / "scripts/research/__init__.py", + *(path for root in source_roots for path in root.glob("*.py")), + ) + if path.is_file() + ), + key=lambda path: path.relative_to(repo_root).as_posix(), + ) + ) + dependencies = {} + for name in ("duckdb", "numba", "numpy", "pandas", "pyarrow", "vectorbt"): try: - code_path = _resolve_repo_path(item["path"], repo_root=repo_root, field="code path") - except ConfigurationError as exc: - raise InputIntegrityError("invalid_code_identity", "code identity path is unsafe") from exc - expected = item["sha256"] - if not code_path.is_file() or not isinstance(expected, str) or _SHA256_PATTERN.fullmatch(expected) is None: - raise InputIntegrityError("invalid_code_identity", "code identity file is missing") - if file_digest(code_path) != expected: - raise InputIntegrityError("code_digest_mismatch", "declared code digest mismatch") - normalized.append( - {"path": code_path.relative_to(repo_root).as_posix(), "sha256": expected} + dependencies[name] = importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError as exc: + raise ConfigurationError( + "missing_runtime_dependency", + f"required dependency is missing: {name}", + ) from exc + return ( + { + "schema_version": 1, + "python": sys.version.split()[0], + "platform": platform.system(), + "dependencies": dependencies, + }, + sources, + ) + + +def _v2_identity( + config: RunConfig, + *, + repo_root: Path, + config_path: Path, +) -> tuple[tuple[Path, ...], str, str, dict[str, object], dict[str, object], dict[str, object]]: + from .strategy_loader import ConfigurationError as StrategyConfigurationError + from .strategy_loader import discover_strategy_sources + + try: + strategy_sources = discover_strategy_sources( + config.strategy_root, + config.strategy_module, + ) + except StrategyConfigurationError as exc: + raise ConfigurationError(exc.code, str(exc)) from exc + runtime_lock, runtime_sources = _runtime_lock(repo_root) + code_sources = tuple( + sorted( + {*strategy_sources, *runtime_sources}, + key=lambda item: item.relative_to(repo_root).as_posix(), ) - if normalized != sorted(normalized, key=lambda item: item["path"]) or len( - {item["path"] for item in normalized} - ) != len(normalized): - raise InputIntegrityError("invalid_code_identity", "code identity must be sorted and unique") - entry_path = config.project_entry.relative_to(repo_root).as_posix() - if entry_path not in {item["path"] for item in normalized}: - raise InputIntegrityError("missing_entry_identity", "project entry is absent from code identity") - normalized_document: dict[str, object] = {"schema_version": 1, "files": normalized} - if execution is not None: - normalized_document["execution"] = dict(execution) - return canonical_digest(normalized_document), normalized_document - - -def _input_evidence(config: RunConfig, *, repo_root: Path) -> tuple[str, str, dict[str, object]]: - code_digest, code_document = _code_identity(config, repo_root=repo_root) - project_config_digest = file_digest(config.project_config) - code_identity_digest = file_digest(config.code_identity) + ) + code_files = [ + { + "path": source.relative_to(repo_root).as_posix(), + "sha256": file_digest(source), + } + for source in code_sources + ] declared = [ { - "path": path.relative_to(repo_root).as_posix(), - "sha256": file_digest(path), + "path": item.relative_to(repo_root).as_posix(), + "sha256": file_digest(item), } - for path in config.declared_inputs + for item in config.declared_inputs ] - benchmark_input = ( - { - "path": config.benchmark_input.relative_to(repo_root).as_posix(), - "sha256": file_digest(config.benchmark_input), - } - if config.benchmark_input is not None - else None - ) + try: + scenario_payload = config.scenario_config.read_bytes() + scenario_document = json.loads(scenario_payload.decode("utf-8")) + project_run_payload = Path(config_path).resolve().read_bytes() + project_run_document = json.loads(project_run_payload.decode("utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ConfigurationError( + "run_input_changed", + "configuration inputs changed during identity capture", + ) from exc + if ( + not isinstance(scenario_document, Mapping) + or project_run_document != dict(config.document) + ): + raise ConfigurationError( + "run_input_changed", + "configuration inputs changed during identity capture", + ) + scenario = { + "path": config.scenario_config.relative_to(repo_root).as_posix(), + "sha256": hashlib.sha256(scenario_payload).hexdigest(), + } + project_run_source = { + "path": Path(config_path).resolve().relative_to(repo_root).as_posix(), + "sha256": hashlib.sha256(project_run_payload).hexdigest(), + } + code_identity = { + "schema_version": 1, + "files": code_files, + "inputs": { + "project_run": project_run_source, + "scenario": scenario, + "declared_inputs": declared, + }, + } config_identity = { - "run_config": dict(config.document), - "project_config_sha256": project_config_digest, - "code_identity_sha256": code_identity_digest, + "project_run": dict(config.document), + "scenario": dict(scenario_document), "declared_inputs": declared, - "benchmark_input": benchmark_input, } config_digest = canonical_digest(config_identity) + code_digest = canonical_digest( + { + "code_identity": {"schema_version": 1, "files": code_files}, + "runtime_lock": runtime_lock, + } + ) evidence = { "config_sha256": config_digest, - "project_config_sha256": project_config_digest, - "code_identity_sha256": code_identity_digest, "code_sha256": code_digest, - "code_identity": code_document, + "project_run_source": project_run_source, + "scenario": scenario, "declared_inputs": declared, - "benchmark_input": benchmark_input, + "code_identity": code_identity, + "runtime_lock": runtime_lock, + "project_run": dict(config.document), + "scenario_document": dict(scenario_document), } - return config_digest, code_digest, evidence + return strategy_sources, config_digest, code_digest, evidence, code_identity, runtime_lock -def _copy_verified_file(source: Path, target: Path, expected_sha256: str) -> None: - try: - content = source.read_bytes() - except OSError as exc: - raise InputIntegrityError( - "run_input_changed", - "a run input could not be frozen", - ) from exc - if hashlib.sha256(content).hexdigest() != expected_sha256: - raise InputIntegrityError( - "run_input_changed", - "a run input changed before it could be frozen", - ) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(content) - - -def _freeze_execution_inputs( +def _copy_v2_inputs( *, + config_path: Path, config: RunConfig, repo_root: Path, market_root: Path, snapshot_document: Mapping[str, object], snapshot_digest: str, - snapshot_normalized_digest: str, - inputs: Mapping[str, object], + identity: Mapping[str, object], + code_identity: Mapping[str, object], + runtime_lock: Mapping[str, object], execution_root: Path, -) -> _FrozenExecutionInputs: + staging: Path, + attempt_id: str, +) -> None: frozen_repo = execution_root / "repository" frozen_market = execution_root / "market-data" try: execution_root.mkdir() - project_config_relative = config.project_config.relative_to(repo_root) - code_identity_relative = config.code_identity.relative_to(repo_root) - _copy_verified_file( - config.project_config, - frozen_repo / project_config_relative, - str(inputs["project_config_sha256"]), - ) - _copy_verified_file( - config.code_identity, - frozen_repo / code_identity_relative, - str(inputs["code_identity_sha256"]), - ) - for item in inputs["code_identity"]["files"]: - relative = Path(str(item["path"])) - _copy_verified_file( - repo_root / relative, - frozen_repo / relative, - str(item["sha256"]), - ) - for item in inputs["declared_inputs"]: - relative = Path(str(item["path"])) - _copy_verified_file( - repo_root / relative, - frozen_repo / relative, - str(item["sha256"]), - ) - frozen_benchmark_input: Path | None = None - benchmark_evidence = inputs.get("benchmark_input") - if isinstance(benchmark_evidence, Mapping): - relative = Path(str(benchmark_evidence["path"])) - frozen_benchmark_input = frozen_repo / relative - _copy_verified_file( - repo_root / relative, - frozen_benchmark_input, - str(benchmark_evidence["sha256"]), - ) - - snapshot_id = config.snapshot_id + expected_files: dict[Path, str] = {} + for item in ( + identity["project_run_source"], + identity["scenario"], + *identity["declared_inputs"], + *code_identity["files"], + ): + if not isinstance(item, Mapping): + raise InputIntegrityError( + "run_input_changed", + "captured run identity is invalid", + ) + relative = Path(str(item.get("path", ""))) + expected = str(item.get("sha256", "")) + source = (repo_root / relative).resolve() + if not _inside(source, repo_root) or _SHA256_PATTERN.fullmatch(expected) is None: + raise InputIntegrityError( + "run_input_changed", + "captured run identity is invalid", + ) + previous = expected_files.setdefault(source, expected) + if previous != expected: + raise InputIntegrityError( + "run_input_changed", + "captured run identity is inconsistent", + ) + for source, expected in sorted( + expected_files.items(), + key=lambda item: item[0].relative_to(repo_root).as_posix(), + ): + relative = source.relative_to(repo_root) + _copy_verified_file(source, frozen_repo / relative, expected) + snapshot_path = market_root / "snapshots" / f"{config.snapshot_id}.json" _copy_verified_file( - market_root / "snapshots" / f"{snapshot_id}.json", - frozen_market / "snapshots" / f"{snapshot_id}.json", + snapshot_path, + frozen_market / "snapshots" / snapshot_path.name, snapshot_digest, ) for batch in snapshot_document["batches"]: batch_id = str(batch["batch_id"]) - source_dir = market_root / "batches" / batch_id - target_dir = frozen_market / "batches" / batch_id for name, digest_field in ( ("manifest.json", "manifest_sha256"), ("market-data.parquet", "parquet_sha256"), @@ -538,27 +687,40 @@ def _freeze_execution_inputs( ("validation.json", "validation_sha256"), ): _copy_verified_file( - source_dir / name, - target_dir / name, + market_root / "batches" / batch_id / name, + frozen_market / "batches" / batch_id / name, str(batch[digest_field]), ) - frozen_view = open_snapshot(snapshot_id, root=frozen_market) - if frozen_view.digest != snapshot_normalized_digest: + request = { + "schema_version": 2, + "project_id": str(identity["project_id"]), + "run_id": str(identity["run_id"]), + "attempt_id": attempt_id, + "output_root": str((repo_root / ".local/quant-research").resolve()), + "repository": str(frozen_repo.resolve()), + "market_data": str(frozen_market.resolve()), + "live_repository": str(repo_root.resolve()), + "runtime_cache": str((execution_root / "runtime-cache").resolve()), + "staging": str(Path(staging).resolve()), + "config": config_path.resolve().relative_to(repo_root).as_posix(), + "code_identity": dict(code_identity), + "runtime_lock": dict(runtime_lock), + "market_snapshot": dict(snapshot_document), + "environment": { + "schema_version": 1, + "python": runtime_lock["python"], + "platform": runtime_lock["platform"], + }, + } + write_manifest(execution_root / "request.json", request) + frozen = open_snapshot(config.snapshot_id, root=frozen_market) + if frozen.digest != identity["snapshot_normalized_sha256"]: raise InputIntegrityError( "run_input_changed", - "the frozen market-data snapshot differs from the run identity", + "frozen snapshot differs from the run identity", ) - return _FrozenExecutionInputs( - root=execution_root, - repository=frozen_repo, - market_data=frozen_market, - project_entry=frozen_repo / config.project_entry.relative_to(repo_root), - project_config=frozen_repo / project_config_relative, - benchmark_input=frozen_benchmark_input, - ) - except (KeyError, TypeError, OSError, MarketDataError, InputIntegrityError) as exc: - if execution_root.exists(): - shutil.rmtree(execution_root, ignore_errors=True) + except (KeyError, OSError, MarketDataError, ValueError, InputIntegrityError) as exc: + shutil.rmtree(execution_root, ignore_errors=True) if isinstance(exc, InputIntegrityError): raise raise InputIntegrityError( @@ -567,136 +729,213 @@ def _freeze_execution_inputs( ) from exc -def _run_inputs_unchanged( +def _v2_environment(repo_root: Path, execution_root: Path) -> dict[str, str]: + environment = _sanitized_environment(repo_root) + cache = execution_root / "runtime-cache" + cache.mkdir() + environment["NUMBA_CACHE_DIR"] = str(cache) + environment["MPLCONFIGDIR"] = str(cache / "matplotlib") + environment["XDG_CACHE_HOME"] = str(cache) + environment["TEMP"] = str(cache) + environment["TMP"] = str(cache) + return environment + + +def _package_identity( + path: Path, + *, + expected: Mapping[str, object], +) -> Mapping[str, object]: + from .result_package import ResultContractError, validate_result_package + + try: + document = validate_result_package(path) + except ResultContractError as exc: + raise EvidenceError("completed result package is invalid") from exc + identity = document.get("object") + project_id = expected.get("project_id") + run_id = expected.get("run_id") + expected_scenario = expected.get("scenario_document") + expected_scenario_id = ( + expected_scenario.get("scenario_id") + if isinstance(expected_scenario, Mapping) + else None + ) + if not isinstance(identity, Mapping) or ( + identity.get("kind") != "local_research" + or identity.get("status") != "complete" + or identity.get("strategy_id") != project_id + or identity.get("scenario_id") != expected_scenario_id + or identity.get("run_id") != run_id + ): + raise EvidenceError("completed result package identity is invalid") + package_root = Path(path) + package_documents: dict[str, object] = {} + for name, relative in ( + ("project_run", "config/project-run.json"), + ("scenario_document", "config/scenario.json"), + ("code_identity", "config/code-identity.json"), + ("market_snapshot", "evidence/market-snapshot.json"), + ("runtime_lock", "evidence/runtime-lock.json"), + ): + try: + package_documents[name] = json.loads( + (package_root / relative).read_text(encoding="utf-8") + ) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise EvidenceError("completed result package identity is invalid") from exc + if package_documents[name] != expected.get(name): + raise EvidenceError("completed result package identity is invalid") + code_identity = package_documents["code_identity"] + if not isinstance(code_identity, Mapping): + raise EvidenceError("completed result package identity is invalid") + inputs = code_identity.get("inputs") + if not isinstance(inputs, Mapping) or not isinstance( + inputs.get("declared_inputs"), list + ): + raise EvidenceError("completed result package identity is invalid") + config_digest = canonical_digest( + { + "project_run": package_documents["project_run"], + "scenario": package_documents["scenario_document"], + "declared_inputs": inputs["declared_inputs"], + } + ) + code_digest = canonical_digest( + { + "code_identity": { + "schema_version": code_identity.get("schema_version"), + "files": code_identity.get("files"), + }, + "runtime_lock": package_documents["runtime_lock"], + } + ) + recalculated_run_id = compute_run_id( + canonical_digest(package_documents["market_snapshot"]), + config_digest, + code_digest, + ) + if ( + config_digest != expected.get("config_sha256") + or code_digest != expected.get("code_sha256") + or recalculated_run_id != run_id + ): + raise EvidenceError("completed result package identity is invalid") + return document + + +def _child_result(completed: subprocess.CompletedProcess[str]) -> tuple[str, str]: + try: + document = json.loads(completed.stdout) + except (TypeError, json.JSONDecodeError): + return "failed", "project_process_failed" + if not isinstance(document, Mapping): + return "failed", "project_process_failed" + status = document.get("status") + reasons = document.get("reasons") + code = reasons[0] if isinstance(reasons, list) and reasons else "project_process_failed" + if not isinstance(code, str) or _REASON_PATTERN.fullmatch(code) is None: + code = "project_process_failed" + if status not in {"complete", "evidence_insufficient", "failed"}: + status = "failed" + return str(status), code + + +def _v2_inputs_unchanged( *, config_path: Path, repo_root: Path, market_root: Path, - snapshot_id: str, - snapshot_path: Path, - snapshot_digest: str, - snapshot_normalized_digest: str, config_digest: str, code_digest: str, - inputs: Mapping[str, object], + snapshot_digest: str, + snapshot_normalized_digest: str, ) -> bool: try: - current_snapshot = open_snapshot(snapshot_id, root=market_root) - current_snapshot_document = json.loads( - snapshot_path.read_text(encoding="utf-8") - ) - current_snapshot_digest = file_digest(snapshot_path) current_config = load_run_config(config_path, repo_root=repo_root) - current_config_digest, current_code_digest, current_inputs = _input_evidence( + _, current_config_digest, current_code_digest, _, _, _ = _v2_identity( current_config, repo_root=repo_root, + config_path=config_path, ) - except ( - ConfigurationError, - InputIntegrityError, - MarketDataError, - OSError, - UnicodeDecodeError, - json.JSONDecodeError, - ): + current_snapshot_path = ( + market_root / "snapshots" / f"{current_config.snapshot_id}.json" + ) + current_snapshot = open_snapshot( + current_config.snapshot_id, + root=market_root, + ) + except (ConfigurationError, MarketDataError, OSError): return False return ( - current_snapshot_digest == snapshot_digest - and current_snapshot.digest == snapshot_normalized_digest - and _snapshot_requirements_match( - current_snapshot_document.get("selection"), - current_config.snapshot_requirements, - ) - and current_config_digest == config_digest + current_config_digest == config_digest and current_code_digest == code_digest - and current_inputs == dict(inputs) - ) - - -def _sanitized_environment(python_path: Path) -> dict[str, str]: - allowed = ( - "SYSTEMROOT", - "WINDIR", - "COMSPEC", - "PATHEXT", - "PATH", - "TEMP", - "TMP", - "HOME", - "USERPROFILE", - "LOCALAPPDATA", - ) - environment = {key: os.environ[key] for key in allowed if key in os.environ} - environment.update( - { - "PYTHONPATH": str(python_path), - "PYTHONNOUSERSITE": "1", - "PYTHONDONTWRITEBYTECODE": "1", - "PYTHONUTF8": "1", - } + and file_digest(current_snapshot_path) == snapshot_digest + and current_snapshot.digest == snapshot_normalized_digest ) - return environment - -def _repo_state( - repo_root: Path, - *, - ignored_roots: Sequence[Path], -) -> dict[str, tuple[int, int]]: - ignored_names = {".git", ".venv", ".pytest_cache", ".ruff_cache", "__pycache__"} - state: dict[str, tuple[int, int]] = {} - ignored = tuple(path.resolve() for path in ignored_roots) - for path in repo_root.rglob("*"): - if any(part in ignored_names for part in path.relative_to(repo_root).parts): - continue - if any(_inside(path, root) for root in ignored): - continue - if path.is_file() and not path.is_symlink(): - stat = path.stat() - state[path.relative_to(repo_root).as_posix()] = (stat.st_size, stat.st_mtime_ns) - return state +def execute_frozen_inputs(frozen_inputs: Path, staging: Path) -> dict[str, object]: + from .scenario import ScenarioRequest, execute_scenario + from .strategy_loader import load_strategy -def _project_status(staging: Path) -> tuple[str, tuple[str, ...]]: - path = staging / "project-status.json" + request_path = Path(frozen_inputs).resolve() + execution_root = request_path.parent try: - document = json.loads(path.read_text(encoding="utf-8")) + request = json.loads(request_path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: - raise EvidenceError("project status is missing or invalid") from exc - required = {"schema_version", "status", "reason_codes"} - if ( - not isinstance(document, dict) - or not required.issubset(document) - or set(document) - required != ({"next_action"} if "next_action" in document else set()) - ): - raise EvidenceError("project status structure is invalid") - status = document["status"] - reasons = document["reason_codes"] + raise ConfigurationError("invalid_frozen_inputs", "frozen inputs are invalid") from exc + if not isinstance(request, Mapping) or request.get("schema_version") != 2: + raise ConfigurationError("invalid_frozen_inputs", "frozen inputs are invalid") + repository = Path(str(request.get("repository", ""))).resolve() + market_data = Path(str(request.get("market_data", ""))).resolve() + runtime_cache = Path(str(request.get("runtime_cache", ""))).resolve() + expected_staging = Path(str(request.get("staging", ""))).resolve() + if Path(staging).resolve() != expected_staging: + raise ConfigurationError("staging_mismatch", "staging does not match frozen inputs") if ( - document["schema_version"] != 1 - or status not in _STOP_STATES - or not isinstance(reasons, list) - or len(reasons) > 10 - or any(not isinstance(code, str) or _REASON_PATTERN.fullmatch(code) is None for code in reasons) + repository != (execution_root / "repository").resolve() + or market_data != (execution_root / "market-data").resolve() + or runtime_cache != (execution_root / "runtime-cache").resolve() ): - raise EvidenceError("project status value is invalid") - if status == "complete" and reasons: - raise EvidenceError("complete project status must not contain reasons") - next_action = document.get("next_action") - expected_next_action = "return_to_caller" if status == "complete" else None - if next_action != expected_next_action: - raise EvidenceError("project status next_action is invalid") - return status, tuple(reasons) - - -def _actual_staging_files(staging: Path) -> set[str]: - files: set[str] = set() - for path in staging.rglob("*"): - if path.is_symlink(): - raise EvidenceError("project output must not contain symlinks") - if path.is_file(): - files.add(path.relative_to(staging).as_posix()) - return files + raise ConfigurationError("unsafe_frozen_inputs", "frozen input roots are unsafe") + config_path = repository / str(request.get("config", "")) + config = load_run_config(config_path, repo_root=repository) + started = time.perf_counter() + loaded = load_strategy(repository, config.document["strategy"]) + if loaded.descriptor.strategy_id != config.project_id: + raise ConfigurationError( + "strategy_identity_mismatch", + "project_id must match the loaded strategy descriptor", + ) + strategy_load_seconds = time.perf_counter() - started + snapshot = open_snapshot(config.snapshot_id, root=market_data) + try: + scenario_document = json.loads(config.scenario_config.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ConfigurationError("invalid_scenario_config", "scenario config is invalid") from exc + if not isinstance(scenario_document, Mapping): + raise ConfigurationError("invalid_scenario_config", "scenario config is invalid") + outcome = execute_scenario( + ScenarioRequest( + loaded_strategy=loaded, + snapshot=snapshot, + scenario=scenario_document, + project_document=config.document, + run_id=str(request["run_id"]), + output_dir=Path(staging), + code_identity=request["code_identity"], + market_snapshot=request["market_snapshot"], + runtime_lock=request["runtime_lock"], + environment=request["environment"], + strategy_load_seconds=strategy_load_seconds, + ) + ) + return { + "status": "complete", + "reasons": [], + "package_sha256": outcome.package.package_sha256, + } def run_project(config_path: Path, *, repo_root: Path) -> RunResult: @@ -704,33 +943,26 @@ def run_project(config_path: Path, *, repo_root: Path) -> RunResult: stages: list[StageRecord] = [] try: raw = _load_raw_config(config_path, repo_root=repo_root) + config = load_run_config(config_path, repo_root=repo_root) except ConfigurationError as exc: + project_id = "_invalid" + try: + project_id = _safe_project_id(raw) # type: ignore[possibly-undefined] + except UnboundLocalError: + pass return _attempt_result( repo_root=repo_root, - project_id="_invalid", + project_id=project_id, status="evidence_insufficient", - stage="snapshot_validation", + stage="config_validation", code=exc.code, message=str(exc), run_id=None, - stages=(StageRecord("snapshot_validation", "evidence_insufficient"),), - ) - project_id = _safe_project_id(raw) - snapshot_id = raw.get("snapshot_id") - if not isinstance(snapshot_id, str) or _SHA256_PATTERN.fullmatch(snapshot_id) is None: - return _attempt_result( - repo_root=repo_root, - project_id=project_id, - status="evidence_insufficient", - stage="snapshot_validation", - code="missing_snapshot", - message="snapshot_id is missing or invalid", - run_id=None, - stages=(StageRecord("snapshot_validation", "evidence_insufficient"),), + stages=(StageRecord("config_validation", "evidence_insufficient"),), ) - - market_root = repo_root / ".local" / "market-data" - snapshot_path = market_root / "snapshots" / f"{snapshot_id}.json" + project_id = config.project_id + market_root = repo_root / ".local/market-data" + snapshot_path = market_root / "snapshots" / f"{config.snapshot_id}.json" if not snapshot_path.is_file(): return _attempt_result( repo_root=repo_root, @@ -743,7 +975,7 @@ def run_project(config_path: Path, *, repo_root: Path) -> RunResult: stages=(StageRecord("snapshot_validation", "evidence_insufficient"),), ) try: - snapshot_view = open_snapshot(snapshot_id, root=market_root) + snapshot = open_snapshot(config.snapshot_id, root=market_root) snapshot_document = json.loads(snapshot_path.read_text(encoding="utf-8")) snapshot_digest = file_digest(snapshot_path) except (MarketDataError, OSError, UnicodeDecodeError, json.JSONDecodeError): @@ -757,18 +989,28 @@ def run_project(config_path: Path, *, repo_root: Path) -> RunResult: run_id=None, stages=(StageRecord("snapshot_validation", "failed"),), ) + if not _snapshot_requirements_match( + snapshot_document.get("selection"), config.snapshot_requirements + ): + return _attempt_result( + repo_root=repo_root, + project_id=project_id, + status="evidence_insufficient", + stage="snapshot_validation", + code="snapshot_requirements_unmet", + message="snapshot does not exactly cover the declared requirements", + run_id=None, + stages=(StageRecord("snapshot_validation", "evidence_insufficient"),), + ) stages.append(StageRecord("snapshot_validation", "complete")) - try: - config = load_run_config(config_path, repo_root=repo_root) - if not _snapshot_requirements_match( - snapshot_document.get("selection"), config.snapshot_requirements - ): - raise ConfigurationError( - "snapshot_requirements_unmet", - "snapshot does not exactly cover the declared requirements", + _, config_digest, code_digest, inputs, code_identity, runtime_lock = ( + _v2_identity( + config, + repo_root=repo_root, + config_path=Path(config_path).resolve(), ) - config_digest, code_digest, inputs = _input_evidence(config, repo_root=repo_root) + ) except ConfigurationError as exc: return _attempt_result( repo_root=repo_root, @@ -780,42 +1022,53 @@ def run_project(config_path: Path, *, repo_root: Path) -> RunResult: run_id=None, stages=(*stages, StageRecord("config_validation", "evidence_insufficient")), ) - except InputIntegrityError as exc: - return _attempt_result( - repo_root=repo_root, + stages.append(StageRecord("config_validation", "complete")) + snapshot_identity_digest = canonical_digest(snapshot_document) + run_id = compute_run_id(snapshot_identity_digest, config_digest, code_digest) + identity = { + **inputs, + "project_id": project_id, + "run_id": run_id, + "market_snapshot": dict(snapshot_document), + "snapshot_manifest_sha256": snapshot_digest, + "snapshot_identity_sha256": snapshot_identity_digest, + "snapshot_normalized_sha256": snapshot.digest, + } + try: + project_root = _resolve_output_project_root(repo_root, project_id) + except ConfigurationError as exc: + return RunResult( + status="evidence_insufficient", project_id=project_id, - status="failed", - stage="config_validation", - code=exc.code, - message=str(exc), run_id=None, - stages=(*stages, StageRecord("config_validation", "failed")), + run_path=None, + attempt_id=None, + reused=False, + reasons=(exc.code,), + stages=(StageRecord("config_validation", "evidence_insufficient"),), + next_action=None, + ) + try: + run_dir = _resolve_output_run_dir(repo_root, project_id, run_id) + except ConfigurationError as exc: + return RunResult( + status="evidence_insufficient", + project_id=project_id, + run_id=run_id, + run_path=None, + attempt_id=None, + reused=False, + reasons=(exc.code,), + stages=(*stages, StageRecord("config_validation", "evidence_insufficient")), + next_action=None, ) - stages.append(StageRecord("config_validation", "complete")) - - run_id = compute_run_id(snapshot_digest, config_digest, code_digest) - snapshot_evidence = { - "snapshot_id": snapshot_id, - "manifest_sha256": snapshot_digest, - "normalized_sha256": snapshot_view.digest, - } - project_root = config.output_root / config.project_id - run_dir = project_root / run_id if run_dir.exists(): try: - validate_complete_run( - run_dir, - project_id=config.project_id, - run_id=run_id, - snapshot=snapshot_evidence, - inputs=inputs, - command=config.command, - required_outputs=config.required_outputs, - ) + _package_identity(run_dir, expected=identity) except EvidenceError: return _attempt_result( repo_root=repo_root, - project_id=config.project_id, + project_id=project_id, status="failed", stage="evidence_finalization", code="completed_evidence_mismatch", @@ -823,99 +1076,61 @@ def run_project(config_path: Path, *, repo_root: Path) -> RunResult: run_id=run_id, stages=(*stages, StageRecord("evidence_finalization", "failed")), ) - complete_stages = tuple(StageRecord(name, "complete") for name in _COMPLETE_STAGE_NAMES) return RunResult( - status="complete", - project_id=config.project_id, - run_id=run_id, - run_path=run_dir, - attempt_id=None, - reused=True, - reasons=(), - stages=complete_stages, - next_action="return_to_caller", + "complete", + project_id, + run_id, + run_dir, + None, + True, + (), + tuple(StageRecord(name, "complete") for name in _COMPLETE_STAGE_NAMES), + "return_to_caller", ) - project_root.mkdir(parents=True, exist_ok=True) attempt_id = uuid.uuid4().hex staging = project_root / f".{run_id}.{attempt_id}.tmp" execution_root = project_root / f".{run_id}.{attempt_id}.inputs" - staging.mkdir() try: - frozen = _freeze_execution_inputs( + _copy_v2_inputs( + config_path=Path(config_path).resolve(), config=config, repo_root=repo_root, market_root=market_root, snapshot_document=snapshot_document, snapshot_digest=snapshot_digest, - snapshot_normalized_digest=snapshot_view.digest, - inputs=inputs, + identity=identity, + code_identity=code_identity, + runtime_lock=runtime_lock, execution_root=execution_root, + staging=staging, + attempt_id=attempt_id, ) - except InputIntegrityError: + except InputIntegrityError as exc: return _attempt_result( repo_root=repo_root, - project_id=config.project_id, + project_id=project_id, status="failed", stage="project_execution", - code="run_input_changed", - message="run inputs could not be frozen for execution", + code=exc.code, + message=str(exc), run_id=run_id, stages=(*stages, StageRecord("project_execution", "failed")), staging=staging, ) - before_state = _repo_state( - repo_root, - ignored_roots=(staging, execution_root), + before_state = _repo_state(repo_root, ignored_roots=(staging, execution_root)) + command = _execute_command( + repo_root=repo_root, + execution_root=execution_root, + staging=staging, ) - command = [ - str((repo_root / config.command[0]).resolve()), - str( - ( - repo_root - / "scripts/research/local_quant_research/adapter_guard.py" - ).resolve() - ), - "--staging-root", - str(staging.resolve()), - "--execution-root", - str(frozen.root.resolve()), - "--repository-root", - str(repo_root), - "--venv-root", - str((repo_root / ".venv").resolve()), - "--entry", - str(frozen.project_entry.resolve()), - "--", - *config.command[2:], - "--snapshot-manifest", - str((frozen.market_data / "snapshots" / f"{snapshot_id}.json").resolve()), - "--market-data-root", - str(frozen.market_data.resolve()), - "--project-config", - str(frozen.project_config.resolve()), - "--output-dir", - str(staging.resolve()), - "--run-id", - run_id, - "--snapshot-id", - snapshot_id, - "--code-sha256", - code_digest, - "--config-sha256", - config_digest, - ] - if frozen.benchmark_input is not None: - command.extend( - ["--benchmark-input", str(frozen.benchmark_input.resolve())] - ) - completed = None + completed: subprocess.CompletedProcess[str] | None = None try: completed = subprocess.run( command, - cwd=staging, + cwd=repo_root, shell=False, - env=_sanitized_environment(frozen.repository), + env=_v2_environment(repo_root, execution_root), capture_output=True, text=True, timeout=_PROJECT_EXECUTION_TIMEOUT_SECONDS, @@ -928,7 +1143,7 @@ def run_project(config_path: Path, *, repo_root: Path) -> RunResult: except OSError: return _attempt_result( repo_root=repo_root, - project_id=config.project_id, + project_id=project_id, status="failed", stage="project_execution", code="input_cleanup_failed", @@ -937,26 +1152,11 @@ def run_project(config_path: Path, *, repo_root: Path) -> RunResult: stages=(*stages, StageRecord("project_execution", "failed")), staging=staging, ) - if completed is None: - return _attempt_result( - repo_root=repo_root, - project_id=config.project_id, - status="failed", - stage="project_execution", - code="project_process_failed", - message="project process could not complete", - run_id=run_id, - stages=(*stages, StageRecord("project_execution", "failed")), - staging=staging, - ) - after_state = _repo_state( - repo_root, - ignored_roots=(staging, execution_root), - ) + after_state = _repo_state(repo_root, ignored_roots=(staging, execution_root)) if after_state != before_state: return _attempt_result( repo_root=repo_root, - project_id=config.project_id, + project_id=project_id, status="failed", stage="project_execution", code="write_outside_staging", @@ -965,162 +1165,79 @@ def run_project(config_path: Path, *, repo_root: Path) -> RunResult: stages=(*stages, StageRecord("project_execution", "failed")), staging=staging, ) - if not _run_inputs_unchanged( - config_path=config_path, + if not _v2_inputs_unchanged( + config_path=Path(config_path).resolve(), repo_root=repo_root, market_root=market_root, - snapshot_id=snapshot_id, - snapshot_path=snapshot_path, - snapshot_digest=snapshot_digest, - snapshot_normalized_digest=snapshot_view.digest, config_digest=config_digest, code_digest=code_digest, - inputs=inputs, + snapshot_digest=snapshot_digest, + snapshot_normalized_digest=snapshot.digest, ): return _attempt_result( repo_root=repo_root, - project_id=config.project_id, + project_id=project_id, status="failed", stage="project_execution", code="run_input_changed", - message="run input changed during project execution", + message="run input changed during shared scenario execution", run_id=run_id, stages=(*stages, StageRecord("project_execution", "failed")), staging=staging, ) - if completed.returncode != 0: + if completed is None: return _attempt_result( repo_root=repo_root, - project_id=config.project_id, + project_id=project_id, status="failed", stage="project_execution", code="project_process_failed", - message="project process returned a non-zero exit code", + message="project process could not complete", run_id=run_id, stages=(*stages, StageRecord("project_execution", "failed")), staging=staging, ) - stages.append(StageRecord("project_execution", "complete")) - - try: - project_status, reason_codes = _project_status(staging) - except EvidenceError: - return _attempt_result( - repo_root=repo_root, - project_id=config.project_id, - status="failed", - stage="output_validation", - code="invalid_project_status", - message="project status is missing, unknown or invalid", - run_id=run_id, - stages=(*stages, StageRecord("output_validation", "failed")), - staging=staging, - ) - if project_status != "complete": - status = project_status if project_status in {"evidence_insufficient", "failed"} else "failed" - code = reason_codes[0] if reason_codes else "project_reported_failure" + child_status, child_code = _child_result(completed) + if completed.returncode != 0 or child_status != "complete": + status = child_status if child_status == "evidence_insufficient" else "failed" return _attempt_result( repo_root=repo_root, - project_id=config.project_id, + project_id=project_id, status=status, - stage="output_validation", - code=code, - message="project reported that research could not complete", + stage="project_execution", + code=child_code, + message=child_code, run_id=run_id, - stages=(*stages, StageRecord("output_validation", status)), + stages=(*stages, StageRecord("project_execution", status)), staging=staging, ) + stages.append(StageRecord("project_execution", "complete")) try: - output_evidence = collect_output_evidence(staging, config.required_outputs) - actual_files = _actual_staging_files(staging) - fixed_files = { - "project-status.json", - *(spec.path for spec in config.required_outputs if spec.format != "directory"), - } - directory_prefixes = tuple( - f"{spec.path}/" - for spec in config.required_outputs - if spec.format == "directory" - ) - if not fixed_files.issubset(actual_files) or any( - path not in fixed_files - and not any(path.startswith(prefix) for prefix in directory_prefixes) - for path in actual_files - ): - raise EvidenceError("project output file set differs from its declaration") + _package_identity(staging, expected=identity) except EvidenceError: return _attempt_result( repo_root=repo_root, - project_id=config.project_id, + project_id=project_id, status="failed", stage="output_validation", code="output_validation_failed", - message="required project outputs failed validation", + message="archive-ready result package failed validation", run_id=run_id, stages=(*stages, StageRecord("output_validation", "failed")), staging=staging, ) stages.append(StageRecord("output_validation", "complete")) - - if not _run_inputs_unchanged( - config_path=config_path, - repo_root=repo_root, - market_root=market_root, - snapshot_id=snapshot_id, - snapshot_path=snapshot_path, - snapshot_digest=snapshot_digest, - snapshot_normalized_digest=snapshot_view.digest, - config_digest=config_digest, - code_digest=code_digest, - inputs=inputs, - ): - return _attempt_result( - repo_root=repo_root, - project_id=config.project_id, - status="failed", - stage="evidence_finalization", - code="run_input_changed", - message="run input changed before evidence finalization", - run_id=run_id, - stages=(*stages, StageRecord("evidence_finalization", "failed")), - staging=staging, - ) - - complete_stages = [ - {"name": name, "status": "complete"} for name in _COMPLETE_STAGE_NAMES - ] - manifest = { - "schema_version": 1, - "project_id": config.project_id, - "run_id": run_id, - "status": "complete", - "snapshot": snapshot_evidence, - "inputs": inputs, - "command": list(config.command), - "stages": complete_stages, - "outputs": output_evidence, - "output_set_sha256": canonical_digest(output_evidence), - } published = False try: - write_manifest(staging / "run-manifest.json", manifest) _publish_directory(staging, run_dir) published = True - validate_complete_run( - run_dir, - project_id=config.project_id, - run_id=run_id, - snapshot=snapshot_evidence, - inputs=inputs, - command=config.command, - required_outputs=config.required_outputs, - ) + _package_identity(run_dir, expected=identity) except (OSError, EvidenceError): - if published and run_dir.exists(): - shutil.rmtree(run_dir) + if published: + shutil.rmtree(run_dir, ignore_errors=True) return _attempt_result( repo_root=repo_root, - project_id=config.project_id, + project_id=project_id, status="failed", stage="evidence_finalization", code="evidence_finalization_failed", @@ -1130,13 +1247,13 @@ def run_project(config_path: Path, *, repo_root: Path) -> RunResult: staging=staging if staging.exists() else None, ) return RunResult( - status="complete", - project_id=config.project_id, - run_id=run_id, - run_path=run_dir, - attempt_id=None, - reused=False, - reasons=(), - stages=tuple(StageRecord(name, "complete") for name in _COMPLETE_STAGE_NAMES), - next_action="return_to_caller", + "complete", + project_id, + run_id, + run_dir, + None, + False, + (), + tuple(StageRecord(name, "complete") for name in _COMPLETE_STAGE_NAMES), + "return_to_caller", ) diff --git a/scripts/research/local_quant_research/scenario.py b/scripts/research/local_quant_research/scenario.py new file mode 100644 index 0000000..6a4f853 --- /dev/null +++ b/scripts/research/local_quant_research/scenario.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +from scripts.research.market_data.query import SnapshotView + +from .contracts import ExecutionBundle, ResultExtension +from .evidence import execution_digest +from .performance import PerformanceEvidence, include_shared_work, run_cold_warm +from .result_package import ( + ResultContractError, + ResultPackage, + ResultPackageRequest, + _validate_extension_contracts, + write_result_package, +) +from .strategy_loader import LoadedStrategy +from .vectorbt_runtime import run_vectorbt + + +SCENARIO_STAGES = ( + "strategy_load", + "strategy_prepare", + "primary_vectorbt", + "followup_prepare", + "followup_vectorbt", + "core_facts", + "strategy_extensions", + "parquet_materialize", + "readback_validate", + "report_and_manifest", +) + + +@dataclass(frozen=True, slots=True) +class ScenarioRequest: + loaded_strategy: LoadedStrategy + snapshot: SnapshotView + scenario: Mapping[str, object] + project_document: Mapping[str, object] + run_id: str + output_dir: Path + code_identity: Mapping[str, object] + market_snapshot: Mapping[str, object] + runtime_lock: Mapping[str, object] + environment: Mapping[str, object] + strategy_load_seconds: float = 0.0 + + +@dataclass(frozen=True, slots=True) +class ScenarioOutcome: + package: ResultPackage + performance: PerformanceEvidence + stages: Mapping[str, float] + + +@dataclass(frozen=True, slots=True) +class _EngineOutcome: + execution: ExecutionBundle + extensions: tuple[ResultExtension, ...] + stages: Mapping[str, float] + + +def _timed(operation): + started = time.perf_counter() + value = operation() + return value, time.perf_counter() - started + + +def _engine_once(request: ScenarioRequest) -> _EngineOutcome: + module = request.loaded_strategy.module + prepared, prepare_seconds = _timed( + lambda: module.prepare(request.snapshot, request.scenario) + ) + primary, primary_seconds = _timed( + lambda: run_vectorbt(prepared.ledger_input, prepared.primary_program) + ) + followup, followup_prepare_seconds = _timed( + lambda: module.followup_program(prepared, primary) + ) + followup_seconds = 0.0 + if followup is None: + execution = ExecutionBundle(primary, primary, ("primary",)) + else: + final, followup_seconds = _timed( + lambda: run_vectorbt(prepared.ledger_input, followup) + ) + execution = ExecutionBundle(primary, final, ("primary", "followup")) + extensions, extension_seconds = _timed( + lambda: module.build_extensions(prepared, execution) + ) + return _EngineOutcome( + execution=execution, + extensions=extensions, + stages={ + "strategy_prepare": prepare_seconds, + "primary_vectorbt": primary_seconds, + "followup_prepare": followup_prepare_seconds, + "followup_vectorbt": followup_seconds, + "strategy_extensions": extension_seconds, + }, + ) + + +def execute_scenario(request: ScenarioRequest) -> ScenarioOutcome: + cold_extensions: tuple[ResultExtension, ...] | None = None + + def digest(outcome: _EngineOutcome) -> str: + nonlocal cold_extensions + _validate_extension_contracts(outcome.extensions) + if cold_extensions is None: + cold_extensions = outcome.extensions + else: + if len(cold_extensions) != len(outcome.extensions): + raise ResultContractError("cold and warm extension counts differ") + for cold, warm in zip(cold_extensions, outcome.extensions, strict=True): + if ( + cold.name != warm.name + or cold.schema_version != warm.schema_version + or cold.unique_key != warm.unique_key + or dict(cold.evidence) != dict(warm.evidence) + or not cold.table.schema.equals(warm.table.schema, check_metadata=True) + or not cold.table.equals(warm.table, check_metadata=True) + ): + raise ResultContractError("cold and warm extensions differ") + return execution_digest(outcome.execution) + + warm, performance = run_cold_warm( + lambda: _engine_once(request), + digest=digest, + ) + scenario_id = request.scenario.get("scenario_id") + if not isinstance(scenario_id, str) or not scenario_id.strip(): + raise ValueError("scenario_id is missing or invalid") + stages = {name: 0.0 for name in SCENARIO_STAGES} + stages["strategy_load"] = request.strategy_load_seconds + stages.update(warm.stages) + performance_document = performance.to_document() + performance_document["stages"] = stages + code_files = { + source.relative_to(request.loaded_strategy.root).as_posix(): source + for source in request.loaded_strategy.source_paths + } + package_request = ResultPackageRequest( + strategy_id=request.loaded_strategy.descriptor.strategy_id, + scenario_id=scenario_id, + run_id=request.run_id, + output_dir=request.output_dir, + execution=warm.execution, + extensions=warm.extensions, + code_files=code_files, + config_documents={ + "scenario.json": dict(request.scenario), + "project-run.json": dict(request.project_document), + "code-identity.json": dict(request.code_identity), + }, + evidence_documents={ + "market-snapshot.json": dict(request.market_snapshot), + "runtime-lock.json": dict(request.runtime_lock), + "performance.json": performance_document, + "environment.json": dict(request.environment), + }, + performance_finalizer=lambda writer_stages, writer_measurement: { + **include_shared_work( + performance, + request.strategy_load_seconds + + writer_measurement["prefinalization_seconds"], + ).to_document(), + "stages": {**stages, **writer_stages}, + }, + atomic_publish=False, + ) + package = write_result_package(package_request) + stages.update(package.writer_stages) + complete_performance = include_shared_work( + performance, + request.strategy_load_seconds + package.writer_seconds, + ) + return ScenarioOutcome(package, complete_performance, stages) diff --git a/scripts/research/local_quant_research/strategy_loader.py b/scripts/research/local_quant_research/strategy_loader.py new file mode 100644 index 0000000..1b41eef --- /dev/null +++ b/scripts/research/local_quant_research/strategy_loader.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import importlib +import os +import re +import stat +import sys +from dataclasses import dataclass +from pathlib import Path +from types import ModuleType +from typing import Mapping + +from .contracts import StrategyDescriptor, StrategyModule + + +_STRATEGY_FIELDS = {"root", "module", "symbol"} +_DOTTED_NAME = re.compile(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*") +_SYMBOL_NAME = re.compile(r"[A-Za-z_]\w*") +_IGNORED_SOURCE_DIRECTORIES = { + ".git", + ".local", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "__pycache__", + "build", + "dist", +} +_FILE_ATTRIBUTE_REPARSE_POINT = 0x0400 + + +class ConfigurationError(ValueError): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True, slots=True) +class LoadedStrategy: + module: StrategyModule + root: Path + source_paths: tuple[Path, ...] + descriptor: StrategyDescriptor + + +def _inside(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + return False + return True + + +def _is_reparse_point(path: Path, metadata: os.stat_result | None = None) -> bool: + try: + details = os.lstat(path) if metadata is None else metadata + except OSError: + return False + return ( + stat.S_ISLNK(details.st_mode) + or bool( + getattr(details, "st_file_attributes", 0) + & _FILE_ATTRIBUTE_REPARSE_POINT + ) + or bool(getattr(os.path, "isjunction", lambda _path: False)(path)) + ) + + +def _reject_reparse_components(path: Path, root: Path) -> None: + current = root + for part in path.relative_to(root).parts: + current /= part + if current.exists() and _is_reparse_point(current): + raise ConfigurationError( + "unsafe_strategy_root", + "strategy_root must not contain a reparse point", + ) + + +def _resolve_strategy_root(repo_root: Path, value: object) -> Path: + if not isinstance(value, str) or not value: + raise ConfigurationError( + "invalid_strategy_root", + "strategy_root must be a repository-relative path", + ) + candidate = Path(value) + if candidate.is_absolute() or ".." in candidate.parts: + raise ConfigurationError( + "unsafe_strategy_root", + "strategy_root must be a repository-relative path without '..'", + ) + unresolved = repo_root / candidate + try: + unresolved.relative_to(repo_root) + except ValueError as exc: + raise ConfigurationError( + "unsafe_strategy_root", + "strategy_root escapes the repository", + ) from exc + _reject_reparse_components(unresolved, repo_root) + resolved = unresolved.resolve() + if not _inside(resolved, repo_root): + raise ConfigurationError( + "unsafe_strategy_root", + "strategy_root escapes the repository", + ) + if not resolved.is_dir(): + raise ConfigurationError( + "missing_strategy_root", + "strategy_root must be an existing directory", + ) + return resolved + + +def _module_source_error(*, unsafe: bool) -> ConfigurationError: + state = "unsafe" if unsafe else "missing" + requirement = "stay inside strategy_root" if unsafe else "exist inside strategy_root" + return ConfigurationError( + f"{state}_strategy_module_file", + f"strategy module file and each package file must {requirement}", + ) + + +def _plain_file(path: Path, strategy_root: Path) -> Path | None: + try: + metadata = os.lstat(path) + except FileNotFoundError: + return None + except OSError as exc: + raise _module_source_error(unsafe=True) from exc + if _is_reparse_point(path, metadata) or not stat.S_ISREG(metadata.st_mode): + raise _module_source_error(unsafe=True) + resolved = path.resolve() + if not _inside(resolved, strategy_root): + raise _module_source_error(unsafe=True) + return resolved + + +def _plain_directory(path: Path, strategy_root: Path) -> bool: + try: + metadata = os.lstat(path) + except FileNotFoundError: + return False + except OSError as exc: + raise _module_source_error(unsafe=True) from exc + if _is_reparse_point(path, metadata) or not stat.S_ISDIR(metadata.st_mode): + raise _module_source_error(unsafe=True) + if not _inside(path.resolve(), strategy_root): + raise _module_source_error(unsafe=True) + return True + + +def _module_source_and_boundary( + strategy_root: Path, + module_name: str, +) -> tuple[Path, Path]: + parts = module_name.split(".") + current = strategy_root + top_package: Path | None = None + for index, part in enumerate(parts): + final = index == len(parts) - 1 + package = current / part + package_init = ( + _plain_file(package / "__init__.py", strategy_root) + if _plain_directory(package, strategy_root) + else None + ) + if package_init is not None: + if top_package is None: + top_package = package.resolve() + if final: + return package_init, top_package + current = package.resolve() + continue + if not final: + raise _module_source_error(unsafe=False) + module_file = _plain_file(current / f"{part}.py", strategy_root) + if module_file is None: + raise _module_source_error(unsafe=False) + return module_file, top_package or module_file.parent + raise _module_source_error(unsafe=False) + + +def _is_archive_directory(path: Path, boundary: Path) -> bool: + parts = path.relative_to(boundary).parts + return len(parts) >= 2 and parts[-2:] == ("research", "archives") + + +def _inside_archive_path(path: Path) -> bool: + parts = tuple(part.casefold() for part in path.parts) + return any( + parts[index : index + 2] == ("research", "archives") + for index in range(len(parts) - 1) + ) + + +def _plain_python_sources(boundary: Path) -> tuple[Path, ...]: + sources: list[Path] = [] + pending = [boundary] + while pending: + directory = pending.pop() + try: + entries = sorted(os.scandir(directory), key=lambda item: item.name) + except OSError as exc: + raise ConfigurationError( + "invalid_strategy_source_tree", + "strategy source tree cannot be inspected", + ) from exc + for entry in entries: + path = Path(entry.path) + try: + metadata = entry.stat(follow_symlinks=False) + except OSError as exc: + raise ConfigurationError( + "invalid_strategy_source_tree", + "strategy source entry cannot be inspected", + ) from exc + if _is_reparse_point(path, metadata): + raise ConfigurationError( + "unsafe_strategy_source_tree", + "strategy source tree must not contain a reparse point", + ) + if stat.S_ISDIR(metadata.st_mode): + if ( + entry.name not in _IGNORED_SOURCE_DIRECTORIES + and not _is_archive_directory(path, boundary) + ): + pending.append(path) + continue + if stat.S_ISREG(metadata.st_mode) and path.suffix.lower() == ".py": + sources.append(path.resolve()) + return tuple( + sorted(sources, key=lambda path: path.relative_to(boundary).as_posix()) + ) + + +def discover_strategy_sources( + strategy_root: Path, + module: str, +) -> tuple[Path, ...]: + root = Path(strategy_root).resolve() + if not root.is_dir(): + raise ConfigurationError( + "missing_strategy_root", + "strategy_root must be an existing directory", + ) + if not isinstance(module, str) or _DOTTED_NAME.fullmatch(module) is None: + raise ConfigurationError("invalid_strategy_module", "strategy module is invalid") + module_source, boundary = _module_source_and_boundary(root, module) + if _inside_archive_path(boundary): + raise ConfigurationError( + "unsafe_strategy_source_tree", + "strategy source boundary must not be inside research/archives", + ) + sources = _plain_python_sources(boundary) + if module_source not in sources: + raise ConfigurationError( + "unsafe_strategy_module_file", + "strategy module file must be an ordinary Python source", + ) + return sources + + +def _validate_module_file(imported: ModuleType, strategy_root: Path) -> None: + module_file = getattr(imported, "__file__", None) + if not isinstance(module_file, (str, os.PathLike)): + raise ConfigurationError( + "invalid_strategy_module_file", + "strategy module file is missing", + ) + resolved = Path(module_file).resolve() + if not resolved.is_file() or not _inside(resolved, strategy_root): + raise ConfigurationError( + "unsafe_strategy_module_file", + "strategy module file must be inside strategy_root", + ) + + +def _validate_strategy_symbol(imported: ModuleType, symbol: str) -> StrategyModule: + try: + module = getattr(imported, symbol) + except AttributeError as exc: + raise ConfigurationError( + "missing_strategy_symbol", + "strategy symbol is missing", + ) from exc + descriptor = getattr(module, "descriptor", None) + if not isinstance(descriptor, StrategyDescriptor): + raise ConfigurationError( + "invalid_strategy_symbol", + "strategy symbol descriptor is invalid", + ) + for method_name in ("prepare", "followup_program", "build_extensions"): + if not callable(getattr(module, method_name, None)): + raise ConfigurationError( + "invalid_strategy_symbol", + f"strategy symbol method {method_name} is missing", + ) + return module + + +def load_strategy( + repo_root: Path, + config: Mapping[str, object], +) -> LoadedStrategy: + if not isinstance(config, Mapping) or set(config) != _STRATEGY_FIELDS: + raise ConfigurationError( + "invalid_strategy_fields", + "strategy fields must be exactly root, module, and symbol", + ) + root = _resolve_strategy_root(Path(repo_root).resolve(), config["root"]) + module_name = config["module"] + symbol = config["symbol"] + if not isinstance(module_name, str) or _DOTTED_NAME.fullmatch(module_name) is None: + raise ConfigurationError("invalid_strategy_module", "strategy module is invalid") + if not isinstance(symbol, str) or _SYMBOL_NAME.fullmatch(symbol) is None: + raise ConfigurationError("invalid_strategy_symbol", "strategy symbol is invalid") + + source_paths = discover_strategy_sources(root, module_name) + import_root = str(root) + sys.path.insert(0, import_root) + importlib.invalidate_caches() + try: + imported = importlib.import_module(module_name) + _validate_module_file(imported, root) + module = _validate_strategy_symbol(imported, symbol) + finally: + if sys.path and sys.path[0] == import_root: + sys.path.pop(0) + else: + sys.path.remove(import_root) + return LoadedStrategy( + module=module, + root=root, + source_paths=source_paths, + descriptor=module.descriptor, + ) diff --git a/scripts/research/local_quant_research/vectorbt_runtime.py b/scripts/research/local_quant_research/vectorbt_runtime.py new file mode 100644 index 0000000..a957efc --- /dev/null +++ b/scripts/research/local_quant_research/vectorbt_runtime.py @@ -0,0 +1,610 @@ +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +from types import MappingProxyType +from typing import Callable + +import numpy as np +import pandas as pd +import vectorbt as vbt +from numba import njit +from vectorbt.portfolio import nb +from vectorbt.portfolio.enums import Direction, OrderSide, OrderStatus, TradeStatus + +from .contracts import ( + FILL_ACCEPTED, + FILL_IGNORED, + FILL_REJECTED, + SIDE_BUY, + SIDE_NONE, + SIDE_SELL, + ExecutionRun, + FillEvent, + LedgerInput, + OrderProgram, + SegmentView, +) + + +_ORDER_DTYPE = np.dtype( + [ + ("match_time", "U32"), + ("pindex", "i8"), + ("cancel_time", "U32"), + ("action", "U16"), + ("limit_price", "f8"), + ("comment", "U64"), + ("entrust_time", "U32"), + ("finish_time", "U32"), + ("side", "U16"), + ("price", "f8"), + ("commission", "f8"), + ("gains", "f8"), + ("type", "U16"), + ("time", "U32"), + ("security_name", "U64"), + ("security", "U64"), + ("filled", "i8"), + ("amount", "i8"), + ("status", "U16"), + ] +) +_ASSET_DTYPE = np.dtype( + [ + ("pindex", "i8"), + ("avg_cost", "f8"), + ("margin", "f8"), + ("amount", "f8"), + ("today_amount", "i8"), + ("hold_cost", "f8"), + ("side", "U16"), + ("price", "f8"), + ("gains", "f8"), + ("daily_gains", "f8"), + ("closeable_amount", "i8"), + ("time", "U32"), + ("security_name", "U64"), + ("security", "U64"), + ] +) +_CASH_DTYPE = np.dtype([("time", "U32"), ("cash", "f8")]) +_VALUE_DTYPE = np.dtype( + [ + ("time", "U32"), + ("total_value", "f8"), + ("returns", "f8"), + ("benchmark_returns", "f8"), + ] +) +_RETURNS_DTYPE = np.dtype([("time", "U32"), ("returns", "f8")]) + + +def _readonly(value: np.ndarray) -> np.ndarray: + array = np.asarray(value) + if not array.flags.owndata or not array.flags.c_contiguous: + array = np.array(array, copy=True, order="C") + array.setflags(write=False) + return array + + +def _times(dates: np.ndarray, suffix: str) -> np.ndarray: + days = np.datetime_as_string( + np.asarray(dates).astype("datetime64[ns]"), + unit="D", + ) + return np.char.add(days, suffix) + + +def _record_array(records: object) -> np.ndarray: + return np.asarray(getattr(records, "records_arr")) + + +def _matrix(value: np.ndarray, shape: tuple[int, int]) -> np.ndarray: + array = np.asarray(value) + if array.shape == (shape[0],) and shape[1] == 1: + array = array.reshape(shape) + if array.shape != shape: + raise RuntimeError("vectorbt accessor shape does not match the ledger") + return array + + +class ExecutionLedger: + def __init__( + self, + portfolio: object, + dates: np.ndarray, + symbols: tuple[str, ...], + close: np.ndarray, + ) -> None: + self.__portfolio = portfolio + self._dates = np.asarray(dates) + self._symbols = symbols + self._close = np.asarray(close) + self._cache: dict[str, np.ndarray] = {} + self._raw_cache: dict[str, np.ndarray] = {} + + def _cached(self, name: str, build: Callable[[], np.ndarray]) -> np.ndarray: + if name not in self._cache: + self._cache[name] = _readonly(build()) + return self._cache[name] + + def _raw(self, name: str, build: Callable[[], object]) -> np.ndarray: + if name not in self._raw_cache: + self._raw_cache[name] = np.asarray(build()) + return self._raw_cache[name] + + def _order_records(self) -> np.ndarray: + return self._raw( + "order_records", + lambda: _record_array(self.__portfolio.orders), + ) + + def _trade_records(self) -> np.ndarray: + return self._raw( + "trades", + lambda: _record_array(self.__portfolio.trades), + ) + + def _position_records(self) -> np.ndarray: + return self._raw( + "positions", + lambda: _record_array(self.__portfolio.positions), + ) + + @property + def orders(self) -> np.ndarray: + def build() -> np.ndarray: + records = self._order_records() + result = np.empty(len(records), dtype=_ORDER_DTYPE) + times = _times(self._dates, "T09:30:00") + realized: dict[tuple[int, int], float] = {} + for trade in self._trade_records(): + if int(trade["status"]) != TradeStatus.Closed: + continue + key = (int(trade["exit_idx"]), int(trade["col"])) + realized[key] = realized.get(key, 0.0) + float(trade["pnl"]) + for index, record in enumerate(records): + row = int(record["idx"]) + column = int(record["col"]) + time = times[row] + quantity = int(round(float(record["size"]))) + is_sell = int(record["side"]) == OrderSide.Sell + price = float(record["price"]) + fees = float(record["fees"]) + gains = realized.get((row, column), 0.0) if is_sell else 0.0 + result[index] = ( + time, + 0, + "", + "close" if is_sell else "open", + 0.0, + "", + time, + time, + "long", + price, + fees, + gains, + "market", + time, + self._symbols[column], + self._symbols[column], + quantity, + quantity, + "done", + ) + return result + + return self._cached("orders", build) + + @property + def assets(self) -> np.ndarray: + def build() -> np.ndarray: + shape = self._close.shape + values = _matrix( + self._raw("assets", self.__portfolio.assets), + shape, + ) + active_rows, active_columns = np.nonzero(np.abs(values) > 1e-12) + result = np.empty(len(active_rows), dtype=_ASSET_DTYPE) + times = _times(self._dates, "T16:00:00") + asset_flow = _matrix( + self._raw("asset_flow", self.__portfolio.asset_flow), + shape, + ) + asset_value = _matrix( + self._raw( + "asset_value", + lambda: self.__portfolio.asset_value(group_by=False), + ), + shape, + ) + cash_flow = _matrix( + self._raw( + "cash_flow", + lambda: self.__portfolio.cash_flow(group_by=False), + ), + shape, + ) + previous_value = np.vstack( + (np.zeros((1, asset_value.shape[1])), asset_value[:-1]) + ) + daily_gains = asset_value - previous_value + cash_flow + cost_by_row = np.full(values.shape, np.nan) + order_records = self._order_records() + for position in self._position_records(): + column = int(position["col"]) + entry_row = int(position["entry_idx"]) + exit_row = int(position["exit_idx"]) + buys = order_records[ + (order_records["col"] == column) + & (order_records["idx"] >= entry_row) + & (order_records["idx"] <= exit_row) + & (order_records["side"] == OrderSide.Buy) + ] + average = 0.0 + for buy_index, buy in enumerate(buys): + row = int(buy["idx"]) + quantity = float(buy["size"]) + after = float(values[row, column]) + before = after - quantity + average = ( + before * average + quantity * float(buy["price"]) + ) / after + stop = ( + int(buys[buy_index + 1]["idx"]) + if buy_index + 1 < len(buys) + else exit_row + 1 + ) + rows = np.arange(row, stop) + held = np.abs(values[rows, column]) > 1e-12 + cost_by_row[rows[held], column] = average + average_cost = cost_by_row[active_rows, active_columns] + if np.any(~np.isfinite(average_cost)): + raise RuntimeError( + "vectorbt order records do not cover active asset costs" + ) + amount = values[active_rows, active_columns] + price = self._close[active_rows, active_columns] + today_bought = np.maximum( + asset_flow[active_rows, active_columns], + 0.0, + ).astype(np.int64) + result["pindex"] = 0 + result["avg_cost"] = average_cost + result["margin"] = 0.0 + result["amount"] = amount + result["today_amount"] = today_bought + result["hold_cost"] = average_cost + result["side"] = "long" + result["price"] = price + result["gains"] = (price - average_cost) * amount + result["daily_gains"] = daily_gains[active_rows, active_columns] + result["closeable_amount"] = np.maximum( + np.rint(amount).astype(np.int64) - today_bought, + 0, + ) + result["time"] = times[active_rows] + securities = np.asarray(self._symbols) + result["security_name"] = securities[active_columns] + result["security"] = securities[active_columns] + return result + + return self._cached("assets", build) + + @property + def cash(self) -> np.ndarray: + def build() -> np.ndarray: + values = self._raw("cash", self.__portfolio.cash).reshape(-1) + result = np.empty(len(values), dtype=_CASH_DTYPE) + result["time"] = _times(self._dates, "T16:00:00") + result["cash"] = values + return result + + return self._cached("cash", build) + + @property + def value(self) -> np.ndarray: + def build() -> np.ndarray: + values = self._raw("value", self.__portfolio.value).reshape(-1) + returns = self._raw( + "returns", + self.__portfolio.cumulative_returns, + ).reshape(-1) + result = np.empty(len(values), dtype=_VALUE_DTYPE) + result["time"] = _times(self._dates, "T16:00:00") + result["total_value"] = values + result["returns"] = returns + result["benchmark_returns"] = np.nan + return result + + return self._cached("value", build) + + @property + def trades(self) -> np.ndarray: + return self._cached("trades", self._trade_records) + + @property + def positions(self) -> np.ndarray: + return self._cached("positions", self._position_records) + + @property + def returns(self) -> np.ndarray: + def build() -> np.ndarray: + values = self._raw( + "returns", + self.__portfolio.cumulative_returns, + ).reshape(-1) + result = np.empty(len(values), dtype=_RETURNS_DTYPE) + result["time"] = _times(self._dates, "T16:00:00") + result["returns"] = values + return result + + return self._cached("returns", build) + + +@njit +def _reset_orders_nb(orders) -> None: + enabled, side, size, price, fixed_fees, granularity, partial, priority = orders + enabled[:] = False + side[:] = SIDE_NONE + size[:] = 0.0 + price[:] = np.nan + fixed_fees[:] = 0.0 + granularity[:] = np.nan + partial[:] = False + priority[:] = 0 + + +@njit +def _pre_sim_func_nb(c, orders): + _reset_orders_nb(orders) + return () + + +@njit +def _order_func_nb(c, orders): + enabled, side, size, price, fixed_fees, granularity, partial, _ = orders + column = c.col + if not enabled[column] or side[column] == SIDE_NONE or size[column] <= 0.0: + return nb.NoOrder + signed_size = size[column] if side[column] == SIDE_BUY else -size[column] + return nb.order_nb( + size=signed_size, + price=price[column], + direction=Direction.LongOnly, + fixed_fees=fixed_fees[column], + size_granularity=granularity[column], + allow_partial=partial[column], + ) + + +def _as_numba_callback(callback: object) -> object: + return callback if hasattr(callback, "py_func") else njit(callback) + + +@dataclass(frozen=True, slots=True) +class _CallbackFunctions: + pre_segment_func_nb: object + post_order_func_nb: object + post_segment_func_nb: object + + +@lru_cache(maxsize=None) +def _specialized_functions( + prepare_segment_nb: object, + after_fill_nb: object, + after_segment_nb: object | None, +) -> _CallbackFunctions: + prepare_callback = _as_numba_callback(prepare_segment_nb) + fill_callback = _as_numba_callback(after_fill_nb) + + @njit + def pre_segment(c, inputs, params, state, trace, orders): + _reset_orders_nb(orders) + view = SegmentView( + c.i, + c.group, + c.from_col, + c.to_col, + c.last_cash[c.group], + c.last_value[c.group], + c.last_position[c.from_col : c.to_col], + c.last_val_price[c.from_col : c.to_col], + ) + prepare_callback(view, inputs, params, state, trace, orders) + for offset in range(c.to_col - c.from_col): + c.call_seq_now[offset] = offset + for offset in range(1, c.to_col - c.from_col): + current = c.call_seq_now[offset] + current_priority = orders[7][c.from_col + current] + insertion = offset + while insertion > 0: + previous = c.call_seq_now[insertion - 1] + previous_priority = orders[7][c.from_col + previous] + if previous_priority <= current_priority: + break + c.call_seq_now[insertion] = previous + insertion -= 1 + c.call_seq_now[insertion] = current + return () + + @njit + def post_order(c, inputs, params, state, trace, orders) -> None: + if not orders[0][c.col]: + return + result = c.order_result + status = FILL_IGNORED + if result.status == OrderStatus.Filled: + status = FILL_ACCEPTED + elif result.status == OrderStatus.Rejected: + status = FILL_REJECTED + side = SIDE_NONE + if result.side == OrderSide.Buy: + side = SIDE_BUY + elif result.side == OrderSide.Sell: + side = SIDE_SELL + event = FillEvent( + c.i, + c.col, + status, + side, + result.size, + result.price, + result.fees, + c.cash_now, + c.position_now, + ) + fill_callback(event, inputs, params, state, trace, orders) + + if after_segment_nb is None: + + @njit + def post_segment(c, inputs, params, state, trace, orders) -> None: + return None + + else: + segment_callback = _as_numba_callback(after_segment_nb) + + @njit + def post_segment(c, inputs, params, state, trace, orders) -> None: + view = SegmentView( + c.i, + c.group, + c.from_col, + c.to_col, + c.last_cash[c.group], + c.last_value[c.group], + c.last_position[c.from_col : c.to_col], + c.last_val_price[c.from_col : c.to_col], + ) + segment_callback(view, inputs, params, state, trace, orders) + + return _CallbackFunctions(pre_segment, post_order, post_segment) + + +@dataclass(frozen=True, slots=True) +class _SpecializedCallbacks: + order_func_nb: object + order_args: tuple[object, ...] + pre_sim_func_nb: object + pre_sim_args: tuple[object, ...] + pre_segment_func_nb: object + pre_segment_args: tuple[object, ...] + post_order_func_nb: object + post_order_args: tuple[object, ...] + post_segment_func_nb: object + post_segment_args: tuple[object, ...] + + +def _order_arrays(program: OrderProgram) -> tuple[np.ndarray, ...]: + orders = program.orders + return ( + orders.enabled, + orders.side, + orders.size, + orders.price, + orders.fixed_fees, + orders.size_granularity, + orders.allow_partial, + orders.priority, + ) + + +def _specialize_program(program: OrderProgram) -> _SpecializedCallbacks: + functions = _specialized_functions( + program.prepare_segment_nb, + program.after_fill_nb, + program.after_segment_nb, + ) + orders = _order_arrays(program) + trace = tuple(program.trace.values()) + program_args = (program.inputs, program.params, program.state, trace, orders) + return _SpecializedCallbacks( + order_func_nb=_order_func_nb, + order_args=(orders,), + pre_sim_func_nb=_pre_sim_func_nb, + pre_sim_args=(orders,), + pre_segment_func_nb=functions.pre_segment_func_nb, + pre_segment_args=program_args, + post_order_func_nb=functions.post_order_func_nb, + post_order_args=program_args, + post_segment_func_nb=functions.post_segment_func_nb, + post_segment_args=program_args, + ) + + +def _validate_input(ledger_input: LedgerInput, program: OrderProgram) -> None: + close = np.asarray(ledger_input.close) + if close.ndim != 2 or close.shape[0] == 0 or close.shape[1] == 0: + raise ValueError("ledger close must be a non-empty two-dimensional array") + if np.asarray(ledger_input.dates).shape != (close.shape[0],): + raise ValueError("ledger dates do not match close rows") + if len(ledger_input.symbols) != close.shape[1]: + raise ValueError("ledger symbols do not match close columns") + groups = np.asarray(ledger_input.group_ids) + if groups.shape != (close.shape[1],): + raise ValueError("ledger groups do not match close columns") + if not ledger_input.cash_sharing: + raise ValueError("vectorbt runtime requires shared cash") + if not np.isfinite(ledger_input.initial_cash) or ledger_input.initial_cash <= 0: + raise ValueError("ledger initial cash must be positive") + arrays = _order_arrays(program) + if any(array.shape != (close.shape[1],) for array in arrays): + raise ValueError("order buffer does not match close columns") + if any(not array.flags.writeable for array in arrays): + raise ValueError("order buffer must be writeable") + + +def run_vectorbt( + ledger_input: LedgerInput, + program: OrderProgram, +) -> ExecutionRun: + _validate_input(ledger_input, program) + callbacks = _specialize_program(program) + close_values = np.asarray(ledger_input.close) + close = pd.DataFrame( + close_values, + index=pd.DatetimeIndex( + np.asarray(ledger_input.dates).astype("datetime64[ns]") + ), + columns=ledger_input.symbols, + ) + portfolio = vbt.Portfolio.from_order_func( + close, + callbacks.order_func_nb, + *callbacks.order_args, + pre_sim_func_nb=callbacks.pre_sim_func_nb, + pre_sim_args=callbacks.pre_sim_args, + pre_segment_func_nb=callbacks.pre_segment_func_nb, + pre_segment_args=callbacks.pre_segment_args, + post_order_func_nb=callbacks.post_order_func_nb, + post_order_args=callbacks.post_order_args, + post_segment_func_nb=callbacks.post_segment_func_nb, + post_segment_args=callbacks.post_segment_args, + init_cash=float(ledger_input.initial_cash), + cash_sharing=True, + group_by=np.asarray(ledger_input.group_ids), + call_pre_segment=True, + call_post_segment=True, + update_value=True, + ffill_val_price=True, + use_numba=True, + max_orders=close.shape[0] * close.shape[1], + max_logs=0, + freq=ledger_input.frequency, + ) + trace = { + name: _readonly(value) + for name, value in program.trace.items() + } + return ExecutionRun( + ledger=ExecutionLedger( + portfolio, + ledger_input.dates, + ledger_input.symbols, + close_values, + ), + trace=MappingProxyType(trace), + ) diff --git a/tests/local_quant_research/fixtures/local-research-v1-baseline.json b/tests/local_quant_research/fixtures/local-research-v1-baseline.json new file mode 100644 index 0000000..a66783e --- /dev/null +++ b/tests/local_quant_research/fixtures/local-research-v1-baseline.json @@ -0,0 +1,261 @@ +{ + "materialized_contract": { + "analysis_data_views": { + "balances": [ + ["total_value", "DOUBLE"], + ["net_value", "DOUBLE"], + ["cash", "DOUBLE"], + ["aval_cash", "DOUBLE"], + ["time", "VARCHAR"] + ], + "orders": [ + ["match_time", "VARCHAR"], + ["pindex", "BIGINT"], + ["cancel_time", "VARCHAR"], + ["action", "VARCHAR"], + ["limit_price", "DOUBLE"], + ["comment", "VARCHAR"], + ["entrust_time", "VARCHAR"], + ["finish_time", "VARCHAR"], + ["side", "VARCHAR"], + ["price", "DOUBLE"], + ["commission", "DOUBLE"], + ["gains", "DOUBLE"], + ["type", "VARCHAR"], + ["time", "VARCHAR"], + ["security_name", "VARCHAR"], + ["security", "VARCHAR"], + ["filled", "BIGINT"], + ["amount", "BIGINT"], + ["status", "VARCHAR"] + ], + "period_risks": [ + ["metric", "VARCHAR"], + ["payload_json", "VARCHAR"] + ], + "positions": [ + ["pindex", "BIGINT"], + ["avg_cost", "DOUBLE"], + ["margin", "DOUBLE"], + ["amount", "DOUBLE"], + ["today_amount", "BIGINT"], + ["hold_cost", "DOUBLE"], + ["side", "VARCHAR"], + ["price", "DOUBLE"], + ["gains", "DOUBLE"], + ["daily_gains", "DOUBLE"], + ["closeable_amount", "BIGINT"], + ["time", "VARCHAR"], + ["security_name", "VARCHAR"], + ["security", "VARCHAR"] + ], + "results": [ + ["benchmark_returns", "DOUBLE"], + ["returns", "DOUBLE"], + ["time", "VARCHAR"] + ], + "risk": [ + ["__version", "BIGINT"], + ["algorithm_return", "DOUBLE"], + ["algorithm_volatility", "DOUBLE"], + ["alpha", "DOUBLE"], + ["annual_algo_return", "DOUBLE"], + ["annual_bm_return", "DOUBLE"], + ["avg_excess_return", "DOUBLE"], + ["avg_position_days", "DOUBLE"], + ["avg_trade_return", "DOUBLE"], + ["benchmark_return", "DOUBLE"], + ["benchmark_volatility", "DOUBLE"], + ["beta", "DOUBLE"], + ["day_win_ratio", "DOUBLE"], + ["excess_return", "DOUBLE"], + ["excess_return_max_drawdown", "DOUBLE"], + ["excess_return_max_drawdown_period", "VARCHAR"], + ["excess_return_sharpe", "DOUBLE"], + ["information", "DOUBLE"], + ["lose_count", "BIGINT"], + ["max_drawdown", "DOUBLE"], + ["max_drawdown_period", "VARCHAR"], + ["max_leverage", "DOUBLE"], + ["period_label", "VARCHAR"], + ["profit_loss_ratio", "DOUBLE"], + ["sharpe", "DOUBLE"], + ["sortino", "DOUBLE"], + ["trading_days", "BIGINT"], + ["treasury_return", "DOUBLE"], + ["turnover_rate", "DOUBLE"], + ["win_count", "BIGINT"], + ["win_ratio", "DOUBLE"] + ], + "source_benchmark_returns": [ + ["trading_date", "DATE"], + ["cumulative_returns", "DOUBLE"] + ], + "strategy_daily_returns": [ + ["trading_date", "DATE"], + ["cumulative_returns", "DOUBLE"], + ["daily_returns", "DOUBLE"], + ["comparable", "BOOLEAN"] + ] + } + }, + "scenarios": [ + { + "cash": { + "rows": 3432, + "sha256": "7d5f2bad3115cb08fb7e38562bdd5004105d794b6501f2087c39129574220382" + }, + "fees": { + "rows": 1799, + "sha256": "b2f1bf548c2ade9c223ff55db19f7d68ba796d6286b1ad88c3d6e6f0254b6e48" + }, + "logic": "21e1bc0e630bda66f6dcd69c1e69434cc9a5ee8fe2824d549b018bfe9f91c1ee", + "materialized": { + "analysis_data_views_sha256": "3235b5cdb3c802c7ff79f258730bdfe3b503e7936cf23dcfe3db089a9893fbd6", + "code_identity": { + "bytes": 5265, + "path": "code.py", + "sha256": "f09e51da53d6ffab74efed93bf8ffd7e3e1bd153e68e7992f09b20b631c3caf1" + }, + "config_identity": { + "current": { + "bytes": 3122, + "path": "params.json", + "sha256": "21e7733e5e92a97c233bcb5afce69434da19d040ef33dc8cee670c8249f8240b" + }, + "version": { + "bytes": 3122, + "path": "params_versions/21e7733e5e92a97c233bcb5afce69434da19d040ef33dc8cee670c8249f8240b.json", + "sha256": "21e7733e5e92a97c233bcb5afce69434da19d040ef33dc8cee670c8249f8240b" + } + }, + "manifest": { + "schema_version": "local-backtest/1", + "sha256": "4f1290c53c22d8d8a6c8f57658fb7c83198408b5346b24e7f2673a3dae723ec3" + } + }, + "orders": { + "rows": 1799, + "sha256": "d1877caaf7d1e97acb7f2fa35091b0b0b7f0bd17ad7f95a5a8960bca92a21c47" + }, + "positions": { + "rows": 4847, + "sha256": "cea5348ac2cf9cee66a798b74d339986c110f820e02c8a8a1c93347cde94c585" + }, + "scenario": "immediate-11-etf", + "state": { + "events": 3249, + "sha256": "46260fb20344348bac7995c1cdd07400ecfe3c7844cbc3f02e6b924020d5ce37" + }, + "value": { + "rows": 3432, + "sha256": "e5e4842ef25cde4d998f87cc0fd877feea88f70d78a8e3f050bf9662222bc78d" + } + }, + { + "cash": { + "rows": 3432, + "sha256": "88233873fe7be4d8566b72ed8bc63fe627ed92663fba1cc3a781e298f028b01a" + }, + "fees": { + "rows": 4008, + "sha256": "eef914a386120c6cbb7321ec4392917eee2a3a12a7465308b93f6eca5c1e5bef" + }, + "logic": "0432dd75ca8a3827af021528130ff2b39e161f903d897dcd6927e29f4273c9a0", + "materialized": { + "analysis_data_views_sha256": "3235b5cdb3c802c7ff79f258730bdfe3b503e7936cf23dcfe3db089a9893fbd6", + "code_identity": { + "bytes": 5265, + "path": "code.py", + "sha256": "f09e51da53d6ffab74efed93bf8ffd7e3e1bd153e68e7992f09b20b631c3caf1" + }, + "config_identity": { + "current": { + "bytes": 3652, + "path": "params.json", + "sha256": "3f00000317ec847557830be7ac8ee46a79b3de1238991ca6387710eaa05ea156" + }, + "version": { + "bytes": 3652, + "path": "params_versions/3f00000317ec847557830be7ac8ee46a79b3de1238991ca6387710eaa05ea156.json", + "sha256": "3f00000317ec847557830be7ac8ee46a79b3de1238991ca6387710eaa05ea156" + } + }, + "manifest": { + "schema_version": "local-backtest/1", + "sha256": "9661b8873ebcf153700733a4d0e35c89270134e5167893e4b52b0b1aa263d721" + } + }, + "orders": { + "rows": 4008, + "sha256": "c87a79d2280f4e67fd6256a36302a3f5943f00daadf69bbc2e9c5f478af34836" + }, + "positions": { + "rows": 8281, + "sha256": "80473f35d321ab22aa0743bf8b318ac01312203a5e745dd4de7703ca09c50be9" + }, + "scenario": "immediate-17-etf", + "state": { + "events": 5148, + "sha256": "9ff69d630275163b2c4df5e67afc2279041fd6f1fbeac1c6f914c83d96559136" + }, + "value": { + "rows": 3432, + "sha256": "3e5fc67e06719da1f41a618321cce95948e65114b15cec7eca478058fecc6a80" + } + }, + { + "cash": { + "rows": 3432, + "sha256": "ca15b2484fe390e70e0cfcd9b6ae4cdc9e3d8fdae369840736dd69e5214530f0" + }, + "fees": { + "rows": 1175, + "sha256": "f0934237db44835f47a8c5add4f2d4b03929391fb955cb1f2fc50f7567b24b0d" + }, + "logic": "ae0d5c854ff5094ff41dd537a695f2a6436ae87a780d0969992731dcb5dd1e2c", + "materialized": { + "analysis_data_views_sha256": "3235b5cdb3c802c7ff79f258730bdfe3b503e7936cf23dcfe3db089a9893fbd6", + "code_identity": { + "bytes": 5265, + "path": "code.py", + "sha256": "f09e51da53d6ffab74efed93bf8ffd7e3e1bd153e68e7992f09b20b631c3caf1" + }, + "config_identity": { + "current": { + "bytes": 3123, + "path": "params.json", + "sha256": "98215da99224136127ca6d0f3dd690d000c6103ccb50d049d2400ef3c34e4457" + }, + "version": { + "bytes": 3123, + "path": "params_versions/98215da99224136127ca6d0f3dd690d000c6103ccb50d049d2400ef3c34e4457.json", + "sha256": "98215da99224136127ca6d0f3dd690d000c6103ccb50d049d2400ef3c34e4457" + } + }, + "manifest": { + "schema_version": "local-backtest/1", + "sha256": "c9c451e68648d54fa2a0370e898d8e641efe421c3ab9964c65cb092ad47b13f1" + } + }, + "orders": { + "rows": 1175, + "sha256": "900249d7d03e416ff4acebd8e8e70a3b61054af2ac27754ed04654d1ff0ad2e9" + }, + "positions": { + "rows": 5691, + "sha256": "812997e88688e6ef7fc56945f6982f107935f2c39219b345b12451fdbc07e192" + }, + "scenario": "delayed-11-etf-1d", + "state": { + "events": 1175, + "sha256": "1fc6d5b118865fe2e9470bcb71d17354bdd807e9875ad78de583db276b368974" + }, + "value": { + "rows": 3432, + "sha256": "8704f912a913d52821abd7ee171d13b303dd4104260f470ee644ff230e372f66" + } + } + ], + "schema_version": 1 +} diff --git a/tests/local_quant_research/fixtures/minimal_strategy/strategy.py b/tests/local_quant_research/fixtures/minimal_strategy/strategy.py new file mode 100644 index 0000000..ab37b7d --- /dev/null +++ b/tests/local_quant_research/fixtures/minimal_strategy/strategy.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Mapping + +import numpy as np + +from scripts.research.local_quant_research.contracts import ( + ExecutionBundle, + ExecutionRun, + LedgerInput, + OrderBuffer, + OrderProgram, + PreparedStrategy, + ResultExtension, + StrategyDescriptor, +) + +if TYPE_CHECKING: + from scripts.research.market_data.query import SnapshotView + + +def _noop(*args: object) -> None: + return None + + +class MinimalStrategy: + descriptor = StrategyDescriptor( + strategy_id="minimal-fixture", + contract_version="1", + extension_names=(), + accounting={}, + ) + + def prepare( + self, + snapshot: SnapshotView, + config: Mapping[str, object], + ) -> PreparedStrategy: + ledger_input = LedgerInput( + dates=np.array(["2026-01-05", "2026-01-06"], dtype="datetime64[D]"), + symbols=("TEST",), + close=np.ones((2, 1)), + initial_cash=100_000.0, + group_ids=np.zeros(1, dtype=np.int64), + cash_sharing=True, + frequency="1d", + ) + orders = OrderBuffer( + enabled=np.zeros(1, dtype=np.bool_), + side=np.zeros(1, dtype=np.int8), + size=np.zeros(1), + price=np.zeros(1), + fixed_fees=np.zeros(1), + size_granularity=np.ones(1), + allow_partial=np.zeros(1, dtype=np.bool_), + priority=np.zeros(1, dtype=np.int64), + ) + return PreparedStrategy( + ledger_input=ledger_input, + primary_program=OrderProgram( + program_id="minimal", + prepare_segment_nb=_noop, + after_fill_nb=_noop, + after_segment_nb=None, + inputs=(), + params=(), + state=(), + trace={}, + orders=orders, + ), + context=None, + ) + + def followup_program( + self, + prepared: PreparedStrategy, + primary_run: ExecutionRun, + ) -> OrderProgram | None: + return None + + def build_extensions( + self, + prepared: PreparedStrategy, + execution: ExecutionBundle, + ) -> tuple[ResultExtension, ...]: + return () + + +MODULE = MinimalStrategy() diff --git a/tests/local_quant_research/fixtures/minimal_strategy_b/__init__.py b/tests/local_quant_research/fixtures/minimal_strategy_b/__init__.py new file mode 100644 index 0000000..5e01db1 --- /dev/null +++ b/tests/local_quant_research/fixtures/minimal_strategy_b/__init__.py @@ -0,0 +1 @@ +STRATEGY_ID = "minimal-fixture-b" diff --git a/tests/local_quant_research/fixtures/minimal_strategy_b/strategy.py b/tests/local_quant_research/fixtures/minimal_strategy_b/strategy.py new file mode 100644 index 0000000..eeaa887 --- /dev/null +++ b/tests/local_quant_research/fixtures/minimal_strategy_b/strategy.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import numpy as np + +from . import STRATEGY_ID + +from scripts.research.local_quant_research.contracts import ( + LedgerInput, + OrderBuffer, + OrderProgram, + PreparedStrategy, + StrategyDescriptor, +) + + +def _noop(*args: object) -> None: + return None + + +class MinimalStrategyB: + descriptor = StrategyDescriptor( + strategy_id=STRATEGY_ID, + contract_version="1", + extension_names=(), + accounting={}, + ) + + def prepare(self, snapshot: object, config: object) -> PreparedStrategy: + ledger_input = LedgerInput( + dates=np.array(["2026-01-05", "2026-01-06"], dtype="datetime64[D]"), + symbols=("TEST",), + close=np.ones((2, 1)), + initial_cash=100_000.0, + group_ids=np.zeros(1, dtype=np.int64), + cash_sharing=True, + frequency="1d", + ) + orders = OrderBuffer( + enabled=np.zeros(1, dtype=np.bool_), + side=np.zeros(1, dtype=np.int8), + size=np.zeros(1), + price=np.zeros(1), + fixed_fees=np.zeros(1), + size_granularity=np.ones(1), + allow_partial=np.zeros(1, dtype=np.bool_), + priority=np.zeros(1, dtype=np.int64), + ) + return PreparedStrategy( + ledger_input=ledger_input, + primary_program=OrderProgram( + program_id="minimal-b", + prepare_segment_nb=_noop, + after_fill_nb=_noop, + after_segment_nb=None, + inputs=(), + params=(), + state=(), + trace={}, + orders=orders, + ), + context=None, + ) + + def followup_program(self, prepared: object, primary_run: object) -> None: + return None + + def build_extensions( + self, + prepared: object, + execution: object, + ) -> tuple[object, ...]: + return () + + +MODULE = MinimalStrategyB() diff --git a/tests/local_quant_research/fixtures/performance-baseline.json b/tests/local_quant_research/fixtures/performance-baseline.json new file mode 100644 index 0000000..11aa9e4 --- /dev/null +++ b/tests/local_quant_research/fixtures/performance-baseline.json @@ -0,0 +1,550 @@ +{ + "collection": { + "cold_process_model": "independent_process_per_sample", + "entrypoint": "joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_cli.py", + "memory_method": "ctypes.GetProcessMemoryInfo", + "python": ".venv/Scripts/python.exe", + "warm_process_model": "same_process_for_all_samples" + }, + "environment": { + "architecture": "AMD64", + "dependencies": { + "numba": "0.66.0", + "numpy": "2.4.6", + "pandas": "3.0.3", + "pyarrow": "23.0.1", + "vectorbt": "1.1.0" + }, + "logical_cpu_count": 20, + "os": "Windows", + "os_release": "11", + "os_version": "10.0.26200", + "physical_memory_bytes": 34163961856, + "processor": "Intel64 Family 6 Model 183 Stepping 1, GenuineIntel", + "python": "3.12.10" + }, + "environment_identity_sha256": "841d513675d3f36677e772a5104b077317ae50747d4dea0dd4026f6b7cc7f5b2", + "sampling": { + "cold_processes": 3, + "full_cli_cold_processes": 3, + "statistic": "median", + "warm_runs": 5 + }, + "scenarios": { + "immediate-11-etf": { + "cleanup": { + "isolated_output_roots_removed": true, + "verified": true + }, + "collected_files": [ + "code.py", + "data/attribution_log-6f320f99d95bb284ccd2086e56f133e2396611b864a3f22a8468c0fbcde9ea1e.parquet", + "data/balances.parquet", + "data/orders.parquet", + "data/positions.parquet", + "data/results.parquet", + "manifest.json", + "params.json", + "params_versions/21e7733e5e92a97c233bcb5afce69434da19d040ef33dc8cee670c8249f8240b.json", + "performance.json" + ], + "cold_process": { + "median_package_bytes": 1301907, + "median_peak_memory_mib": 742.508, + "median_seconds": 27.350171, + "samples": [ + { + "package_bytes": 1301908, + "peak_working_set_bytes": 776437760, + "process_id": 25056, + "sample_id": "cold-1", + "seconds": 27.350171 + }, + { + "package_bytes": 1301907, + "peak_working_set_bytes": 779202560, + "process_id": 12264, + "sample_id": "cold-2", + "seconds": 27.328044 + }, + { + "package_bytes": 1301907, + "peak_working_set_bytes": 778575872, + "process_id": 8184, + "sample_id": "cold-3", + "seconds": 27.379933 + } + ] + }, + "fixed_files_bytes": 20681, + "full_cli_cold": { + "median_seconds": 68.205946, + "samples": [ + { + "cold_cli_total_seconds": 68.41595, + "package_sha256": "c56a87780723d01bedcc2c955673b01375bb84e7106295adb620505433427f8a", + "pid": 39180, + "post_publish_validation": "passed", + "reused": false, + "run_id": "52225816e08779b0d7fa607f055ee57666c432f982e37c03d540f5e85bd08e35", + "sample_index": 1, + "sample_type": "full_cli_cold", + "scenario": "immediate-11-etf" + }, + { + "cold_cli_total_seconds": 68.205946, + "package_sha256": "2980ca503f49db62ce270de68e8679e6d5ce42bd4f00dba3115054e16aab1953", + "pid": 39776, + "post_publish_validation": "passed", + "reused": false, + "run_id": "23d8547e3367e9ff2b43290044ff1958d284e6259066c44e5337dfe9b8677dd5", + "sample_index": 2, + "sample_type": "full_cli_cold", + "scenario": "immediate-11-etf" + }, + { + "cold_cli_total_seconds": 66.287519, + "package_sha256": "f4dd8a62c61b2fdfc9f040f746f95e12b9206eff8180f418144e600a72197e57", + "pid": 31556, + "post_publish_validation": "passed", + "reused": false, + "run_id": "03b32bbb5a671983d13b65fdfc30502b96d2d524e7bb93b37898bb92cc97717e", + "sample_index": 3, + "sample_type": "full_cli_cold", + "scenario": "immediate-11-etf" + } + ] + }, + "parquet_payload_bytes": 1281277, + "snapshot_id": "e88238cca420a8ae66b90adb6cda4dd6c38a07390a13b8ac2f471e534742e33e", + "summary_sha256": "c6ac2f4859df1c11aad57e927350d8f10bdd989984514255b2e3bd2fb3acf09e", + "warm": { + "median_peak_memory_mib": 721.848, + "median_seconds": 4.664146, + "phase_medians_seconds": { + "adapt": 0.986605, + "materialize": 1.27292, + "prepare": 1.479399, + "simulate": 0.103399, + "snapshot": 0.790033 + }, + "samples": [ + { + "package_bytes": 1281277, + "peak_working_set_bytes": 755023872, + "phase_seconds": { + "adapt": 1.005442, + "materialize": 1.266728, + "prepare": 1.453404, + "simulate": 0.104756, + "snapshot": 0.786358 + }, + "process_id": 14060, + "sample_id": "warm-1", + "seconds": 4.616696 + }, + { + "package_bytes": 1281277, + "peak_working_set_bytes": 755023872, + "phase_seconds": { + "adapt": 0.98139, + "materialize": 1.270248, + "prepare": 1.531598, + "simulate": 0.103399, + "snapshot": 0.777506 + }, + "process_id": 14060, + "sample_id": "warm-2", + "seconds": 4.664146 + }, + { + "package_bytes": 1281277, + "peak_working_set_bytes": 756912128, + "phase_seconds": { + "adapt": 0.977264, + "materialize": 1.274467, + "prepare": 1.479399, + "simulate": 0.103902, + "snapshot": 0.807764 + }, + "process_id": 14060, + "sample_id": "warm-3", + "seconds": 4.642801 + }, + { + "package_bytes": 1281277, + "peak_working_set_bytes": 758992896, + "phase_seconds": { + "adapt": 0.986605, + "materialize": 1.285781, + "prepare": 1.456695, + "simulate": 0.102775, + "snapshot": 0.873627 + }, + "process_id": 14060, + "sample_id": "warm-4", + "seconds": 4.705488 + }, + { + "package_bytes": 1281277, + "peak_working_set_bytes": 780541952, + "phase_seconds": { + "adapt": 0.988505, + "materialize": 1.27292, + "prepare": 1.534652, + "simulate": 0.102404, + "snapshot": 0.790033 + }, + "process_id": 14060, + "sample_id": "warm-5", + "seconds": 4.688518 + } + ] + } + }, + "immediate-17-etf": { + "cleanup": { + "isolated_output_roots_removed": true, + "verified": true + }, + "collected_files": [ + "code.py", + "data/attribution_log-f8b55bbeff17e2ab0f96e2c79fb8053e14837ec944ead8a9d43c60f07f88488e.parquet", + "data/balances.parquet", + "data/orders.parquet", + "data/positions.parquet", + "data/results.parquet", + "manifest.json", + "params.json", + "params_versions/6ccb72652e816167ea0d12da5f3bf7617da5c88881e7a7875f4c2d429f456fc6.json", + "performance.json" + ], + "cold_process": { + "median_package_bytes": 2201265, + "median_peak_memory_mib": 819.57, + "median_seconds": 33.215047, + "samples": [ + { + "package_bytes": 2201266, + "peak_working_set_bytes": 859381760, + "process_id": 6380, + "sample_id": "cold-1", + "seconds": 33.164935 + }, + { + "package_bytes": 2201263, + "peak_working_set_bytes": 840359936, + "process_id": 13116, + "sample_id": "cold-2", + "seconds": 33.215047 + }, + { + "package_bytes": 2201265, + "peak_working_set_bytes": 860909568, + "process_id": 7264, + "sample_id": "cold-3", + "seconds": 33.260551 + } + ] + }, + "fixed_files_bytes": 21775, + "full_cli_cold": { + "median_seconds": 75.335246, + "samples": [ + { + "cold_cli_total_seconds": 74.395766, + "package_sha256": "bc400d687d04579e26f94e0d4adc706ca28913d5a7cf2bc189984e3eb107f428", + "pid": 18228, + "post_publish_validation": "passed", + "reused": false, + "run_id": "c8900384dc59579b09db9fa1569c1d8b0764e41c9da94457adafe6af5134dc92", + "sample_index": 1, + "sample_type": "full_cli_cold", + "scenario": "immediate-17-etf" + }, + { + "cold_cli_total_seconds": 75.335246, + "package_sha256": "13048b76954973ec72c360f42675dc44adc43df482d12f4b99cd9d1168709044", + "pid": 34608, + "post_publish_validation": "passed", + "reused": false, + "run_id": "87a9abc3d271f32d0ff9209c1b671b29e6353d4bc74ad2015c3090c6baff5b97", + "sample_index": 2, + "sample_type": "full_cli_cold", + "scenario": "immediate-17-etf" + }, + { + "cold_cli_total_seconds": 76.217841, + "package_sha256": "fffe7f41ccfecde332c0cd84c20f10d651386e2d97820640a1008cd76021f5ad", + "pid": 11208, + "post_publish_validation": "passed", + "reused": false, + "run_id": "552e23c378621cdcaddbef8ffd87dbcf9e34b227b6c1b1b417409c9ec282677f", + "sample_index": 3, + "sample_type": "full_cli_cold", + "scenario": "immediate-17-etf" + } + ] + }, + "parquet_payload_bytes": 2179571, + "snapshot_id": "27c0a452ad5cd8c7f865d2d8cd7555595df0b1fc7afecb86eace4b36336a7ddd", + "summary_sha256": "cfb42161eec33fbb87e3799f71f35b6a7e11db926a71dca218d3daaa1efc2524", + "warm": { + "median_peak_memory_mib": 863.402, + "median_seconds": 7.346294, + "phase_medians_seconds": { + "adapt": 1.640124, + "materialize": 2.171504, + "prepare": 2.140393, + "simulate": 0.143466, + "snapshot": 1.270234 + }, + "samples": [ + { + "package_bytes": 2179571, + "peak_working_set_bytes": 871301120, + "phase_seconds": { + "adapt": 1.643303, + "materialize": 2.177581, + "prepare": 2.116857, + "simulate": 0.141557, + "snapshot": 1.177482 + }, + "process_id": 14060, + "sample_id": "warm-1", + "seconds": 7.256785 + }, + { + "package_bytes": 2179571, + "peak_working_set_bytes": 898465792, + "phase_seconds": { + "adapt": 1.634968, + "materialize": 2.164927, + "prepare": 2.137434, + "simulate": 0.143774, + "snapshot": 1.259592 + }, + "process_id": 14060, + "sample_id": "warm-2", + "seconds": 7.340699 + }, + { + "package_bytes": 2179571, + "peak_working_set_bytes": 905342976, + "phase_seconds": { + "adapt": 1.649341, + "materialize": 2.178504, + "prepare": 2.160176, + "simulate": 0.143827, + "snapshot": 1.270234 + }, + "process_id": 14060, + "sample_id": "warm-3", + "seconds": 7.402086 + }, + { + "package_bytes": 2179571, + "peak_working_set_bytes": 905342976, + "phase_seconds": { + "adapt": 1.640124, + "materialize": 2.171504, + "prepare": 2.14131, + "simulate": 0.142223, + "snapshot": 1.298777 + }, + "process_id": 14060, + "sample_id": "warm-4", + "seconds": 7.393945 + }, + { + "package_bytes": 2179571, + "peak_working_set_bytes": 907571200, + "phase_seconds": { + "adapt": 1.625617, + "materialize": 2.162463, + "prepare": 2.140393, + "simulate": 0.143466, + "snapshot": 1.27435 + }, + "process_id": 14060, + "sample_id": "warm-5", + "seconds": 7.346294 + } + ] + } + }, + "delayed-11-etf-1d": { + "cleanup": { + "isolated_output_roots_removed": true, + "verified": true + }, + "collected_files": [ + "code.py", + "data/attribution_log-4ade2b6e69eb5db6af384c36f7920f513b44db8a76f4fd74b8afbd6a14f6c244.parquet", + "data/balances.parquet", + "data/orders.parquet", + "data/positions.parquet", + "data/results.parquet", + "manifest.json", + "params.json", + "params_versions/b39e9f6a0f2bf5874b0eb25d861aea9c1632cea2b0a4dde08e1c89ba29a2def4.json", + "performance.json" + ], + "cold_process": { + "median_package_bytes": 1273318, + "median_peak_memory_mib": 747.129, + "median_seconds": 27.757129, + "samples": [ + { + "package_bytes": 1273318, + "peak_working_set_bytes": 799870976, + "process_id": 34388, + "sample_id": "cold-1", + "seconds": 27.769536 + }, + { + "package_bytes": 1273318, + "peak_working_set_bytes": 783421440, + "process_id": 22016, + "sample_id": "cold-2", + "seconds": 27.757129 + }, + { + "package_bytes": 1273317, + "peak_working_set_bytes": 781729792, + "process_id": 28808, + "sample_id": "cold-3", + "seconds": 27.624058 + } + ] + }, + "fixed_files_bytes": 20716, + "full_cli_cold": { + "median_seconds": 69.12615, + "samples": [ + { + "cold_cli_total_seconds": 71.605849, + "package_sha256": "17f8e4d194eec520920d327285cb21dfdb42c880305b2a208404a52567602ade", + "pid": 29760, + "post_publish_validation": "passed", + "reused": false, + "run_id": "d71a7edc7729b63ceab1ad3400ceb12fc85dd64a511e98afbe9836d7c30f3c17", + "sample_index": 1, + "sample_type": "full_cli_cold", + "scenario": "delayed-11-etf-1d" + }, + { + "cold_cli_total_seconds": 68.276414, + "package_sha256": "ddbae02565040834466df8e49d885448e8126eea8987f6e2ed15338ff3a51e4c", + "pid": 35340, + "post_publish_validation": "passed", + "reused": false, + "run_id": "1a3da3f82e55f21366a753bf530f8dfcd68ac9f3ba3447122dc95481e4903eca", + "sample_index": 2, + "sample_type": "full_cli_cold", + "scenario": "delayed-11-etf-1d" + }, + { + "cold_cli_total_seconds": 69.12615, + "package_sha256": "1b5777e720710dfb87475e8da8b03ee81a74dd6b9560c2694b67aa402ef81cc0", + "pid": 36448, + "post_publish_validation": "passed", + "reused": false, + "run_id": "36c5cf62fa177a898efacc60ed091cd3e2184fb48db66759cdec4f308d1215dc", + "sample_index": 3, + "sample_type": "full_cli_cold", + "scenario": "delayed-11-etf-1d" + } + ] + }, + "parquet_payload_bytes": 1252683, + "snapshot_id": "e88238cca420a8ae66b90adb6cda4dd6c38a07390a13b8ac2f471e534742e33e", + "summary_sha256": "60218b20e774981c2555f079c093cb249d692425976f26dbd87515daa9da25c8", + "warm": { + "median_peak_memory_mib": 865.527, + "median_seconds": 4.70711, + "phase_medians_seconds": { + "adapt": 0.965305, + "materialize": 1.287114, + "prepare": 1.501302, + "simulate": 0.185535, + "snapshot": 0.786126 + }, + "samples": [ + { + "package_bytes": 1252683, + "peak_working_set_bytes": 907571200, + "phase_seconds": { + "adapt": 0.965305, + "materialize": 1.290191, + "prepare": 1.421793, + "simulate": 0.185535, + "snapshot": 0.790935 + }, + "process_id": 14060, + "sample_id": "warm-1", + "seconds": 4.653763 + }, + { + "package_bytes": 1252683, + "peak_working_set_bytes": 907571200, + "phase_seconds": { + "adapt": 0.958211, + "materialize": 1.279316, + "prepare": 1.502601, + "simulate": 0.184109, + "snapshot": 0.785772 + }, + "process_id": 14060, + "sample_id": "warm-2", + "seconds": 4.710013 + }, + { + "package_bytes": 1252683, + "peak_working_set_bytes": 907571200, + "phase_seconds": { + "adapt": 0.963309, + "materialize": 1.272238, + "prepare": 1.501302, + "simulate": 0.186448, + "snapshot": 0.783808 + }, + "process_id": 14060, + "sample_id": "warm-3", + "seconds": 4.70711 + }, + { + "package_bytes": 1252683, + "peak_working_set_bytes": 907571200, + "phase_seconds": { + "adapt": 0.975865, + "materialize": 1.287114, + "prepare": 1.426913, + "simulate": 0.185574, + "snapshot": 0.786814 + }, + "process_id": 14060, + "sample_id": "warm-4", + "seconds": 4.662283 + }, + { + "package_bytes": 1252683, + "peak_working_set_bytes": 907571200, + "phase_seconds": { + "adapt": 0.980808, + "materialize": 1.311796, + "prepare": 1.508867, + "simulate": 0.184094, + "snapshot": 0.786126 + }, + "process_id": 14060, + "sample_id": "warm-5", + "seconds": 4.771696 + } + ] + } + } + }, + "protocol_version": "local-research-release/2", + "schema_version": 1 +} diff --git a/tests/local_quant_research/fixtures/strategy-module-v1-baseline.json b/tests/local_quant_research/fixtures/strategy-module-v1-baseline.json new file mode 100644 index 0000000..e1b241d --- /dev/null +++ b/tests/local_quant_research/fixtures/strategy-module-v1-baseline.json @@ -0,0 +1,97 @@ +{ + "migrated_from": { + "path": "tests/local_quant_research/fixtures/local-research-v1-baseline.json", + "sha256": "b5048b10245f220a8cb930cf8071b8b1581b02135639ae22ee5d44ad18fcdc38" + }, + "public_contract": { + "extension": "ResultExtension:turtle_etf", + "ledger": "ExecutionLedger" + }, + "scenarios": [ + { + "cash": { + "rows": 3432, + "sha256": "dc677f3cc8474b9fcd30b5fade4a2e26e19eef0d35fcfa4e3ea91b8c4511f693" + }, + "fees": { + "rows": 1175, + "sha256": "068515cb6b9ac919ea236fc8328ae63c48f822a59a9333234802cbb516130cca" + }, + "logic": "61ad786371c0d45f5dc59263e69bc65d89b68dfd6867632c036cded15af02396", + "orders": { + "rows": 1175, + "sha256": "57bee02a5ce8b8674fda3ca972b88ddfde21de474e5ef748921fefce488d1c67" + }, + "positions": { + "rows": 4847, + "sha256": "7b33f38971ba96de5107836017c4c71042d24c90e5f3ce97b54760cfdf04e507" + }, + "scenario": "immediate-11-etf", + "state": { + "events": 3249, + "sha256": "46260fb20344348bac7995c1cdd07400ecfe3c7844cbc3f02e6b924020d5ce37" + }, + "value": { + "rows": 3432, + "sha256": "c2c591416ece0f4b20c50ddd18e56f8fa2cc9e2fb7e43b40def8e470d76ab864" + } + }, + { + "cash": { + "rows": 3432, + "sha256": "1f5023ded63daa19b4761adc608054e06064373362fc2e0131639c0acfb87722" + }, + "fees": { + "rows": 3120, + "sha256": "12e27c82a86d918012faddfaf3b741dad92f859425a3472208858897d82556ab" + }, + "logic": "854db348723254a6a28dfaed9fd6c40e04776a92e14db922c3880d1a0a077962", + "orders": { + "rows": 3120, + "sha256": "fa7363e9d7d8efc0123410971945cc89d22b76041804d2243f3096ae23d55955" + }, + "positions": { + "rows": 8281, + "sha256": "3e2a6d781c72ff6832588bd00e5e7ac611c280be87c78b68fde140607df2c8a7" + }, + "scenario": "immediate-17-etf", + "state": { + "events": 5148, + "sha256": "9ff69d630275163b2c4df5e67afc2279041fd6f1fbeac1c6f914c83d96559136" + }, + "value": { + "rows": 3432, + "sha256": "bdfc6b270882eee1a2b11de5922b42269928ac6234129743076e227b438de3aa" + } + }, + { + "cash": { + "rows": 3432, + "sha256": "b17574b64fd806ea2968dfe6ef74d20311599701964ace4c82459646713c918f" + }, + "fees": { + "rows": 1169, + "sha256": "0b89b10d72852415a0c396bc46bf2f3b2a791212a70ff17ea8cfd05052a8fffa" + }, + "logic": "61c897b7c3b800c14766a7e5558922bc00ed4b8fa883042beb888c35ab9dc2f0", + "orders": { + "rows": 1169, + "sha256": "56d60dbebbd1cdabedb88af3760b7dca4de560b86061ad6729bdfce3aa43f8d1" + }, + "positions": { + "rows": 5691, + "sha256": "5d0924b014e69dfdb75e0d3794a8b56b6c417a507fe6935b3fc50f3522e5a14c" + }, + "scenario": "delayed-11-etf-1d", + "state": { + "events": 1175, + "sha256": "1fc6d5b118865fe2e9470bcb71d17354bdd807e9875ad78de583db276b368974" + }, + "value": { + "rows": 3432, + "sha256": "35d866ae6bd3a2e214f1f6388040f00fba0052348ceb56c612eb4eacc8b60152" + } + } + ], + "schema_version": "strategy-module-baseline/1" +} diff --git a/tests/local_quant_research/test_analysis_data_views.py b/tests/local_quant_research/test_analysis_data_views.py index 9157964..b5cd98c 100644 --- a/tests/local_quant_research/test_analysis_data_views.py +++ b/tests/local_quant_research/test_analysis_data_views.py @@ -2,14 +2,28 @@ import hashlib import json +from collections import Counter from pathlib import Path +import numpy as np import pyarrow as pa import pyarrow.parquet as pq import pytest -from scripts.research.analysis_data.manifest import AnalysisManifestError +from scripts.research.analysis_data.manifest import ( + AnalysisManifestError, + open_analysis_source, +) from scripts.research.analysis_data.views import open_analysis_database +from scripts.research.local_quant_research.contracts import ( + ExecutionBundle, + ExecutionRun, + ResultExtension, +) +from scripts.research.local_quant_research.result_package import ( + ResultPackageRequest, + write_result_package, +) SHA_FIELDS = ("path", "sha256", "bytes") @@ -207,6 +221,279 @@ def _tree_sha(root: Path) -> str: return digest.hexdigest() +class _AnalysisLedger: + @property + def orders(self) -> np.ndarray: + return np.array( + [], + dtype=[ + ("match_time", "O"), + ("pindex", "i8"), + ("cancel_time", "O"), + ("action", "U8"), + ("limit_price", "f8"), + ("comment", "U8"), + ("entrust_time", "U19"), + ("finish_time", "O"), + ("side", "U8"), + ("price", "f8"), + ("commission", "f8"), + ("gains", "f8"), + ("type", "U8"), + ("time", "U19"), + ("security_name", "U16"), + ("security", "U16"), + ("filled", "i8"), + ("amount", "i8"), + ("status", "U8"), + ], + ) + + @property + def assets(self) -> np.ndarray: + return np.array( + [], + dtype=[ + ("pindex", "i8"), + ("avg_cost", "f8"), + ("margin", "f8"), + ("amount", "f8"), + ("today_amount", "i8"), + ("hold_cost", "f8"), + ("side", "U8"), + ("price", "f8"), + ("gains", "f8"), + ("daily_gains", "f8"), + ("closeable_amount", "i8"), + ("time", "U19"), + ("security_name", "U16"), + ("security", "U16"), + ], + ) + + @property + def cash(self) -> np.ndarray: + return np.array( + [("2024-01-02 16:00:00", 100.0), ("2024-01-03 16:00:00", 110.0)], + dtype=[("time", "U19"), ("cash", "f8")], + ) + + @property + def value(self) -> np.ndarray: + return np.array( + [ + ("2024-01-02 16:00:00", 100.0, 0.0, np.nan), + ("2024-01-03 16:00:00", 110.0, 0.1, np.nan), + ], + dtype=[ + ("time", "U19"), + ("total_value", "f8"), + ("returns", "f8"), + ("benchmark_returns", "f8"), + ], + ) + + +def _write_result_package(root: Path) -> Path: + code = root.parent / "strategy.py" + code.write_text("VALUE = 1\n", encoding="utf-8") + ledger = _AnalysisLedger() + run = ExecutionRun(ledger=ledger, trace={}) + package = write_result_package( + ResultPackageRequest( + strategy_id="minimal", + scenario_id="baseline", + run_id="run-analysis", + output_dir=root, + execution=ExecutionBundle(primary=run, final=run, stages=("primary",)), + extensions=( + ResultExtension( + name="signals", + schema_version="signals/1", + table=pa.table({"event_id": ["signal-1"], "score": [0.5]}), + unique_key=("event_id",), + evidence={"status": "complete"}, + ), + ), + code_files={"strategy.py": code}, + config_documents={ + "scenario.json": {"scenario_id": "baseline"}, + "project-run.json": {"schema_version": 2}, + "code-identity.json": {"digest": "b" * 64}, + }, + evidence_documents={ + "market-snapshot.json": {"snapshot_id": "a" * 64}, + "runtime-lock.json": {"python": "3.12"}, + "performance.json": {"status": "pass"}, + "environment.json": {"platform": "windows"}, + }, + ) + ) + return package.path + + +def _sync_package_reference( + root: Path, manifest: dict[str, object], dataset: str +) -> None: + path = root / f"data/{dataset}.parquet" + reference = manifest["datasets"][dataset]["files"][0] + reference["sha256"] = _sha(path) + reference["bytes"] = path.stat().st_size + + +def test_local_research_package_exposes_identity_core_and_named_extension( + tmp_path: Path, +) -> None: + root = _write_result_package(tmp_path / "result") + source = open_analysis_source(root) + + assert source.kind == "local_research" + assert source.authority == "local_research" + assert source.backend == "vectorbt" + assert source.formula_version == "unified-strategy-analysis/1" + assert set(source.manifest["datasets"]) == { + "results", + "balances", + "positions", + "orders", + } + + with open_analysis_database(root) as database: + assert database.table_names == ("results", "balances", "positions", "orders") + assert database.connection.sql("select count(*) from results").fetchone() == (2,) + assert database.extension("signals").fetchall() == [("signal-1", 0.5)] + with pytest.raises(KeyError, match="unknown"): + database.extension("unknown") + + +def test_open_analysis_source_summarizes_each_core_table_once( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import result_package + + root = _write_result_package(tmp_path / "result") + names_by_fields = { + tuple(schema.names): name for name, schema in result_package._SCHEMAS.items() + } + summaries: Counter[str] = Counter() + real_summary = result_package._table_summary + + def counting_summary(table: pa.Table) -> dict[str, object]: + name = names_by_fields.get(tuple(table.schema.names)) + if name is not None: + summaries[name] += 1 + return real_summary(table) + + monkeypatch.setattr(result_package, "_table_summary", counting_summary) + + open_analysis_source(root) + + assert summaries == Counter( + {name: 1 for name in result_package.CORE_DATASETS} + ) + + +def test_local_research_rejects_noncanonical_core_path_before_query( + tmp_path: Path, +) -> None: + root = _write_result_package(tmp_path / "result") + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + reference = manifest["datasets"]["results"]["files"][0] + source = root / str(reference["path"]) + replacement = root / "data/renamed-results.parquet" + replacement.write_bytes(source.read_bytes()) + reference["path"] = replacement.relative_to(root).as_posix() + reference["sha256"] = _sha(replacement) + reference["bytes"] = replacement.stat().st_size + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, sort_keys=True), encoding="utf-8" + ) + + with pytest.raises(AnalysisManifestError, match="file identity"): + open_analysis_database(root) + + +def test_local_research_extension_digest_is_validated_before_query( + tmp_path: Path, +) -> None: + root = _write_result_package(tmp_path / "result") + extension = root / "extensions/signals/data.parquet" + extension.write_bytes(extension.read_bytes() + b"tampered") + + with pytest.raises(AnalysisManifestError, match="digest"): + open_analysis_database(root) + + +@pytest.mark.parametrize( + "tampering", + ["schema", "unique_key", "time_range", "reconciliation", "package_sha"], +) +def test_local_research_source_rejects_untruthful_complete_contract( + tmp_path: Path, + tampering: str, +) -> None: + root = _write_result_package(tmp_path / "result") + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + + if tampering == "schema": + path = root / "data/results.parquet" + table = pq.read_table(path) + replacement = table.set_column( + table.schema.get_field_index("returns"), + pa.field("returns", pa.float32(), nullable=False), + pa.array(table["returns"].to_pylist(), type=pa.float32()), + ) + pq.write_table(replacement, path, compression="snappy") + manifest["datasets"]["results"]["schema"] = [ + {"name": field.name, "type": str(field.type), "nullable": field.nullable} + for field in replacement.schema + ] + _sync_package_reference(root, manifest, "results") + elif tampering == "unique_key": + path = root / "data/results.parquet" + table = pq.read_table(path) + repeated_time = table["time"][0].as_py() + replacement = table.set_column( + table.schema.get_field_index("time"), + table.schema.field("time"), + pa.array([repeated_time] * table.num_rows, type=pa.string()), + ) + pq.write_table(replacement, path, compression="snappy") + repeated_date = str(repeated_time)[:10] + manifest["datasets"]["results"]["time_range"] = { + "start": repeated_date, + "end": repeated_date, + } + _sync_package_reference(root, manifest, "results") + elif tampering == "time_range": + manifest["datasets"]["results"]["time_range"] = { + "start": "2020-01-01", + "end": "2020-01-02", + } + elif tampering == "reconciliation": + path = root / "data/balances.parquet" + table = pq.read_table(path) + replacement = table.set_column( + table.schema.get_field_index("total_value"), + table.schema.field("total_value"), + pa.array([100.0, 999.0], type=pa.float64()), + ) + pq.write_table(replacement, path, compression="snappy") + _sync_package_reference(root, manifest, "balances") + else: + manifest["package_sha256"] = "f" * 64 + + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, sort_keys=True), encoding="utf-8" + ) + + with pytest.raises(AnalysisManifestError): + open_analysis_source(root) + + def test_joinquant_source_builds_six_read_only_logical_views(repo_root: Path) -> None: root = repo_root / "joinquant/strategies/strategy-001/backtests/111" before = _tree_sha(root) diff --git a/tests/local_quant_research/test_archive_promotion.py b/tests/local_quant_research/test_archive_promotion.py new file mode 100644 index 0000000..a9219aa --- /dev/null +++ b/tests/local_quant_research/test_archive_promotion.py @@ -0,0 +1,1091 @@ +from __future__ import annotations + +import ast +import builtins +import hashlib +import importlib +import json +import os +import shutil +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Callable, Iterator + +import pyarrow as pa +import pytest + +from scripts.research.analysis_data.manifest import open_analysis_source +from scripts.research.analysis_data.views import open_analysis_database +from scripts.research.local_quant_research.archive import ( + ArchiveResult, + promote_archive, +) +from scripts.research.local_quant_research.contracts import ( + ExecutionBundle, + ExecutionRun, + ResultExtension, +) +from scripts.research.local_quant_research.result_package import ( + ResultPackageRequest, + write_result_package, +) +from tests.local_quant_research.test_analysis_data_views import _AnalysisLedger + + +STRATEGY_ID = "strategy-003" +RUN_ID = "a" * 64 +ANALYSIS_ID = "baseline-v2" + + +@pytest.fixture +def isolated_repo(tmp_path: Path) -> Path: + root = tmp_path / "repo" + (root / "joinquant/strategies" / STRATEGY_ID).mkdir(parents=True) + return root + + +def _write_complete_package( + repo_root: Path, + *, + manifest_strategy_id: str = STRATEGY_ID, + manifest_run_id: str = RUN_ID, + directory_run_id: str = RUN_ID, +) -> Path: + code = repo_root / "fixture-strategy.py" + code.write_bytes(b"VALUE = 1\r\n") + ledger = _AnalysisLedger() + run = ExecutionRun(ledger=ledger, trace={}) + package = write_result_package( + ResultPackageRequest( + strategy_id=manifest_strategy_id, + scenario_id="baseline", + run_id=manifest_run_id, + output_dir=( + repo_root + / ".local/quant-research" + / STRATEGY_ID + / directory_run_id + ), + execution=ExecutionBundle( + primary=run, + final=run, + stages=("primary",), + ), + extensions=( + ResultExtension( + name="signals", + schema_version="signals/1", + table=pa.table( + {"event_id": ["signal-1"], "score": [0.5]} + ), + unique_key=("event_id",), + evidence={"status": "complete"}, + ), + ), + code_files={"strategy.py": code}, + config_documents={ + "scenario.json": {"scenario_id": "baseline"}, + "project-run.json": {"schema_version": 2}, + "code-identity.json": {"digest": "b" * 64}, + }, + evidence_documents={ + "market-snapshot.json": {"snapshot_id": "c" * 64}, + "runtime-lock.json": {"python": "3.12"}, + "performance.json": {"status": "pass"}, + "environment.json": {"platform": "windows"}, + }, + ) + ) + (package.path / "evidence/raw-bytes.bin").write_bytes( + b"\x00\xffarchive-ready\r\n" + ) + (package.path / "extensions/empty-evidence").mkdir() + return package.path + + +@pytest.fixture +def complete_package(isolated_repo: Path) -> Path: + return _write_complete_package(isolated_repo) + + +def _boom(*_args: object, **_kwargs: object) -> None: + raise AssertionError("archive promotion must not recompute results") + + +def _guard_recomputation(monkeypatch: pytest.MonkeyPatch) -> None: + import vectorbt as vbt + + from scripts.research.local_quant_research import ( + result_package, + runner, + strategy_loader, + ) + + monkeypatch.setattr(strategy_loader, "load_strategy", _boom) + monkeypatch.setattr(vbt.Portfolio, "from_order_func", _boom) + monkeypatch.setattr(result_package.pq, "write_table", _boom) + monkeypatch.setattr(result_package, "write_result_package", _boom) + monkeypatch.setattr(runner, "run_project", _boom) + monkeypatch.setitem( + sys.modules, + "scripts.research.local_quant_research.vectorbt_runtime", + SimpleNamespace(run_vectorbt=_boom), + ) + + +def _tree_digests(root: Path) -> dict[str, tuple[int, str]]: + return { + path.relative_to(root).as_posix(): ( + path.stat().st_size, + hashlib.sha256(path.read_bytes()).hexdigest(), + ) + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def _tree_directories(root: Path) -> tuple[str, ...]: + return tuple( + path.relative_to(root).as_posix() + for path in sorted(root.rglob("*")) + if path.is_dir() + ) + + +def test_archive_has_no_descriptor_or_inode_state_machine(repo_root: Path) -> None: + source = ( + repo_root / "scripts/research/local_quant_research/archive.py" + ).read_text(encoding="utf-8") + tree = ast.parse(source) + functions = { + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + os_calls = { + node.func.attr + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "os" + } + + assert functions.isdisjoint( + {"_open_verified_descriptor", "_descriptor_identity", "_file_identity"} + ) + assert os_calls.isdisjoint({"open", "dup", "fdopen", "fstat"}) + + +@pytest.mark.parametrize( + "analysis_id", + ( + "Upper", + "../escape", + "a/b", + "", + "x" * 65, + "con", + "aux.txt", + "baseline-v2.", + "baseline-v2 ", + ), +) +def test_promote_rejects_invalid_analysis_id( + isolated_repo: Path, + analysis_id: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _guard_recomputation(monkeypatch) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, analysis_id) + + assert result.status == "failed" + assert result.reasons == ("invalid_analysis_id",) + + +@pytest.mark.parametrize( + "strategy_id", + ( + "Upper", + "../escape", + "a/b", + "", + "x" * 65, + "strategy-999", + "con", + "aux.txt", + "strategy-003.", + "strategy-003 ", + ), +) +def test_promote_rejects_invalid_strategy_identity( + isolated_repo: Path, + strategy_id: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _guard_recomputation(monkeypatch) + + result = promote_archive(isolated_repo, strategy_id, RUN_ID, ANALYSIS_ID) + + assert result.status == "failed" + assert result.reasons == ("invalid_strategy_id",) + + +@pytest.mark.parametrize( + "run_id", ("A" * 64, "a" * 63, "a" * 65, "../escape", "a/b", "") +) +def test_promote_rejects_invalid_run_identity( + isolated_repo: Path, + run_id: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _guard_recomputation(monkeypatch) + + result = promote_archive(isolated_repo, STRATEGY_ID, run_id, ANALYSIS_ID) + + assert result.status == "failed" + assert result.reasons == ("invalid_run_id",) + + +@pytest.mark.skipif(os.name != "nt", reason="Windows name matching is required") +def test_promote_rejects_case_insensitive_strategy_directory_alias( + complete_package: Path, + isolated_repo: Path, +) -> None: + strategies = isolated_repo / "joinquant/strategies" + exact = strategies / STRATEGY_ID + temporary = strategies / "strategy-rename" + alias = strategies / "Strategy-003" + exact.rename(temporary) + temporary.rename(alias) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "failed" + assert result.reasons == ("invalid_strategy_id",) + assert not (alias / "research/archives" / ANALYSIS_ID).exists() + + +def _replace_directory_with_link( + directory: Path, + target: Path, + kind: str, +) -> None: + directory.rename(target) + if kind == "symlink": + try: + directory.symlink_to(target, target_is_directory=True) + except OSError as exc: + target.rename(directory) + pytest.skip(f"directory symlink is unavailable: {exc}") + return + if os.name != "nt": + target.rename(directory) + pytest.skip("junction coverage is Windows-specific") + completed = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(directory), str(target)], + capture_output=True, + check=False, + text=True, + ) + if completed.returncode != 0: + target.rename(directory) + pytest.skip(f"junction is unavailable: {completed.stderr}") + + +def _restore_linked_directory(directory: Path, target: Path) -> None: + if directory.is_symlink(): + directory.unlink() + else: + os.rmdir(directory) + target.rename(directory) + + +@pytest.mark.parametrize("link_kind", ("symlink", "junction")) +@pytest.mark.parametrize( + "relative_parent", + ( + ".local", + ".local/quant-research", + f".local/quant-research/{STRATEGY_ID}", + f".local/quant-research/{STRATEGY_ID}/{RUN_ID}", + "joinquant", + "joinquant/strategies", + f"joinquant/strategies/{STRATEGY_ID}", + f"joinquant/strategies/{STRATEGY_ID}/research", + f"joinquant/strategies/{STRATEGY_ID}/research/archives", + ), +) +def test_promote_rejects_linked_fixed_parent_without_crossing_boundary( + complete_package: Path, + isolated_repo: Path, + relative_parent: str, + link_kind: str, +) -> None: + (isolated_repo / f"joinquant/strategies/{STRATEGY_ID}/research/archives").mkdir( + parents=True + ) + linked_parent = isolated_repo / relative_parent + outside = isolated_repo.parent / "outside-parent" + _replace_directory_with_link(linked_parent, outside, link_kind) + try: + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "failed" + assert not ( + isolated_repo + / f"joinquant/strategies/{STRATEGY_ID}/research/archives" + / ANALYSIS_ID + ).exists() + finally: + _restore_linked_directory(linked_parent, outside) + + +def test_promote_rejects_incomplete_source_package( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (complete_package / "data/results.parquet").unlink() + _guard_recomputation(monkeypatch) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "failed" + assert result.reasons == ("source_incomplete",) + assert not (isolated_repo / "joinquant/strategies/strategy-003/research").exists() + + +@pytest.mark.parametrize( + ("manifest_strategy_id", "manifest_run_id"), + (("strategy-004", RUN_ID), (STRATEGY_ID, "b" * 64)), +) +def test_promote_rejects_source_identity_mismatch( + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, + manifest_strategy_id: str, + manifest_run_id: str, +) -> None: + _write_complete_package( + isolated_repo, + manifest_strategy_id=manifest_strategy_id, + manifest_run_id=manifest_run_id, + ) + _guard_recomputation(monkeypatch) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "failed" + assert result.reasons == ("source_identity_mismatch",) + + +def test_promote_preserves_layout_and_every_source_byte( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manifest_before = (complete_package / "manifest.json").read_bytes() + _guard_recomputation(monkeypatch) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result == ArchiveResult( + status="complete", + reused=False, + source=complete_package, + target=( + isolated_repo + / "joinquant/strategies/strategy-003/research/archives/baseline-v2" + ), + reasons=(), + ) + assert _tree_digests(result.source) == _tree_digests(result.target) + assert _tree_directories(result.source) == _tree_directories(result.target) + assert (complete_package / "manifest.json").read_bytes() == manifest_before + manifest = json.loads((result.target / "manifest.json").read_text("utf-8")) + assert manifest["object"]["run_id"] == RUN_ID + assert "analysis_id" not in manifest + + +@pytest.mark.parametrize("unsafe_kind", ("symlink", "hardlink", "junction")) +def test_promote_rejects_links_and_reparse_points( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, + unsafe_kind: str, +) -> None: + if unsafe_kind == "symlink": + try: + (complete_package / "unsafe-link").symlink_to( + complete_package / "manifest.json" + ) + except OSError as exc: + pytest.skip(f"symlink is unavailable: {exc}") + elif unsafe_kind == "hardlink": + os.link( + complete_package / "manifest.json", + complete_package / "manifest-hardlink.json", + ) + else: + if os.name != "nt": + pytest.skip("junction coverage is Windows-specific") + outside = isolated_repo.parent / "junction-target" + outside.mkdir() + completed = subprocess.run( + [ + "cmd", + "/c", + "mklink", + "/J", + str(complete_package / "unsafe-junction"), + str(outside), + ], + capture_output=True, + check=False, + text=True, + ) + if completed.returncode != 0: + pytest.skip(f"junction is unavailable: {completed.stderr}") + _guard_recomputation(monkeypatch) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "failed" + assert result.reasons == ("unsafe_source_entry",) + + +def test_promote_reuses_identical_target_without_writing( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + first = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + before = _tree_digests(first.target) + _guard_recomputation(monkeypatch) + from scripts.research.local_quant_research import archive + + monkeypatch.setattr(archive.shutil, "copy2", _boom) + monkeypatch.setattr(archive.os, "replace", _boom) + + second = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert second.status == "complete" + assert second.reused is True + assert second.reasons == () + assert _tree_digests(second.target) == before + + +def test_identical_target_is_revalidated_before_reuse( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import archive + + first = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + before = _tree_digests(first.target) + real_validate_views = archive._validate_analysis_views + + def reject_existing_target(path: Path) -> None: + if path == first.target: + raise ValueError("simulated invalid existing archive") + real_validate_views(path) + + monkeypatch.setattr(archive, "_validate_analysis_views", reject_existing_target) + + second = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert second.status == "conflict" + assert second.reused is False + assert second.reasons == ("target_conflict",) + assert _tree_digests(second.target) == before + + +def test_promote_reports_conflict_without_changing_existing_target( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + first = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + before = _tree_digests(first.target) + (complete_package / "new-evidence.bin").write_bytes(b"different") + _guard_recomputation(monkeypatch) + from scripts.research.local_quant_research import archive + + monkeypatch.setattr(archive.shutil, "copy2", _boom) + monkeypatch.setattr(archive.os, "replace", _boom) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "conflict" + assert result.reused is False + assert result.reasons == ("target_conflict",) + assert _tree_digests(first.target) == before + + +def test_existing_target_is_never_written( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = ( + isolated_repo + / "joinquant/strategies/strategy-003/research/archives/baseline-v2" + ) + target.mkdir(parents=True) + marker = target / "user-owned.txt" + marker.write_bytes(b"keep") + _guard_recomputation(monkeypatch) + from scripts.research.local_quant_research import archive + + monkeypatch.setattr(archive.shutil, "copy2", _boom) + monkeypatch.setattr(archive.os, "replace", _boom) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "conflict" + assert marker.read_bytes() == b"keep" + assert tuple(target.iterdir()) == (marker,) + + +def test_target_appearing_before_publish_does_not_leave_staging( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import archive + + target = ( + isolated_repo + / f"joinquant/strategies/{STRATEGY_ID}/research/archives/{ANALYSIS_ID}" + ) + real_validate_views = archive._validate_analysis_views + + def publish_competing_target(staging: Path) -> None: + real_validate_views(staging) + if staging != target: + shutil.copytree(complete_package, target) + + monkeypatch.setattr( + archive, + "_validate_analysis_views", + publish_competing_target, + ) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "complete" + assert result.reused is True + assert list(target.parent.glob(f".{ANALYSIS_ID}.*.tmp")) == [] + + +@pytest.mark.parametrize( + ("competing_content", "expected_status", "expected_reused"), + ( + ("identical", "complete", True), + ("different", "conflict", False), + ), +) +def test_target_appearing_during_atomic_publish_uses_idempotency_rules( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, + competing_content: str, + expected_status: str, + expected_reused: bool, +) -> None: + from scripts.research.local_quant_research import archive + + target = ( + isolated_repo + / f"joinquant/strategies/{STRATEGY_ID}/research/archives/{ANALYSIS_ID}" + ) + + def publish_competing_target(*_args: object, **_kwargs: object) -> None: + shutil.copytree(complete_package, target) + if competing_content == "different": + (target / "competing.bin").write_bytes(b"different") + raise FileExistsError("simulated competing publish") + + monkeypatch.setattr(archive.os, "replace", publish_competing_target) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == expected_status + assert result.reused is expected_reused + assert result.reasons == (() if expected_reused else ("target_conflict",)) + assert list(target.parent.glob(f".{ANALYSIS_ID}.*.tmp")) == [] + + +def test_publish_failure_cleans_staging( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import archive + + target = ( + isolated_repo + / f"joinquant/strategies/{STRATEGY_ID}/research/archives/{ANALYSIS_ID}" + ) + _guard_recomputation(monkeypatch) + + def fail_publish(*_args: object, **_kwargs: object) -> None: + raise OSError("simulated publish failure") + + monkeypatch.setattr(archive.os, "replace", fail_publish) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "failed" + assert result.reasons == ("copy_failed",) + assert not target.exists() + assert list(target.parent.glob(f".{ANALYSIS_ID}.*.tmp")) == [] + + +def test_copy_interruption_cleans_only_its_staging_directory( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + archives = ( + isolated_repo / "joinquant/strategies/strategy-003/research/archives" + ) + archives.mkdir(parents=True) + neighbor = archives / "neighbor.keep" + neighbor.write_bytes(b"keep") + source_before = _tree_digests(complete_package) + _guard_recomputation(monkeypatch) + from scripts.research.local_quant_research import archive + + real_copy2 = shutil.copy2 + copies = 0 + + def interrupted_copy( + source: object, + target: object, + *args: object, + **kwargs: object, + ) -> object: + nonlocal copies + copies += 1 + if copies == 2: + raise OSError("simulated copy interruption") + return real_copy2(source, target, *args, **kwargs) + + monkeypatch.setattr(archive.shutil, "copy2", interrupted_copy) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "failed" + assert result.reasons == ("copy_failed",) + assert not (archives / ANALYSIS_ID).exists() + assert list(archives.glob(f".{ANALYSIS_ID}.*.tmp")) == [] + assert neighbor.read_bytes() == b"keep" + assert _tree_digests(complete_package) == source_before + + +def test_copy_failure_rolls_back_new_empty_archive_parents( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import archive + + strategy = isolated_repo / f"joinquant/strategies/{STRATEGY_ID}" + assert not (strategy / "research").exists() + _guard_recomputation(monkeypatch) + + def fail_copy(*_args: object, **_kwargs: object) -> None: + raise OSError("simulated copy failure") + + monkeypatch.setattr(archive.shutil, "copy2", fail_copy) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "failed" + assert result.reasons == ("copy_failed",) + assert not (strategy / "research").exists() + + +def test_cleanup_failure_returns_stable_reason_and_preserves_existing_parent( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import archive + + archives = isolated_repo / f"joinquant/strategies/{STRATEGY_ID}/research/archives" + archives.mkdir(parents=True) + neighbor = archives / "neighbor.keep" + neighbor.write_bytes(b"keep") + _guard_recomputation(monkeypatch) + + def fail_copy(*_args: object, **_kwargs: object) -> None: + raise OSError("simulated copy failure") + + def fail_real_cleanup( + _path: Path, + *, + ignore_errors: bool = False, + ) -> None: + if not ignore_errors: + raise OSError("simulated cleanup failure") + + monkeypatch.setattr(archive.shutil, "copy2", fail_copy) + monkeypatch.setattr(archive.shutil, "rmtree", fail_real_cleanup) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "failed" + assert result.reasons == ("cleanup_failed",) + assert neighbor.read_bytes() == b"keep" + assert archives.is_dir() + + +def test_source_change_after_validation_is_not_published( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import archive + + real_validate = archive.validate_result_package + + def mutate_after_validation(path: Path) -> object: + manifest = real_validate(path) + if Path(path) == complete_package: + results = complete_package / "data/results.parquet" + results.write_bytes(results.read_bytes() + b"changed-after-validation") + return manifest + + monkeypatch.setattr(archive, "validate_result_package", mutate_after_validation) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "failed" + assert result.reasons == ("copy_failed",) + assert not result.target.exists() + archives = result.target.parent + assert list(archives.glob(f".{ANALYSIS_ID}.*.tmp")) == [] + + +def test_promoted_archive_remains_queryable_after_local_source_is_deleted( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _guard_recomputation(monkeypatch) + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + shutil.rmtree(isolated_repo / ".local") + + source = open_analysis_source(result.target) + with open_analysis_database(result.target) as database: + assert database.table_names == ( + "results", + "balances", + "positions", + "orders", + ) + assert database.connection.sql("select count(*) from results").fetchone() == ( + 2, + ) + assert database.extension("signals").fetchall() == [("signal-1", 0.5)] + assert source.backend == "vectorbt" + assert source.formula_version == "unified-strategy-analysis/1" + + +def test_unified_analysis_validation_failure_prevents_publish( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research import analysis_data + + def reject_staging(_path: Path) -> object: + raise analysis_data.AnalysisManifestError( + "simulated unified analysis failure" + ) + + _guard_recomputation(monkeypatch) + monkeypatch.setattr(analysis_data, "open_analysis_database", reject_staging) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "failed" + assert not result.target.exists() + + +def test_complete_staging_passes_the_real_unified_analysis_entry( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research import analysis_data + + real_open = analysis_data.open_analysis_database + validated: list[Path] = [] + + def record_real_validation(path: Path) -> object: + validated.append(Path(path)) + return real_open(path) + + _guard_recomputation(monkeypatch) + monkeypatch.setattr( + analysis_data, + "open_analysis_database", + record_real_validation, + ) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "complete" + assert len(validated) == 1 + assert validated[0].parent == result.target.parent + assert validated[0].name.startswith(f".{ANALYSIS_ID}.") + assert validated[0].name.endswith(".tmp") + + +def test_prepublish_uses_authoritative_validator_once_per_tree( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.analysis_data import manifest as analysis_manifest + from scripts.research.local_quant_research import archive + + real_validate = archive.validate_result_package + validated: list[Path] = [] + + def record_validation(path: Path) -> object: + validated.append(Path(path)) + return real_validate(path) + + monkeypatch.setattr(archive, "validate_result_package", record_validation) + monkeypatch.setattr( + analysis_manifest, + "validate_result_package", + record_validation, + ) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "complete" + assert validated.count(complete_package) == 1 + staging_validations = [path for path in validated if path != complete_package] + assert len(staging_validations) == 1 + assert staging_validations[0].parent == result.target.parent + assert staging_validations[0].name.startswith(f".{ANALYSIS_ID}.") + + +def test_prepublish_analysis_queries_one_row_per_relation( + complete_package: Path, + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research import analysis_data + + real_open = analysis_data.open_analysis_database + fetched: list[tuple[str, int | None]] = [] + + def probe_relation( + relation: Any, + name: str, + *, + limit_count: int | None = None, + ) -> SimpleNamespace: + def limit(count: int) -> SimpleNamespace: + return probe_relation( + relation.limit(count), + name, + limit_count=count, + ) + + def fetchall() -> object: + fetched.append((name, limit_count)) + return relation.fetchall() + + return SimpleNamespace(limit=limit, fetchall=fetchall) + + @contextmanager + def record_bounded_queries(path: Path) -> Iterator[object]: + with real_open(path) as database: + connection = SimpleNamespace( + table=lambda name: probe_relation( + database.connection.table(name), + f"core:{name}", + ) + ) + yield SimpleNamespace( + source=database.source, + table_names=database.table_names, + connection=connection, + extension=lambda name: probe_relation( + database.extension(name), + f"extension:{name}", + ), + ) + + monkeypatch.setattr( + analysis_data, + "open_analysis_database", + record_bounded_queries, + ) + + result = promote_archive(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID) + + assert result.status == "complete" + assert fetched == [ + (f"core:{name}", 1) + for name in analysis_data.LOCAL_PHYSICAL_DATASETS + ] + [("extension:signals", 1)] + + +@pytest.mark.parametrize( + ("status", "exit_code"), + (("complete", 0), ("conflict", 1), ("failed", 2)), +) +def test_promote_cli_uses_exact_arguments_sorted_json_and_exit_codes( + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + status: str, + exit_code: int, +) -> None: + from scripts.research.local_quant_research import cli + + source = isolated_repo / ".local/quant-research/strategy-003" / RUN_ID + target = ( + isolated_repo + / "joinquant/strategies/strategy-003/research/archives/baseline-v2" + ) + calls: list[tuple[Path, str, str, str]] = [] + + def fake_promote( + repo_root: Path, + strategy_id: str, + run_id: str, + analysis_id: str, + ) -> ArchiveResult: + calls.append((repo_root, strategy_id, run_id, analysis_id)) + return ArchiveResult( + status=status, + reused=status == "complete", + source=source, + target=target, + reasons=() if status == "complete" else (f"{status}_reason",), + ) + + monkeypatch.setattr(cli, "REPO_ROOT", isolated_repo) + monkeypatch.setattr(cli, "promote_archive", fake_promote) + + actual = cli.main( + [ + "promote", + "--strategy-id", + STRATEGY_ID, + "--run-id", + RUN_ID, + "--analysis-id", + ANALYSIS_ID, + ] + ) + + assert actual == exit_code + assert calls == [(isolated_repo, STRATEGY_ID, RUN_ID, ANALYSIS_ID)] + expected = { + "reasons": [] if status == "complete" else [f"{status}_reason"], + "reused": status == "complete", + "source": str(source), + "status": status, + "target": str(target), + } + assert capsys.readouterr().out == ( + json.dumps(expected, ensure_ascii=False, sort_keys=True) + "\n" + ) + + +@pytest.mark.parametrize( + "arguments", + ( + ["promote", "--strategy-id", STRATEGY_ID, "--run-id", RUN_ID], + [ + "promote", + "--strategy-id", + STRATEGY_ID, + "--run-id", + RUN_ID, + "--analysis-id", + ANALYSIS_ID, + "--target", + "elsewhere", + ], + ), +) +def test_promote_cli_rejects_missing_or_extra_public_arguments( + arguments: list[str], +) -> None: + from scripts.research.local_quant_research.cli import _parser + + with pytest.raises(SystemExit) as error: + _parser().parse_args(arguments) + + assert error.value.code == 2 + + +def test_promote_cli_does_not_import_the_run_or_recompute_paths( + isolated_repo: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + cli_name = "scripts.research.local_quant_research.cli" + archive_module = importlib.import_module( + "scripts.research.local_quant_research.archive" + ) + result = ArchiveResult( + status="complete", + reused=False, + source=None, + target=None, + reasons=(), + ) + real_import: Callable[..., object] = builtins.__import__ + + def guarded_import( + name: str, + globals_: object = None, + locals_: object = None, + fromlist: object = (), + level: int = 0, + ) -> object: + if name.rsplit(".", 1)[-1] in { + "runner", + "strategy_loader", + "vectorbt_runtime", + }: + raise AssertionError(f"promote imported forbidden module: {name}") + return real_import(name, globals_, locals_, fromlist, level) + + with monkeypatch.context() as context: + context.setattr(archive_module, "promote_archive", lambda *_args: result) + context.setattr(builtins, "__import__", guarded_import) + sys.modules.pop(cli_name, None) + cli = importlib.import_module(cli_name) + context.setattr(cli, "REPO_ROOT", isolated_repo) + + assert cli.main( + [ + "promote", + "--strategy-id", + STRATEGY_ID, + "--run-id", + RUN_ID, + "--analysis-id", + ANALYSIS_ID, + ] + ) == 0 + sys.modules.pop(cli_name, None) + importlib.import_module(cli_name) diff --git a/tests/local_quant_research/test_contract_fixtures.py b/tests/local_quant_research/test_contract_fixtures.py index 267a52a..21f88cd 100644 --- a/tests/local_quant_research/test_contract_fixtures.py +++ b/tests/local_quant_research/test_contract_fixtures.py @@ -162,3 +162,55 @@ def test_authoritative_docs_confirm_the_same_new_baseline(repo_root: Path) -> No assert "全量仓位再分配" in text assert "4/6/12" in text assert "180 秒" in text + + +def test_local_research_performance_baseline_freezes_observations( + repo_root: Path, +) -> None: + fixture = json.loads( + ( + repo_root + / "tests/local_quant_research/fixtures/performance-baseline.json" + ).read_text(encoding="utf-8") + ) + + assert fixture["schema_version"] == 1 + assert fixture["environment"] == { + "architecture": "AMD64", + "dependencies": { + "numba": "0.66.0", + "numpy": "2.4.6", + "pandas": "3.0.3", + "pyarrow": "23.0.1", + "vectorbt": "1.1.0", + }, + "logical_cpu_count": 20, + "os": "Windows", + "os_release": "11", + "os_version": "10.0.26200", + "physical_memory_bytes": 34163961856, + "processor": "Intel64 Family 6 Model 183 Stepping 1, GenuineIntel", + "python": "3.12.10", + } + assert fixture["sampling"] == { + "cold_processes": 3, + "full_cli_cold_processes": 3, + "warm_runs": 5, + "statistic": "median", + } + assert "limits" not in fixture + assert fixture["collection"] == { + "python": ".venv/Scripts/python.exe", + "entrypoint": ( + "joinquant/strategies/strategy-003/research/" + "turtle_etf/vectorbt_cli.py" + ), + "memory_method": "ctypes.GetProcessMemoryInfo", + "cold_process_model": "independent_process_per_sample", + "warm_process_model": "same_process_for_all_samples", + } + assert tuple(fixture["scenarios"]) == ( + "immediate-11-etf", + "immediate-17-etf", + "delayed-11-etf-1d", + ) diff --git a/tests/local_quant_research/test_evidence.py b/tests/local_quant_research/test_evidence.py index a3a98ae..46c104c 100644 --- a/tests/local_quant_research/test_evidence.py +++ b/tests/local_quant_research/test_evidence.py @@ -2,15 +2,25 @@ import hashlib from pathlib import Path +from types import MappingProxyType +import numpy as np +import pyarrow as pa import pytest -from scripts.research.local_quant_research.contracts import OutputSpec +from scripts.research.local_quant_research.contracts import ( + ExecutionBundle, + ExecutionRun, + OutputSpec, + ResultExtension, +) from scripts.research.local_quant_research.evidence import ( EvidenceError, collect_output_evidence, compute_run_id, + execution_digest, ) +from scripts.research.local_quant_research import evidence def test_run_id_is_stable_and_binds_all_three_input_digests() -> None: @@ -77,3 +87,118 @@ def test_directory_evidence_binds_dynamic_result_files(tmp_path: Path) -> None: }, ] assert len(evidence[0]["sha256"]) == 64 + + +def test_execution_digest_scans_shared_run_and_arrays_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class CountingLedger: + def __init__(self) -> None: + self.calls: dict[str, int] = {} + self.shared = np.array([(1,)], dtype=[("value", "i8")]) + + def _value(self, name: str) -> np.ndarray: + self.calls[name] = self.calls.get(name, 0) + 1 + return self.shared + + orders = property(lambda self: self._value("orders")) + assets = property(lambda self: self._value("assets")) + cash = property(lambda self: self._value("cash")) + value = property(lambda self: self._value("value")) + trades = property(lambda self: self._value("trades")) + positions = property(lambda self: self._value("positions")) + returns = property(lambda self: self._value("returns")) + + ledger = CountingLedger() + run = ExecutionRun( + ledger=ledger, + trace=MappingProxyType({"trace": np.array([1.0, np.nan])}), + ) + execution = ExecutionBundle(run, run, ("primary",)) + monkeypatch.setattr( + "scripts.research.local_quant_research.evidence.np.ascontiguousarray", + lambda _value: pytest.fail("digest must not create a full contiguous copy"), + ) + + digest = execution_digest(execution) + + assert len(digest) == 64 + assert ledger.calls == { + name: 1 + for name in ( + "orders", + "assets", + "cash", + "value", + "trades", + "positions", + "returns", + ) + } + + +@pytest.mark.parametrize( + "table", + ( + pa.table({"value": pa.array([["nested"]])}), + pa.table({"value": pa.array([float("nan")], type=pa.float64())}), + pa.table({"value": pa.array([1], type=pa.int32())}), + pa.table( + { + "value": pa.DictionaryArray.from_arrays( + pa.array([0], type=pa.int8()), pa.array(["value"]) + ) + } + ), + pa.table( + { + "value": pa.array( + [{"child": 1}], + type=pa.struct([("child", pa.int64())]), + ) + } + ), + pa.table( + { + "value": pa.array( + [[("key", 1)]], + type=pa.map_(pa.string(), pa.int64()), + ) + } + ), + pa.table( + { + "value": pa.UnionArray.from_sparse( + pa.array([5], type=pa.int8()), + [pa.array(["value"]), pa.array([1])], + field_names=["text", "number"], + type_codes=[5, 7], + ) + } + ), + pa.table( + { + "value": pa.RunEndEncodedArray.from_arrays( + pa.array([1], type=pa.int16()), + pa.array(["value"]), + ) + } + ), + ), +) +def test_extension_table_rejects_non_flat_or_nan_values(table: pa.Table) -> None: + with pytest.raises(EvidenceError, match="extension"): + evidence.validate_extension_table(table) + + +def test_extension_table_accepts_flat_values_and_arrow_nulls() -> None: + table = pa.table( + { + "text": pa.array(["value", None], type=pa.string()), + "flag": pa.array([True, None], type=pa.bool_()), + "count": pa.array([1, None], type=pa.int64()), + "score": pa.array([1.0, None], type=pa.float64()), + } + ) + + evidence.validate_extension_table(table) diff --git a/tests/local_quant_research/test_generic_e2e.py b/tests/local_quant_research/test_generic_e2e.py index 76d89fc..2ac4326 100644 --- a/tests/local_quant_research/test_generic_e2e.py +++ b/tests/local_quant_research/test_generic_e2e.py @@ -42,14 +42,20 @@ def _remove_empty_test_roots(repo_root: Path, market_root: Path) -> None: pass -def test_non_strategy_project_completes_through_shared_market_and_runner( +def test_minimal_strategy_completes_reuses_and_rejects_digest_conflict( tmp_path: Path, repo_root: Path, ) -> None: token = uuid.uuid4().hex - project_id = f"plain-project-{token[:12]}" + project_id = "minimal-fixture-b" project_root = repo_root / ".local/e2e-tests" / token output_project = repo_root / ".local/quant-research" / project_id + attempts_root = output_project / ".attempts" + existing_attempts = ( + {path.name for path in attempts_root.glob("*.json")} + if attempts_root.is_dir() + else set() + ) market_root = repo_root / ".local/market-data" snapshot = None batch_ids: list[str] = [] @@ -125,59 +131,25 @@ def test_non_strategy_project_completes_through_shared_market_and_runner( batch_ids = list(snapshot_document["batch_ids"]) project_root.mkdir(parents=True) - adapter = ( - repo_root / "tests/local_quant_research/fixtures/plain_project_adapter.py" + scenario_config = project_root / "scenario.json" + scenario_config.write_text( + '{"scenario_id":"baseline","schema_version":1}\n', + encoding="utf-8", ) - project_config = project_root / "project.json" - project_config.write_text('{"schema_version":1}\n', encoding="utf-8") declared = project_root / "input.txt" declared.write_text("plain input\n", encoding="utf-8") - code_identity = project_root / "code-identity.json" - shared_sources = [ - repo_root / "scripts/__init__.py", - repo_root / "scripts/research/__init__.py", - *sorted((repo_root / "scripts/research/market_data").glob("*.py")), - ] - identity_sources = sorted( - {adapter, *shared_sources}, - key=lambda path: path.relative_to(repo_root).as_posix(), - ) - code_identity.write_text( - json.dumps( - { - "schema_version": 1, - "files": [ - { - "path": source_path.relative_to(repo_root).as_posix(), - "sha256": _sha256(source_path), - } - for source_path in identity_sources - ], - }, - sort_keys=True, - ) - + "\n", - encoding="utf-8", - ) - required_outputs = [ - {"path": "result.json", "format": "json"}, - ] run_config = { - "schema_version": 1, + "schema_version": 2, "project_id": project_id, + "strategy": { + "root": "tests/local_quant_research/fixtures", + "module": "minimal_strategy_b.strategy", + "symbol": "MODULE", + }, "snapshot_id": snapshot.snapshot_id, "snapshot_requirements": snapshot_document["selection"], - "project_entry": adapter.relative_to(repo_root).as_posix(), - "command": [ - ".venv/Scripts/python.exe", - adapter.relative_to(repo_root).as_posix(), - ], - "project_config": project_config.relative_to(repo_root).as_posix(), - "code_identity": code_identity.relative_to(repo_root).as_posix(), + "scenario_config": scenario_config.relative_to(repo_root).as_posix(), "declared_inputs": [declared.relative_to(repo_root).as_posix()], - "required_outputs": required_outputs, - "output_root": ".local/quant-research", - "stop_states": ["complete", "evidence_insufficient", "failed"], } run_path = project_root / "run.json" run_path.write_text( @@ -204,19 +176,126 @@ def test_non_strategy_project_completes_through_shared_market_and_runner( result = json.loads(completed.stdout) assert result["status"] == "complete" run_output = Path(result["run_path"]) - document = json.loads((run_output / "result.json").read_text(encoding="utf-8")) - assert document["snapshot_id"] == snapshot.snapshot_id - assert document["run_id"] == result["run_id"] - status = json.loads( - (run_output / "project-status.json").read_text(encoding="utf-8") + manifest = json.loads((run_output / "manifest.json").read_text(encoding="utf-8")) + assert manifest["object"]["strategy_id"] == project_id + assert manifest["object"]["run_id"] == result["run_id"] + assert { + path.stem for path in (run_output / "data").glob("*.parquet") + } == {"results", "balances", "positions", "orders"} + assert (run_output / "report/execution-summary.md").is_file() + assert (run_output / "report/metrics.json").is_file() + expected_code = { + "minimal_strategy_b/__init__.py": ( + repo_root + / "tests/local_quant_research/fixtures/minimal_strategy_b/__init__.py" + ), + "minimal_strategy_b/strategy.py": ( + repo_root + / "tests/local_quant_research/fixtures/minimal_strategy_b/strategy.py" + ), + } + code_identity = json.loads( + (run_output / "config/code-identity.json").read_text(encoding="utf-8") + ) + strategy_identity = { + item["path"]: item["sha256"] + for item in code_identity["files"] + if item["path"].startswith( + "tests/local_quant_research/fixtures/minimal_strategy_b/" + ) + } + assert set(manifest["code"]) == set(expected_code) + assert set(strategy_identity) == { + source.relative_to(repo_root).as_posix() + for source in expected_code.values() + } + for relative, source in expected_code.items(): + archived = run_output / "code" / relative + reference = manifest["code"][relative] + digest = _sha256(source) + assert archived.read_bytes() == source.read_bytes() + assert reference["path"] == f"code/{relative}" + assert reference["sha256"] == digest + assert strategy_identity[source.relative_to(repo_root).as_posix()] == digest + performance = json.loads( + (run_output / "evidence/performance.json").read_text(encoding="utf-8") + ) + assert performance["cold"]["digest"] == performance["warm"]["digest"] + assert 0 <= performance["cold"]["seconds"] < 180 + assert 0 <= performance["warm"]["seconds"] < 180 + assert tuple(performance["stages"]) == ( + "core_facts", + "followup_prepare", + "followup_vectorbt", + "parquet_materialize", + "primary_vectorbt", + "readback_validate", + "report_and_manifest", + "strategy_extensions", + "strategy_load", + "strategy_prepare", ) - assert status["next_action"] == "return_to_caller" - assert not (run_output / "recommendation.json").exists() - assert not (run_output / "local-research-report.md").exists() - assert not tuple(run_output.glob("*.parquet")) + assert not tuple(run_output.rglob("*.duckdb")) assert not tuple(market_root.rglob("*.duckdb")) + + reused = subprocess.run( + [ + str(repo_root / ".venv/Scripts/python.exe"), + str(repo_root / "scripts/research/local_quant_research/cli.py"), + "run", + "--config", + str(run_path.relative_to(repo_root)), + ], + cwd=repo_root, + capture_output=True, + text=True, + shell=False, + check=False, + timeout=120, + ) + assert reused.returncode == 0, reused.stderr + reused.stdout + assert json.loads(reused.stdout)["reused"] is True + + manifest_path = run_output / "manifest.json" + tampered = json.loads(manifest_path.read_text(encoding="utf-8")) + tampered["package_sha256"] = "0" * 64 + manifest_path.write_text( + json.dumps(tampered, ensure_ascii=False, sort_keys=True) + "\n", + encoding="utf-8", + ) + conflict = subprocess.run( + [ + str(repo_root / ".venv/Scripts/python.exe"), + str(repo_root / "scripts/research/local_quant_research/cli.py"), + "run", + "--config", + str(run_path.relative_to(repo_root)), + ], + cwd=repo_root, + capture_output=True, + text=True, + shell=False, + check=False, + timeout=120, + ) + assert conflict.returncode == 1 + assert json.loads(conflict.stdout)["status"] == "failed" finally: - shutil.rmtree(output_project, ignore_errors=True) + if "run_output" in locals(): + shutil.rmtree(run_output, ignore_errors=True) + try: + output_project.rmdir() + except OSError: + pass + if attempts_root.is_dir(): + for attempt in attempts_root.glob("*.json"): + if attempt.name not in existing_attempts: + attempt.unlink(missing_ok=True) + try: + attempts_root.rmdir() + output_project.rmdir() + except OSError: + pass shutil.rmtree(project_root, ignore_errors=True) if snapshot is not None: snapshot.path.unlink(missing_ok=True) @@ -225,6 +304,32 @@ def test_non_strategy_project_completes_through_shared_market_and_runner( _remove_empty_test_roots(repo_root, market_root) +def test_public_cli_maps_missing_config_to_evidence_insufficient( + repo_root: Path, +) -> None: + missing = repo_root / ".local/e2e-tests/does-not-exist.json" + + completed = subprocess.run( + [ + str(repo_root / ".venv/Scripts/python.exe"), + str(repo_root / "scripts/research/local_quant_research/cli.py"), + "run", + "--config", + str(missing.relative_to(repo_root)), + ], + cwd=repo_root, + capture_output=True, + text=True, + shell=False, + check=False, + timeout=30, + ) + + assert completed.returncode == 2 + assert json.loads(completed.stdout)["status"] == "evidence_insufficient" + assert "Traceback" not in completed.stderr + completed.stdout + + def test_shared_sources_do_not_depend_on_one_strategy(repo_root: Path) -> None: paths = [ *sorted((repo_root / "scripts/research/market_data").glob("*.py")), @@ -240,3 +345,12 @@ def test_shared_sources_do_not_depend_on_one_strategy(repo_root: Path) -> None: "512100.xshg", ): assert forbidden not in text + + shared_runtime = repo_root / "scripts/research/local_quant_research" + for path in shared_runtime.glob("*.py"): + source = path.read_text(encoding="utf-8") + if path.name == "vectorbt_runtime.py": + assert "Portfolio.from_order_func" in source + else: + assert "import vectorbt" not in source + assert "from vectorbt" not in source diff --git a/tests/local_quant_research/test_local_research_equivalence.py b/tests/local_quant_research/test_local_research_equivalence.py new file mode 100644 index 0000000..3392e5c --- /dev/null +++ b/tests/local_quant_research/test_local_research_equivalence.py @@ -0,0 +1,454 @@ +from __future__ import annotations + +import hashlib +import json +import sys +from functools import lru_cache +from pathlib import Path +from typing import Iterable + +import numpy as np +import pyarrow as pa +import pytest + + +RESEARCH_ROOT = ( + Path(__file__).resolve().parents[2] + / "joinquant" + / "strategies" + / "strategy-003" + / "research" +) +sys.path.insert(0, str(RESEARCH_ROOT)) + +from scripts.research.market_data.query import open_snapshot # noqa: E402 +from scripts.research.local_quant_research.contracts import ExecutionBundle # noqa: E402 +from scripts.research.local_quant_research.vectorbt_runtime import run_vectorbt # noqa: E402 +from turtle_etf.strategy import MODULE # noqa: E402 + + +SCENARIOS = ( + "immediate-11-etf", + "immediate-17-etf", + "delayed-11-etf-1d", +) + +_SNAPSHOTS = { + "immediate-11-etf": "e88238cca420a8ae66b90adb6cda4dd6c38a07390a13b8ac2f471e534742e33e", + "immediate-17-etf": "27c0a452ad5cd8c7f865d2d8cd7555595df0b1fc7afecb86eace4b36336a7ddd", + "delayed-11-etf-1d": "e88238cca420a8ae66b90adb6cda4dd6c38a07390a13b8ac2f471e534742e33e", +} +_EXPANDED_UNIVERSE = { + "159980.XSHE": "commodity_futures", + "159981.XSHE": "commodity_futures", + "159985.XSHE": "commodity_futures", + "511260.XSHG": "treasury_bond", + "513030.XSHG": "developed_non_us_equity", + "513800.XSHG": "developed_non_us_equity", +} +_ANALYSIS_VIEWS = ( + "results", + "balances", + "positions", + "orders", + "risk", + "period_risks", + "strategy_daily_returns", + "source_benchmark_returns", +) +_V1_FIXTURE = "tests/local_quant_research/fixtures/local-research-v1-baseline.json" +_MODULE_FIXTURE = ( + "tests/local_quant_research/fixtures/strategy-module-v1-baseline.json" +) + + +def _canonical_json_bytes(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def _update_array_digest( + digest: object, + values: Iterable[object], + *, + floating: bool, +) -> None: + items = list(values) + valid = np.asarray([value is not None for value in items], dtype=np.uint8) + if floating: + array = np.asarray( + [0.0 if value is None else float(value) for value in items], + dtype=" dict[str, object]: + digest = hashlib.sha256() + digest.update(_canonical_json_bytes({"fields": fields, "rows": table.num_rows})) + for name in fields: + column = table[name].combine_chunks() + digest.update(name.encode("utf-8")) + digest.update(b"\0") + if pa.types.is_floating(column.type): + _update_array_digest(digest, column.to_pylist(), floating=True) + elif pa.types.is_integer(column.type): + _update_array_digest(digest, column.to_pylist(), floating=False) + else: + digest.update(_canonical_json_bytes(column.to_pylist())) + return {"rows": table.num_rows, "sha256": digest.hexdigest()} + + +def _structured_array_digest( + array: np.ndarray, + fields: tuple[str, ...] | None = None, +) -> dict[str, object]: + records = np.asarray(array) + names = tuple(records.dtype.names or ()) + selected = names if fields is None else fields + if not selected or not set(selected).issubset(names): + raise AssertionError("structured public ledger array fields are incomplete") + digest = hashlib.sha256() + digest.update(_canonical_json_bytes({"fields": selected, "rows": len(records)})) + for name in selected: + values = records[name] + digest.update(name.encode("utf-8")) + digest.update(b"\0") + if np.issubdtype(values.dtype, np.floating): + _update_array_digest(digest, values.tolist(), floating=True) + elif np.issubdtype(values.dtype, np.integer): + _update_array_digest(digest, values.tolist(), floating=False) + else: + digest.update(_canonical_json_bytes(values.tolist())) + return {"rows": len(records), "sha256": digest.hexdigest()} + + +def _state_digest(attribution: pa.Table) -> dict[str, object]: + identities: list[dict[str, object]] = [] + unit_counts: list[object] = [] + common_stops: list[object] = [] + for row in attribution.to_pylist(): + details = json.loads(str(row["details_json"])) + if "unit_count_before" not in details: + continue + identities.append( + { + "reason_code": row["reason_code"], + "security": row["security"], + "time": row["time"], + } + ) + unit_counts.extend( + (details["unit_count_before"], details["unit_count_after"]) + ) + common_stops.extend( + (details["common_stop_before"], details["common_stop_after"]) + ) + digest = hashlib.sha256(_canonical_json_bytes(identities)) + _update_array_digest(digest, unit_counts, floating=False) + _update_array_digest(digest, common_stops, floating=True) + return {"events": len(identities), "sha256": digest.hexdigest()} + + +def logic_digest(orders: np.ndarray, attribution: pa.Table) -> str: + orders = [ + { + key: row[key].item() if isinstance(row[key], np.generic) else row[key] + for key in ( + "action", + "amount", + "comment", + "filled", + "security", + "side", + "status", + "time", + "type", + ) + } + for row in np.asarray(orders) + ] + attribution_rows = [] + for row in attribution.to_pylist(): + normalized = dict(row) + normalized["details_json"] = json.loads(str(row["details_json"])) + attribution_rows.append(normalized) + return hashlib.sha256( + _canonical_json_bytes( + {"attribution": attribution_rows, "orders": orders} + ) + ).hexdigest() + + +def _summarize_public_result( + execution: ExecutionBundle, + extension: object, + scenario: str, +) -> dict[str, object]: + ledger = execution.final.ledger + attribution = extension.table + return { + "scenario": scenario, + "orders": _structured_array_digest(ledger.orders), + "fees": _structured_array_digest( + ledger.orders, ("match_time", "security", "commission") + ), + "cash": _structured_array_digest(ledger.cash), + "positions": _structured_array_digest(ledger.assets), + "value": _structured_array_digest(ledger.value), + "state": _state_digest(attribution), + "logic": logic_digest(ledger.orders, attribution), + } + + +def _scenario_config(scenario: str) -> dict[str, object]: + config = json.loads((RESEARCH_ROOT / "baseline.json").read_text(encoding="utf-8")) + config["scenario_id"] = scenario + config["execution"]["additional_delay_days"] = ( + 1 if scenario == "delayed-11-etf-1d" else 0 + ) + if scenario == "immediate-17-etf": + config["universe"].extend( + {"security": security, "asset_group": asset_group} + for security, asset_group in sorted(_EXPANDED_UNIVERSE.items()) + ) + return config + + +def _require_fixed_machine_snapshots(repo_root: Path) -> None: + snapshot_root = repo_root / ".local/market-data/snapshots" + missing = sorted( + snapshot_id + for snapshot_id in set(_SNAPSHOTS.values()) + if not (snapshot_root / f"{snapshot_id}.json").is_file() + ) + if missing: + pytest.skip( + "fixed-machine recomputation requires private market-data snapshots: " + + ", ".join(missing) + + "; committed fixture structure and digest linkage remain verified" + ) + + +@lru_cache(maxsize=None) +def _actual_execution( + repo_root_text: str, scenario: str +) -> tuple[ExecutionBundle, object, dict[str, object], str]: + repo_root = Path(repo_root_text) + _require_fixed_machine_snapshots(repo_root) + config = _scenario_config(scenario) + snapshot = open_snapshot( + _SNAPSHOTS[scenario], root=repo_root / ".local/market-data" + ) + prepared = MODULE.prepare(snapshot, config) + primary = run_vectorbt(prepared.ledger_input, prepared.primary_program) + followup = MODULE.followup_program(prepared, primary) + if followup is None: + execution = ExecutionBundle(primary, primary, ("primary",)) + else: + final = run_vectorbt(prepared.ledger_input, followup) + execution = ExecutionBundle(primary, final, ("primary", "followup")) + extension = MODULE.build_extensions(prepared, execution)[0] + return execution, extension, config, snapshot.corporate_actions_digest + + +def _actual_scenario(repo_root_text: str, scenario: str) -> dict[str, object]: + execution, extension, _, _ = _actual_execution(repo_root_text, scenario) + return _summarize_public_result(execution, extension, scenario) + + +def assert_equivalent( + actual: dict[str, object], expected: dict[str, object] +) -> None: + assert actual["scenario"] == expected["scenario"] + for key in ("orders", "fees", "cash", "positions", "value", "state", "logic"): + assert actual[key] == expected[key] + + +def test_historical_v1_fixture_keeps_materialized_contract_provenance( + repo_root: Path, +) -> None: + fixture = json.loads( + (repo_root / _V1_FIXTURE).read_text(encoding="utf-8") + ) + + assert fixture["schema_version"] == 1 + assert set(fixture["materialized_contract"]) == {"analysis_data_views"} + assert tuple(item["scenario"] for item in fixture["scenarios"]) == SCENARIOS + assert all( + set(item) + == { + "scenario", + "orders", + "fees", + "cash", + "positions", + "value", + "state", + "logic", + "materialized", + } + for item in fixture["scenarios"] + ) + views_sha256 = hashlib.sha256( + _canonical_json_bytes( + fixture["materialized_contract"]["analysis_data_views"] + ) + ).hexdigest() + for item in fixture["scenarios"]: + materialized = item["materialized"] + assert set(materialized) == { + "manifest", + "config_identity", + "code_identity", + "analysis_data_views_sha256", + } + assert materialized["analysis_data_views_sha256"] == views_sha256 + assert materialized["manifest"]["schema_version"] == "local-backtest/1" + assert len(materialized["manifest"]["sha256"]) == 64 + assert materialized["code_identity"]["path"] == "code.py" + assert materialized["code_identity"]["bytes"] > 0 + assert len(materialized["code_identity"]["sha256"]) == 64 + current = materialized["config_identity"]["current"] + version = materialized["config_identity"]["version"] + assert current["path"] == "params.json" + assert current["bytes"] == version["bytes"] > 0 + assert current["sha256"] == version["sha256"] + assert version["path"] == ( + f"params_versions/{current['sha256']}.json" + ) + + +def test_strategy_module_fixture_covers_only_public_ledger_and_extension( + repo_root: Path, +) -> None: + fixture = json.loads( + (repo_root / _MODULE_FIXTURE).read_text(encoding="utf-8") + ) + v1_bytes = (repo_root / _V1_FIXTURE).read_bytes() + + assert fixture["schema_version"] == "strategy-module-baseline/1" + assert fixture["public_contract"] == { + "ledger": "ExecutionLedger", + "extension": "ResultExtension:turtle_etf", + } + assert fixture["migrated_from"] == { + "path": _V1_FIXTURE, + "sha256": hashlib.sha256(v1_bytes).hexdigest(), + } + assert tuple(item["scenario"] for item in fixture["scenarios"]) == SCENARIOS + assert all( + set(item) + == {"scenario", "orders", "fees", "cash", "positions", "value", "state", "logic"} + for item in fixture["scenarios"] + ) + + +def test_logic_digest_covers_complete_attribution_rows_and_details() -> None: + order = np.array( + [ + ( + "open", + 100, + "entry", + 100, + "ETF-A", + "long", + "held", + "2026-01-05", + "market", + ) + ], + dtype=[ + ("action", "U16"), + ("amount", "i8"), + ("comment", "U64"), + ("filled", "i8"), + ("security", "U64"), + ("side", "U16"), + ("status", "U16"), + ("time", "U32"), + ("type", "U16"), + ], + ) + attribution = { + "time": "2026-01-05", + "event_id": "event-1", + "scope": "security", + "security": "ETF-A", + "event_type": "decision", + "reason_code": "signal_entry", + "requested_amount": 100, + "executed_amount": 100, + "reference_price": 10.0, + "risk_before": 0.0, + "risk_after": 1.0, + "details_json": json.dumps( + { + "candidate_units": 1, + "planned_amount": 100, + "position_before": 0, + "position_after": 100, + "stop_failure": None, + }, + sort_keys=True, + ), + } + + def digest(**changes: object) -> str: + row = {**attribution, **changes} + return logic_digest(order, pa.Table.from_pylist([row])) + + assert len( + { + digest(), + digest(event_id="event-2"), + digest( + details_json=json.dumps( + { + **json.loads(attribution["details_json"]), + "planned_amount": 200, + }, + sort_keys=True, + ) + ), + } + ) == 3 + + +def test_missing_private_snapshots_are_an_explicit_fixed_machine_gate( + tmp_path: Path, +) -> None: + with pytest.raises(pytest.skip.Exception) as skipped: + _actual_scenario(str(tmp_path), SCENARIOS[0]) + assert str(skipped.value) == ( + "fixed-machine recomputation requires private market-data snapshots: " + + ", ".join(sorted(set(_SNAPSHOTS.values()))) + + "; committed fixture structure and digest linkage remain verified" + ) + + +@pytest.mark.parametrize("scenario", SCENARIOS) +def test_strategy_module_matches_frozen_equivalence_fixture( + repo_root: Path, + scenario: str, +) -> None: + fixture = json.loads( + (repo_root / _MODULE_FIXTURE).read_text(encoding="utf-8") + ) + expected = next( + item for item in fixture["scenarios"] if item["scenario"] == scenario + ) + + assert_equivalent(_actual_scenario(str(repo_root), scenario), expected) diff --git a/tests/local_quant_research/test_local_research_v2_e2e.py b/tests/local_quant_research/test_local_research_v2_e2e.py new file mode 100644 index 0000000..c5118c8 --- /dev/null +++ b/tests/local_quant_research/test_local_research_v2_e2e.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import ast +import json +from pathlib import Path + + +_LEGACY_STRATEGY_FILES = ( + "indicators.py", + "result_adapter.py", + "single_scenario.py", + "vectorbt_benchmark.py", + "vectorbt_callbacks.py", + "vectorbt_cli.py", + "vectorbt_delayed.py", + "vectorbt_engine.py", + "vectorbt_inputs.py", +) +_LEGACY_CONFIG_FIELDS = { + "code_identity", + "command", + "output_root", + "project_config", + "project_entry", + "required_outputs", + "stop_states", +} + + +def test_strategy_003_production_config_uses_only_v2_contract( + repo_root: Path, +) -> None: + config = json.loads( + ( + repo_root + / "joinquant/strategies/strategy-003/research/project-run.json" + ).read_text(encoding="utf-8") + ) + + assert set(config) == { + "schema_version", + "project_id", + "strategy", + "snapshot_id", + "snapshot_requirements", + "scenario_config", + "declared_inputs", + } + assert config["schema_version"] == 2 + assert config["project_id"] == "strategy-003" + assert config["strategy"] == { + "root": "joinquant/strategies/strategy-003/research", + "module": "turtle_etf.strategy", + "symbol": "MODULE", + } + assert config["scenario_config"] == ( + "joinquant/strategies/strategy-003/research/baseline.json" + ) + assert config["declared_inputs"] == [ + "joinquant/strategies/strategy-003/manifest.json" + ] + assert config["snapshot_requirements"] + assert _LEGACY_CONFIG_FIELDS.isdisjoint(config) + + +def test_obsolete_strategy_and_runner_paths_are_physically_absent( + repo_root: Path, +) -> None: + strategy_root = ( + repo_root / "joinquant/strategies/strategy-003/research/turtle_etf" + ) + assert not [ + name for name in _LEGACY_STRATEGY_FILES if (strategy_root / name).exists() + ] + assert not ( + repo_root / "scripts/research/local_quant_research/adapter_guard.py" + ).exists() + assert not ( + repo_root / "joinquant/strategies/strategy-003/research/code-identity.json" + ).exists() + + +def test_runner_contains_only_fixed_v2_execution_path(repo_root: Path) -> None: + path = repo_root / "scripts/research/local_quant_research/runner.py" + source = path.read_text(encoding="utf-8") + tree = ast.parse(source) + definitions = { + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.ClassDef, ast.FunctionDef)) + } + + assert { + "_FrozenExecutionInputs", + "_legacy_load_run_config", + "_legacy_run_project", + }.isdisjoint(definitions) + assert "config.command" not in source + assert "adapter_guard" not in source + + +def test_vectorbt_import_is_owned_only_by_shared_runtime(repo_root: Path) -> None: + roots = ( + repo_root / "scripts/research/local_quant_research", + repo_root / "joinquant/strategies/strategy-003/research/turtle_etf", + ) + matches: list[str] = [] + for root in roots: + for path in root.glob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + module = None + if isinstance(node, ast.Import): + module = node.names[0].name + elif isinstance(node, ast.ImportFrom) and node.level == 0: + module = node.module + if module and module.startswith("vectorbt"): + matches.append(path.relative_to(repo_root).as_posix()) + + assert sorted(set(matches)) == [ + "scripts/research/local_quant_research/vectorbt_runtime.py" + ] diff --git a/tests/local_quant_research/test_result_package.py b/tests/local_quant_research/test_result_package.py new file mode 100644 index 0000000..7f463cf --- /dev/null +++ b/tests/local_quant_research/test_result_package.py @@ -0,0 +1,1193 @@ +from __future__ import annotations + +import hashlib +import json +import os +import time +from collections import Counter +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from typing import Mapping + +import numpy as np +import pyarrow as pa +import pyarrow.parquet as pq +import pytest + +from scripts.research.local_quant_research.contracts import ( + ExecutionBundle, + ExecutionRun, + ResultExtension, +) +from scripts.research.local_quant_research.result_package import ( + ResultContractError, + ResultPackageRequest, + validate_result_package, + write_result_package, +) + + +FORBIDDEN_REPORT_PHRASES = ("推荐", "稳健性通过", "适合实盘", "实盘准入") + + +def _values() -> np.ndarray: + return np.array( + [ + ("2026-01-05 16:00:00", 1_000.0, 0.0, np.nan), + ("2026-01-06 16:00:00", 1_050.0, 0.05, np.nan), + ], + dtype=[ + ("time", "U19"), + ("total_value", "f8"), + ("returns", "f8"), + ("benchmark_returns", "f8"), + ], + ) + + +def _cash() -> np.ndarray: + return np.array( + [ + ("2026-01-05 16:00:00", 400.0), + ("2026-01-06 16:00:00", 390.0), + ], + dtype=[("time", "U19"), ("cash", "f8")], + ) + + +def _assets() -> np.ndarray: + dtype = [ + ("pindex", "i8"), + ("avg_cost", "f8"), + ("margin", "f8"), + ("amount", "f8"), + ("today_amount", "i8"), + ("hold_cost", "f8"), + ("side", "U8"), + ("price", "f8"), + ("gains", "f8"), + ("daily_gains", "f8"), + ("closeable_amount", "i8"), + ("time", "U19"), + ("security_name", "U16"), + ("security", "U16"), + ] + return np.array( + [ + ( + 0, + 90.0, + 0.0, + 6.0, + 0, + 90.0, + "long", + 100.0, + 60.0, + 0.0, + 6, + "2026-01-05 16:00:00", + "ETF-A", + "ETF-A", + ), + ( + 0, + 90.0, + 0.0, + 6.0, + 0, + 90.0, + "long", + 110.0, + 120.0, + 60.0, + 6, + "2026-01-06 16:00:00", + "ETF-A", + "ETF-A", + ), + ], + dtype=dtype, + ) + + +def _orders() -> np.ndarray: + dtype = [ + ("match_time", "O"), + ("pindex", "i8"), + ("cancel_time", "O"), + ("action", "U8"), + ("limit_price", "f8"), + ("comment", "U16"), + ("entrust_time", "U19"), + ("finish_time", "O"), + ("side", "U8"), + ("price", "f8"), + ("commission", "f8"), + ("gains", "f8"), + ("type", "U8"), + ("time", "U19"), + ("security_name", "U16"), + ("security", "U16"), + ("filled", "i8"), + ("amount", "i8"), + ("status", "U8"), + ] + return np.array( + [ + ( + "2026-01-06 09:30:00", + 0, + None, + "open", + 0.0, + "", + "2026-01-06 09:30:00", + "2026-01-06 09:30:00", + "long", + 100.0, + 1.0, + 0.0, + "market", + "2026-01-06 09:30:00", + "ETF-A", + "ETF-A", + 1, + 1, + "done", + ) + ], + dtype=dtype, + ) + + +class CountingLedger: + def __init__( + self, + *, + orders: np.ndarray | None = None, + assets: np.ndarray | None = None, + cash: np.ndarray | None = None, + value: np.ndarray | None = None, + ) -> None: + self._orders = _orders() if orders is None else orders + self._assets = _assets() if assets is None else assets + self._cash = _cash() if cash is None else cash + self._value = _values() if value is None else value + self.calls = {"orders": 0, "assets": 0, "cash": 0, "value": 0} + + @property + def orders(self) -> np.ndarray: + self.calls["orders"] += 1 + return self._orders + + @property + def assets(self) -> np.ndarray: + self.calls["assets"] += 1 + return self._assets + + @property + def cash(self) -> np.ndarray: + self.calls["cash"] += 1 + return self._cash + + @property + def value(self) -> np.ndarray: + self.calls["value"] += 1 + return self._value + + @property + def trades(self) -> np.ndarray: + raise AssertionError("result writer must not read trades") + + @property + def positions(self) -> np.ndarray: + raise AssertionError("result writer must not read positions") + + @property + def returns(self) -> np.ndarray: + raise AssertionError("result writer must not read returns") + + +def _execution(ledger: CountingLedger) -> ExecutionBundle: + run = ExecutionRun(ledger=ledger, trace={}) + return ExecutionBundle(primary=run, final=run, stages=("primary",)) + + +@pytest.fixture +def counting_ledger() -> CountingLedger: + return CountingLedger() + + +@pytest.fixture +def package_request( + tmp_path: Path, counting_ledger: CountingLedger +) -> ResultPackageRequest: + code = tmp_path / "strategy.py" + code.write_text("VALUE = 1\n", encoding="utf-8") + extension = ResultExtension( + name="decision_log", + schema_version="decision-log/1", + table=pa.table( + { + "time": ["2026-01-06 09:30:00"], + "event_id": ["event-1"], + "score": pa.array([0.75], type=pa.float64()), + } + ), + unique_key=("event_id",), + evidence={"source": "strategy"}, + ) + return ResultPackageRequest( + strategy_id="strategy-003", + scenario_id="baseline", + run_id="run-1", + output_dir=tmp_path / "result", + execution=_execution(counting_ledger), + extensions=(extension,), + code_files={"strategy.py": code}, + config_documents={ + "scenario.json": { + "scenario_id": "baseline", + "parameters": {"lookback": 20}, + }, + "project-run.json": {"schema_version": 2}, + "code-identity.json": {"digest": "a" * 64}, + }, + evidence_documents={ + "market-snapshot.json": {"snapshot_id": "b" * 64}, + "runtime-lock.json": {"python": "3.12"}, + "performance.json": {"status": "pass", "elapsed_seconds": 0.25}, + "environment.json": {"platform": "windows"}, + }, + ) + + +def _tree_digest(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(item for item in root.rglob("*") if item.is_file()): + digest.update(path.relative_to(root).as_posix().encode("utf-8")) + digest.update(hashlib.sha256(path.read_bytes()).digest()) + return digest.hexdigest() + + +def _assert_zstd(path: Path) -> None: + metadata = pq.ParquetFile(path).metadata + if metadata.num_row_groups == 0: + return + for row_group in range(metadata.num_row_groups): + for column in range(metadata.num_columns): + assert metadata.row_group(row_group).column(column).compression == "ZSTD" + + +def _write_manifest(path: Path, document: Mapping[str, object]) -> None: + path.write_text( + json.dumps(document, ensure_ascii=False, sort_keys=True), encoding="utf-8" + ) + + +def _point_reference_at( + root: Path, + reference: dict[str, object], + source: Path, + relative: str, +) -> None: + destination = root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(source.read_bytes()) + reference["path"] = relative + reference["sha256"] = hashlib.sha256(destination.read_bytes()).hexdigest() + reference["bytes"] = destination.stat().st_size + + +def _replace_persisted_extension_table(package: Path, table: pa.Table) -> None: + """Keep the disk declaration self-consistent up to logical package identity.""" + from scripts.research.local_quant_research import result_package + + data_path = package / "extensions/decision_log/data.parquet" + pq.write_table(table, data_path, compression="zstd") + persisted = pq.read_table(data_path) + manifest_path = package / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + entry = manifest["extensions"]["decision_log"] + reference = entry["files"][0] + reference["sha256"] = hashlib.sha256(data_path.read_bytes()).hexdigest() + reference["bytes"] = data_path.stat().st_size + reference["rows"] = table.num_rows + entry["rows"] = table.num_rows + entry["verified_empty"] = table.num_rows == 0 + entry["schema"] = result_package._schema_document(persisted.schema) + entry["time_range"] = result_package._time_range(persisted) + entry["evidence"]["fields"] = persisted.schema.names + _write_manifest(manifest_path, manifest) + + +def test_writer_materializes_one_package_without_recomputing_ledger( + package_request: ResultPackageRequest, + counting_ledger: CountingLedger, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import result_package + + writes: list[tuple[str, str | None]] = [] + real_write = result_package.pq.write_table + + def recording_write( + table: pa.Table, path: Path, *, compression: str | None = None + ) -> None: + writes.append((Path(path).name, compression)) + real_write(table, path, compression=compression) + + monkeypatch.setattr(result_package.pq, "write_table", recording_write) + package = write_result_package(package_request) + manifest = validate_result_package(package.path) + + assert set(manifest["datasets"]) == {"results", "balances", "positions", "orders"} + assert counting_ledger.calls == {"orders": 1, "assets": 1, "cash": 1, "value": 1} + assert writes == [ + ("results.parquet", "zstd"), + ("balances.parquet", "zstd"), + ("positions.parquet", "zstd"), + ("orders.parquet", "zstd"), + ("data.parquet", "zstd"), + ] + assert set(package.writer_stages) == { + "core_facts", + "parquet_materialize", + "readback_validate", + "report_and_manifest", + } + assert all(seconds > 0 for seconds in package.writer_stages.values()) + performance = json.loads( + (package.path / "evidence/performance.json").read_text(encoding="utf-8") + ) + for name in ("core_facts", "parquet_materialize", "readback_validate"): + assert performance["stages"][name] == package.writer_stages[name] + assert ( + performance["stages"]["report_and_manifest"] + <= package.writer_stages["report_and_manifest"] + ) + assert len(package.package_sha256) == 64 + for name in manifest["datasets"]: + reference = manifest["datasets"][name]["files"][0] + path = package.path / reference["path"] + assert hashlib.sha256(path.read_bytes()).hexdigest() == reference["sha256"] + _assert_zstd(path) + extension_ref = manifest["extensions"]["decision_log"]["files"][0] + _assert_zstd(package.path / extension_ref["path"]) + report = (package.path / "report/execution-summary.md").read_text( + encoding="utf-8" + ) + assert not any(phrase in report for phrase in FORBIDDEN_REPORT_PHRASES) + + +def test_writer_freezes_code_before_digest_and_copy( + package_request: ResultPackageRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = next(iter(package_request.code_files.values())).resolve() + original = source.read_bytes() + replacement = b"VALUE = 2\n" + real_read_bytes = Path.read_bytes + source_reads = 0 + + def changing_read_bytes(path: Path) -> bytes: + nonlocal source_reads + payload = real_read_bytes(path) + if path.resolve() == source: + source_reads += 1 + if source_reads == 1: + path.write_bytes(replacement) + return payload + + monkeypatch.setattr(Path, "read_bytes", changing_read_bytes) + + package = write_result_package(package_request) + + assert source_reads == 1 + assert source.read_bytes() == replacement + assert (package.path / "code/strategy.py").read_bytes() == original + assert validate_result_package(package.path)["package_sha256"] == package.package_sha256 + + +def test_writer_rejects_forbidden_report_before_publish( + package_request: ResultPackageRequest, +) -> None: + config = dict(package_request.config_documents) + config["scenario.json"] = { + "scenario_id": "baseline", + "parameters": {"conclusion": "推荐"}, + } + + with pytest.raises(ResultContractError, match="forbidden"): + write_result_package(replace(package_request, config_documents=config)) + + assert not package_request.output_dir.exists() + assert list(package_request.output_dir.parent.glob(".run-1.*.tmp")) == [] + + +def test_writer_reads_each_materialized_parquet_table_only_once( + package_request: ResultPackageRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import result_package + + reads: Counter[Path] = Counter() + real_read = result_package.pq.read_table + + def recording_read(path: Path, *args: object, **kwargs: object) -> pa.Table: + reads[Path(path).resolve()] += 1 + return real_read(path, *args, **kwargs) + + monkeypatch.setattr(result_package.pq, "read_table", recording_read) + write_result_package(package_request) + + read_ids = Counter( + ( + "extensions/decision_log/data.parquet" + if path.name == "data.parquet" + else f"data/{path.name}" + ) + for path, count in reads.items() + for _ in range(count) + ) + assert read_ids == Counter( + { + "data/results.parquet": 1, + "data/balances.parquet": 1, + "data/positions.parquet": 1, + "data/orders.parquet": 1, + "extensions/decision_log/data.parquet": 1, + } + ) + + +def test_writer_uses_its_single_readback_without_calling_disk_validator( + package_request: ResultPackageRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import result_package + + def fail_validator(*_args: object, **_kwargs: object) -> None: + pytest.fail("writer must not call the public disk validator") + + monkeypatch.setattr(result_package, "validate_result_package", fail_validator) + monkeypatch.setattr(result_package, "_validate_result_package_document", fail_validator) + + write_result_package(package_request) + + +def test_writer_distinguishes_persisted_prefinalization_from_returned_full_duration( + package_request: ResultPackageRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import result_package + + delay_seconds = 0.05 + real_write_documents = result_package._write_documents + + def delayed_write_documents( + root: Path, + directory: str, + documents: Mapping[str, object], + ) -> dict[str, dict[str, object]]: + if directory == "evidence": + time.sleep(delay_seconds) + return real_write_documents(root, directory, documents) + + monkeypatch.setattr(result_package, "_write_documents", delayed_write_documents) + package = write_result_package(package_request) + performance = json.loads( + (package.path / "evidence/performance.json").read_text(encoding="utf-8") + ) + + writer = performance["writer"] + assert set(writer) == {"prefinalization_seconds"} + assert writer["prefinalization_seconds"] > 0 + assert package.writer_seconds - writer["prefinalization_seconds"] >= ( + delay_seconds * 0.8 + ) + assert package.writer_stages["report_and_manifest"] >= delay_seconds * 0.8 + assert performance["measurement_scope"] == { + "actual_gate_basis": "returned_writer_seconds_through_writer_return", + "persisted_writer_measurement": "writer_start_through_prefinalization_before_final_evidence_report_manifest_write", + "persisted_measurement_excludes": "final_evidence_report_manifest_write_and_atomic_publish", + } + + +def test_scenario_gate_uses_writer_duration_through_delayed_return( + package_request: ResultPackageRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import performance, scenario + from scripts.research.local_quant_research.performance import ( + PerformanceEvidence, + PerformanceGateError, + PerformanceSample, + ) + from scripts.research.local_quant_research.result_package import ResultPackage + + code_path = next(iter(package_request.code_files.values())) + loaded_strategy = SimpleNamespace( + root=code_path.parent, + source_paths=(code_path,), + descriptor=SimpleNamespace(strategy_id=package_request.strategy_id), + ) + warm = SimpleNamespace( + execution=package_request.execution, + extensions=package_request.extensions, + stages={}, + ) + samples = PerformanceEvidence( + cold=PerformanceSample("cold", 0.0, "same"), + warm=PerformanceSample("warm", 0.0, "same"), + ) + monkeypatch.setattr( + scenario, + "run_cold_warm", + lambda _operation, *, digest: (warm, samples), + ) + + def delayed_writer(_request: ResultPackageRequest) -> ResultPackage: + started = time.perf_counter() + time.sleep(0.05) + return ResultPackage( + path=package_request.output_dir, + manifest={}, + package_sha256="a" * 64, + writer_stages={}, + writer_seconds=time.perf_counter() - started, + ) + + monkeypatch.setattr(scenario, "write_result_package", delayed_writer) + monkeypatch.setattr(performance, "PERFORMANCE_LIMIT_SECONDS", 0.04) + request = scenario.ScenarioRequest( + loaded_strategy=loaded_strategy, + snapshot=SimpleNamespace(), + scenario={"scenario_id": package_request.scenario_id}, + project_document={}, + run_id=package_request.run_id, + output_dir=package_request.output_dir, + code_identity={}, + market_snapshot={}, + runtime_lock={}, + environment={}, + ) + + with pytest.raises(PerformanceGateError) as caught: + scenario.execute_scenario(request) + + assert caught.value.code == "cold_performance_limit" + + +def test_validator_summarizes_each_core_table_once( + package_request: ResultPackageRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import result_package + + package = write_result_package(package_request) + names_by_fields = { + tuple(schema.names): name for name, schema in result_package._SCHEMAS.items() + } + summaries: Counter[str] = Counter() + reads: Counter[Path] = Counter() + real_summary = result_package._table_summary + real_read = result_package.pq.read_table + + def counting_summary(table: pa.Table) -> dict[str, object]: + name = names_by_fields.get(tuple(table.schema.names)) + if name is not None: + summaries[name] += 1 + return real_summary(table) + + def counting_read(path: Path, *args: object, **kwargs: object) -> pa.Table: + reads[Path(path).resolve()] += 1 + return real_read(path, *args, **kwargs) + + monkeypatch.setattr(result_package, "_table_summary", counting_summary) + monkeypatch.setattr(result_package.pq, "read_table", counting_read) + + validate_result_package(package.path) + + assert summaries == Counter({name: 1 for name in result_package.CORE_DATASETS}) + assert reads == Counter( + { + (package.path / f"data/{name}.parquet").resolve(): 1 + for name in result_package.CORE_DATASETS + } + | { + (package.path / "extensions/decision_log/data.parquet").resolve(): 1 + } + ) + + +def test_writer_reuse_summarizes_new_and_existing_core_once_each( + package_request: ResultPackageRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from scripts.research.local_quant_research import result_package + + write_result_package(package_request) + names_by_fields = { + tuple(schema.names): name for name, schema in result_package._SCHEMAS.items() + } + summaries: Counter[str] = Counter() + real_summary = result_package._table_summary + + def counting_summary(table: pa.Table) -> dict[str, object]: + name = names_by_fields.get(tuple(table.schema.names)) + if name is not None: + summaries[name] += 1 + return real_summary(table) + + monkeypatch.setattr(result_package, "_table_summary", counting_summary) + + reused = write_result_package( + replace(package_request, execution=_execution(CountingLedger())) + ) + + assert reused.path == package_request.output_dir.resolve() + assert summaries == Counter({name: 2 for name in result_package.CORE_DATASETS}) + + +def test_execution_report_contains_only_reproducible_package_facts( + package_request: ResultPackageRequest, +) -> None: + package = write_result_package(package_request) + report = (package.path / "report/execution-summary.md").read_text( + encoding="utf-8" + ) + for heading in ( + "## 参数与配置", + "## 时间范围", + "## 成交摘要", + "## 持仓摘要", + "## 净值摘要", + "## 性能", + "## 完整性门禁", + ): + assert heading in report + assert '"lookback": 20' in report + assert "2026-01-05" in report and "2026-01-06" in report + assert "订单记录:1" in report + assert "成交数量:1" in report + assert "最新持仓数量:1" in report + assert "最新持仓市值:660.000000" in report + assert "起始总资产:1000.000000" in report + assert "结束总资产:1050.000000" in report + assert '"elapsed_seconds": 0.25' in report + assert "状态:`pass`" in report + assert not any(phrase in report for phrase in FORBIDDEN_REPORT_PHRASES) + + metrics = json.loads( + (package.path / "report/metrics.json").read_text(encoding="utf-8") + ) + assert metrics["parameters"] == {"lookback": 20} + assert metrics["time_range"] == {"start": "2026-01-05", "end": "2026-01-06"} + assert metrics["orders"] == { + "records": 1, + "requested_amount": 1, + "filled_amount": 1, + "commission": 1.0, + } + assert metrics["positions"]["latest_records"] == 1 + assert metrics["positions"]["latest_market_value"] == 660.0 + assert metrics["net_value"]["start_total_value"] == 1000.0 + assert metrics["net_value"]["end_total_value"] == 1050.0 + assert metrics["performance"]["status"] == "pass" + assert metrics["performance"]["elapsed_seconds"] == 0.25 + assert set(metrics["performance"]["stages"]) == { + "core_facts", + "parquet_materialize", + "readback_validate", + "report_and_manifest", + } + assert metrics["integrity_gate"]["status"] == "pass" + + +def test_writer_cleans_only_its_staging_directory_when_readback_fails( + package_request: ResultPackageRequest, monkeypatch: pytest.MonkeyPatch +) -> None: + from scripts.research.local_quant_research import result_package + + sibling = package_request.output_dir.parent / "keep-me" + sibling.mkdir() + + def fail_readback(*_args: object, **_kwargs: object) -> pa.Table: + raise OSError("injected readback failure") + + monkeypatch.setattr(result_package.pq, "read_table", fail_readback) + with pytest.raises(ResultContractError, match="readback"): + write_result_package(package_request) + + assert sibling.is_dir() + assert not package_request.output_dir.exists() + assert list(package_request.output_dir.parent.glob(".run-1.*.tmp")) == [] + + +def test_writer_reuses_equal_package_and_rejects_digest_conflict( + package_request: ResultPackageRequest, +) -> None: + first = write_result_package(package_request) + before = _tree_digest(first.path) + + same_ledger = CountingLedger() + same = write_result_package( + replace(package_request, execution=_execution(same_ledger)) + ) + assert same.package_sha256 == first.package_sha256 + assert _tree_digest(first.path) == before + assert same_ledger.calls == {"orders": 1, "assets": 1, "cash": 1, "value": 1} + + conflicting_ledger = CountingLedger() + with pytest.raises(ResultContractError, match="conflict"): + write_result_package( + replace( + package_request, + scenario_id="other", + execution=_execution(conflicting_ledger), + ) + ) + assert _tree_digest(first.path) == before + + +def test_validator_rejects_manifest_scenario_different_from_frozen_config( + package_request: ResultPackageRequest, +) -> None: + mismatched = replace( + package_request, + scenario_id="different-scenario", + output_dir=package_request.output_dir.parent / "scenario-mismatch", + ) + + with pytest.raises(ResultContractError, match="scenario identity"): + write_result_package(mismatched) + + assert not mismatched.output_dir.exists() + + +def test_writer_refuses_reuse_when_report_and_manifest_reference_are_tampered( + package_request: ResultPackageRequest, +) -> None: + package = write_result_package(package_request) + manifest_path = package.path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + summary = package.path / "report/execution-summary.md" + metrics = package.path / "report/metrics.json" + summary.write_text( + summary.read_text(encoding="utf-8") + "\n外部替换内容\n", + encoding="utf-8", + ) + metrics_document = json.loads(metrics.read_text(encoding="utf-8")) + metrics_document["external"] = True + metrics.write_text( + json.dumps(metrics_document, ensure_ascii=False, sort_keys=True), + encoding="utf-8", + ) + for name, path in (("execution-summary", summary), ("metrics", metrics)): + reference = manifest["reports"][name] + reference["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest() + reference["bytes"] = path.stat().st_size + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, sort_keys=True), encoding="utf-8" + ) + + with pytest.raises(ResultContractError, match="report"): + write_result_package( + replace(package_request, execution=_execution(CountingLedger())) + ) + + +def test_writer_atomically_publishes_only_after_staging_is_complete( + package_request: ResultPackageRequest, monkeypatch: pytest.MonkeyPatch +) -> None: + from scripts.research.local_quant_research import result_package + + observed: list[tuple[Path, Path]] = [] + real_replace = os.replace + + def observing_replace(source: Path, target: Path) -> None: + source_path = Path(source) + target_path = Path(target) + assert source_path.name.startswith(".run-1.") + assert source_path.name.endswith(".tmp") + assert (source_path / "manifest.json").is_file() + assert (source_path / "report/execution-summary.md").is_file() + assert set(path.name for path in (source_path / "data").iterdir()) == { + "results.parquet", + "balances.parquet", + "positions.parquet", + "orders.parquet", + } + assert not target_path.exists() + observed.append((source_path, target_path)) + real_replace(source_path, target_path) + + monkeypatch.setattr(result_package.os, "replace", observing_replace) + package = write_result_package(package_request) + + assert observed == [(observed[0][0], package.path)] + assert package.path.is_dir() + + +@pytest.mark.parametrize("name", ["", "BadName", "bad/name", "-leading"]) +def test_writer_rejects_invalid_extension_name_before_reading_ledger( + package_request: ResultPackageRequest, + counting_ledger: CountingLedger, + name: str, +) -> None: + invalid = replace(package_request.extensions[0], name=name) + with pytest.raises(ResultContractError, match="extension name"): + write_result_package(replace(package_request, extensions=(invalid,))) + assert counting_ledger.calls == {"orders": 0, "assets": 0, "cash": 0, "value": 0} + + +@pytest.mark.parametrize( + "run_id", + [ + "C:foo", + "/absolute", + "\\anchored", + "../escape", + "nested/run", + "nested\\run", + "run:ads", + "CON", + "trailing.", + "trailing ", + ], +) +def test_writer_rejects_unsafe_run_id_before_reading_or_writing( + package_request: ResultPackageRequest, + counting_ledger: CountingLedger, + tmp_path: Path, + run_id: str, +) -> None: + marker = tmp_path / "outside-marker.txt" + marker.write_text("unchanged", encoding="utf-8") + output_dir = tmp_path / "publish" / "result" + + with pytest.raises(ResultContractError, match="run_id"): + write_result_package( + replace(package_request, run_id=run_id, output_dir=output_dir) + ) + + assert counting_ledger.calls == {"orders": 0, "assets": 0, "cash": 0, "value": 0} + assert marker.read_text(encoding="utf-8") == "unchanged" + assert not output_dir.parent.exists() + + +def test_writer_rejects_blank_scenario_identity_before_reading_or_writing( + package_request: ResultPackageRequest, + counting_ledger: CountingLedger, +) -> None: + config = dict(package_request.config_documents) + config["scenario.json"] = {"scenario_id": " "} + + with pytest.raises(ResultContractError, match="identity"): + write_result_package( + replace( + package_request, + scenario_id=" ", + config_documents=config, + ) + ) + + assert counting_ledger.calls == {"orders": 0, "assets": 0, "cash": 0, "value": 0} + + +@pytest.mark.parametrize( + ("field", "unsafe_name"), + [ + ("code_files", "C:escape.py"), + ("code_files", "/escape.py"), + ("code_files", "../escape.py"), + ("code_files", "strategy.py:ads"), + ("code_files", "CON"), + ("config_documents", "C:scenario"), + ("config_documents", "scenario:ads"), + ("config_documents", "AUX"), + ("evidence_documents", "C:environment"), + ("evidence_documents", "environment:ads"), + ("evidence_documents", "NUL"), + ], +) +def test_writer_rejects_unsafe_package_paths_before_reading_or_writing( + package_request: ResultPackageRequest, + counting_ledger: CountingLedger, + tmp_path: Path, + field: str, + unsafe_name: str, +) -> None: + marker = tmp_path / "outside-marker.txt" + marker.write_text("unchanged", encoding="utf-8") + output_dir = tmp_path / "publish" / "result" + replacement: dict[str, object] + if field == "code_files": + replacement = { + **package_request.code_files, + unsafe_name: next(iter(package_request.code_files.values())), + } + elif field == "config_documents": + replacement = {**package_request.config_documents, unsafe_name: {}} + else: + replacement = {**package_request.evidence_documents, unsafe_name: {}} + + with pytest.raises(ResultContractError, match="unsafe"): + write_result_package( + replace(package_request, output_dir=output_dir, **{field: replacement}) + ) + + assert counting_ledger.calls == {"orders": 0, "assets": 0, "cash": 0, "value": 0} + assert marker.read_text(encoding="utf-8") == "unchanged" + assert not output_dir.parent.exists() + + +@pytest.mark.parametrize( + ("field", "missing_name"), + [ + ("code_files", None), + ("config_documents", "scenario.json"), + ("config_documents", "project-run.json"), + ("config_documents", "code-identity.json"), + ("evidence_documents", "market-snapshot.json"), + ("evidence_documents", "runtime-lock.json"), + ("evidence_documents", "performance.json"), + ("evidence_documents", "environment.json"), + ], +) +def test_writer_requires_archive_ready_inputs_before_reading_or_writing( + package_request: ResultPackageRequest, + counting_ledger: CountingLedger, + tmp_path: Path, + field: str, + missing_name: str | None, +) -> None: + output_dir = tmp_path / "publish" / "result" + replacement = ( + {} + if field == "code_files" + else { + name: value + for name, value in getattr(package_request, field).items() + if name != missing_name + } + ) + + with pytest.raises(ResultContractError, match="archive-ready"): + write_result_package( + replace(package_request, output_dir=output_dir, **{field: replacement}) + ) + + assert counting_ledger.calls == {"orders": 0, "assets": 0, "cash": 0, "value": 0} + assert not output_dir.parent.exists() + + +def test_writer_rejects_duplicate_extension_names_and_keys( + package_request: ResultPackageRequest, + counting_ledger: CountingLedger, +) -> None: + duplicate_name = replace( + package_request.extensions[0], schema_version="decision-log/2" + ) + with pytest.raises(ResultContractError, match="unique"): + write_result_package( + replace( + package_request, + extensions=(package_request.extensions[0], duplicate_name), + ) + ) + + duplicate_key = replace( + package_request.extensions[0], + table=pa.table( + { + "time": ["2026-01-05", "2026-01-06"], + "event_id": ["same", "same"], + "score": [0.1, 0.2], + } + ), + ) + with pytest.raises(ResultContractError, match="unique key"): + write_result_package(replace(package_request, extensions=(duplicate_key,))) + assert counting_ledger.calls == {"orders": 0, "assets": 0, "cash": 0, "value": 0} + + +def test_writer_rejects_cross_table_reconciliation_and_cleans_staging( + package_request: ResultPackageRequest, +) -> None: + broken_assets = _assets().copy() + broken_assets[1]["price"] = 109.0 + ledger = CountingLedger(assets=broken_assets) + + with pytest.raises(ResultContractError, match="reconcile"): + write_result_package( + replace(package_request, execution=_execution(ledger)) + ) + + assert ledger.calls == {"orders": 1, "assets": 1, "cash": 1, "value": 1} + assert not package_request.output_dir.exists() + assert list(package_request.output_dir.parent.glob(".run-1.*.tmp")) == [] + + +def test_validator_rejects_tampered_materialized_table( + package_request: ResultPackageRequest, +) -> None: + package = write_result_package(package_request) + results = package.path / "data/results.parquet" + results.write_bytes(results.read_bytes() + b"tampered") + + with pytest.raises(ResultContractError, match="digest"): + validate_result_package(package.path) + + +@pytest.mark.parametrize( + "invalid_value", + ( + pa.array([["nested"]], type=pa.list_(pa.string())), + pa.array([1], type=pa.int32()), + ), +) +def test_disk_validator_rejects_extension_types_outside_flat_contract( + package_request: ResultPackageRequest, + invalid_value: pa.Array, +) -> None: + package = write_result_package(package_request) + _replace_persisted_extension_table( + package.path, + pa.table({"event_id": ["event-1"], "value": invalid_value}), + ) + + with pytest.raises(ResultContractError, match="flat string/bool/int64/float64"): + validate_result_package(package.path) + + +def test_validator_rejects_non_zstd_physical_compression_with_synced_digest( + package_request: ResultPackageRequest, +) -> None: + package = write_result_package(package_request) + results = package.path / "data/results.parquet" + table = pq.read_table(results) + pq.write_table(table, results, compression="snappy") + manifest_path = package.path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + reference = manifest["datasets"]["results"]["files"][0] + reference["sha256"] = hashlib.sha256(results.read_bytes()).hexdigest() + reference["bytes"] = results.stat().st_size + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=False, sort_keys=True), encoding="utf-8" + ) + + with pytest.raises(ResultContractError, match="physical compression"): + validate_result_package(package.path) + + +@pytest.mark.parametrize( + "relative", + [ + "C:scenario.json", + "/absolute/scenario.json", + "../escape/scenario.json", + "config/scenario.json:ads", + ], +) +def test_validator_rejects_unsafe_declared_paths( + package_request: ResultPackageRequest, + relative: str, +) -> None: + package = write_result_package(package_request) + manifest_path = package.path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + reference = manifest["config"]["scenario.json"] + if relative.endswith(":ads"): + source = package.path / str(reference["path"]) + _point_reference_at(package.path, reference, source, relative) + else: + reference["path"] = relative + _write_manifest(manifest_path, manifest) + + with pytest.raises(ResultContractError, match="unsafe"): + validate_result_package(package.path) + + +@pytest.mark.parametrize( + ("section", "name"), + [ + ("code", "strategy.py"), + ("config", "scenario.json"), + ("config", "project-run.json"), + ("config", "code-identity.json"), + ("evidence", "market-snapshot.json"), + ("evidence", "runtime-lock.json"), + ("evidence", "performance.json"), + ("evidence", "environment.json"), + ], +) +def test_validator_requires_archive_ready_file_sets( + package_request: ResultPackageRequest, + section: str, + name: str, +) -> None: + package = write_result_package(package_request) + manifest_path = package.path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + del manifest[section][name] + _write_manifest(manifest_path, manifest) + + with pytest.raises(ResultContractError, match="archive-ready"): + validate_result_package(package.path) + + +@pytest.mark.parametrize( + "declaration", + ["code", "config", "evidence", "dataset", "extension"], +) +def test_validator_requires_fixed_declared_file_paths( + package_request: ResultPackageRequest, + declaration: str, +) -> None: + package = write_result_package(package_request) + manifest_path = package.path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if declaration == "code": + reference = manifest["code"]["strategy.py"] + relative = "code/renamed-strategy.py" + elif declaration == "config": + reference = manifest["config"]["scenario.json"] + relative = "config/renamed-scenario.json" + elif declaration == "evidence": + reference = manifest["evidence"]["performance.json"] + relative = "evidence/renamed-performance.json" + elif declaration == "dataset": + reference = manifest["datasets"]["results"]["files"][0] + relative = "data/renamed-results.parquet" + else: + reference = manifest["extensions"]["decision_log"]["files"][0] + relative = "extensions/decision_log/renamed-data.parquet" + source = package.path / str(reference["path"]) + _point_reference_at(package.path, reference, source, relative) + _write_manifest(manifest_path, manifest) + + with pytest.raises(ResultContractError, match="file identity"): + validate_result_package(package.path) + + +@pytest.mark.parametrize("strategy_evidence", [None, [], "unverified"]) +def test_validator_requires_strategy_evidence_object( + package_request: ResultPackageRequest, + strategy_evidence: object, +) -> None: + package = write_result_package(package_request) + manifest_path = package.path / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + extension = manifest["extensions"]["decision_log"] + if strategy_evidence is None: + del extension["strategy_evidence"] + else: + extension["strategy_evidence"] = strategy_evidence + _write_manifest(manifest_path, manifest) + + with pytest.raises(ResultContractError, match="strategy_evidence"): + validate_result_package(package.path) diff --git a/tests/local_quant_research/test_runner.py b/tests/local_quant_research/test_runner.py index b7cf3b0..9f27040 100644 --- a/tests/local_quant_research/test_runner.py +++ b/tests/local_quant_research/test_runner.py @@ -1,23 +1,19 @@ from __future__ import annotations -import hashlib +import gc +import importlib +import inspect import json import os +import shutil import subprocess +import uuid +import weakref from pathlib import Path -from typing import Callable import pytest -import pyarrow as pa -import pyarrow.parquet as pq - -from scripts.research.local_quant_research.contracts import OutputSpec -from scripts.research.local_quant_research.evidence import ( - EvidenceError, - canonical_digest, - collect_output_evidence, -) -from scripts.research.local_quant_research.runner import _project_status, run_project + +from scripts.research.local_quant_research.evidence import EvidenceError from scripts.research.market_data.contracts import SnapshotSelection from scripts.research.market_data.storage import create_snapshot, import_batch @@ -37,45 +33,6 @@ "high_limit", "low_limit", ) -COMPLETE_STATUS = { - "schema_version": 1, - "status": "complete", - "reason_codes": [], - "next_action": "return_to_caller", -} - - -def test_complete_project_status_rejects_human_confirmation_next_action( - tmp_path: Path, -) -> None: - _write_json( - tmp_path / "project-status.json", - { - "schema_version": 1, - "status": "complete", - "reason_codes": [], - "next_action": "human_confirmation_required", - }, - ) - - with pytest.raises(EvidenceError, match="next_action"): - _project_status(tmp_path) - - -def test_complete_project_status_can_return_single_scenario_to_caller( - tmp_path: Path, -) -> None: - _write_json( - tmp_path / "project-status.json", - { - "schema_version": 1, - "status": "complete", - "reason_codes": [], - "next_action": "return_to_caller", - }, - ) - - assert _project_status(tmp_path) == ("complete", ()) def _write_json(path: Path, value: object) -> None: @@ -87,10 +44,6 @@ def _write_json(path: Path, value: object) -> None: ) -def _sha256(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - - def _build_repo(tmp_path: Path, source_repo: Path) -> tuple[Path, Path, dict[str, object]]: root = tmp_path / "repo" venv_python = root / ".venv" / "Scripts" / "python.exe" @@ -98,26 +51,35 @@ def _build_repo(tmp_path: Path, source_repo: Path) -> tuple[Path, Path, dict[str venv_python.write_bytes(b"test launcher") project_dir = root / "projects" / "generic-research" - entry = project_dir / "adapter.py" + strategy_module = f"strategy_{uuid.uuid4().hex}" + entry = project_dir / f"{strategy_module}.py" entry.parent.mkdir(parents=True) - entry.write_text("# generic adapter\n", encoding="utf-8") - project_config = project_dir / "project.json" - _write_json(project_config, {"schema_version": 1, "parameter": 7}) - declared_input = project_dir / "input.txt" - declared_input.write_text("declared input\n", encoding="utf-8") - code_identity = project_dir / "code-identity.json" + source_strategy = ( + source_repo + / "tests/local_quant_research/fixtures/minimal_strategy/strategy.py" + ).read_text(encoding="utf-8") + entry.write_text( + source_strategy.replace('strategy_id="minimal-fixture"', 'strategy_id="generic-research"'), + encoding="utf-8", + ) + project_config = project_dir / "scenario.json" _write_json( - code_identity, - { - "schema_version": 1, - "files": [ - { - "path": "projects/generic-research/adapter.py", - "sha256": _sha256(entry), - } - ], - }, + project_config, + {"schema_version": 1, "scenario_id": "baseline", "parameter": 7}, ) + declared_input = project_dir / "input.txt" + declared_input.write_text("declared input\n", encoding="utf-8") + for relative in ( + Path("scripts/research/local_quant_research"), + Path("scripts/research/market_data"), + ): + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(source_repo / relative, target) + for relative in (Path("scripts/__init__.py"), Path("scripts/research/__init__.py")): + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_repo / relative, target) fixture = ( source_repo @@ -161,677 +123,835 @@ def _build_repo(tmp_path: Path, source_repo: Path) -> tuple[Path, Path, dict[str root=market_root, ) config = { - "schema_version": 1, + "schema_version": 2, "project_id": "generic-research", + "strategy": { + "root": "projects/generic-research", + "module": strategy_module, + "symbol": "MODULE", + }, "snapshot_id": snapshot.snapshot_id, "snapshot_requirements": selection.to_document(), - "project_entry": "projects/generic-research/adapter.py", - "command": [ - ".venv/Scripts/python.exe", - "projects/generic-research/adapter.py", - ], - "project_config": "projects/generic-research/project.json", - "code_identity": "projects/generic-research/code-identity.json", + "scenario_config": "projects/generic-research/scenario.json", "declared_inputs": ["projects/generic-research/input.txt"], - "required_outputs": [{"path": "result.json", "format": "json"}], - "output_root": ".local/quant-research", - "stop_states": ["complete", "evidence_insufficient", "failed"], } config_path = project_dir / "run.json" _write_json(config_path, config) return root, config_path, config +LEGACY_RUN_FIELDS = ( + "command", + "project_entry", + "code_identity", + "required_outputs", + "output_root", + "stop_states", +) -def _output_dir(command: list[str]) -> Path: - return Path(command[command.index("--output-dir") + 1]) - - -def _successful_process( - assertions: Callable[[list[str], dict[str, object]], None] | None = None, -): - def fake_run(command: list[str], **kwargs): - if assertions: - assertions(command, kwargs) - output_dir = _output_dir(command) - _write_json( - output_dir / "project-status.json", - COMPLETE_STATUS, - ) - _write_json(output_dir / "result.json", {"answer": 42}) - return subprocess.CompletedProcess(command, 0, stdout="ignored", stderr="ignored") - - return fake_run +def _build_v2_config(repo: Path) -> tuple[Path, dict[str, object]]: + strategy_root = repo / "tests/local_quant_research/fixtures/minimal_strategy" + strategy_root.mkdir(parents=True, exist_ok=True) + (strategy_root / "strategy.py").write_text("MODULE = object()\n", encoding="utf-8") + scenario = repo / "tests/local_quant_research/minimal-scenario.json" + _write_json(scenario, {"schema_version": 1, "scenario_id": "baseline"}) + declared = repo / "tests/local_quant_research/declared.txt" + declared.write_text("declared\n", encoding="utf-8") + document: dict[str, object] = { + "schema_version": 2, + "project_id": "minimal-fixture", + "strategy": { + "root": "tests/local_quant_research/fixtures/minimal_strategy", + "module": "strategy", + "symbol": "MODULE", + }, + "snapshot_id": "a" * 64, + "snapshot_requirements": {}, + "scenario_config": "tests/local_quant_research/minimal-scenario.json", + "declared_inputs": ["tests/local_quant_research/declared.txt"], + } + config = repo / "tests/local_quant_research/run-v2.json" + _write_json(config, document) + return config, document -def test_required_output_accepts_valid_parquet_and_rejects_invalid_bytes( +def test_v2_config_has_only_strategy_snapshot_scenario_and_declared_inputs( tmp_path: Path, ) -> None: - path = tmp_path / "analysis.parquet" - pq.write_table(pa.table({"date": ["2026-01-05"], "value": [1.0]}), path) - spec = OutputSpec(path=path.name, format="parquet") - - evidence = collect_output_evidence(tmp_path, (spec,)) - - assert evidence[0]["format"] == "parquet" - path.write_bytes(b"not parquet") - with pytest.raises(Exception, match="Parquet"): - collect_output_evidence(tmp_path, (spec,)) + from scripts.research.local_quant_research import contracts, runner + + config_path, document = _build_v2_config(tmp_path) + + config = runner.load_run_config(config_path, repo_root=tmp_path) + + assert config == contracts.RunConfig( + project_id="minimal-fixture", + strategy_root=( + tmp_path / "tests/local_quant_research/fixtures/minimal_strategy" + ).resolve(), + strategy_module="strategy", + strategy_symbol="MODULE", + snapshot_id="a" * 64, + snapshot_requirements={}, + scenario_config=( + tmp_path / "tests/local_quant_research/minimal-scenario.json" + ).resolve(), + declared_inputs=( + (tmp_path / "tests/local_quant_research/declared.txt").resolve(), + ), + document=document, + ) + assert contracts.RUN_OUTPUT_ROOT == Path(".local/quant-research") + assert contracts.RUN_STATUSES == ( + "complete", + "evidence_insufficient", + "failed", + ) -def test_optional_benchmark_input_is_frozen_and_passed_to_project( - repo_root: Path, +@pytest.mark.parametrize("legacy_field", LEGACY_RUN_FIELDS) +def test_v2_config_rejects_each_legacy_run_field( + legacy_field: str, tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: - fake_root, config_path, config = _build_repo(tmp_path, repo_root) - benchmark = fake_root / ".local/market-data/benchmarks/input.parquet" - benchmark.parent.mkdir(parents=True) - pq.write_table(pa.table({"date": ["2026-01-05"], "return": [0.0]}), benchmark) - config["benchmark_input"] = benchmark.relative_to(fake_root).as_posix() - _write_json(config_path, config) - - def assert_invocation(command: list[str], _: dict[str, object]) -> None: - argument = Path(command[command.index("--benchmark-input") + 1]) - assert argument.is_file() - assert ".inputs" in argument.as_posix() - assert argument.read_bytes() == benchmark.read_bytes() + from scripts.research.local_quant_research import runner - monkeypatch.setattr(subprocess, "run", _successful_process(assert_invocation)) + config_path, document = _build_v2_config(tmp_path) + document[legacy_field] = [] + _write_json(config_path, document) - result = run_project(config_path, repo_root=fake_root) + with pytest.raises(runner.ConfigurationError) as caught: + runner.load_run_config(config_path, repo_root=tmp_path) - assert result.status == "complete" + assert caught.value.code == "legacy_run_field" -def test_project_execution_timeout_covers_complete_research_workflow( - repo_root: Path, +@pytest.mark.parametrize( + "scenario_id", + (None, "", " ", 7), + ids=("missing", "empty", "whitespace", "non-string"), +) +def test_v2_config_rejects_missing_scenario_id_before_strategy_execution( + scenario_id: object, tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: - fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - - def assert_invocation(_: list[str], kwargs: dict[str, object]) -> None: - assert kwargs["timeout"] >= 3_600 + from scripts.research.local_quant_research import runner - monkeypatch.setattr(subprocess, "run", _successful_process(assert_invocation)) + config_path, document = _build_v2_config(tmp_path) + scenario_path = tmp_path / str(document["scenario_config"]) + scenario_document = {"schema_version": 1} + if scenario_id is not None: + scenario_document["scenario_id"] = scenario_id + _write_json(scenario_path, scenario_document) - result = run_project(config_path, repo_root=fake_root) + with pytest.raises(runner.ConfigurationError) as caught: + runner.load_run_config(config_path, repo_root=tmp_path) - assert result.status == "complete" + assert caught.value.code == "missing_scenario_id" -@pytest.mark.parametrize( - "mutation", - [ - lambda config: config.update(command="python adapter.py"), - lambda config: config.update(project_entry="../outside.py"), - lambda config: config.pop("snapshot_id"), - lambda config: config.update(required_outputs=[]), - lambda config: config.update( - stop_states=["complete", "evidence_insufficient", "unknown"] - ), - lambda config: config.update( - command=["python.exe", "projects/generic-research/adapter.py"] - ), - lambda config: config.update( - command=[".venv/Scripts/python.exe", "-m", "pip", "install", "duckdb"] - ), - lambda config: config.update(api_token="must-not-appear"), - ], - ids=[ - "shell-string", - "outside-entry", - "missing-snapshot", - "missing-outputs", - "unknown-state", - "system-python", - "implicit-install", - "credential-field", - ], -) -def test_unsafe_or_incomplete_config_stops_before_project_process( - mutation, - repo_root: Path, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_root, config_path, config = _build_repo(tmp_path, repo_root) - mutation(config) - _write_json(config_path, config) - called = False - - def forbidden_run(*args, **kwargs): - nonlocal called - called = True - raise AssertionError("project process must not run") +def test_parent_generates_one_fixed_private_execute_command(tmp_path: Path) -> None: + from scripts.research.local_quant_research import runner - monkeypatch.setattr(subprocess, "run", forbidden_run) + execution_root = tmp_path / "inputs" + staging = tmp_path / "staging" - result = run_project(config_path, repo_root=fake_root) + command = runner._execute_command( + repo_root=tmp_path, + execution_root=execution_root, + staging=staging, + ) - assert result.status == "evidence_insufficient" - assert called is False - assert "must-not-appear" not in json.dumps(result.to_document()) + assert command == ( + tmp_path / ".venv/Scripts/python.exe", + tmp_path / "scripts/research/local_quant_research/cli.py", + "_execute", + "--frozen-inputs", + execution_root / "request.json", + "--staging", + staging, + ) -def test_missing_declared_input_is_evidence_insufficient_before_process( - repo_root: Path, +def test_parent_request_binds_project_run_attempt_and_derived_directories( tmp_path: Path, + repo_root: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - (fake_root / "projects" / "generic-research" / "input.txt").unlink() - monkeypatch.setattr( - subprocess, - "run", - lambda *args, **kwargs: pytest.fail("project process must not run"), - ) - - result = run_project(config_path, repo_root=fake_root) + from scripts.research.local_quant_research import runner - assert result.status == "evidence_insufficient" - assert result.run_path is None + fake_root, config_path, _ = _build_repo(tmp_path, repo_root) + def inspect_request(command: list[str], **_kwargs: object): + request_path = Path(command[command.index("--frozen-inputs") + 1]) + request = json.loads(request_path.read_text(encoding="utf-8")) + project_id = request["project_id"] + run_id = request["run_id"] + attempt_id = request["attempt_id"] + project_root = fake_root / ".local/quant-research" / project_id + assert request_path.parent == project_root / f".{run_id}.{attempt_id}.inputs" + assert Path(request["staging"]) == project_root / f".{run_id}.{attempt_id}.tmp" + assert Path(request["output_root"]) == fake_root / ".local/quant-research" + return subprocess.CompletedProcess( + command, + 1, + stdout='{"reasons":["stop_after_inspection"],"status":"failed"}', + stderr="", + ) -def test_missing_snapshot_and_tampered_snapshot_have_distinct_states( - repo_root: Path, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - missing_root, missing_config, _ = _build_repo(tmp_path / "missing", repo_root) - next((missing_root / ".local/market-data/snapshots").glob("*.json")).unlink() - tampered_root, tampered_config, _ = _build_repo(tmp_path / "tampered", repo_root) - market_parquet = next( - (tampered_root / ".local/market-data/batches").rglob("market-data.parquet") - ) - market_parquet.write_bytes(market_parquet.read_bytes() + b"tampered") - monkeypatch.setattr( - subprocess, - "run", - lambda *args, **kwargs: pytest.fail("invalid snapshot must stop before project"), - ) + monkeypatch.setattr(subprocess, "run", inspect_request) - missing = run_project(missing_config, repo_root=missing_root) - tampered = run_project(tampered_config, repo_root=tampered_root) + result = runner.run_project(config_path, repo_root=fake_root) - assert missing.status == "evidence_insufficient" - assert tampered.status == "failed" + assert result.status == "failed" -def test_project_can_report_evidence_insufficient_without_false_completion( - repo_root: Path, +def test_fixed_output_root_cannot_escape_repository_through_directory_link( tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: - fake_root, config_path, _ = _build_repo(tmp_path, repo_root) + from scripts.research.local_quant_research import runner - def process(command: list[str], **kwargs): - _write_json( - _output_dir(command) / "project-status.json", - { - "schema_version": 1, - "status": "evidence_insufficient", - "reason_codes": ["insufficient_domain_samples"], - }, + repository = tmp_path / "repository" + outside = tmp_path / "outside" + (repository / ".local").mkdir(parents=True) + outside.mkdir() + try: + (repository / ".local/quant-research").symlink_to( + outside, + target_is_directory=True, ) - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + except OSError as exc: + pytest.skip(f"directory links are unavailable: {exc}") - monkeypatch.setattr(subprocess, "run", process) + with pytest.raises(runner.ConfigurationError) as caught: + runner._resolve_output_project_root(repository, "minimal-fixture") - result = run_project(config_path, repo_root=fake_root) + assert caught.value.code == "unsafe_output_root" - assert result.status == "evidence_insufficient" - assert result.run_path is None + +def _make_directory_link(link: Path, target: Path, kind: str) -> None: + if kind == "symlink": + link.symlink_to(target, target_is_directory=True) + return + completed = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(link), str(target)], + capture_output=True, + text=True, + shell=False, + check=False, + ) + if completed.returncode != 0: + pytest.skip(f"junctions are unavailable: {completed.stderr or completed.stdout}") -def test_complete_run_uses_fixed_stages_sanitized_environment_and_atomic_path( +@pytest.mark.parametrize("field", ("repository", "market_data", "runtime_cache")) +@pytest.mark.parametrize("kind", ("symlink", "junction")) +def test_private_bootstrap_rejects_linked_frozen_roots_before_import( repo_root: Path, tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, + field: str, + kind: str, ) -> None: - fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - monkeypatch.setenv("RESEARCH_TEST_PASSWORD", "credential-value") - - def assert_invocation(command: list[str], kwargs: dict[str, object]) -> None: - assert Path(command[0]) == (fake_root / ".venv/Scripts/python.exe").resolve() - assert Path(command[1]) == ( - fake_root / "scripts/research/local_quant_research/adapter_guard.py" - ).resolve() - assert Path(command[command.index("--entry") + 1]) == ( - Path(command[command.index("--execution-root") + 1]) - / "repository/projects/generic-research/adapter.py" - ) - assert kwargs["shell"] is False - assert Path(kwargs["cwd"]) == _output_dir(command) - assert "RESEARCH_TEST_PASSWORD" not in kwargs["env"] - assert command.count("--snapshot-manifest") == 1 - assert command.count("--project-config") == 1 - assert command.count("--output-dir") == 1 - assert command.count("--run-id") == 1 - assert command.count("--snapshot-id") == 1 - assert command.count("--code-sha256") == 1 - assert command.count("--config-sha256") == 1 - - monkeypatch.setattr(subprocess, "run", _successful_process(assert_invocation)) - - result = run_project(config_path, repo_root=fake_root) - - assert result.status == "complete" - assert result.reused is False - assert result.run_path == ( - fake_root / ".local" / "quant-research" / "generic-research" / result.run_id - ) - assert {path.name for path in result.run_path.iterdir()} == { - "project-status.json", - "result.json", - "run-manifest.json", + from scripts.research.local_quant_research import cli + + project_id = "bootstrap-link-test" + run_id = "a" * 64 + attempt_id = uuid.uuid4().hex + project_root = repo_root / ".local/quant-research" / project_id + execution_root = project_root / f".{run_id}.{attempt_id}.inputs" + staging = project_root / f".{run_id}.{attempt_id}.tmp" + targets = { + "repository": execution_root / "repository", + "market_data": execution_root / "market-data", + "runtime_cache": execution_root / "runtime-cache", } - manifest = json.loads( - (result.run_path / "run-manifest.json").read_text(encoding="utf-8") + outside = tmp_path / f"outside-{field}-{kind}" + outside.mkdir() + execution_root.mkdir(parents=True) + for name, path in targets.items(): + if name == field: + try: + _make_directory_link(path, outside, kind) + except OSError as exc: + pytest.skip(f"directory links are unavailable: {exc}") + else: + path.mkdir() + _write_json( + execution_root / "request.json", + { + "schema_version": 2, + "project_id": project_id, + "run_id": run_id, + "attempt_id": attempt_id, + "output_root": str((repo_root / ".local/quant-research").resolve()), + "repository": str(targets["repository"]), + "market_data": str(targets["market_data"]), + "live_repository": str(repo_root.resolve()), + "runtime_cache": str(targets["runtime_cache"]), + "staging": str(staging), + }, ) - assert [stage["name"] for stage in manifest["stages"]] == [ - "snapshot_validation", - "config_validation", - "project_execution", - "output_validation", - "evidence_finalization", - ] - assert all(stage["status"] == "complete" for stage in manifest["stages"]) - assert manifest["status"] == "complete" - assert not list((fake_root / ".local/quant-research").rglob("*.tmp")) - - -def test_same_complete_identity_is_revalidated_and_reused_without_execution( - repo_root: Path, + + try: + with pytest.raises(cli._BootstrapError) as caught: + cli._bootstrap_request(execution_root / "request.json", staging) + assert caught.value.code == "unsafe_frozen_inputs" + finally: + linked = targets[field] + if linked.is_symlink(): + linked.unlink() + elif bool(getattr(os.path, "isjunction", lambda _value: False)(linked)): + linked.rmdir() + shutil.rmtree(project_root, ignore_errors=True) + for parent in (project_root.parent, project_root.parent.parent): + try: + parent.rmdir() + except OSError: + pass + + +def test_early_attempt_refuses_linked_attempts_directory( tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: - fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - monkeypatch.setattr(subprocess, "run", _successful_process()) - first = run_project(config_path, repo_root=fake_root) - before = {path.name: _sha256(path) for path in first.run_path.iterdir()} - monkeypatch.setattr( - subprocess, - "run", - lambda *args, **kwargs: pytest.fail("complete run must be reused"), - ) + from scripts.research.local_quant_research import runner - second = run_project(config_path, repo_root=fake_root) + repo = tmp_path / "repo" + attempts = repo / ".local/quant-research/_invalid/.attempts" + outside = tmp_path / "outside-attempts" + attempts.parent.mkdir(parents=True) + outside.mkdir() + try: + attempts.symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory links are unavailable: {exc}") - assert second.status == "complete" - assert second.reused is True - assert second.run_id == first.run_id - assert {path.name: _sha256(path) for path in second.run_path.iterdir()} == before + result = runner.run_project(repo / "missing.json", repo_root=repo) + assert result.status == "evidence_insufficient" + assert result.attempt_id is None + assert list(outside.iterdir()) == [] -def test_complete_run_with_human_confirmation_status_is_not_reused( - repo_root: Path, + +def test_output_run_directory_rejects_existing_directory_link( tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: - fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - monkeypatch.setattr(subprocess, "run", _successful_process()) - first = run_project(config_path, repo_root=fake_root) - _write_json( - first.run_path / "project-status.json", - { - "schema_version": 1, - "status": "complete", - "reason_codes": [], - "next_action": "human_confirmation_required", - }, - ) - monkeypatch.setattr( - subprocess, - "run", - lambda *args, **kwargs: pytest.fail("invalid complete run must not execute"), - ) + from scripts.research.local_quant_research import runner - result = run_project(config_path, repo_root=fake_root) + repo = tmp_path / "repo" + project_root = repo / ".local/quant-research/minimal-fixture" + run_id = "b" * 64 + linked_run = project_root / run_id + outside = tmp_path / "outside-run" + project_root.mkdir(parents=True) + outside.mkdir() + try: + linked_run.symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory links are unavailable: {exc}") - assert result.status == "failed" - assert result.reused is False + with pytest.raises(runner.ConfigurationError) as caught: + runner._resolve_output_run_dir(repo, "minimal-fixture", run_id) + + assert caught.value.code == "unsafe_output_root" -def test_tampered_complete_output_fails_without_overwriting_old_run( +def test_private_execute_rejects_staging_not_bound_to_frozen_request( repo_root: Path, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: - fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - monkeypatch.setattr(subprocess, "run", _successful_process()) - first = run_project(config_path, repo_root=fake_root) - output = first.run_path / "result.json" - output.write_text('{"answer":99}\n', encoding="utf-8") - tampered = output.read_bytes() - monkeypatch.setattr( - subprocess, - "run", - lambda *args, **kwargs: pytest.fail("tampered run must not execute"), - ) + token = uuid.uuid4().hex + project_root = repo_root / ".local/quant-research/private-protocol-test" + execution_root = project_root / f".{token}.inputs" + repository = execution_root / "repository" + expected_staging = project_root / f".{token}.tmp" + supplied_staging = project_root / f".{token}.other.tmp" + try: + repository.mkdir(parents=True) + _write_json( + execution_root / "request.json", + { + "schema_version": 2, + "repository": str(repository.resolve()), + "market_data": str((execution_root / "market-data").resolve()), + "live_repository": str(repo_root.resolve()), + "runtime_cache": str((execution_root / "runtime-cache").resolve()), + "staging": str(expected_staging.resolve()), + }, + ) - second = run_project(config_path, repo_root=fake_root) + completed = subprocess.run( + [ + str(repo_root / ".venv/Scripts/python.exe"), + str(repo_root / "scripts/research/local_quant_research/cli.py"), + "_execute", + "--frozen-inputs", + str(execution_root / "request.json"), + "--staging", + str(supplied_staging), + ], + cwd=repo_root, + capture_output=True, + text=True, + shell=False, + check=False, + ) - assert second.status == "failed" - assert output.read_bytes() == tampered - attempts = list( - (fake_root / ".local/quant-research/generic-research/.attempts").glob("*.json") - ) - assert len(attempts) == 1 + assert completed.returncode == 2 + assert json.loads(completed.stdout) == { + "reasons": ["staging_mismatch"], + "status": "evidence_insufficient", + } + assert not supplied_staging.exists() + finally: + shutil.rmtree(project_root, ignore_errors=True) + try: + project_root.parent.rmdir() + except OSError: + pass -def test_rewritten_manifest_cannot_replace_the_declared_output_contract( - repo_root: Path, +def test_freezing_copies_every_captured_shared_runtime_source( tmp_path: Path, + repo_root: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: + from scripts.research.local_quant_research import runner + fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - monkeypatch.setattr(subprocess, "run", _successful_process()) - first = run_project(config_path, repo_root=fake_root) - manifest_path = first.run_path / "run-manifest.json" - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - (first.run_path / "result.json").unlink() - replacement = first.run_path / "replacement.txt" - replacement.write_text("replacement\n", encoding="utf-8") - outputs = [ - { - "path": "replacement.txt", - "format": "text", - "bytes": replacement.stat().st_size, - "sha256": _sha256(replacement), - } - ] - manifest["outputs"] = outputs - manifest["output_set_sha256"] = canonical_digest(outputs) - _write_json(manifest_path, manifest) - monkeypatch.setattr( - subprocess, - "run", - lambda *args, **kwargs: pytest.fail("rewritten evidence must not be reused"), - ) + observed: set[str] = set() + + def inspect_frozen_runtime(command: list[str], **_kwargs: object): + request_path = Path(command[command.index("--frozen-inputs") + 1]) + request = json.loads(request_path.read_text(encoding="utf-8")) + frozen_repository = Path(request["repository"]) + observed.update( + item.relative_to(frozen_repository).as_posix() + for item in frozen_repository.rglob("*.py") + ) + return subprocess.CompletedProcess( + command, + 1, + stdout='{"reasons":["stop_after_inspection"],"status":"failed"}', + stderr="", + ) + + monkeypatch.setattr(subprocess, "run", inspect_frozen_runtime) - result = run_project(config_path, repo_root=fake_root) + result = runner.run_project(config_path, repo_root=fake_root) assert result.status == "failed" + assert { + "scripts/__init__.py", + "scripts/research/__init__.py", + "scripts/research/local_quant_research/runner.py", + "scripts/research/local_quant_research/scenario.py", + "scripts/research/market_data/query.py", + }.issubset(observed) + assert "scripts/research/local_quant_research/adapter_guard.py" not in observed + + +def test_v2_bootstrap_does_not_install_adapter_guard(repo_root: Path) -> None: + from scripts.research.local_quant_research import cli, runner + + private_execute = inspect.getsource(cli._private_execute) + assert "adapter_guard" not in private_execute + assert "install_access_guard" not in private_execute + assert "sys.modules" not in private_execute + + _, runtime_sources = runner._runtime_lock(repo_root) + assert ( + repo_root / "scripts/research/local_quant_research/adapter_guard.py" + not in runtime_sources + ) -def test_failed_post_publish_validation_removes_false_complete_directory( - repo_root: Path, +def test_parent_runner_never_executes_strategy_top_level_before_frozen_child( tmp_path: Path, + repo_root: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: from scripts.research.local_quant_research import runner - fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - monkeypatch.setattr(subprocess, "run", _successful_process()) - monkeypatch.setattr( - runner, - "validate_complete_run", - lambda *args, **kwargs: (_ for _ in ()).throw( - runner.EvidenceError("forced post-publish validation failure") + fake_root, config_path, config = _build_repo(tmp_path, repo_root) + strategy = ( + fake_root + / "projects/generic-research" + / f"{config['strategy']['module']}.py" + ) + marker = fake_root.parent / "parent-strategy-executed" + source = strategy.read_text(encoding="utf-8") + strategy.write_text( + source.replace( + "from __future__ import annotations\n", + "from __future__ import annotations\n" + "from pathlib import Path\n" + f"Path({str(marker)!r}).write_text('executed', encoding='utf-8')\n", + 1, ), + encoding="utf-8", ) + process_calls = 0 + + def stop_at_child(command: list[str], **_kwargs: object): + nonlocal process_calls + process_calls += 1 + return subprocess.CompletedProcess( + command, + 1, + stdout='{"reasons":["stop_at_child"],"status":"failed"}', + stderr="", + ) - result = run_project(config_path, repo_root=fake_root) + monkeypatch.setattr(subprocess, "run", stop_at_child) + + result = runner.run_project(config_path, repo_root=fake_root) assert result.status == "failed" - project_root = fake_root / ".local/quant-research/generic-research" - assert not any(path.is_dir() and len(path.name) == 64 for path in project_root.iterdir()) + assert process_calls == 1 + assert not marker.exists() -def test_transient_directory_publish_lock_is_retried_without_weakening_validation( - repo_root: Path, +def test_freezing_uses_first_captured_scenario_digest_before_process( tmp_path: Path, + repo_root: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: from scripts.research.local_quant_research import runner fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - monkeypatch.setattr(subprocess, "run", _successful_process()) - real_replace = runner.os.replace - calls = 0 + scenario = fake_root / "projects/generic-research/scenario.json" + original_copy = runner._copy_v2_inputs + process_calls = 0 + + def change_after_identity(**kwargs: object) -> None: + scenario.write_text( + '{"parameter":8,"scenario_id":"baseline","schema_version":1}\n', + encoding="utf-8", + ) + original_copy(**kwargs) - def transient_replace(source: Path, target: Path) -> None: - nonlocal calls - calls += 1 - if calls == 1: - raise PermissionError(13, "directory is temporarily in use", str(source)) - real_replace(source, target) + def record_process(*_args: object, **_kwargs: object): + nonlocal process_calls + process_calls += 1 + return subprocess.CompletedProcess([], 1, stdout="", stderr="") - monkeypatch.setattr(runner.os, "replace", transient_replace) + monkeypatch.setattr(runner, "_copy_v2_inputs", change_after_identity) + monkeypatch.setattr(subprocess, "run", record_process) - result = run_project(config_path, repo_root=fake_root) + result = runner.run_project(config_path, repo_root=fake_root) - assert result.status == "complete" - assert calls == 2 - assert result.run_path is not None - assert (result.run_path / "run-manifest.json").is_file() + assert result.status == "failed" + assert "changed" in " ".join(result.reasons) + assert process_calls == 0 -@pytest.mark.parametrize("project_status", ["failed", "mystery"]) -def test_project_failure_or_unknown_status_records_attempt_not_complete_run( - project_status: str, +def test_private_execute_bootstrap_loads_runner_from_frozen_repository( repo_root: Path, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: - fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - - def process(command: list[str], **kwargs): + run_id = "c" * 64 + attempt_id = uuid.uuid4().hex + project_root = repo_root / ".local/quant-research/private-freeze-test" + execution_root = project_root / f".{run_id}.{attempt_id}.inputs" + frozen = execution_root / "repository" + staging = project_root / f".{run_id}.{attempt_id}.tmp" + modules = { + "scripts/__init__.py": "", + "scripts/research/__init__.py": "", + "scripts/research/local_quant_research/__init__.py": "", + "scripts/research/local_quant_research/runner.py": ( + "class ConfigurationError(ValueError):\n" + " def __init__(self, code, message):\n" + " super().__init__(message)\n" + " self.code = code\n" + "def execute_frozen_inputs(_request, _staging):\n" + " return {'status': 'complete', 'reasons': [], 'source': 'frozen'}\n" + ), + "scripts/research/local_quant_research/contracts.py": ( + "class StrategyEvidenceError(RuntimeError):\n" + " code = 'strategy_evidence'\n" + ), + "scripts/research/local_quant_research/performance.py": ( + "class PerformanceGateError(RuntimeError):\n" + " code = 'performance_gate'\n" + ), + "scripts/research/local_quant_research/result_package.py": ( + "class ResultContractError(ValueError):\n" + " pass\n" + ), + "scripts/research/local_quant_research/strategy_loader.py": ( + "class ConfigurationError(ValueError):\n" + " def __init__(self, code='strategy_config', message='invalid'):\n" + " super().__init__(message)\n" + " self.code = code\n" + ), + "scripts/research/market_data/__init__.py": "", + "scripts/research/market_data/storage.py": ( + "class MarketDataError(RuntimeError):\n" + " pass\n" + ), + } + try: + for relative, source in modules.items(): + path = frozen / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8") + (execution_root / "market-data").mkdir() + (execution_root / "runtime-cache").mkdir() _write_json( - _output_dir(command) / "project-status.json", - { - "schema_version": 1, - "status": project_status, - "reason_codes": ["project_declined"], + execution_root / "request.json", + { + "schema_version": 2, + "project_id": "private-freeze-test", + "run_id": run_id, + "attempt_id": attempt_id, + "output_root": str((repo_root / ".local/quant-research").resolve()), + "repository": str(frozen.resolve()), + "market_data": str((execution_root / "market-data").resolve()), + "live_repository": str(repo_root.resolve()), + "runtime_cache": str((execution_root / "runtime-cache").resolve()), + "staging": str(staging.resolve()), }, ) - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") - monkeypatch.setattr(subprocess, "run", process) + completed = subprocess.run( + [ + str(repo_root / ".venv/Scripts/python.exe"), + str(repo_root / "scripts/research/local_quant_research/cli.py"), + "_execute", + "--frozen-inputs", + str(execution_root / "request.json"), + "--staging", + str(staging), + ], + cwd=repo_root, + capture_output=True, + text=True, + shell=False, + check=False, + ) - result = run_project(config_path, repo_root=fake_root) - - assert result.status == "failed" - assert result.run_path is None - project_root = fake_root / ".local/quant-research/generic-research" - assert not any(path.is_dir() and len(path.name) == 64 for path in project_root.iterdir()) - attempt = next((project_root / ".attempts").glob("*.json")) - document = json.loads(attempt.read_text(encoding="utf-8")) - assert set(document) == { - "schema_version", - "attempt_id", - "project_id", - "run_id", - "status", - "stage", - "reason_codes", - } + assert completed.returncode == 0, completed.stderr + completed.stdout + assert json.loads(completed.stdout)["source"] == "frozen" + finally: + shutil.rmtree(project_root, ignore_errors=True) + try: + project_root.parent.rmdir() + except OSError: + pass -def test_failed_retry_keeps_each_compact_attempt_and_discards_process_output( - repo_root: Path, +def test_completed_package_reuse_binds_all_frozen_identity_documents( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - secret = "credential-must-not-be-recorded" + from scripts.research.local_quant_research import result_package, runner + from scripts.research.local_quant_research.evidence import canonical_digest + + package = tmp_path / "package" + project_run = {"schema_version": 2, "project_id": "minimal-fixture"} + scenario = {"schema_version": 1, "scenario_id": "baseline"} + declared_inputs = [{"path": "input.txt", "sha256": "1" * 64}] + code_identity = { + "schema_version": 1, + "files": [{"path": "strategy.py", "sha256": "2" * 64}], + "inputs": { + "project_run": {"path": "run.json", "sha256": "3" * 64}, + "scenario": {"path": "scenario.json", "sha256": "4" * 64}, + "declared_inputs": declared_inputs, + }, + } + runtime_lock = {"schema_version": 1, "python": "3.12", "dependencies": {}} + market_snapshot = {"schema_version": 1, "snapshot_id": "5" * 64} + config_digest = canonical_digest( + { + "project_run": project_run, + "scenario": scenario, + "declared_inputs": declared_inputs, + } + ) + code_digest = canonical_digest( + { + "code_identity": { + "schema_version": 1, + "files": code_identity["files"], + }, + "runtime_lock": runtime_lock, + } + ) + run_id = runner.compute_run_id( + canonical_digest(market_snapshot), + config_digest, + code_digest, + ) + for relative, document in { + "config/project-run.json": project_run, + "config/scenario.json": scenario, + "config/code-identity.json": code_identity, + "evidence/market-snapshot.json": market_snapshot, + "evidence/runtime-lock.json": runtime_lock, + }.items(): + _write_json(package / relative, document) + manifest = { + "object": { + "kind": "local_research", + "status": "complete", + "strategy_id": "minimal-fixture", + "scenario_id": "baseline", + "run_id": run_id, + } + } + monkeypatch.setattr(result_package, "validate_result_package", lambda _path: manifest) + expected = { + "project_id": "minimal-fixture", + "run_id": run_id, + "project_run": project_run, + "scenario_document": scenario, + "code_identity": code_identity, + "runtime_lock": runtime_lock, + "market_snapshot": market_snapshot, + "config_sha256": config_digest, + "code_sha256": code_digest, + } + + runner._package_identity(package, expected=expected) + manifest["object"]["scenario_id"] = "other" + with pytest.raises(EvidenceError, match="identity"): + runner._package_identity(package, expected=expected) + manifest["object"]["scenario_id"] = "baseline" + _write_json( + package / "config/project-run.json", + {"schema_version": 2, "project_id": "other"}, + ) + + with pytest.raises(EvidenceError, match="identity"): + runner._package_identity(package, expected=expected) - def process(command: list[str], **kwargs): - return subprocess.CompletedProcess(command, 9, stdout=secret, stderr=secret) - monkeypatch.setattr(subprocess, "run", process) +def _performance_module(): + try: + return importlib.import_module( + "scripts.research.local_quant_research.performance" + ) + except ModuleNotFoundError: + pytest.fail("shared performance module is missing") + - first = run_project(config_path, repo_root=fake_root) - second = run_project(config_path, repo_root=fake_root) +def test_daily_performance_runs_exactly_one_cold_and_one_warm() -> None: + performance = _performance_module() + calls: list[int] = [] - assert first.status == second.status == "failed" - attempts = list( - (fake_root / ".local/quant-research/generic-research/.attempts").glob("*.json") + def operation() -> dict[str, object]: + calls.append(len(calls)) + return {"execution": "same"} + + outcome, evidence = performance.run_cold_warm( + operation, + digest=lambda value: value["execution"], ) - assert len(attempts) == 2 - assert secret not in json.dumps(first.to_document()) - assert secret not in json.dumps(second.to_document()) - assert all(secret not in path.read_text(encoding="utf-8") for path in attempts) + assert outcome == {"execution": "same"} + assert calls == [0, 1] + assert evidence.cold.digest == evidence.warm.digest == "same" + assert evidence.cold.seconds < 180 + assert evidence.warm.seconds < 180 -def test_missing_required_output_is_failed(tmp_path: Path, repo_root: Path, monkeypatch) -> None: - fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - def process(command: list[str], **kwargs): - _write_json( - _output_dir(command) / "project-status.json", - COMPLETE_STATUS, - ) - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") +def test_daily_performance_releases_cold_outcome_before_warm() -> None: + performance = _performance_module() + references: list[weakref.ReferenceType[object]] = [] - monkeypatch.setattr(subprocess, "run", process) + class Outcome: + pass - assert run_project(config_path, repo_root=fake_root).status == "failed" + def operation() -> Outcome: + if references: + gc.collect() + assert references[0]() is None + outcome = Outcome() + references.append(weakref.ref(outcome)) + return outcome + performance.run_cold_warm( + operation, + digest=lambda _value: "same", + ) -def test_project_write_outside_staging_is_detected( - tmp_path: Path, - repo_root: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - def process(command: list[str], **kwargs): - (fake_root / "escaped.txt").write_text("not allowed", encoding="utf-8") - return _successful_process()(command, **kwargs) +def test_daily_performance_rejects_full_execution_digest_mismatch() -> None: + performance = _performance_module() + calls = 0 - monkeypatch.setattr(subprocess, "run", process) + def operation() -> dict[str, int]: + nonlocal calls + calls += 1 + return {"execution": calls} - result = run_project(config_path, repo_root=fake_root) + with pytest.raises(performance.PerformanceGateError) as caught: + performance.run_cold_warm( + operation, + digest=lambda value: str(value["execution"]), + ) - assert result.status == "failed" - assert "outside staging" in " ".join(result.reasons) + assert caught.value.code == "execution_digest_mismatch" + assert calls == 2 -def test_project_reads_frozen_input_when_original_changes_temporarily( - tmp_path: Path, - repo_root: Path, +def test_daily_performance_counts_digest_time_in_each_180_second_gate( monkeypatch: pytest.MonkeyPatch, ) -> None: - fake_root, config_path, _ = _build_repo(tmp_path, repo_root) - declared_input = fake_root / "projects/generic-research/input.txt" - original_stat = declared_input.stat() - - def process(command: list[str], **kwargs): - declared_input.write_text("tampered input\n", encoding="utf-8") - frozen_project_config = Path(command[command.index("--project-config") + 1]) - frozen_input = frozen_project_config.parent / "input.txt" - value_used = frozen_input.read_text(encoding="utf-8") - os.utime( - declared_input, - ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns), - ) - declared_input.write_text("declared input\n", encoding="utf-8") - os.utime( - declared_input, - ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns), - ) - output_dir = _output_dir(command) - _write_json( - output_dir / "project-status.json", - COMPLETE_STATUS, - ) - _write_json(output_dir / "result.json", {"input": value_used}) - return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + performance = _performance_module() + clock = 0.0 - monkeypatch.setattr(subprocess, "run", process) + def now() -> float: + return clock - result = run_project(config_path, repo_root=fake_root) + def slow_digest(value: str) -> str: + nonlocal clock + clock = 180.0 + return value - assert result.status == "complete" - output = json.loads((result.run_path / "result.json").read_text(encoding="utf-8")) - assert output == {"input": "declared input\n"} - project_root = fake_root / ".local/quant-research/generic-research" - assert not list(project_root.glob(".*.inputs")) + monkeypatch.setattr(performance.time, "perf_counter", now) + with pytest.raises(performance.PerformanceGateError) as caught: + performance.run_cold_warm(lambda: "same", digest=slow_digest) -def test_adapter_guard_allows_staging_writes_and_blocks_external_writes( - repo_root: Path, - tmp_path: Path, + assert caught.value.code == "cold_performance_limit" + + +@pytest.mark.parametrize( + ("clock", "reason"), + ( + ([0.0, 180.0, 180.0, 181.0], "cold_performance_limit"), + ([0.0, 1.0, 1.0, 181.0], "warm_performance_limit"), + ), +) +def test_daily_performance_rejects_each_180_second_limit( + clock: list[float], + reason: str, + monkeypatch: pytest.MonkeyPatch, ) -> None: - execution_root = tmp_path / "execution" - adapter = execution_root / "repository" / "adapter.py" - adapter.parent.mkdir(parents=True) - adapter.write_text( - "from pathlib import Path\n" - "import os\n" - "import sys\n" - "with open(os.devnull, 'r+b'):\n" - " pass\n" - "output = Path(sys.argv[1])\n" - "cache = Path(os.environ['NUMBA_CACHE_DIR'])\n" - "cache.mkdir(parents=True, exist_ok=True)\n" - "(cache / 'compiled.bin').write_bytes(b'cache')\n" - "(output / 'inside.txt').write_text('inside', encoding='utf-8')\n" - "if len(sys.argv) > 3 and sys.argv[2] == 'write':\n" - " Path(sys.argv[3]).write_text('escaped', encoding='utf-8')\n" - "if len(sys.argv) > 3 and sys.argv[2] == 'read':\n" - " Path(sys.argv[3]).read_text(encoding='utf-8')\n", - encoding="utf-8", - ) - output_dir = tmp_path / "output" - output_dir.mkdir() - escaped = tmp_path / "escaped.txt" - python = repo_root / ".venv" / "Scripts" / "python.exe" - base_command = [ - str(python), - "-m", - "scripts.research.local_quant_research.adapter_guard", - "--staging-root", - str(output_dir), - "--execution-root", - str(execution_root), - "--repository-root", - str(repo_root), - "--venv-root", - str(repo_root / ".venv"), - "--entry", - str(adapter), - "--", - str(output_dir), - ] - environment = dict(os.environ) - environment["PYTHONPATH"] = str(repo_root) - environment["PYTHONDONTWRITEBYTECODE"] = "1" - - allowed = subprocess.run( - base_command, - cwd=output_dir, - env=environment, - capture_output=True, - text=True, - shell=False, - check=False, - ) - blocked = subprocess.run( - [*base_command, "write", str(escaped)], - cwd=output_dir, - env=environment, - capture_output=True, - text=True, - shell=False, - check=False, - ) + performance = _performance_module() + values = iter(clock) + monkeypatch.setattr(performance.time, "perf_counter", lambda: next(values)) - assert allowed.returncode == 0, allowed.stderr - assert (output_dir / "inside.txt").read_text(encoding="utf-8") == "inside" - assert not (output_dir / ".runtime-cache").exists() - assert blocked.returncode != 0 - assert not escaped.exists() + with pytest.raises(performance.PerformanceGateError) as caught: + performance.run_cold_warm(lambda: "same", digest=lambda value: value) - blocked_read = subprocess.run( - [*base_command, "read", str(repo_root / "AGENTS.md")], - cwd=output_dir, - env=environment, - capture_output=True, - text=True, - shell=False, - check=False, + assert caught.value.code == reason + + +def test_daily_performance_adds_single_writer_duration_to_both_limits() -> None: + performance = _performance_module() + _, evidence = performance.run_cold_warm( + lambda: "same", + digest=lambda value: value, ) - assert blocked_read.returncode != 0 + + with pytest.raises(performance.PerformanceGateError) as caught: + performance.include_shared_work(evidence, 180.0) + + assert caught.value.code == "cold_performance_limit" + + +def test_public_cli_omits_release_performance_workflow() -> None: + from scripts.research.local_quant_research import cli + + assert "performance" not in cli._parser().format_help() diff --git a/tests/local_quant_research/test_skill_contract.py b/tests/local_quant_research/test_skill_contract.py index 2e647e1..6f17f9d 100644 --- a/tests/local_quant_research/test_skill_contract.py +++ b/tests/local_quant_research/test_skill_contract.py @@ -2,11 +2,20 @@ from pathlib import Path +from scripts.research.local_quant_research.cli import _parser + PUBLIC_COMMAND = ( ".\\.venv\\Scripts\\python.exe " "scripts\\research\\local_quant_research\\cli.py run --config " ) +PROMOTE_COMMAND = ( + ".\\.venv\\Scripts\\python.exe " + "scripts\\research\\local_quant_research\\cli.py promote `\n" + " --strategy-id `\n" + " --run-id `\n" + " --analysis-id " +) def _skill_text(repo_root: Path) -> str: @@ -24,6 +33,7 @@ def test_local_research_skill_is_thin_and_strategy_agnostic( "---\nname: run-local-quant-research\ndescription: Use when " ) assert text.count(PUBLIC_COMMAND) == 1 + assert text.count(PROMOTE_COMMAND) == 1 assert all( status in text for status in ("complete", "evidence_insufficient", "failed") ) @@ -34,7 +44,9 @@ def test_local_research_skill_is_thin_and_strategy_agnostic( "单场景", "完整报告", "return_to_caller", - "必需输出", + "archive-ready(可归档)", + "输出固定为唯一 archive-ready(可归档)结果包", + "不由配置声明", "正式回测", "JoinQuant(聚宽)", "Cookie(浏览器凭证)", @@ -43,6 +55,18 @@ def test_local_research_skill_is_thin_and_strategy_agnostic( assert required in text for forbidden in ("海龟", "turtle", "55日", "0.5N", "strategy-003", "510300"): assert forbidden not in text + for forbidden in ( + "project_entry", + "project-entry", + "项目入口", + "任意命令", + "required_outputs", + "output_root", + "stop_states", + "唯一必需输出", + "必需输出声明", + ): + assert forbidden not in text def test_local_research_skill_has_one_fixed_orchestration_order( @@ -52,7 +76,7 @@ def test_local_research_skill_has_one_fixed_orchestration_order( stages = [ "校验行情快照", "校验单场景配置", - "运行项目入口", + "运行共享场景", "校验单场景结果", "固化运行证据", "返回调用者", @@ -81,3 +105,11 @@ def test_local_research_skill_ui_metadata_matches_public_entry( assert 'display_name: "本地量化研究流程"' in metadata assert "$run-local-quant-research" in metadata + + +def test_private_execute_protocol_is_absent_from_public_cli_help() -> None: + help_text = _parser().format_help() + + assert "run" in help_text + assert "promote" in help_text + assert "_execute" not in help_text diff --git a/tests/local_quant_research/test_strategy_contract.py b/tests/local_quant_research/test_strategy_contract.py new file mode 100644 index 0000000..16b4712 --- /dev/null +++ b/tests/local_quant_research/test_strategy_contract.py @@ -0,0 +1,542 @@ +from __future__ import annotations + +import dataclasses +import inspect +import shutil +import sys +import uuid +from pathlib import Path +from typing import get_type_hints + +import numpy as np +import pyarrow as pa +import pytest + +from scripts.research.local_quant_research import strategy_loader +from scripts.research.local_quant_research.contracts import ( + FILL_ACCEPTED, + FILL_IGNORED, + FILL_REJECTED, + SIDE_BUY, + SIDE_NONE, + SIDE_SELL, + ExecutionBundle, + ExecutionLedger, + ExecutionRun, + FillEvent, + LedgerInput, + OrderBuffer, + OrderProgram, + PreparedStrategy, + ResultExtension, + SegmentView, + StrategyDescriptor, + StrategyEvidenceError, + StrategyModule, +) +from scripts.research.local_quant_research.strategy_loader import ( + ConfigurationError, + LoadedStrategy, + load_strategy, +) + + +def _fixture_config(name: str) -> dict[str, str]: + if name == "minimal_strategy_b": + return { + "root": "tests/local_quant_research/fixtures", + "module": "minimal_strategy_b.strategy", + "symbol": "MODULE", + } + return { + "root": f"tests/local_quant_research/fixtures/{name}", + "module": "strategy", + "symbol": "MODULE", + } + + +@pytest.fixture +def temporary_strategy_root(repo_root: Path): + root = repo_root / ".local" / "strategy-contract-tests" / uuid.uuid4().hex + root.mkdir(parents=True) + try: + yield root + finally: + shutil.rmtree(root, ignore_errors=True) + for parent in (root.parent, root.parent.parent): + try: + parent.rmdir() + except OSError: + pass + + +def _relative_to_repo(path: Path, repo_root: Path) -> str: + return path.relative_to(repo_root).as_posix() + + +def test_contract_constants_and_namedtuple_fields_are_stable() -> None: + assert (SIDE_NONE, SIDE_BUY, SIDE_SELL) == (0, 1, -1) + assert (FILL_IGNORED, FILL_ACCEPTED, FILL_REJECTED) == (0, 1, 2) + assert SegmentView._fields == ( + "row", + "group", + "from_col", + "to_col", + "cash", + "value", + "positions", + "valuation_prices", + ) + assert FillEvent._fields == ( + "row", + "column", + "status", + "side", + "size", + "price", + "fees", + "cash_after", + "position_after", + ) + + +def test_new_contract_dataclasses_are_frozen_and_slotted() -> None: + descriptor = StrategyDescriptor( + strategy_id="example", + contract_version="1", + extension_names=(), + accounting={"basis": "cash"}, + ) + + assert not hasattr(descriptor, "__dict__") + with pytest.raises(dataclasses.FrozenInstanceError): + descriptor.strategy_id = "changed" # type: ignore[misc] + with pytest.raises(TypeError): + descriptor.accounting["basis"] = "other" # type: ignore[index] + + +def test_order_buffer_requires_equal_array_lengths_and_remains_writable() -> None: + arrays = [np.zeros(2) for _ in range(8)] + buffer = OrderBuffer(*arrays) + + buffer.enabled[0] = 1 + assert buffer.enabled.flags.writeable + assert buffer.enabled[0] == 1 + + arrays[-1] = np.zeros(1) + with pytest.raises(ValueError, match="same length"): + OrderBuffer(*arrays) + + +def test_strategy_evidence_error_preserves_stable_code() -> None: + error = StrategyEvidenceError("missing_declared_input", "input is missing") + + assert error.code == "missing_declared_input" + assert str(error) == "input is missing" + + +def test_contract_shapes_and_strategy_protocol_are_exact() -> None: + assert [field.name for field in dataclasses.fields(StrategyDescriptor)] == [ + "strategy_id", + "contract_version", + "extension_names", + "accounting", + ] + assert [field.name for field in dataclasses.fields(LedgerInput)] == [ + "dates", + "symbols", + "close", + "initial_cash", + "group_ids", + "cash_sharing", + "frequency", + ] + assert [field.name for field in dataclasses.fields(OrderProgram)] == [ + "program_id", + "prepare_segment_nb", + "after_fill_nb", + "after_segment_nb", + "inputs", + "params", + "state", + "trace", + "orders", + ] + assert [field.name for field in dataclasses.fields(PreparedStrategy)] == [ + "ledger_input", + "primary_program", + "context", + ] + assert [field.name for field in dataclasses.fields(ExecutionRun)] == [ + "ledger", + "trace", + ] + assert [field.name for field in dataclasses.fields(ExecutionBundle)] == [ + "primary", + "final", + "stages", + ] + assert [field.name for field in dataclasses.fields(ResultExtension)] == [ + "name", + "schema_version", + "table", + "unique_key", + "evidence", + ] + assert set(StrategyModule.__protocol_attrs__) == { + "descriptor", + "prepare", + "followup_program", + "build_extensions", + } + assert list(inspect.signature(StrategyModule.prepare).parameters) == [ + "self", + "snapshot", + "config", + ] + assert get_type_hints(ResultExtension)["table"] is pa.Table + + +def test_execution_ledger_protocol_exposes_read_only_properties() -> None: + for name in ("orders", "assets", "cash", "value", "trades", "positions", "returns"): + member = inspect.getattr_static(ExecutionLedger, name) + assert isinstance(member, property) + assert member.fset is None + + +@pytest.mark.parametrize("invalid", ("C:/outside", "../outside", "/outside")) +def test_loader_rejects_strategy_root_escape(repo_root: Path, invalid: str) -> None: + with pytest.raises(ConfigurationError, match="strategy_root"): + load_strategy( + repo_root, + {"root": invalid, "module": "strategy", "symbol": "MODULE"}, + ) + + +@pytest.mark.parametrize("unknown", ("command", "project_entry")) +def test_loader_rejects_unknown_or_legacy_config_fields( + repo_root: Path, + unknown: str, +) -> None: + config = {**_fixture_config("minimal_strategy"), unknown: "forbidden"} + + with pytest.raises(ConfigurationError, match="fields"): + load_strategy(repo_root, config) + + +def test_shared_loader_accepts_two_strategy_modules(repo_root: Path) -> None: + first = load_strategy(repo_root, _fixture_config("minimal_strategy")) + second = load_strategy(repo_root, _fixture_config("minimal_strategy_b")) + + assert isinstance(first, LoadedStrategy) + assert (first.descriptor.strategy_id, second.descriptor.strategy_id) == ( + "minimal-fixture", + "minimal-fixture-b", + ) + assert first.root != second.root + assert first.source_paths == (first.root / "strategy.py",) + assert tuple(path.relative_to(second.root).as_posix() for path in second.source_paths) == ( + "minimal_strategy_b/__init__.py", + "minimal_strategy_b/strategy.py", + ) + + +def test_static_source_discovery_does_not_execute_strategy_and_captures_all_python( + repo_root: Path, + temporary_strategy_root: Path, +) -> None: + package = temporary_strategy_root / "fixture_package" + package.mkdir() + marker = package / "executed" + (package / "__init__.py").write_text("", encoding="utf-8") + helper = package / "helper.py" + helper.write_text("VALUE = 1\n", encoding="utf-8") + strategy = package / "strategy.py" + strategy.write_text( + "from pathlib import Path\n" + f"Path({str(marker)!r}).write_text('executed', encoding='utf-8')\n", + encoding="utf-8", + ) + adjacent = temporary_strategy_root / "adjacent" + adjacent.mkdir() + (adjacent / "ignored.py").write_text("VALUE = 2\n", encoding="utf-8") + archive = package / "research" / "archives" + archive.mkdir(parents=True) + (archive / "ignored.py").write_text("VALUE = 3\n", encoding="utf-8") + + discovered = strategy_loader.discover_strategy_sources( + temporary_strategy_root, + "fixture_package.strategy", + ) + + assert discovered == ( + (package / "__init__.py").resolve(), + helper.resolve(), + strategy.resolve(), + ) + assert not marker.exists() + + +def test_static_source_discovery_rejects_linked_python_source( + repo_root: Path, + temporary_strategy_root: Path, +) -> None: + (temporary_strategy_root / "strategy.py").write_text("MODULE = object()\n", encoding="utf-8") + external = temporary_strategy_root.parent / f"{uuid.uuid4().hex}.py" + external.write_text("VALUE = 1\n", encoding="utf-8") + linked = temporary_strategy_root / "linked.py" + try: + linked.symlink_to(external) + except OSError as exc: + external.unlink(missing_ok=True) + pytest.skip(f"file links are unavailable: {exc}") + try: + with pytest.raises(ConfigurationError) as caught: + strategy_loader.discover_strategy_sources( + temporary_strategy_root, + "strategy", + ) + assert caught.value.code == "unsafe_strategy_source_tree" + finally: + linked.unlink(missing_ok=True) + external.unlink(missing_ok=True) + + +def test_static_source_discovery_rejects_archive_as_strategy_root( + temporary_strategy_root: Path, +) -> None: + archive_code = temporary_strategy_root / "research" / "archives" / "saved" / "code" + archive_code.mkdir(parents=True) + (archive_code / "strategy.py").write_text("MODULE = object()\n", encoding="utf-8") + + with pytest.raises(ConfigurationError) as caught: + strategy_loader.discover_strategy_sources(archive_code, "strategy") + + assert caught.value.code == "unsafe_strategy_source_tree" + + +def test_loader_uses_standard_import_and_restores_sys_path(repo_root: Path) -> None: + before_path = list(sys.path) + loaded = load_strategy(repo_root, _fixture_config("minimal_strategy")) + + assert loaded.descriptor.strategy_id == "minimal-fixture" + assert loaded.module.__class__.__module__ == "strategy" + assert sys.path == before_path + assert Path(sys.modules["strategy"].__file__).resolve() == loaded.source_paths[0] + + +def test_loader_supports_package_relative_imports( + repo_root: Path, + temporary_strategy_root: Path, +) -> None: + package = temporary_strategy_root / "fixture_package" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "identity.py").write_text( + "STRATEGY_ID = 'relative-import-fixture'\n", + encoding="utf-8", + ) + (package / "strategy.py").write_text( + "\n".join( + ( + "from .identity import STRATEGY_ID", + "from scripts.research.local_quant_research.contracts import StrategyDescriptor", + "class RelativeImportModule:", + " descriptor = StrategyDescriptor(", + " strategy_id=STRATEGY_ID,", + " contract_version='1',", + " extension_names=(),", + " accounting={},", + " )", + " def prepare(self, snapshot, config): raise NotImplementedError", + " def followup_program(self, prepared, primary_run): return None", + " def build_extensions(self, prepared, execution): return ()", + "MODULE = RelativeImportModule()", + "", + ) + ), + encoding="utf-8", + ) + + loaded = load_strategy( + repo_root, + { + "root": _relative_to_repo(temporary_strategy_root, repo_root), + "module": "fixture_package.strategy", + "symbol": "MODULE", + }, + ) + + assert loaded.descriptor.strategy_id == "relative-import-fixture" + assert "fixture_package" in sys.modules + assert "fixture_package.strategy" in sys.modules + + +def test_loader_rejects_external_parent_package_before_execution( + repo_root: Path, + temporary_strategy_root: Path, +) -> None: + package = temporary_strategy_root / "external_parent" + package.mkdir() + marker = temporary_strategy_root / "external-parent-executed" + external_init = temporary_strategy_root.parent / f"{uuid.uuid4().hex}.py" + external_init.write_text( + "from pathlib import Path\n" + f"Path({str(marker)!r}).write_text('executed', encoding='utf-8')\n", + encoding="utf-8", + ) + (package / "__init__.py").symlink_to(external_init) + (package / "strategy.py").write_text( + "\n".join( + ( + "from scripts.research.local_quant_research.contracts import StrategyDescriptor", + "class ExternalParentModule:", + " descriptor = StrategyDescriptor(", + " strategy_id='external-parent-fixture',", + " contract_version='1',", + " extension_names=(),", + " accounting={},", + " )", + " def prepare(self, snapshot, config): raise NotImplementedError", + " def followup_program(self, prepared, primary_run): return None", + " def build_extensions(self, prepared, execution): return ()", + "MODULE = ExternalParentModule()", + "", + ) + ), + encoding="utf-8", + ) + try: + with pytest.raises(ConfigurationError, match="package file"): + load_strategy( + repo_root, + { + "root": _relative_to_repo(temporary_strategy_root, repo_root), + "module": "external_parent.strategy", + "symbol": "MODULE", + }, + ) + assert not marker.exists() + finally: + external_init.unlink(missing_ok=True) + + +def test_loaded_strategy_supports_delayed_relative_imports( + repo_root: Path, + temporary_strategy_root: Path, +) -> None: + package = temporary_strategy_root / "delayed_package" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "identity.py").write_text( + "STRATEGY_ID = 'delayed-relative-import-fixture'\n", + encoding="utf-8", + ) + (package / "strategy.py").write_text( + "\n".join( + ( + "from scripts.research.local_quant_research.contracts import StrategyDescriptor", + "class DelayedImportModule:", + " descriptor = StrategyDescriptor(", + " strategy_id='delayed-relative-import-fixture',", + " contract_version='1',", + " extension_names=(),", + " accounting={},", + " )", + " def prepare(self, snapshot, config):", + " from .identity import STRATEGY_ID", + " return STRATEGY_ID", + " def followup_program(self, prepared, primary_run): return None", + " def build_extensions(self, prepared, execution): return ()", + "MODULE = DelayedImportModule()", + "", + ) + ), + encoding="utf-8", + ) + + loaded = load_strategy( + repo_root, + { + "root": _relative_to_repo(temporary_strategy_root, repo_root), + "module": "delayed_package.strategy", + "symbol": "MODULE", + }, + ) + + assert loaded.module.prepare(object(), {}) == "delayed-relative-import-fixture" + assert "delayed_package" in sys.modules + assert "delayed_package.strategy" in sys.modules + + +def test_loader_rejects_unknown_symbol(repo_root: Path) -> None: + config = {**_fixture_config("minimal_strategy"), "symbol": "UNKNOWN"} + + with pytest.raises(ConfigurationError, match="symbol"): + load_strategy(repo_root, config) + + +def test_loader_rejects_module_file_outside_strategy_root(repo_root: Path) -> None: + config = { + **_fixture_config("minimal_strategy"), + "module": "scripts.research.local_quant_research.contracts", + "symbol": "StrategyDescriptor", + } + + with pytest.raises(ConfigurationError, match="module file"): + load_strategy(repo_root, config) + + +def test_loader_does_not_execute_module_file_outside_strategy_root( + repo_root: Path, + temporary_strategy_root: Path, +) -> None: + module_name = f"strategy_contract_external_{uuid.uuid4().hex}" + external_module = repo_root / f"{module_name}.py" + marker = temporary_strategy_root / "external-module-executed" + external_module.write_text( + "from pathlib import Path\n" + f"Path({str(marker)!r}).write_text('executed', encoding='utf-8')\n", + encoding="utf-8", + ) + try: + with pytest.raises(ConfigurationError, match="module file"): + load_strategy( + repo_root, + { + "root": _relative_to_repo(temporary_strategy_root, repo_root), + "module": module_name, + "symbol": "MODULE", + }, + ) + assert not marker.exists() + finally: + external_module.unlink(missing_ok=True) + + +def test_strategy_loader_has_no_private_import_lifecycle() -> None: + source = inspect.getsource(strategy_loader) + + assert "threading" not in source + assert "uuid" not in source + assert "_PRIVATE_NAMESPACE" not in source + assert "spec_from_file_location" not in source + assert "module_from_spec" not in source + assert "sys.modules" not in source + + +@pytest.mark.parametrize("fixture_name", ("minimal_strategy", "minimal_strategy_b")) +def test_minimal_strategy_prepares_two_days_one_column_without_orders( + repo_root: Path, + fixture_name: str, +) -> None: + loaded = load_strategy(repo_root, _fixture_config(fixture_name)) + + prepared = loaded.module.prepare(object(), {}) + + assert prepared.ledger_input.close.shape == (2, 1) + assert prepared.ledger_input.symbols == ("TEST",) + assert prepared.primary_program.orders.enabled.shape == (1,) + assert not prepared.primary_program.orders.enabled.any() + assert loaded.module.followup_program(prepared, object()) is None + assert loaded.module.build_extensions(prepared, object()) == () diff --git a/tests/local_quant_research/test_turtle_e2e.py b/tests/local_quant_research/test_turtle_e2e.py index 0503c91..1d84903 100644 --- a/tests/local_quant_research/test_turtle_e2e.py +++ b/tests/local_quant_research/test_turtle_e2e.py @@ -11,6 +11,7 @@ import pandas as pd import pyarrow.parquet as pq +from scripts.research import analysis_data from scripts.research.market_data.contracts import SnapshotSelection from scripts.research.market_data.query import MARKET_DATA_FIELDS from scripts.research.market_data.storage import create_snapshot, import_batch @@ -74,6 +75,8 @@ def test_turtle_project_completes_full_single_scenario_entrypoint( snapshot = None batch_ids: list[str] = [] run_output: Path | None = None + analysis_id = f"e2e-{token[:16]}" + archive_output = research_root / "archives" / analysis_id try: source = tmp_path / "turtle-e2e.csv" _write_market_csv(source, securities=securities, dates=dates) @@ -126,33 +129,19 @@ def test_turtle_project_completes_full_single_scenario_entrypoint( encoding="utf-8", ) run_config = { - "schema_version": 1, + "schema_version": 2, "project_id": "strategy-003", + "strategy": { + "root": "joinquant/strategies/strategy-003/research", + "module": "turtle_etf.strategy", + "symbol": "MODULE", + }, "snapshot_id": snapshot.snapshot_id, "snapshot_requirements": snapshot_document["selection"], - "project_entry": ( - "joinquant/strategies/strategy-003/research/" - "turtle_etf/vectorbt_cli.py" - ), - "command": [ - ".venv/Scripts/python.exe", - ( - "joinquant/strategies/strategy-003/research/" - "turtle_etf/vectorbt_cli.py" - ), - ], - "project_config": config_path.relative_to(repo_root).as_posix(), - "code_identity": ( - "joinquant/strategies/strategy-003/research/code-identity.json" - ), + "scenario_config": config_path.relative_to(repo_root).as_posix(), "declared_inputs": [ "joinquant/strategies/strategy-003/manifest.json" ], - "required_outputs": [ - {"path": "backtests/local-baseline", "format": "directory"} - ], - "output_root": ".local/quant-research", - "stop_states": ["complete", "evidence_insufficient", "failed"], } run_path = project_root / "run.json" run_path.write_text( @@ -179,12 +168,9 @@ def test_turtle_project_completes_full_single_scenario_entrypoint( outcome = json.loads(completed.stdout) assert outcome["status"] == "complete" run_output = Path(outcome["run_path"]) - status = json.loads( - (run_output / "project-status.json").read_text(encoding="utf-8") - ) - assert status["next_action"] == "return_to_caller" + assert outcome["next_action"] == "return_to_caller" - result_root = run_output / "backtests/local-baseline" + result_root = run_output manifest = json.loads( (result_root / "manifest.json").read_text(encoding="utf-8") ) @@ -193,20 +179,21 @@ def test_turtle_project_completes_full_single_scenario_entrypoint( "balances", "positions", "orders", - "risk", - "period_risks", } performance = json.loads( - (result_root / "performance.json").read_text(encoding="utf-8") + (result_root / "evidence/performance.json").read_text(encoding="utf-8") ) - assert performance["result_match"] is True - assert performance["cold_seconds"] < 180.0 - assert performance["warm_seconds"] < 180.0 - assert performance["cleanup"]["verified"] is True + assert performance["cold"]["digest"] == performance["warm"]["digest"] + assert performance["cold"]["seconds"] < 180.0 + assert performance["warm"]["seconds"] < 180.0 + assert (result_root / "config/code-identity.json").is_file() + assert (result_root / "evidence/runtime-lock.json").is_file() + assert (result_root / "report/execution-summary.md").is_file() + assert (result_root / "report/metrics.json").is_file() - attribution_ref = manifest["extensions"]["turtle_etf"][ - "attribution_log" - ]["files"][0]["path"] + attribution_ref = manifest["extensions"]["turtle_etf"]["files"][0][ + "path" + ] attribution = pq.read_table(result_root / attribution_ref).to_pandas() redistributions = attribution.loc[ (attribution["event_type"] == "decision") @@ -225,9 +212,82 @@ def test_turtle_project_completes_full_single_scenario_entrypoint( assert any(float(item["portfolio_scale"]) < 1.0 for item in details) assert not tuple(result_root.rglob("*.tmp")) assert not tuple(market_root.rglob("*.duckdb")) + + promote = subprocess.run( + [ + str(repo_root / ".venv/Scripts/python.exe"), + str(repo_root / "scripts/research/local_quant_research/cli.py"), + "promote", + "--strategy-id", + "strategy-003", + "--run-id", + outcome["run_id"], + "--analysis-id", + analysis_id, + ], + cwd=repo_root, + capture_output=True, + text=True, + shell=False, + check=False, + timeout=120, + ) + assert promote.returncode == 0, promote.stderr + promote.stdout + promoted = json.loads(promote.stdout) + assert promoted["status"] == "complete" + assert promoted["reused"] is False + assert archive_output.is_dir() + source_files = { + path.relative_to(run_output).as_posix(): hashlib.sha256( + path.read_bytes() + ).hexdigest() + for path in run_output.rglob("*") + if path.is_file() + } + archive_files = { + path.relative_to(archive_output).as_posix(): hashlib.sha256( + path.read_bytes() + ).hexdigest() + for path in archive_output.rglob("*") + if path.is_file() + } + assert archive_files == source_files + + reused = subprocess.run( + [ + str(repo_root / ".venv/Scripts/python.exe"), + str(repo_root / "scripts/research/local_quant_research/cli.py"), + "promote", + "--strategy-id", + "strategy-003", + "--run-id", + outcome["run_id"], + "--analysis-id", + analysis_id, + ], + cwd=repo_root, + capture_output=True, + text=True, + shell=False, + check=False, + timeout=120, + ) + assert reused.returncode == 0 + assert json.loads(reused.stdout)["reused"] is True + + shutil.rmtree(run_output) + run_output = None + archived_source = analysis_data.open_analysis_source(archive_output) + assert archived_source.kind == "local_research" + assert archived_source.manifest["object"]["run_id"] == outcome["run_id"] finally: if run_output is not None: shutil.rmtree(run_output, ignore_errors=True) + shutil.rmtree(archive_output, ignore_errors=True) + try: + archive_output.parent.rmdir() + except OSError: + pass shutil.rmtree(project_root, ignore_errors=True) if snapshot is not None: snapshot.path.unlink(missing_ok=True) diff --git a/tests/local_quant_research/test_turtle_indicators.py b/tests/local_quant_research/test_turtle_indicators.py index bbb3fc6..c869d3a 100644 --- a/tests/local_quant_research/test_turtle_indicators.py +++ b/tests/local_quant_research/test_turtle_indicators.py @@ -16,7 +16,7 @@ ) sys.path.insert(0, str(RESEARCH_ROOT)) -from turtle_etf.indicators import breakout_levels, true_range, turtle_n # noqa: E402 +from turtle_etf._kernel import breakout_levels, true_range, turtle_n # noqa: E402 def test_true_range_uses_all_three_unadjusted_price_components() -> None: diff --git a/tests/local_quant_research/test_turtle_result_adapter.py b/tests/local_quant_research/test_turtle_result_adapter.py index c7ee033..2e85fe8 100644 --- a/tests/local_quant_research/test_turtle_result_adapter.py +++ b/tests/local_quant_research/test_turtle_result_adapter.py @@ -1,15 +1,19 @@ from __future__ import annotations -import hashlib +import importlib import json import sys from pathlib import Path -from types import SimpleNamespace -import numpy as np -import pandas as pd import pytest +from scripts.research.local_quant_research.contracts import ExecutionBundle +from scripts.research.local_quant_research.vectorbt_runtime import run_vectorbt +from scripts.research.market_data.economic_returns import ( + canonical_corporate_actions_digest, +) +from scripts.research.market_data.query import SnapshotView + RESEARCH_ROOT = ( Path(__file__).resolve().parents[2] @@ -20,804 +24,248 @@ ) sys.path.insert(0, str(RESEARCH_ROOT)) -from scripts.research.analysis_data import open_analysis_source # noqa: E402 -from turtle_etf.result_adapter import ( # noqa: E402 - ATTRIBUTION_FIELDS, - ATTRIBUTION_SCHEMA_VERSION, - ResultContractError, - to_joinquant_facts, - validate_turtle_result, - validate_turtle_attribution, - write_local_result, -) -from turtle_etf.vectorbt_callbacks import ( # noqa: E402 - ACTION_ADDITION, - ACTION_ENTRY, - ACTION_FULL_EXIT, - ACTION_REDISTRIBUTION_SELL, - REASON_ENTRY_BREAKOUT, - REASON_FULL_POSITION_REDISTRIBUTION, - REASON_ORDER_REJECTED, - REASON_PROTECTIVE_STOP, - REASON_TREND_EXIT, -) -from turtle_etf.vectorbt_engine import run_vectorbt_simulation # noqa: E402 -from turtle_etf.vectorbt_inputs import SimulationInputs # noqa: E402 - - -class _Portfolio: - def value(self) -> pd.Series: - return pd.Series([9_990.0, 10_180.0]) - - def cash(self) -> pd.Series: - return pd.Series([6_990.0, 9_130.0]) - -def _readonly(values: object, dtype: str) -> np.ndarray: - result = np.ascontiguousarray(values, dtype=dtype) - result.setflags(write=False) - return result +ATTRIBUTION_FIELDS = ( + "time", + "event_id", + "scope", + "security", + "event_type", + "reason_code", + "requested_amount", + "executed_amount", + "reference_price", + "risk_before", + "risk_after", + "details_json", +) -def _simulation() -> tuple[SimpleNamespace, SimpleNamespace]: - inputs = SimpleNamespace( - dates=np.asarray(["2026-01-05", "2026-01-06"], dtype="datetime64[D]"), - securities=("ETF-A", "ETF-B"), - close=np.asarray([[10.0, 20.0], [11.0, 21.0]], dtype=np.float64), - signal_n=np.asarray([[1.0, 1.0], [1.0, 1.0]], dtype=np.float64), +def _snapshot(closes: tuple[float, ...]) -> SnapshotView: + rows = tuple( + { + "date": f"2026-01-{5 + index:02d}", + "security": "ETF-A", + "open": close + 0.25, + "high": close + 1.0, + "low": close - 1.0, + "close": close, + "pre_close": closes[max(index - 1, 0)], + "volume": 1_000_000.0, + "money": 10_000_000.0, + "factor": 1.0, + "paused": False, + "high_limit": close + 2.0, + "low_limit": close - 2.0, + } + for index, close in enumerate(closes) ) - simulation = SimpleNamespace( - initial_cash=10_000.0, - portfolio=_Portfolio(), - action_codes=np.asarray( - [ - [ACTION_ENTRY, ACTION_ENTRY], - [ACTION_FULL_EXIT, ACTION_REDISTRIBUTION_SELL], - ], - dtype=np.int16, - ), - reason_codes=np.asarray( - [ - [REASON_ENTRY_BREAKOUT, REASON_ENTRY_BREAKOUT], - [ - REASON_PROTECTIVE_STOP, - REASON_FULL_POSITION_REDISTRIBUTION, - ], - ], - dtype=np.int16, - ), - requested_quantities=np.asarray([[100, 100], [100, 50]], dtype=np.int64), - planned_quantities=np.asarray([[100, 100], [100, 50]], dtype=np.int64), - filled_quantities=np.asarray([[100, 100], [100, 50]], dtype=np.int64), - fill_prices=np.asarray([[10.0, 20.0], [11.0, 21.0]], dtype=np.float64), - fees=np.asarray([[5.0, 5.0], [5.0, 5.0]], dtype=np.float64), - state_quantities=np.asarray([[100, 100], [0, 50]], dtype=np.int64), - state_common_stop=np.asarray([[8.0, 18.0], [np.nan, 18.0]], dtype=np.float64), - state_next_add_index=np.asarray([[1, 1], [0, 1]], dtype=np.int64), - state_unit_counts=np.asarray([[1, 1], [0, 1]], dtype=np.int64), - candidate_base_quantities=np.asarray( - [[100, 100], [0, 0]], dtype=np.int64 - ), - event_group_scales=np.asarray( - [[1.0, 1.0], [1.0, 0.75]], dtype=np.float64 - ), - event_portfolio_scales=np.asarray( - [1.0, 12.0 / 13.0], dtype=np.float64 - ), - event_cash_scales=np.asarray([1.0, 0.9], dtype=np.float64), - portfolio_unit_cap=12.0, + return SnapshotView( + snapshot_id="attribution-test", + fields=tuple(rows[0]), + rows=rows, + digest="1" * 64, + corporate_action_fields=(), + corporate_actions=(), + corporate_actions_digest=canonical_corporate_actions_digest(()), ) - return inputs, simulation -def test_attribution_exposes_unit_and_redistribution_evidence() -> None: - inputs, simulation = _simulation() - - facts = to_joinquant_facts(inputs, simulation, scenario_id="baseline") - decisions = [ - row - for row in facts.attribution.to_pylist() - if row["event_type"] == "decision" - ] - entry = next( - row - for row in decisions - if row["time"].startswith("2026-01-05") - and row["security"] == "ETF-A" - ) - entry_details = json.loads(entry["details_json"]) - assert entry_details["candidate_base_quantity"] == 100 - assert entry_details["frozen_signal_n"] == 1.0 - assert entry_details["actual_fill_price"] == 10.0 - assert entry_details["unit_count_after"] == 1 - assert entry_details["common_stop_after"] == 8.0 - - redistribution = next( - row - for row in decisions - if row["reason_code"] == "full_position_redistribution" - ) - details = json.loads(redistribution["details_json"]) - assert details["unit_count_after"] == 1 - assert details["group_scale"] == pytest.approx(0.75) - assert details["portfolio_scale"] == pytest.approx(12.0 / 13.0) - assert details["cash_scale"] == pytest.approx(0.9) - assert details["redistribution_state_changed"] is False - - -def _delayed_inputs( - *, - delayed_open: float = 12.0, - rows: int = 3, -) -> SimulationInputs: - dates = np.arange( - np.datetime64("2026-01-05"), - np.datetime64("2026-01-05") + np.timedelta64(rows, "D"), +def _split_snapshot() -> SnapshotView: + closes = (100.0, 102.0, 51.0, 52.0, 53.0) + pre_closes = (100.0, 100.0, 51.0, 51.0, 52.0) + rows = tuple( + { + "date": f"2026-01-{5 + index:02d}", + "security": "ETF-A", + "open": close, + "high": close + 1.0, + "low": close - 1.0, + "close": close, + "pre_close": pre_closes[index], + "volume": 1_000_000.0, + "money": 10_000_000.0, + "factor": 1.0, + "paused": False, + "high_limit": close + 5.0, + "low_limit": close - 5.0, + } + for index, close in enumerate(closes) ) - opens = np.asarray([[10.0], *([[delayed_open]] * (rows - 1))], dtype=np.float64) - signal_close = np.full((rows, 1), np.nan, dtype=np.float64) - signal_entry_high = np.full((rows, 1), np.nan, dtype=np.float64) - signal_n = np.full((rows, 1), 999.0, dtype=np.float64) - signal_close[0, 0] = 11.0 - signal_entry_high[0, 0] = 10.0 - signal_n[0, 0] = 1.5 - return SimulationInputs( - dates=_readonly(dates, "datetime64[D]"), - securities=("ETF-A",), - asset_groups=("group-a",), - asset_group_ids=_readonly([0], "int64"), - raw_open=_readonly(opens, "float64"), - raw_high=_readonly(opens, "float64"), - raw_low=_readonly(opens, "float64"), - raw_close=_readonly(opens, "float64"), - raw_pre_close=_readonly(opens, "float64"), - continuous_open=_readonly(opens, "float64"), - continuous_high=_readonly(opens, "float64"), - continuous_low=_readonly(opens, "float64"), - continuous_close=_readonly(opens, "float64"), - continuous_pre_close=_readonly(opens, "float64"), - continuity_factor=_readonly(np.ones((rows, 1)), "float64"), - corporate_action_applied=_readonly(np.zeros((rows, 1)), "bool"), - corporate_actions_digest="4" * 64, - corporate_action_applications=(), - paused=_readonly(np.zeros((rows, 1)), "bool"), - high_limit=_readonly(np.full((rows, 1), np.nan), "float64"), - low_limit=_readonly(np.full((rows, 1), np.nan), "float64"), - signal_source_index=_readonly(np.arange(rows) - 1, "int64"), - signal_close=_readonly(signal_close, "float64"), - signal_entry_high=_readonly(signal_entry_high, "float64"), - signal_exit_low=_readonly(np.full((rows, 1), np.nan), "float64"), - signal_n=_readonly(signal_n, "float64"), + action = { + "source_event_id": "FUND_DIVIDEND:101", + "security": "ETF-A", + "event_type": "split", + "announcement_date": "2026-01-06", + "record_date": "2026-01-06", + "ex_date": "2026-01-07", + "effective_date": "2026-01-07", + "pay_date": None, + "status": "active", + "knowledge_cutoff_date": "2026-01-10", + "split_ratio": 2.0, + "cash_per_share": None, + "source": "joinquant.finance.FUND_DIVIDEND", + "source_record_sha256": "b" * 64, + } + actions = (action,) + return SnapshotView( + snapshot_id="corporate-action-test", + fields=tuple(rows[0]), + rows=rows, + digest="2" * 64, + corporate_action_fields=tuple(action), + corporate_actions=actions, + corporate_actions_digest=canonical_corporate_actions_digest(actions), ) -def _delayed_config(*, initial_cash: float = 100_000.0) -> dict[str, object]: +def _config(delay_days: int) -> dict[str, object]: return { - "research": {"initial_cash": initial_cash}, - "signal": {"add_step_n": 0.5, "stop_n": 2.0, "max_units": 4}, + "scenario_id": f"attribution-{delay_days}", + "universe": [{"security": "ETF-A", "asset_group": "equity"}], + "signal": { + "entry_days": 1, + "exit_days": 1, + "n_days": 1, + "add_step_n": 0.5, + "stop_n": 2.0, + "max_units": 4, + }, "risk": { - "unit_risk_per_n": 0.025, + "lot_size": 100, + "unit_risk_per_n": 0.01, "asset_group_unit_cap": 6.0, "portfolio_unit_cap": 12.0, }, "costs": {"commission_multiplier": 1.0, "one_way_slippage": 0.0}, - "execution": {"additional_delay_days": 1}, + "research": {"initial_cash": 100_000.0}, + "execution": {"additional_delay_days": delay_days}, } -def test_result_adapter_writes_joinquant_shaped_package(tmp_path: Path) -> None: - inputs, simulation = _simulation() - facts = to_joinquant_facts(inputs, simulation, scenario_id="baseline") - code = tmp_path / "entry.py" - code.write_text("print('local research')\n", encoding="utf-8") - backtest_dir = tmp_path / "run-1" / "backtests" / "local-1" - - package = write_local_result( - backtest_dir, - facts=facts, - run_id="run-1", - local_backtest_id="local-1", - scenario_id="baseline", - snapshot_id="a" * 64, - corporate_actions_sha256="e" * 64, - code_path=code, - params={"scenario_id": "baseline", "research": {"initial_cash": 10_000}}, - performance={"status": "pass", "cold_seconds": 1.2, "warm_seconds": 0.4}, - ) - - assert package.root == backtest_dir.resolve() - expected = { - "manifest.json", - "code.py", - "params.json", - "performance.json", - f"params_versions/{package.params_sha256}.json", - "data/results.parquet", - "data/balances.parquet", - "data/positions.parquet", - "data/orders.parquet", - f"data/attribution_log-{package.attribution_sha256}.parquet", - } - actual = { - path.relative_to(backtest_dir).as_posix() - for path in backtest_dir.rglob("*") - if path.is_file() - } - assert actual == expected - assert not any( - (backtest_dir / relative).exists() - for relative in ( - "data/risk.parquet", - "data/period_risks.parquet", - "data/equity.parquet", - "data/trades.parquet", - "raw", - ) - ) - - source = open_analysis_source(backtest_dir) - assert source.kind == "local_backtest" - manifest = source.manifest - assert manifest["source"]["accounting"] == { - "version": "turtle-etf-corporate-actions/1", - "corporate_action_mode": "point_in_time_total_return_approximation", - "continuity_factor_basis": "raw_previous_close_over_current_pre_close", - "corporate_action_metadata_timing": "audit_only_may_be_retrospective", - "price_basis": "continuous_economic_price", - "quantity_basis": "economic_units", - "cash_dividend_mode": "implicit_reinvestment_on_ex_date", - "pay_date_cash_supported": False, - "exact_joinquant_reconciliation": False, - "corporate_actions_sha256": "e" * 64, - } - assert manifest["run"] == { - "run_id": "run-1", - "scenario_id": "baseline", - "snapshot_id": "a" * 64, - } - attribution = manifest["extensions"]["turtle_etf"]["attribution_log"] - reference = attribution["files"][0] - assert attribution["required"] is True - assert attribution["status"] == "complete" - assert attribution["schema_version"] == ATTRIBUTION_SCHEMA_VERSION - assert reference["path"].endswith(f"{reference['sha256']}.parquet") - assert hashlib.sha256((backtest_dir / reference["path"]).read_bytes()).hexdigest() == reference[ - "sha256" +def _execute(closes: tuple[float, ...], delay_days: int): + module = importlib.import_module("turtle_etf.strategy").MODULE + prepared = module.prepare(_snapshot(closes), _config(delay_days)) + primary = run_vectorbt(prepared.ledger_input, prepared.primary_program) + followup = module.followup_program(prepared, primary) + if followup is None: + execution = ExecutionBundle(primary, primary, ("primary",)) + else: + final = run_vectorbt(prepared.ledger_input, followup) + execution = ExecutionBundle(primary, final, ("primary", "followup")) + extension = module.build_extensions(prepared, execution)[0] + return execution, extension + + +def test_attribution_extension_uses_exact_fields_and_covers_each_order() -> None: + execution, extension = _execute((10.0, 12.0, 13.0, 14.0), 0) + rows = extension.table.to_pylist() + + assert extension.name == "turtle_etf" + assert extension.schema_version == "turtle-etf-attribution/2" + assert tuple(extension.table.schema.names) == ATTRIBUTION_FIELDS + assert extension.unique_key == ("event_id",) + assert len({row["event_id"] for row in rows}) == len(rows) + assert [row["reason_code"] for row in rows if row["event_type"] == "decision"] == [ + "signal_entry" ] - - -def test_adapter_accepts_real_vectorbt_portfolio() -> None: - inputs = SimulationInputs( - dates=_readonly(["2026-01-05", "2026-01-06"], "datetime64[D]"), - securities=("ETF-A",), - asset_groups=("group-a",), - asset_group_ids=_readonly([0], "int64"), - raw_open=_readonly([[10.0], [10.5]], "float64"), - raw_high=_readonly([[10.0], [10.5]], "float64"), - raw_low=_readonly([[10.0], [10.5]], "float64"), - raw_close=_readonly([[10.0], [10.5]], "float64"), - raw_pre_close=_readonly([[10.0], [10.0]], "float64"), - continuous_open=_readonly([[10.0], [10.5]], "float64"), - continuous_high=_readonly([[10.0], [10.5]], "float64"), - continuous_low=_readonly([[10.0], [10.5]], "float64"), - continuous_close=_readonly([[10.0], [10.5]], "float64"), - continuous_pre_close=_readonly([[10.0], [10.0]], "float64"), - continuity_factor=_readonly([[1.0], [1.0]], "float64"), - corporate_action_applied=_readonly([[False], [False]], "bool"), - corporate_actions_digest="4" * 64, - corporate_action_applications=(), - paused=_readonly([[False], [False]], "bool"), - high_limit=_readonly([[np.nan], [np.nan]], "float64"), - low_limit=_readonly([[np.nan], [np.nan]], "float64"), - signal_source_index=_readonly([-1, 0], "int64"), - signal_close=_readonly([[11.0], [np.nan]], "float64"), - signal_entry_high=_readonly([[10.0], [np.nan]], "float64"), - signal_exit_low=_readonly([[np.nan], [np.nan]], "float64"), - signal_n=_readonly([[1.0], [1.0]], "float64"), + assert sum(row["event_type"] == "decision" for row in rows) == len( + execution.final.ledger.orders ) - config = { - "research": {"initial_cash": 10_000.0}, - "signal": {"add_step_n": 0.5, "stop_n": 2.0, "max_units": 4}, - "risk": { - "unit_risk_per_n": 0.025, - "asset_group_unit_cap": 6.0, - "portfolio_unit_cap": 12.0, - }, - "costs": {"commission_multiplier": 1.0, "one_way_slippage": 0.0}, - } - - simulation = run_vectorbt_simulation(inputs, config) - facts = to_joinquant_facts(inputs, simulation, scenario_id="real-vectorbt") - - assert facts.orders.num_rows == 1 - assert facts.positions.num_rows == 2 - assert facts.results["returns"].to_pylist()[0] == pytest.approx(-0.0005) - validate_turtle_attribution(facts) - - -def test_delayed_order_keeps_planned_and_execution_dates_and_frozen_evidence() -> None: - inputs = _delayed_inputs() - simulation = run_vectorbt_simulation(inputs, _delayed_config()) + assert all(isinstance(json.loads(row["details_json"]), dict) for row in rows) - facts = to_joinquant_facts(inputs, simulation, scenario_id="delayed") - order = facts.orders.to_pylist()[0] - assert order["entrust_time"] == "2026-01-05 09:30:00" - assert order["match_time"] == "2026-01-06 09:30:00" - assert order["finish_time"] == order["match_time"] - assert order["time"] == order["match_time"] - assert order["amount"] == int(simulation.planned_quantities[1, 0]) - assert order["filled"] == int(simulation.filled_quantities[1, 0]) +def test_delayed_attribution_preserves_planned_and_execution_dates() -> None: + _, extension = _execute((10.0, 12.0, 13.0, 14.0), 1) decision = next( - row - for row in facts.attribution.to_pylist() - if row["event_type"] == "decision" + row for row in extension.table.to_pylist() if row["event_type"] == "decision" ) details = json.loads(decision["details_json"]) - assert details["planned_date"] == "2026-01-05" - assert details["execution_date"] == "2026-01-06" + + assert decision["time"] == "2026-01-08 09:30:00" + assert details["planned_date"] == "2026-01-07" + assert details["execution_date"] == "2026-01-08" assert details["delay_days"] == 1 - assert details["frozen_reason"] == "entry_breakout" - assert details["frozen_target_amount"] == order["amount"] - assert details["frozen_signal_n"] == 1.5 + assert details["frozen_target_amount"] == decision["requested_amount"] assert details["execution_adjustment"] == "none" -def test_delayed_partial_fill_preserves_frozen_order_amount() -> None: - inputs = _delayed_inputs(delayed_open=200.0) - simulation = run_vectorbt_simulation( - inputs, _delayed_config(initial_cash=25_000.0) - ) - - facts = to_joinquant_facts(inputs, simulation, scenario_id="delayed-partial") +def test_horizon_expired_order_is_attribution_only() -> None: + execution, extension = _execute((10.0, 12.0, 13.0), 1) + rows = extension.table.to_pylist() - order = facts.orders.to_pylist()[0] - assert order["status"] == "done" - assert order["comment"] == "cash_truncated" - assert order["filled"] == 100 - assert order["amount"] > order["filled"] - decision = next( - row - for row in facts.attribution.to_pylist() - if row["event_type"] == "decision" - ) - assert json.loads(decision["details_json"])["execution_adjustment"] == ( - "cash_truncated" - ) - - -def test_delayed_horizon_expiry_is_attribution_only() -> None: - inputs = _delayed_inputs(rows=1) - simulation = run_vectorbt_simulation(inputs, _delayed_config()) - - facts = to_joinquant_facts(inputs, simulation, scenario_id="delayed-expired") - - assert facts.orders.num_rows == 0 - expired = [ - row - for row in facts.attribution.to_pylist() - if json.loads(row["details_json"]).get("execution_adjustment") - == "horizon_expired" - ] - assert len(expired) == 1 - assert expired[0]["time"] == "2026-01-05 09:30:00" - assert expired[0]["requested_amount"] > 0 - assert expired[0]["executed_amount"] == 0 - - -def test_physical_fields_and_cross_table_facts_match_joinquant_contract( - tmp_path: Path, -) -> None: - inputs, simulation = _simulation() - facts = to_joinquant_facts(inputs, simulation, scenario_id="baseline") - - assert facts.results.schema.names == ["benchmark_returns", "returns", "time"] - assert facts.balances.schema.names == [ - "total_value", - "net_value", - "cash", - "aval_cash", - "time", - ] - assert facts.positions.schema.names == [ - "pindex", - "avg_cost", - "margin", - "amount", - "today_amount", - "hold_cost", - "side", - "price", - "gains", - "daily_gains", - "closeable_amount", - "time", - "security_name", - "security", - ] - assert facts.orders.schema.names == [ - "match_time", - "pindex", - "cancel_time", - "action", - "limit_price", - "comment", - "entrust_time", - "finish_time", - "side", - "price", - "commission", - "gains", - "type", - "time", - "security_name", - "security", - "filled", - "amount", - "status", - ] - assert facts.attribution.schema.names == list(ATTRIBUTION_FIELDS) - assert facts.results["benchmark_returns"].null_count == facts.results.num_rows - assert facts.results["returns"].to_pylist() == pytest.approx([-0.001, 0.018]) - assert facts.balances["total_value"].to_pylist() == [9_990.0, 10_180.0] - assert facts.orders.num_rows == 4 - assert sum(facts.orders["filled"].to_pylist()) == 350 - assert facts.attribution.num_rows == 8 - validate_turtle_attribution(facts) - - -def test_corporate_action_application_is_audited_without_fake_order_or_cash() -> None: - inputs, simulation = _simulation() - inputs.corporate_action_applications = ( - SimpleNamespace( - source_event_id="FUND_DIVIDEND:101", - security="ETF-A", - event_type="split", - effective_date="2026-01-05", - application_date="2026-01-06", - announcement_date="2026-01-05", - knowledge_cutoff_date="2026-01-10", - split_ratio=2.0, - cash_per_share=None, - cumulative_factor=2.0, - price_basis_changed=True, - source="joinquant.finance.FUND_DIVIDEND", - source_record_sha256="b" * 64, - ), - ) - - facts = to_joinquant_facts(inputs, simulation, scenario_id="corporate-action") - - rows = [ - row - for row in facts.attribution.to_pylist() - if row["event_type"] == "corporate_action" - ] + assert len(execution.final.ledger.orders) == 0 assert len(rows) == 1 - assert rows[0]["reason_code"] == "corporate_action_applied" - assert rows[0]["requested_amount"] is None - assert rows[0]["executed_amount"] is None + assert rows[0]["event_type"] == "decision" + assert rows[0]["executed_amount"] == 0.0 details = json.loads(rows[0]["details_json"]) - assert details == { - "announcement_date": "2026-01-05", - "cash_per_share": None, - "corporate_action_mode": "point_in_time_total_return_approximation", - "cumulative_factor": 2.0, - "effective_date": "2026-01-05", - "application_date": "2026-01-06", - "event_type": "split", - "evidence_timing": "point_in_time", - "knowledge_cutoff_date": "2026-01-10", - "source": "joinquant.finance.FUND_DIVIDEND", - "source_event_id": "FUND_DIVIDEND:101", - "source_record_sha256": "b" * 64, - "split_ratio": 2.0, - "price_basis_changed": True, - } - assert not any( - order["time"].startswith("2026-01-06") - and order["comment"] == "corporate_action" - for order in facts.orders.to_pylist() - ) - assert facts.attribution.num_rows == 9 - validate_turtle_attribution(facts) - - -def test_attribution_uses_exact_openspec_fields_and_parseable_details() -> None: - inputs, simulation = _simulation() - - facts = to_joinquant_facts(inputs, simulation, scenario_id="baseline") - - assert ATTRIBUTION_FIELDS == ( - "time", - "event_id", - "scope", - "security", - "event_type", - "reason_code", - "requested_amount", - "executed_amount", - "reference_price", - "risk_before", - "risk_after", - "details_json", - ) - assert facts.attribution.schema.names == list(ATTRIBUTION_FIELDS) - rows = facts.attribution.to_pylist() - assert len({row["event_id"] for row in rows}) == len(rows) - for row in rows: - assert row["scope"] == "security" - assert isinstance(json.loads(row["details_json"]), dict) + assert details["execution_date"] is None + assert details["execution_adjustment"] == "horizon_expired" + + +def test_corporate_action_is_extension_evidence_without_synthetic_order() -> None: + module = importlib.import_module("turtle_etf.strategy").MODULE + prepared = module.prepare(_split_snapshot(), _config(0)) + primary = run_vectorbt(prepared.ledger_input, prepared.primary_program) + execution = ExecutionBundle(primary, primary, ("primary",)) + rows = module.build_extensions(prepared, execution)[0].table.to_pylist() + + event = next(row for row in rows if row["event_type"] == "corporate_action") + details = json.loads(event["details_json"]) + assert event["reason_code"] == "corporate_action_applied" + assert event["requested_amount"] is None + assert event["executed_amount"] is None + assert details["source_event_id"] == "FUND_DIVIDEND:101" + assert details["split_ratio"] == 2.0 + assert details["application_date"] == "2026-01-07" + assert all(str(order["comment"]) != "corporate_action" for order in primary.ledger.orders) + + +def test_delayed_cash_truncation_is_reported_from_shared_ledger_execution() -> None: + execution, extension = _execute((10.0, 12.0, 13.0, 1000.0), 1) decision = next( - row - for row in rows - if row["time"].startswith("2026-01-06") - and row["security"] == "ETF-A" - and row["event_type"] == "decision" + row for row in extension.table.to_pylist() if row["event_type"] == "decision" ) - assert decision["reason_code"] == "protective_stop" - assert decision["risk_before"] == pytest.approx(200.0) - assert decision["risk_after"] == pytest.approx(0.0) - - -def test_security_daily_pnl_reconciles_entry_partial_exit_and_full_exit() -> None: - inputs, simulation = _simulation() - - facts = to_joinquant_facts(inputs, simulation, scenario_id="baseline") - - held_pnl = { - (row["time"][:10], row["security"]): row["daily_gains"] - for row in facts.positions.to_pylist() - } - assert held_pnl == pytest.approx( - { - ("2026-01-05", "ETF-A"): -5.0, - ("2026-01-05", "ETF-B"): -5.0, - ("2026-01-06", "ETF-B"): 95.0, - } - ) - assert ("2026-01-06", "ETF-A") not in held_pnl + details = json.loads(decision["details_json"]) - valuations = [ - row for row in facts.attribution.to_pylist() if row["event_type"] == "valuation" - ] - daily_security_pnl = { - (row["time"][:10], row["security"]): json.loads(row["details_json"])[ - "security_daily_pnl" - ] - for row in valuations - } - assert daily_security_pnl == pytest.approx( - { - ("2026-01-05", "ETF-A"): -5.0, - ("2026-01-05", "ETF-B"): -5.0, - ("2026-01-06", "ETF-A"): 95.0, - ("2026-01-06", "ETF-B"): 95.0, - } - ) - assert sum( - value - for (date, _), value in daily_security_pnl.items() - if date == "2026-01-05" - ) == pytest.approx(-10.0) - assert sum( - value - for (date, _), value in daily_security_pnl.items() - if date == "2026-01-06" - ) == pytest.approx(190.0) - - -def test_security_daily_pnl_prices_additions_and_trend_exit_at_execution() -> None: - inputs = SimpleNamespace( - dates=np.asarray( - ["2026-01-05", "2026-01-06", "2026-01-07", "2026-01-08"], - dtype="datetime64[D]", - ), - securities=("ETF-A",), - close=np.asarray([[10.0], [11.0], [12.0], [11.0]], dtype=np.float64), - signal_n=np.asarray([[1.0], [1.0], [1.0], [1.0]], dtype=np.float64), - ) - simulation = SimpleNamespace( - initial_cash=10_000.0, - portfolio=SimpleNamespace( - value=lambda: pd.Series([10_049.0, 10_173.0, 10_362.0, 10_256.0]), - cash=lambda: pd.Series([9_049.0, 8_523.0, 9_522.0, 10_256.0]), - ), - action_codes=np.asarray( - [ - [ACTION_ENTRY], - [ACTION_ADDITION], - [ACTION_REDISTRIBUTION_SELL], - [ACTION_FULL_EXIT], - ], - dtype=np.int16, - ), - reason_codes=np.asarray( - [ - [REASON_ENTRY_BREAKOUT], - [REASON_ENTRY_BREAKOUT], - [REASON_FULL_POSITION_REDISTRIBUTION], - [REASON_TREND_EXIT], - ], - dtype=np.int16, - ), - requested_quantities=np.asarray([[100], [50], [80], [70]], dtype=np.int64), - planned_quantities=np.asarray([[100], [50], [80], [70]], dtype=np.int64), - filled_quantities=np.asarray([[100], [50], [80], [70]], dtype=np.int64), - fill_prices=np.asarray([[9.5], [10.5], [12.5], [10.5]], dtype=np.float64), - fees=np.asarray([[1.0], [1.0], [1.0], [1.0]], dtype=np.float64), - state_quantities=np.asarray([[100], [150], [70], [0]], dtype=np.int64), - state_common_stop=np.asarray([[8.0], [9.0], [9.0], [np.nan]], dtype=np.float64), - state_next_add_index=np.asarray([[1], [2], [2], [0]], dtype=np.int64), - ) + assert len(execution.final.ledger.orders) == 0 + assert decision["requested_amount"] == 300.0 + assert decision["executed_amount"] == 0.0 + assert details["execution_adjustment"] == "cash_truncated" + assert details["state_changed"] is False - facts = to_joinquant_facts(inputs, simulation, scenario_id="path-dependent") - assert facts.positions["daily_gains"].to_pylist() == pytest.approx( - [49.0, 124.0, 189.0] +def test_valuation_reconciles_addition_and_full_exit_to_public_value() -> None: + execution, extension = _execute( + (10.0, 12.0, 13.0, 14.0, 15.0, 10.0, 9.0), + 0, ) - valuation_rows = [ - row for row in facts.attribution.to_pylist() if row["event_type"] == "valuation" + rows = extension.table.to_pylist() + decisions = [ + (row["reason_code"], json.loads(row["details_json"])["action"]) + for row in rows + if row["event_type"] == "decision" ] - valuation_pnl = [ - json.loads(row["details_json"])["security_daily_pnl"] - for row in valuation_rows + valuations = [ + json.loads(row["details_json"]) + for row in rows + if row["event_type"] == "valuation" ] - assert valuation_pnl == pytest.approx([49.0, 124.0, 189.0, -106.0]) - final_details = json.loads(valuation_rows[-1]["details_json"]) - assert valuation_rows[-1]["reason_code"] == "signal_exit" - assert final_details["source_reason"] == "trend_exit" - assert final_details["position_after"] == 0 - - -def test_adapter_rejects_security_pnl_that_does_not_match_portfolio_change() -> None: - inputs, simulation = _simulation() - simulation.portfolio = SimpleNamespace( - value=lambda: pd.Series([9_990.0, 10_181.0]), - cash=lambda: pd.Series([6_990.0, 9_130.0]), - ) - - with pytest.raises(ResultContractError, match="daily PnL"): - to_joinquant_facts(inputs, simulation, scenario_id="broken-pnl") - - -def test_attribution_rejects_unknown_reason_and_uncovered_order() -> None: - inputs, simulation = _simulation() - facts = to_joinquant_facts(inputs, simulation, scenario_id="baseline") - document = facts.attribution.to_pydict() - document["reason_code"][0] = "unknown_reason" - invalid = facts.with_attribution(document) - with pytest.raises(ResultContractError, match="reason"): - validate_turtle_attribution(invalid) - - document = facts.attribution.slice(1).to_pydict() - uncovered = facts.with_attribution(document) - with pytest.raises(ResultContractError, match="cover"): - validate_turtle_attribution(uncovered) - - -def test_rejected_vectorbt_order_is_preserved_as_canceled_order() -> None: - inputs, simulation = _simulation() - simulation.action_codes[1, 0] = ACTION_ADDITION - simulation.reason_codes[1, 0] = REASON_ORDER_REJECTED - simulation.filled_quantities[1, 0] = 0 - simulation.fill_prices[1, 0] = np.nan - simulation.fees[1, 0] = 0.0 - simulation.state_quantities[1, 0] = 100 - simulation.state_common_stop[1, 0] = 8.0 - simulation.state_next_add_index[1, 0] = 1 - simulation.portfolio = SimpleNamespace( - value=lambda: pd.Series([9_990.0, 10_185.0]), - cash=lambda: pd.Series([6_990.0, 8_035.0]), - ) - facts = to_joinquant_facts(inputs, simulation, scenario_id="rejected-order") - - canceled = [ - item for item in facts.orders.to_pylist() if item["status"] == "canceled" + assert decisions == [ + ("signal_entry", "entry"), + ("signal_add", "addition"), + ("signal_exit", "full_exit"), ] - assert canceled == [ - { - "match_time": None, - "pindex": 0, - "cancel_time": "2026-01-06 09:30:00", - "action": "open", - "limit_price": 0.0, - "comment": "order_rejected", - "entrust_time": "2026-01-06 09:30:00", - "finish_time": None, - "side": "long", - "price": 0.0, - "commission": 0.0, - "gains": 0.0, - "type": "market", - "time": "2026-01-06 09:30:00", - "security_name": "ETF-A", - "security": "ETF-A", - "filled": 0, - "amount": 100, - "status": "canceled", - } - ] - validate_turtle_attribution(facts) - - -def test_params_current_and_version_are_byte_identical(tmp_path: Path) -> None: - inputs, simulation = _simulation() - facts = to_joinquant_facts(inputs, simulation, scenario_id="baseline") - code = tmp_path / "entry.py" - code.write_text("pass\n", encoding="utf-8") - root = tmp_path / "backtest" - package = write_local_result( - root, - facts=facts, - run_id="run-1", - local_backtest_id="local-1", - scenario_id="baseline", - snapshot_id="b" * 64, - corporate_actions_sha256="e" * 64, - code_path=code, - params={"z": 2, "a": 1}, - performance={"status": "pass"}, + assert [item["security_daily_pnl"] for item in valuations] == pytest.approx( + [-80.0, 300.0, 300.0, -1630.0, -605.0] ) - - current = (root / "params.json").read_bytes() - version = (root / "params_versions" / f"{package.params_sha256}.json").read_bytes() - assert current == version - assert hashlib.sha256(current).hexdigest() == package.params_sha256 - assert json.loads(current) == {"a": 1, "z": 2} - - -def test_existing_output_directory_is_never_overwritten(tmp_path: Path) -> None: - inputs, simulation = _simulation() - facts = to_joinquant_facts(inputs, simulation, scenario_id="baseline") - code = tmp_path / "entry.py" - code.write_text("pass\n", encoding="utf-8") - root = tmp_path / "backtest" - root.mkdir() - - with pytest.raises(ResultContractError, match="already exists"): - write_local_result( - root, - facts=facts, - run_id="run-1", - local_backtest_id="local-1", - scenario_id="baseline", - snapshot_id="c" * 64, - corporate_actions_sha256="e" * 64, - code_path=code, - params={"scenario_id": "baseline"}, - performance={"status": "pass"}, - ) - - -def test_project_validator_rejects_missing_attribution_declaration( - tmp_path: Path, -) -> None: - inputs, simulation = _simulation() - facts = to_joinquant_facts(inputs, simulation, scenario_id="baseline") - code = tmp_path / "entry.py" - code.write_text("pass\n", encoding="utf-8") - root = tmp_path / "backtest" - write_local_result( - root, - facts=facts, - run_id="run-1", - local_backtest_id="local-1", - scenario_id="baseline", - snapshot_id="d" * 64, - corporate_actions_sha256="e" * 64, - code_path=code, - params={"scenario_id": "baseline"}, - performance={"status": "pass"}, + assert all( + item["security_daily_pnl"] == pytest.approx(item["portfolio_daily_pnl"]) + for item in valuations ) - manifest_path = root / "manifest.json" - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - manifest["extensions"] = {} - manifest_path.write_text( - json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n", - encoding="utf-8", + assert valuations[-1]["position_after"] == 0 + assert execution.final.ledger.value[-1]["total_value"] == pytest.approx( + 98_285.0 ) - - with pytest.raises(ResultContractError, match="attribution declaration"): - validate_turtle_result(root) diff --git a/tests/local_quant_research/test_turtle_single_scenario.py b/tests/local_quant_research/test_turtle_single_scenario.py deleted file mode 100644 index 9674e1d..0000000 --- a/tests/local_quant_research/test_turtle_single_scenario.py +++ /dev/null @@ -1,138 +0,0 @@ -from __future__ import annotations - -import json -import sys -from pathlib import Path -from types import SimpleNamespace - -import pytest - - -RESEARCH_ROOT = ( - Path(__file__).resolve().parents[2] - / "joinquant" - / "strategies" - / "strategy-003" - / "research" -) -sys.path.insert(0, str(RESEARCH_ROOT)) - -from turtle_etf import single_scenario # noqa: E402 -from turtle_etf.result_adapter import LocalResultPackage # noqa: E402 - - -def _config() -> dict[str, object]: - return { - "schema_version": 1, - "project_id": "strategy-003", - "scenario_id": "baseline", - "research": {"initial_cash": 1_500_000}, - } - - -def test_one_call_executes_exactly_one_scenario_and_returns_to_caller( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - calls: list[object] = [] - facts = SimpleNamespace(name="facts") - def fake_benchmark(**kwargs: object) -> SimpleNamespace: - calls.append(kwargs) - return SimpleNamespace(facts=facts, performance={"status": "pass"}) - - monkeypatch.setattr(single_scenario, "benchmark_scenario", fake_benchmark) - - def fake_write(target: Path, **kwargs: object) -> LocalResultPackage: - calls.append((target, kwargs)) - target.mkdir(parents=True) - return LocalResultPackage( - root=target.resolve(), params_sha256="a" * 64, attribution_sha256="b" * 64 - ) - - monkeypatch.setattr(single_scenario, "write_local_result", fake_write) - code = tmp_path / "cli.py" - code.write_text("pass\n", encoding="utf-8") - - outcome = single_scenario.execute_prepared_scenario( - prepared_inputs=SimpleNamespace( - name="prepared", corporate_actions_digest="f" * 64 - ), - config=_config(), - output_dir=tmp_path / "output", - run_id="run-1", - snapshot_id="c" * 64, - code_sha256="e" * 64, - config_sha256="d" * 64, - code_path=code, - ) - - benchmark_calls = [call for call in calls if isinstance(call, dict)] - assert len(benchmark_calls) == 1 - assert benchmark_calls[0]["prepared_inputs"].name == "prepared" - assert benchmark_calls[0]["scenario_id"] == "baseline" - write_calls = [call for call in calls if isinstance(call, tuple)] - assert write_calls[0][1]["corporate_actions_sha256"] == "f" * 64 - assert outcome.scenario_id == "baseline" - assert outcome.local_backtest_id == "local-baseline" - assert outcome.next_action == "return_to_caller" - assert outcome.result_path == ( - tmp_path / "output" / "backtests" / "local-baseline" - ).resolve() - assert len(list((tmp_path / "output" / "backtests").iterdir())) == 1 - - -@pytest.mark.parametrize("forbidden", ["candidates", "scenarios", "analysis_plan"]) -def test_single_scenario_rejects_batch_or_analysis_inputs( - forbidden: str, -) -> None: - config = _config() - config[forbidden] = [] - - with pytest.raises(single_scenario.SingleScenarioError, match="single scenario"): - single_scenario.validate_single_scenario_config(config) - - -def test_project_status_is_minimal_and_returns_to_caller(tmp_path: Path) -> None: - single_scenario.write_project_status( - tmp_path, - status="complete", - reason_codes=(), - next_action="return_to_caller", - ) - - assert json.loads((tmp_path / "project-status.json").read_text(encoding="utf-8")) == { - "schema_version": 1, - "status": "complete", - "reason_codes": [], - "next_action": "return_to_caller", - } - - -def test_repository_entry_and_run_config_expose_only_one_result( - repo_root: Path, -) -> None: - research_root = repo_root / "joinquant/strategies/strategy-003/research" - baseline = json.loads((research_root / "baseline.json").read_text(encoding="utf-8")) - run_config = json.loads( - (research_root / "project-run.json").read_text(encoding="utf-8") - ) - entry = ( - research_root / "turtle_etf/vectorbt_cli.py" - ).read_text(encoding="utf-8") - - assert baseline["scenario_id"] == "baseline" - assert run_config["project_entry"].endswith("/vectorbt_cli.py") - assert run_config["required_outputs"] == [ - {"path": "backtests/local-baseline", "format": "directory"} - ] - assert "benchmark_input" not in run_config - assert "candidates.json" not in run_config["declared_inputs"] - assert "corporate_actions=snapshot.corporate_actions" in entry - assert "corporate_actions_digest=snapshot.corporate_actions_digest" in entry - for forbidden in ( - "run_candidate_set", - "quant_analysis", - "analysis-plan.json", - "candidate-strategies.json", - "Vibe-Trading", - ): - assert forbidden not in entry diff --git a/tests/local_quant_research/test_turtle_strategy_module.py b/tests/local_quant_research/test_turtle_strategy_module.py new file mode 100644 index 0000000..3bd9ff1 --- /dev/null +++ b/tests/local_quant_research/test_turtle_strategy_module.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import ast +import importlib +import sys +from pathlib import Path + +import numpy as np + +from scripts.research.local_quant_research.contracts import ExecutionBundle +from scripts.research.local_quant_research.vectorbt_runtime import run_vectorbt +from scripts.research.market_data.economic_returns import ( + canonical_corporate_actions_digest, +) +from scripts.research.market_data.query import SnapshotView +from scripts.research.local_quant_research.strategy_loader import ( + discover_strategy_sources, +) + + +RESEARCH_ROOT = ( + Path(__file__).resolve().parents[2] + / "joinquant" + / "strategies" + / "strategy-003" + / "research" +) +sys.path.insert(0, str(RESEARCH_ROOT)) + + +def test_turtle_package_has_one_public_strategy_symbol() -> None: + turtle_etf = importlib.import_module("turtle_etf") + strategy = importlib.import_module("turtle_etf.strategy") + module = strategy.MODULE + + assert module.descriptor.strategy_id == "strategy-003" + assert module.descriptor.extension_names == ("turtle_etf",) + assert set(turtle_etf.__all__) == {"MODULE"} + assert turtle_etf.MODULE is module + assert callable(module.prepare) + assert callable(module.followup_program) + assert callable(module.build_extensions) + + +def test_turtle_private_strategy_sources_do_not_import_vectorbt() -> None: + root = RESEARCH_ROOT / "turtle_etf" + for name in ("strategy.py", "_kernel.py", "_attribution.py", "_delayed.py"): + tree = ast.parse((root / name).read_text(encoding="utf-8")) + imports = { + alias.name + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + } + imports |= { + node.module + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module + } + assert all(not item.startswith("vectorbt") for item in imports) + + +def test_attribution_module_does_not_define_a_second_core_result_adapter() -> None: + tree = ast.parse( + (RESEARCH_ROOT / "turtle_etf/_attribution.py").read_text(encoding="utf-8") + ) + definitions = { + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.ClassDef, ast.FunctionDef)) + } + + assert { + "LocalExecutionFacts", + "to_joinquant_facts", + "_validate_common_facts", + "validate_turtle_attribution", + }.isdisjoint(definitions) + + +def test_turtle_source_identity_includes_private_implementation() -> None: + sources = discover_strategy_sources(RESEARCH_ROOT, "turtle_etf.strategy") + relative = {path.relative_to(RESEARCH_ROOT).as_posix() for path in sources} + + assert { + "turtle_etf/strategy.py", + "turtle_etf/_kernel.py", + "turtle_etf/_attribution.py", + "turtle_etf/_delayed.py", + }.issubset(relative) + strategy = importlib.import_module("turtle_etf.strategy") + assert not hasattr(strategy.MODULE.descriptor, "source_files") + + +def _snapshot() -> SnapshotView: + closes = (10.0, 12.0, 13.0, 14.0) + rows = tuple( + { + "date": f"2026-01-{5 + index:02d}", + "security": "ETF-A", + "open": close + 0.25, + "high": close + 1.0, + "low": close - 1.0, + "close": close, + "pre_close": closes[max(index - 1, 0)], + "volume": 1_000_000.0, + "money": 10_000_000.0, + "factor": 1.0, + "paused": False, + "high_limit": close + 2.0, + "low_limit": close - 2.0, + } + for index, close in enumerate(closes) + ) + return SnapshotView( + snapshot_id="snapshot-test", + fields=tuple(rows[0]), + rows=rows, + digest="1" * 64, + corporate_action_fields=(), + corporate_actions=(), + corporate_actions_digest=canonical_corporate_actions_digest(()), + ) + + +def _config(*, delay_days: int = 0) -> dict[str, object]: + return { + "scenario_id": "module-test", + "universe": [{"security": "ETF-A", "asset_group": "equity"}], + "signal": { + "entry_days": 1, + "exit_days": 1, + "n_days": 1, + "add_step_n": 0.5, + "stop_n": 2.0, + "max_units": 4, + }, + "risk": { + "lot_size": 100, + "unit_risk_per_n": 0.01, + "asset_group_unit_cap": 6.0, + "portfolio_unit_cap": 12.0, + }, + "costs": {"commission_multiplier": 1.0, "one_way_slippage": 0.0}, + "research": {"initial_cash": 100_000.0}, + "execution": {"additional_delay_days": delay_days}, + } + + +def test_turtle_module_executes_immediate_orders_through_shared_runtime() -> None: + module = importlib.import_module("turtle_etf.strategy").MODULE + prepared = module.prepare(_snapshot(), _config()) + + primary = run_vectorbt(prepared.ledger_input, prepared.primary_program) + + assert primary.trace["action_codes"][:, 0].tolist() == [0, 0, 3, 0] + assert len(primary.ledger.orders) == 1 + assert primary.ledger.orders["filled"].tolist() == [300] + assert primary.ledger.orders["time"].tolist() == ["2026-01-07T09:30:00"] + assert primary.ledger.orders["security"].tolist() == ["ETF-A"] + assert np.all(primary.ledger.orders["status"] == "done") + + +def test_turtle_module_replays_delayed_plan_through_same_runtime() -> None: + module = importlib.import_module("turtle_etf.strategy").MODULE + prepared = module.prepare(_snapshot(), _config(delay_days=1)) + primary = run_vectorbt(prepared.ledger_input, prepared.primary_program) + + followup = module.followup_program(prepared, primary) + assert followup is not None + final = run_vectorbt(prepared.ledger_input, followup) + extension = module.build_extensions( + prepared, + ExecutionBundle(primary, final, ("primary", "followup")), + )[0] + + assert final.trace["planned_row_indices"][:, 0].tolist() == [-1, -1, -1, 2] + assert len(final.ledger.orders) == 1 + assert final.ledger.orders["filled"].tolist() == [300] + assert final.ledger.orders["time"].tolist() == ["2026-01-08T09:30:00"] + assert extension.name == "turtle_etf" + assert extension.table["event_type"].to_pylist() == ["decision", "valuation"] + + +def test_strategy_trace_does_not_mirror_vectorbt_account_facts() -> None: + module = importlib.import_module("turtle_etf.strategy").MODULE + prepared = module.prepare(_snapshot(), _config(delay_days=1)) + primary = run_vectorbt(prepared.ledger_input, prepared.primary_program) + followup = module.followup_program(prepared, primary) + assert followup is not None + final = run_vectorbt(prepared.ledger_input, followup) + + forbidden = { + "filled_quantities", + "fill_prices", + "fees", + "state_quantities", + "day_equity", + } + assert forbidden.isdisjoint(primary.trace) + assert forbidden.isdisjoint(final.trace) + + +def test_segment_runtime_uses_prepare_time_scratch_buffers() -> None: + tree = ast.parse( + (RESEARCH_ROOT / "turtle_etf/_kernel.py").read_text(encoding="utf-8") + ) + runtime_functions = { + "prepare_segment_nb", + "_risk_scales_into_nb", + "_targets_for_scale_into_nb", + "_cash_feasible_targets_nb", + } + forbidden = {"zeros", "ones", "full", "array", "asarray", "copy"} + calls: set[str] = set() + for function in ( + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name in runtime_functions + ): + for call in (node for node in ast.walk(function) if isinstance(node, ast.Call)): + if isinstance(call.func, ast.Attribute): + calls.add(call.func.attr) + + assert forbidden.isdisjoint(calls) diff --git a/tests/local_quant_research/test_turtle_vectorbt_callbacks.py b/tests/local_quant_research/test_turtle_vectorbt_callbacks.py index 0638006..19f628c 100644 --- a/tests/local_quant_research/test_turtle_vectorbt_callbacks.py +++ b/tests/local_quant_research/test_turtle_vectorbt_callbacks.py @@ -2,11 +2,11 @@ import sys from pathlib import Path -from types import SimpleNamespace import numpy as np import pytest -from vectorbt.portfolio.enums import OrderResult, OrderSide, OrderStatus + +from scripts.research.local_quant_research.contracts import FillEvent RESEARCH_ROOT = ( @@ -18,13 +18,49 @@ ) sys.path.insert(0, str(RESEARCH_ROOT)) -from turtle_etf import vectorbt_callbacks as callbacks # noqa: E402 -from turtle_etf.vectorbt_engine import ( # noqa: E402 +from scripts.research.local_quant_research.vectorbt_runtime import run_vectorbt # noqa: E402 +from turtle_etf import _kernel as callbacks # noqa: E402 +from turtle_etf._kernel import ( # noqa: E402 + SimulationInputs, + _prepare_turtle_inputs, _mutable_state, _params, - run_vectorbt_simulation, ) -from turtle_etf.vectorbt_inputs import SimulationInputs # noqa: E402 + + +class _Result: + def __init__(self, execution: object, inputs: SimulationInputs) -> None: + self.execution = execution + shape = inputs.close.shape + rows = { + np.datetime_as_string(value, unit="D"): index + for index, value in enumerate(inputs.dates) + } + columns = {security: index for index, security in enumerate(inputs.securities)} + self.filled_quantities = np.zeros(shape, dtype=np.int64) + for order in execution.ledger.orders: + self.filled_quantities[ + rows[str(order["time"])[:10]], + columns[str(order["security"])], + ] += int(order["filled"]) + self.state_quantities = np.zeros(shape, dtype=np.int64) + for asset in execution.ledger.assets: + self.state_quantities[ + rows[str(asset["time"])[:10]], + columns[str(asset["security"])], + ] = int(round(float(asset["amount"]))) + + def __getattr__(self, name: str) -> object: + return self.execution.trace[name] + + +def _run(inputs: SimulationInputs, config: dict[str, object]) -> _Result: + config = {**config, "scenario_id": "callback-test"} + prepared = _prepare_turtle_inputs(inputs, config) + return _Result( + run_vectorbt(prepared.ledger_input, prepared.primary_program), + inputs, + ) def _ro(values: object, dtype: str) -> np.ndarray: @@ -171,7 +207,7 @@ def test_late_breakout_displaces_earlier_position_without_changing_its_unit() -> signal_close=[[11.0, np.nan], [10.0, 11.0], [10.0, 10.0]], entry_high=[[10.0, np.nan], [20.0, 10.0], [20.0, 20.0]], ) - result = run_vectorbt_simulation( + result = _run( inputs, _config(initial_cash=100_005.0, portfolio_cap=1.0) ) @@ -195,7 +231,7 @@ def test_same_group_units_scale_uniformly() -> None: entry_high=[[10.0, np.nan], [20.0, 10.0]], group_ids=[0, 0], ) - result = run_vectorbt_simulation( + result = _run( inputs, _config(initial_cash=100_005.0, group_cap=1.0) ) @@ -211,7 +247,7 @@ def test_each_filled_unit_freezes_its_own_n_and_only_raises_common_stop() -> Non entry_high=[[10.0], [20.0], [20.0]], signal_n=[[1.0], [2.0], [999.0]], ) - result = run_vectorbt_simulation(inputs, _config()) + result = _run(inputs, _config()) assert result.state_unit_counts[:, 0].tolist() == [1, 2, 2] assert result.state_common_stop[0, 0] == pytest.approx(8.0) @@ -227,7 +263,7 @@ def test_additions_use_fixed_initial_levels_one_per_day_and_stop_at_four() -> No entry_high=[[10.0], [20.0], [20.0], [20.0], [20.0]], signal_n=[[1.0], [8.0], [8.0], [8.0], [8.0]], ) - result = run_vectorbt_simulation(inputs, _config()) + result = _run(inputs, _config()) assert result.action_codes[:, 0].tolist() == [ callbacks.ACTION_ENTRY, @@ -247,7 +283,7 @@ def test_untradeable_candidate_does_not_advance_unit_stop_or_add_level() -> None paused=[[True], [False], [False]], high_limit=[[np.nan], [np.nan], [11.0]], ) - result = run_vectorbt_simulation(inputs, _config()) + result = _run(inputs, _config()) assert result.reason_codes[0, 0] == callbacks.REASON_PAUSED assert result.state_unit_counts[0, 0] == 0 @@ -265,12 +301,15 @@ def test_exit_sells_before_same_day_entry_uses_released_cash() -> None: entry_high=[[10.0, np.nan], [np.nan, 20.0], [np.nan, np.nan]], exit_low=[[np.nan, np.nan], [6.0, np.nan], [np.nan, np.nan]], ) - result = run_vectorbt_simulation(inputs, _config(initial_cash=10_005.0)) - day_two = result.portfolio.orders.records_readable.loc[ - lambda frame: frame["Timestamp"] == np.datetime64("2026-01-06") + result = _run(inputs, _config(initial_cash=10_005.0)) + day_two = result.execution.ledger.orders[ + np.char.startswith( + result.execution.ledger.orders["time"], + "2026-01-06", + ) ] - assert day_two["Side"].tolist() == ["Sell", "Buy"] + assert day_two["action"].tolist() == ["close", "open"] assert result.state_quantities[1, 0] == 0 assert result.state_unit_counts[1, 0] == 0 assert result.state_quantities[1, 1] > 0 @@ -284,7 +323,7 @@ def test_low_limit_blocks_full_exit_and_preserves_unit_state() -> None: exit_low=[[np.nan], [6.0]], low_limit=[[np.nan], [8.0]], ) - result = run_vectorbt_simulation(inputs, _config()) + result = _run(inputs, _config()) assert result.reason_codes[1, 0] == callbacks.REASON_LOW_LIMIT assert result.filled_quantities[1, 0] == 0 @@ -313,27 +352,26 @@ def test_rejected_official_order_does_not_establish_candidate() -> None: inputs.low_limit, inputs.asset_group_ids, ) - context = SimpleNamespace( - i=0, - col=0, - call_idx=0, - group_len=1, - from_col=0, - to_col=1, - position_now=0.0, - last_position=np.zeros(1, dtype=np.float64), - order_result=OrderResult( - size=100.0, - price=10.0, - fees=5.0, - side=OrderSide.Buy, - status=OrderStatus.Rejected, - status_info=0, - ), + event = FillEvent( + row=0, + column=0, + status=callbacks.FILL_REJECTED, + side=callbacks.SIDE_BUY, + size=100.0, + price=10.0, + fees=5.0, + cash_after=100_000.0, + position_after=0.0, ) - callbacks.post_order_func_nb.py_func(context, state, callback_inputs, params) + callbacks.after_fill_nb.py_func( + event, + callback_inputs, + params, + state, + (), + (), + ) assert state.reason_codes[0, 0] == callbacks.REASON_ORDER_REJECTED assert state.unit_count[0] == 0 - assert state.state_quantities[0, 0] == 0 diff --git a/tests/local_quant_research/test_turtle_vectorbt_delayed.py b/tests/local_quant_research/test_turtle_vectorbt_delayed.py index 638d7b3..1225009 100644 --- a/tests/local_quant_research/test_turtle_vectorbt_delayed.py +++ b/tests/local_quant_research/test_turtle_vectorbt_delayed.py @@ -6,6 +6,12 @@ import numpy as np +from scripts.research.local_quant_research.contracts import ( # noqa: E402 + ExecutionRun, + LedgerInput, + PreparedStrategy, +) +from scripts.research.local_quant_research.vectorbt_runtime import run_vectorbt # noqa: E402 RESEARCH_ROOT = ( Path(__file__).resolve().parents[2] @@ -16,7 +22,7 @@ ) sys.path.insert(0, str(RESEARCH_ROOT)) -from turtle_etf.vectorbt_callbacks import ( # noqa: E402 +from turtle_etf._kernel import ( # noqa: E402 ACTION_ENTRY, ACTION_FULL_EXIT, ACTION_REDISTRIBUTION_BUY, @@ -24,13 +30,14 @@ REASON_ENTRY_BREAKOUT, REASON_FULL_POSITION_REDISTRIBUTION, REASON_PROTECTIVE_STOP, + TurtleContext, + _params, ) -from turtle_etf.vectorbt_delayed import ( # noqa: E402 +from turtle_etf._delayed import ( # noqa: E402 ADJUST_CASH_TRUNCATED, ADJUST_HOLDING_TRUNCATED, ADJUST_NONE, - freeze_order_plan, - run_delayed_execution, + build_delayed_program, ) @@ -88,17 +95,91 @@ def _run( *, initial_cash: float = 100_000.0, ): - plan = freeze_order_plan(inputs, immediate) - return plan, run_delayed_execution( - inputs, - plan, + config = { + "research": {"initial_cash": initial_cash}, + "signal": {"add_step_n": 0.5, "stop_n": 2.0, "max_units": 4}, + "risk": { + "lot_size": 100, + "unit_risk_per_n": 0.01, + "asset_group_unit_cap": 6.0, + "portfolio_unit_cap": 12.0, + }, + "costs": {"commission_multiplier": 1.0, "one_way_slippage": 0.0}, + } + _, params = _params(config) + rows, columns = inputs.close.shape + trace = { + **{ + name: value + for name, value in vars(immediate).items() + if name != "filled_quantities" + }, + "candidate_base_quantities": np.zeros((rows, columns), dtype=np.int64), + "event_group_scales": np.ones((rows, columns), dtype=np.float64), + "event_portfolio_scales": np.ones(rows, dtype=np.float64), + "event_cash_scales": np.ones(rows, dtype=np.float64), + } + ledger_input = LedgerInput( + dates=inputs.dates, + symbols=inputs.securities, + close=inputs.close, initial_cash=initial_cash, - lot_size=100, - stop_n=2.0, - commission_multiplier=1.0, - one_way_slippage=0.0, - delay_days=1, + group_ids=np.zeros(columns, dtype=np.int64), + cash_sharing=True, + frequency="1D", + ) + prepared = PreparedStrategy( + ledger_input=ledger_input, + primary_program=SimpleNamespace(), + context=TurtleContext(inputs, params, "delayed-test", 1, initial_cash), + ) + primary_orders = [] + for row, column in zip(*np.nonzero(immediate.filled_quantities > 0)): + primary_orders.append( + ( + f"{np.datetime_as_string(inputs.dates[row], unit='D')}T09:30:00", + inputs.securities[column], + int(immediate.filled_quantities[row, column]), + ) + ) + primary = ExecutionRun( + ledger=SimpleNamespace( + orders=np.asarray( + primary_orders, + dtype=[("time", "U32"), ("security", "U64"), ("filled", "i8")], + ) + ), + trace=trace, + ) + program = build_delayed_program(prepared, primary) + assert program is not None + execution = run_vectorbt(ledger_input, program) + date_rows = { + np.datetime_as_string(value, unit="D"): index + for index, value in enumerate(inputs.dates) + } + security_columns = { + security: index for index, security in enumerate(inputs.securities) + } + filled = np.zeros((rows, columns), dtype=np.int64) + for order in execution.ledger.orders: + filled[ + date_rows[str(order["time"])[:10]], + security_columns[str(order["security"])], + ] += int(order["filled"]) + quantities = np.zeros((rows, columns), dtype=np.int64) + for asset in execution.ledger.assets: + quantities[ + date_rows[str(asset["time"])[:10]], + security_columns[str(asset["security"])], + ] = int(round(float(asset["amount"]))) + result = SimpleNamespace( + **execution.trace, + execution=execution, + filled_quantities=filled, + state_quantities=quantities, ) + return program.inputs, result def test_delayed_execution_freezes_original_action_target_reason_and_signal_n() -> None: @@ -113,7 +194,7 @@ def test_delayed_execution_freezes_original_action_target_reason_and_signal_n() plan, delayed = _run(inputs, immediate) - assert plan.signal_n[1, 0] == 1.5 + assert plan.plan_signal_n[1, 0] == 1.5 assert delayed.action_codes[2, 0] == ACTION_ENTRY assert delayed.reason_codes[2, 0] == REASON_ENTRY_BREAKOUT assert delayed.planned_quantities[2, 0] == 200 @@ -179,10 +260,8 @@ def test_delayed_queue_is_executed_in_original_priority_then_security_order() -> _, delayed = _run(inputs, immediate) - assert delayed.execution_sequence[2] == ( - "queued-from-row-1:ETF-A", - "queued-from-row-1:ETF-B", - ) + day = delayed.execution.ledger.orders + assert day["security"].tolist() == ["ETF-A", "ETF-B"] def test_vectorbt_ledger_uses_priority_sequence_not_inverse_column_ranks() -> None: @@ -207,13 +286,12 @@ def test_vectorbt_ledger_uses_priority_sequence_not_inverse_column_ranks() -> No _, delayed = _run(inputs, immediate, initial_cash=10_005.0) - assert delayed.execution_sequence[2] == ( - "queued-from-row-1:ETF-C", - "queued-from-row-1:ETF-A", - "queued-from-row-1:ETF-B", - ) + day = delayed.execution.ledger.orders[ + np.char.startswith(delayed.execution.ledger.orders["time"], "2026-01-07") + ] + assert day["security"].tolist() == ["ETF-C", "ETF-A", "ETF-B"] assert delayed.filled_quantities[2].tolist() == [100, 100, 100] - assert delayed.portfolio.orders.count() == 4 + assert len(delayed.execution.ledger.orders) == 4 def test_delayed_redistribution_keeps_units_and_stops_and_uses_priority() -> None: @@ -251,11 +329,10 @@ def test_delayed_redistribution_keeps_units_and_stops_and_uses_priority() -> Non _, delayed = _run(inputs, immediate) - assert delayed.execution_sequence[2] == ( - "queued-from-row-1:ETF-A", - "queued-from-row-1:ETF-B", - "queued-from-row-1:ETF-C", - ) + day = delayed.execution.ledger.orders[ + np.char.startswith(delayed.execution.ledger.orders["time"], "2026-01-07") + ] + assert day["security"].tolist() == ["ETF-A", "ETF-B", "ETF-C"] assert delayed.state_quantities[2].tolist() == [100, 100, 300] assert delayed.state_unit_counts[2].tolist() == [1, 1, 1] assert delayed.state_common_stop[2].tolist() == [8.0, 9.0, 8.0] diff --git a/tests/local_quant_research/test_turtle_vectorbt_engine.py b/tests/local_quant_research/test_turtle_vectorbt_engine.py index 042c889..53cdec5 100644 --- a/tests/local_quant_research/test_turtle_vectorbt_engine.py +++ b/tests/local_quant_research/test_turtle_vectorbt_engine.py @@ -1,9 +1,7 @@ from __future__ import annotations -import hashlib import importlib.metadata import importlib.util -import json import sys from pathlib import Path @@ -19,8 +17,7 @@ ) sys.path.insert(0, str(RESEARCH_ROOT)) -from turtle_etf.vectorbt_callbacks import CallbackInputs, CallbackParams # noqa: E402 -from turtle_etf.vectorbt_engine import _params # noqa: E402 +from turtle_etf._kernel import CallbackInputs, CallbackParams, _params # noqa: E402 def test_vectorbt_runtime_is_pinned_and_available(repo_root: Path) -> None: @@ -45,51 +42,7 @@ def test_vectorbt_runtime_is_pinned_and_available(repo_root: Path) -> None: assert importlib.metadata.version("vectorbt") == "1.1.0" -def test_vectorbt_execution_identity_and_license_are_auditable(repo_root: Path) -> None: - research_root = repo_root / "joinquant/strategies/strategy-003/research" - callback_path = research_root / "turtle_etf/vectorbt_callbacks.py" - identity = json.loads( - (research_root / "code-identity.json").read_text(encoding="utf-8") - ) - execution = identity["execution"] - - assert execution == { - "backend": "vectorbt.Portfolio.from_order_func", - "delayed_backend": "vectorbt.Portfolio.from_orders", - "adapter_version": "local-vectorbt-adapter/2", - "dependencies": { - "vectorbt": "1.1.0", - "numba": "0.66.0", - "numpy": "2.4.6", - "pandas": "3.0.3", - }, - "callbacks_sha256": hashlib.sha256(callback_path.read_bytes()).hexdigest(), - "accounting": { - "version": "turtle-etf-corporate-actions/1", - "corporate_action_mode": "point_in_time_total_return_approximation", - "continuity_factor_basis": "raw_previous_close_over_current_pre_close", - "corporate_action_metadata_timing": "audit_only_may_be_retrospective", - "price_basis": "continuous_economic_price", - "quantity_basis": "economic_units", - "cash_dividend_mode": "implicit_reinvestment_on_ex_date", - "pay_date_cash_supported": False, - "exact_joinquant_reconciliation": False, - }, - "license": { - "expression": "Apache-2.0 WITH Commons-Clause", - "usage": "internal_research_only", - "resale_prohibited": True, - }, - } - identity_paths = {item["path"] for item in identity["files"]} - assert { - "joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_inputs.py", - "joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_callbacks.py", - "joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_delayed.py", - "joinquant/strategies/strategy-003/research/turtle_etf/vectorbt_engine.py", - "scripts/research/market_data/economic_returns.py", - }.issubset(identity_paths) - +def test_vectorbt_license_is_auditable() -> None: distribution = importlib.metadata.distribution("vectorbt") license_file = next( file for file in distribution.files or () if str(file).endswith("LICENSE.md") diff --git a/tests/local_quant_research/test_turtle_vectorbt_inputs.py b/tests/local_quant_research/test_turtle_vectorbt_inputs.py index aeab905..8cd7188 100644 --- a/tests/local_quant_research/test_turtle_vectorbt_inputs.py +++ b/tests/local_quant_research/test_turtle_vectorbt_inputs.py @@ -19,8 +19,11 @@ ) sys.path.insert(0, str(RESEARCH_ROOT)) -from turtle_etf.vectorbt_engine import run_vectorbt_simulation # noqa: E402 -from turtle_etf.vectorbt_inputs import prepare_simulation_inputs # noqa: E402 +from scripts.research.local_quant_research.vectorbt_runtime import run_vectorbt # noqa: E402 +from turtle_etf._kernel import ( # noqa: E402 + _prepare_turtle_inputs, + prepare_simulation_inputs, +) def _frame( @@ -63,6 +66,7 @@ def _config() -> dict[str, object]: def _simulation_config() -> dict[str, object]: config = _config() + config["scenario_id"] = "inputs-test" config["research"] = {"initial_cash": 1_000_000.0} config["signal"] = { **config["signal"], @@ -221,22 +225,24 @@ def simulate(money: float | None): "ETF-A": _frame("ETF-A", close_a, money=money), "ETF-B": _frame("ETF-B", close_b, money=money), } - return run_vectorbt_simulation( - prepare_simulation_inputs(frames, _simulation_config()), - _simulation_config(), + config = _simulation_config() + prepared = _prepare_turtle_inputs( + prepare_simulation_inputs(frames, config), + config, ) + return run_vectorbt(prepared.ledger_input, prepared.primary_program) low = simulate(1.0) high = simulate(1_000_000_000_000.0) - assert np.array_equal(low.action_codes, high.action_codes) - assert np.array_equal(low.filled_quantities, high.filled_quantities) - assert low.portfolio.orders.count() == high.portfolio.orders.count() + assert np.array_equal(low.trace["action_codes"], high.trace["action_codes"]) + assert np.array_equal(low.ledger.orders, high.ledger.orders) + assert len(low.ledger.orders) == len(high.ledger.orders) missing = simulate(None) - assert np.array_equal(low.action_codes, missing.action_codes) - assert np.array_equal(low.filled_quantities, missing.filled_quantities) - assert low.portfolio.orders.count() == missing.portfolio.orders.count() - assert int(low.filled_quantities.sum()) > 0 + assert np.array_equal(low.trace["action_codes"], missing.trace["action_codes"]) + assert np.array_equal(low.ledger.orders, missing.ledger.orders) + assert len(low.ledger.orders) == len(missing.ledger.orders) + assert int(low.ledger.orders["filled"].sum()) > 0 def test_simulation_inputs_expose_only_strategy_and_risk_arrays() -> None: @@ -478,4 +484,3 @@ def test_corporate_action_digest_mismatch_closes_the_run() -> None: corporate_actions=[_corporate_action()], corporate_actions_digest="0" * 64, ) - diff --git a/tests/local_quant_research/test_turtle_vectorbt_performance.py b/tests/local_quant_research/test_turtle_vectorbt_performance.py index b07602b..d39ba91 100644 --- a/tests/local_quant_research/test_turtle_vectorbt_performance.py +++ b/tests/local_quant_research/test_turtle_vectorbt_performance.py @@ -1,167 +1,101 @@ from __future__ import annotations -import sys +import hashlib +import json +import statistics from pathlib import Path -from types import SimpleNamespace import pytest -RESEARCH_ROOT = ( - Path(__file__).resolve().parents[2] - / "joinquant" - / "strategies" - / "strategy-003" - / "research" +REFERENCE_SCENARIOS = ( + "immediate-11-etf", + "immediate-17-etf", + "delayed-11-etf-1d", ) -sys.path.insert(0, str(RESEARCH_ROOT)) - -from turtle_etf import vectorbt_benchmark # noqa: E402 -from turtle_etf.vectorbt_inputs import CorporateActionApplication # noqa: E402 - - -def test_benchmark_runs_cold_and_warm_once_then_cleans_temp( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +_REQUIRED_CLI_SAMPLE_FIELDS = { + "scenario", + "sample_type", + "sample_index", + "pid", + "run_id", + "package_sha256", + "reused", + "post_publish_validation", + "cold_cli_total_seconds", +} + + +def test_performance_observations_have_v2_engine_and_cli_samples( + repo_root: Path, ) -> None: - simulations = iter(("cold-simulation", "warm-simulation")) - facts = SimpleNamespace(name="same-facts") - run_calls: list[str] = [] - - def run(inputs: object, config: object) -> str: - value = next(simulations) - run_calls.append(value) - return value - - monkeypatch.setattr(vectorbt_benchmark, "run_vectorbt_simulation", run) - monkeypatch.setattr( - vectorbt_benchmark, - "to_joinquant_facts", - lambda inputs, simulation, scenario_id: facts, - ) - - def materialize(path: Path, value: object) -> str: - path.mkdir(parents=True) - (path / "evidence.bin").write_bytes(b"same") - return "e" * 64 - - monkeypatch.setattr( - vectorbt_benchmark, "materialize_execution_facts", materialize + fixture = json.loads( + ( + repo_root + / "tests/local_quant_research/fixtures/performance-baseline.json" + ).read_text(encoding="utf-8") ) - ticks = iter((10.0, 11.5, 20.0, 20.4)) - monkeypatch.setattr(vectorbt_benchmark.time, "perf_counter", lambda: next(ticks)) - work = tmp_path / "benchmark-work" - result = vectorbt_benchmark.benchmark_scenario( - prepared_inputs=SimpleNamespace( - identity="prepared", - corporate_action_applications=( - CorporateActionApplication( - source_event_id="FUND_DIVIDEND:101", - security="ETF-A", - event_type="split", - effective_date="2026-01-05", - application_date="2026-01-06", - announcement_date="2026-01-05", - knowledge_cutoff_date="2026-01-10", - evidence_timing="point_in_time", - split_ratio=2.0, - cash_per_share=None, - cumulative_factor=2.0, - price_basis_changed=True, - source="joinquant.finance.FUND_DIVIDEND", - source_record_sha256="b" * 64, - ), - ), - ), - config={"scenario_id": "baseline", "research": {"initial_cash": 1}}, - scenario_id="baseline", - work_dir=work, - code_sha256="a" * 64, - config_sha256="b" * 64, - ) - - assert run_calls == ["cold-simulation", "warm-simulation"] - assert result.facts is facts - assert result.performance["cold_seconds"] == 1.5 - assert result.performance["warm_seconds"] == pytest.approx(0.4) - assert result.performance["cold_result_sha256"] == "e" * 64 - assert result.performance["warm_result_sha256"] == "e" * 64 - assert result.performance["result_match"] is True - assert result.performance["limit_seconds"] == 180.0 - assert result.performance["cleanup"] == { - "cold_temporary_result_removed": True, - "warm_temporary_result_removed": True, - "work_directory_removed": True, - "verified": True, + assert fixture["protocol_version"] == "local-research-release/2" + environment_bytes = json.dumps( + fixture["environment"], + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + assert fixture["environment_identity_sha256"] == hashlib.sha256( + environment_bytes + ).hexdigest() + assert set(fixture["environment"]) == { + "architecture", + "dependencies", + "logical_cpu_count", + "os", + "os_release", + "os_version", + "physical_memory_bytes", + "processor", + "python", } - assert not work.exists() - - -def test_benchmark_rejects_nondeterminism_and_cleans_temp( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - vectorbt_benchmark, - "run_vectorbt_simulation", - lambda inputs, config: object(), - ) - monkeypatch.setattr( - vectorbt_benchmark, - "to_joinquant_facts", - lambda inputs, simulation, scenario_id: object(), - ) - digests = iter(("c" * 64, "d" * 64)) - - def materialize(path: Path, facts: object) -> str: - path.mkdir(parents=True) - return next(digests) - - monkeypatch.setattr( - vectorbt_benchmark, "materialize_execution_facts", materialize - ) - work = tmp_path / "benchmark-work" - - with pytest.raises(vectorbt_benchmark.PerformanceGateError, match="deterministic"): - vectorbt_benchmark.benchmark_scenario( - prepared_inputs=SimpleNamespace(identity="prepared"), - config={"scenario_id": "baseline"}, - scenario_id="baseline", - work_dir=work, - code_sha256="a" * 64, - config_sha256="b" * 64, + assert tuple(fixture["scenarios"]) == REFERENCE_SCENARIOS + assert fixture["sampling"] == { + "cold_processes": 3, + "full_cli_cold_processes": 3, + "statistic": "median", + "warm_runs": 5, + } + for scenario in REFERENCE_SCENARIOS: + metrics = fixture["scenarios"][scenario] + cold_samples = metrics["cold_process"]["samples"] + warm_samples = metrics["warm"]["samples"] + cli_samples = metrics["full_cli_cold"]["samples"] + + assert len(cold_samples) == 3 + assert len(warm_samples) == 5 + assert len(cli_samples) == 3 + assert len({sample["process_id"] for sample in cold_samples}) == 3 + assert len({sample["process_id"] for sample in warm_samples}) == 1 + assert [sample["sample_index"] for sample in cli_samples] == [1, 2, 3] + assert len({sample["pid"] for sample in cli_samples}) == 3 + assert all(set(sample) == _REQUIRED_CLI_SAMPLE_FIELDS for sample in cli_samples) + assert all(sample["scenario"] == scenario for sample in cli_samples) + assert all(sample["sample_type"] == "full_cli_cold" for sample in cli_samples) + assert all(sample["reused"] is False for sample in cli_samples) + assert all( + sample["post_publish_validation"] == "passed" + for sample in cli_samples ) - - assert not work.exists() - - -def test_benchmark_rejects_either_run_over_180_seconds( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - vectorbt_benchmark, "run_vectorbt_simulation", lambda inputs, config: object() - ) - monkeypatch.setattr( - vectorbt_benchmark, - "to_joinquant_facts", - lambda inputs, simulation, scenario_id: object(), - ) - - def materialize(path: Path, facts: object) -> str: - path.mkdir(parents=True) - return "e" * 64 - - monkeypatch.setattr( - vectorbt_benchmark, "materialize_execution_facts", materialize - ) - ticks = iter((0.0, 180.1, 200.0, 200.2)) - monkeypatch.setattr(vectorbt_benchmark.time, "perf_counter", lambda: next(ticks)) - - with pytest.raises(vectorbt_benchmark.PerformanceGateError, match="180"): - vectorbt_benchmark.benchmark_scenario( - prepared_inputs=SimpleNamespace(identity="prepared"), - config={"scenario_id": "baseline"}, - scenario_id="baseline", - work_dir=tmp_path / "benchmark-work", - code_sha256="a" * 64, - config_sha256="b" * 64, + assert all(len(sample["package_sha256"]) == 64 for sample in cli_samples) + assert all(sample["cold_cli_total_seconds"] > 0.0 for sample in cli_samples) + assert metrics["full_cli_cold"]["median_seconds"] == pytest.approx( + statistics.median( + sample["cold_cli_total_seconds"] for sample in cli_samples + ) ) + assert metrics["parquet_payload_bytes"] > 0 + assert metrics["fixed_files_bytes"] > 0 + assert metrics["collected_files"] + assert metrics["cleanup"] == { + "isolated_output_roots_removed": True, + "verified": True, + } diff --git a/tests/local_quant_research/test_vectorbt_runtime.py b/tests/local_quant_research/test_vectorbt_runtime.py new file mode 100644 index 0000000..dd7cc04 --- /dev/null +++ b/tests/local_quant_research/test_vectorbt_runtime.py @@ -0,0 +1,612 @@ +from __future__ import annotations + +import importlib +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +from numba import njit + +from scripts.research.local_quant_research.contracts import ( + FILL_ACCEPTED, + FILL_REJECTED, + SIDE_BUY, + SIDE_NONE, + SIDE_SELL, + ExecutionRun, + LedgerInput, + OrderBuffer, + OrderProgram, +) +from scripts.research.local_quant_research.strategy_loader import load_strategy +from scripts.research.market_data.query import SnapshotView + + +def _snapshot() -> SnapshotView: + return SnapshotView( + snapshot_id="a" * 64, + fields=(), + rows=(), + digest="b" * 64, + corporate_action_fields=(), + corporate_actions=(), + corporate_actions_digest="c" * 64, + ) + + +def _ledger_input( + rows: int, + columns: int, + *, + initial_cash: float = 100.0, +) -> LedgerInput: + return LedgerInput( + dates=np.arange(rows).astype("timedelta64[D]") + + np.datetime64("2026-01-05"), + symbols=tuple(f"S{column}" for column in range(columns)), + close=np.full((rows, columns), 10.0), + initial_cash=initial_cash, + group_ids=np.zeros(columns, dtype=np.int64), + cash_sharing=True, + frequency="1d", + ) + + +def _order_buffer(columns: int) -> OrderBuffer: + return OrderBuffer( + enabled=np.zeros(columns, dtype=np.bool_), + side=np.full(columns, SIDE_NONE, dtype=np.int8), + size=np.zeros(columns, dtype=np.float64), + price=np.full(columns, np.nan, dtype=np.float64), + fixed_fees=np.zeros(columns, dtype=np.float64), + size_granularity=np.ones(columns, dtype=np.float64), + allow_partial=np.zeros(columns, dtype=np.bool_), + priority=np.zeros(columns, dtype=np.int64), + ) + + +@njit +def _prepare_priority_program(view, inputs, params, state, trace, orders) -> None: + priorities = inputs[0] + enabled, side, size, price, fixed_fees, granularity, partial, priority = orders + for column in range(view.from_col, view.to_col): + enabled[column] = True + side[column] = SIDE_BUY + size[column] = 1.0 + price[column] = 10.0 + fixed_fees[column] = 0.0 + granularity[column] = 1.0 + partial[column] = False + priority[column] = priorities[column] + + +@njit +def _prepare_priority_with_disabled( + view, + inputs, + params, + state, + trace, + orders, +) -> None: + priorities, enabled_mask = inputs + enabled, side, size, price, fixed_fees, granularity, partial, priority = orders + for column in range(view.from_col, view.to_col): + enabled[column] = enabled_mask[column] + side[column] = SIDE_BUY + size[column] = 1.0 + price[column] = 10.0 + fixed_fees[column] = 0.0 + granularity[column] = 1.0 + partial[column] = False + priority[column] = priorities[column] + + +@njit +def _prepare_sell_then_buy(view, inputs, params, state, trace, orders) -> None: + enabled, side, size, price, fixed_fees, granularity, partial, priority = orders + if view.row == 0: + enabled[1] = True + side[1] = SIDE_BUY + size[1] = 1.0 + price[1] = 10.0 + priority[1] = 0 + else: + enabled[0] = True + side[0] = SIDE_BUY + size[0] = 1.0 + price[0] = 10.0 + priority[0] = 1 + enabled[1] = True + side[1] = SIDE_SELL + size[1] = 1.0 + price[1] = 10.0 + priority[1] = 0 + for column in range(view.from_col, view.to_col): + fixed_fees[column] = 0.0 + granularity[column] = 1.0 + partial[column] = False + + +@njit +def _prepare_rejected_buy(view, inputs, params, state, trace, orders) -> None: + enabled, side, size, price, fixed_fees, granularity, partial, priority = orders + enabled[0] = True + side[0] = SIDE_BUY + size[0] = 1.0 + price[0] = 10.0 + fixed_fees[0] = 0.0 + granularity[0] = 1.0 + partial[0] = False + priority[0] = 0 + + +@njit +def _prepare_buy_once(view, inputs, params, state, trace, orders) -> None: + if view.row != 0: + return + enabled, side, size, price, fixed_fees, granularity, partial, priority = orders + enabled[0] = True + side[0] = SIDE_BUY + size[0] = 1.0 + price[0] = 10.0 + fixed_fees[0] = 0.0 + granularity[0] = 1.0 + partial[0] = False + priority[0] = 0 + + +@njit +def _prepare_partial_exit(view, inputs, params, state, trace, orders) -> None: + enabled, side, size, price, fixed_fees, granularity, partial, priority = orders + enabled[0] = True + side[0] = SIDE_BUY if view.row == 0 else SIDE_SELL + size[0] = 2.0 if view.row == 0 else 1.0 + price[0] = 10.0 if view.row == 0 else 12.0 + fixed_fees[0] = 0.0 + granularity[0] = 1.0 + partial[0] = False + priority[0] = 0 + + +@njit +def _prepare_two_buys(view, inputs, params, state, trace, orders) -> None: + enabled, side, size, price, fixed_fees, granularity, partial, priority = orders + enabled[0] = True + side[0] = SIDE_BUY + size[0] = 1.0 + price[0] = 10.0 if view.row == 0 else 20.0 + fixed_fees[0] = 0.0 + granularity[0] = 1.0 + partial[0] = False + priority[0] = 0 + + +@njit +def _record_order_event(event, inputs, params, state, trace, orders) -> None: + accepted = state[0] + sequence, statuses, count, _ = trace + index = count[0] + sequence[index] = event.column + statuses[index] = event.status + count[0] = index + 1 + if event.status == FILL_ACCEPTED: + accepted[event.column] += 1 + + +@njit +def _record_segment(view, inputs, params, state, trace, orders) -> None: + trace[3][view.row] += 1 + + +def _program( + prepare: object, + *, + columns: int, + rows: int, + inputs: tuple[object, ...] = (), +) -> OrderProgram: + return OrderProgram( + program_id="runtime-test", + prepare_segment_nb=prepare, + after_fill_nb=_record_order_event, + after_segment_nb=_record_segment, + inputs=inputs, + params=(), + state=(np.zeros(columns, dtype=np.int64),), + trace={ + "sequence": np.full(rows * columns, -1, dtype=np.int64), + "statuses": np.full(rows * columns, -1, dtype=np.int64), + "count": np.zeros(1, dtype=np.int64), + "segments": np.zeros(rows, dtype=np.int64), + }, + orders=_order_buffer(columns), + ) + + +@pytest.mark.parametrize( + ("strategy_root", "strategy_module"), + ( + ("tests/local_quant_research/fixtures/minimal_strategy", "strategy"), + ("tests/local_quant_research/fixtures", "minimal_strategy_b.strategy"), + ), +) +def test_minimal_no_order_strategy_runs_through_shared_vectorbt_runtime( + strategy_root: str, + strategy_module: str, + repo_root: Path, +) -> None: + try: + runtime = importlib.import_module( + "scripts.research.local_quant_research.vectorbt_runtime" + ) + except ModuleNotFoundError: + pytest.fail("shared vectorbt runtime is missing") + loaded = load_strategy( + repo_root, + {"root": strategy_root, "module": strategy_module, "symbol": "MODULE"}, + ) + prepared = loaded.module.prepare(_snapshot(), {}) + + result = runtime.run_vectorbt( + prepared.ledger_input, + prepared.primary_program, + ) + + assert isinstance(result, ExecutionRun) + value = result.ledger.value + assert value.dtype.names == ( + "time", + "total_value", + "returns", + "benchmark_returns", + ) + assert value.shape == (2,) + assert value.flags.writeable is False + assert result.ledger.value is value + assert np.array_equal(value["total_value"], np.array([100_000.0, 100_000.0])) + + +def test_ledger_and_trace_public_arrays_are_cached_readonly_and_hide_portfolio( + repo_root: Path, +) -> None: + runtime = importlib.import_module( + "scripts.research.local_quant_research.vectorbt_runtime" + ) + loaded = load_strategy( + repo_root, + { + "root": "tests/local_quant_research/fixtures/minimal_strategy", + "module": "strategy", + "symbol": "MODULE", + }, + ) + prepared = loaded.module.prepare(_snapshot(), {}) + program = replace( + prepared.primary_program, + trace={"state": np.array([1.0, 2.0])}, + ) + + result = runtime.run_vectorbt(prepared.ledger_input, program) + + public_arrays = ( + "orders", + "assets", + "cash", + "value", + "trades", + "positions", + "returns", + ) + for name in public_arrays: + first = getattr(result.ledger, name) + assert first.flags.writeable is False + assert getattr(result.ledger, name) is first + trace = result.trace["state"] + assert trace.flags.writeable is False + assert result.trace["state"] is trace + assert { + name for name in dir(result.ledger) if not name.startswith("_") + } == set(public_arrays) + assert not hasattr(result.ledger, "portfolio") + + +def test_runtime_uses_stable_priority_and_only_converts_enabled_slots() -> None: + runtime = importlib.import_module( + "scripts.research.local_quant_research.vectorbt_runtime" + ) + program = _program( + _prepare_priority_with_disabled, + columns=4, + rows=1, + inputs=( + np.array([2, 1, 1, 0], dtype=np.int64), + np.array([True, True, True, False]), + ), + ) + + result = runtime.run_vectorbt(_ledger_input(1, 4), program) + + assert result.trace["count"][0] == 3 + assert np.array_equal(result.trace["sequence"][:3], np.array([1, 2, 0])) + assert np.array_equal( + result.trace["statuses"][:3], + np.full(3, FILL_ACCEPTED, dtype=np.int64), + ) + assert result.trace["segments"].tolist() == [1] + + +def test_sell_priority_releases_shared_cash_before_buy() -> None: + runtime = importlib.import_module( + "scripts.research.local_quant_research.vectorbt_runtime" + ) + program = _program(_prepare_sell_then_buy, columns=2, rows=2) + + result = runtime.run_vectorbt( + _ledger_input(2, 2, initial_cash=10.0), + program, + ) + + assert result.trace["count"][0] == 3 + assert result.trace["sequence"][:3].tolist() == [1, 1, 0] + assert result.trace["statuses"][:3].tolist() == [ + FILL_ACCEPTED, + FILL_ACCEPTED, + FILL_ACCEPTED, + ] + assert program.state[0].tolist() == [1, 2] + + +def test_rejected_order_is_reported_without_advancing_fill_state() -> None: + runtime = importlib.import_module( + "scripts.research.local_quant_research.vectorbt_runtime" + ) + program = _program(_prepare_rejected_buy, columns=1, rows=1) + + result = runtime.run_vectorbt( + _ledger_input(1, 1, initial_cash=5.0), + program, + ) + + assert result.trace["count"][0] == 1 + assert result.trace["statuses"][0] == FILL_REJECTED + assert program.state[0].tolist() == [0] + + +def test_ledger_formats_vectorbt_orders_positions_and_cumulative_returns() -> None: + runtime = importlib.import_module( + "scripts.research.local_quant_research.vectorbt_runtime" + ) + ledger_input = replace( + _ledger_input(2, 1, initial_cash=10.0), + close=np.array([[10.0], [12.0]]), + ) + program = _program(_prepare_buy_once, columns=1, rows=2) + + result = runtime.run_vectorbt(ledger_input, program) + + assert result.trace["count"][0] == 1 + assert result.ledger.orders["action"].tolist() == ["open"] + assert result.ledger.orders["security"].tolist() == ["S0"] + assert result.ledger.orders["time"].tolist() == ["2026-01-05T09:30:00"] + assert result.ledger.cash["cash"].tolist() == [0.0, 0.0] + assert result.ledger.value["total_value"].tolist() == [10.0, 12.0] + assert np.allclose(result.ledger.value["returns"], np.array([0.0, 0.2])) + assert np.allclose(result.ledger.returns["returns"], np.array([0.0, 0.2])) + assert result.ledger.assets["avg_cost"].tolist() == [10.0, 10.0] + assert result.ledger.assets["hold_cost"].tolist() == [10.0, 10.0] + assert result.ledger.assets["gains"].tolist() == [0.0, 2.0] + assert result.ledger.assets["daily_gains"].tolist() == [0.0, 2.0] + assert result.ledger.assets["today_amount"].tolist() == [1, 0] + assert result.ledger.assets["closeable_amount"].tolist() == [0, 1] + assert result.ledger.assets["time"].tolist() == [ + "2026-01-05T16:00:00", + "2026-01-06T16:00:00", + ] + + +def test_order_gains_only_include_closed_vectorbt_trades() -> None: + runtime = importlib.import_module( + "scripts.research.local_quant_research.vectorbt_runtime" + ) + ledger_input = replace( + _ledger_input(2, 1, initial_cash=20.0), + close=np.array([[10.0], [12.0]]), + ) + program = _program(_prepare_partial_exit, columns=1, rows=2) + + result = runtime.run_vectorbt(ledger_input, program) + + assert result.ledger.orders["action"].tolist() == ["open", "close"] + assert result.ledger.orders["gains"].tolist() == [0.0, 2.0] + + +def test_later_buy_does_not_rewrite_earlier_daily_average_cost() -> None: + runtime = importlib.import_module( + "scripts.research.local_quant_research.vectorbt_runtime" + ) + ledger_input = replace( + _ledger_input(2, 1, initial_cash=30.0), + close=np.array([[10.0], [20.0]]), + ) + program = _program(_prepare_two_buys, columns=1, rows=2) + + result = runtime.run_vectorbt(ledger_input, program) + + assert result.ledger.assets["amount"].tolist() == [1.0, 2.0] + assert result.ledger.assets["avg_cost"].tolist() == [10.0, 15.0] + assert result.ledger.assets["hold_cost"].tolist() == [10.0, 15.0] + assert result.ledger.assets["gains"].tolist() == [0.0, 10.0] + + +def test_same_callback_identity_reuses_specialized_functions() -> None: + runtime = importlib.import_module( + "scripts.research.local_quant_research.vectorbt_runtime" + ) + first_program = _program(_prepare_priority_program, columns=1, rows=1) + second_program = _program(_prepare_priority_program, columns=1, rows=1) + + first = runtime._specialize_program(first_program) + second = runtime._specialize_program(second_program) + + assert first.pre_segment_func_nb is second.pre_segment_func_nb + assert first.post_order_func_nb is second.post_order_func_nb + assert first.post_segment_func_nb is second.post_segment_func_nb + + +class _CountingPortfolio: + def __init__(self) -> None: + self.calls = { + name: 0 + for name in ( + "orders", + "assets", + "asset_flow", + "asset_value", + "cash", + "cash_flow", + "value", + "trades", + "positions", + "returns", + ) + } + self.order_records = np.zeros( + 1, + dtype=[ + ("id", "i8"), + ("col", "i8"), + ("idx", "i8"), + ("size", "f8"), + ("price", "f8"), + ("fees", "f8"), + ("side", "i8"), + ], + ) + self.order_records[0] = (0, 0, 1, 1.0, 10.0, 0.0, 0) + self.trade_records = np.zeros( + 1, + dtype=[ + ("col", "i8"), + ("exit_idx", "i8"), + ("pnl", "f8"), + ("status", "i8"), + ], + ) + self.trade_records["status"] = 1 + position_dtype = [ + ("col", "i8"), + ("entry_idx", "i8"), + ("exit_idx", "i8"), + ("entry_price", "f8"), + ] + self._position_base = np.zeros(2, dtype=position_dtype) + self._position_base[0] = (0, 1, 1, 10.0) + self.position_records = self._position_base[::2] + + @property + def orders(self) -> object: + self.calls["orders"] += 1 + return SimpleNamespace(records_arr=self.order_records) + + @property + def trades(self) -> object: + self.calls["trades"] += 1 + return SimpleNamespace(records_arr=self.trade_records) + + @property + def positions(self) -> object: + self.calls["positions"] += 1 + return SimpleNamespace(records_arr=self.position_records) + + def assets(self) -> np.ndarray: + self.calls["assets"] += 1 + return np.array([[0.0], [1.0]]) + + def asset_flow(self) -> np.ndarray: + self.calls["asset_flow"] += 1 + return np.array([[0.0], [1.0]]) + + def asset_value(self, *, group_by: bool) -> np.ndarray: + assert group_by is False + self.calls["asset_value"] += 1 + return np.array([[0.0], [10.0]]) + + def cash(self) -> np.ndarray: + self.calls["cash"] += 1 + return np.array([100.0, 90.0]) + + def cash_flow(self, *, group_by: bool) -> np.ndarray: + assert group_by is False + self.calls["cash_flow"] += 1 + return np.array([[0.0], [-10.0]]) + + def value(self) -> np.ndarray: + self.calls["value"] += 1 + return np.array([100.0, 100.0]) + + def cumulative_returns(self) -> np.ndarray: + self.calls["returns"] += 1 + return np.array([0.0, 0.0]) + + +def test_ledger_computes_each_accessor_once_and_avoids_unneeded_copies() -> None: + runtime = importlib.import_module( + "scripts.research.local_quant_research.vectorbt_runtime" + ) + portfolio = _CountingPortfolio() + ledger = runtime.ExecutionLedger( + portfolio, + np.array(["2026-01-05", "2026-01-06"], dtype="datetime64[D]"), + ("S0",), + np.full((2, 1), 10.0), + ) + + views = {} + for name in ( + "orders", + "assets", + "cash", + "value", + "trades", + "positions", + "returns", + ): + first = getattr(ledger, name) + second = getattr(ledger, name) + assert second is first + assert first.flags.writeable is False + views[name] = first + + assert portfolio.calls == {name: 1 for name in portfolio.calls} + assert np.shares_memory(views["trades"], portfolio.trade_records) + assert not np.shares_memory(views["positions"], portfolio.position_records) + + +def test_primary_and_followup_programs_use_the_same_runtime_entry() -> None: + runtime = importlib.import_module( + "scripts.research.local_quant_research.vectorbt_runtime" + ) + ledger_input = _ledger_input(1, 1) + primary_program = _program( + _prepare_priority_program, + columns=1, + rows=1, + inputs=(np.array([0], dtype=np.int64),), + ) + followup_program = _program( + _prepare_priority_program, + columns=1, + rows=1, + inputs=(np.array([0], dtype=np.int64),), + ) + + primary = runtime.run_vectorbt(ledger_input, primary_program) + final = runtime.run_vectorbt(ledger_input, followup_program) + + assert isinstance(primary, ExecutionRun) + assert isinstance(final, ExecutionRun) + assert primary.trace["count"][0] == 1 + assert final.trace["count"][0] == 1 diff --git a/tests/test_skill_layout.py b/tests/test_skill_layout.py index a6d527b..70473ce 100644 --- a/tests/test_skill_layout.py +++ b/tests/test_skill_layout.py @@ -129,15 +129,19 @@ def test_build_and_verify_covers_local_quant_research_without_local_data( "-m", "pytest", "tests\\local_quant_research", - "-k", - "not test_non_strategy_project_completes_through_shared_market_and_runner", + "tests\\quant_analysis", + "--ignore=tests\\local_quant_research\\test_generic_e2e.py", + "--ignore=tests\\local_quant_research\\test_turtle_e2e.py", + "--ignore=tests\\local_quant_research\\test_local_research_v2_e2e.py", ] + assert unit["timeoutSeconds"] == 300 assert e2e["command"] == [ ".\\.venv\\Scripts\\python.exe", "-m", "pytest", "tests\\local_quant_research\\test_generic_e2e.py", "tests\\local_quant_research\\test_turtle_e2e.py", + "tests\\local_quant_research\\test_local_research_v2_e2e.py", ] required_paths = { ".agents/skills/run-local-quant-research/**",