隔离单测并整合命令结算与一字板修复

接续 Claude ead20bf 与 01dbcdb,分别保留为第 36、37 批测试。按用户决定在命令完成前取消未解锁方案,并支持故障重试。补齐漏桩与联网失败护栏,开发机 41 批 983 例通过。同步 Codex 开发入口与交接记录。
This commit is contained in:
baobao 2026-10-12 11:50:40 +08:00
parent fbe0014413
commit 55e2ed6aa3
59 changed files with 1642 additions and 162 deletions

View File

@ -5,3 +5,7 @@ __pycache__
.env
logs
.DS_Store
# 开发代理的本地配置和嵌套工作树不进入运行镜像。
.claude
.codex
.agents

3
.gitignore vendored
View File

@ -18,3 +18,6 @@ _to_delete/
docker-compose.override.yml
/.claude/output-styles/readable-chinese.md
/.claude/settings.json
# 保留旧 Claude 工作树供接续,不把嵌套检出加入主仓库。
/.claude/worktrees/

148
AGENTS.md Normal file
View File

@ -0,0 +1,148 @@
# AGENTS.md
本文件是 Codex 在本仓库工作的项目指令,也是项目开发规则的唯一维护入口。`CLAUDE.md` 保留兼容入口,指向本文件。
用可读中文沟通和写文档。写完整句子,一句一事,少用符号,不自造缩写。术语先用白话解释。命令和表格内部不受此限。这些规则直接保存在本文件,不依赖开发机上缺失的输出风格文件。
## 接续开发从哪里开始
当前接续记录是 `docs/开发接续_2026-10-12.md`。接手中断的工作时先读它,再按任务查设计文档。它记录了两个 Claude 修复的整合状态、测试隔离方式、尚未执行的探针和部署判收。
开工先看 `git status --short`、`git worktree list` 和相关提交。已有的未提交文件与其他工作树属于用户正在进行的工作,不覆盖、不清理。判断是否推送要直接核对远程,不凭过期的 `origin/main` 引用下结论。
创建新分支时默认用 `codex/` 前缀。旧的 `claude/` 分支仍是有效的 Git 分支,保留名字和工作树即可接续。合并时核对测试文件是否重名,不能让一批测试覆盖另一批。
历史交接里的服务器状态只代表记录当天。过期的执行日期不会自动变成今天的执行授权。2026-10-12 用户确认先以当前仓库为准,原定 10-09 的两项探针均未执行。
## 这是什么
这是一套命令驱动的持仓管理系统,代码里和文档里都简称 PMS(Position Management System,持仓管理系统)。它在整条交易链路里只负责一段:用户在管理页面下达大方向命令,例如设定总规模、调仓位上限、升降仓、对某只股票做 T;PMS 据此把命令拆成分股分批的方案,并管理账本(批次、摊薄成本、安全垫、纪律)。
链路上还有另外两个系统,它们不在本仓库里,但 PMS 时时与它们打交道:
1. 决策系统,代码里叫 bionic。它负责研判(该不该做)与盘中择时(何时做)。PMS 不自建研判栈,研判和择时都可以委托它。
2. 下游系统,即 QMT 侧的执行服务。它只负责挂单成交。PMS 经一条 WebSocket 长连接直连它。
一句话概括分工:PMS 管「做什么、多少」,决策系统管「该不该、何时」,下游只管执行。
## 三条铁律(改任何东西前先读)
这三条写在 README 开头,是整个系统的设计前提,违反它们的改动一律是错的。
1. 命令至上。自动决策不得突破用户的命令参数;冲突时命令优先;命令之间的冲突交用户裁决。
2. 分工不越权。研判不可用时降级为保守规则加人工确认,绝不自建第二套研判。
3. 先记账后动作,故障即守成。指令先落表再下发;出了故障不产生新指令;账本与下游定期对账,以下游为实际持仓的事实源。
## 常用命令
日常操作都由 `Makefile` 包了一层,`make help` 看全部目标。下面这些是最常用的。所有 `make` 命令都在部署 PMS 的服务器上跑(源码打进容器镜像,本机直接跑 Python 会因为缺少容器环境而对不上)。
```bash
make deploy # 一句话部署:git pull → 重建镜像 → 重建容器(force-recreate)→ 建表
make deploy-local # 同上但跳过 git pull(本地已经改好时用)
make test # 跑全部单测(不连库,秒级)。应输出 ALL SUITES PASS
make watch # 盯盘看这一个:通道/账户/在途指令/出口委托/账本持仓 一屏看完,只读
make health # 页面健康检查(配置装载自证 + 库连通自证)
make check # 实机连通性与表结构自检(需要真实 .env)
```
跑单元测试有两种粒度。跑全部用 `make test`,它会先比对容器镜像与工作树的代码指纹,对不上就报警(防「git pull 了没重建,跑的还是旧代码而且照样通过」)。只跑某一批时,直接把那个测试文件当脚本跑:
```bash
# 只跑某一批(例:择时实现B 与规则闸那一批)
docker compose run --rm --no-deps pms-web python scripts/unit_test_guard.py scripts/test_batch3_units.py
```
每个 `scripts/test_batch*.py` 都是能独立运行的脚本,`scripts/run_tests.py` 只是把它们逐个 subprocess 起来。测试设计为不连接外部服务,服务层用内存桩(见 `scripts/test_wiring.py` 的 `FakeRepo`)。
**单测必须隔离配置并禁止联网。** `scripts/run_tests.py` 逐批经 `scripts/unit_test_guard.py` 启动,清除业务环境变量,在没有 `.env` 的临时目录运行。各测试文件直接运行时也先安装联网护栏。Python 套接字调用会在连接前被阻断,即使业务代码吞掉异常,整批仍以失败退出。`install_fakes` 已补齐观察读数仓储与扫描中遗漏的外部依赖。新增服务调用必须补内存桩,不能依赖异常降级让测试通过。
开发机可以在单独虚拟环境安装 `requirements.txt` 后经隔离入口跑全量回归。服务器部署前仍要用匹配镜像的断网容器验证,不能把本地通过称作服务器验收。2026-09-20 留下的四行历史假数据尚未清理,修测试不会自动删除库里的旧记录。
## 改代码时最容易踩的坑
**改了 Python 代码必须重建镜像加 force-recreate,绝不能用 `docker compose restart`。** 源码是打进镜像的,`restart` 只是把老容器停了再起,跑的还是旧镜像里的旧代码,而且一点报错都没有。`make deploy` 和 `make up` 走的都是正确路径。这是本项目头号坑,`Makefile` 顶部、`scripts/deploy.sh`、`run_tests.py` 里都反复写了这一条。
**模拟仓 155 是个例外,要另记(2026-09-20 实测)。** 155 的项目目录里有一个服务器本地文件 `docker-compose.override.yml`,内容同仓库里的 `docker-compose.dev.yml`,它把整个仓库目录绑定挂载到四个容器的 `/app`。这个文件被 `.gitignore` 忽略,仓库里看不到;真实仓 188 没有它。后果有三条。一,在 155 上 `git pull` 之后,页面静态文件立刻就是新的,后端四个进程仍是旧代码,直到重启或 `make deploy`。二,`make test` 与 `make stale` 起的一次性容器读的也是工作树,所以在 155 上「指纹一致」恒成立,查不出「拉了代码没重启」。三,往 155 拉代码等于把页面先上线了,拉之前先看 `git log` 里有没有别的会话提交的、还没到上线日的改动。`make deploy` 在两台机器上都仍是对的做法。
**下发模式由参数 `PMS_DISPATCH_MODE` 决定,实际以页面顶部横幅为准,不要拿这段文字当现状。** 设置文件的初值是 `shadow`(影子模式:指令照常生成、过规则闸、记账、置为已下发,但不写下游,需人工在 QMT 侧执行,成交由回放任务按先进先出认领回账本)。但模拟仓 155 运行时已切到 `ws`,指令经 `pms-ws` 真向模拟 QMT 发出、自主动作按档位可自动执行,不再需要人工下单。真实仓 188 的 PMS 尚未追平到最新代码,上线等 QMT 侧,追平部署时按真实仓准备包的初始参数清单先进影子期。切换只是 `PMS_DISPATCH_MODE` 与 `PMS_QMT_WS_ENABLED` 两个开关加一次联调,不是再写代码。改动涉及下单链路时,务必先确认自己面对的是哪一套实例、此刻是哪个模式。
**业务参数一律走 ParamStore 读,不要直接读 settings。** `config/settings.py` 里的业务参数(`PMS_TOTAL_SCALE`、各种档位阈值等)只是「初值」。运行时以数据库表 `pms_runtime_param` 的值为准,页面改参数即时生效。取业务参数统一经 `app/services/param_store.py`,直接读 settings 会让页面调参失效。基础设施类配置(连接串、密钥)才从 settings 与 `.env` 读。
**密钥只从 `.env` 注入,不入库、不进 ParamStore、不上页面。** 会话密钥、QMT 通道的两把 Ed25519 密钥都属此类。新增密钥类配置时,要同时登记进 `param_store.py` 的 `SECRET_KEYS`,否则它会被当成可调参数暴露到页面快照里。
## 架构分层
代码在 `app/` 下按依赖方向分层,越靠上越纯。
- `app/core/`:纯逻辑,零外部依赖,可单测。系统的算数与纪律都在这里——批次拆分与组合约束(`sizer.py`)、摊薄成本与安全垫状态机(`cushion.py`)、命令目录与校验(`command_spec.py`)、方案生成(`planner.py`)、规则闸终检(`rule_gate.py`)、择时实现B(`exec_timing.py`)、动作引擎(`action_engine.py`)、QMT 协议编解码与签名(`ws_codec.py`)等。改核心逻辑先在这一层,它有最密的单测覆盖。
- `app/db/session.py`:三个数据库的连接,外加一道严格单表访问守卫(下面单列一节)。
- `app/repo/`:单表数据访问层。每个函数只碰一张表。
- `app/services/`:编排层,把 core 的纯逻辑接上 repo 的取数与落表。命令下达在 `command_service.py`,方案转指令与出手在 `executor.py`,下发通道适配器在 `dispatcher.py`,运行参数中心在 `param_store.py`,组合快照在 `portfolio.py`。
- `app/ws/runner.py`:常驻的 QMT 连接进程,容器名 `pms-ws`。它只做通道,不碰账本。
- `app/web/`:FastAPI 后端加一个单页前端(`static/index.html`,Vue3 加 ElementPlus,无构建步骤)。
- `app/scheduler.py`:Celery beat 调度总表与调度守卫。调度位以代码和 `scripts/test_wiring.py` 中的哨兵清单为准。
进程边界值得单独理解一下。出手的 `executor` 跑在短命的 Celery worker 任务里,而 QMT 连接必须是一条常驻长连接(协议规定 PMS 只准开一条,多开会让指令乱序)。两者只经数据库耦合:worker 把待发委托写进出口表 `pms_qmt_order`,`pms-ws` 进程亚秒轮询取走并签名发出;QMT 的回报落进 `pms_qmt_inbox`,再由回放任务认领进账本。因此 `pms-ws` 这个服务绝不能扩副本。
三组容器服务:默认起 `pms-web`(管理页面,端口 38100);`--profile sched` 起 `pms-beat` 和 `pms-worker`(调度与执行);`--profile ws` 起 `pms-ws`(QMT 直连)。
## 严格单表访问守卫
153 代理库不支持多表联查,写错了要到线上才报错。为把这条纪律钉死,`app/db/session.py` 在每条 SQL 的执行入口做静态检查,出现 JOIN、逗号连表、跨表子查询一律拒绝抛异常。写数据访问代码时,一条 SQL 只能碰一张表;要联的数据在 service 层分两次查再在内存里拼。`upsert` 语句里的 `ON DUPLICATE KEY UPDATE` 是这道守卫的历史误伤点,改 upsert 时留意。
## 数据库与外部依赖
三个 MySQL 库(键名与 bionic 对齐,同一份 `.env` 可服务两个系统):`PROXY_DB_URL` 是 153 代理,放 PMS 全部自有表加决策系统结论表;`SOURCE_DB_EXT_DSN` 是因子库,当前 PMS 不用;`DB_MYSQL_URL` 是大盘指数库,只做页面区制提示。建表脚本是 `scripts/init_db.py`,建表语句在 `ddl_pms_v1.sql`。
Redis 有几路:决策系统的盘中信号流与风控卖出流、实时行情、PMS 自己的 Celery 总线。**`redis` 依赖必须锁在 5.x**:6.x 默认按 RESP3 握手,而现网的 Redis 版本偏旧会回 `unknown command HELLO`,导致行情与调度总线一起断。`requirements.txt` 已锁版本,升级依赖时别放开。
## 时区口径(很容易读错)
不能按「哪个系统」一刀切,要按「哪种时间」分。容器时钟与容器日志正文是北京时间,不要加八小时。数据库里的时间戳列(如各种 `created_at`、`updated_at`)是 UTC,读的时候要加八小时。日期列(如 `trade_date`、`audit_date`)是北京日期,不换算。写记录时一律用北京时间,引用数据库时间列先加八小时并标注已换算。完整依据见 `DEVLOG.md` 开头。
**待核实的例外。** 上段是历史约定。PMS 自有表的部分列实际是 `DATETIME`,`pms_repo.py` 与 `qmt_repo.py` 用容器内 `datetime.now()` 写值。10-08 的两个探针因此提出先用心跳和快照校准时区。用户已确认探针尚未执行,这个差异尚无新的真机结论。读这些列前要核对写入路径和实际时区,并明确标注原值与换算依据,不把两种口径混在同一份读数里。
## 前端的两个坑
管理页面是无构建步骤的单页,这两个坑都实际炸过页面,都写在 README 里。
1. 模板里不能用自闭合的自定义标签。`<el-alert .../>` 这种写法浏览器 HTML 解析器不认,会把后面所有内容当成它的子节点;若它带 `v-if` 且条件为假,整页就跟着消失。一律写成 `<el-xxx ...></el-xxx>`。
2. `setup()` 里定义给模板用的东西必须真的 `return` 出去。漏一个函数没接出去,用到它的地方会在渲染时报错并白屏。`scripts/test_page_wiring_guard.py` 就是为这个坑加的护栏(2026-09-10 白屏即漏了十一个显示函数所致)。
页面有三道静态护栏:`scripts/test_page_enum_guard.py`、`scripts/test_page_wiring_guard.py`、`scripts/test_page_copy_guard.py`。改页面后要一起跑,涉及布局和交互时还要浏览器验收。
页面措辞要专业,不出现内部设计词,不自造简写。同一概念统一用基本面、技术面、择时三种维度。看多用红色,看空用绿色,中性用灰色,无读数用虚框。说明放在悬停里,表格要能在中栏完整显示。
## 加测试时的登记纪律
`scripts/run_tests.py` 把「缺一个测试文件」当失败处理,而不是当「跳过」——因为镜像旧了的时候,新加的那批测试正好全部「不存在」,一条没跑却会报全部通过。所以新增一个 `test_batch*.py`,必须同时登记进 `scripts/run_tests.py` 的 `SUITES` 清单和它上面的例数说明表,否则会被判失败。
代码里还有若干「哨兵断言」,改了对应的东西就得回到那里改断言,断言不动就是漏了。清单写在 `run_tests.py` 顶部的注释里(DDL 表数、调度位、API 路由、密钥名单、动作求值器次序等)。改这些结构性的东西时先去那份清单对一遍。
## 部署与运维纪律
在开发机修改代码,通过git提交,然后在服务器上部署
部署方式与服务器信息以 `DEVLOG.md` 开头那段为准(PMS 跑在哪台机、决策系统与因子桥各在哪、各自怎么生效)。有模拟仓与真实仓两套实例,靠表名前缀(`PMS_TABLE_PREFIX`)与容器名前缀区分,共用同一套代码。对真实仓做写操作前先确认清楚是哪一套。
服务器上的容器操作偏敏感:不要随意重启或关停容器,涉及容器与数据库的操作先与用户确认,不碰本仓库以外的项目。登录到哪台机、面对的是哪一套实例,不清楚时先问。
每次交付写明验证命令在哪台机器上运行,以及预期结果。一个命令块只放一台机器的命令。重要节点及时追加到 `DEVLOG.md`,包含做了什么、动了哪些文件、部署方式、真机判收、还欠着什么。判收只写「真机见过」或「未判收」,本地通过不代替真机判收。
## 文档地图
仓库根目录有大量设计与交接文档,需要细节时按主题查这几份:
- `README.md`:总览,最全。上面各节多是从它摘的。
- `DEVLOG.md`:开发节点流水账,每条含「做了什么、动了哪些文件、部署方式、真机判收、还欠什么」五样。查某个改动的来龙去脉看这里。
- `docs/开发接续_2026-10-12.md`:Claude 到 Codex 的仓库迁移记录、未合入分支和当前待办入口。
- `POSITION_MGMT_DESIGN.md`:总体设计定稿。
- `QMT_WS_PROTOCOL.md`:PMS 与 QMT 的 WebSocket 协议,下发通道的唯一实现依据。
- `BIONIC_PMS_INTERFACE.md`:PMS 与决策系统的接口契约(研判闸与盘中择时两个接口)。
- `UPSTREAM_PLAN_API.md`:上游选股计划接口的接入记录,候选池的事实源。
- `docs/复盘决定台账.md`:近期改动按「台账」编号推进,提交信息里的台账号对应到这里。
## 提交约定
提交信息用简短中文,描述这次改动的行为而非罗列文件。近期工作按台账编号组织,提交里出现的台账号可回到 `docs/复盘决定台账.md` 对照。

126
CLAUDE.md
View File

@ -1,127 +1,7 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
本仓库的开发规则统一维护在 [AGENTS.md](AGENTS.md)。开始工作前请完整阅读并遵守该文件,不在这里另存一份规则。
本文件用可读中文写成,正文遵循 `.claude/output-styles/readable-chinese.md` 的规矩:完整句子、一句一事、少用符号。命令和表格内部不受此限。
中断后的接续记录见 [2026-10-12 开发接续](docs/开发接续_2026-10-12.md)。历史文档中的 `.claude/output-styles/readable-chinese.md` 已不在当前开发机上,可读中文规则已直接写入 `AGENTS.md`。
## 这是什么
这是一套命令驱动的持仓管理系统,代码里和文档里都简称 PMS(Position Management System,持仓管理系统)。它在整条交易链路里只负责一段:用户在管理页面下达大方向命令,例如设定总规模、调仓位上限、升降仓、对某只股票做 T;PMS 据此把命令拆成分股分批的方案,并管理账本(批次、摊薄成本、安全垫、纪律)。
链路上还有另外两个系统,它们不在本仓库里,但 PMS 时时与它们打交道:
1. 决策系统,代码里叫 bionic。它负责研判(该不该做)与盘中择时(何时做)。PMS 不自建研判栈,研判和择时都可以委托它。
2. 下游系统,即 QMT 侧的执行服务。它只负责挂单成交。PMS 经一条 WebSocket 长连接直连它。
一句话概括分工:PMS 管「做什么、多少」,决策系统管「该不该、何时」,下游只管执行。
## 三条铁律(改任何东西前先读)
这三条写在 README 开头,是整个系统的设计前提,违反它们的改动一律是错的。
1. 命令至上。自动决策不得突破用户的命令参数;冲突时命令优先;命令之间的冲突交用户裁决。
2. 分工不越权。研判不可用时降级为保守规则加人工确认,绝不自建第二套研判。
3. 先记账后动作,故障即守成。指令先落表再下发;出了故障不产生新指令;账本与下游定期对账,以下游为实际持仓的事实源。
## 常用命令
日常操作都由 `Makefile` 包了一层,`make help` 看全部目标。下面这些是最常用的。所有 `make` 命令都在部署 PMS 的服务器上跑(源码打进容器镜像,本机直接跑 Python 会因为缺少容器环境而对不上)。
```bash
make deploy # 一句话部署:git pull → 重建镜像 → 重建容器(force-recreate)→ 建表
make deploy-local # 同上但跳过 git pull(本地已经改好时用)
make test # 跑全部单测(不连库,秒级)。应输出 ALL SUITES PASS
make watch # 盯盘看这一个:通道/账户/在途指令/出口委托/账本持仓 一屏看完,只读
make health # 页面健康检查(配置装载自证 + 库连通自证)
make check # 实机连通性与表结构自检(需要真实 .env)
```
跑单元测试有两种粒度。跑全部用 `make test`,它会先比对容器镜像与工作树的代码指纹,对不上就报警(防「git pull 了没重建,跑的还是旧代码而且照样通过」)。只跑某一批时,直接把那个测试文件当脚本跑:
```bash
# 只跑某一批(例:择时实现B 与规则闸那一批)
docker compose run --rm --no-deps pms-web python scripts/test_batch3_units.py
```
每个 `scripts/test_batch*.py` 都是能独立运行的脚本,`run_tests.py` 只是把它们逐个 subprocess 起来。测试全部零外部依赖、不连库,服务层用内存桩(见 `scripts/test_wiring.py` 的 `FakeRepo`)。
## 改代码时最容易踩的坑
**改了 Python 代码必须重建镜像加 force-recreate,绝不能用 `docker compose restart`。** 源码是打进镜像的,`restart` 只是把老容器停了再起,跑的还是旧镜像里的旧代码,而且一点报错都没有。`make deploy` 和 `make up` 走的都是正确路径。这是本项目头号坑,`Makefile` 顶部、`scripts/deploy.sh`、`run_tests.py` 里都反复写了这一条。
**模拟仓 155 是个例外,要另记(2026-09-20 实测)。** 155 的项目目录里有一个服务器本地文件 `docker-compose.override.yml`,内容同仓库里的 `docker-compose.dev.yml`,它把整个仓库目录绑定挂载到四个容器的 `/app`。这个文件被 `.gitignore` 忽略,仓库里看不到;真实仓 188 没有它。后果有三条。一,在 155 上 `git pull` 之后,页面静态文件立刻就是新的,后端四个进程仍是旧代码,直到重启或 `make deploy`。二,`make test` 与 `make stale` 起的一次性容器读的也是工作树,所以在 155 上「指纹一致」恒成立,查不出「拉了代码没重启」。三,往 155 拉代码等于把页面先上线了,拉之前先看 `git log` 里有没有别的会话提交的、还没到上线日的改动。`make deploy` 在两台机器上都仍是对的做法。
**下发模式由参数 `PMS_DISPATCH_MODE` 决定,实际以页面顶部横幅为准,不要拿这段文字当现状。** 设置文件的初值是 `shadow`(影子模式:指令照常生成、过规则闸、记账、置为已下发,但不写下游,需人工在 QMT 侧执行,成交由回放任务按先进先出认领回账本)。但模拟仓 155 运行时已切到 `ws`,指令经 `pms-ws` 真向模拟 QMT 发出、自主动作按档位可自动执行,不再需要人工下单。真实仓 188 的 PMS 尚未追平到最新代码,上线等 QMT 侧,追平部署时按真实仓准备包的初始参数清单先进影子期。切换只是 `PMS_DISPATCH_MODE` 与 `PMS_QMT_WS_ENABLED` 两个开关加一次联调,不是再写代码。改动涉及下单链路时,务必先确认自己面对的是哪一套实例、此刻是哪个模式。
**业务参数一律走 ParamStore 读,不要直接读 settings。** `config/settings.py` 里的业务参数(`PMS_TOTAL_SCALE`、各种档位阈值等)只是「初值」。运行时以数据库表 `pms_runtime_param` 的值为准,页面改参数即时生效。取业务参数统一经 `app/services/param_store.py`,直接读 settings 会让页面调参失效。基础设施类配置(连接串、密钥)才从 settings 与 `.env` 读。
**密钥只从 `.env` 注入,不入库、不进 ParamStore、不上页面。** 会话密钥、QMT 通道的两把 Ed25519 密钥都属此类。新增密钥类配置时,要同时登记进 `param_store.py` 的 `SECRET_KEYS`,否则它会被当成可调参数暴露到页面快照里。
## 架构分层
代码在 `app/` 下按依赖方向分层,越靠上越纯。
- `app/core/`:纯逻辑,零外部依赖,可单测。系统的算数与纪律都在这里——批次拆分与组合约束(`sizer.py`)、摊薄成本与安全垫状态机(`cushion.py`)、命令目录与校验(`command_spec.py`)、方案生成(`planner.py`)、规则闸终检(`rule_gate.py`)、择时实现B(`exec_timing.py`)、动作引擎(`action_engine.py`)、QMT 协议编解码与签名(`ws_codec.py`)等。改核心逻辑先在这一层,它有最密的单测覆盖。
- `app/db/session.py`:三个数据库的连接,外加一道严格单表访问守卫(下面单列一节)。
- `app/repo/`:单表数据访问层。每个函数只碰一张表。
- `app/services/`:编排层,把 core 的纯逻辑接上 repo 的取数与落表。命令下达在 `command_service.py`,方案转指令与出手在 `executor.py`,下发通道适配器在 `dispatcher.py`,运行参数中心在 `param_store.py`,组合快照在 `portfolio.py`。
- `app/ws/runner.py`:常驻的 QMT 连接进程,容器名 `pms-ws`。它只做通道,不碰账本。
- `app/web/`:FastAPI 后端加一个单页前端(`static/index.html`,Vue3 加 ElementPlus,无构建步骤)。
- `app/scheduler.py`:Celery beat 调度总表,九个调度位加三条守卫。
进程边界值得单独理解一下。出手的 `executor` 跑在短命的 Celery worker 任务里,而 QMT 连接必须是一条常驻长连接(协议规定 PMS 只准开一条,多开会让指令乱序)。两者只经数据库耦合:worker 把待发委托写进出口表 `pms_qmt_order`,`pms-ws` 进程亚秒轮询取走并签名发出;QMT 的回报落进 `pms_qmt_inbox`,再由回放任务认领进账本。因此 `pms-ws` 这个服务绝不能扩副本。
三组容器服务:默认起 `pms-web`(管理页面,端口 38100);`--profile sched` 起 `pms-beat` 和 `pms-worker`(调度与执行);`--profile ws` 起 `pms-ws`(QMT 直连)。
## 严格单表访问守卫
153 代理库不支持多表联查,写错了要到线上才报错。为把这条纪律钉死,`app/db/session.py` 在每条 SQL 的执行入口做静态检查,出现 JOIN、逗号连表、跨表子查询一律拒绝抛异常。写数据访问代码时,一条 SQL 只能碰一张表;要联的数据在 service 层分两次查再在内存里拼。`upsert` 语句里的 `ON DUPLICATE KEY UPDATE` 是这道守卫的历史误伤点,改 upsert 时留意。
## 数据库与外部依赖
三个 MySQL 库(键名与 bionic 对齐,同一份 `.env` 可服务两个系统):`PROXY_DB_URL` 是 153 代理,放 PMS 全部自有表加决策系统结论表;`SOURCE_DB_EXT_DSN` 是因子库,当前 PMS 不用;`DB_MYSQL_URL` 是大盘指数库,只做页面区制提示。建表脚本是 `scripts/init_db.py`,建表语句在 `ddl_pms_v1.sql`。
Redis 有几路:决策系统的盘中信号流与风控卖出流、实时行情、PMS 自己的 Celery 总线。**`redis` 依赖必须锁在 5.x**:6.x 默认按 RESP3 握手,而现网的 Redis 版本偏旧会回 `unknown command HELLO`,导致行情与调度总线一起断。`requirements.txt` 已锁版本,升级依赖时别放开。
## 时区口径(很容易读错)
不能按「哪个系统」一刀切,要按「哪种时间」分。容器时钟与容器日志正文是北京时间,不要加八小时。数据库里的时间戳列(如各种 `created_at`、`updated_at`)是 UTC,读的时候要加八小时。日期列(如 `trade_date`、`audit_date`)是北京日期,不换算。写记录时一律用北京时间,引用数据库时间列先加八小时并标注已换算。完整依据见 `DEVLOG.md` 开头。
## 前端的两个坑
管理页面是无构建步骤的单页,这两个坑都实际炸过页面,都写在 README 里。
1. 模板里不能用自闭合的自定义标签。`<el-alert .../>` 这种写法浏览器 HTML 解析器不认,会把后面所有内容当成它的子节点;若它带 `v-if` 且条件为假,整页就跟着消失。一律写成 `<el-xxx ...></el-xxx>`。
2. `setup()` 里定义给模板用的东西必须真的 `return` 出去。漏一个函数没接出去,用到它的地方会在渲染时报错并白屏。`scripts/test_page_wiring_guard.py` 就是为这个坑加的护栏(2026-09-10 白屏即漏了十一个显示函数所致)。
`index.html` 没有别的自动化测试,页面的两道静态护栏是 `test_page_enum_guard.py` 与 `test_page_wiring_guard.py`,改页面后要一起跑。
## 加测试时的登记纪律
`run_tests.py` 把「缺一个测试文件」当失败处理,而不是当「跳过」——因为镜像旧了的时候,新加的那批测试正好全部「不存在」,一条没跑却会报全部通过。所以新增一个 `test_batch*.py`,必须同时登记进 `run_tests.py` 顶部的 `SUITES` 清单和它上面的例数说明表,否则会被判失败。
代码里还有若干「哨兵断言」,改了对应的东西就得回到那里改断言,断言不动就是漏了。清单写在 `run_tests.py` 顶部的注释里(DDL 表数、调度位、API 路由、密钥名单、动作求值器次序等)。改这些结构性的东西时先去那份清单对一遍。
## 部署与运维纪律
在开发机修改代码,通过git提交,然后在服务器上部署
部署方式与服务器信息以 `DEVLOG.md` 开头那段为准(PMS 跑在哪台机、决策系统与因子桥各在哪、各自怎么生效)。有模拟仓与真实仓两套实例,靠表名前缀(`PMS_TABLE_PREFIX`)与容器名前缀区分,共用同一套代码。对真实仓做写操作前先确认清楚是哪一套。
服务器上的容器操作偏敏感:不要随意重启或关停容器,涉及容器与数据库的操作先与用户确认,不碰本仓库以外的项目。登录到哪台机、面对的是哪一套实例,不清楚时先问。
## 文档地图
仓库根目录有大量设计与交接文档,需要细节时按主题查这几份:
- `README.md`:总览,最全。上面各节多是从它摘的。
- `DEVLOG.md`:开发节点流水账,每条含「做了什么、动了哪些文件、部署方式、真机判收、还欠什么」五样。查某个改动的来龙去脉看这里。
- `POSITION_MGMT_DESIGN.md`:总体设计定稿。
- `QMT_WS_PROTOCOL.md`:PMS 与 QMT 的 WebSocket 协议,下发通道的唯一实现依据。
- `BIONIC_PMS_INTERFACE.md`:PMS 与决策系统的接口契约(研判闸与盘中择时两个接口)。
- `UPSTREAM_PLAN_API.md`:上游选股计划接口的接入记录,候选池的事实源。
- `docs/复盘决定台账.md`:近期改动按「台账」编号推进,提交信息里的台账号对应到这里。
## 提交约定
提交信息用简短中文,描述这次改动的行为而非罗列文件。近期工作按台账编号组织,提交里出现的台账号可回到 `docs/复盘决定台账.md` 对照。
旧的 `claude/` 分支和 `.claude/worktrees/` 中的未合入工作继续保留。迁移开发工具不代表这些改动已经合并、部署或验收。

View File

@ -1737,6 +1737,46 @@ docs/交接_2026-09-20.md(新)、docs/dev_preview/mock_preview.py 与 sample
---
## 2026-10-12 · 从 Claude 接续到 Codex,整理未合入修复与未执行探针
**做了什么**
以当前仓库为准完成开发规则迁移。根目录 `AGENTS.md` 作为唯一规则入口,`CLAUDE.md` 改为兼容引用。补回缺失的可读中文要求与页面偏好,补全三道页面静态守卫,记录单测隔离缺口和数据库时间口径的待核实差异。新增接续记录,区分主分支、两个未合入的 Claude 修复分支、未提交讨论稿和探针。用户确认原定 10-09 的两项探针均未执行,先不另找旧会话资料。
**动了哪些文件**
`AGENTS.md`、`CLAUDE.md`、`README.md`、`docs/开发接续_2026-10-12.md`、`.gitignore`、`.dockerignore`、`DEVLOG.md`。忽略规则排除本地代理目录与嵌套工作树;两个工作树仍保留。接手时已有的台账、讨论稿与两个探针内容未改。
**部署方式**
仅开发机规则与文档迁移,未提交、未推送、未部署。直接查询代码服务器确认远程 `main` 与本机同为 fbe0014;两个 Claude 修复分支未返回同名远程引用。后续提交要把规则入口与接续文档一起提交,避免兼容入口指向不存在的文件。
**真机判收**
未判收。本次未连接 155 或 188 核对运行状态,未运行业务全量测试。开发机三道页面静态守卫与 `git diff --check` 均通过,两个探针只做语法解析。原有台账、讨论稿和两个探针的文件摘要与接手时一致。
**还欠着什么**
测试装配仍漏替换 `consensus_stat_repo`,需先修复隔离。两个修复分支重用了 `test_batch35_units.py`,合并时要保留两批测试并重新统计。命令完成后的 GATED 批次收尾仍待明确。排序开关的当前状态、两个探针的新执行时间、方案第二轮拍板与真实仓准备仍待接续。
---
## 2026-10-12(续)· 修复单测隔离并整合命令结算与一字板修复
**做了什么**
在 `codex/test-isolation-integrate` 整合 Claude `ead20bf` 与 `01dbcdb` 的修复,原工作树保留。补齐观察读数仓储、扫描取数和量比等内存桩。增加配置隔离与禁止联网入口,外部调用被吞异常也判测试失败。两分支重名测试分别登记为第三十六、三十七批,隔离回归登记为第三十五批。
用户确认命令完成时取消未解锁批次。完成前持久化扣除额,取消本命令的 GATED 方案后再写完成状态,故障后可重试,方案记录仍保留。补非零目标无方案不完成,以及昨收空值、陈旧值、无效价格不缓存的保护。
**动了哪些文件**
`app/core/command_spec.py`、`planner.py`、`exec_timing.py`、`rule_gate.py`,`app/repo/pms_repo.py`,`app/services/command_service.py`、`executor.py`、`exec_advisor.py`、`market.py`。测试改动集中在 `scripts/unit_test_guard.py`、`test_wiring.py`、`test_batch35_units.py` 至 `test_batch37_units.py`、各批护栏入口和 `run_tests.py`。同步规则与接续记录。没有 DDL、依赖或运行参数变更。
**部署方式**
开发机准备提交后,在 155 模拟仓先用无网络、无生产配置的临时容器验证,再按 `make build` 和 `make up` 重建镜像与四个 PMS 服务。本次无需建表。188 真实仓不在本次部署范围。
**真机判收**
未判收。开发机临时 Python 3.9 环境全量 41 批、983 例通过,日志 `/tmp/pms-integrated-fifth.log`。校正旧统计:原有 941 例,本次新增 42 例。155 只读核对见仓库 `fbe0014`、四服务运行且网页健康;它仍挂载源码,必须另核后端重建时间。服务器隔离测试与部署验收待执行。
**还欠着什么**
完成 155 的容器回归与部署验收。历史假读数和历史 DONE 命令遗留批次不在本次自动清理范围。盘中才封住的跌停、因子库前复权基准、两项旧探针和 188 真实仓仍按原计划单独接续。
---
<!--
下一条节点从这里往下写,格式照抄上面:
## YYYY-MM-DD · 一句话标题

View File

@ -10,6 +10,8 @@
| 文件 | 内容 |
|---|---|
| `AGENTS.md` | Codex 项目指令与统一开发规则。`CLAUDE.md` 保留兼容入口 |
| `docs/开发接续_2026-10-12.md` | 中断后的工作接续:两个未合入修复、未执行探针、待确认事项与本地验证边界 |
| `POSITION_MGMT_DESIGN.md` | 总体设计 **V0.4(定稿,开发启动)**:命令系统与管理页面/账本/仓位框架/动作引擎/两道关口/择时执行/下游通道。功能一次性开发,上线按依赖分三步切换 |
| `QMT_WS_PROTOCOL.md` | **PMS ↔ QMT WebSocket 指令与回报协议 V1.0(定稿)**:传输与重连、Ed25519 签名与幂等、消息集、状态机、断线补发与对账兜底、部署前检查清单。**这是下发通道的唯一实现依据** |
| `QMT_INTERFACE_REQUIREMENTS.md` | 与 QMT 侧的数据与接口需求清单 **V2.0**:A 部分(只读数据)与 C 部分(切换约定)有效;**B 部分的表通道已废止**,改由上面的 ws 协议承担 |

View File

@ -425,6 +425,22 @@ def initial_status(cls: str) -> str:
return ST_PENDING
def effective_target(target_amount, planned_amount, gated_amount=0.0) -> float:
"""任务命令的结算线 = min(命令目标, 方案额) − 未解锁 GATED 批金额。
命令目标 (progress.target_amount) 是「规模 × 比例 − 现有市值」那种理论金额, 没取整到手;
方案额是各条买卖方案取整到手后的金额之和。A 股只能整手买卖, 方案额几乎总比目标少一截
零头 —— 拿理论目标当完成线, 方案一股不差全部成交也够不着, 命令永远停在执行中、窗口末
转部分完成 (2026-10-08 查实: 现价 10.37 建仓 6% × 100 万, 目标 60,000、方案 59,109,
全部成交仍差 891 元)。方案额比目标多 (减仓清弱票整票卖、升仓取整微超) 时仍以目标为线。
GATED 批没有解锁机制, 从结算线里扣掉 (2026-08-28 口径不变)。"""
t = float(target_amount or 0)
p = float(planned_amount or 0)
if p <= 0 and t > 0:
return t # 没有方案金额时不把非零目标折成零而误判完成。
return max(0.0, min(t, p) - float(gated_amount or 0))
def settle_task_status(target_amount: float, done_amount: float, window_over: bool,
eps: float = 1e-6) -> str:
"""任务命令进度结算 (设计 §3.2 第4步): 达标 DONE; 窗口末仍未完成 PARTIAL; 否则继续 EXECUTING。"""

View File

@ -60,6 +60,66 @@ def in_session(now_min: int) -> bool:
return (OPEN_MIN <= now_min <= LUNCH_START) or (LUNCH_END <= now_min <= CLOSE_MIN)
# ================================================================ 一字板方向
# 方向按「现价相对昨收」定, 且这个涨跌幅必须落在该板块涨跌停的合理带里才算数:
# 下沿 4%: 涨跌幅限制最窄的是主板风险警示股 5%, 低价股取整误差留出余量。
# 上沿 = 板块限制 + 1%: 主板 10%, 创业板与科创板 20%, 北交所 30%。
# 落在带外的一律当方向不明, 回退旧口径 (见 limit_board 的说明)。
ONE_WORD_MIN_MOVE = 0.04
ONE_WORD_TOL = 0.01
def board_limit_ratio(ts_code) -> float:
"""该票所在板块的涨跌幅限制 (不辨风险警示, 风险警示更窄, 落在下沿与上沿之间照样认得出)。"""
s = str(ts_code or "").strip().upper()
if s.endswith(".BJ"):
return 0.30
if s.startswith(("688", "689", "300", "301")):
return 0.20
return 0.10
def is_one_word(day: dict) -> bool:
"""一字板: 窗口里最高 = 最低 = 现价, 且已有三根以上分钟线。"""
if not day:
return False
hi, lo, px = day.get("high"), day.get("low"), day.get("price")
return bool(hi and lo and px and hi == lo == px and day.get("bars", 0) > 3)
def limit_board(day: dict, prev_close=None, ts_code: str = "") -> dict:
"""一字板是涨停还是跌停 (2026-10-08 修)。返回 {limit_up, limit_down, basis}。
旧口径拿 day_chg_from_open 的正负定方向, 可一字板的开盘价就等于现价, 这个涨幅恒为 0,
于是同时满足「>= 0 算涨停」与「<= 0 算跌停」, 两个标记一起亮。后果是一字跌停的买单
以「涨停一字板」的理由跳过, 一字涨停的卖单在非末日被当跌停跳过, 规则闸还给卖单挂上
跌停告警。
现在按现价相对昨收定方向。prev_close 由调用方给, 必须已核实是上一交易日的收盘
(market.get_prev_close), 核不实就传 None。basis 标明这次按什么定的:
none 不是一字板, 两个标记都不亮 (与旧口径相同)
prev_close 按昨收定出了方向, 只亮一个
fallback 昨收缺失、或涨跌幅落在合理带外, 方向不明, 与旧口径逐字相同 (两个都亮)
落在带外的典型是除权除息日: 昨收没有按除权参考价折算, 涨跌幅对不上板块限制。
方向不明时宁可两边都当一字板: 买入跳过, 卖出非末日顺延, 不比修之前多做一笔。
"""
if not is_one_word(day):
return {"limit_up": False, "limit_down": False, "basis": "none"}
try:
pc = float(prev_close or 0)
except (TypeError, ValueError):
pc = 0.0
if pc > 0:
r = float(day["price"]) / pc - 1
cap = board_limit_ratio(ts_code) + ONE_WORD_TOL
if ONE_WORD_MIN_MOVE <= r <= cap:
return {"limit_up": True, "limit_down": False, "basis": "prev_close"}
if -cap <= r <= -ONE_WORD_MIN_MOVE:
return {"limit_up": False, "limit_down": True, "basis": "prev_close"}
chg = day.get("day_chg_from_open")
return {"limit_up": (chg or 0) >= 0, "limit_down": (chg or 0) <= 0, "basis": "fallback"}
def daily_quota(remaining_qty: int, tdays_left: int, *, total_qty: int = None,
lot: int = LOT, allow_odd_tail: bool = False) -> int:
"""当日配额 = 剩余量 ÷ 剩余交易日, 向上取整到一手。
@ -233,6 +293,11 @@ def hard_gate(*, side: str, now, day: dict, params: dict, is_last_day: bool,
if side == "buy":
if day.get("limit_up"):
return out(ACT_SKIP, "涨停一字板, 当日跳过顺延")
if day.get("limit_down"):
# 一字跌停不接 (2026-10-08)。修一字板方向之前, 一字跌停被误判成涨停而跳过,
# 方向修对以后这里接住, 买入照旧跳过, 只把理由说对。盘中才封住、不是一字板的
# 跌停不归这里管, 实现B 照样会买, 那是另一道可选护栏。
return out(ACT_SKIP, "跌停一字板, 不接, 当日跳过顺延")
dayup = day.get("day_chg_from_open")
cap = float(params.get("buy_halt_dayup") or 0.05)
if dayup is not None and float(dayup) > cap:

View File

@ -239,7 +239,9 @@ def plan_reduce_exposure(*, release_amount: float, positions: list, pending_buys
gap = max(0.0, target - acc)
if gap > 0:
notes.append(f"可减持仓不足, 缺口 {gap:,.0f} 元 —— 命令将置部分完成并告警")
# 结算按方案额 (command_spec.effective_target): 方案全部成交即判完成, 不再挂部分完成
notes.append(f"可减持仓不足, 缺口 {gap:,.0f} 元 —— 方案全部成交后命令按方案额结算完成, "
f"缺口不会自动补, 需要再下命令或手工减")
return {"ok": gap <= 0, "target_amount": round(target, 2), "planned_amount": round(acc, 2),
"gap": round(gap, 2), "items": items, "notes": notes}

View File

@ -194,6 +194,10 @@ def _check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard,
if day.get("limit_up"):
failed.append("LIMIT_UP: 涨停封板, 不追买")
elif day.get("limit_down"):
# 一字跌停不接 (2026-10-08): 方向修对之前它被误判成涨停而拦在上一行, 现在单独拦。
# 用 elif: 方向不明时两个标记都亮, 只记上一行那一条, 与修之前逐字相同。
failed.append("LIMIT_DOWN: 跌停封板, 不接")
# 不追高: 当日涨幅 与 距 MA5 两道
dayup = day.get("day_chg_from_open")

View File

@ -241,6 +241,13 @@ def update_plan(plan_id: str, *, status=None, filled_qty=None) -> int:
return execute(f"UPDATE pms_plan SET {', '.join(sets)} WHERE plan_id = :pid", p)
def cancel_gated_plans_of_command(command_id: str) -> int:
"""命令完成前只取消未解锁批次,保留记录,不碰可执行方案和在途指令。"""
return execute("UPDATE pms_plan SET status = 'CANCELLED', updated_at = :now "
"WHERE command_id = :cid AND status = 'GATED'",
{"cid": command_id, "now": _NOW()})
def cancel_plans_of_command(command_id: str) -> int:
# GATED (建仓的补足/加仓批) 也是在途状态, 撤命令必须一并作废 (2026-08-28 审查修 ——
# 原来 WHERE 漏了它, 撤销后的 GATED 批还以"在途建仓意图"挂在各个视图里)

View File

@ -611,24 +611,47 @@ def refresh_progress(command_id=None) -> dict:
prog["done_amount"] = round(done_amt, 2)
dl = prog.get("deadline")
over = bool(dl) and str(today) > str(dl)[:10]
# 两条结算口径修正 (2026-08-28 审查):
# 三条结算口径修正 (①② 2026-08-28 审查, ③ 2026-10-08):
# ① GATED 批 (建仓的补足/加仓批) 当前没有解锁机制, 不该算进完成分母 ——
# 算进去的话每条建仓命令必然拖满窗口被判 PARTIAL。有效目标 = 目标 − 未解锁
# GATED 批金额; GATED 金额单独存进 progress 供页面如实显示。
# ② 目标金额为 0 但还有未执行方案 (盘前一键清仓: 全部持仓取不到现价, 金额都记 0)
# 不能判 DONE —— 那会让方案永不物化、一股没卖、页面却显示"已完成"。
gated_amt = round(sum(float(p.get("amount") or 0) for p in plans
if str(p.get("status")) == "GATED"), 2)
# ③ 结算线取 min(目标, 方案额)。目标没取整到手, 方案额是整手的, 方案全部成交也够
# 不着目标 —— 建仓/升仓/个股减仓从来到不了 DONE, 一直占着在途: 同股重下被判重复、
# 暂停买入与一键清仓报冲突。方案额从方案行直接加 (撤单 HALT 行不算, 与 planner
# 的 planned_amount 同口径), 与上面的 done_amt 同源同精度。
# 口径随之变的一处: 方案本身没凑够目标 (升仓候选不够、降仓可卖不够) 的命令, 方案
# 全部成交后也判 DONE, 缺口照旧留在 progress.gap 里由页面显示 —— 方案只在下达时
# 生成一次, 留在 PARTIAL 也不会再补, 只会挡住后面的命令。
gated_plans = [p for p in plans if str(p.get("status")) == "GATED"]
gated_amt = round(sum(float(p.get("amount") or 0) for p in gated_plans), 2)
# 完成前先持久化扣除额,再取消 GATED。若取消成功后进程退出,下轮仍用相同结算线。
gated_amt = max(gated_amt, float(prog.get("settlement_gated_amount") or 0))
plan_amt = sum(float(p.get("amount") or 0) for p in plans
if str(p.get("action")) != "HALT")
open_exec = any(str(p.get("status")) in ("PENDING", "EXECUTING") for p in plans)
if gated_amt > 0:
prog["gated_amount"] = gated_amt
target_eff = max(0.0, float(prog.get("target_amount") or 0) - gated_amt)
target_eff = cs.effective_target(prog.get("target_amount"), plan_amt, gated_amt)
st = cs.settle_task_status(target_eff, done_amt, over)
if st == cs.ST_DONE and open_exec:
st = cs.ST_PARTIAL if over else cs.ST_EXECUTING
if st != c["status"] and cs.can_transition(cs.CLS_TASK, c["status"], st):
pms_repo.update_command(c["command_id"], status=st, progress=prog,
done_at=datetime.now() if st == cs.ST_DONE else None)
if st == cs.ST_DONE and gated_plans:
prog["settlement_gated_amount"] = gated_amt
saved = pms_repo.update_command(c["command_id"], progress=prog,
only_if_status=[c["status"]])
if not saved:
raise RuntimeError("命令状态已变化,停止完成结算: " + c["command_id"])
cancelled = pms_repo.cancel_gated_plans_of_command(c["command_id"])
if cancelled < len(gated_plans):
raise RuntimeError("未解锁批次未全部取消,保留命令待重试: " + c["command_id"])
updated = pms_repo.update_command(c["command_id"], status=st, progress=prog,
done_at=datetime.now() if st == cs.ST_DONE else None,
only_if_status=[c["status"]])
if not updated:
raise RuntimeError("命令状态未更新,停止结算: " + c["command_id"])
else:
pms_repo.update_command(c["command_id"], progress=prog)
out.append({"command_id": c["command_id"], "status": st,

View File

@ -96,6 +96,8 @@ def _strategy_immediate(*, side, now, day, quota, fired_today,
return _o(et.ACT_SKIP, "停牌或无实时价, 当日顺延")
if str(side).lower() == "buy" and day.get("limit_up"):
return _o(et.ACT_WAIT, "涨停封板, 不追买")
if str(side).lower() == "buy" and day.get("limit_down"):
return _o(et.ACT_WAIT, "跌停封板, 不接") # 2026-10-08, 见 et.limit_board
if str(side).lower() == "sell" and day.get("limit_down"):
return _o(et.ACT_WAIT, "跌停封板, 挂单等回封")
disc = 1.002 if str(side).lower() == "buy" else 0.998

View File

@ -201,8 +201,9 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict:
is_urgent = bool(prog.get("urgent"))
fired_today = (_inflight_today(children, ymd_today) if (is_urgent or is_last)
else _consumed_today(children, ymd_today))
lim_up, lim_down = _limit_flags(code, day)
day_ctx = {**day, "support": pos.get("support_ref"),
"limit_up": _limit_up(day), "limit_down": _limit_down(day),
"limit_up": lim_up, "limit_down": lim_down,
"halted": not day or not day.get("price")}
# 择时判定统一走 exec_advisor: PMS_EXEC_IMPL=B (默认) 时它就是 et.decide 原样;
# =A 时先过硬闸 (配额/兜底等 PMS 自留地), 再委托决策系统, 不可用退实现B。
@ -543,18 +544,18 @@ def _inflight_today(children: list, ymd_today: int) -> int:
return total
def _limit_up(day: dict) -> bool:
"""一字板粗判: 当日最高=最低=现价 且 涨幅为正 (无涨跌停价字段时的兜底口径)。"""
if not day:
return False
hi, lo, px = day.get("high"), day.get("low"), day.get("price")
chg = day.get("day_chg_from_open")
return bool(hi and lo and px and hi == lo == px and (chg or 0) >= 0 and day.get("bars", 0) > 3)
def _limit_flags(code: str, day: dict) -> tuple:
"""一字板的 (涨停, 跌停) 两个标记 (2026-10-08 修, 判据在 et.limit_board)。
def _limit_down(day: dict) -> bool:
if not day:
return False
hi, lo, px = day.get("high"), day.get("low"), day.get("price")
chg = day.get("day_chg_from_open")
return bool(hi and lo and px and hi == lo == px and (chg or 0) <= 0 and day.get("bars", 0) > 3)
只有一字板才去取昨收 —— 一字板很少见, 平常这一跳不多查一次库。昨收取不到或
核不实时 limit_board 回退旧口径 (两个都亮), 与修之前逐字相同。
"""
if not et.is_one_word(day):
return False, False
try:
pc = market.get_prev_close(code)
except Exception as e:
logger.warning("[一字板] 昨收取数异常 %s: %s —— 方向按不明处理", code, e)
pc = None
b = et.limit_board(day, prev_close=pc, ts_code=code)
return b["limit_up"], b["limit_down"]

View File

@ -17,6 +17,7 @@ import threading
from datetime import datetime, timedelta
from config.settings import settings
from app.core import tradedays as td
from app.db.session import fetch_all
from app.repo import downstream_repo
from app.services import param_store
@ -203,6 +204,46 @@ def get_last_close(ts_code: str):
return val
def get_prev_close(ts_code: str, today=None):
"""**核实过是上一交易日的**昨收 (日线 close_qfq)。核不实一律返回 None。
给一字板定方向用 (executor._limit_flags, 2026-10-08)。与 get_last_close 的区别:
那个是盘前定价的兜底, 拿到最近一根就用, 隔了几天也无妨; 这个拿去判涨停还是跌停,
隔天的收盘会把方向判反, 所以只认今天之前最近的那一根, 而且它的日期必须正好是
上一交易日。因子桥某晚没跑、长假后日历缺年份 (退化成周一到周五) 都会对不上,
这时回 None, 调用方按方向不明回退旧口径。
前复权口径: 按常规算法最近一根的复权基准就是它自己, 数值等于当日原始收盘 (因子桥
的实际算法未在库上核对)。除权除息日今天的价格已按除权参考价报, 与这一根对不上,
由 et.limit_board 的合理带兜住: 落在带外回退旧口径; 大比例送转把一字涨停压进跌停
那一侧时会判成跌停, 结局与修之前相同 (买入跳过, 卖出非末日顺延)。跌停反过来判成
涨停不会发生, 除权只会让价格更低。
只按日缓存日期正确且价格有效的昨收。底层可能把分片异常转为空行,空值或陈旧值不缓存。
"""
t_ymd, want = td.ymd(today), td.ymd(td.prev_trade_day(today))
key = f"PV:{ts_code}"
if _ma_cache["day"] != str(t_ymd):
_ma_cache.update({"day": str(t_ymd), "data": {}})
if key in _ma_cache["data"]:
hit = _ma_cache["data"][key]
else:
try:
rows = [r for r in _factor_rows(ts_code, days=3) if td.ymd(r["trade_date"]) < t_ymd]
except Exception as e:
logger.warning("昨收取数失败 [%s] (本次不缓存, 下次重查): %s", ts_code, e)
return None
hit = ({"close": float(rows[-1]["close_qfq"]), "ymd": td.ymd(rows[-1]["trade_date"])}
if rows else None)
if hit and hit["ymd"] == want and 0 < hit["close"] < float("inf"):
_ma_cache["data"][key] = hit
else:
logger.warning("昨收核不实 [%s]: 最近一根 %s, 上一交易日应为 %s —— "
"一字板方向按不明处理", ts_code, hit and hit["ymd"], want)
return None
return round(hit["close"], 3) if hit and hit["ymd"] == want else None
def day_volume(ts_code: str) -> dict:
"""当日累计成交量与它实际覆盖的交易时段 (2026-09-09, 解锁重问的量能确认要用)。

View File

@ -0,0 +1,123 @@
# PMS 开发接续与 Codex 迁移记录
日期为 2026-10-12,北京时间。开发入口已迁到根目录 `AGENTS.md`。本次整理完成了仓库规则与工作上下文的接续,下文保留接手时快照,最新进展见第七节。
用户在本次会话确认两件事。第一,原定 10-09 的服务器只读核对与离线探针均未执行。第二,先以当前仓库为准,不另找旧 Claude 会话资料。
## 一、当前开发基线
本机主分支是 `main`,提交为 `fbe00144137c9b03a51c90c00e6b1bf1ccda3a77`,日期是 2026-09-20。本次用 `git ls-remote` 直接核对代码服务器,远程 `main` 与本机相同。不能据此判断服务器运行的代码版本。
9 月 20 日交接里的模拟仓读数和真实仓版本都是历史快照。10 月 8 日命令结算分支的开发记录还记了一次隔离容器测试,但不是新版本部署。155 和 188 的当前运行状态均未在本次会话核对。
接手时已有以下未提交工作,本次保持内容不变。
| 文件 | 内容与状态 |
|---|---|
| `docs/复盘决定台账.md` | 已修改,新增台账 017。记录 09-22 把上游排序开关设回 False,等待约定时点再开。后续是否已重开,当前材料没有结论 |
| `docs/方案讨论稿_量化平台接入与急跌满仓预案_2026-10-08.md` | 未跟踪的讨论稿。已记第一轮反馈,仍有待拍板项 |
| `scripts/probe_crash_live.py` | 未跟踪的盘中只读探针。用户确认尚未执行 |
| `scripts/probe_both_offline.py` | 未跟踪的合并离线探针。用户确认尚未执行 |
根目录 `AGENTS.md` 在接手时也已存在但未跟踪,内容基本是 `CLAUDE.md` 的改名副本。本次在该文件上完成迁移。
## 二、接手时的两个未合入修复
两个工作树均位于 `.claude/worktrees/`,都应保留。每个工作树还有自己的未跟踪 `AGENTS.md`,本次未修改。向远程查询下面两个完整分支名时没有返回同名引用;不要删除本地分支或工作树。
| 分支 | 提交 | 改动 | 历史验证记录 |
|---|---|---|---|
| `claude/happy-almeida-0b0343` | `ead20bf` | 命令结算线按目标金额与方案金额取较小者,再扣未解锁批次金额。解决取整零头导致命令不能完成的问题 | 分支 `DEVLOG.md` 记载开发机与 155 断网隔离容器通过,新增 10 例。未部署,未真机判收 |
| `claude/great-herschel-e57674` | `01dbcdb` | 一字板方向结合昨收判定。一字涨停的卖单不再误按跌停顺延,一字跌停的买单继续拦截 | 分支 `DEVLOG.md` 记载开发机通过,新增 19 例。未部署,未真机判收 |
这些是旧会话留下的验证记录,本次没有重新运行这两个分支的业务测试,也没有完成代码复审。
合并之前有三件事要处理。
第一,两条分支都新增了 `scripts/test_batch35_units.py`,并改了 `scripts/run_tests.py` 和 `DEVLOG.md`。合并时必须为两批测试分别登记,不能用一边覆盖另一边。历史记录中的测试总数也不一致,要在最终合并版本上实跑统计。
第二,命令结算分支留下了未解锁批次的收尾问题。命令判为完成后,GATED 批仍可能阻挡策略自动挂载,而完成的命令不能再用撤销路径清理它们。该分支也改变了方案本身不足目标时的完成判定,缺口保留在进度里。这些行为需要在复审时明确。
第三,一字板分支没有解决盘中才封住、最高价不等于最低价的跌停买入问题。昨收所用 `close_qfq` 的复权基准也还需核实。不能把这次修复描述成已经补齐所有跌停买入保护。
## 三、接手时发现的问题
### 接手时的单测隔离缺口
`scripts/test_wiring.py` 的 `install_fakes` 没有替换 `consensus_stat_repo`。`proposal_service.scan_and_route` 会调用 `consensus_stats.record_round`,后者仍有真实仓储写路径。
9 月 20 日交接记录了测试污染 `pms_consensus_stat` 的四行假数据。本次只核对了代码缺口,没有连接数据库确认这些行的现状。修复并验证隔离前,不在带真实配置的服务器容器里执行全量测试。清理历史数据另列具体行,再按仓库纪律确认后操作。
### 排序轴协同要补当前读数
台账 017 已记录 09-22 关闭 `PMS_PLAN_RANK_BY_UPSTREAM`。原计划随后重新打开,并把 `PMS_PLAN_TIERS` 调为两档,但当前材料没有执行结果。10-16 前后的四周复核仍是历史方案中的计划,不能先假定切轴已完成。
后续核对至少需要三个事实:当前开关和档位白名单、上游计划的 `rank_axis`、名册快照的 `rank_mode` 与名次顺序。
### 时间口径有待校准
旧规则把数据库时间列统一写成 UTC。代码里 PMS 部分自有列是 `DATETIME`,由容器内 `datetime.now()` 写入,容器配置为北京时间。两个新探针都注意到了这个差异,准备通过心跳或快照校准。
探针尚未执行,不能把代码推断写成真机结论。后续读数要按具体列核对写入方式,并保留时区标注。
### 旧链路问题单独排期
9 月 20 日记录的开盘研判不可用、网格持仓对账差异、页面人工验收、盘中确认开关和真实仓准备包都还在旧交接中。10 月 8 日讨论稿记载当轮不查旧链路、宏观档位不改。本次迁移没有替这些事项重新排期或执行。
## 四、两个新方向停在哪里
量化平台接入仍处于讨论阶段。已有方向是先做接入契约、只读探针、每日快照和影子记录。量化因子最终作为第二意见还是独立来源,尚未形成最终执行方案。
偏冷区满仓操作也仍是讨论稿。尾盘预估、分档节奏、退出规则、名单篮子和参数恢复范围都需要继续讨论。此前的数字是方案初值,不是当前运行参数。
原定 10-09 的两项探针没有运行,不能跳到依赖探针结果的回测和实施结论。重新执行前需要重定时间,按脚本检查实际实例、查询范围与运行负荷。旧日期不会自动顺延成今天的容器操作授权。
## 五、本次迁移具体做了什么
`AGENTS.md` 成为唯一开发规则入口,保留三条铁律、单表访问纪律、ParamStore、密钥保护、双实例和部署边界。可读中文与页面偏好直接写入该文件,移除了不存在的 `.Codex/output-styles/readable-chinese.md` 引用。页面检查补全为枚举、接线、文案三道守卫。
`CLAUDE.md` 改为兼容入口,明确要求先读 `AGENTS.md`。历史设计文档与交接中的 Claude 名字保留,避免把历史记录改写成新事实。
`.gitignore` 排除旧 Claude 嵌套工作树,`.dockerignore` 排除开发代理本地目录,避免嵌套源码与本机配置进入运行镜像。工作树和分支本身仍保留。
当前仓库没有可迁移的 `.claude/settings.json`、项目 MCP 配置或项目技能目录。本次没有创建无实际用途的 Codex 配置,也没有修改全局账号设置。
Codex 使用仓库中的 `AGENTS.md` 加载项目规则,依据见 [官方项目指令文档](https://learn.chatgpt.com/docs/agent-configuration/agents-md)。本次会话也已实际收到根目录 `AGENTS.md`。以后在新会话中可要求先复述项目三条铁律和当前接续事项,核对读取的是新入口。
迁移改动目前保存在本地工作区,尚未提交或推送。后续提交必须同时包含 `AGENTS.md` 和 `CLAUDE.md`,以及它们引用的本接续记录,不能只提交兼容入口。业务讨论稿、探针和台账的原有修改应另行安排提交。
## 六、迁移阶段的验证与后续安排
本次验证限定为开发机静态检查。三道页面守卫均已通过,`git diff --check` 通过。两个探针仅做语法解析,未执行。迁移文档的本地链接检查通过,原有台账、讨论稿和两个探针的文件摘要与接手时一致。
三道页面守卫可以用以下命令复核,预期各自输出通过并以状态码零退出。
```bash
# 开发机,仓库根目录。
python3 scripts/test_page_enum_guard.py
python3 scripts/test_page_wiring_guard.py
python3 scripts/test_page_copy_guard.py
git diff --check
```
本次没有运行全量业务回归,没有部署服务器,也没有改运行参数。服务器状态仍标为未判收。
建议下一步先修复测试隔离缺口,再复审并整合两个现成修复;探针另定执行窗口,取得读数后继续两份方向的方案讨论。真实仓上线继续按 `REAL_TRADING_DEPLOY_PLAN.md` 的准备边界处理。
## 七、修复与整合进展
用户已要求先修复单测隔离,再复审整合两个分支,并择机部署。用户进一步确认,命令完成时取消未解锁批次。当前整合分支为 `codex/test-isolation-integrate`。
两个原始修复已按代码差异整合,保留原分支和工作树。`ead20bf` 的十例测试改为第三十六批,`01dbcdb` 的十九例测试改为第三十七批。第三十五批留给隔离回归,避免同名测试覆盖。
测试隔离修复补齐了 `consensus_stat_repo` 的四个内存入口,以及账本扫描、夜间定性、量比和资金流水等漏桩。全部测试经独立进程清除业务环境变量,在空临时目录运行,并在套接字连接前拦截外部访问。联网异常即使被业务代码捕获,进程仍返回失败。各批直接运行时也安装此护栏。
命令结算按命令目标与方案金额中的较小值计算,扣除未解锁批次。没有任何方案金额的非零目标不会误报完成。完成前先保存扣除金额,再把该命令的 GATED 方案改成 CANCELLED,最后写命令完成状态。写入失败时保留执行状态并报错,下一轮可按保存的金额继续结算。目标金额、缺口和取消的方案记录都保留。此逻辑只处理本次及后续结算,不批量改写历史已完成命令。
一字板修复按核实过日期的昨收识别方向,保留昨收不明时的保守回退。复审补了空值、陈旧值和无效价格不缓存,避免短暂失败影响全天。它仍只识别最高价、最低价和现价一致的一字板。盘中才封板的情况以及因子库前复权基准仍是原有边界,不能写成已经覆盖。
开发机用临时 Python 3.9 虚拟环境按 `requirements.txt` 安装依赖,经 `scripts/run_tests.py` 全量回归,41 批、983 例通过,包含三道页面守卫。旧总数 904 已过期,接手时实际为 941 例,本次新增 42 例。日志保存在开发机 `/tmp/pms-integrated-fifth.log`。容器运行时是 Python 3.11,仍需服务器断网容器验证。
155 已做主机与容器元数据只读核对。仓库在 `/home/factor/project/tradingSystem`,主分支仍为 `fbe0014`,四个 PMS 服务运行,网页健康检查通过。四个容器均有源码挂载,启动时间为 2026-09-18 11:51:59 北京时间,由 Docker 的 UTC 值换算。服务器有本地文件和 `scripts/deploy.sh` 的修改,部署时保留。188 未操作。
部署候选不含原有台账、讨论稿及两个探针。上线前需确认实例的运行参数与在途队列,在 155 断网临时容器通过后再重建镜像、重建四个 PMS 容器。本次没有 DDL 和依赖变更,无需重新建表。部署和真实交易行为尚未判收。

View File

@ -4,7 +4,7 @@
======================================
运行: docker compose run --rm pms-web python scripts/run_tests.py
包含 (例数按 2026-09-03 实跑校正; 此前写的 591 是过期数字, 当时实跑已是 608):
包含 (例数按 2026-10-12 隔离回归实跑校正):
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例)
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例)
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 / 用户止损价只披露不拦截 (26 例)
@ -61,16 +61,16 @@
到价提议挂着时止损照落并作废该提议 (17 例)
test_batch25_units.py 解锁重问 (2026-09-09 方案第四之一节): 时段占比与量比换算/四条护栏/
三条解锁条件与 fail-closed/请求体 force 为假时逐字节相同/名额只在真发
请求时用掉/判定零副作用/重问放行强制交人/九个参数登记 (14 例)
请求时用掉/判定零副作用/重问放行强制交人/九个参数登记 (15 例)
test_batch24_units.py 点股票看它今天全部信号 (2026-09-09 方案第三节): 代码归一与跨市场不误配/
分页回扫边界与按今天零点截断/五源解析与超龄标陈旧/去重同源合并跨源不合并/
单源失败逐源标明与按票缓存/只读守卫/页面三处入口与层级 (9 例)
单源失败逐源标明与按票缓存/只读守卫/页面三处入口与层级 (10 例)
test_batch23_units.py 公司质地与建议方案接入 (2026-09-09 接入方案): 解析层收公司深度两键/整句送研判可关/建议
方案矩阵与研判补档/持仓页公司块。
方案矩阵与研判补档/持仓页公司块 (5 例)。
test_batch22_units.py 逻辑状态四态接入 (2026-09-07 第三件): 解析层收逻辑状态/硬数字带它而研判
白名单不收/判决候选而逻辑存疑强制交人/持仓存疑停增持侧/研究走弱
减持默认关且开了必交人/同轮减持优先级/早上取回与映射新鲜度/策略
买入腿按来源暂停恢复/持仓视图两栏三情形/假仓库签名/安全边际整句透传/参考目标价/催化事件与定价状态透传/两个期限的头 (19 例)
买入腿按来源暂停恢复/持仓视图两栏三情形/假仓库签名/安全边际整句透传/参考目标价/催化事件与定价状态透传/两个期限的头 (22 例)
test_batch26_units.py 建议档位与真下股数对齐 (2026-09-10): 卡上写试探仓 1% 采纳后就真只买 1%/
提议卡重排要紧的在前证据收进展开/建议档位与实下股数不一致标出 (17 例)
test_batch27_units.py 技术面接入·工作包一 (2026-09-11): 相位合成九格与四种无读数/震荡市抑制翻向/
@ -111,23 +111,22 @@
开关、四类交人、逻辑存疑放弃、深亏补仓三分支、目标价直接采纳、每日上限、record/decline
降级)/mark_decide 优先级/eval_dca 深档打 decide_kind/三参数登记与范围/两个 repo 查询
+ 评审补: 真通过只认带应答体的 PASS、弱表态按强弱打标记 (24 例)
test_batch35_units.py 单测隔离:观察读数内存仓储、整轮扫描与覆盖重建、
异常吞掉仍判联网失败、配置隔离与独立入口护栏 (8 例)
test_batch36_units.py 命令按方案额结算:原分支回归、未解锁批次收尾与故障重试 (13 例)
test_batch37_units.py 一字板方向识别:原分支回归、昨收失败不缓存与盘中恢复 (21 例)
test_page_copy_guard.py 页面文案守卫·口语化 (2026-09-14 页面专业化包): 禁用词清单
(第一二人称/拟人口语/生造动词) 扫显示文本, 剥三种注释、成对豁免、
网址不误截、自检样例 (1 例)
test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 例)
test_page_wiring_guard.py 页面接线守卫:模板名字与 setup return 清单一致 (1 例)
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) +
目标价到价自决关时入队、自决开时自动清仓按可卖量夹紧、挂着方案且有在途买单时先撤再清 +
用户设的止损价与目标价单独成列显示 (72 例)
共 904 例
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 到 655; 09-07 审查修复加了跨轮减持等五例, 到 660; 第二件低把握驳回交人一例, 到 661; 第三件逻辑状态接入第二十二批十五例, 到 676; 第四件安全边际整句透传一例, 到 677; 参考目标价一例, 到 678; 催化事件与定价状态透传一例, 到 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724; 三源合议工作包二纯逻辑第二十八批三十七例到 763; 接入下单链路第二十九批二十四例到 787; 工作包三离场纪律第三十批十八例到 805; part3 盘中 SAR 止损线补九例到 814; part4 弱基本面紧止盈补六例到 820;
09-14 观察读数包第三十一批二十例到 840; 加固包给第二十八批加三例、第三十批加三例到 846; 评审修订第三十一批加一例到 847; 09-14 盘中确认包第三十二批十九例到 866; 页面收尾包第三十三批五例到 871;
09-14 系统自决包第三十四批二十二例、test_wiring 目标价自动清仓加一例到 894;
09-14 选股打分包 §5.3 来源字段给第七批加两例到 896; 页面专业化包加文案守卫一例到 897;
09-14 评审补第三十四批两例、test_wiring 撤方案与在途买单一例到 900;
09-16 提议失效不跨自然日给第二十批加两例到 902; 09-18 评析报告代取给第七批加两例, 现为 904)
共 983 例
2026-10-12 校正历史漏计,原有测试实际 941 例;本次新增三批共 42 例。
任一子集失败即整体失败 (退出码 1)。
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
@ -185,6 +184,9 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch32_units.py",
"test_batch33_units.py",
"test_batch34_units.py",
"test_batch35_units.py",
"test_batch36_units.py",
"test_batch37_units.py",
"test_page_copy_guard.py",
"test_page_enum_guard.py",
"test_page_wiring_guard.py",
@ -197,7 +199,7 @@ def main():
# 工作树对一遍, 对不上就喊。详见 scripts/code_fingerprint.py。
try:
from code_fingerprint import fingerprint
print(f"代码指纹 {fingerprint()} (与工作树对不上 = 容器跑的是旧镜像, 先 make deploy)")
print(f"代码指纹 {fingerprint()} (与工作树对不上 = 容器跑的是旧镜像, 先 make deploy)", flush=True)
except Exception as e:
print(f"代码指纹取不到: {type(e).__name__}: {e}")
@ -211,8 +213,9 @@ def main():
print(f"== 缺失 {s} —— **这一批一条都没跑**")
missing.append(s)
continue
print(f"\n=== {s} " + "=" * (52 - len(s)))
r = subprocess.run([sys.executable, path], cwd=ROOT)
print(f"\n=== {s} " + "=" * (52 - len(s)), flush=True)
r = subprocess.run([sys.executable, os.path.join(HERE, "unit_test_guard.py"), path],
cwd=ROOT)
if r.returncode != 0:
failed.append(s)
print("\n" + "=" * 62)

View File

@ -23,6 +23,7 @@
[A] 组是这批里唯一一条**静态**用例: 它不测行为, 它扫源码, 守住"关键路径不许丢返回值"
这条纪律本身 —— 新加的调用点一旦又把返回值丢了, 这里立刻红。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import ast
import os
import sys

View File

@ -15,6 +15,7 @@
* 配额与收尾兜底始终留在 PMS 本地 —— 本地检查命中时连咨询都不发生 (用 HTTP 计数器钉死)。
* 默认档位 B 必须与接通前一字不差 —— 实现A是加装不是改装。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -9,6 +9,7 @@
研判的单轮时间预算。
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback
@ -657,6 +658,8 @@ def _():
# ================================================================ 跑
def main():
from test_wiring import install_fakes
install_fakes() # 其余外部依赖也必须有默认内存桩。
passed, failed = 0, []
for name, fn in RESULTS:
try:

View File

@ -9,6 +9,7 @@
* 确认即加速: 决策系统卖出命中在途清仓 → 只把卖出**指令**升级紧急 (打桩 repo);
* 闭仓清场: 撤策略/在途建仓/买单/提议, 幂等第二遍无动作 (打桩 repo/service)。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -12,6 +12,7 @@
* 服务层 (打桩): 全自动下命令与冲突让路 / 建议与采纳幂等 / 闸状态读写与陈旧失效 /
proposal_service 的买入侧过滤留 TRIM。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import json
import math
import os

View File

@ -22,6 +22,7 @@
* 丁(减仓看情况): _pending_buys(exclude_strategy=True) 剔掉策略腿 —— 减仓不掐网格/做T,
清仓与全局暂停买入仍全撤.
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -12,6 +12,7 @@ pms_repo 的 execute / fetch_all, 把真实生成的 SQL 抓出来核对三件
四类记录 (命令/策略/指令/提议) 的终态集与后端 archive_* 和前端可移除判据是同一份, 一并钉死。
运行: python scripts/test_batch16_units.py
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -11,6 +11,7 @@
转派发暂停买入与恢复 / 接力全链 (撤网格→打标记→挂止盈→初始化高水位)。
运行: python scripts/test_batch17_units.py
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback
@ -836,6 +837,8 @@ def _():
def main():
from test_wiring import install_fakes
install_fakes() # 其余外部依赖也必须有默认内存桩。
passed, failed = 0, []
for name, fn in RESULTS:
try:

View File

@ -9,6 +9,7 @@
4. 接口鉴权 authz_decision + is_public_path: 只有运维维护类接口归管理员, 其余登录人都放行。
运行: python scripts/test_batch18_units.py
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -16,6 +16,7 @@
11. 双实例表名前缀 (SQL 执行唯一入口统一映射 pms_* 表名 + 旁路扫描)。
运行: python scripts/test_batch19_units.py
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -12,6 +12,7 @@
* 页面静态守卫: 提议卡按 plan_rank 取名次、裁决理由必填并随请求体发。
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback
@ -614,6 +615,8 @@ def _():
def main():
from test_wiring import install_fakes
install_fakes() # 其余外部依赖也必须有默认内存桩。
import logging
logging.disable(logging.CRITICAL)
passed, failed = 0, 0

View File

@ -19,6 +19,7 @@
驱动的清仓, 它们根本不经过提议分流。这两条用「分流函数上装绊线」的办法证明。
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -18,6 +18,7 @@
* 持仓视图两栏: 有指令链 / 外部成交并入 / 账本无行 三种情形都说得出话, 仓库读失败页面不塌。
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback
@ -593,6 +594,8 @@ def test_bad_y_signals_pinned():
assert isinstance(rule_gate.BAD_Y_SIGNALS, tuple)
def main():
from test_wiring import install_fakes
install_fakes() # 参数与仓储默认用内存,各用例再覆盖需要的依赖。
ok = 0
for name, fn in RESULTS:
try:

View File

@ -6,6 +6,7 @@
3. 逐票逻辑状态解析收公司深度两键; 持仓页 company_view 一行。
4. 建议方案矩阵: 六种情形各一例; 研判补档; 开关开着时评估参数按建议。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -8,6 +8,7 @@
5. 韧性: 单源失败不拖垮整体且逐源标明、按票缓存、实况分不编时间戳。
6. 只读守卫与页面静态断言。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import io
import os
import re
@ -277,6 +278,8 @@ def test_route_registered():
def main():
from test_wiring import install_fakes
install_fakes() # 参数与仓储默认用内存,各用例再覆盖需要的依赖。
ok = 0
for name, fn in RESULTS:
try:

View File

@ -9,6 +9,7 @@
F 分流: 重问放行强制交人, 哪怕档位 full。
G 参数与页面: 九个键登记齐、页面认得新动作名。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import io
import os
import sys
@ -299,6 +300,8 @@ def test_params_and_page():
def main():
from test_wiring import install_fakes
install_fakes() # 参数与仓储默认用内存,各用例再覆盖需要的依赖。
ok = 0
for name, fn in RESULTS:
try:

View File

@ -29,6 +29,7 @@ A 到 E 组在开发机上就能跑(纯逻辑与静态扫描);F 组要 imp
sqlalchemy,得在容器里跑:
docker compose run --rm --no-deps -v $PWD:/app pms-web python scripts/test_batch26_units.py
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -8,6 +8,7 @@
E 参数与口径: PMS_TECH_* 登记进 RUNTIME_EXTRA 与 _RANGES、交易日差口径。
F 落表 (pull): 接口状态非 OK 整轮不落表、正常落表并推进日期 (桩掉库)。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback
@ -333,6 +334,8 @@ def test_upsert_odku_no_bind():
def main():
from test_wiring import install_fakes
install_fakes() # build_map 的读数落表也必须隔离。
ok = 0
for name, fn in RESULTS:
try:

View File

@ -4,6 +4,7 @@
B 择时 timing_rules: 昨夜定性映射、盘中转多升级、超期无读数。
C 合议 consensus: 多数决方向、平局中性、强弱、附录丙路由表逐条。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -11,6 +11,7 @@
E 候选按质地排序 (质地看多分桶在前、技术面不参与、观察档进池开关、开关关掉回旧)。
F 三个新参数登记。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback
@ -374,6 +375,8 @@ def _():
def main():
from test_wiring import install_fakes
install_fakes() # 参数与仓储默认用内存,各用例再覆盖需要的依赖。
ok = 0
for name, fn in RESULTS:
try:

View File

@ -7,6 +7,7 @@
recon 回放认领/外部成交/对账差异/除权/T+1可用量。
约定同 test_core_units.py: 全过输出 "ALL PASS (n cases)" 退出码 0, 任一失败退出码 1。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -9,6 +9,7 @@
D 策略票只看目标价 (按函数判, 不按动作名) —— 技术面转空对策略票不评。
E 参数登记。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback
@ -432,6 +433,8 @@ def _():
def main():
from test_wiring import install_fakes
install_fakes() # 参数与仓储默认用内存,各用例再覆盖需要的依赖。
ok = 0
for name, fn in RESULTS:
try:

View File

@ -10,6 +10,7 @@
D 仓库 upsert: 更新子句只用 VALUES 列不带绑定参数 (pymysql 批量陷阱), SQL 单表合规。
E 参数登记与检查点窗口。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import json
import os
import sys

View File

@ -9,6 +9,7 @@
开口未确认路由改观察带 wait_confirm; 总闸关掉 _intraday_cfg 为 None (逐字回旧)。
E 参数登记与范围。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -6,6 +6,7 @@
A 研究面来源解析 _research_st: 持仓票 / 候选票 / 都没有 三情形。
B 研究面接口 api_research: 六块齐全且来源逐路标明; 无读数票 company 为 None、来源标不在持仓也不在候选。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -11,6 +11,7 @@
端到端的自动清仓/自动采纳落表见 test_wiring.py (目标价那两例)。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
@ -316,6 +317,8 @@ def _():
def main():
from test_wiring import install_fakes
install_fakes() # 参数与仓储默认用内存,各用例再覆盖需要的依赖。
ok = 0
for name, fn in RESULTS:
try:

View File

@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
"""第三十五批:单测隔离与观察读数内存仓储,全部离线。"""
import unit_test_guard # noqa: F401
from datetime import datetime
import inspect
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import traceback
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.repo import consensus_stat_repo as csr
from app.services import consensus_stats as stats, proposal_service
from test_wiring import install_fakes, _target_fakes
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
@case("仓储四个入口均替换,签名与真实函数一致")
def _():
originals = {name: getattr(csr, name) for name in
("upsert_many", "delete_day_kind", "list_range", "count_by_kind")}
fake = install_fakes()
for name, original in originals.items():
bound = getattr(csr, name)
assert bound.__self__ is fake.consensus_stats
def shape(fn):
return [(p.name, p.kind, p.default) for p in inspect.signature(fn).parameters.values()]
assert shape(original) == shape(bound), name
@case("重复读数累加轮次且保留首次时间,读取与日报都用内存")
def _():
fake = install_fakes()
out = {"self_decide_seen": [{"ts_code": "600000.SH", "decision": "accept",
"why": "target_price", "action": "EXIT"}]}
first, last = datetime(2026, 9, 18, 9, 31), datetime(2026, 9, 18, 9, 32)
for now in (first, last):
assert stats.record_round(out, {}, {}, now)["ok"]
rows = csr.list_range(20260918, 20260918, ["auto_accept"])
assert len(rows) == 1 and rows[0]["rounds"] == 2, rows
assert rows[0]["first_at"] == first and rows[0]["last_at"] == last, rows
assert stats.daily_summary(20260918)["auto_accept"] == {"rows": 1, "rounds": 2}
assert len(fake.consensus_stats.rows) == 1
@case("整轮目标价扫描的自决读数落入内存,试算不写")
def _():
fake, restore = _target_fakes(13.0, 12.5, params={"PMS_AUTONOMY": "full",
"PMS_SELF_DECIDE": "full"})
try:
proposal_service.scan_and_route(now=datetime(2026, 9, 18, 10, 30), dry_run=True)
assert not fake.consensus_stats.rows
result = proposal_service.scan_and_route(now=datetime(2026, 9, 18, 10, 30))
rows = csr.list_range(20260918, 20260918, ["auto_accept"])
assert result["ok"] and len(rows) == 1, (result, rows)
assert rows[0]["ts_code"] == "600000.SH"
finally:
restore()
@case("重建覆盖先清当日无读数,保留其他日期与类别")
def _():
install_fakes()
for day in (17, 18):
assert stats.record_map_cover(relevant_codes=["600000.SH", "000001.SZ"],
state_codes=[], now=datetime(2026, 9, day, 6, 30))["ok"]
assert stats.record_map_cover(relevant_codes=["600000.SH", "000001.SZ"],
state_codes=["600000.SH"], now=datetime(2026, 9, 18, 8, 40))["ok"]
today = csr.list_range(20260918, 20260918, ["tech_noread"])
assert [r["ts_code"] for r in today] == ["000001.SZ"], today
assert len(csr.list_range(20260917, 20260917, ["tech_noread"])) == 2
assert stats.daily_summary(20260918)["map_cover"] == {"rows": 1, "rounds": 2}
@case("重新装桩清空读数,不把前一用例的数据带入下一例")
def _():
first = install_fakes()
csr.upsert_many([{"stat_date": 20260918, "ts_code": "600000.SH", "kind": "auto_accept"}])
second = install_fakes()
assert first.consensus_stats.rows and not second.consensus_stats.rows
assert csr.list_range(20260918, 20260918) == []
def _child(source, env=None, dotenv=None):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "probe.py"
path.write_text("import sys\nsys.path.insert(0, %r)\n" % str(ROOT) + source)
if dotenv:
(Path(tmp) / ".env").write_text(dotenv)
return subprocess.run([sys.executable, str(ROOT / "scripts/unit_test_guard.py"), str(path)],
cwd=tmp, env={**os.environ, **(env or {})}, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
@case("联网异常被吞掉也必须失败,阻断发生在建立连接之前")
def _():
r = _child("import socket\ntry:\n socket.socket().connect(('127.0.0.1', 1))\n"
"except Exception:\n pass\nprint('swallowed')\n")
assert "swallowed" in r.stdout and r.returncode == 1, r
assert "TEST ISOLATION FAILED" in r.stderr and "socket.connect" in r.stderr, r.stderr
@case("测试入口不继承生产参数,也不读启动目录的 .env")
def _():
r = _child("from config.settings import settings\n"
"assert settings.PMS_TOTAL_SCALE == 2000000\nprint('clean')\n",
env={"PMS_TOTAL_SCALE": "987654"}, dotenv="PMS_TOTAL_SCALE=123456\n")
assert r.returncode == 0 and "clean" in r.stdout, (r.stdout, r.stderr)
@case("所有业务测试独立运行时也安装联网护栏")
def _():
import ast
import run_tests
for name in run_tests.SUITES:
tree = ast.parse((ROOT / "scripts" / name).read_text())
imports = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom))]
assert isinstance(imports[0], ast.Import) and imports[0].names[0].name == "unit_test_guard", name
if __name__ == "__main__":
ok = 0
for name, fn in RESULTS:
try:
fn()
ok += 1
print(" ok " + name)
except Exception:
print(" FAIL " + name)
traceback.print_exc()
print("ALL PASS (%d cases)" % ok if ok == len(RESULTS) else "FAILED %d/%d" % (len(RESULTS)-ok, len(RESULTS)))
sys.exit(0 if ok == len(RESULTS) else 1)

View File

@ -0,0 +1,340 @@
# -*- coding: utf-8 -*-
"""任务命令结算按方案额 (2026-10-08)。后端离线单测, 不连库。
由来: 建仓 / 升仓 / 个股减仓这类任务命令, 方案全部成交也到不了 DONE。命令目标
(progress.target_amount) 是「规模 × 比例 − 现有市值」的理论金额, 没取整到手; 方案是整手的,
方案额几乎总比目标少一截零头。结算拿理论目标当完成线, 一股不差地成交完也够不着 ——
命令一直挂在执行中、窗口末转部分完成, 同股重下被判重复、暂停买入与一键清仓报冲突。
开发机实算: 现价 10.37 建仓 6% × 100 万, 目标 60,000、方案 59,109, 全部成交仍差 891 元。
修法: 结算线 = min(目标, 方案额) − 未解锁 GATED 批金额 (command_spec.effective_target)。
这里测三块:
A effective_target 纯逻辑: 取整零头、方案额高于目标、GATED 扣减与边界。
B 方案生成接结算: plan_open_target / plan_reduce_stock 产出的方案全部成交即判 DONE。
C 服务层 refresh_progress (内存桩): 建仓端到端与同股重下、HALT 行不进方案额、
方案没凑够目标的口径、没成交完的照旧不放过。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core import command_spec as cs # noqa: E402
from app.core import planner as pl # noqa: E402
RESULTS = []
PX = 10.37 # 开发机实算用的现价
SCALE = 1_000_000.0
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
def _ctx(**kw):
"""plan_open_target 的组合上下文: 空仓、上限宽松, 只让取整这一件事起作用。"""
c = {"scale": SCALE, "portfolio_cap": 0.60, "stock_cap": 0.08, "max_names": 15,
"portfolio_mv": 0.0, "names_count": 0, "stock_mv": 0.0, "is_new_name": True,
"sector": None}
c.update(kw)
return c
def _settle(target, items, filled_ratio=1.0, window_over=False):
"""把方案条目当作非 GATED 批按比例成交, 走一遍与 refresh_progress 相同的结算口径。"""
planned = sum(i["amount"] for i in items if i["action"] != pl.A_HALT)
gated = sum(i["amount"] for i in items if i.get("gated"))
done = sum(i["amount"] * filled_ratio for i in items
if not i.get("gated") and i["action"] != pl.A_HALT)
return cs.settle_task_status(cs.effective_target(target, planned, gated), done, window_over)
# ================================================================ A 纯逻辑
@case("A1 一手取整零头: 目标 60,000、方案 59,109 全部成交 → 判 DONE (旧口径永远够不着)")
def _():
eff = cs.effective_target(60_000.0, 59_109.0, 0.0)
assert abs(eff - 59_109.0) < 1e-9, eff
assert cs.settle_task_status(eff, 59_109.0, False) == cs.ST_DONE
# 钉住由来: 旧口径拿理论目标当线, 全部成交仍差 891 元, 窗口内执行中、窗口后部分完成
assert cs.settle_task_status(60_000.0, 59_109.0, False) == cs.ST_EXECUTING
assert cs.settle_task_status(60_000.0, 59_109.0, True) == cs.ST_PARTIAL
@case("A2 方案额高于目标 (清弱票整票卖、升仓取整微超): 结算线仍是目标, 没够目标不判 DONE")
def _():
eff = cs.effective_target(200_000.0, 201_500.0, 0.0)
assert eff == 200_000.0, eff
assert cs.settle_task_status(eff, 150_000.0, False) == cs.ST_EXECUTING
assert cs.settle_task_status(eff, 200_000.0, False) == cs.ST_DONE
@case("A3 GATED 扣减口径不变; 结算线不为负; 空值按 0")
def _():
# 三批建仓: 方案 58,072 里 FILL/ADD 两批 29,036 是 GATED → 结算线 = 首批 29,036
assert abs(cs.effective_target(60_000.0, 58_072.0, 29_036.0) - 29_036.0) < 1e-9
assert cs.effective_target(10_000.0, 5_000.0, 8_000.0) == 0.0
assert cs.effective_target(None, None) == 0.0
assert cs.effective_target(50_000.0, None) == 50_000.0
# ================================================================ B 方案生成接结算
@case("B1 建仓 10.37 元 × 6%: 单批与 50/25/25 两种拆法, 首批全部成交都判 DONE; 没成交完不放过")
def _():
one = pl.plan_open_target(ts_code="600000.SH", target_pct=0.06, price=PX, ctx=_ctx(),
params={"batch_split": (1.0,)})
assert one["target_amount"] == 60_000.0 and one["planned_amount"] == 59_109.0, one
assert len(one["items"]) == 1 and one["items"][0]["qty"] == 5_700, one["items"]
three = pl.plan_open_target(ts_code="600000.SH", target_pct=0.06, price=PX, ctx=_ctx(),
params={"batch_split": (0.5, 0.25, 0.25)})
assert [i["qty"] for i in three["items"]] == [2_800, 1_400, 1_400], three["items"]
assert [bool(i.get("gated")) for i in three["items"]] == [False, True, True]
for r in (one, three):
assert _settle(r["target_amount"], r["items"]) == cs.ST_DONE, r
assert _settle(r["target_amount"], r["items"], filled_ratio=0.9) == cs.ST_EXECUTING
assert _settle(r["target_amount"], r["items"], filled_ratio=0.9,
window_over=True) == cs.ST_PARTIAL
@case("B2 个股减至 5%: 持 10,000 股 @10.37, 超出 53,700 → 方案 5,100 股 52,887, 全部成交判 DONE")
def _():
r = pl.plan_reduce_stock(ts_code="600000.SH", target_pct=0.05,
position={"total_qty": 10_000, "price": PX}, scale=SCALE)
assert abs(r["target_amount"] - 53_700.0) < 1e-6, r
assert r["items"][0]["qty"] == 5_100 and abs(r["planned_amount"] - 52_887.0) < 1e-6, r
assert _settle(r["target_amount"], r["items"]) == cs.ST_DONE
assert _settle(r["target_amount"], r["items"], filled_ratio=0.98) == cs.ST_EXECUTING
# ================================================================ C 服务层
def _fill(fake, plans):
for p in plans:
fake.update_plan(p["plan_id"], status="DONE", filled_qty=p["qty"])
@case("C1 建仓端到端 (50/25/25): 首批成交完即 DONE、GATED 批照记; 结算前同股重下判重复, 结算后放行")
def _():
from test_wiring import install_fakes
from app.services import command_service as csvc
fake = install_fakes(prices={"600000.SH": PX}, params={"PMS_TOTAL_SCALE": str(int(SCALE))})
r = csvc.issue("OPEN_TARGET", {"ts_code": "600000.SH", "target_pct": "6%"})
assert r["ok"] and r["status"] == "EXECUTING", r
cid = r["command_id"]
plans = fake.list_plans(command_id=cid)
base = [p for p in plans if p["status"] == "PENDING"]
assert len(base) == 1 and base[0]["qty"] == 2_800, plans
csvc.refresh_progress(cid)
assert fake.commands[cid]["status"] == "EXECUTING", fake.commands[cid]
dup = csvc.issue("OPEN_TARGET", {"ts_code": "600000.SH", "target_pct": "6%"})
assert not dup["ok"] and any("重复下达" in c["reason"] for c in dup["conflicts"]), dup
_fill(fake, base)
csvc.refresh_progress(cid)
c = fake.commands[cid]
assert c["status"] == "DONE", c
# 页面照实显示: 目标仍是 60,000, 已成交 29,036, GATED 两批 29,036 单列
assert c["progress"]["target_amount"] == 60_000.0, c["progress"]
assert abs(c["progress"]["done_amount"] - 29_036.0) < 1e-6, c["progress"]
assert abs(c["progress"]["gated_amount"] - 29_036.0) < 1e-6, c["progress"]
assert [p["status"] for p in fake.list_plans(command_id=cid)] == ["DONE", "CANCELLED", "CANCELLED"]
again = csvc.issue("OPEN_TARGET", {"ts_code": "600000.SH", "target_pct": "6%"})
assert again["ok"] and not again.get("conflicts"), again
@case("C2 建仓单批 (开发机实算口径): 方案 59,109 全部成交 → DONE, 进度照记目标 60,000")
def _():
from test_wiring import install_fakes
from app.services import command_service as csvc
fake = install_fakes(prices={"600000.SH": PX},
params={"PMS_TOTAL_SCALE": str(int(SCALE)), "PMS_BATCH_SPLIT": "1.0"})
r = csvc.issue("OPEN_TARGET", {"ts_code": "600000.SH", "target_pct": "6%"})
assert r["ok"], r
cid = r["command_id"]
plans = fake.list_plans(command_id=cid)
assert len(plans) == 1 and plans[0]["qty"] == 5_700, plans
_fill(fake, plans)
out = csvc.refresh_progress(cid)
assert out["commands"][0]["status"] == "DONE", out
prog = fake.commands[cid]["progress"]
assert prog["target_amount"] == 60_000.0 and abs(prog["done_amount"] - 59_109.0) < 1e-6, prog
@case("C3 撤单 HALT 行不进方案额: 降仓顺手撤的在途买单金额再大, 卖出方案成交完即 DONE")
def _():
from test_wiring import install_fakes
from app.services import command_service as csvc
fake = install_fakes(params={"PMS_TOTAL_SCALE": str(int(SCALE))})
fake.insert_command(command_id="CMD_H1", cmd_class=cs.CLS_TASK, cmd_type="REDUCE_EXPOSURE",
ts_code=None, params={}, status="EXECUTING",
progress={"target_amount": 50_000.0, "planned_amount": 49_800.0,
"deadline": "2099-01-01"})
fake.insert_plans([
{"plan_id": "PL_H1", "command_id": "CMD_H1", "ts_code": "000001.SZ", "action": "HALT",
"qty": 8_000, "amount": 80_000.0, "status": "DONE", "filled_qty": 0,
"priority": 10, "deadline": "2099-01-01"},
{"plan_id": "PL_H2", "command_id": "CMD_H1", "ts_code": "600000.SH", "action": "TRIM",
"qty": 4_800, "amount": 49_800.0, "status": "DONE", "filled_qty": 4_800,
"priority": 30, "deadline": "2099-01-01"},
])
csvc.refresh_progress("CMD_H1")
# HALT 行若算进方案额: min(50,000, 129,800) = 50,000 > 49,800, 又卡住
assert fake.commands["CMD_H1"]["status"] == "DONE", fake.commands["CMD_H1"]
@case("C4 方案本身没凑够目标 (升仓候选不够): 方案全部成交判 DONE, 缺口原样留在进度里")
def _():
from test_wiring import install_fakes
from app.services import command_service as csvc
fake = install_fakes(params={"PMS_TOTAL_SCALE": str(int(SCALE))})
fake.insert_command(command_id="CMD_S1", cmd_class=cs.CLS_TASK,
cmd_type="INCREASE_EXPOSURE", ts_code=None, params={},
status="EXECUTING",
progress={"target_amount": 200_000.0, "planned_amount": 120_000.0,
"gap": 80_000.0, "deadline": "2099-01-01"})
fake.insert_plans([
{"plan_id": "PL_S1", "command_id": "CMD_S1", "ts_code": "600000.SH", "action": "OPEN",
"qty": 12_000, "amount": 120_000.0, "status": "DONE", "filled_qty": 12_000,
"priority": 10, "deadline": "2099-01-01"},
])
csvc.refresh_progress("CMD_S1")
c = fake.commands["CMD_S1"]
assert c["status"] == "DONE", c
assert c["progress"]["gap"] == 80_000.0 and c["progress"]["target_amount"] == 200_000.0, c
# 降仓可卖不够那一侧的方案注记同步改口: 不再承诺「置部分完成」
r = pl.plan_reduce_exposure(release_amount=10_000_000, positions=[
{"ts_code": "600000.SH", "price": 10.0, "total_qty": 1_000, "base_qty": 1_000,
"cushion_pct": 0.05, "neg_cushion_days": 0}])
gap_note = [n for n in r["notes"] if "可减持仓不足" in n]
assert gap_note and "部分完成" not in gap_note[0] and "缺口" in gap_note[0], r["notes"]
@case("C5 没成交完的照旧不放过: 成交一半窗口内 EXECUTING、窗口过后 PARTIAL; 有未出清方案不判 DONE")
def _():
from test_wiring import install_fakes
from app.services import command_service as csvc
fake = install_fakes(params={"PMS_TOTAL_SCALE": str(int(SCALE))})
for cid, dl in (("CMD_P1", "2099-01-01"), ("CMD_P2", "2000-01-01")):
fake.insert_command(command_id=cid, cmd_class=cs.CLS_TASK, cmd_type="OPEN_TARGET",
ts_code="600000.SH", params={}, status="EXECUTING",
progress={"target_amount": 60_000.0, "deadline": dl})
fake.insert_plans([
{"plan_id": f"PL_{cid}", "command_id": cid, "ts_code": "600000.SH",
"action": "OPEN", "qty": 5_700, "amount": 59_109.0, "status": "EXECUTING",
"filled_qty": 2_800, "priority": 10, "deadline": dl}])
csvc.refresh_progress("CMD_P1")
csvc.refresh_progress("CMD_P2")
assert fake.commands["CMD_P1"]["status"] == "EXECUTING", fake.commands["CMD_P1"]
assert fake.commands["CMD_P2"]["status"] == "PARTIAL", fake.commands["CMD_P2"]
# 金额都到了但还有一条 0 金额方案没出清 (取不到现价的整票卖单) → 不判 DONE
fake.insert_command(command_id="CMD_P3", cmd_class=cs.CLS_TASK, cmd_type="EXIT_STOCK",
ts_code="600000.SH", params={}, status="EXECUTING",
progress={"target_amount": 30_000.0, "deadline": "2099-01-01"})
fake.insert_plans([
{"plan_id": "PL_P3a", "command_id": "CMD_P3", "ts_code": "600000.SH", "action": "EXIT",
"qty": 3_000, "amount": 30_000.0, "status": "DONE", "filled_qty": 3_000,
"priority": 20, "deadline": "2099-01-01"},
{"plan_id": "PL_P3b", "command_id": "CMD_P3", "ts_code": "000001.SZ", "action": "EXIT",
"qty": 1_000, "amount": 0.0, "status": "PENDING", "filled_qty": 0,
"priority": 20, "deadline": "2099-01-01"}])
csvc.refresh_progress("CMD_P3")
assert fake.commands["CMD_P3"]["status"] == "EXECUTING", fake.commands["CMD_P3"]
@case("C6 非零目标但方案为空不判完成")
def _():
from test_wiring import install_fakes
from app.services import command_service as svc
fake = install_fakes()
fake.insert_command(command_id="EMPTY", cmd_class=cs.CLS_TASK, cmd_type="OPEN_TARGET",
ts_code="600000.SH", params={}, status="EXECUTING",
progress={"target_amount": 60_000, "deadline": "2099-01-01"})
svc.refresh_progress("EMPTY")
assert fake.commands["EMPTY"]["status"] == "EXECUTING"
@case("C7 完成清理失败不置 DONE,取消成功后进程失败仍可重试")
def _():
from test_wiring import install_fakes
from app.repo import pms_repo
from app.services import command_service as svc
for stage in ("save", "cancel", "final"):
fake = install_fakes(prices={"600000.SH": PX}, params={"PMS_TOTAL_SCALE": str(int(SCALE))})
cmd = svc.issue("OPEN_TARGET", {"ts_code": "600000.SH", "target_pct": "6%"})
cid = cmd["command_id"]
_fill(fake, fake.list_plans(command_id=cid, statuses=["PENDING"]))
update, cancel = pms_repo.update_command, pms_repo.cancel_gated_plans_of_command
def broken_update(cid, **kw):
if (stage == "save" and kw.get("status") is None) or (stage == "final" and kw.get("status") == "DONE"):
return 0
return update(cid, **kw)
try:
pms_repo.update_command = broken_update
if stage == "cancel":
pms_repo.cancel_gated_plans_of_command = lambda cid: 0
try:
svc.refresh_progress(cid)
raise AssertionError("结算写入失败必须报错")
except RuntimeError:
pass
assert fake.commands[cid]["status"] == "EXECUTING"
if stage in ("save", "cancel"):
assert len(fake.list_plans(command_id=cid, statuses=["GATED"])) == 2
finally:
pms_repo.update_command, pms_repo.cancel_gated_plans_of_command = update, cancel
svc.refresh_progress(cid)
assert fake.commands[cid]["status"] == "DONE"
assert not fake.list_plans(command_id=cid, statuses=["GATED"])
assert fake.commands[cid]["progress"]["settlement_gated_amount"] == 29_036.0
@case("C8 只取消本命令 GATED,不改在途与其他命令;真实 SQL 限定同一范围")
def _():
import importlib.util
from pathlib import Path
from test_wiring import install_fakes
fake = install_fakes()
for i, (cid, status) in enumerate([("A", "GATED"), ("A", "EXECUTING"), ("A", "PENDING"), ("B", "GATED")]):
fake.insert_plans([{"plan_id": str(i), "command_id": cid, "status": status}])
assert fake.cancel_gated_plans_of_command("A") == 1
assert [p["status"] for p in fake.plans] == ["CANCELLED", "EXECUTING", "PENDING", "GATED"]
path = Path(__file__).resolve().parents[1] / "app/repo/pms_repo.py"
spec = importlib.util.spec_from_file_location("repo_sql_check", path)
repo = importlib.util.module_from_spec(spec)
spec.loader.exec_module(repo)
calls = []
repo.execute = lambda sql, params: calls.append((sql, params)) or 1
assert repo.cancel_gated_plans_of_command("A") == 1
sql, params = calls[0]
from app.db.session import assert_single_table
assert_single_table(sql)
assert "WHERE command_id = :cid AND status = 'GATED'" in sql and params["cid"] == "A"
def main():
ok = 0
for name, fn in RESULTS:
try:
fn()
ok += 1
print(" ok " + name)
except Exception:
import traceback
print(" FAIL " + name)
traceback.print_exc()
print("-" * 60)
if ok == len(RESULTS):
print("ALL PASS (%d cases)" % ok)
return 0
print("FAILED %d/%d" % (len(RESULTS) - ok, len(RESULTS)))
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,421 @@
# -*- coding: utf-8 -*-
"""
第三十七批模块单测 (一字板方向按昨收定, 2026-10-08, 零外部依赖)
================================================================
运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch37_units.py
病根: executor._limit_up / _limit_down 拿 day_chg_from_open 的正负定一字板方向。这个涨幅是
现价比窗口首根分钟线的开盘价, 一字板上两者相等, 涨幅恒为 0, 同时满足「>= 0 算涨停」与
「<= 0 算跌停」, 两个标记一起亮。于是一字跌停的买单以「涨停一字板」跳过, 一字涨停的卖单
在非末日被当跌停顺延, 规则闸还给卖单挂上跌停告警。这两个函数此前没有任何单测钉着。
本批覆盖:
A et.limit_board 纯函数: 一字涨停/一字跌停/正常 K 线/三个板块的限制/风险警示 5%/
低价股取整/除权除息日落在带外回退/昨收缺失与旧口径逐字相同/涨跌幅过小回退。
B 接线 (executor._limit_flags 加三处消费方): 一字跌停买入跳过且理由说对、规则闸拦
LIMIT_DOWN 不拦 LIMIT_UP; 一字涨停卖出非末日不再顺延、规则闸不挂跌停告警;
正常 K 线不查昨收; 昨收缺失或取数异常与修之前逐字相同; 网格即时成交两个方向。
C market.get_prev_close 新鲜度: 只认上一交易日那一根/隔夜没跑回 None/今天的行不算/
三种日期格式/取数异常不缓存/长假后第一天。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback
from datetime import date, datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core import exec_timing as et # noqa: E402
from app.core import rule_gate as rg # noqa: E402
from app.core import tradedays as td # noqa: E402
from app.services import exec_advisor, executor, market # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
PRM = {"sell_avoid_open_min": 30, "buy_halt_dayup": 0.05, "eod_force_time": "14:45",
"eod_force_discount": 0.998, "urgent_sell_discount": 0.995, "sell_bucket_times": ""}
def one_word(px, bars=30):
"""一字板的当日聚合: 最高 = 最低 = 开盘 = 现价, 于是 day_chg_from_open 恰为 0。"""
return {"price": px, "open": px, "high": px, "low": px, "vwap": px,
"day_chg_from_open": 0.0, "bars": bars}
def normal(px=10.3):
return {"price": px, "open": 10.0, "high": 10.5, "low": 9.9, "vwap": 10.2,
"day_chg_from_open": px / 10.0 - 1, "bars": 60}
def _old_up(day):
"""修之前 executor._limit_up 的原文, 拿来比「回退时逐字相同」。"""
if not day:
return False
hi, lo, px = day.get("high"), day.get("low"), day.get("price")
chg = day.get("day_chg_from_open")
return bool(hi and lo and px and hi == lo == px and (chg or 0) >= 0 and day.get("bars", 0) > 3)
def _old_down(day):
if not day:
return False
hi, lo, px = day.get("high"), day.get("low"), day.get("price")
chg = day.get("day_chg_from_open")
return bool(hi and lo and px and hi == lo == px and (chg or 0) <= 0 and day.get("bars", 0) > 3)
def _flags(b):
return (b["limit_up"], b["limit_down"], b["basis"])
# ================================================================ A 纯函数
@case("A 一字涨停 (主板 +10%) → 只亮涨停, 按昨收定")
def _():
assert _flags(et.limit_board(one_word(11.0), prev_close=10.0, ts_code="600000.SH")) \
== (True, False, "prev_close")
@case("A 一字跌停 (主板 -10%) → 只亮跌停, 按昨收定 (旧口径两个都亮, 这就是病根)")
def _():
d = one_word(9.0)
assert _old_up(d) and _old_down(d), "旧口径在一字板上两个都亮 —— 若这条不成立说明病根描述错了"
assert _flags(et.limit_board(d, prev_close=10.0, ts_code="600000.SH")) \
== (False, True, "prev_close")
@case("A 正常 K 线 (最高≠最低) → 两个都不亮, 给了昨收也一样")
def _():
for pc in (None, 10.0, 9.0):
assert _flags(et.limit_board(normal(), prev_close=pc, ts_code="600000.SH")) \
== (False, False, "none")
# 三根以内的分钟线不认一字板 (开盘头几分钟没成交时价格一条线, 不是封板)
assert _flags(et.limit_board(one_word(11.0, bars=3), prev_close=10.0)) == (False, False, "none")
assert _flags(et.limit_board({}, prev_close=10.0)) == (False, False, "none")
assert _flags(et.limit_board(None, prev_close=10.0)) == (False, False, "none")
@case("A 板块限制: 创业板/科创板 20%, 北交所 30%, 主板 10%")
def _():
assert et.board_limit_ratio("600000.SH") == 0.10
assert et.board_limit_ratio("000001.SZ") == 0.10
assert et.board_limit_ratio("300750.SZ") == 0.20
assert et.board_limit_ratio("301001.SZ") == 0.20
assert et.board_limit_ratio("688981.SH") == 0.20
assert et.board_limit_ratio("830799.BJ") == 0.30
assert _flags(et.limit_board(one_word(120.0), prev_close=100.0, ts_code="300750.SZ"))[:2] \
== (True, False)
assert _flags(et.limit_board(one_word(80.0), prev_close=100.0, ts_code="688981.SH"))[:2] \
== (False, True)
assert _flags(et.limit_board(one_word(13.0), prev_close=10.0, ts_code="830799.BJ"))[:2] \
== (True, False)
# 同样 +20% 放在主板上不可能是一字板, 落在带外 → 方向不明回退
assert _flags(et.limit_board(one_word(12.0), prev_close=10.0, ts_code="600000.SH"))[2] \
== "fallback"
@case("A 风险警示 5% 与低价股取整都认得出")
def _():
assert _flags(et.limit_board(one_word(10.5), prev_close=10.0, ts_code="600000.SH"))[:2] \
== (True, False)
assert _flags(et.limit_board(one_word(9.5), prev_close=10.0, ts_code="600000.SH"))[:2] \
== (False, True)
# 1.05 × 1.1 = 1.155, 交易所四舍五入到 1.16, 实际涨幅 +10.48%, 仍在上沿 11% 之内
assert _flags(et.limit_board(one_word(1.16), prev_close=1.05, ts_code="600000.SH"))[:2] \
== (True, False)
# 1.05 × 0.95 = 0.9975 → 1.00, 实际 -4.76%, 仍在下沿 4% 之外
assert _flags(et.limit_board(one_word(1.0), prev_close=1.05, ts_code="600000.SH"))[:2] \
== (False, True)
@case("A 除权除息日: 小额分红仍判得对, 大比例送转落在带外回退")
def _():
# 昨收 10, 每股派 0.3, 除息参考价 9.7, 涨停 10.67: 比未折算的昨收 +6.7%, 仍在带里 → 涨停
assert _flags(et.limit_board(one_word(10.67), prev_close=10.0, ts_code="600000.SH"))[:2] \
== (True, False)
# 10 送 3: 参考价 7.69, 涨停 8.46, 比未折算的昨收 -15.4%。主板跌停不可能跌这么多 → 回退,
# 而不是把一字涨停判成跌停
assert _flags(et.limit_board(one_word(8.46), prev_close=10.0, ts_code="600000.SH")) \
== (True, True, "fallback")
# 10 转 10: 涨停 5.5, -45%, 哪个板块都不可能 → 回退
assert _flags(et.limit_board(one_word(5.5), prev_close=10.0, ts_code="300750.SZ"))[2] \
== "fallback"
@case("A 昨收缺失/为零/认不出 → 回退, 两个标记与旧口径逐字相同")
def _():
days = [one_word(11.0), one_word(9.0), one_word(11.0, bars=3), normal(), {},
{**one_word(11.0), "day_chg_from_open": None},
{**one_word(11.0), "day_chg_from_open": 0.02},
{**one_word(11.0), "day_chg_from_open": -0.02},
{**one_word(11.0), "high": 0}]
for pc in (None, 0, "", "abc", -1):
for d in days:
b = et.limit_board(d, prev_close=pc, ts_code="600000.SH")
assert (b["limit_up"], b["limit_down"]) == (_old_up(d), _old_down(d)), (pc, d, b)
@case("A 涨跌幅过小 (停牌行情冻在昨收、或一条线但没封板) → 回退旧口径")
def _():
for px in (10.0, 10.1, 9.9, 10.39):
assert _flags(et.limit_board(one_word(px), prev_close=10.0, ts_code="600000.SH")) \
== (True, True, "fallback"), px
# ================================================================ B 接线
class _Stub:
"""临时替换 market.get_prev_close; 退出时还原。raise_on_call 用来证明「不该查」。"""
def __init__(self, value=None, exc=None, raise_on_call=False):
self.value, self.exc, self.raise_on_call, self.calls = value, exc, raise_on_call, []
def __call__(self, code, *a, **k):
self.calls.append(code)
if self.raise_on_call:
raise AssertionError(f"不该为 {code} 取昨收")
if self.exc:
raise self.exc
return self.value
def __enter__(self):
self._orig = market.get_prev_close
market.get_prev_close = self
return self
def __exit__(self, *a):
market.get_prev_close = self._orig
def _ctx_day(code, day):
up, down = executor._limit_flags(code, day)
return {**day, "limit_up": up, "limit_down": down, "halted": False}
def _gate(side, day_ctx, qty=1000):
ctx = {"ts_code": "600000.SH",
"position": {"total_qty": 6000, "avail_qty": 6000, "frozen_reason": "NONE"},
"day": {**day_ctx, "ma5": day_ctx["price"]},
"params": {"no_chase_ma5": 0.06, "buy_halt_dayup": 0.05},
"flags": {"buy_halt": False, "exec_halt": False, "brake_active": False,
"blacklisted": False, "is_command": True},
"caps": dict(scale=2_000_000, portfolio_cap=0.60, stock_cap=0.08, max_names=15,
portfolio_mv=800_000, names_count=5, stock_mv=60_000, is_new_name=False,
sector=None, sector_names=0, sector_mv=0.0, sector_max_names=4,
sector_max_ratio=0.40, cash_reserve=0.0, sector_source_ready=True)}
return rg.check(side=side, action="ADD" if side == "buy" else "TRIM", qty=qty,
price=day_ctx["price"], ctx=ctx)
def _codes(lst):
return {x.split(":")[0] for x in lst}
@case("B 一字跌停·买入: 跳过且理由是跌停不是涨停; 规则闸拦 LIMIT_DOWN 不拦 LIMIT_UP")
def _():
with _Stub(value=10.0) as s:
dc = _ctx_day("600000.SH", one_word(9.0))
assert s.calls == ["600000.SH"]
assert (dc["limit_up"], dc["limit_down"]) == (False, True)
r = et.decide(side="buy", now="10:30", day=dc, params=PRM, is_last_day=False, quota=1000)
assert r["action"] == et.ACT_SKIP and "跌停一字板" in r["reason"] \
and "涨停" not in r["reason"], r
# 末日 14:50 也不接 (修之前同样是跳过, 只是理由错)
r = et.decide(side="buy", now="14:50", day=dc, params=PRM, is_last_day=True, quota=1000)
assert r["action"] == et.ACT_SKIP and "跌停一字板" in r["reason"], r
kw = dict(side="buy", now="10:30", day=dc, params=PRM, is_last_day=False,
is_command=True, quota=1000)
assert et.hard_gate(**kw) == et.decide(**kw) # 硬闸与 decide 不拆漂
g = _gate("buy", dc)
assert not g["passed"] and "LIMIT_DOWN" in _codes(g["failed"]) \
and "LIMIT_UP" not in _codes(g["failed"]), g
@case("B 一字涨停·卖出: 非末日不再当跌停顺延, 规则闸不挂跌停告警; 买入照旧跳过")
def _():
with _Stub(value=10.0):
dc = _ctx_day("600000.SH", one_word(11.0))
assert (dc["limit_up"], dc["limit_down"]) == (True, False)
r = et.decide(side="sell", now="10:30", day=dc, params=PRM, is_last_day=False, quota=1000)
assert r["action"] == et.ACT_FIRE, r # 现价 = 均价, 过了开盘半小时, 出手
r = et.decide(side="sell", now="10:30", day=dc, params=PRM, is_last_day=False, quota=1000,
urgent=True)
assert r["action"] == et.ACT_FIRE and "紧急" in r["reason"], r
g = _gate("sell", dc)
assert g["passed"] and "LIMIT_DOWN" not in _codes(g["warnings"]), g
r = et.decide(side="buy", now="10:30", day=dc, params=PRM, is_last_day=False, quota=1000)
assert r["action"] == et.ACT_SKIP and "涨停一字板" in r["reason"], r
assert "LIMIT_UP" in _codes(_gate("buy", dc)["failed"])
@case("B 正常 K 线: 两个标记都不亮, 而且根本不去取昨收 (平常这一跳不多查库)")
def _():
with _Stub(raise_on_call=True) as s:
assert executor._limit_flags("600000.SH", normal()) == (False, False)
assert executor._limit_flags("600000.SH", {}) == (False, False)
assert s.calls == []
@case("B 昨收缺失或取数异常: 两个都亮, 买卖的结局与修之前逐字相同")
def _():
for stub in (_Stub(value=None), _Stub(exc=RuntimeError("库抖"))):
with stub:
dc = _ctx_day("600000.SH", one_word(9.0))
assert (dc["limit_up"], dc["limit_down"]) == (True, True)
old = {**one_word(9.0), "limit_up": True, "limit_down": True, "halted": False}
for side in ("buy", "sell"):
kw = dict(side=side, now="10:30", params=PRM, is_last_day=False, quota=1000)
assert et.decide(day=dc, **kw) == et.decide(day=old, **kw)
r = et.decide(side="buy", now="10:30", day=dc, params=PRM, is_last_day=False, quota=1000)
assert r["action"] == et.ACT_SKIP and "涨停一字板" in r["reason"], r # 旧理由原样
g = _gate("buy", dc)
assert _codes(g["failed"]) & {"LIMIT_UP", "LIMIT_DOWN"} == {"LIMIT_UP"}, g # 只记一条
@case("B 网格即时成交: 一字跌停不接买, 一字涨停可卖")
def _():
with _Stub(value=10.0):
down = _ctx_day("600000.SH", one_word(9.0))
up = _ctx_day("600000.SH", one_word(11.0))
now = datetime(2026, 9, 16, 10, 30)
r = exec_advisor._strategy_immediate(side="buy", now=now, day=down, quota=1000, fired_today=0)
assert r["action"] == et.ACT_WAIT and "跌停" in r["reason"], r
r = exec_advisor._strategy_immediate(side="sell", now=now, day=up, quota=1000, fired_today=0)
assert r["action"] == et.ACT_FIRE, r
# ================================================================ C 昨收新鲜度
class _Rows:
def __init__(self, rows=None, exc=None):
self.rows, self.exc, self.calls = rows or [], exc, 0
def __call__(self, ts_code, days=70):
self.calls += 1
if self.exc:
raise self.exc
return list(self.rows)
def _with_rows(fake, fn):
orig = market._factor_rows
market._factor_rows = fake
market._ma_cache.update({"day": None, "data": {}})
try:
return fn()
finally:
market._factor_rows = orig
market._ma_cache.update({"day": None, "data": {}})
TODAY = date(2026, 9, 16) # 周三, 前后都不是节假日, 不受日历库有没有当年数据影响
PREV = date(2026, 9, 15)
@case("C 最近一根正是上一交易日 → 返回它的收盘")
def _():
f = _Rows([{"trade_date": date(2026, 9, 14), "close_qfq": 9.8},
{"trade_date": PREV, "close_qfq": 10.0}])
assert _with_rows(f, lambda: market.get_prev_close("600000.SH", today=TODAY)) == 10.0
@case("C 因子桥隔夜没跑 (最近一根是前天) → None, 不拿隔天的收盘判方向")
def _():
f = _Rows([{"trade_date": date(2026, 9, 11), "close_qfq": 9.6},
{"trade_date": date(2026, 9, 14), "close_qfq": 9.8}])
assert _with_rows(f, lambda: market.get_prev_close("600000.SH", today=TODAY)) is None
@case("C 表里已有今天的行 → 不算, 仍取上一交易日那根")
def _():
f = _Rows([{"trade_date": PREV, "close_qfq": 10.0},
{"trade_date": TODAY, "close_qfq": 11.0}])
assert _with_rows(f, lambda: market.get_prev_close("600000.SH", today=TODAY)) == 10.0
@case("C 日期列几种写法都认: date / datetime / 横线串 / 八位串")
def _():
for td_val in (PREV, "2026-09-15", "20260915", datetime(2026, 9, 15, 0, 0)):
f = _Rows([{"trade_date": td_val, "close_qfq": 10.0}])
assert _with_rows(f, lambda: market.get_prev_close("600000.SH", today=TODAY)) == 10.0, td_val
@case("C 没有行 → None; 取数异常 → None 且不缓存, 下一次重查")
def _():
assert _with_rows(_Rows([]), lambda: market.get_prev_close("600000.SH", today=TODAY)) is None
f = _Rows(exc=RuntimeError("代理库抖"))
def run():
a = market.get_prev_close("600000.SH", today=TODAY)
f.exc, f.rows = None, [{"trade_date": PREV, "close_qfq": 10.0}]
b = market.get_prev_close("600000.SH", today=TODAY)
c = market.get_prev_close("600000.SH", today=TODAY) # 这次走缓存
return a, b, c
assert _with_rows(f, run) == (None, 10.0, 10.0)
assert f.calls == 2, f.calls
@case("C 长假后第一天 (10-08 接 09-30): 日历有当年数据时认得出")
def _():
today = date(2026, 10, 8)
f = _Rows([{"trade_date": date(2026, 9, 30), "close_qfq": 10.0}])
got = _with_rows(f, lambda: market.get_prev_close("600000.SH", today=today))
if td.calendar_degraded(today):
# 日历退化成周一到周五, 上一交易日算成 10-07, 对不上 → None, 一字板回退旧口径 (守成)
assert got is None, got
print(" (本机日历缺 2026 年数据, 走的是退化分支)")
else:
assert td.prev_trade_day(today) == date(2026, 9, 30)
assert got == 10.0, got
@case("C 暂无昨收或日期陈旧不缓存,数据恢复后同日可重新识别")
def _():
for rows in ([], [{"trade_date": date(2026, 9, 14), "close_qfq": 9.0}]):
f = _Rows(rows)
def run():
assert market.get_prev_close("600000.SH", today=TODAY) is None
f.rows = [{"trade_date": PREV, "close_qfq": 10.0}]
assert market.get_prev_close("600000.SH", today=TODAY) == 10.0
assert market.get_prev_close("600000.SH", today=TODAY) == 10.0
_with_rows(f, run)
assert f.calls == 2
@case("C 非正数与非有限昨收不缓存,不作为方向依据")
def _():
for value in (0, -10, float("nan"), float("inf")):
f = _Rows([{"trade_date": PREV, "close_qfq": value}])
def run():
assert market.get_prev_close("600000.SH", today=TODAY) is None
f.rows = [{"trade_date": PREV, "close_qfq": 10.0}]
assert market.get_prev_close("600000.SH", today=TODAY) == 10.0
_with_rows(f, run)
assert f.calls == 2
def main():
ok = 0
for name, fn in RESULTS:
try:
fn()
ok += 1
print(" ok " + name)
except Exception:
print(" FAIL " + name)
traceback.print_exc()
print("-" * 60)
if ok == len(RESULTS):
print("ALL PASS (%d cases)" % ok)
return 0
print("FAILED %d/%d" % (len(RESULTS) - ok, len(RESULTS)))
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -7,6 +7,7 @@
rule_gate 减持放行口径、增持全约束、命令与自主的刹车差别。
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -5,6 +5,7 @@
运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch4_units.py
覆盖: action_engine 四类自主动作的触发边界与数量口径 (FILL/ADD/DCA/TRIM) 及扫描剪枝。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -5,6 +5,7 @@
运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch5_units.py
覆盖: signal_rules 两条信号流的解析 (含两条流置信度尺度不同这个坑) 与消化口径。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import json
import os
import sys

View File

@ -17,6 +17,7 @@
H. dispatcher 纯助手: valid_until 归一、子单→父指令反推。
I. ws 逐笔成交 → 账本动作: 精确认领、批次类型、费用不进成本、金额自洽性。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import base64
import os
import sys

View File

@ -8,6 +8,7 @@
fixture 用的是 2026-07-30 上游给的真实应答样例 (截取前若干条, 结构一字未改),
坏数据用例另造 —— 「上游改了字段/漏了字段」这类事故必须由单测先炸, 而不是盘中静默少票。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -9,6 +9,7 @@
这一批的重点不是"能不能算出差异", 而是**不报假变化**: 榜单是按 top 截断过的, 尾部的进出
是截断噪音而不是上游观点变化。假警报天天响, 提示就等于没有 —— 与 `_capped` 治的是同一个病。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -12,6 +12,7 @@
所以用例的重点不是"算得对不对", 而是**该拦的拦不拦得住**, 以及**不该拦的会不会误伤** ——
误伤的代价是多等一天, 漏拦的代价是一本每个数都错、却和真账长得一模一样的账。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -6,6 +6,7 @@
约定: 全部断言通过输出 "ALL PASS (n cases)" 并退出码 0; 任一失败输出明细并退出码 1。
覆盖: sizer 批次拆分/一手合并/组合约束/风险披露; cushion 摊薄成本/垫子状态/保垫触发/核销次序/补仓档。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback

View File

@ -23,6 +23,7 @@
跑法: python3 scripts/test_page_copy_guard.py
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import re
import sys

View File

@ -29,6 +29,7 @@ app/web/static/index.html 是三千行的单文件页面,**没有任何单测*
跑法:python3 scripts/test_page_enum_guard.py
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import re
import sys

View File

@ -45,6 +45,7 @@ scripts/test_page_enum_guard.py 守的是「文案有没有翻译」,守不到
跑法:python3 scripts/test_page_wiring_guard.py
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import re
import sys

View File

@ -8,6 +8,7 @@
导入链、API 路由、调度表与守卫、单表访问守卫、交易日历、参数中心回退,
以及命令下达→方案落表→撤销、成交回放→批次入账→成本重算 两条主干流程。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import os
import sys
import traceback
@ -140,6 +141,12 @@ class FakeRepo:
return 1
return 0
def cancel_gated_plans_of_command(self, command_id: str) -> int:
rows = [p for p in self.plans if p["command_id"] == command_id and p["status"] == "GATED"]
for row in rows:
row["status"] = "CANCELLED"
return len(rows)
def cancel_plans_of_command(self, cid):
n = 0
for p in self.plans:
@ -323,6 +330,36 @@ class FakeRepo:
return {(r["ts_code"], r["action"]) for r in self.ledger
if r.get("verdict") == "REJECT" and r.get("arbiter") == "rule"}
def judge_rejected_today(self, since) -> set:
return {(r["ts_code"], r["action"]) for r in self.ledger
if r.get("verdict") == "REJECT" and r.get("arbiter") == "judge"}
def judge_rejected_open_rows(self, since) -> dict:
return {r["ts_code"]: {"at": str(r.get("decided_at") or since),
"price": r.get("price_at"), "hard": r.get("hard_numbers") or {}}
for r in self.ledger if r.get("verdict") == "REJECT"
and r.get("arbiter") == "judge" and r.get("action") == "OPEN"}
def buy_signals_today(self, since) -> dict:
out = {}
for row in self.ledger:
if row.get("action") == "SIGNAL_BUY" and row.get("verdict") == "NOTE":
code = row["ts_code"]
out[code] = {"reason": row.get("reason"), "at": str(row.get("decided_at") or since),
"price": max(float(row.get("price_at") or 0), out.get(code, {}).get("price", 0))}
return out
def reask_count_today(self, since) -> dict:
out = {}
for row in self.ledger:
if row.get("action") == "OPEN_REASK":
out[row["ts_code"]] = out.get(row["ts_code"], 0) + 1
return out
def sum_cash_flow(self, ymd, kind=None) -> float:
return sum(float(r.get("amount") or 0) for r in self.cash_flows
if (not kind or r.get("kind") == kind) and int(r.get("ymd") or 0) == int(ymd))
def upsert_report(self, ymd, report):
self.reports[int(ymd)] = report
return 1
@ -557,13 +594,57 @@ class FakeQmtRepo:
return None
class FakeConsensusStatRepo:
"""观察读数内存仓储。保留唯一键、轮次和首次时间,便于验证扫描落表行为。"""
def __init__(self):
self.rows = {}
def upsert_many(self, rows: list) -> int:
affected = 0
for row in rows or []:
if not all(row.get(k) for k in ("stat_date", "ts_code", "kind")):
continue
key = (int(row["stat_date"]), row["ts_code"], row["kind"])
if key in self.rows:
old = self.rows[key]
old.update({k: row.get(k) for k in ("reason", "detail", "last_at")})
old["rounds"] += 1
affected += 2
else:
self.rows[key] = {**row, "rounds": 1}
affected += 1
return affected
def list_range(self, date_from: int, date_to: int, kinds=None) -> list:
return [dict(r) for key, r in sorted(self.rows.items(), key=lambda x: (x[0][0], x[0][2], x[0][1]))
if int(date_from) <= key[0] <= int(date_to) and (not kinds or key[2] in kinds)]
def delete_day_kind(self, stat_date: int, kind: str) -> int:
keys = [k for k in self.rows if k[0] == int(stat_date) and k[2] == str(kind)]
for key in keys:
del self.rows[key]
return len(keys)
def count_by_kind(self, date_from: int, date_to: int) -> dict:
out = {}
for row in self.list_range(date_from, date_to):
agg = out.setdefault(int(row["stat_date"]), {}).setdefault(row["kind"], {"rows": 0, "rounds": 0})
agg["rows"] += 1
agg["rounds"] += row["rounds"]
return out
def install_fakes(prices=None, positions=None, params=None, high5=None, prev_close=None):
"""把内存桩装到各模块上, 返回 FakeRepo 实例 (ws 通道桩挂在 .qmt 上)。"""
from app.repo import downstream_repo, pms_repo, qmt_repo
from app.repo import consensus_stat_repo, downstream_repo, pms_repo, qmt_repo
from app.services import industry, market, param_store, portfolio
fake = FakeRepo()
fake.qmt = FakeQmtRepo()
fake.consensus_stats = FakeConsensusStatRepo()
for name in ("upsert_many", "delete_day_kind", "list_range", "count_by_kind"):
setattr(consensus_stat_repo, name, getattr(fake.consensus_stats, name))
for _n in ("get_state", "inbox_pending_count", "queue_depth", "enqueue_order",
"request_cancel", "get_order", "inbox_pending", "inbox_mark",
"inbox_orphan_count", "latest_snapshot"):
@ -595,6 +676,8 @@ def install_fakes(prices=None, positions=None, params=None, high5=None, prev_clo
else ({"price": (prev_close or {}).get(c), "source": "prev_close"}
if (prev_close or {}).get(c) else {"price": None, "source": "none"}))
market.get_high5 = lambda c: (high5 or prices or {}).get(c)
market.day_volume = lambda c: {"vol": None, "bars": 0, "frac": 0.0}
market.get_vol_base5 = lambda c: None
market.day_snapshot = lambda c: ({} if not (prices or {}).get(c) else {
"price": prices[c], "vwap": prices[c], "open": prices[c], "high": prices[c] * 1.02,
"low": prices[c] * 0.98, "day_chg_from_open": 0.0, "bars": 60})
@ -608,6 +691,7 @@ def install_fakes(prices=None, positions=None, params=None, high5=None, prev_clo
downstream_repo.fetch_positions = lambda: {"rows": [], "columns": {"qty": None},
"raw_count": 0}
downstream_repo.fetch_refs = lambda c: None
downstream_repo.fetch_nightly_verdicts = lambda codes: {}
downstream_repo.fetch_names = lambda codes: {
c: {"name": "测" + str(c)[:6], "full": ""} for c in (codes or [])}
downstream_repo.category_columns = lambda force=False: {

View File

@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""单测进程的联网护栏。导入即安装,联网失败即使被捕获,进程也必须失败。
完整隔离入口:python scripts/unit_test_guard.py scripts/test_batch3_units.py。
入口在空临时目录运行测试,只继承系统环境,不读取仓库 .env 或生产参数。
仅供测试使用,生产模块不得导入。
"""
import atexit
import os
from pathlib import Path
import runpy
import sys
import tempfile
import traceback
_violations = []
_ROOT = str(Path(__file__).resolve().parents[1]) + os.sep
def _audit(event, args):
if event in {"socket.connect", "socket.getaddrinfo", "socket.sendto", "socket.sendmsg"}:
# 不输出连接地址或凭据,只保留调用位置。
_violations.append((event, traceback.extract_stack()[:-1]))
raise RuntimeError("单测禁止联网: " + event)
def _finish():
if _violations:
print("\nTEST ISOLATION FAILED: 检测到未替换的外部调用", file=sys.stderr)
try:
for event, stack in _violations[:5]:
print(event, file=sys.stderr)
local = [frame for frame in stack if frame.filename.startswith(_ROOT)]
traceback.print_list(local[-12:] or stack[-12:], file=sys.stderr)
sys.stdout.flush()
sys.stderr.flush()
finally:
os._exit(1) # atexit 里抛异常不会改变进程退出码。
def run_suite(path):
path = Path(path).resolve()
sys.path.insert(0, str(path.parent))
keep = {k: v for k, v in os.environ.items()
if k in {"PATH", "SYSTEMROOT", "LANG", "LC_ALL"}}
keep.update(TZ="Asia/Shanghai", PYTHONDONTWRITEBYTECODE="1")
os.environ.clear()
os.environ.update(keep)
sys.dont_write_bytecode = True
import time
if hasattr(time, "tzset"):
time.tzset()
previous = Path.cwd()
with tempfile.TemporaryDirectory(prefix="pms-unit-") as isolated:
try:
os.chdir(isolated)
sys.argv = [str(path)]
runpy.run_path(str(path), run_name="__main__")
finally:
os.chdir(previous)
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("用法: python scripts/unit_test_guard.py 测试文件")
# 独立模块保存护栏状态,不与 runpy 临时使用的 __main__ 共用全局字典。
from unit_test_guard import run_suite
run_suite(sys.argv[1])
else:
sys.addaudithook(_audit)
atexit.register(_finish)