隔离单测并整合命令结算与一字板修复
接续 Claude ead20bf 与 01dbcdb,分别保留为第 36、37 批测试。按用户决定在命令完成前取消未解锁方案,并支持故障重试。补齐漏桩与联网失败护栏,开发机 41 批 983 例通过。同步 Codex 开发入口与交接记录。
This commit is contained in:
parent
fbe0014413
commit
55e2ed6aa3
|
|
@ -5,3 +5,7 @@ __pycache__
|
|||
.env
|
||||
logs
|
||||
.DS_Store
|
||||
# 开发代理的本地配置和嵌套工作树不进入运行镜像。
|
||||
.claude
|
||||
.codex
|
||||
.agents
|
||||
|
|
|
|||
|
|
@ -18,3 +18,6 @@ _to_delete/
|
|||
docker-compose.override.yml
|
||||
/.claude/output-styles/readable-chinese.md
|
||||
/.claude/settings.json
|
||||
|
||||
# 保留旧 Claude 工作树供接续,不把嵌套检出加入主仓库。
|
||||
/.claude/worktrees/
|
||||
|
|
|
|||
|
|
@ -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
126
CLAUDE.md
|
|
@ -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/` 中的未合入工作继续保留。迁移开发工具不代表这些改动已经合并、部署或验收。
|
||||
|
|
|
|||
40
DEVLOG.md
40
DEVLOG.md
|
|
@ -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 · 一句话标题
|
||||
|
|
|
|||
|
|
@ -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 协议承担 |
|
||||
|
|
|
|||
|
|
@ -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。"""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 批还以"在途建仓意图"挂在各个视图里)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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, 解锁重问的量能确认要用)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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 和依赖变更,无需重新建表。部署和真实交易行为尚未判收。
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
[A] 组是这批里唯一一条**静态**用例: 它不测行为, 它扫源码, 守住"关键路径不许丢返回值"
|
||||
这条纪律本身 —— 新加的调用点一旦又把返回值丢了, 这里立刻红。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import ast
|
||||
import os
|
||||
import sys
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
* 配额与收尾兜底始终留在 PMS 本地 —— 本地检查命中时连咨询都不发生 (用 HTTP 计数器钉死)。
|
||||
* 默认档位 B 必须与接通前一字不差 —— 实现A是加装不是改装。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
* 确认即加速: 决策系统卖出命中在途清仓 → 只把卖出**指令**升级紧急 (打桩 repo);
|
||||
* 闭仓清场: 撤策略/在途建仓/买单/提议, 幂等第二遍无动作 (打桩 repo/service)。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
* 服务层 (打桩): 全自动下命令与冲突让路 / 建议与采纳幂等 / 闸状态读写与陈旧失效 /
|
||||
proposal_service 的买入侧过滤留 TRIM。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
* 丁(减仓看情况): _pending_buys(exclude_strategy=True) 剔掉策略腿 —— 减仓不掐网格/做T,
|
||||
清仓与全局暂停买入仍全撤.
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
驱动的清仓, 它们根本不经过提议分流。这两条用「分流函数上装绊线」的办法证明。
|
||||
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
3. 逐票逻辑状态解析收公司深度两键; 持仓页 company_view 一行。
|
||||
4. 建议方案矩阵: 六种情形各一例; 研判补档; 开关开着时评估参数按建议。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
B 择时 timing_rules: 昨夜定性映射、盘中转多升级、超期无读数。
|
||||
C 合议 consensus: 多数决方向、平局中性、强弱、附录丙路由表逐条。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
D 仓库 upsert: 更新子句只用 VALUES 列不带绑定参数 (pymysql 批量陷阱), SQL 单表合规。
|
||||
E 参数登记与检查点窗口。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
开口未确认路由改观察带 wait_confirm; 总闸关掉 _intraday_cfg 为 None (逐字回旧)。
|
||||
E 参数登记与范围。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
A 研究面来源解析 _research_st: 持仓票 / 候选票 / 都没有 三情形。
|
||||
B 研究面接口 api_research: 六块齐全且来源逐路标明; 无读数票 company 为 None、来源标不在持仓也不在候选。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
rule_gate 减持放行口径、增持全约束、命令与自主的刹车差别。
|
||||
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
H. dispatcher 纯助手: valid_until 归一、子单→父指令反推。
|
||||
I. ws 逐笔成交 → 账本动作: 精确认领、批次类型、费用不进成本、金额自洽性。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
fixture 用的是 2026-07-30 上游给的真实应答样例 (截取前若干条, 结构一字未改),
|
||||
坏数据用例另造 —— 「上游改了字段/漏了字段」这类事故必须由单测先炸, 而不是盘中静默少票。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
这一批的重点不是"能不能算出差异", 而是**不报假变化**: 榜单是按 top 截断过的, 尾部的进出
|
||||
是截断噪音而不是上游观点变化。假警报天天响, 提示就等于没有 —— 与 `_capped` 治的是同一个病。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
所以用例的重点不是"算得对不对", 而是**该拦的拦不拦得住**, 以及**不该拦的会不会误伤** ——
|
||||
误伤的代价是多等一天, 漏拦的代价是一本每个数都错、却和真账长得一模一样的账。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
约定: 全部断言通过输出 "ALL PASS (n cases)" 并退出码 0; 任一失败输出明细并退出码 1。
|
||||
覆盖: sizer 批次拆分/一手合并/组合约束/风险披露; cushion 摊薄成本/垫子状态/保垫触发/核销次序/补仓档。
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
|
||||
跑法: python3 scripts/test_page_copy_guard.py
|
||||
"""
|
||||
import unit_test_guard # noqa: F401,单独运行也禁止联网
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue