修改qmt地址
This commit is contained in:
parent
654d88f266
commit
c05887038d
|
|
@ -58,3 +58,6 @@ PMS_QMT_SIGN_SEED_HEX=
|
||||||
# -----END PUBLIC KEY-----
|
# -----END PUBLIC KEY-----
|
||||||
# 裸 32 字节 base64 / 64 位 hex 也都认, 代码会自动归一 (ws_codec.normalize_pubkey)。
|
# 裸 32 字节 base64 / 64 位 hex 也都认, 代码会自动归一 (ws_codec.normalize_pubkey)。
|
||||||
PMS_QMT_PEER_PUBKEY_B64=
|
PMS_QMT_PEER_PUBKEY_B64=
|
||||||
|
|
||||||
|
|
||||||
|
PMS_QMT_WS_URL=ws://192.168.18.182:9443/pms
|
||||||
|
|
@ -0,0 +1,340 @@
|
||||||
|
# 全仓库代码审查报告(2026-08-28)
|
||||||
|
|
||||||
|
## 一、这次审查做了什么
|
||||||
|
|
||||||
|
审查对象是提交 654d88f(2026-08-28 09:51)时的全部代码。范围包括 app 目录约一万八千行、scripts 全部脚本、前端页面、配置与环境模板。
|
||||||
|
|
||||||
|
审查分四层做。第一层,全部 86 个 Python 文件做语法编译检查,全部通过。第二层,把代码快照到一个干净的 Python 3.11 环境,装好依赖,把项目自带的 560 例单测完整跑了一遍,结果是 ALL SUITES PASS。第三层,用静态分析工具扫了一遍,没有未定义变量这类硬伤。第四层是重点:分十二路对全部模块逐行人工审查,然后把重要发现逐条回到代码里复核真伪。
|
||||||
|
|
||||||
|
一句话结论:编译和单测全部通过,但逐行审查发现了一批真问题。其中最严重的十四条我已经逐条复核确认。这批问题多数出在两类地方:一类是单测的桩和真实表结构脱节,测试测不到;另一类是模块之间的接线处,每个模块自己是对的,拼起来是错的。
|
||||||
|
|
||||||
|
下文每条问题都给出文件和行号、一句话结论、为什么错、什么场景触发。行号以当天快照为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、已复核确认的严重问题(十四条)
|
||||||
|
|
||||||
|
这一档的共同点是:会造成错账、错单、假状态,或者让一道安全闸完全失效。
|
||||||
|
|
||||||
|
### 1. 联调单识别闸从未生效过,假成交会按真单入账
|
||||||
|
|
||||||
|
位置:`app/services/ledger_service.py` 第 127 行。
|
||||||
|
|
||||||
|
入账三分流时读的是 `od.get("parent_instruction_id")`,但出口表 `pms_qmt_order` 的真实列名是 `parent_id`(见 `ddl_pms_v1.sql` 第 215 行和 `qmt_repo.enqueue_order`)。这个键永远取不到值,`is_smoke` 永远返回否,所以联调测试单(SMOKE 前缀)的成交全部落进真单分支入账。
|
||||||
|
|
||||||
|
这正是代码注释里记载过的那类事故:模拟端按 99.99 元造假成交,批次按荒谬价核销,摊薄成本和安全垫全错。这道闸写了,但从没起过作用。
|
||||||
|
|
||||||
|
单测没抓住它的原因很说明问题:`scripts/test_wiring.py` 第 380 到 382 行的测试桩把键名也写成了 `parent_instruction_id`,还配了一句错误注释说真表就叫这个名。桩错得和代码一样,所以全绿。
|
||||||
|
|
||||||
|
触发场景:ws 通道在线时跑一次 `scripts/ws_smoke.py` 联调,标的恰好在持仓里,一分钟内假成交就进账本。
|
||||||
|
|
||||||
|
修法方向:第 127 行改读 `parent_id`,同时把 test_wiring 桩的键名和注释一起改对,再补一条用真实列名的用例。
|
||||||
|
|
||||||
|
### 2. 成交回放游标按字典序推进,会永久漏掉一部分成交
|
||||||
|
|
||||||
|
位置:`app/repo/downstream_repo.py` 第 126 到 147 行,配合 `app/core/recon.py` 的 `next_cursor`。
|
||||||
|
|
||||||
|
回放的增量条件是 `order_id > :sid`,按字符串比较。但 `latest_filled_order_id` 自己的注释写明,order_id 长得像 `SELL_601956.SH_1778549400`,是方向前缀加代码加时间戳,不是时间序。字符串比较时 BUY 开头的永远小于 SELL 开头的。游标一旦落在任何一条 SELL 上(冷启动取 MAX 几乎必然如此),此后所有买入成交、以及代码序更小的卖出成交,都满足不了「大于游标」,永远拉不到。
|
||||||
|
|
||||||
|
后果:影子期 trading_order 是唯一入账路径,手工买入这类外部成交会静默漏账。日终对账只能按数量差补一条 RECON 批次,真实成交价和费用丢了,摊薄成本从此不准。
|
||||||
|
|
||||||
|
触发场景:游标停在某条 SELL 之后,发生任何 BUY 成交。
|
||||||
|
|
||||||
|
修法方向:游标别用 order_id,改按成交时间加自增主键推进;或者拉取条件放宽成按时间窗拉、逐单靠已见集合去重(`recon.map_fills_to_book` 本来就有 `known_order_ids` 参数,一直没接线,见第 4 条)。
|
||||||
|
|
||||||
|
### 3. ws 成交入账不是「精确一次」:批内出错会重复入账
|
||||||
|
|
||||||
|
位置:`app/services/ledger_service.py` 第 199 到 206 行,配合 `_apply_action`(第 331 行起)。
|
||||||
|
|
||||||
|
入账循环逐笔执行,出错记入 errors;但「标记已入账」是整批的:只有全程无错才把这批 inbox 行标成已处理。批内任何一笔出错(一笔成交入账失败、费用写入失败、成本重算失败都算),已经成功入账的其余笔也留在未处理状态,下一分钟整批重来,再入一遍账。
|
||||||
|
|
||||||
|
代码注释说「重试是安全的,幂等由成交号兜着」,这句话不成立。成交号去重只在收件那一层,防的是同一笔成交插两行;`_apply_action` 本身没有任何按成交号防重的逻辑:买入直接插批次,卖出直接累加核销,批次表也没有成交号唯一键。重复消费一遍就是持仓翻倍、成本全错。(费用那条恰好有唯一键兜住,批次账没有。)
|
||||||
|
|
||||||
|
叠加因素:worker 开了两个并发,回放任务每分钟发一跳、单跳最长可跑 300 秒,又没有互斥锁。前一跳超过一分钟,下一跳并发读到同一批未处理行,同样双入账。
|
||||||
|
|
||||||
|
反向问题在 trading_order 回放那条路(第 285 到 305 行):单笔入账失败只记 errors,游标照样推进,那笔成交永久丢失。同一种失败,ws 路是重复入账,回放路是永久漏账,两边口径相反,两边都错。
|
||||||
|
|
||||||
|
修法方向:给批次入账加成交号幂等(批次表加成交号列和唯一键,或入账前按成交号查已入集合);标记改成逐笔成功逐笔标;回放路在游标推进前检查本批是否全部成功;把 `known_order_ids` 参数接上;给每分钟任务加互斥。
|
||||||
|
|
||||||
|
### 4. 撤销命令不撤下游,券商侧委托继续成交
|
||||||
|
|
||||||
|
位置:`app/services/command_service.py` 的 `cancel` 函数(第 426 到 443 行附近)。
|
||||||
|
|
||||||
|
撤销命令时,对在途指令只做了本端置 CANCELLED,既不调 `executor.cancel_instruction`,也不发下游撤单。同一个文件里 `_cancel_marked_instructions` 的注释白纸黑字记着 2026-07-31 的教训:必须走 executor 的撤单,不能只把本端的行标掉,并且点名「撤销命令」是五条中招路径之一。但 `cancel` 本身恰恰还是老写法。
|
||||||
|
|
||||||
|
后果:ws 模式下,用户在页面点「撤销命令」,看到「已撤销,撤回指令 N 条」,而出口表里排队和已发出的子单没人管,券商侧继续挂着继续成交。成交回来还会按父指令入账。用户以为踩了刹车,实际只是账面划掉。
|
||||||
|
|
||||||
|
触发场景:ws 模式下撤销一条有在途指令的命令。
|
||||||
|
|
||||||
|
修法方向:`cancel` 里对每条在途指令改走 `executor.cancel_instruction`,失败的单独列出来,不能吞成「已撤销」。
|
||||||
|
|
||||||
|
### 5. 指令编号会撞车,命令从此每分钟报错、永久卡死
|
||||||
|
|
||||||
|
位置:`app/services/executor.py` 第 72 行和第 95 到 96 行。
|
||||||
|
|
||||||
|
方案转指令时,序号 `seq` 每次调用从零重数,指令编号是「日期、代码、动作、序号」,不含方案编号。指令表对编号有唯一键,而已终态(撤销、过期)的旧指令行还在。同一天对同一只票再下一次同动作的命令,新方案在扫描里排到同一个序号,生成同一个编号,插入报唯一键冲突。方案留在待转状态,下一分钟重算出同一个编号再撞,死循环,这条命令永远执行不了,错误日志每分钟一条。
|
||||||
|
|
||||||
|
触发场景:同日「下清仓命令、撤销、再对同一只票下清仓」;或上午一条降仓完成后,下午再下一条,同股同动作序号对齐即撞。
|
||||||
|
|
||||||
|
修法方向:编号里带上方案编号(方案编号本身已含命令号和序号,天然幂等),或撞键时改用时间戳序号重试。
|
||||||
|
|
||||||
|
### 6. 指令全部成交后,进度永远回不到方案,完成的命令被判「部分完成」
|
||||||
|
|
||||||
|
位置:`app/services/executor.py` 的 `sweep_windows`(第 330 行起),配合 `ledger_service._settle_instruction`。
|
||||||
|
|
||||||
|
方案的已成交量只有 `sweep_windows` 一处回写,而它只扫「在途」状态的指令。但成交入账的同一个调用里,`_settle_instruction` 就把成交量达标的指令即时置成 CONFIRMED,指令当场离开在途集合。于是最后一笔成交的增量必然漏同步;几笔成交在同一次回放里到齐时,方案的已成交量干脆停在零。
|
||||||
|
|
||||||
|
后果是主路径上的:命令进度按「金额乘以方案成交比例」结算,账永远欠一截,正常全部成交的命令拖到窗口末被判 PARTIAL 并告警;方案永久停在执行中,后续降仓命令还会把这些票当「已有在途方案」跳过。撤销和过期路径同样漏同步。
|
||||||
|
|
||||||
|
修法方向:在 `_settle_instruction` 置终态之前(或之后顺手)把 exec_qty 回写方案;或 sweep 扫描范围加上刚进终态的指令。
|
||||||
|
|
||||||
|
### 7. 盘前一键清仓会被判「已完成」,实际一股没卖
|
||||||
|
|
||||||
|
位置:`app/services/command_service.py` 的 `refresh_progress`,配合 `app/core/command_spec.py` 第 421 行和 `app/core/planner.py` 的 `plan_liquidate_all`。
|
||||||
|
|
||||||
|
链条是这样的。清仓方案对取不到现价的持仓照样下单,但金额记零,命令的目标金额只累计有价票。盘前和夜间全部持仓都取不到现价,目标金额等于零。而进度结算规定「目标金额小于等于零就是 DONE」。命令轮询全天每分钟跑,方案转指令却只在交易时段跑。于是夜里下的一键清仓,下一分钟就被判 DONE,方案永远不会转成指令,一股不卖,页面显示已完成。
|
||||||
|
|
||||||
|
一键清仓恰恰最容易在盘前盘后下达,这个场景不是边角。部分停牌票的变体也存在:有价票成交后命令提前 DONE,停牌票的卖出还在途,但命令已经不能撤销。
|
||||||
|
|
||||||
|
修法方向:目标金额为零但方案里有单时不判 DONE;或清仓类命令的完成判据改成「全部方案到终态」。
|
||||||
|
|
||||||
|
### 8. 自主建仓的名额和金额不计在途,跨跳会超配
|
||||||
|
|
||||||
|
位置:`app/services/proposal_service.py` 第 216 到 217 行。
|
||||||
|
|
||||||
|
名额等于上限减「已成交持仓只数」,金额余量按已成交市值算。在途的建仓指令和等拍板的建仓提议,两头都不占。同票同动作有去重,换一只票就拦不住。扫描每分钟一跳:候选没进买入区间时指令滞留在途,下一跳名额照旧,又放两只。全自动档位下几分钟就能放出超过上限的新票建仓指令;有 ws 资金快照时金额侧有兜底(可用资金封顶),但影子模式没有,名额侧任何模式都没有。
|
||||||
|
|
||||||
|
修法方向:算名额和金额时把在途建仓指令与等拍板的建仓提议计入;执行终检同样计入。
|
||||||
|
|
||||||
|
### 9. 信号「试算」会把真信号永久吞掉
|
||||||
|
|
||||||
|
位置:`app/services/signal_service.py` 的 `_read`(第 310 行附近)和 `_ack`。
|
||||||
|
|
||||||
|
消费用的是 Redis 消费组,读取参数是「只给从未投递过的消息」。消息一旦读到就进了本消费者的待确认清单;全模块没有任何找回待确认消息的逻辑。所以「读了不确认」不等于「下次还能拿到」,而是永远拿不到。
|
||||||
|
|
||||||
|
页面上的「信号消化试算」正踩在这上面:试算走同一个消费组读取,但刻意不确认。恰有一条未消化的高置信风控卖出时,点一次试算,这条消息被标记已投递、永不确认、永不重来,该票永远不会因这条信号清仓,唯一痕迹是待处理计数悄悄加一。正常消费中处理抛异常的消息,同样一次性永久滞留。
|
||||||
|
|
||||||
|
修法方向:试算改用只读命令(旁边的 `upstream_signals.py` 就是这么做的,用 XREVRANGE 不进消费组);正常消费补一段启动时找回待确认消息的逻辑。
|
||||||
|
|
||||||
|
### 10. 除权检测永远不会触发,而正常加仓日天天误报
|
||||||
|
|
||||||
|
位置:`app/services/ledger_service.py` 的 `detect_and_apply_ex_right`(第 922 行起),配合 `daily_settle` 的步骤顺序和 `app/core/recon.py` 的 `detect_ex_right`。
|
||||||
|
|
||||||
|
除权检测比较的是「昨日快照数量」对「今日账本数量」。但送转股发生在券商账户,账本数量只会因成交或对账修正而变,而对账排在除权检测之后。所以除权当天,账本数量和昨日快照相等,检测返回无异动;随后对账把多出来的股数当普通数量差异,按下游成本价补一条 RECON 批次,老批次的成本价不做除权缩减,摊薄成本被抬高,安全垫假报深亏,补仓档位被凭空触发。次日快照已同步,再也检不出来。
|
||||||
|
|
||||||
|
反过来,任何正常加仓日(当天有买入成交),账本数量真的比昨日快照多了,检测就进入比例核对:数量比是一点几,价格反比接近一,判成「疑似非除权变动,ERROR 待人工」。也就是说,这个检测对真除权失明,对正常加仓天天误报。
|
||||||
|
|
||||||
|
修法方向:除权检测改用下游快照的数量跳变来比(对账拿到的下游持仓对昨日下游持仓),并把「当日有成交的票」排除出比例核对;`apply_ex_right` 对已部分核销的批次也要处理 closed_qty。
|
||||||
|
|
||||||
|
### 11. 「须人工全量对账」的标记活不过下一次重连
|
||||||
|
|
||||||
|
位置:`app/ws/runner.py` 第 344 行。
|
||||||
|
|
||||||
|
补发不全时置 resync_flag,语义是「中间一段成交永远丢了,必须人工全量对账后经页面清除」。但每次握手都调 `set_conn` 并传入本次算出的 resync 布尔值,正常重连传的是否,于是把标记写回零。断线置标记后几分钟网络恢复、正常重连,标记消失,只剩滚动日志里一行错误。丢失区间的成交没人补对账,成本静默错下去。页面专设的人工清除接口形同虚设。
|
||||||
|
|
||||||
|
修法方向:`set_conn` 只在 resync 为真时写标记,清零只留给页面接口。
|
||||||
|
|
||||||
|
### 12. 科创板两百股起的规则,规划与闸门层完全没有处理
|
||||||
|
|
||||||
|
位置:`app/core/sizer.py` 第 15 行(LOT 等于 100),planner 和 rule_gate 全部取整点。
|
||||||
|
|
||||||
|
科创板(688、689 开头)买入申报两百股起。`strategy_advisor.py` 第 76 到 84 行自己承认:持仓里已经有 688802.SH,全库其他地方一律按一百股一手,两百股这条只在策略挂载侧兜了。规划器会切出一百股的批次,规则闸按一百校验放行,交易所拒单,指令挂死到窗口过期。减仓摊派同样会切出一百股。
|
||||||
|
|
||||||
|
触发场景:候选池或持仓中出现科创板代码,而现在就真实持有一只。
|
||||||
|
|
||||||
|
修法方向:把 `strategy_advisor.lot_of` 这类「按代码定最小申报单位」的函数下沉到 sizer,planner、rule_gate、exec_timing 全部改用它。
|
||||||
|
|
||||||
|
### 13. 网格会在中枢上方买入,和它自己的说明相反
|
||||||
|
|
||||||
|
位置:`app/services/strategy_runner.py` 第 429 到 450 行,配合 `_grid_levels`。
|
||||||
|
|
||||||
|
档位表是从下界到上界整个区间生成的,中枢上下都有档。下行分支的注释写着「只买中枢下方」,但代码对跌破的任何档都买,没有任何和中枢的比较。预算按「中枢以下档数」摊每档金额,前提也是只买下半区。
|
||||||
|
|
||||||
|
后果:价格先涨向上界再回落一档,就在中枢上方买入一手。价格在区间上半区震荡时,预算先被上半区吃掉,等真跌回吸筹区反而触到投入上限停买。恰好高买、低不买。
|
||||||
|
|
||||||
|
修法方向:下行买入分支加一条「档价低于中枢才买」。
|
||||||
|
|
||||||
|
### 14. 跟踪止盈的部分卖出没有闩锁,回撤期间会级联卖光
|
||||||
|
|
||||||
|
位置:`app/services/strategy_runner.py` 第 494 到 507 行。
|
||||||
|
|
||||||
|
设定「回撤百分之五卖一半」的本意是卖一次。但高水位只抬不降、武装标志永不复位,卖出后也没有任何「本次回撤已经卖过」的状态。上一笔卖出到终态后,价格仍在触发线下,条件再次成立,再卖剩余的一半。几何级联,直到剩不足一手。持一万股的票会在半小时级别被卖到只剩零头,和挂载说明完全不符。
|
||||||
|
|
||||||
|
修法方向:卖出后置一个「本轮回撤已处理」标志,高水位重新创高时复位;或每次卖出后把触发线下移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、已复核确认的中等问题
|
||||||
|
|
||||||
|
### 15. 操作留痕的「操作人」取自请求体,可以随意冒名
|
||||||
|
|
||||||
|
`app/web/main.py` 第 77 行已经把认证身份放进了 `request.state.user`,但所有写接口的操作人都取 `payload.get("by")`(第 289、292、245、414、780、791、851 行等)。任何登录用户都能在请求体里写别人的名字,下单、撤单、挂策略都记到别人头上。权限体系刚建,问责凭证却是客户端说了算。修法:操作人一律改取会话里的用户名,请求体的 by 顶多当备注。
|
||||||
|
|
||||||
|
### 16. 环境模板的两处敏感配置
|
||||||
|
|
||||||
|
`.env.example` 第 40 行把管理员标记写成了 `band:yhyqx`(「用户与权限」菜单),和 `config/settings.py` 的默认值 `pms:admin` 不一致。注释说明这是联测用的临时做法,但模板是运维会照抄的文件,凡持有这条通用权限的账号都会成为 PMS 系统管理员。联测结束后要记得收回来。另外第 19 行 `SIGNAL_REDIS_PASSWORD=wlkj2018` 看起来是真实密码,进了版本库就算泄漏,建议改成占位符并考虑轮换。
|
||||||
|
|
||||||
|
### 17. 宏观信号同日重扫的失败路径会冲掉当日命令留痕
|
||||||
|
|
||||||
|
`app/services/macro_service.py` 第 155 和 174 行附近:成功路径小心保留了当日已下命令的动作字段、单号和周期状态,但取数失败与数据守卫未过这两条路径按默认值整行覆盖(`macro_repo.upsert_signal` 是全字段更新)。盘中重扫恰逢行情源抖动,当天的「已下命令」痕迹被冲掉,「当日不重复下命令」的判据落空,之后再扫成功可再下一条同向命令;次日取昨日状态还会取到这行不可用记录,极值周期和退出事件丢失,偏热侧「回落再动」的降仓会被静默吞掉。修法:失败路径只更新 zone 和 note,不动 action、ref_id、detail 里的 cycle。
|
||||||
|
|
||||||
|
### 18. 安全垫峰值清仓后不清零,复开仓的票一开仓就可能触发保垫减仓
|
||||||
|
|
||||||
|
`cushion_peak` 在 `ledger_service.py` 第 401 和 1170 行只做「取历史最大」,全库没有任何清零点。清仓再重新建仓的票带着上一轮的峰值:旧峰值百分之十二,新仓垫子百分之一,回吐条件立即成立,保垫减仓不设确认门槛直接落指令。候选池反复推荐旧票是常态,这条会真实触发。修法:持仓归零转 CLOSED 时把峰值一并清零。
|
||||||
|
|
||||||
|
### 19. GATED 批次没有任何解锁代码,建仓命令永远到不了「完成」
|
||||||
|
|
||||||
|
方案状态 GATED(等条件解锁的补足与加仓批)在 `executor.py` 第 9 行注释里说「由动作引擎解锁」,但全库 `update_plan` 只有四个调用点,没有一处把 GATED 转成 PENDING,动作引擎里也没有这个词。命令目标金额含全部批次,GATED 批永远不执行,每条建仓命令必然拖满窗口判 PARTIAL 并告警。另外撤销命令和调整窗口的 SQL(`pms_repo.py` 的 `cancel_plans_of_command` 和 `set_plans_deadline`)WHERE 里只认 PENDING 和 EXECUTING,GATED 批撤不掉也顺延不了。修法:要么把解锁机制补上,要么建仓命令的目标金额只算基础批、GATED 批单独呈现;两个 SQL 的状态清单加上 GATED。
|
||||||
|
|
||||||
|
### 20. 调整窗口对已转成指令的单子完全无效
|
||||||
|
|
||||||
|
`_adjust_window` 只改方案表的截止日和命令进度,但方案转指令时把截止日冻结进了指令自己的进度字段,此后配额、窗口末日强制完成、收口全读指令里那份。方案落表后一分钟内就会转指令,所以用户真去调窗口时指令早已存在,顺延等于没顺延。修法:调窗口时同步更新该命令下在途指令进度里的截止日。
|
||||||
|
|
||||||
|
### 21. 行业约束在一次库抖动后会静默失效一整天
|
||||||
|
|
||||||
|
`app/services/industry.py` 第 213 到 219 行:批量查行业抛异常时,这批票全部按「查不到」缓存为空并当日不再重试。「查不到」和「查询失败」被写进同一个按日缓存。开盘第一跳库超时一次,全天所有票的行业都是空,行业集中度硬拦截逐票跳过,而就绪探测走的是另一条路,故障恢复后报就绪,页面无任何异常。这违反本仓库自己反复强调的「拿不到不等于通过」。修法:异常路径不写缓存,下一跳重查。
|
||||||
|
|
||||||
|
### 22. 长假后第一个交易日取不到昨收,候选池会空一天
|
||||||
|
|
||||||
|
`app/services/market.py` 第 125 行:昨收回看窗按「天数乘一点六」个自然日算,默认三天只回看四点八天。春节国庆停市七到九个自然日,节后首日最近一根日线在窗外,昨收取空,候选池整日空掉。这正是该函数注释里说要修的 2026-07-31 故障在长假后的复发形态。同文件三个取数函数还把「查询异常」的空结果按日缓存,盘前一次抖动当天不再重试。修法:回看窗按交易日算或放宽到十五个自然日以上;异常不落缓存。
|
||||||
|
|
||||||
|
### 23. 同一批告警类小问题
|
||||||
|
|
||||||
|
这几条各自独立,一并列出。`signal_service` 的当日去重集合超过五百条时按字典序裁剪,丢的不是最旧的键(第 338 行)。提议编号是「日期加代码加动作」的确定性编号,驳回后的同日重建撞唯一键,该动作当日进不了队列且每分钟报错刷屏(`proposal_service.py` 第 538 行,信号路同病)。置信度归一只对「大于一」除以一百,零到一百制里的一会被当成百分之百,越低的置信被解释得越高,风控流置信度恰好是零到一百制(`app/core/signal_rules.py` 第 57 到 62 行)。动作引擎四个求值器互不知晓,同一只票同轮可以同时产出减仓和加仓两条候选,各自分流成一卖一买(`action_engine.py`,根子在保垫峰值是全时段而加仓判据是五日窗,两套时间基准)。规则闸持续拦截的指令每分钟写一行拒绝留痕,无当日去重,会把作为判分事实源的账本淹掉(`executor.py` 第 249 到 261 行,同文件参考位漂移刚为同类问题做过一天一条)。
|
||||||
|
|
||||||
|
### 24. 调度与通道层的几条
|
||||||
|
|
||||||
|
回放任务 15:05 停跑而日终对账 15:10 全量修正,尾窗成交先被对账按数量补一次、次日开盘又正常入账一次,账本多出一份整整一个交易日(`scheduler.py` 会话窗口与 `daily_settle` 的时间差)。休假模式下 14:50 的做 T 强制平回也被跳过,当日 T 仓会裸奔过夜,违反「绝不过夜」的铁律(`t0_close` 受执行暂停闸管辖)。ws 进程启动自检失败时就绪标志已提前置真,「下轮重连会重试」的承诺不成立,进程会带着零水位上线,把真缺口误判成冷启动(`runner.py` 第 176 到 232 行)。重连退避的失败计数只在优雅退出时清零,健康跑几天后每次断线都固定等满三十秒(第 241 到 251 行)。心跳把正被并发修改的统计字典直接交给线程里的 JSON 序列化,高峰期可能炸掉当轮心跳(第 212 行)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、前端页面(index.html)
|
||||||
|
|
||||||
|
以下都已对照后端路由核实。
|
||||||
|
|
||||||
|
第一条,纯交易员每次登录、刷新、快捷下单成功后都会弹「无权限」错误。`loadAll`(第 2739 行)无条件调管理员专属的 `/api/ws-channel`,而 `call` 对 403 一律弹全局错误提示(第 2407 到 2409 行)。交易员刚点完清仓看到成功提示,紧跟一条无权限报错,极易误解为交易被拒;且通道告警对纯交易员永远不亮。
|
||||||
|
|
||||||
|
第二条,策略产生的指令会从「今日」面板凭空消失。「今日成交」「已经结束」按 `INS_` 加日期的前缀过滤(第 2181 行),但策略指令的前缀是 `STR`(`strategy_runner.py` 第 139 行)。策略单在途时可见,一旦成交或撤销就从三个页签和消息栏同时消失,「今日成交」计数永远不含策略成交。
|
||||||
|
|
||||||
|
第三条,快捷命令只看 ok 字段就报「已下达」(第 2281 行)。后端对「该产方案却一条没产」的命令返回 ok 为真、状态为已作废,交易员看到的是成功,实际命令已自我作废。运维视图的下达入口会显示状态和说明,交易员视图没有。
|
||||||
|
|
||||||
|
第四条,采纳提议成功时丢弃了后端刻意返回的警告字段(第 2819 行)。「一次性纪律计数器写入失败」的提示永远到不了用户眼前。
|
||||||
|
|
||||||
|
第五条,交易员视图里有两个按钮调的是管理员接口,点了必弹无权限:「我的策略」区的「试算一遍」(第 993 行,调 `/api/ops/strategy-attach-scan`)和「选股计划」抽屉的「强刷加灌行业映射」(第 1744 行,调 `/api/ops/plan-refresh`)。要么前端按角色隐藏,要么后端把这两条挪出管理员前缀。
|
||||||
|
|
||||||
|
第六条,候选「已下单」的判定用任意日期、任意状态的建仓指令(第 2201 行),几天前早已作废的指令会把今天真实在盯的候选从两张表里吞掉。
|
||||||
|
|
||||||
|
第七条,交易员视图的全部危险操作没有防重复点击的禁用态(第 2269 到 2283 行),双击能发出两条相同的资金命令,而同类命令后端不互斥,两条降仓百分之五等于降仓百分之十。
|
||||||
|
|
||||||
|
第八条,保存参数在传输层失败时仍弹「已保存」(第 2457 到 2460 行),随后刷新还会用服务器旧值覆盖用户刚改的输入。
|
||||||
|
|
||||||
|
第九条,登出或会话过期后,三十秒一轮的自动刷新照常运转,停在登录遮罩上的页面无限期打 401 请求(第 2976 行,登出不清定时器)。
|
||||||
|
|
||||||
|
第十条,「今天发生了什么」没有日期过滤(第 2484 行),当天记录少时昨天的行只显示时分,看起来就像今天发生的。
|
||||||
|
|
||||||
|
另有一条后端配套问题:`/health` 在免登录白名单里,返回体带总操作规模、各仓位上限、自主档位和数据库连接错误串,未认证即可读到真实资金体量。建议把这些字段挪进需登录的接口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、两个回测脚本(backtest_churn.py 与 backtest_entry.py,逐行审)
|
||||||
|
|
||||||
|
这两个脚本整体结构清楚,只读不写表的承诺守住了,读数纪律(样本不足只报数)也贯彻了。以下是方法学层面的问题,会影响你拿它们做「要不要加护栏、要不要加入场过滤」的决策,按影响大小排。
|
||||||
|
|
||||||
|
第一条,入场过滤的三条规则都用到了买入时刻还不知道的信息。追高度用的是当日最高最低价(全天才知道),当日涨跌和放量阴线用的是收盘价和全天成交量。注释说「只用买入日及之前的信息,无未来函数」,按日粒度成立,按盘中粒度不成立:真实盘中过滤器拿不到当天收盘。⑤ 段的反事实结果会比实盘可实现的效果偏乐观。建议在读法里加一句说明,或把规则改成「昨日阴线放量」「开盘至买入时点涨幅」这类盘中可得的口径。
|
||||||
|
|
||||||
|
第二条,持有天数是自然日,输出文案却写「交易日」。配对时 `hold_days` 按日历日差算(churn 第 237 行、entry 第 293 行,注释自己承认是近似),但 ① 段和护栏档位都印着「交易日」。护栏 N 等于 1 那档失真最大:周五买周一卖是一个交易日,却算三个自然日,被 N 等于 1 漏掉。判护栏档位前建议把它改成真交易日(`app/core/tradedays.py` 现成)。
|
||||||
|
|
||||||
|
第三条,无行情日的回退用了未来数据。`entry_context` 在买入日没有 K 线时用「其后第一根」近似(entry 第 239 到 244 行),画像和过滤用到了买入之后那天的数据,和「无未来函数」的承诺相悖。路径罕见,建议直接跳过该样本而不是回退。
|
||||||
|
|
||||||
|
第四条,费用函数和「每边最低五元」从没被用到。两个脚本都定义了 `trade_cost`(含最低佣金),但全部计算只用比例费率,小金额来回的摩擦被低估。churn ④ 段注释还承诺「有 hard_numbers.amount 时用真实额」,代码没实现;「累计摩擦约等于名义规模的百分之几」这个口径是各来回等名义的假设,建议在输出里说明白。
|
||||||
|
|
||||||
|
第五条,churn 的护栏扫描里,卖价缺失的来回会被当成零收益样本混入。`_guard_sweep` 第 368 行 `(t["gross_ret"] or 0)` 把取不到收益的来回按零处理再参与差值,污染样本。建议 gross_ret 为空直接跳过。
|
||||||
|
|
||||||
|
另有一条小的:churn 的 ④ 段循环累加的是同一个常量,写法绕了(第 339 到 342 行),结果没错但读着像要按笔算又没算。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、测试套件的盲区
|
||||||
|
|
||||||
|
560 例的断言密度和边界覆盖高于一般水准,但有两类结构性盲区,其中第一类直接掩护了本报告第 1 条严重问题。
|
||||||
|
|
||||||
|
第一类,桩没跟着产品长。`test_wiring.py` 的 FakeRepo 落后真实 `pms_repo` 二十六个函数。缺 `active_strategy_codes` 导致「挂策略的票收到风控卖出只落提议不强清」这条 2026-08 加的保护全套件零覆盖,而且生产上这个查询失败也会同样静默降级成空集,直接强清用户挂着策略的票。缺列名核对导致第 1 条的 SMOKE 闸测试桩键名错误还全绿。建议给 repo 层加函数时同步检查假仓面,并给「桩键名对齐真表列名」补一条结构自检。
|
||||||
|
|
||||||
|
第二类,个别用例声称测了链路实际没测。`test_batch10` 第 622 行起的用例注释说「走 _route_one,规则闸必须拿到真涨幅」,实际只测了取数函数,接线处(历史 bug 恰恰在那)没碰。`test_wiring.py` 第 1720 行拿了桩没断言「关闭开关时不确认消息」;`test_batch7` 第 223 行的「超期抛错」用例在不抛错时静默通过(try 后缺 else raise);`test_batch17` 第 483 行的 `out2`、`test_batch11` 第 262 行的 `r3` 赋值后没断言;`test_batch2` 第 197 行「不超卖」断言全在循环里,空计划零断言通过;`test_batch6` 第 621 行的密钥自证拿实现自己的名单当预期,名单被删测试跟着消失。
|
||||||
|
|
||||||
|
顺带一条脚本侧的同类问题:`gen_keys.py --check` 和 `check_db.py` 的「密钥不落库自证」是永真检查,因为 `param_store.get` 对密钥键无条件返回空串,等于用门上的锁证明屋里没贼。要自证得直查参数表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、看过且认为干净的部分
|
||||||
|
|
||||||
|
以下文件逐行看过,除上文点名的条目外没有发现真缺陷:`plan_diff.py`、`rebuild_check.py`、`macro_rules.py`(区域迟滞、对数映射、标定口径实测一致)、`rule_gate.py`、`ws_codec.py`(签名验签、水位、防重放实测正确)、`tradedays.py`(调休周六正确排除)、`db/session.py`(单表守卫、事务收尾、无连接泄漏)、`industry_repo.py`、`macro_repo.py`、`param_store.py`、`portfolio.py`、`judge.py`、`upstream_signals.py`(严格只读)、`plan_feed.py` 的新鲜度链路、`qmt_repo.py`(水位防回退、先查后插去重、认领的条件更新都对)、`calibrate_macro_signal.py`(标定数学数值复核过)、`init_db.py`、`rebuild_ledger.py`、`reset_ledger.py` 的确认闸、`code_fingerprint.py`、`probe_bionic.py`、`probe_plan_api.py`。
|
||||||
|
|
||||||
|
时区专项查过:三个容器统一上海时区,表时间戳都由应用侧写入,SQL 里没有数据库端的取时函数,「当日」口径一致。唯一例外是 `reset_ledger.py` 第 208 行用了数据库端 NOW()(库时钟是 UTC),该行时间戳会倒退八小时,只影响排障时的时间线阅读。
|
||||||
|
|
||||||
|
登录会话票本身的签发与校验可靠:HMAC 覆盖完整、常量时间比较、过期有校验,不可伪造。多角色集合判定逻辑正确。34 条路由逐条核对过,当前没有漏挂鉴权的运维写接口;但鉴权模型是「前缀黑名单之外全放行」,日后新增运维接口挂错前缀会静默对交易员开放,建议改成运维默认拒绝。另外会话 cookie 未设 secure(当前裸 HTTP 部署下是既成事实)、登出无服务端失效手段、`/api/params` 对交易员放行的键里含下发通道切换和自主档位这类系统级开关,这三条属于要不要收紧的取舍,列出供拍板。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、其余由分路审查报出、我未逐行复核的线索
|
||||||
|
|
||||||
|
以下条目方向可信但我没有逐行验证,建议照单核对:ws 通道 T_ACK 分支无终态保护,重发场景下已成交委托可能被改回在途(runner 第 561 行);优雅停机的最后一次确认与关连接是死代码(runner 第 840 行);watch.py 对紧急单的「今日投余废」口径与执行器不一致会双扣;watch.py 的交叉核对拿最近两百条未归档指令当全集会误报;migrate_archived_at 对查列失败的表可能报全绿;ws_smoke 的持仓快照读取失败会打出两句矛盾的诊断;策略侧买入暂停表同票单条先写先赢,风控来源的暂停可能被吸筹来源吞掉后又被自动解除;用户手动暂停的网格会被自动接力撤掉换挂止盈;网格卖出在发单时就弹档扣钱、未成交不回滚;主板零股尾巴全清后永久滞留;做 T 平回数量未整百取整会被拒单循环;派发定性的票仍会被挂上止盈。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、验证命令
|
||||||
|
|
||||||
|
在你的开发机上,进入项目目录执行。每条命令后面写了预期读数。
|
||||||
|
|
||||||
|
验证第 1 条(联调单闸失效)的两个事实:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/baobao/Documents/work/project/tradingSystem
|
||||||
|
grep -n 'parent_instruction_id' app/services/ledger_service.py
|
||||||
|
grep -n 'parent_id' ddl_pms_v1.sql | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:第一条命令能看到第 127 行在读 `parent_instruction_id`;第二条命令能看到表列名是 `parent_id`。两个名字对不上,就是问题本身。
|
||||||
|
|
||||||
|
验证第 5 条(指令编号撞车的前提):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/baobao/Documents/work/project/tradingSystem
|
||||||
|
grep -n 'seq = 0' app/services/executor.py
|
||||||
|
grep -n 'instruction_id.*UNIQUE\|uk_ins' ddl_pms_v1.sql | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:能看到 executor 里序号从零起数,且指令编号在表上有唯一键。
|
||||||
|
|
||||||
|
验证第 11 条(resync 标记被覆盖):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/baobao/Documents/work/project/tradingSystem
|
||||||
|
grep -n 'set_conn' app/ws/runner.py | head -5
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:能看到握手路径上那次调用把 resync 变量原样传入,正常重连时它是否值,于是写回零。
|
||||||
|
|
||||||
|
复现整套单测通过(说明上述问题都在单测覆盖之外,修复时每条都该补用例)。这条在部署服务器 tlai4090 上执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
预期读数:ALL SUITES PASS。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、修复建议的先后次序
|
||||||
|
|
||||||
|
以下只是建议,改不改、怎么改由你拍板。
|
||||||
|
|
||||||
|
第一批(真金白银直接相关,建议最先修):第 1 条联调单闸、第 3 条入账幂等、第 4 条撤销不撤下游、第 11 条 resync 标记、第 14 条止盈级联、第 13 条网格上半区买入。
|
||||||
|
|
||||||
|
第二批(会造成假状态和错误决策):第 2 条回放游标、第 6 条进度回写、第 7 条盘前清仓判完成、第 10 条除权、第 5 条指令编号、第 12 条科创板手数、第 8 条建仓超配、第 9 条信号试算。
|
||||||
|
|
||||||
|
第三批(权限与页面):第 15 条留痕冒名、第 16 条环境模板、前端第一二三条。
|
||||||
|
|
||||||
|
第四批:其余中低项与测试补桩。
|
||||||
|
|
||||||
|
修复时的两条纪律,来自这次审查本身的教训:每修一条,先补一个用真实列名、真实前缀、真实时序的用例,让它先红后绿;桩层的键名与真表列名做一次全面对齐。
|
||||||
|
|
@ -205,7 +205,7 @@ class Settings(BaseSettings):
|
||||||
# --- QMT WebSocket 直连通道 (协议见 QMT_WS_PROTOCOL.md V1.0) ---
|
# --- QMT WebSocket 直连通道 (协议见 QMT_WS_PROTOCOL.md V1.0) ---
|
||||||
# 连接由 pms-ws 常驻进程独占 (app/ws/runner.py); executor 经 pms_qmt_order
|
# 连接由 pms-ws 常驻进程独占 (app/ws/runner.py); executor 经 pms_qmt_order
|
||||||
# 出口表递单, 详见 app/services/dispatcher.py 头部的「进程边界」一节。
|
# 出口表递单, 详见 app/services/dispatcher.py 头部的「进程边界」一节。
|
||||||
PMS_QMT_WS_URL: str = "ws://192.168.16.98:9443/pms"
|
PMS_QMT_WS_URL: str = "ws://192.168.18.182:9443/pms"
|
||||||
PMS_QMT_WS_ENABLED: bool = False # pms-ws 进程总开关; False = 空转不连接不写心跳
|
PMS_QMT_WS_ENABLED: bool = False # pms-ws 进程总开关; False = 空转不连接不写心跳
|
||||||
PMS_QMT_ACK_BATCH: int = 20 # 累积确认: 每落库 N 条发一次 ack_seq
|
PMS_QMT_ACK_BATCH: int = 20 # 累积确认: 每落库 N 条发一次 ack_seq
|
||||||
PMS_QMT_ACK_INTERVAL_SEC: float = 2.0 # 累积确认: 或每 N 秒发一次 (取先到)
|
PMS_QMT_ACK_INTERVAL_SEC: float = 2.0 # 累积确认: 或每 N 秒发一次 (取先到)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue