代码全面修改

This commit is contained in:
zlt 2026-08-28 13:07:27 +08:00
parent c05887038d
commit 7e06e43334
48 changed files with 2297 additions and 406 deletions

View File

@ -919,6 +919,69 @@ bionic 的 `pms_advisor`(支撑压力位算 PMS 买卖区间、`signal_type`
--- ---
## 2026-08-28 · 全仓库审查后的一轮集中修复47 个文件585 例单测两端全绿
**做了什么**
以提交 654d88f 为基线做了一次全仓库审查(报告见 `CODE_REVIEW_2026-08-28.md`,已在 c058870 入库),
然后把审查确认的问题全部修掉。要点按危害排序:
1. **联调单识别闸修通**`ledger_service` 入账三分流读错列名(`parent_instruction_id`
真表叫 `parent_id`SMOKE 联调单的假成交一直按真单入账。改对列名,并把 test_wiring
桩里同样写错的键名与那句写反的注释一起改对——桩错得和代码一样,所以从前全绿。
2. **成交回放游标改成 v2 时间游标**:原来按 order_id 字典序推进,乱序单号会被永久漏掉。
现在按「时间窗 + 已入账清单seen」推进带 24 小时重叠回看与 48 小时清单裁剪;
冷启动只对齐锚点不追认历史。`daily_settle` 开头先补一次回放再结算。
3. **ws 入账逐笔推进**:每笔入账成功立刻 `inbox_mark`,中途崩溃只影响当笔(原来整批
最后一起标,崩一次重放整批)。费用记账对重复键容忍。剩余风险见「还欠着什么」第 1 条。
4. **撤销链路修通**:撤销命令的在途指令必须逐条经 `executor.cancel_instruction` 撤回下游,
撤不成的命令保持在途、单独列出可重撤(原来只把本端行标 CANCELLED券商侧委托继续挂着
5. **科创板 200 股最小申报统一进 `sizer.lot_of`**planner 批次拆分、rule_gate 买卖校验、
executor 当日配额、action_engine 四类动作与新建仓、signal 消化、strategy 两侧,全走同一口径。
6. **风控置信度按文档 0~100 制归一**`_norm_conf_pct`):原启发式把 1 当 100% 直接触发
自动清仓,方向恰好反了。
7. **命令进度两处口径**GATED 批金额不进完成分母(建仓命令不再必然拖成 PARTIAL
目标金额 0 但方案未出清(盘前一键清仓无现价)不判 DONE。
8. **并发防护**plan 认领锁PENDING→PLANNING 条件更新)、四个调度任务加租约互斥、
指令/提议确定性编号撞唯一键自动重试、beat 任务 expires=50 防堆积。
9. 其余:动作引擎同轮 TRIM×买入互斥除权调整同步缩放已核销批次交易日历按年探测降级
网格只买中枢下方;跟踪止盈部分卖一次性闩锁;策略买入暂停按来源分记;宏观失败路径保留
当日留痕;行业批量查询失败不写日缓存;市场取数失败不缓存;前端十来处(管理员按钮
v-if、试算防连点、撤销警示展示、今日账本口径等脚本与回测口径若干。
10. **测试侧补桩补例**FakeRepo 补上 pms_strategy 全套桩(原来一个策略函数都没有,
所有策略接线在单测里靠 try/except 吞异常混过去FakeRedis 改成真消费组语义
">"/"0"/PEL/ack原桩恰好掩护了试算吞消息的真 bug新增 test_batch1917 例
审查回归test_wiring 增到 66 例。全套 585 例。
**动了哪些文件**
app 下 26 个、scripts 下 21 个(含新增 `scripts/test_batch19_units.py`),完整清单看本次
提交的 diff。`.env.example`、`config/settings.py`、鉴权与 cookie 策略这轮**特意没动**
(属于要拍板的策略项,见「还欠着什么」)。没有 DDL 变更,没有新增 env。
**部署方式**
tlai4090 那台:`git pull` 后 `make deploy`(源码打进镜像,必须重建),再 `make test`
ALL SUITES PASS 且代码指纹与工作树一致。纪律照旧:**收盘后再部署**。
**真机判收**
容器py3.11与生产镜像同版本与开发机本地py3.10pip 装齐依赖后)各跑一遍
`python3 scripts/run_tests.py`,两端都是 ALL SUITES PASS585 例)。部署后的判收三步:
`make test` 全绿且指纹对上;② ws 通道在线时跑一次 `scripts/ws_smoke.py`SMOKE 单
必须**不**入账(联调单闸首次真正生效,账本里查不到 99.99 这类假价核销);③ 盘中盯一跳
`signal_digest``intraday_exec` 的日志,无新报错。
**还欠着什么**
1. 单笔成交「入账成功→inbox 标记」之间仍非原子:恰好崩在这个窗口会重复入账一笔。
根治要给 trade 入账加 trade_no 唯一键DDL 变更),等拍板。
2. `.env.example``SIGNAL_REDIS_PASSWORD` 是真密码,建议轮换并改成占位符;
`PMS_ADMIN_PERMS=band:yhyqx` 维持联测拍板不动。
3. `/api/params` 目前只把 `PMS_DISPATCH_MODE` 收进管理员白名单,其余自治类参数键
(自主档位、总规模这类)要不要一并收,等拍板。
4. cookie 的 Secure 标记、登出的服务端失效、鉴权默认拒绝的结构调整,这轮没动。
5. GATED 批仍没有解锁机制(这轮只是让它不再拖坏进度结算),补解锁还是砍掉这层,等拍板。
6. 审查报告第八节里"疑似待核"的几条(交接冷却文档口径与实现不一致、派发→止盈边沿)未处理。
7. `.git` 下有几个 `index.lock.stale*` 空文件(云会话删不了文件只能改名避让),可随手删掉。
---
<!-- <!--
下一条节点从这里往下写,格式照抄上面: 下一条节点从这里往下写,格式照抄上面:
## YYYY-MM-DD · 一句话标题 ## YYYY-MM-DD · 一句话标题

View File

@ -31,7 +31,7 @@
from __future__ import annotations from __future__ import annotations
from app.core.cushion import dca_stage, trim_trigger from app.core.cushion import dca_stage, trim_trigger
from app.core.sizer import LOT, lot_qty, split_batches from app.core.sizer import LOT, lot_of, lot_qty, split_batches
# 新建仓要在这里滚动校验上限, 用的必须是规则闸那一份 check_all_caps, 不能另写一套。 # 新建仓要在这里滚动校验上限, 用的必须是规则闸那一份 check_all_caps, 不能另写一套。
# _new_name_ctx / _ctx_after 是命令驱动建仓 (planner.plan_increase_exposure) 滚动更新组合 # _new_name_ctx / _ctx_after 是命令驱动建仓 (planner.plan_increase_exposure) 滚动更新组合
# 快照用的同两个函数, 一起借过来 —— 为的是让「自主建仓」与「命令建仓」的上限口径逐字一致。 # 快照用的同两个函数, 一起借过来 —— 为的是让「自主建仓」与「命令建仓」的上限口径逐字一致。
@ -101,7 +101,7 @@ def eval_fill(p: dict, params: dict, mkt: dict):
room = room_to_target(p, params) room = room_to_target(p, params)
amt = min(batch_amount(p, params, 1), room) amt = min(batch_amount(p, params, 1), room)
qty = lot_qty(amt, price) qty = lot_qty(amt, price)
if qty < LOT: if qty < lot_of(p.get("ts_code")): # 科创板买入 200 股起
return None return None
return _cand(p, A_FILL, BUY, qty, return _cand(p, A_FILL, BUY, qty,
f"回踩补足: 建仓第 {tdays} 个交易日, 浮亏 {_f(cushion):.2%} 未破支撑 " f"回踩补足: 建仓第 {tdays} 个交易日, 浮亏 {_f(cushion):.2%} 未破支撑 "
@ -133,7 +133,7 @@ def eval_add(p: dict, params: dict, mkt: dict):
room = room_to_target(p, params) room = room_to_target(p, params)
amt = min(batch_amount(p, params, 2), room) if room > 0 else batch_amount(p, params, 2) amt = min(batch_amount(p, params, 2), room) if room > 0 else batch_amount(p, params, 2)
qty = lot_qty(amt, price) qty = lot_qty(amt, price)
if qty < LOT: if qty < lot_of(p.get("ts_code")): # 科创板买入 200 股起
return None return None
why = "创 5 日新高" if (high5 > 0 and price >= high5) else "站上压力位" why = "创 5 日新高" if (high5 > 0 and price >= high5) else "站上压力位"
return _cand(p, A_ADD, BUY, qty, return _cand(p, A_ADD, BUY, qty,
@ -163,7 +163,7 @@ def eval_dca(p: dict, params: dict, mkt: dict):
return None return None
max_ratio = _f(params.get("dca_max_ratio"), 0.5) max_ratio = _f(params.get("dca_max_ratio"), 0.5)
qty = int(base_qty * max_ratio // LOT) * LOT qty = int(base_qty * max_ratio // LOT) * LOT
if qty < LOT: if qty < lot_of(p.get("ts_code")): # 科创板买入 200 股起
return None return None
deep = _f(params.get("dca_deep_confirm"), -0.15) deep = _f(params.get("dca_deep_confirm"), -0.15)
is_deep = _f(cushion) <= deep + 1e-12 is_deep = _f(cushion) <= deep + 1e-12
@ -188,7 +188,7 @@ def eval_trim(p: dict, params: dict, mkt: dict = None):
return None return None
total = int(p.get("total_qty") or 0) total = int(p.get("total_qty") or 0)
qty = int(total / 3 // LOT) * LOT qty = int(total / 3 // LOT) * LOT
if qty < LOT: if qty < lot_of(p.get("ts_code")): # 科创板部分卖 200 股起 (不足就不自动减)
return None return None
return _cand(p, A_TRIM, SELL, qty, return _cand(p, A_TRIM, SELL, qty,
f"保垫减仓: 安全垫峰值 {peak:.2%} 回吐至 {_f(now):.2%} (过半), " f"保垫减仓: 安全垫峰值 {peak:.2%} 回吐至 {_f(now):.2%} (过半), "
@ -257,6 +257,7 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
continue continue
mkt = (market or {}).get(code) or {} mkt = (market or {}).get(code) or {}
frozen = (p.get("frozen_reason") or "NONE") != "NONE" frozen = (p.get("frozen_reason") or "NONE") != "NONE"
cands_this = []
for action, fn in EVALUATORS: for action, fn in EVALUATORS:
why = skip_why(skip, (code, action)) why = skip_why(skip, (code, action))
if why: if why:
@ -274,7 +275,18 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
"why": f"评估异常 {type(e).__name__}: {e}"}) "why": f"评估异常 {type(e).__name__}: {e}"})
continue continue
if c: if c:
out.append(c) cands_this.append(c)
# 同一只票同轮买卖互斥 (2026-08-28 审查修): 保垫减仓的峰值是**全时段**只增不减,
# 加仓判据看的是近 5 日窗口, 两套时间基准可以同时成立 —— 一轮里对同一只票
# 一边 TRIM 锁盈一边 ADD 加仓, 自动对倒空耗手续费。触发 TRIM 时买入侧让路
# (先落袋为安, 保守方向优先), 买入条件真成立的话下一轮 TRIM 不触发时自然会来。
if any(c["action"] == A_TRIM for c in cands_this):
for c in cands_this:
if c["side"] == BUY:
skipped.append({"ts_code": code, "action": c["action"],
"why": "同轮已触发保垫减仓(TRIM), 买卖互斥, 买入侧让路"})
cands_this = [c for c in cands_this if c["side"] != BUY]
out.extend(cands_this)
return {"candidates": out, "skipped": skipped} return {"candidates": out, "skipped": skipped}
@ -311,7 +323,8 @@ def eval_open(c: dict, params: dict, caps: dict, room_amt: float):
return None, (f"剩余可投金额 {room_amt:,.0f} 元不足一只目标仓位 " return None, (f"剩余可投金额 {room_amt:,.0f} 元不足一只目标仓位 "
f"{want:,.0f} 元 ({target_pct:.0%}), 不开半截新仓") f"{want:,.0f} 元 ({target_pct:.0%}), 不开半截新仓")
sp = split_batches(want, price, splits=params.get("batch_split"), sp = split_batches(want, price, splits=params.get("batch_split"),
merge=bool(params.get("min_lot_merge", True))) merge=bool(params.get("min_lot_merge", True)),
min_lot=lot_of(code))
if not sp["ok"]: if not sp["ok"]:
return None, sp["reason"] return None, sp["reason"]
@ -327,8 +340,8 @@ def eval_open(c: dict, params: dict, caps: dict, room_amt: float):
base = sp["batches"][0] base = sp["batches"][0]
qty = int(base["qty"]) qty = int(base["qty"])
if qty < LOT: if qty < lot_of(code):
return None, f"底仓批 {qty} 股不足一手" return None, f"底仓批 {qty} 股不足最小申报数量 ({lot_of(code)} 股)"
hard = { hard = {
# ---- 定性材料: 这只票凭什么被选出来。研判闸要看的就是这几项 ---- # ---- 定性材料: 这只票凭什么被选出来。研判闸要看的就是这几项 ----

View File

@ -78,8 +78,12 @@ def daily_quota(remaining_qty: int, tdays_left: int, *, total_qty: int = None,
q = min(q, r) q = min(q, r)
if not allow_odd_tail and q % lot and q != r: if not allow_odd_tail and q % lot and q != r:
q = (q // lot) * lot q = (q // lot) * lot
# 若本次取整后剩下不足一手的尾巴, 并进本次一起出, 免得最后一天剩 30 股卡住 # 若本次取整后剩下不足一手的尾巴, 并进本次一起出, 免得最后一天剩 30 股卡住。
if 0 < r - q < lot: # **只有整票清仓 (allow_odd_tail=True) 才允许并零股** —— 部分减持的申报必须整百,
# 把 50 股尾巴并进 TRIM 片会让整片变成非整百数量、被规则闸按 LOT_INVALID 整片拒掉,
# 本可先卖的整数部分也一并卡死 (2026-08-28 审查修)。部分减持的零股尾巴留给窗口收口
# 报部分完成, 由人处理。
if allow_odd_tail and 0 < r - q < lot:
q = r q = r
return max(q, 0) if q > 0 else r return max(q, 0) if q > 0 else r

View File

@ -24,7 +24,7 @@ from __future__ import annotations
import re import re
from app.core.sizer import LOT, lot_qty, split_batches, check_caps from app.core.sizer import LOT, lot_of, lot_qty, split_batches, check_caps
from app.core.cushion import sell_allocation # noqa: F401 (供调用方做核销预览) from app.core.cushion import sell_allocation # noqa: F401 (供调用方做核销预览)
# 方案动作词表 (与 ddl_pms_v1.sql pms_plan.action 注释一致) # 方案动作词表 (与 ddl_pms_v1.sql pms_plan.action 注释一致)
@ -39,13 +39,28 @@ P_OPEN_BASE, P_OPEN_FILL, P_OPEN_ADD = 10, 20, 30
# ---------------------------------------------------------------- 小工具 # ---------------------------------------------------------------- 小工具
def floor_lot(qty) -> int: def floor_lot(qty, lot: int = LOT) -> int:
return int(max(0, int(qty)) // LOT) * LOT return int(max(0.0, float(qty or 0)) // lot) * lot
def ceil_lot(qty) -> int: def ceil_lot(qty, lot: int = LOT) -> int:
q = max(0, int(qty)) """向上取整到一手。带小数的股数也按"多凑一手盖住缺口"的语义向上取
return ((q + LOT - 1) // LOT) * LOT (原实现先 int() 截断再进位, ceil_lot(100.5) 会算成 100 而不是 200
收利润档"多卖一手把缺口盖掉"就永远盖不掉; 2026-08-28 审查修)
1e-9 容差防浮点噪声把 100.0000001 顶成 200"""
import math
q = max(0.0, float(qty or 0))
return int(math.ceil(q / lot - 1e-9)) * lot
def _min_sell(qty: int, sellable: int, ts_code) -> int:
"""部分减持的最小申报修正: 科创板部分卖出不得低于 200 股。
qty 已是整百; 不足 200 , 可卖量够就抬到 200 (方向偏保守: 多卖一点),
不够就返回 0 (这只票本轮不切部分卖单)主板原样返回"""
ml = lot_of(ts_code)
if qty <= 0 or ml <= LOT or qty >= ml:
return qty
return ml if sellable >= ml else 0
def mv_of(p: dict) -> float: def mv_of(p: dict) -> float:
@ -165,6 +180,7 @@ def plan_reduce_exposure(*, release_amount: float, positions: list, pending_buys
continue continue
need = target - acc need = target - acc
want = min(sellable, ceil_lot(need / float(p["price"]))) want = min(sellable, ceil_lot(need / float(p["price"])))
want = _min_sell(want, sellable, p["ts_code"]) # 科创板部分卖 ≥200
if want <= 0: if want <= 0:
continue continue
amt = want * float(p["price"]) amt = want * float(p["price"])
@ -178,7 +194,7 @@ def plan_reduce_exposure(*, release_amount: float, positions: list, pending_buys
# ---- ④ 等比微减: 仍不足 → 各票按市值等比例微减 (此时可动底仓) ---- # ---- ④ 等比微减: 仍不足 → 各票按市值等比例微减 (此时可动底仓) ----
if acc < target: if acc < target:
gap = target - acc gap = target - acc
cand = [p for p in pool if remain_qty(p) >= LOT] cand = [p for p in pool if remain_qty(p) >= lot_of(p["ts_code"])]
total_mv = sum(remain_qty(p) * float(p["price"]) for p in cand) total_mv = sum(remain_qty(p) * float(p["price"]) for p in cand)
if total_mv <= 0: if total_mv <= 0:
notes.append("等比微减: 无可减持仓") notes.append("等比微减: 无可减持仓")
@ -187,6 +203,7 @@ def plan_reduce_exposure(*, release_amount: float, positions: list, pending_buys
for p in cand: for p in cand:
w = remain_qty(p) * float(p["price"]) / total_mv w = remain_qty(p) * float(p["price"]) / total_mv
q = floor_lot(min(remain_qty(p), lot_qty(gap * w, float(p["price"])))) q = floor_lot(min(remain_qty(p), lot_qty(gap * w, float(p["price"]))))
q = _min_sell(q, remain_qty(p), p["ts_code"]) # 科创板部分卖 ≥200
if q > 0: if q > 0:
plan_qty[p["ts_code"]] = q plan_qty[p["ts_code"]] = q
# 取整造成的缺口: 按剩余市值从大到小逐手补齐 # 取整造成的缺口: 按剩余市值从大到小逐手补齐
@ -200,9 +217,11 @@ def plan_reduce_exposure(*, release_amount: float, positions: list, pending_buys
progressed = False progressed = False
for p in order: for p in order:
cur = plan_qty.get(p["ts_code"], 0) cur = plan_qty.get(p["ts_code"], 0)
if cur + LOT <= remain_qty(p): # 从零起步的票, 第一步就要跨过它的最小申报数量 (科创板 200)
plan_qty[p["ts_code"]] = cur + LOT step = LOT if cur > 0 else lot_of(p["ts_code"])
planned += LOT * float(p["price"]) if cur + step <= remain_qty(p):
plan_qty[p["ts_code"]] = cur + step
planned += step * float(p["price"])
progressed = True progressed = True
if planned >= gap - 1e-6: if planned >= gap - 1e-6:
break break
@ -267,7 +286,7 @@ def plan_increase_exposure(*, add_amount: float, positions: list, candidates=Non
continue continue
want_amt = min(room, target - acc) want_amt = min(room, target - acc)
q = lot_qty(want_amt, float(p["price"])) q = lot_qty(want_amt, float(p["price"]))
if q < LOT: if q < lot_of(p["ts_code"]): # 科创板买入 200 股起
continue continue
amt = q * float(p["price"]) amt = q * float(p["price"])
bad = check_all_caps(ts_code=p["ts_code"], add_amount=amt, ctx=_stock_ctx(ctx, p, False)) bad = check_all_caps(ts_code=p["ts_code"], add_amount=amt, ctx=_stock_ctx(ctx, p, False))
@ -289,7 +308,8 @@ def plan_increase_exposure(*, add_amount: float, positions: list, candidates=Non
if price <= 0: if price <= 0:
continue continue
want_amt = min(default_target * scale, target - acc) want_amt = min(default_target * scale, target - acc)
sp = split_batches(want_amt, price, splits=batch_split, merge=merge) sp = split_batches(want_amt, price, splits=batch_split, merge=merge,
min_lot=lot_of(c.get("ts_code")))
if not sp["ok"]: if not sp["ok"]:
rejects.append({"ts_code": c.get("ts_code"), "reasons": [sp["reason"]]}) rejects.append({"ts_code": c.get("ts_code"), "reasons": [sp["reason"]]})
continue continue
@ -362,7 +382,8 @@ def plan_open_target(*, ts_code: str, target_pct: float, price: float, ctx: dict
"planned_amount": 0.0, "gap": round(add_amount, 2), "notes": [], "planned_amount": 0.0, "gap": round(add_amount, 2), "notes": [],
"rejects": [{"ts_code": ts_code, "reasons": bad}]} "rejects": [{"ts_code": ts_code, "reasons": bad}]}
sp = split_batches(add_amount, float(price), splits=batch_split, merge=merge) sp = split_batches(add_amount, float(price), splits=batch_split, merge=merge,
min_lot=lot_of(ts_code))
if not sp["ok"]: if not sp["ok"]:
return {"ok": False, "items": [], "target_amount": round(add_amount, 2), return {"ok": False, "items": [], "target_amount": round(add_amount, 2),
"planned_amount": 0.0, "gap": round(add_amount, 2), "notes": [], "planned_amount": 0.0, "gap": round(add_amount, 2), "notes": [],
@ -414,10 +435,14 @@ def plan_reduce_stock(*, ts_code: str, target_pct: float, position: dict, scale:
"gap": 0.0, "notes": [f"{ts_code} 当前仓位未超过目标 {target_pct:.1%}"], "gap": 0.0, "notes": [f"{ts_code} 当前仓位未超过目标 {target_pct:.1%}"],
"rejects": []} "rejects": []}
qty = min(qty_hold, floor_lot(lot_qty(over, price))) qty = min(qty_hold, floor_lot(lot_qty(over, price)))
qty = _min_sell(qty, floor_lot(qty_hold), ts_code) # 科创板部分卖 ≥200 (不够就不切)
if qty <= 0: if qty <= 0:
ml = lot_of(ts_code)
return {"ok": False, "items": [], "target_amount": round(over, 2), "planned_amount": 0.0, return {"ok": False, "items": [], "target_amount": round(over, 2), "planned_amount": 0.0,
"gap": round(over, 2), "rejects": [], "gap": round(over, 2), "rejects": [],
"notes": [f"{ts_code} 超出额 {over:,.0f} 元不足一手, 不减持"]} "notes": [f"{ts_code} 超出额 {over:,.0f} 元不足最小申报数量"
+ (f" (科创板部分卖出最少 {ml} 股)" if ml > LOT else " (一手)")
+ ", 不减持"]}
amt = qty * price amt = qty * price
return {"ok": True, "target_amount": round(over, 2), "planned_amount": round(amt, 2), return {"ok": True, "target_amount": round(over, 2), "planned_amount": round(amt, 2),
"gap": round(max(0.0, over - amt), 2), "notes": [], "rejects": [], "gap": round(max(0.0, over - amt), 2), "notes": [], "rejects": [],
@ -466,20 +491,39 @@ def plan_liquidate_all(*, positions: list, pending_buys=None) -> dict:
def plan_sector_exit(*, sector: str, positions: list) -> dict: def plan_sector_exit(*, sector: str, positions: list) -> dict:
"""清仓某行业 (行业名由 IndustryClassifier 提供, positions 需带 sector 字段)。""" """清仓某行业 (行业名由 IndustryClassifier 提供, positions 需带 sector 字段)。
hit = [p for p in _usable(positions, ()) if (p.get("sector") or "") == sector]
if not hit: **取不到现价的持仓不许静默消失** plan_liquidate_all 同一条纪律 (2026-08-28 ):
行业级利空与个股停牌高度相关, `_usable` 把停牌票滤掉的话, 行业清仓会漏掉恰恰最该清的
那几只, 还报"已全部清仓" plan_exit_stock 的口径: 照样下整票卖单 (金额记 0,
由择时按实时行情定价), 并在 notes 里点名
"""
priced = [p for p in _usable(positions, ()) if (p.get("sector") or "") == sector]
priced_codes = {p["ts_code"] for p in priced}
unpriced = [p for p in (positions or [])
if int(p.get("total_qty") or 0) > 0
and (p.get("sector") or "") == sector
and p["ts_code"] not in priced_codes]
if not priced and not unpriced:
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0, return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [f"行业[{sector}]当前无持仓"], "rejects": []} "gap": 0.0, "notes": [f"行业[{sector}]当前无持仓"], "rejects": []}
items, acc = [], 0.0 items, acc = [], 0.0
for p in sorted(hit, key=lambda x: (-mv_of(x), x["ts_code"])): for p in sorted(priced, key=lambda x: (-mv_of(x), x["ts_code"])):
qty = int(p["total_qty"]) qty = int(p["total_qty"])
amt = qty * float(p["price"]) amt = qty * float(p["price"])
acc += amt acc += amt
items.append(_item(p["ts_code"], A_EXIT, SIDE_SELL, qty, amt, P_WEAK, items.append(_item(p["ts_code"], A_EXIT, SIDE_SELL, qty, amt, P_WEAK,
f"清仓行业[{sector}]", tier="行业清仓")) f"清仓行业[{sector}]", tier="行业清仓"))
for p in sorted(unpriced, key=lambda x: x["ts_code"]):
qty = int(p.get("total_qty") or 0)
items.append(_item(p["ts_code"], A_EXIT, SIDE_SELL, qty, 0.0, P_WEAK,
f"清仓行业[{sector}]: **取不到现价**, 金额待择时按实时行情定",
tier="行业清仓", need_price=True))
notes = ([f"**{len(unpriced)} 只取不到现价** (多为停牌), 已照常下清仓单但金额算不出: "
+ ", ".join(p["ts_code"] for p in unpriced[:6])
+ ("" if len(unpriced) > 6 else "")] if unpriced else [])
return {"ok": True, "target_amount": round(acc, 2), "planned_amount": round(acc, 2), return {"ok": True, "target_amount": round(acc, 2), "planned_amount": round(acc, 2),
"gap": 0.0, "items": items, "notes": [], "rejects": []} "gap": 0.0, "items": items, "notes": notes, "rejects": []}
def plan_sector_cap(*, sector: str, cap: float, positions: list) -> dict: def plan_sector_cap(*, sector: str, cap: float, positions: list) -> dict:
@ -505,6 +549,7 @@ def plan_sector_cap(*, sector: str, cap: float, positions: list) -> dict:
for p in sorted(hit, key=lambda x: (-mv_of(x), x["ts_code"])): for p in sorted(hit, key=lambda x: (-mv_of(x), x["ts_code"])):
w = mv_of(p) / sec_mv w = mv_of(p) / sec_mv
q = min(int(p["total_qty"]), floor_lot(lot_qty(over * w, float(p["price"])))) q = min(int(p["total_qty"]), floor_lot(lot_qty(over * w, float(p["price"]))))
q = _min_sell(q, floor_lot(int(p["total_qty"])), p["ts_code"]) # 科创板部分卖 ≥200
if q <= 0: if q <= 0:
continue continue
amt = q * float(p["price"]) amt = q * float(p["price"])

View File

@ -223,7 +223,7 @@ def map_trades_to_book(trades: list, parent_actions=None) -> dict:
"lot_type": (ACTION_TO_LOT.get(str(action or "").upper(), "BASE") "lot_type": (ACTION_TO_LOT.get(str(action or "").upper(), "BASE")
if side == "buy" else None), if side == "buy" else None),
"instruction_id": parent if action else None, "instruction_id": parent if action else None,
"order_id": pl.get("trade_no"), "alerts": []}) "order_id": pl.get("trade_no"), "seq": seq, "alerts": []})
fee = pl.get("fee") fee = pl.get("fee")
if fee is not None and float(fee) != 0: if fee is not None and float(fee) != 0:
@ -401,7 +401,12 @@ def detect_ex_right(prev_qty: int, now_qty: int, prev_price: float, now_price: f
def apply_ex_right(lots: list, ratio: float) -> list: def apply_ex_right(lots: list, ratio: float) -> list:
"""按除权比例调整批次: 数量 ×ratio, 成本价 ÷ratio (总成本不变)。""" """按除权比例调整批次: 数量 ×ratio, 成本价 ÷ratio (总成本不变)。
已部分核销的批次, closed_qty 与核销均价也要同比例调 (2026-08-28 审查修):
不调的话 剩余数量是新股数单位已核销是旧股数单位, cum_buy/cum_sell 两个单位混算,
摊薄成本照样错核销均价 ÷ratio 保持已实现金额不变
"""
r = float(ratio) r = float(ratio)
out = [] out = []
for l in lots or []: for l in lots or []:
@ -409,6 +414,10 @@ def apply_ex_right(lots: list, ratio: float) -> list:
px = float(l.get("open_price") or 0) / r if r else 0.0 px = float(l.get("open_price") or 0) / r if r else 0.0
n = dict(l) n = dict(l)
n["qty"], n["open_price"] = q, round(px, 3) n["qty"], n["open_price"] = q, round(px, 3)
if l.get("closed_qty"):
n["closed_qty"] = int(round(int(l.get("closed_qty") or 0) * r))
if l.get("close_avg_price"):
n["close_avg_price"] = round(float(l["close_avg_price"]) / r, 3) if r else 0.0
n["note"] = f"除权调整 ×{r:g}" n["note"] = f"除权调整 ×{r:g}"
out.append(n) out.append(n)
return out return out

View File

@ -17,7 +17,7 @@
""" """
from __future__ import annotations from __future__ import annotations
from app.core.sizer import LOT from app.core.sizer import LOT, lot_of
from app.core.planner import check_all_caps from app.core.planner import check_all_caps
BUY, SELL = "buy", "sell" BUY, SELL = "buy", "sell"
@ -84,33 +84,40 @@ def check(*, side: str, action: str, qty: int, price: float, ctx: dict) -> dict:
if flg.get("exec_halt"): if flg.get("exec_halt"):
failed.append("EXEC_HALT: 全局暂停执行 (休假模式) 生效中") failed.append("EXEC_HALT: 全局暂停执行 (休假模式) 生效中")
ml = lot_of(ctx.get("ts_code")) # 最小申报数量 (科创板 200, 其余 100)
if side == SELL: if side == SELL:
_check_sell(failed, warns, qty, total_qty, avail_qty, day) _check_sell(failed, warns, qty, total_qty, avail_qty, day, ml)
elif side == BUY: elif side == BUY:
_check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard) _check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard, ml)
else: else:
failed.append(f"SIDE_INVALID: 方向 {side!r} 非法") failed.append(f"SIDE_INVALID: 方向 {side!r} 非法")
return {"passed": not failed, "failed": failed, "warnings": warns, "hard_numbers": hard} return {"passed": not failed, "failed": failed, "warnings": warns, "hard_numbers": hard}
def _check_sell(failed, warns, qty, total_qty, avail_qty, day): def _check_sell(failed, warns, qty, total_qty, avail_qty, day, min_lot: int = LOT):
"""减持方向: 冻结/刹车/上限一概不拦, 只看「卖得出去吗」。""" """减持方向: 冻结/刹车/上限一概不拦, 只看「卖得出去吗」。"""
if qty > total_qty: if qty > total_qty:
failed.append(f"OVER_SELL: 卖出 {qty} > 持仓 {total_qty}") failed.append(f"OVER_SELL: 卖出 {qty} > 持仓 {total_qty}")
elif qty > avail_qty: elif qty > avail_qty:
failed.append(f"T1_UNAVAILABLE: 卖出 {qty} > T+1 可卖 {avail_qty} (当日买入次日才可卖)") failed.append(f"T1_UNAVAILABLE: 卖出 {qty} > T+1 可卖 {avail_qty} (当日买入次日才可卖)")
# 清仓允许卖零股 (A股规则); 部分减持必须整百 # 清仓允许卖零股 (A股规则); 部分减持必须整百, 科创板部分减持还须 ≥200 股
if qty % LOT != 0 and qty != total_qty: if qty % LOT != 0 and qty != total_qty:
failed.append(f"LOT_INVALID: 部分减持 {qty} 股非整百 (零股只能在清仓时一次性卖出)") failed.append(f"LOT_INVALID: 部分减持 {qty} 股非整百 (零股只能在清仓时一次性卖出)")
elif min_lot > LOT and 0 < qty < min_lot and qty != total_qty:
failed.append(f"LOT_INVALID: 科创板部分减持最少 {min_lot} 股 (卖 {qty} 股不合法; "
f"余额不足 {min_lot} 股只能一次性清出)")
if day.get("limit_down"): if day.get("limit_down"):
warns.append("LIMIT_DOWN: 跌停封板, 大概率成交不了, 已按限价挂出") warns.append("LIMIT_DOWN: 跌停封板, 大概率成交不了, 已按限价挂出")
def _check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard): def _check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard,
min_lot: int = LOT):
"""增持方向: 全部硬约束都要过。""" """增持方向: 全部硬约束都要过。"""
if qty % LOT != 0: if qty % LOT != 0:
failed.append(f"LOT_INVALID: 买入 {qty} 股非整百") failed.append(f"LOT_INVALID: 买入 {qty} 股非整百")
elif min_lot > LOT and 0 < qty < min_lot:
failed.append(f"LOT_INVALID: 科创板买入申报最少 {min_lot} 股 (买 {qty} 股会被交易所拒单)")
if (pos.get("frozen_reason") or "NONE") != "NONE": if (pos.get("frozen_reason") or "NONE") != "NONE":
failed.append(f"FROZEN: 该股处于 {pos['frozen_reason']}, 禁止增持") failed.append(f"FROZEN: 该股处于 {pos['frozen_reason']}, 禁止增持")
if flg.get("buy_halt"): if flg.get("buy_halt"):

View File

@ -55,13 +55,26 @@ def _num(v, d=0.0):
def _norm_conf(v) -> float: def _norm_conf(v) -> float:
"""置信度归一到 0~1。两条流一条给 0~1 一条给 0~100, 大于 1 的一律按百分制处理。""" """盘中流 (0~1 制) 的置信度归一。大于 1 的按百分制兜底处理。"""
c = _num(v) c = _num(v)
if c > 1: if c > 1:
c = c / 100.0 c = c / 100.0
return max(0.0, min(1.0, c)) return max(0.0, min(1.0, c))
def _norm_conf_pct(v) -> float:
"""风控流 (db3) 的置信度归一: **文档口径固定 0~100 制, 一律除以 100**。
原来两条流共用 _norm_conf "大于 1 才除"启发式, 0~100 制里 (0,1] 的取值
( 0~1% 这种噪声级置信度) 不会被缩放, 1 会被当成 100% 而直接触发自动清仓
方向恰好反了: 越低的置信被解释得越高 (2026-08-28 审查修)
既然文档定死了百分制, 就不做"像不像小数"的猜测; 上游若真发 0~1 制小数,
会被折成不足 1% 而落进"低于门槛只留痕"一档, 错的方向是漏报不是误清仓
"""
c = _num(v)
return max(0.0, min(100.0, c)) / 100.0
def parse_intraday(fields: dict, *, msg_id: str = None) -> dict: def parse_intraday(fields: dict, *, msg_id: str = None) -> dict:
"""买入/盘中信号流 (db2) 的一条消息 → 归一结构。""" """买入/盘中信号流 (db2) 的一条消息 → 归一结构。"""
f = fields or {} f = fields or {}
@ -97,7 +110,7 @@ def parse_risk_sell(fields: dict, *, msg_id: str = None) -> dict:
return {"source": SRC_RISK_SELL, "msg_id": msg_id, return {"source": SRC_RISK_SELL, "msg_id": msg_id,
"ts_code": (inner.get("ts_code") or "").strip(), "ts_code": (inner.get("ts_code") or "").strip(),
"action": (inner.get("action") or "").strip().upper(), "action": (inner.get("action") or "").strip().upper(),
"confidence": _norm_conf(inner.get("confidence")), "confidence": _norm_conf_pct(inner.get("confidence")),
"dominant_signal": inner.get("dominant_signal") or "", "dominant_signal": inner.get("dominant_signal") or "",
"reason": (inner.get("llm_reason") or inner.get("reason") or "")[:500], "reason": (inner.get("llm_reason") or inner.get("reason") or "")[:500],
"suggested_price": _num(inner.get("suggested_price")) or None} "suggested_price": _num(inner.get("suggested_price")) or None}
@ -162,6 +175,9 @@ def digest(signal: dict, position: dict, params: dict) -> dict:
qty = int(round(held * ratio / 100)) * 100 qty = int(round(held * ratio / 100)) * 100
if qty <= 0: if qty <= 0:
qty = held qty = held
# 科创板部分卖出最少 200 股: 量够就抬到 200 (偏保守多卖一点), 不够就退化为全卖
if code.startswith(("688", "689")) and 0 < qty < 200 and qty != held:
qty = 200 if held >= 200 else held
return _r(ACT_PROPOSE, qty, return _r(ACT_PROPOSE, qty,
f"风控 SELL 置信度 {conf:.0%} 介于 {conf_min:.0%}~{auto_conf:.0%}, " f"风控 SELL 置信度 {conf:.0%} 介于 {conf_min:.0%}~{auto_conf:.0%}, "
f"提议减 {qty} 股待确认 —— {why}", hard) f"提议减 {qty} 股待确认 —— {why}", hard)

View File

@ -23,21 +23,40 @@ MERGE_LADDER = [
BATCH_NAMES = ["BASE", "FILL", "ADD"] BATCH_NAMES = ["BASE", "FILL", "ADD"]
def lot_qty(amount: float, price: float) -> int: def lot_of(ts_code) -> int:
"""金额换算成整手股数 (向下取整到一手)。价格非法返回 0。""" """最小申报数量: 科创板 (688/689 开头) 买入 200 股起, 其余 100 股。
2026-08-28 全库统一到这里 (原先只有 strategy_advisor / strategy_runner 各自兜了一份):
planner 的批次拆分rule_gate 的一手检查executor 的当日配额action_engine
四类动作与新建仓, 全部改为按代码取最小申报数量规则出处: 科创板限价申报单笔
不小于 200 ; 卖出余额不足 200 股时应当一次性申报卖出 (各清仓路径单独处理)
"""
return 200 if str(ts_code or "").strip().upper().startswith(("688", "689")) else 100
def lot_qty(amount: float, price: float, lot: int = LOT) -> int:
"""金额换算成整手股数 (向下取整到一手)。价格非法返回 0。
加了 1e-9 的浮点容差: 407 元买 4.07 元的票, 407/4.07 在浮点里是 99.9999,
不加容差会把"恰好买得起一手"算成零手 (2026-08-28 审查发现的边界)
"""
if price is None or price <= 0 or amount is None or amount <= 0: if price is None or price <= 0 or amount is None or amount <= 0:
return 0 return 0
return int(amount / price / LOT) * LOT return int(amount / price / lot + 1e-9) * lot
def split_batches(target_amount: float, price: float, splits=None, merge: bool = True) -> dict: def split_batches(target_amount: float, price: float, splits=None, merge: bool = True,
min_lot: int = None) -> dict:
"""把单股目标金额拆成分批投放计划, 含一手检查与自动合并。 """把单股目标金额拆成分批投放计划, 含一手检查与自动合并。
返回 {"ok": bool, "scheme": tuple, "batches": [{"name","amount","qty"}...], "reason": str} 返回 {"ok": bool, "scheme": tuple, "batches": [{"name","amount","qty"}...], "reason": str}
- ok=False batches 为空, reason 说明原因 ("目标金额买不足一手") - ok=False batches 为空, reason 说明原因 ("目标金额买不足一手")
- 自动合并: 首选方案任一批次不足一手 逐级降档 (60/40 100) - 自动合并: 首选方案任一批次不足一手 逐级降档 (60/40 100)
merge=False 时不合并, 直接返回失败明细 (供页面提示) merge=False 时不合并, 直接返回失败明细 (供页面提示)
- min_lot: 每批的最小申报数量 (科创板 200; 调用方按代码传 lot_of(code))
数量仍按整百取整 (200 以上按 100 递增合法), 只是可行性线抬到 min_lot
""" """
floor = int(min_lot or LOT)
if price is None or price <= 0: if price is None or price <= 0:
return {"ok": False, "scheme": (), "batches": [], "reason": "价格非法"} return {"ok": False, "scheme": (), "batches": [], "reason": "价格非法"}
if target_amount is None or target_amount <= 0: if target_amount is None or target_amount <= 0:
@ -51,7 +70,7 @@ def split_batches(target_amount: float, price: float, splits=None, merge: bool =
for i, ratio in enumerate(scheme): for i, ratio in enumerate(scheme):
amt = target_amount * ratio amt = target_amount * ratio
q = lot_qty(amt, price) q = lot_qty(amt, price)
if q < LOT: if q < floor:
feasible = False feasible = False
break break
name = BATCH_NAMES[i] if i < len(BATCH_NAMES) else f"B{i+1}" name = BATCH_NAMES[i] if i < len(BATCH_NAMES) else f"B{i+1}"
@ -61,7 +80,9 @@ def split_batches(target_amount: float, price: float, splits=None, merge: bool =
return {"ok": True, "scheme": scheme, "batches": batches, "reason": ""} return {"ok": True, "scheme": scheme, "batches": batches, "reason": ""}
return { return {
"ok": False, "scheme": (), "batches": [], "ok": False, "scheme": (), "batches": [],
"reason": f"目标金额 {target_amount:.0f} 元按现价 {price:.2f} 买不足一手 (已尝试: {''.join(tried)})", "reason": (f"目标金额 {target_amount:.0f} 元按现价 {price:.2f} 买不足一手"
+ (f" (科创板最少 {floor} 股)" if floor > LOT else "")
+ f" (已尝试: {''.join(tried)})"),
} }

View File

@ -21,6 +21,27 @@ except Exception: # pragma: no cover
MAX_SCAN_DAYS = 400 # 防呆: 连续找不到交易日时的扫描上限 MAX_SCAN_DAYS = 400 # 防呆: 连续找不到交易日时的扫描上限
# 按年份缓存「chinesecalendar 有没有这一年的节假日数据」的探测结果。
# 库装了但版本旧 (没有当年数据) 时, is_workday 对当年任何日期都抛异常 —— 这是
# 最常见的降级场景 (每年年初库没升级), 原来 degraded 标记只看"装没装", 这种情况
# 全年法定节假日都被当交易日而页面显示一切正常 (2026-08-28 审查修)。
_YEAR_OK: dict = {}
def _year_supported(year: int) -> bool:
if not _HAS_CAL:
return False
hit = _YEAR_OK.get(year)
if hit is not None:
return hit
try:
_is_workday(date(year, 1, 1))
ok = True
except Exception:
ok = False
_YEAR_OK[year] = ok
return ok
def _as_date(d) -> date: def _as_date(d) -> date:
if d is None: if d is None:
@ -41,15 +62,21 @@ def is_trade_day(d=None) -> bool:
return False return False
if _HAS_CAL: if _HAS_CAL:
try: try:
return bool(_is_workday(dd)) ok = bool(_is_workday(dd))
_YEAR_OK.setdefault(dd.year, True)
return ok
except Exception: except Exception:
return True # 年份超范围 → 按工作日处理 (degraded) _YEAR_OK[dd.year] = False
return True # 年份超范围 → 按工作日处理 (degraded, 页面会亮标记)
return True return True
def calendar_degraded() -> bool: def calendar_degraded(d=None) -> bool:
"""True = 未装 chinesecalendar, 节假日不可辨 (页面与日报应提示)。""" """True = 节假日不可辨: 未装 chinesecalendar, **或它没有当年的数据** (库版本旧)。
return not _HAS_CAL 页面与日报据此提示默认探测今天所在年份"""
if not _HAS_CAL:
return True
return not _year_supported(_as_date(d).year)
def next_trade_day(d=None, n: int = 1) -> date: def next_trade_day(d=None, n: int = 1) -> date:

View File

@ -136,17 +136,28 @@ def fetch_filled_orders(*, since_id=None, since_time=None, limit: int = 500) ->
def latest_filled_order_id(): def latest_filled_order_id():
"""当前已成交单里最大的 order_id —— 回放游标冷启动时的锚点 """当前已成交单里字典序最大的 order_id。
MAX 而不是"最新时间", 是为了跟游标推进用同一把尺子: `fetch_filled_orders` 走的是 **2026-08-28 起回放游标不再用它**: order_id 形如 `SELL_601956.SH_1778549400`
`order_id > :sid` + `ORDER BY order_id ASC`, **字典序** (order_id 形如 (方向前缀+代码+时间戳), 按字典序推进的话, 游标一旦落在某条 SELL , 所有 BUY_*
`SELL_601956.SH_1778549400`, 并非时间序)锚点若按时间取, 会跟字典序对不上而漏行 与代码序更小的新成交都永远满足不了 `order_id > 游标`, 被系统性漏掉
游标已改为时间窗 + 已见 order_id 集合( ledger_service.replay_fills)
本函数保留给旧数据排查用
""" """
r = fetch_one("SELECT MAX(order_id) AS mx FROM trading_order WHERE order_status IN ('" r = fetch_one("SELECT MAX(order_id) AS mx FROM trading_order WHERE order_status IN ('"
+ "', '".join(FILLED_STATUSES) + "')") + "', '".join(FILLED_STATUSES) + "')")
return (r or {}).get("mx") return (r or {}).get("mx")
def latest_filled_time():
"""已成交单里最新的成交/委托时间 —— 时间游标冷启动的锚点 (不追认历史)。"""
r = fetch_one("SELECT MAX(filled_time) AS ft, MAX(order_time) AS ot FROM trading_order "
"WHERE order_status IN ('" + "', '".join(FILLED_STATUSES) + "')")
r = r or {}
vals = [str(v) for v in (r.get("ft"), r.get("ot")) if v]
return max(vals) if vals else None
def _norm_order(r: dict) -> dict: def _norm_order(r: dict) -> dict:
side = str(r.get("order_side") or "").strip().lower() side = str(r.get("order_side") or "").strip().lower()
if side in ("1", "b", "买入", ""): if side in ("1", "b", "买入", ""):

View File

@ -113,7 +113,10 @@ def list_commands(*, statuses=None, cmd_class=None, limit: int = 200,
def update_command(command_id: str, *, status=None, progress=None, done_at=None, def update_command(command_id: str, *, status=None, progress=None, done_at=None,
note=None) -> int: note=None, only_if_status=None) -> int:
"""only_if_status: 给了就带条件更新 (WHERE status IN ...), 返回影响行数 ——
plan_command 用它当认领锁: 抢到 PLANNING 的那一跳才规划, 输家直接退出,
免得 issue() 与每分钟的 plan_pending 双入口把同一条命令规划两遍 (2026-08-28)"""
sets, p = [], {"cid": command_id} sets, p = [], {"cid": command_id}
if status is not None: if status is not None:
sets.append("status = :st") sets.append("status = :st")
@ -129,7 +132,14 @@ def update_command(command_id: str, *, status=None, progress=None, done_at=None,
p["note"] = note p["note"] = note
if not sets: if not sets:
return 0 return 0
return execute(f"UPDATE pms_command SET {', '.join(sets)} WHERE command_id = :cid", p) where = "command_id = :cid"
if only_if_status:
keys = []
for i, st_ in enumerate(list(only_if_status)):
p[f"os{i}"] = st_
keys.append(f":os{i}")
where += f" AND status IN ({', '.join(keys)})"
return execute(f"UPDATE pms_command SET {', '.join(sets)} WHERE {where}", p)
def supersede_param_commands(cmd_type: str, ts_code=None, keep_command_id=None) -> int: def supersede_param_commands(cmd_type: str, ts_code=None, keep_command_id=None) -> int:
@ -232,14 +242,16 @@ def update_plan(plan_id: str, *, status=None, filled_qty=None) -> int:
def cancel_plans_of_command(command_id: str) -> int: def cancel_plans_of_command(command_id: str) -> int:
# GATED (建仓的补足/加仓批) 也是在途状态, 撤命令必须一并作废 (2026-08-28 审查修 ——
# 原来 WHERE 漏了它, 撤销后的 GATED 批还以"在途建仓意图"挂在各个视图里)
return execute("UPDATE pms_plan SET status = 'CANCELLED', updated_at = :ts " return execute("UPDATE pms_plan SET status = 'CANCELLED', updated_at = :ts "
"WHERE command_id = :cid AND status IN ('PENDING', 'EXECUTING')", "WHERE command_id = :cid AND status IN ('PENDING', 'GATED', 'EXECUTING')",
{"cid": command_id, "ts": _NOW()}) {"cid": command_id, "ts": _NOW()})
def set_plans_deadline(command_id: str, deadline) -> int: def set_plans_deadline(command_id: str, deadline) -> int:
return execute("UPDATE pms_plan SET deadline = :dl, updated_at = :ts " return execute("UPDATE pms_plan SET deadline = :dl, updated_at = :ts "
"WHERE command_id = :cid AND status IN ('PENDING', 'EXECUTING')", "WHERE command_id = :cid AND status IN ('PENDING', 'GATED', 'EXECUTING')",
{"cid": command_id, "dl": deadline, "ts": _NOW()}) {"cid": command_id, "dl": deadline, "ts": _NOW()})

View File

@ -90,6 +90,48 @@ def guard(*, trade_day=True, session=False, respect_exec_halt=True):
return deco return deco
def _exclusive(lease_key: str, ttl_sec: int = 115):
"""同名任务互斥 (2026-08-28 审查加): worker 双并发 + 每分钟一发 + 任务可跑到 300 秒,
重叠是常态配置 而成交入账方案物化这些路径不是可重入的 (重叠 = 双入账 / 双下单)
租约存参数表 (INFRA_ 前缀不进页面): 拿到租约才跑, 没拿到直接返回 skipped
写后回读校验缩小并发窗口; 租约超时 (ttl) 自动失效, 崩溃不会永久卡死
读写参数表失败按"照常执行"降级 互斥是保护, 不能反过来把任务停摆"""
def deco(fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
import json as _json
import time as _time
import uuid as _uuid
from app.repo import pms_repo
tok = _uuid.uuid4().hex[:12]
try:
raw = pms_repo.get_param(lease_key)
if raw:
d = _json.loads(raw)
if d.get("tok") and _time.time() - float(d.get("ts") or 0) < ttl_sec:
logger.info("[%s] 另一跳仍在跑 (租约 %.0fs 内), 本跳跳过",
fn.__name__, ttl_sec)
return {"skipped": "another_run_inflight"}
pms_repo.set_param(lease_key, _json.dumps({"ts": _time.time(), "tok": tok}),
"system")
back = _json.loads(pms_repo.get_param(lease_key) or "{}")
if back.get("tok") != tok:
return {"skipped": "lease_lost"}
except Exception as e:
logger.warning("[%s] 租约获取失败 (照常执行): %s", fn.__name__, e)
try:
return fn(*a, **kw)
finally:
try:
cur = _json.loads(pms_repo.get_param(lease_key) or "{}")
if cur.get("tok") == tok:
pms_repo.set_param(lease_key, "", "system")
except Exception:
pass
return wrapper
return deco
def _brief(r): def _brief(r):
if not isinstance(r, dict): if not isinstance(r, dict):
return str(r)[:200] return str(r)[:200]
@ -129,6 +171,7 @@ def premarket():
@celery_app.task(name="pms.command_poll") @celery_app.task(name="pms.command_poll")
@guard(trade_day=False, respect_exec_halt=True) @guard(trade_day=False, respect_exec_halt=True)
@_exclusive("INFRA_LOCK_COMMAND_POLL", ttl_sec=115)
def command_poll(): def command_poll():
"""命令轮询 (全天, 含非交易日 —— 用户随时可下命令, 方案先生成好待开盘执行)。""" """命令轮询 (全天, 含非交易日 —— 用户随时可下命令, 方案先生成好待开盘执行)。"""
from app.services import command_service from app.services import command_service
@ -146,6 +189,7 @@ def command_poll():
@celery_app.task(name="pms.replay_fills") @celery_app.task(name="pms.replay_fills")
@guard(trade_day=True, session=True) @guard(trade_day=True, session=True)
@_exclusive("INFRA_LOCK_REPLAY_FILLS", ttl_sec=115)
def replay_fills(): def replay_fills():
from app.services import ledger_service from app.services import ledger_service
r = ledger_service.replay_fills() r = ledger_service.replay_fills()
@ -157,6 +201,7 @@ def replay_fills():
@celery_app.task(name="pms.intraday_exec") @celery_app.task(name="pms.intraday_exec")
@guard(trade_day=True, session=True) @guard(trade_day=True, session=True)
@_exclusive("INFRA_LOCK_INTRADAY_EXEC", ttl_sec=230)
def intraday_exec(): def intraday_exec():
"""盘中执行: 方案转指令 → 择时出手 (规则闸终检 → 下发 → 记子单)。 """盘中执行: 方案转指令 → 择时出手 (规则闸终检 → 下发 → 记子单)。
@ -176,6 +221,7 @@ def intraday_exec():
@celery_app.task(name="pms.signal_digest") @celery_app.task(name="pms.signal_digest")
@guard(trade_day=True, session=True) @guard(trade_day=True, session=True)
@_exclusive("INFRA_LOCK_SIGNAL_DIGEST", ttl_sec=115)
def signal_digest(): def signal_digest():
"""信号消化: 订阅决策系统盘中信号 (db2 盘中广播 + db3 风控 LLM 卖出) → 卖出指令或提议。""" """信号消化: 订阅决策系统盘中信号 (db2 盘中广播 + db3 风控 LLM 卖出) → 卖出指令或提议。"""
from app.services import signal_service from app.services import signal_service
@ -211,8 +257,8 @@ def strategy_attach():
@celery_app.task(name="pms.t0_close") @celery_app.task(name="pms.t0_close")
@guard(trade_day=True) @guard(trade_day=True, respect_exec_halt=False) # 平回是守成收敛动作: 盘中开休假模式
def t0_close(): def t0_close(): # 也不能让当日 T 仓裸奔过夜 (2026-08-28)
"""T 仓强制平回 (14:50): 对挂了做T策略且当日仍有未平腿的票, 立刻发对向平仓腿打平, """T 仓强制平回 (14:50): 对挂了做T策略且当日仍有未平腿的票, 立刻发对向平仓腿打平,
绝不过夜 (设计 § rails)平回后再自证账面 T , 残留的记 WARN 交人工核查 绝不过夜 (设计 § rails)平回后再自证账面 T , 残留的记 WARN 交人工核查
@ -233,7 +279,20 @@ def t0_close():
@guard(trade_day=True, respect_exec_halt=False) # 对账属守成动作, 休假模式下照跑 @guard(trade_day=True, respect_exec_halt=False) # 对账属守成动作, 休假模式下照跑
def daily_settle(): def daily_settle():
from app.services import executor, ledger_service from app.services import executor, ledger_service
# 先把尾窗成交入账 (2026-08-28 审查修): replay_fills 15:05 停跑而这里 15:10 全量对账,
# 15:05~15:10 落进 inbox 的成交若不先消化, 会被对账当数量差异按 RECON 补一次,
# 次日开盘回放又正常入账一次 —— 同一笔账两份。休假模式下 replay 任务被闸住,
# 这一步同样兜住 (入账是守成, 不产生新指令)。
pre = {}
try:
pre = ledger_service.replay_fills()
except Exception as e:
logger.error("[daily_settle] 尾窗成交入账失败 (对账将按差异吸收): %s", e)
out = ledger_service.daily_settle() out = ledger_service.daily_settle()
out.setdefault("steps", {})["pre_replay"] = {
"ok": pre.get("ok"), "actions": pre.get("actions"),
"ws_actions": (pre.get("ws") or {}).get("actions"),
"errors": (pre.get("errors") or [])[:3]}
try: try:
out["steps"]["windows"] = executor.sweep_windows() # 窗口耗尽收口 + 方案成交回写 out["steps"]["windows"] = executor.sweep_windows() # 窗口耗尽收口 + 方案成交回写
except Exception as e: except Exception as e:
@ -258,12 +317,17 @@ def daily_report():
celery_app.conf.beat_schedule = { celery_app.conf.beat_schedule = {
"plan_pull": {"task": "pms.plan_pull", "schedule": crontab(hour=8, minute=40)}, "plan_pull": {"task": "pms.plan_pull", "schedule": crontab(hour=8, minute=40)},
"premarket": {"task": "pms.premarket", "schedule": crontab(hour=8, minute=50)}, "premarket": {"task": "pms.premarket", "schedule": crontab(hour=8, minute=50)},
"command_poll": {"task": "pms.command_poll", "schedule": crontab(minute="*")}, "command_poll": {"task": "pms.command_poll", "schedule": crontab(minute="*"),
"options": {"expires": 50}},
# ws 通道的成交是推过来的, 落 inbox 后没必要再等 5 分钟才入账 —— 改成每分钟。 # ws 通道的成交是推过来的, 落 inbox 后没必要再等 5 分钟才入账 —— 改成每分钟。
# 影子期这一跳基本是空转 (inbox 为空 + 下游无新成交), 成本可以忽略。 # 影子期这一跳基本是空转 (inbox 为空 + 下游无新成交), 成本可以忽略。
"replay_fills": {"task": "pms.replay_fills", "schedule": crontab(minute="*")}, # expires=50: 队列积压时过期的火直接作废, 不叠着补跑 (配合各任务的互斥租约)。
"intraday_exec": {"task": "pms.intraday_exec", "schedule": crontab(minute="*")}, "replay_fills": {"task": "pms.replay_fills", "schedule": crontab(minute="*"),
"signal_digest": {"task": "pms.signal_digest", "schedule": crontab(minute="*")}, "options": {"expires": 50}},
"intraday_exec": {"task": "pms.intraday_exec", "schedule": crontab(minute="*"),
"options": {"expires": 50}},
"signal_digest": {"task": "pms.signal_digest", "schedule": crontab(minute="*"),
"options": {"expires": 50}},
"macro_scan": {"task": "pms.macro_scan", "schedule": crontab(hour=9, minute=35)}, "macro_scan": {"task": "pms.macro_scan", "schedule": crontab(hour=9, minute=35)},
"strategy_attach": {"task": "pms.strategy_attach", "schedule": crontab(hour=9, minute=40)}, "strategy_attach": {"task": "pms.strategy_attach", "schedule": crontab(hour=9, minute=40)},
"t0_close": {"task": "pms.t0_close", "schedule": crontab(hour=14, minute=50)}, "t0_close": {"task": "pms.t0_close", "schedule": crontab(hour=14, minute=50)},

View File

@ -194,8 +194,21 @@ def plan_command(cmd: dict) -> dict:
spec = cs.SPECS.get(cmd_type, {}) spec = cs.SPECS.get(cmd_type, {})
p = cmd.get("params") or {} p = cmd.get("params") or {}
# 认领锁 (2026-08-28 审查修): PENDING→PLANNING 带条件更新, 抢到的那一跳才规划。
# issue() 的同步规划与每分钟的 plan_pending 是两个入口, worker 又是双并发 ——
# 原来无条件置 PLANNING, 两跳同时规划, 输家 insert_plans 撞确定性方案主键后
# 会把这条**健康的**命令误置 CANCELLED。
try: try:
pms_repo.update_command(command_id, status=cs.ST_PLANNING) claimed = pms_repo.update_command(command_id, status=cs.ST_PLANNING,
only_if_status=[cs.ST_PENDING])
if not claimed and str(cmd.get("status")) == cs.ST_PENDING:
# 没抢到 = 另一跳正在 (或刚) 规划同一条命令 —— 静静退出, 不算失败
return {"ok": True, "command_id": command_id,
"status": cs.ST_PLANNING, "plan": {}, "items": [],
"errors": [], "note": "另一跳正在规划该命令, 本跳退出"}
if not claimed and str(cmd.get("status")) != cs.ST_PENDING:
# 传进来的 cmd 已是 PLANNING (issue 刚插入后直调) —— 继续, 但仍由方案主键兜底
pms_repo.update_command(command_id, status=cs.ST_PLANNING)
except Exception as e: except Exception as e:
return {"ok": False, "errors": [f"DB_ERROR: {e}"]} return {"ok": False, "errors": [f"DB_ERROR: {e}"]}
@ -226,6 +239,18 @@ def plan_command(cmd: dict) -> dict:
if rows: if rows:
pms_repo.insert_plans(rows) pms_repo.insert_plans(rows)
except Exception as e: except Exception as e:
# 方案主键是确定性的 (PLAN_{命令}_{序号}): 撞主键说明另一跳已把方案落好了 ——
# 那是收敛不是失败, 绝不能把健康命令置 CANCELLED (2026-08-28 审查修)
try:
existed = pms_repo.list_plans(command_id=command_id)
except Exception:
existed = []
if existed:
logger.warning("[命令] %s 方案已由另一跳落表 (%s 条), 本跳收敛退出",
command_id, len(existed))
return {"ok": True, "command_id": command_id, "status": cs.ST_EXECUTING,
"plan": {}, "items": items, "errors": [],
"note": "方案已由另一跳落表, 本跳收敛"}
logger.exception("方案落表失败 %s", command_id) logger.exception("方案落表失败 %s", command_id)
pms_repo.update_command(command_id, status=cs.ST_CANCELLED, pms_repo.update_command(command_id, status=cs.ST_CANCELLED,
note=f"方案落表失败: {type(e).__name__}: {e}") note=f"方案落表失败: {type(e).__name__}: {e}")
@ -411,9 +436,20 @@ def _adjust_window(p: dict) -> dict:
prog = dict(target.get("progress") or {}) prog = dict(target.get("progress") or {})
prog["deadline"] = str(dl) prog["deadline"] = str(dl)
pms_repo.update_command(target["command_id"], progress=prog) pms_repo.update_command(target["command_id"], progress=prog)
# 已物化的在途指令也要同步 (2026-08-28 审查修): 物化时截止日被冻结进指令自己的
# progress, 配额 / 窗口末日强制完成 / 收口全读那一份 —— 只改方案表的话, 调窗对
# 已经转成指令的单子完全无效, 旧末日照样强制完成或判过期。
n_ins = 0
plan_ids = {pl["plan_id"] for pl in pms_repo.list_plans(command_id=target["command_id"])}
for ins in pms_repo.list_instructions(statuses=list(LIVE_INSTR), limit=500):
if ins.get("origin_type") == "plan" and ins.get("origin_id") in plan_ids:
iprog = dict(ins.get("progress") or {})
iprog["deadline"] = str(dl)
pms_repo.update_instruction(ins["instruction_id"], progress=iprog)
n_ins += 1
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0, "gap": 0.0, return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0, "gap": 0.0,
"notes": [f"命令 {target['command_id']} 窗口调整为 {p['window_tdays']} 交易日 " "notes": [f"命令 {target['command_id']} 窗口调整为 {p['window_tdays']} 交易日 "
f"(截止 {dl})"], "rejects": []} f"(截止 {dl}; 已同步到 {n_ins} 条在途指令)"], "rejects": []}
def _cancel_target(p: dict) -> dict: def _cancel_target(p: dict) -> dict:
@ -424,7 +460,15 @@ def _cancel_target(p: dict) -> dict:
# ================================================================ 撤销与进度 # ================================================================ 撤销与进度
def cancel(command_id: str) -> dict: def cancel(command_id: str) -> dict:
"""撤销在途任务命令: 命令置 CANCELLED, 方案作废, 在途指令撤回 (设计: 在途自主指令自动撤销)。""" """撤销在途任务命令: 方案作废, 在途指令**经 executor 撤回下游**, 全撤成才置 CANCELLED。
2026-08-28 审查修原来这里只把本端指令行标成 CANCELLED 恰是本文件
_cancel_marked_instructions 注释里点名的 2026-07-31 教训的第五条路: ws 模式下
`pms_qmt_order` 的子单没人撤, 券商侧委托继续挂着继续成交, 页面却回"已撤销"
改成逐条走 executor.cancel_instruction (它会发下游撤单, 撤不成不置终态);
有撤不成的, 命令**保持原状态**继续被跟踪, 消息里单独列出来, 用户处理完可再点撤销
"""
from app.services import executor
cmd = pms_repo.get_command(command_id) cmd = pms_repo.get_command(command_id)
if not cmd: if not cmd:
return {"ok": False, "message": f"命令 {command_id} 不存在"} return {"ok": False, "message": f"命令 {command_id} 不存在"}
@ -433,17 +477,36 @@ def cancel(command_id: str) -> dict:
if cmd["status"] not in cs.ACTIVE_TASK_STATES: if cmd["status"] not in cs.ACTIVE_TASK_STATES:
return {"ok": False, "message": f"命令处于 {cmd['status']}, 不可撤销"} return {"ok": False, "message": f"命令处于 {cmd['status']}, 不可撤销"}
plans = pms_repo.list_plans(command_id=command_id, statuses=["PENDING", "EXECUTING"])
n_plan = pms_repo.cancel_plans_of_command(command_id) n_plan = pms_repo.cancel_plans_of_command(command_id)
n_ins = 0 # 匹配在途指令用**全部**方案 (不带状态过滤): 上一次撤销若只撤了一半, 方案已进
plan_ids = {p["plan_id"] for p in plans} # CANCELLED, 按状态过滤会把剩下的在途指令漏成孤儿。
plan_ids = {p["plan_id"] for p in pms_repo.list_plans(command_id=command_id)}
cancelled, failed = [], []
for ins in pms_repo.list_instructions(statuses=list(LIVE_INSTR), limit=500): for ins in pms_repo.list_instructions(statuses=list(LIVE_INSTR), limit=500):
if ins.get("origin_type") == "plan" and ins.get("origin_id") in plan_ids: if ins.get("origin_type") == "plan" and ins.get("origin_id") in plan_ids:
pms_repo.update_instruction(ins["instruction_id"], status="CANCELLED") try:
n_ins += 1 r = executor.cancel_instruction(
ins["instruction_id"], reason=f"撤销命令 {command_id}") or {}
except Exception as e:
r = {"ok": False, "error": f"{type(e).__name__}: {e}"}
if r.get("ok"):
cancelled.append(ins["instruction_id"])
else:
failed.append({"instruction_id": ins["instruction_id"],
"why": r.get("message") or r.get("error") or "下游未受理"})
if failed:
logger.error("[撤销命令] %s: %s 条指令没撤掉, **仍在下游挂着**: %s",
command_id, len(failed), failed)
return {"ok": False, "cancelled": cancelled, "failed": failed,
"message": (f"命令 {command_id} **部分撤销**: 已作废方案 {n_plan} 条、"
f"撤回指令 {len(cancelled)} 条, 但 {len(failed)} 条**没撤掉, "
f"仍在下游挂着**: "
+ "; ".join(f"{x['instruction_id']}({x['why']})" for x in failed[:5])
+ " —— 命令保持在途继续跟踪, 处理后可再次撤销")}
pms_repo.update_command(command_id, status=cs.ST_CANCELLED, done_at=datetime.now()) pms_repo.update_command(command_id, status=cs.ST_CANCELLED, done_at=datetime.now())
return {"ok": True, "message": f"命令 {command_id} 已撤销 (作废方案 {n_plan} 条, " return {"ok": True, "cancelled": cancelled,
f"撤回指令 {n_ins} 条)"} "message": f"命令 {command_id} 已撤销 (作废方案 {n_plan} 条, "
f"撤回指令 {len(cancelled)} 条, 下游已确认)"}
def plan_pending(limit: int = 20) -> dict: def plan_pending(limit: int = 20) -> dict:
@ -503,7 +566,21 @@ def refresh_progress(command_id=None) -> dict:
prog["done_amount"] = round(done_amt, 2) prog["done_amount"] = round(done_amt, 2)
dl = prog.get("deadline") dl = prog.get("deadline")
over = bool(dl) and str(today) > str(dl)[:10] over = bool(dl) and str(today) > str(dl)[:10]
st = cs.settle_task_status(float(prog.get("target_amount") or 0), done_amt, over) # 两条结算口径修正 (2026-08-28 审查):
# ① 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)
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)
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): 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, pms_repo.update_command(c["command_id"], status=st, progress=prog,
done_at=datetime.now() if st == cs.ST_DONE else None) done_at=datetime.now() if st == cs.ST_DONE else None)
@ -866,18 +943,23 @@ def cleanup_exited_positions(limit: int = 500) -> dict:
per_code = {c: {"ts_code": c, "strategies": [], "plans": [], "instructions": [], per_code = {c: {"ts_code": c, "strategies": [], "plans": [], "instructions": [],
"proposals": []} for c in todo} "proposals": []} for c in todo}
err_codes = set() # 本轮有清场动作失败的票: 不进"已清"标记, 下一跳重试 (2026-08-28)
for s in strategies: for s in strategies:
try: try:
r = strategy_service.set_status(s["strategy_id"], "CANCELLED", by="system") r = strategy_service.set_status(s["strategy_id"], "CANCELLED", by="system")
if r.get("ok") or r.get("status"): if r.get("ok") or r.get("status"):
per_code[s["ts_code"]]["strategies"].append(s["strategy_id"]) per_code[s["ts_code"]]["strategies"].append(s["strategy_id"])
else:
err_codes.add(s["ts_code"])
except Exception as e: except Exception as e:
err_codes.add(s.get("ts_code"))
out["errors"].append(f"{s.get('ts_code')} 撤策略失败: {e}") out["errors"].append(f"{s.get('ts_code')} 撤策略失败: {e}")
for pl in plans: for pl in plans:
try: try:
pms_repo.update_plan(pl["plan_id"], status="CANCELLED") pms_repo.update_plan(pl["plan_id"], status="CANCELLED")
per_code[pl["ts_code"]]["plans"].append(pl["plan_id"]) per_code[pl["ts_code"]]["plans"].append(pl["plan_id"])
except Exception as e: except Exception as e:
err_codes.add(pl.get("ts_code"))
out["errors"].append(f"{pl.get('ts_code')} 撤建仓方案失败: {e}") out["errors"].append(f"{pl.get('ts_code')} 撤建仓方案失败: {e}")
for ins in buys: for ins in buys:
try: try:
@ -885,14 +967,17 @@ def cleanup_exited_positions(limit: int = 500) -> dict:
if r.get("ok"): if r.get("ok"):
per_code[ins["ts_code"]]["instructions"].append(ins["instruction_id"]) per_code[ins["ts_code"]]["instructions"].append(ins["instruction_id"])
else: else:
err_codes.add(ins.get("ts_code"))
out["errors"].append(f"{ins.get('ts_code')} 撤买单未成: {r.get('message') or r.get('error')}") out["errors"].append(f"{ins.get('ts_code')} 撤买单未成: {r.get('message') or r.get('error')}")
except Exception as e: except Exception as e:
err_codes.add(ins.get("ts_code"))
out["errors"].append(f"{ins.get('ts_code')} 撤买单失败: {e}") out["errors"].append(f"{ins.get('ts_code')} 撤买单失败: {e}")
for pr in props: for pr in props:
try: try:
if pms_repo.decide_proposal(pr["proposal_id"], "DECLINED"): if pms_repo.decide_proposal(pr["proposal_id"], "DECLINED"):
per_code[pr["ts_code"]]["proposals"].append(pr["proposal_id"]) per_code[pr["ts_code"]]["proposals"].append(pr["proposal_id"])
except Exception as e: except Exception as e:
err_codes.add(pr.get("ts_code"))
out["errors"].append(f"{pr.get('ts_code')} 撤提议失败: {e}") out["errors"].append(f"{pr.get('ts_code')} 撤提议失败: {e}")
for c, acted in per_code.items(): for c, acted in per_code.items():
@ -907,5 +992,7 @@ def cleanup_exited_positions(limit: int = 500) -> dict:
logger.warning("[清场] %s 清仓完成, 已撤 策略%d/方案%d/买单%d/提议%d", logger.warning("[清场] %s 清仓完成, 已撤 策略%d/方案%d/买单%d/提议%d",
c, len(acted["strategies"]), len(acted["plans"]), c, len(acted["strategies"]), len(acted["plans"]),
len(acted["instructions"]), len(acted["proposals"])) len(acted["instructions"]), len(acted["proposals"]))
_save_cleanup_done(new_done) # 标记本轮闭仓集 (含刚清过的), 只清一次 # 有清场动作失败的票**不进**"已清"标记 —— 原来失败也照标, 残留的策略/买单在该
# 闭仓周期内永不重试 (2026-08-28 审查修)。err_codes 留在标记外, 下一跳重清。
_save_cleanup_done(set(new_done) - {c for c in err_codes if c})
return out return out

View File

@ -63,8 +63,14 @@ DEFAULT_STALE_SEC = 15
def mode() -> str: def mode() -> str:
m = (param_store.get("PMS_DISPATCH_MODE", MODE_SHADOW) or MODE_SHADOW).strip() raw = (param_store.get("PMS_DISPATCH_MODE", MODE_SHADOW) or MODE_SHADOW).strip()
return m if m in MODES else MODE_SHADOW m = raw.lower() # 大小写归一: 手改参数写成 "WS"/"Ws" 不该静默退回影子模式
if m in MODES:
return m
# 非法值退安全侧 (shadow), 但必须吭声 —— "以为在实盘, 实际一单没发"比报错糟得多
logger.error("[下发] PMS_DISPATCH_MODE=%r 不是合法模式 %s, 已按 shadow (影子) 处理 —— "
"指令只记账不下发, 请到参数页改回合法值", raw, sorted(MODES))
return MODE_SHADOW
# ================================================================ 通道健康 # ================================================================ 通道健康

View File

@ -18,10 +18,13 @@ from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
import hashlib
from app.core import command_spec as cs from app.core import command_spec as cs
from app.core import exec_timing as et from app.core import exec_timing as et
from app.core import rule_gate from app.core import rule_gate
from app.core import tradedays as td from app.core import tradedays as td
from app.core.sizer import lot_of
from app.repo import pms_repo, qmt_repo from app.repo import pms_repo, qmt_repo
from app.services import (command_service, dispatcher, exec_advisor, industry, market, from app.services import (command_service, dispatcher, exec_advisor, industry, market,
param_store, portfolio) param_store, portfolio)
@ -69,7 +72,6 @@ def materialize_plans(limit: int = 100) -> dict:
return out return out
cmd_cache = {} cmd_cache = {}
ymd = td.ymd() ymd = td.ymd()
seq = 0
for p in plans: for p in plans:
act = p.get("action") act = p.get("action")
if act == "HALT": # 撤单类在规划期已执行完毕 if act == "HALT": # 撤单类在规划期已执行完毕
@ -92,8 +94,11 @@ def materialize_plans(limit: int = 100) -> dict:
if qty <= 0: if qty <= 0:
out["skipped"].append({"plan_id": p["plan_id"], "why": "数量为 0"}) out["skipped"].append({"plan_id": p["plan_id"], "why": "数量为 0"})
continue continue
seq += 1 # 指令编号由方案编号**确定性派生** (2026-08-28 审查修)。原来是"本次扫描内的序号",
iid = cs.make_instruction_id(ymd, p["ts_code"], act, seq) # 同日同股同动作第二条命令的方案会撞出同一个编号 → 唯一键冲突 → 方案每分钟重算
# 同一个编号再撞, 永久卡死。派生编号还顺手拿到真幂等: 两跳并发扫到同一条方案时
# 生成同一个编号, 唯一键把后来者挡下, 由下面的冲突处理把方案标 EXECUTING 收敛。
iid = _plan_iid(ymd, p["ts_code"], act, p["plan_id"])
window = int((cmd.get("progress") or {}).get("window_tdays") window = int((cmd.get("progress") or {}).get("window_tdays")
or param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3)) or param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3))
prog0 = {"deadline": str(p.get("deadline") or ""), "command_id": cid, prog0 = {"deadline": str(p.get("deadline") or ""), "command_id": cid,
@ -112,11 +117,31 @@ def materialize_plans(limit: int = 100) -> dict:
pms_repo.update_plan(p["plan_id"], status=PLAN_EXEC) pms_repo.update_plan(p["plan_id"], status=PLAN_EXEC)
out["created"].append(iid) out["created"].append(iid)
except Exception as e: except Exception as e:
# 编号撞唯一键: 先看撞的是不是**同一条方案**已生成过的指令 (并发扫描 / 上次
# update_plan 没写上) —— 是就把方案收敛到 EXECUTING 完事, 不算错。
try:
exist = pms_repo.get_instruction(iid)
except Exception:
exist = None
if exist and exist.get("origin_id") == p["plan_id"]:
pms_repo.update_plan(p["plan_id"], status=PLAN_EXEC)
out["skipped"].append({"plan_id": p["plan_id"],
"why": f"指令 {iid} 已存在 (并发扫描/重试收敛), 方案已标 EXECUTING"})
continue
logger.exception("方案转指令失败 %s", p["plan_id"]) logger.exception("方案转指令失败 %s", p["plan_id"])
out["errors"].append(f"{p['plan_id']}: {type(e).__name__}: {e}") out["errors"].append(f"{p['plan_id']}: {type(e).__name__}: {e}")
return out return out
def _plan_iid(ymd: int, ts_code: str, action: str, plan_id: str) -> str:
"""方案 → 指令编号 (确定性): INS_{ymd}_{代码}_{动作}_{方案id 的 md5 前 6 位}。
前缀保持 INS_{ymd}_ 不变 页面今日过滤认这个前缀md5 后缀让编号只由方案决定:
同一条方案永远同一个编号 (天然幂等), 不同方案哪怕同日同股同动作也不同号"""
h = hashlib.md5(str(plan_id).encode("utf-8")).hexdigest()[:6]
return f"INS_{ymd}_{ts_code.replace('.', '')}_{action}_{h}"
# ================================================================ 出手 tick # ================================================================ 出手 tick
def run_tick(*, now=None, dry_run: bool = False) -> dict: def run_tick(*, now=None, dry_run: bool = False) -> dict:
"""盘中每分钟一跳。dry_run=True 时只算不发不落库 (页面「试算」用)。""" """盘中每分钟一跳。dry_run=True 时只算不发不落库 (页面「试算」用)。"""
@ -161,7 +186,7 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict:
try: try:
day = market.day_snapshot(code) day = market.day_snapshot(code)
pos = _pos_of(view, code) pos = _pos_of(view, code)
quota = et.daily_quota(remaining, tdays_left, quota = et.daily_quota(remaining, tdays_left, lot=lot_of(code),
allow_odd_tail=(ins.get("action") == "EXIT")) allow_odd_tail=(ins.get("action") == "EXIT"))
# 当日配额的「今日已投放量」口径分两种: # 当日配额的「今日已投放量」口径分两种:
# 普通多日单 —— _consumed_today (今日已成交 + 今日在途), 给多日出手节流用。 # 普通多日单 —— _consumed_today (今日已成交 + 今日在途), 给多日出手节流用。
@ -234,11 +259,25 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict:
continue continue
ma5 = market.get_ma5(code) if side == "buy" else None ma5 = market.get_ma5(code) if side == "buy" else None
# 新建仓 (从没持仓过的票) 的组合上下文必须显式带 is_new_name 与行业名:
# caps_ctx 在 view 里找不到该票时带不出行业, 行业集中度终检会静默跳过
# (与 proposal_service._route_one 已修过的同一个洞, 2026-08-28 补齐执行终检侧)
caps = None
if side == "buy":
is_new = int(pos.get("total_qty") or 0) <= 0
sec = None
if is_new:
try:
sec = industry.get(code)
except Exception:
sec = None
caps = portfolio.caps_ctx(view, ts_code=code, is_new_name=is_new, sector=sec) \
if is_new else portfolio.caps_ctx(view, ts_code=code)
gate = rule_gate.check( gate = rule_gate.check(
side=side, action=ins.get("action"), qty=qty, price=day_ctx.get("price"), side=side, action=ins.get("action"), qty=qty, price=day_ctx.get("price"),
ctx={"ts_code": code, "position": pos, "day": {**day_ctx, "ma5": ma5}, ctx={"ts_code": code, "position": pos, "day": {**day_ctx, "ma5": ma5},
"params": {**exec_prm, "sector_source_ready": view["sector_ready"]}, "params": {**exec_prm, "sector_source_ready": view["sector_ready"]},
"caps": portfolio.caps_ctx(view, ts_code=code) if side == "buy" else None, "caps": caps,
"flags": {"buy_halt": prm["buy_halt"], "exec_halt": prm["exec_halt"], "flags": {"buy_halt": prm["buy_halt"], "exec_halt": prm["exec_halt"],
"brake_active": brake_active, "brake_active": brake_active,
"blacklisted": bool(stock_params.get(code, {}).get("black")), "blacklisted": bool(stock_params.get(code, {}).get("black")),
@ -250,11 +289,21 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict:
out["rejected"].append({"instruction_id": ins["instruction_id"], "code": code, out["rejected"].append({"instruction_id": ins["instruction_id"], "code": code,
"failed": gate["failed"]}) "failed": gate["failed"]})
if not dry_run: if not dry_run:
pms_repo.insert_ledger( # 终检拒绝在账本里记**一天一条同因** (2026-08-28 审查修, 与上面
ts_code=code, action=ins.get("action"), arbiter="rule", verdict="REJECT", # ref_drift 的口径一致): 这一跳每分钟一次, 一条持续被拒的指令一天能
price_at=day_ctx.get("price") or 0, hard_numbers=gate["hard_numbers"], # 往账本灌几百行一模一样的记录, 把判分锚淹掉 —— 正是 2026-07-29
failed_checks=gate["failed"], ref_id=ins["instruction_id"], # rule_rejected_today 那次事故的形状。当下状态看 progress.last_decision。
reason="规则闸终检未通过 (宁可不动)") rej_key = ";".join(sorted(x.split(":")[0] for x in gate["failed"]))[:120]
logged = prog.get("gate_reject_logged") or {}
if not (int(logged.get("ymd") or 0) == ymd_today
and logged.get("key") == rej_key):
pms_repo.insert_ledger(
ts_code=code, action=ins.get("action"), arbiter="rule",
verdict="REJECT", price_at=day_ctx.get("price") or 0,
hard_numbers=gate["hard_numbers"],
failed_checks=gate["failed"], ref_id=ins["instruction_id"],
reason="规则闸终检未通过 (宁可不动; 同因当日只记一条)")
prog["gate_reject_logged"] = {"ymd": ymd_today, "key": rej_key}
prog["last_decision"] = {"at": now.strftime("%H:%M"), "action": "REJECT", prog["last_decision"] = {"at": now.strftime("%H:%M"), "action": "REJECT",
"reason": "; ".join(gate["failed"])} "reason": "; ".join(gate["failed"])}
pms_repo.update_instruction(ins["instruction_id"], progress=prog) pms_repo.update_instruction(ins["instruction_id"], progress=prog)
@ -389,6 +438,12 @@ def cancel_instruction(instruction_id: str, reason: str = "页面人工撤销")
"message": f"指令 {instruction_id} **未能撤销**: " "message": f"指令 {instruction_id} **未能撤销**: "
f"{r.get('error') or '下游未受理'} —— 指令仍在途, " f"{r.get('error') or '下游未受理'} —— 指令仍在途, "
f"请在 QMT 侧确认该委托是否还挂着"} f"请在 QMT 侧确认该委托是否还挂着"}
# 进终态前把成交进度回写方案 (与 ledger._settle_instruction 同一条纪律, 2026-08-28)
if ins.get("origin_type") == "plan" and ins.get("origin_id"):
try:
pms_repo.update_plan(ins["origin_id"], filled_qty=int(ins.get("exec_qty") or 0))
except Exception as e:
logger.warning("撤销指令进度回写失败 %s: %s", instruction_id, e)
pms_repo.update_instruction(instruction_id, status=ST_CANCELLED) pms_repo.update_instruction(instruction_id, status=ST_CANCELLED)
pms_repo.insert_ledger(ts_code=ins["ts_code"], action=ins.get("action"), arbiter="user", pms_repo.insert_ledger(ts_code=ins["ts_code"], action=ins.get("action"), arbiter="user",
verdict="REJECT", price_at=0, ref_id=instruction_id, reason=reason) verdict="REJECT", price_at=0, ref_id=instruction_id, reason=reason)

View File

@ -213,10 +213,15 @@ def _hybk_many(codes) -> dict:
try: try:
got = industry_repo.primary_industry_map(miss, level=level()) got = industry_repo.primary_industry_map(miss, level=level())
except Exception as e: except Exception as e:
logger.warning("gp_hybk 批量查询失败 [%d 只]: %s", len(miss), e) # **查询失败不写缓存** (2026-08-28 审查修): "查不到"(合法的无行业) 与
got = {} # "查询失败"(临时故障) 不能进同一个按日缓存 —— 原来开盘第一跳库抖一次,
# 全部票的行业被缓存成 None 一整天, 行业集中度硬拦截静默失效, 而 ready()
# 走独立探测、故障恢复后照样报就绪。失败这轮按 None 返回, 下一跳重查。
logger.warning("gp_hybk 批量查询失败 [%d 只] (本轮不写缓存, 下一跳重查): %s",
len(miss), e)
return {c: _hybk["data"].get(c) for c in (codes or [])}
for c in miss: for c in miss:
_hybk["data"][c] = got.get(c) # 查不到也缓存 None, 免得每跳都重查 _hybk["data"][c] = got.get(c) # 查得到才缓存; 合法的"无行业"也缓存 None
return {c: _hybk["data"].get(c) for c in (codes or [])} return {c: _hybk["data"].get(c) for c in (codes or [])}

View File

@ -39,31 +39,78 @@ LIVE_INSTR = ("DISPATCHED", "JUDGE_PASSED", "RULE_PASSED")
# ================================================================ 回放 # ================================================================ 回放
def _seed_cursor(out: dict) -> dict: # 游标格式 (2026-08-28 起, v2): {"v":2, "t":"YYYY-MM-DD HH:MM:SS", "seen":{order_id: 时间}}
"""首次回放: 把游标对齐到当前最新成交, **不追认历史**。 # t 已入账成交里最新的成交时间; 每跳按 t重叠窗 拉取, 晚到的记录也逃不掉
# seen 重叠窗内已入账的 order_id, 用来去重 (order_id 不是时间序, 不能当增量游标 ——
# 它形如 SELL_601956.SH_1778549400, 字典序推进会把 BUY_* 永久漏掉, 这正是
# v1 游标的病根, 2026-08-28 审查修)
_REPLAY_OVERLAP_SEC = 24 * 3600 # 回看重叠窗: 晚到/漏拉的成交在这个窗内都能补上
_REPLAY_PRUNE_SEC = 2 * 24 * 3600 # seen 集合保留时长 (须大于重叠窗)
def _cursor_load(raw):
"""游标解析。返回 (state, note): state=None 表示要冷启动播种。
兼容三种旧值: =冷启动; ALL=从头全量; order_id 字符串=按冷启动迁移 (不追认历史)"""
import json as _json
s = str(raw or "").strip()
if not s:
return None, ""
if s.upper() == CURSOR_ALL:
return {"v": 2, "t": "1970-01-01 00:00:00", "seen": {}}, "显式要求从头全量回放"
if s.startswith("{"):
try:
d = _json.loads(s)
if isinstance(d, dict) and d.get("t"):
return {"v": 2, "t": str(d["t"]), "seen": dict(d.get("seen") or {})}, ""
except Exception:
pass
# 旧格式 (v1 的 order_id 字符串): 字典序游标本就不可靠, 迁移为"从现在起跟踪"
return None, f"旧游标 {s[:40]} 已废弃 (字典序不可靠), 按冷启动重新对齐"
def _cursor_save(state: dict, out: dict):
import json as _json
try:
w = pms_repo.set_param(CURSOR_KEY, _json.dumps(state, ensure_ascii=False), "system")
out["cursor"] = state["t"]
return w
except Exception as e:
out["errors"].append(f"游标写入失败: {type(e).__name__}: {e}")
return None
def _fill_key(f: dict) -> str:
oid = f.get("order_id")
if oid not in (None, ""):
return str(oid)
# 没有 order_id 的极端脏数据: 用内容合成键, 保证同一条不重复入账
return f"~{f.get('ts_code')}|{f.get('side')}|{f.get('qty')}|{f.get('price')}|{f.get('done_time')}"
def _seed_cursor(out: dict, note_extra: str = "") -> dict:
"""首次回放: 把时间游标对齐到当前最新成交, **不追认历史**。
`trading_order` 里躺着旧系统多年的成交记录PMS 刚上线时一条在途指令都没有, 若从头 `trading_order` 里躺着旧系统多年的成交记录PMS 刚上线时一条在途指令都没有, 若从头
回放, 每一条历史成交都会被判成外部成交并入 BASE 批次 账本上凭空长出一堆早就 回放, 每一条历史成交都会被判成外部成交并入 BASE 批次 账本上凭空长出一堆早就
清掉的持仓, 摊薄成本与安全垫全错, 日志还刷几百条 EXTERNAL_FILL 告警而安全垫是补仓 清掉的持仓, 摊薄成本与安全垫全错**账本必须从干净的起点开始**
盈利加仓保垫减仓共同的判断依据, 它一错整条纪律链跟着错**账本必须从干净的起点开始**
要补历史有两条路 (页面参数设置 PMS_REPLAY_CURSOR): 要补历史: 页面把 PMS_REPLAY_CURSOR 改成 ALL (从头全量), 或改成
* 设成某个 order_id 从它之后开始回放 {"v":2,"t":"YYYY-MM-DD HH:MM:SS","seen":{}} 从指定时刻开始
* 设成 ALL 从头全量回放
""" """
try: try:
anchor = downstream_repo.latest_filled_order_id() anchor = downstream_repo.latest_filled_time()
except Exception as e: except Exception as e:
out.update({"ok": False, "errors": [f"读下游最新成交失败: {type(e).__name__}: {e}"]}) out.update({"ok": False, "errors": [f"读下游最新成交失败: {type(e).__name__}: {e}"]})
return out return out
if not anchor: if not anchor:
out["note"] = "下游暂无已成交单, 游标待下次再对齐" out["note"] = "下游暂无已成交单, 游标待下次再对齐"
return out return out
pms_repo.set_param(CURSOR_KEY, anchor, updated_by="system") state = {"v": 2, "t": str(anchor)[:19], "seen": {}}
out.update({"cursor": anchor, "seeded": True}) _cursor_save(state, out)
out["note"] = (f"首次回放: 游标已对齐到当前最新成交 {anchor}, **不追认历史** —— " out.update({"seeded": True})
f"账本从现在起跟踪。需要补历史请在页面把 PMS_REPLAY_CURSOR 改成某个 " out["note"] = ((note_extra + "; ") if note_extra else "") + \
f"order_id (从它之后开始) 或 {CURSOR_ALL} (从头全量)") (f"首次回放: 时间游标已对齐到最新成交时刻 {state['t']}, **不追认历史** —— "
f"账本从现在起跟踪。需要补历史请把 PMS_REPLAY_CURSOR 改成 {CURSOR_ALL}")
logger.warning("[replay_fills] %s", out["note"]) logger.warning("[replay_fills] %s", out["note"])
return out return out
@ -93,7 +140,7 @@ def consume_ws_trades(*, limit: int = 500) -> dict:
return out return out
# ---- 三分流: 联调单 / 孤儿成交 / 真单。判据只查**我们自己的出口表** # ---- 三分流: 联调单 / 孤儿成交 / 真单。判据只查**我们自己的出口表**
# (pms_qmt_order.parent_instruction_id 以 SMOKE_ 开头), 不看对端字段 —— 对端只回子 # (pms_qmt_order.parent_id 以 SMOKE_ 开头), 不看对端字段 —— 对端只回子
# instruction_id, 分不出联调还是真单; 出口表是本端写的, 骗不了自己。 # instruction_id, 分不出联调还是真单; 出口表是本端写的, 骗不了自己。
# 联调单标 processed=2 (已消化) 而不是 1 (已入账): 两者语义不同, 事后翻 inbox 一眼能看出 # 联调单标 processed=2 (已消化) 而不是 1 (已入账): 两者语义不同, 事后翻 inbox 一眼能看出
# 这笔是被有意跳过的, 而不是入账入丢了。见 qmt_repo.SMOKE_PREFIX 与 PROC_* 的注释。 # 这笔是被有意跳过的, 而不是入账入丢了。见 qmt_repo.SMOKE_PREFIX 与 PROC_* 的注释。
@ -124,7 +171,11 @@ def consume_ws_trades(*, limit: int = 500) -> dict:
continue continue
if od is None: if od is None:
orphan.append(r) orphan.append(r)
elif qmt_repo.is_smoke(od.get("parent_instruction_id")): elif qmt_repo.is_smoke(od.get("parent_id")):
# 出口表的真实列名是 parent_id (ddl_pms_v1.sql / qmt_repo.enqueue_order)。
# 2026-08-28 审查发现这里原来读的是不存在的 parent_instruction_id, 恒取到
# None → is_smoke 恒 False → 联调单全部按真单入账, 这道闸从未生效过;
# 单测没抓住是因为 test_wiring 的桩把键名也造错了 (已一并改对)。
smoke.append(r) smoke.append(r)
else: else:
real.append(r) real.append(r)
@ -169,41 +220,72 @@ def consume_ws_trades(*, limit: int = 500) -> dict:
out["errors"].append(f"读指令 {pid} 失败: {type(e).__name__}: {e}") out["errors"].append(f"读指令 {pid} 失败: {type(e).__name__}: {e}")
mapped = rc.map_trades_to_book(trades, parent_actions=parent_actions) mapped = rc.map_trades_to_book(trades, parent_actions=parent_actions)
done = []
# ---- 逐笔入账、逐笔标记 (2026-08-28 审查修) ----
# 原来是"整批全有或全无"地标记: 批内任何一笔出错, 已经成功入账的那几笔也留在
# processed=0, 下一分钟整批重来 —— 而 _apply_action 没有按成交号防重的能力
# (原注释声称的"幂等由 trade_no 兜着"只存在于 inbox 收件层, 挡的是重复插行,
# 不挡同一行被重复消费), 重来一遍就是持仓翻倍、成本全错。
# 改成: 每笔成交 = 入账 + 对应费用 + 立刻把**这一条** inbox 行标 processed=1。
# 出错的那笔留在 0 下一跳单独重试, 成功的绝不重复。费用另有 (kind, trade_no)
# 唯一键兜底, 重复插入会被拒, 这里按"已入过即成功"处理。
ymd = td.ymd()
fees_by_no = {}
for f in mapped["fees"]:
fees_by_no.setdefault(f.get("trade_no"), []).append(f)
booked_codes, booked_seqs = set(), []
for act in mapped["actions"]: for act in mapped["actions"]:
seq = act.get("seq")
try: try:
_apply_action(act) _apply_action(act)
out["actions"] += 1 out["actions"] += 1
except Exception as e: except Exception as e:
logger.exception("ws 成交入账失败 %s", act) logger.exception("ws 成交入账失败 %s", act)
out["errors"].append(f"{act.get('ts_code')} 入账失败: {type(e).__name__}: {e}") out["errors"].append(f"{act.get('ts_code')} 入账失败: {type(e).__name__}: {e}")
ymd = td.ymd() continue # 这笔留在 processed=0, 下一跳单独重试
for f in mapped["fees"]: booked_codes.add(act["ts_code"])
try: for f in fees_by_no.get(act.get("order_id")) or []:
pms_repo.insert_cash_flow(ymd=ymd, kind=CF_FEE, amount=f["amount"], try:
ts_code=f["ts_code"], estimated=f["estimated"], pms_repo.insert_cash_flow(ymd=ymd, kind=CF_FEE, amount=f["amount"],
trade_no=f["trade_no"], ts_code=f["ts_code"], estimated=f["estimated"],
instruction_id=f["instruction_id"], note=f["note"]) trade_no=f["trade_no"],
out["fees"] += 1 instruction_id=f["instruction_id"], note=f["note"])
except Exception as e: out["fees"] += 1
out["errors"].append(f"费用入账失败 {f.get('trade_no')}: {type(e).__name__}: {e}") except Exception as e:
# (kind, trade_no) 唯一键: 重复插入被拒 = 上一跳已入过, 不算错
if "uplicate" in str(e) or "唯一" in str(e):
out["fees"] += 1
else:
out["errors"].append(f"费用入账失败 {f.get('trade_no')}: "
f"{type(e).__name__}: {e}")
if seq is not None:
try:
qmt_repo.inbox_mark([seq], processed=qmt_repo.PROC_BOOKED, note="ws 成交已入账")
booked_seqs.append(seq)
except Exception as e:
# 账已入、标记没写上: 这一笔存在重复入账风险, 必须吼出来待人工 ——
# 但不能因此把后面的成交也卡住
out["errors"].append(f"seq={seq} 已入账但 inbox 标记失败 "
f"(**下一跳可能重复入账, 请人工核对**): "
f"{type(e).__name__}: {e}")
logger.error("[ws 入账] seq=%s 已入账但标记失败 —— 重复入账风险: %s", seq, e)
for code in {a["ts_code"] for a in mapped["actions"]}: # 字段不完整被跳过的坏行: 标 processed=2 (已消化不入账), 免得每跳重扫; 告警里已留痕
action_seqs = {a.get("seq") for a in mapped["actions"]}
bad_seqs = [s for s in mapped["seqs"] if s is not None and s not in action_seqs]
if bad_seqs:
try:
qmt_repo.inbox_mark(bad_seqs, processed=qmt_repo.PROC_DIGESTED,
note="成交字段不完整, 已跳过不入账 (见告警)")
except Exception as e:
out["errors"].append(f"坏行标记失败: {type(e).__name__}: {e}")
for code in booked_codes:
try: try:
recompute_position(code) recompute_position(code)
except Exception as e: except Exception as e:
# 成本重算失败只影响派生字段, 下一笔入账或日终会再算 —— 不留着成交行重复入账
out["errors"].append(f"{code} 成本重算失败: {e}") out["errors"].append(f"{code} 成本重算失败: {e}")
# 只有全程无错才标已入账 —— 有错就留在 processed=0, 下一跳重试。
# 重试是安全的: _apply_action 幂等由 trade_no 兜着 (inbox 那层已按 trade_no 去过重)。
if not out["errors"]:
done = [s for s in mapped["seqs"] if s is not None]
if done:
try:
qmt_repo.inbox_mark(done, processed=qmt_repo.PROC_BOOKED,
note="ws 成交已入账")
except Exception as e:
out["errors"].append(f"inbox 标记失败: {type(e).__name__}: {e}")
# extend 而不是赋值 —— 上面孤儿成交的告警已经在 out["alerts"] 里了, 直接赋值会把它冲掉, # extend 而不是赋值 —— 上面孤儿成交的告警已经在 out["alerts"] 里了, 直接赋值会把它冲掉,
# 于是"挂起了一笔账"这件事只剩日志里一行, 页面和调度返回值里全看不见。 # 于是"挂起了一笔账"这件事只剩日志里一行, 页面和调度返回值里全看不见。
out["alerts"].extend(mapped["alerts"]) out["alerts"].extend(mapped["alerts"])
@ -236,10 +318,17 @@ def calibrate_fees(*, ymd=None, actual_fee=None) -> dict:
"暂不校准 —— 费用不进成本, 晚校准无风险") "暂不校准 —— 费用不进成本, 晚校准无风险")
return out return out
actual = -abs(float(actual_fee)) actual = -abs(float(actual_fee))
diff = round(actual - out["estimated"], 2) # 幂等 (2026-08-28 审查修): 差额要扣掉当日**已插过的校准流水**, 否则同一天带
# actual_fee 跑两次 (人工点两下 / 日终重跑) 会把同一笔差额平两遍, 现金账翻倍偏。
try:
prior = round(pms_repo.sum_cash_flow(ymd, CF_CALIBRATE), 2)
except Exception:
prior = 0.0
out["prior_calibrated"] = prior
diff = round(actual - out["estimated"] - prior, 2)
out["actual"] = actual out["actual"] = actual
if abs(diff) < 0.01: if abs(diff) < 0.01:
out["note"] = "估算与实际一致, 无需校准" out["note"] = "估算与实际一致 (含已校准部分), 无需校准"
return out return out
pms_repo.insert_cash_flow( pms_repo.insert_cash_flow(
ymd=ymd, kind=CF_CALIBRATE, amount=diff, estimated=0, trade_no=None, ymd=ymd, kind=CF_CALIBRATE, amount=diff, estimated=0, trade_no=None,
@ -260,19 +349,30 @@ def replay_fills(*, limit: int = 500) -> dict:
out["ws"] = consume_ws_trades(limit=limit) out["ws"] = consume_ws_trades(limit=limit)
if out["ws"].get("errors"): if out["ws"].get("errors"):
out["errors"].extend(out["ws"]["errors"]) out["errors"].extend(out["ws"]["errors"])
cursor = pms_repo.get_param(CURSOR_KEY) state, note = _cursor_load(pms_repo.get_param(CURSOR_KEY))
if not cursor: # 从未设过 (或被清空) —— 冷启动, 只对齐游标不入账 if state is None: # 从未设过 / 旧格式 —— 冷启动, 只对齐游标不入账
return _seed_cursor(out) r = _seed_cursor(out, note_extra=note)
if str(cursor).strip().upper() == CURSOR_ALL: r["ok"] = not r.get("errors")
cursor = None # 显式要求从头全量回放 return r
since = str(state["t"])
try: try:
fills = downstream_repo.fetch_filled_orders(since_id=cursor, limit=limit) from datetime import datetime as _dt
t0 = _dt.strptime(since[:19], "%Y-%m-%d %H:%M:%S")
since = (t0 - timedelta(seconds=_REPLAY_OVERLAP_SEC)).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
pass # t 解析不了就按原值拉 (只可能多拉, seen 兜住去重)
try:
fills = downstream_repo.fetch_filled_orders(since_time=since, limit=limit)
except Exception as e: except Exception as e:
out.update({"ok": False, "errors": [f"读 trading_order 失败: {type(e).__name__}: {e}"]}) out.update({"ok": False, "errors": [f"读 trading_order 失败: {type(e).__name__}: {e}"]})
return out return out
out["fills"] = len(fills) seen = state["seen"]
if not fills: fresh = [f for f in fills if _fill_key(f) not in seen]
out["cursor"] = cursor out["fills"] = len(fresh)
if not fresh:
out["cursor"] = state["t"]
out["ok"] = not out["errors"] # 早退也要按 errors 定 ok (ws 那半的错不能吞)
return out return out
try: try:
@ -281,28 +381,64 @@ def replay_fills(*, limit: int = 500) -> dict:
instrs = [] instrs = []
out["errors"].append(f"读在途指令失败(按外部成交处理): {e}") out["errors"].append(f"读在途指令失败(按外部成交处理): {e}")
mapped = rc.map_fills_to_book(fills, instrs) # ---- 逐单入账、逐单进 seen (2026-08-28 审查修) ----
# 一张成交单可能拆成多条账本动作 (认领到多条指令 + 外部余量), 同一单的动作要么全成
# 要么整单下一跳重来; 成功的单立刻进 seen, 失败的不进 —— 与 ws 路同一口径:
# 错了重试, 成功不重复。原来是"单笔失败也推游标" = 那笔永久漏账, 方向反了。
mapped = rc.map_fills_to_book(fresh, instrs)
by_key = {}
for act in mapped["actions"]: for act in mapped["actions"]:
try: by_key.setdefault(str(act.get("order_id")), []).append(act)
_apply_action(act) time_by_key = {_fill_key(f): str(f.get("done_time") or "")[:19] for f in fresh}
out["actions"] += 1 booked_codes = set()
except Exception as e: t_max = str(state["t"])
logger.exception("入账失败 %s", act) for key, acts in by_key.items():
out["errors"].append(f"{act.get('ts_code')} 入账失败: {type(e).__name__}: {e}") ok_acts = 0
err = None
for act in acts:
try:
_apply_action(act)
ok_acts += 1
except Exception as e:
err = e
break
ft = time_by_key.get(key) or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if err is not None and ok_acts == 0:
# 一条都没入: 不进 seen, 重叠窗内下一跳整单重试
logger.exception("入账失败 order_id=%s", key)
out["errors"].append(f"order_id={key} 入账失败 (下一跳重试): "
f"{type(err).__name__}: {err}")
continue
if err is not None:
# 部分入账: **不能重试** (已入的那部分没有防重能力, 重来会双记)。
# 进 seen 防双记, 差额交日终对账按数量补 —— 并大声留痕待人工。
out["errors"].append(f"order_id={key} 部分入账 ({ok_acts}/{len(acts)} 条动作), "
f"余下差额待日终对账修正: {type(err).__name__}: {err}")
logger.error("[回放] order_id=%s 部分入账 (%s/%s) —— 已进 seen 防双记, "
"差额待对账", key, ok_acts, len(acts))
out["actions"] += ok_acts
booked_codes.update(a["ts_code"] for a in acts[:ok_acts])
seen[key] = ft
if ft > t_max:
t_max = ft
out["alerts"].extend(mapped["alerts"]) # 与 ws 那批告警合并, 日报一处看全 out["alerts"].extend(mapped["alerts"]) # 与 ws 那批告警合并, 日报一处看全
for code in {a["ts_code"] for a in mapped["actions"]}: for code in booked_codes:
try: try:
recompute_position(code) recompute_position(code)
except Exception as e: except Exception as e:
out["errors"].append(f"{code} 成本重算失败: {e}") out["errors"].append(f"{code} 成本重算失败: {e}")
# 游标推进 + seen 修剪 (只留重叠窗覆盖得到的部分, 防止无限膨胀)
state["t"] = t_max
try: try:
nc = rc.next_cursor(fills, cursor) from datetime import datetime as _dt
pms_repo.set_param(CURSOR_KEY, nc, "system") cut = (_dt.strptime(t_max[:19], "%Y-%m-%d %H:%M:%S")
out["cursor"] = nc - timedelta(seconds=_REPLAY_PRUNE_SEC)).strftime("%Y-%m-%d %H:%M:%S")
except Exception as e: state["seen"] = {k: v for k, v in seen.items() if str(v) >= cut}
out["errors"].append(f"游标推进失败: {e}") except Exception:
state["seen"] = seen
_cursor_save(state, out)
out["ok"] = not out["errors"] out["ok"] = not out["errors"]
for a in out["alerts"]: for a in out["alerts"]:
logger.warning("[回放告警] %s", a.get("message")) logger.warning("[回放告警] %s", a.get("message"))
@ -368,9 +504,26 @@ def _apply_action(act: dict):
def _settle_instruction(instruction_id: str): def _settle_instruction(instruction_id: str):
"""成交量达到指令数量 → 置 CONFIRMED (部分成交保持在途, 由窗口/过期规则收口)。""" """成交量达到指令数量 → 置 CONFIRMED (部分成交保持在途, 由窗口/过期规则收口)。
**每笔成交都把进度回写方案** (2026-08-28 审查修): 方案的 filled_qty 原来只有
sweep_windows 回写, 而它只扫在途指令 本函数把满额指令即时置 CONFIRMED ,
最后一笔增量 sweep 永远看不到 (一次性全成时 filled_qty 干脆停在 0), 命令进度
按方案成交比例结算, 全成的命令拖到窗口末被误判 PARTIAL在入账现场同步回写,
sweep 保持原样兜底
"""
ins = pms_repo.get_instruction(instruction_id) ins = pms_repo.get_instruction(instruction_id)
if ins and int(ins.get("exec_qty") or 0) >= int(ins.get("qty") or 0) > 0: if not ins:
return
exec_qty, qty = int(ins.get("exec_qty") or 0), int(ins.get("qty") or 0)
if ins.get("origin_type") == "plan" and ins.get("origin_id"):
try:
pms_repo.update_plan(ins["origin_id"], filled_qty=exec_qty,
status=("DONE" if exec_qty >= qty > 0 else None))
except Exception as e:
logger.warning("方案进度回写失败 %s%s: %s", instruction_id,
ins.get("origin_id"), e)
if exec_qty >= qty > 0:
pms_repo.update_instruction(instruction_id, status="CONFIRMED") pms_repo.update_instruction(instruction_id, status="CONFIRMED")
@ -398,7 +551,10 @@ def recompute_position(ts_code: str) -> dict:
cp = (px / avg_cost - 1.0) if (avg_cost and avg_cost > 0 and px) else None cp = (px / avg_cost - 1.0) if (avg_cost and avg_cost > 0 and px) else None
solid = param_store.get_float("PMS_CUSHION_SOLID", 0.03) solid = param_store.get_float("PMS_CUSHION_SOLID", 0.03)
pos = pms_repo.get_position(ts_code) or {} pos = pms_repo.get_position(ts_code) or {}
peak = max(float(pos.get("cushion_peak") or 0), cp or 0) # 垫子峰值随批次结账: 持仓归零 (清仓) 时清零 —— 上一轮的峰值残留到复开仓的话,
# 新仓一开就满足"峰值回吐过半"的保垫减仓条件, 刚建的仓被立刻自动减掉
# (2026-08-28 审查修; 与 cushion.py "清仓即结账, 防止空仓残留影响下一轮"同一条纪律)。
peak = 0.0 if qty <= 0 else max(float(pos.get("cushion_peak") or 0), cp or 0)
scale = param_store.get_float("PMS_TOTAL_SCALE", 0) scale = param_store.get_float("PMS_TOTAL_SCALE", 0)
fields = { fields = {
@ -920,20 +1076,39 @@ def _apply_fix(f: dict):
# ================================================================ 除权 # ================================================================ 除权
def detect_and_apply_ex_right() -> dict: def detect_and_apply_ex_right() -> dict:
"""用昨日结算快照与今日持仓/价格比对, 识别送转股并按比例调整批次。""" """识别送转股并按比例调整批次: 昨日快照数量 vs **下游当前数量**, 昨收 vs 现价反比。
2026-08-28 审查重写原实现比较的是昨日快照 vs 今日账本 但送转股发生在券商
账户, 账本数量只会因成交或对账修正而变, 而对账排在除权之后, 于是:
* 真除权检不出来 (两边永远同步), 红股被对账当普通差异按下游成本价补 RECON 批次,
老批次成本价不除权, 摊薄成本虚高约 (11/k)/2, 补仓档被凭空触发;
* 正常加仓日反而天天误报 (当日成交让账本数量真的变了, 价格反比又对不上)
改成拿下游事实源的数量来比 (除权当天下游数量已 ×k 而账本还没动, 恰好是可检测窗口),
并跳过今天账本数量已变(当日有成交入账) 的票 那是成交不是除权
"""
out = {"checked": 0, "ex_rights": [], "mismatches": [], "errors": []} out = {"checked": 0, "ex_rights": [], "mismatches": [], "errors": []}
prev = _prev_snapshot() prev = _prev_snapshot()
if not prev: if not prev:
out["errors"].append("无昨日结算快照, 本次跳过除权检测 (次日起生效)") out["errors"].append("无昨日结算快照, 本次跳过除权检测 (次日起生效)")
return out return out
src = positions_source()
if src["source"] == SRC_NONE:
out["errors"].append("两个事实源都拿不到下游持仓, 本次跳过除权检测")
return out
ds_qty = {r["ts_code"]: int(r.get("qty") or 0) for r in src["rows"]}
for pos in pms_repo.list_positions(only_open=True): for pos in pms_repo.list_positions(only_open=True):
code = pos["ts_code"] code = pos["ts_code"]
old = prev.get(code) old = prev.get(code)
if not old: if not old:
continue continue
book_now = int(pos.get("total_qty") or 0)
if book_now != int(old.get("qty") or 0):
continue # 今天账本已因成交变动, 数量跳变是成交不是除权 —— 不误报
if code not in ds_qty:
continue # 下游没这只票, 交对账按缺失处理
out["checked"] += 1 out["checked"] += 1
px = market.get_price(code) px = market.get_price(code)
r = rc.detect_ex_right(int(old.get("qty") or 0), int(pos.get("total_qty") or 0), r = rc.detect_ex_right(int(old.get("qty") or 0), ds_qty[code],
float(old.get("price") or 0), float(px or 0)) float(old.get("price") or 0), float(px or 0))
if not r: if not r:
continue continue
@ -941,8 +1116,13 @@ def detect_and_apply_ex_right() -> dict:
try: try:
lots = pms_repo.list_lots(code, status="OPEN") lots = pms_repo.list_lots(code, status="OPEN")
for l in rc.apply_ex_right(lots, r["ratio"]): for l in rc.apply_ex_right(lots, r["ratio"]):
pms_repo.update_lot(l["id"], qty=l["qty"], open_price=l["open_price"], fields = {"qty": l["qty"], "open_price": l["open_price"],
note=l["note"]) "note": l["note"]}
if "closed_qty" in l:
fields["closed_qty"] = l["closed_qty"]
if "close_avg_price" in l:
fields["close_avg_price"] = l["close_avg_price"]
pms_repo.update_lot(l["id"], **fields)
recompute_position(code) recompute_position(code)
pms_repo.insert_ledger(ts_code=code, action="RECON", arbiter="rule", pms_repo.insert_ledger(ts_code=code, action="RECON", arbiter="rule",
verdict="PASS", price_at=px or 0, hard_numbers=r, verdict="PASS", price_at=px or 0, hard_numbers=r,
@ -1269,6 +1449,13 @@ def expire_stale_instructions() -> int:
for r in pms_repo.list_instructions(statuses=["DISPATCHED"], limit=500): for r in pms_repo.list_instructions(statuses=["DISPATCHED"], limit=500):
try: try:
if str(r.get("updated_at") or "") and str(r["updated_at"]) < str(cut): if str(r.get("updated_at") or "") and str(r["updated_at"]) < str(cut):
# 进终态前把成交进度回写方案 (与 _settle_instruction 同一条纪律)
if r.get("origin_type") == "plan" and r.get("origin_id"):
try:
pms_repo.update_plan(r["origin_id"],
filled_qty=int(r.get("exec_qty") or 0))
except Exception as e:
logger.warning("过期指令进度回写失败 %s: %s", r["instruction_id"], e)
pms_repo.update_instruction(r["instruction_id"], status="EXPIRED") pms_repo.update_instruction(r["instruction_id"], status="EXPIRED")
n += 1 n += 1
except Exception: except Exception:

View File

@ -152,9 +152,7 @@ def _scan_one(key: str, spec: dict, p: dict, today: int, now, dry_run: bool,
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.error("[宏观] %s 取数/计算失败: %s", key, e) logger.error("[宏观] %s 取数/计算失败: %s", key, e)
if not dry_run: if not dry_run:
macro_repo.upsert_signal(signal_key=key, trade_date=today, value=None, _upsert_unavailable(key, today, f"取数失败: {type(e).__name__}: {e}")
zone=mr.Z_UNAVAILABLE, detail={},
note=f"取数失败: {type(e).__name__}: {e}")
return {"key": key, "label": label, "zone": mr.Z_UNAVAILABLE, "value": None, return {"key": key, "label": label, "zone": mr.Z_UNAVAILABLE, "value": None,
"note": f"取数失败: {e}"} "note": f"取数失败: {e}"}
@ -167,13 +165,25 @@ def _scan_one(key: str, spec: dict, p: dict, today: int, now, dry_run: bool,
prev_row = next((r for r in history if int(r["trade_date"]) < today), None) prev_row = next((r for r in history if int(r["trade_date"]) < today), None)
today_row = next((r for r in history if int(r["trade_date"]) == today), None) today_row = next((r for r in history if int(r["trade_date"]) == today), None)
prev_zone = (prev_row or {}).get("zone") or mr.Z_NEUTRAL prev_zone = (prev_row or {}).get("zone") or mr.Z_NEUTRAL
prev_cycle = ((prev_row or {}).get("detail") or {}).get("cycle") # 周期状态跨过 UNAVAILABLE 日继承 (2026-08-28 审查修): 一天数据不可用不该把偏热周期
# 积累的 e_peak / done_shift 整个丢掉 —— 丢的正是"回落再动"那笔降仓。区域迟滞按
# UNAVAILABLE 后重判 NEUTRAL 是文档定的口径, 不动; 只有 cycle 往回找最近一个带
# cycle 的行 (只跨 UNAVAILABLE 行, 碰到正常行为止)。
prev_cycle = None
for r in history:
if int(r["trade_date"]) >= today:
continue
c = (r.get("detail") or {}).get("cycle")
if c:
prev_cycle = c
break
if (r.get("zone") or "") != mr.Z_UNAVAILABLE:
break
if not comp["ok"]: if not comp["ok"]:
if not dry_run: if not dry_run:
macro_repo.upsert_signal(signal_key=key, trade_date=today, value=None, _upsert_unavailable(key, today, comp.get("reason") or "数据守卫未过",
zone=mr.Z_UNAVAILABLE, detail=comp.get("detail"), extra_detail=comp.get("detail"))
note=comp.get("reason") or "数据守卫未过")
return {"key": key, "label": label, "zone": mr.Z_UNAVAILABLE, "value": None, return {"key": key, "label": label, "zone": mr.Z_UNAVAILABLE, "value": None,
"note": comp.get("reason")} "note": comp.get("reason")}
@ -191,10 +201,18 @@ def _scan_one(key: str, spec: dict, p: dict, today: int, now, dry_run: bool,
detail = {**(comp.get("detail") or {}), "cycle": cycle, "e_now": round(e_now, 2)} detail = {**(comp.get("detail") or {}), "cycle": cycle, "e_now": round(e_now, 2)}
if exit_acted: if exit_acted:
detail["exit_acted"] = True detail["exit_acted"] = True
# 当日已下过命令的行不回退动作字段与说明 (先记账后动作: 命令已在命令表, 行只增不改口) # 当日已下过命令 / 已给出建议的行, 不回退动作字段与说明 (先记账后动作; 建议"当日
action_field = A_CMD if (today_row or {}).get("action") == A_CMD else A_NONE # 有效", 中途一次重扫不该把待采纳的建议抹掉 —— 2026-08-28 把 ADVICE 也纳入保护)
t_action = str((today_row or {}).get("action") or "NONE")
if t_action == A_CMD:
action_field = A_CMD
elif t_action in (A_ADV_RED, A_ADV_INC) and t_detail.get("advice"):
action_field = t_action
detail["advice"] = t_detail["advice"]
else:
action_field = A_NONE
ref_id = (today_row or {}).get("ref_id") or "" ref_id = (today_row or {}).get("ref_id") or ""
note = ((today_row or {}).get("note") if action_field == A_CMD else None) \ note = ((today_row or {}).get("note") if action_field != A_NONE else None) \
or f"区域 {zone}, 指数 {value:+.1f}" or f"区域 {zone}, 指数 {value:+.1f}"
if not dry_run: if not dry_run:
@ -309,6 +327,43 @@ def _upsert_action(key, today, value, zone, detail, action, ref_id, note):
logger.error("[宏观] %s 动作回写信号行失败: %s", key, e) logger.error("[宏观] %s 动作回写信号行失败: %s", key, e)
def _upsert_unavailable(key, today, note, extra_detail=None):
"""失败路径落 UNAVAILABLE 行, **保留当日已有的动作留痕与周期状态** (2026-08-28 审查修)。
upsert 是全字段覆盖: 原来失败路径按默认值整行重写, 盘中重扫恰逢数据源抖一下,
上午的 CMD_ISSUED / 建议 / cycle.done_shift 会被冲掉 当日不重复下命令
判据随之失效, 次日取昨日状态也拿不到周期这里先读回当日行, action / ref_id /
detail 里的 cycle / exit_acted / advice 原样带回去"""
prev = None
try:
prev = macro_repo.get_signal(key, today)
except Exception: # noqa: BLE001
prev = None
prev = prev or {}
pd = prev.get("detail")
if pd is None and prev.get("detail_json"):
try:
import json as _json
pd = _json.loads(prev["detail_json"])
except Exception: # noqa: BLE001
pd = None
pd = pd or {}
keep = dict(extra_detail or {})
for k in ("cycle", "exit_acted", "advice"):
if pd.get(k) is not None and k not in keep:
keep[k] = pd[k]
prev_action = str(prev.get("action") or "NONE")
if prev_action != "NONE":
note = ((prev.get("note") or "") + f"; 重扫失败: {note}")[:490]
try:
macro_repo.upsert_signal(signal_key=key, trade_date=today, value=None,
zone=mr.Z_UNAVAILABLE, detail=keep,
action=prev_action, ref_id=str(prev.get("ref_id") or ""),
note=note)
except Exception as e: # noqa: BLE001
logger.error("[宏观] %s UNAVAILABLE 行写入失败: %s", key, e)
def _ledger_once(today_row, action_field, note, key, value, detail, *, verdict, def _ledger_once(today_row, action_field, note, key, value, detail, *, verdict,
ref_id=None): ref_id=None):
"""评审账本留痕; 同日同动作同说明不重复写 (手动重扫防刷屏)。""" """评审账本留痕; 同日同动作同说明不重复写 (手动重扫防刷屏)。"""

View File

@ -90,14 +90,16 @@ def day_snapshot(ts_code: str) -> dict:
if not bars: if not bars:
return {} return {}
try: try:
closes = [float(b.get("close") or 0) for b in bars if float(b.get("close") or 0) > 0] pairs = [(float(b.get("close") or 0), float(b.get("vol") or 0)) for b in bars]
pairs = [(c, v) for c, v in pairs if c > 0] # 坏 bar 价量成对剔除, 不错位 (2026-08-28)
closes = [c for c, _ in pairs]
if not closes: if not closes:
return {} return {}
highs = [float(b.get("high") or b.get("close") or 0) for b in bars] highs = [float(b.get("high") or b.get("close") or 0) for b in bars]
lows = [float(b.get("low") or b.get("close") or 0) for b in bars if lows = [float(b.get("low") or b.get("close") or 0) for b in bars if
float(b.get("low") or b.get("close") or 0) > 0] float(b.get("low") or b.get("close") or 0) > 0]
opens = float(bars[0].get("open") or closes[0]) opens = float(bars[0].get("open") or closes[0])
vols = [float(b.get("vol") or 0) for b in bars] vols = [v for _, v in pairs]
amt = sum(c * v for c, v in zip(closes, vols)) amt = sum(c * v for c, v in zip(closes, vols))
vwap = (amt / sum(vols)) if sum(vols) > 0 else sum(closes) / len(closes) vwap = (amt / sum(vols)) if sum(vols) > 0 else sum(closes) / len(closes)
px = closes[-1] px = closes[-1]
@ -121,8 +123,12 @@ def _shard_tables(months: int = 4) -> list:
def _factor_rows(ts_code: str, days: int = 70) -> list: def _factor_rows(ts_code: str, days: int = 70) -> list:
"""逐分表单查 (代理要求单表), 合并后取最近 days 条。symbol 先点式后前缀式试。""" """逐分表单查 (代理要求单表), 合并后取最近 days 条。symbol 先点式后前缀式试。
since = (datetime.now() - timedelta(days=int(days * 1.6))).strftime("%Y-%m-%d")
回看窗至少 15 个自然日 (2026-08-28 审查修): 原来按 days×1.6 , days=3 时只回看
4.8 春节国庆停市 7~9 个自然日, 长假后第一个交易日最近一根日线落在窗外,
昨收取空候选池整日空掉 (正是 2026-07-31 那次故障在长假后的复发形态)"""
since = (datetime.now() - timedelta(days=max(15, int(days * 2.5)))).strftime("%Y-%m-%d")
rows = [] rows = []
for tbl in _shard_tables(): for tbl in _shard_tables():
for sym in (ts_code, downstream_repo.to_prefix(ts_code)): for sym in (ts_code, downstream_repo.to_prefix(ts_code)):
@ -183,15 +189,17 @@ def get_last_close(ts_code: str):
_ma_cache.update({"day": today, "data": {}}) _ma_cache.update({"day": today, "data": {}})
if key in _ma_cache["data"]: if key in _ma_cache["data"]:
return _ma_cache["data"][key] return _ma_cache["data"][key]
val = None val, failed = None, False
try: try:
rows = _factor_rows(ts_code, days=3) rows = _factor_rows(ts_code, days=3)
closes = [float(r["close_qfq"]) for r in rows if r.get("close_qfq")] closes = [float(r["close_qfq"]) for r in rows if r.get("close_qfq")]
if closes: if closes:
val = round(closes[-1], 3) val = round(closes[-1], 3)
except Exception as e: except Exception as e:
logger.warning("昨收取数失败 [%s]: %s", ts_code, e) failed = True # 异常不落当日缓存: 盘前一次库抖不该让该票全天无昨收 (2026-08-28)
_ma_cache["data"][key] = val logger.warning("昨收取数失败 [%s] (本次不缓存, 下次重查): %s", ts_code, e)
if not failed:
_ma_cache["data"][key] = val
return val return val
@ -217,15 +225,17 @@ def get_ma5(ts_code: str):
_ma_cache.update({"day": today, "data": {}}) _ma_cache.update({"day": today, "data": {}})
if ts_code in _ma_cache["data"]: if ts_code in _ma_cache["data"]:
return _ma_cache["data"][ts_code] return _ma_cache["data"][ts_code]
val = None val, failed = None, False
try: try:
rows = _factor_rows(ts_code, days=10) rows = _factor_rows(ts_code, days=10)
closes = [float(r["close_qfq"]) for r in rows if r.get("close_qfq")] closes = [float(r["close_qfq"]) for r in rows if r.get("close_qfq")]
if len(closes) >= 5: if len(closes) >= 5:
val = round(sum(closes[-5:]) / 5, 3) val = round(sum(closes[-5:]) / 5, 3)
except Exception as e: except Exception as e:
logger.warning("MA5 取数失败 [%s]: %s", ts_code, e) failed = True
_ma_cache["data"][ts_code] = val logger.warning("MA5 取数失败 [%s] (本次不缓存, 下次重查): %s", ts_code, e)
if not failed:
_ma_cache["data"][ts_code] = val
return val return val
@ -237,7 +247,7 @@ def get_high5(ts_code: str):
_ma_cache.update({"day": today, "data": {}}) _ma_cache.update({"day": today, "data": {}})
if key in _ma_cache["data"]: if key in _ma_cache["data"]:
return _ma_cache["data"][key] return _ma_cache["data"][key]
val = None val, failed = None, False
try: try:
rows = _factor_rows(ts_code, days=10) rows = _factor_rows(ts_code, days=10)
highs = [float(r.get("high_qfq") or r.get("close_qfq") or 0) for r in rows] highs = [float(r.get("high_qfq") or r.get("close_qfq") or 0) for r in rows]
@ -245,8 +255,10 @@ def get_high5(ts_code: str):
if len(highs) >= 5: if len(highs) >= 5:
val = round(max(highs[-5:]), 3) val = round(max(highs[-5:]), 3)
except Exception as e: except Exception as e:
logger.warning("5日高点取数失败 [%s]: %s", ts_code, e) failed = True
_ma_cache["data"][key] = val logger.warning("5日高点取数失败 [%s] (本次不缓存, 下次重查): %s", ts_code, e)
if not failed:
_ma_cache["data"][key] = val
return val return val

View File

@ -88,7 +88,8 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
# 这四种处境完全不同 —— 在途的明天照样被挡, 被拒的日切就重新评估。从前它们在 # 这四种处境完全不同 —— 在途的明天照样被挡, 被拒的日切就重新评估。从前它们在
# skipped 里长得一模一样, 看的人判断不了这只票明天还会不会再被评估。 # skipped 里长得一模一样, 看的人判断不了这只票明天还会不会再被评估。
# 一只票既有在途指令又今天被拒过时, 显示"有在途"更贴近它此刻的实际状态。 # 一只票既有在途指令又今天被拒过时, 显示"有在途"更贴近它此刻的实际状态。
skip = {**_judge_rejected_open_keys(), **_rejected_today_keys(), **_inflight_keys()} skip = {**_judge_rejected_open_keys(), **_rejected_today_keys(),
**_declined_today_keys(), **_inflight_keys()}
# 挂了 ACTIVE 交易方案(策略)的票交策略层接管, 动作引擎不再对它自动提议 # 挂了 ACTIVE 交易方案(策略)的票交策略层接管, 动作引擎不再对它自动提议
# (读库失败按空集 —— 宁可这轮不排除, 也不能因读不到把全体持仓都排除) # (读库失败按空集 —— 宁可这轮不排除, 也不能因读不到把全体持仓都排除)
try: try:
@ -213,8 +214,18 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list:
t = view["totals"] t = view["totals"]
p = view["params"] p = view["params"]
slots = int(p["max_names"] or 0) - int(t["names_count"] or 0) # 名额与金额都要扣掉**在途的建仓承诺** (2026-08-28 审查修): names_count 只数已成交
room = float(p["portfolio_cap"] or 0) * float(p["scale"] or 0) - float(t["portfolio_mv"] or 0) # 持仓, 在途 OPEN 指令与等拍板的 OPEN 提议两头都不占 —— 候选没进买入区间时指令滞留
# 在途, 下一跳名额照旧又放两只, 几分钟就把持仓数上限击穿。同票去重挡不住换一只票。
inflight = _inflight_open_commitments(view)
slots = int(p["max_names"] or 0) - int(t["names_count"] or 0) - len(inflight["names"])
room = float(p["portfolio_cap"] or 0) * float(p["scale"] or 0) \
- float(t["portfolio_mv"] or 0) - inflight["amount"]
if inflight["names"]:
out["skipped"].append({"action": ae.A_OPEN,
"why": f"在途建仓已占 {len(inflight['names'])} 个名额、"
f"{inflight['amount']:,.0f}"
f"({sorted(inflight['names'])[:6]}), 本轮按余量扫描"})
# 真实可用资金封顶。**这一条比规则闸严一档, 是刻意的**: 规则闸那条「拿不到 ws 资金快照 # 真实可用资金封顶。**这一条比规则闸严一档, 是刻意的**: 规则闸那条「拿不到 ws 资金快照
# 只告警不拦」是为**已经排好的命令**设计的 —— 通道故障不该升级成业务停摆。而无人值守地 # 只告警不拦」是为**已经排好的命令**设计的 —— 通道故障不该升级成业务停摆。而无人值守地
# 从零建仓完全可以等一等, 07-30 那次 (scale 200 万 / 账户实际 98 万, 方案一路放行到 # 从零建仓完全可以等一等, 07-30 那次 (scale 200 万 / 账户实际 98 万, 方案一路放行到
@ -245,6 +256,45 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list:
return res["candidates"] return res["candidates"]
def _inflight_open_commitments(view) -> dict:
"""在途的新建仓承诺: {names: {代码}, amount: 预估占用金额}。
口径: 在途 (LIVE) OPEN 指令 + 等拍板 (WAIT_USER) OPEN 提议, 只算**尚未持有**
的票 (已成交的部分 names_count 里已经有了)金额按 数量×现价 (指令下发前没有限价),
现价取不到时按提议里存的价兜底读库失败按空 与其它在途读取同一口径, 宁可这轮
多放也不能因读不到把新建仓全停 (规则闸和真实资金封顶仍在后面兜)"""
names, amount = set(), 0.0
held = {x["ts_code"] for x in view.get("held") or []}
rows = []
try:
for i in pms_repo.list_instructions(statuses=list(executor.LIVE), limit=300):
if i.get("action") == ae.A_OPEN and i["ts_code"] not in held:
rows.append((i["ts_code"], int(i.get("qty") or 0), None))
except Exception as e:
logger.warning("读在途 OPEN 指令失败 (按无在途): %s", e)
try:
for pr in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200):
if pr.get("action") == ae.A_OPEN and pr["ts_code"] not in held:
hn = pr.get("hard_numbers") or {}
rows.append((pr["ts_code"], int(pr.get("qty") or 0),
float(hn.get("price") or 0) or None))
except Exception as e:
logger.warning("读在途 OPEN 提议失败 (按无在途): %s", e)
if not rows:
return {"names": names, "amount": 0.0}
try:
prices = market.get_prices(sorted({c for c, _, _ in rows}))
except Exception:
prices = {}
for code, qty, px_hint in rows:
if code in names:
continue
names.add(code)
px = float(prices.get(code) or 0) or float(px_hint or 0)
amount += qty * px
return {"names": names, "amount": round(amount, 2)}
def _plain_check(c) -> str: def _plain_check(c) -> str:
"""规则闸的 failed 串形如 "T1_UNAVAILABLE: 卖出…" —— 去掉给运维看的大写代码前缀, 只留中文说明。""" """规则闸的 failed 串形如 "T1_UNAVAILABLE: 卖出…" —— 去掉给运维看的大写代码前缀, 只留中文说明。"""
s = str(c) s = str(c)
@ -467,16 +517,29 @@ def _judge_budget_left(deadline) -> bool:
# ================================================================ 落表 # ================================================================ 落表
def _make_instruction(c, price, now) -> str: def _make_instruction(c, price, now) -> str:
ymd = td.ymd(now) ymd = td.ymd(now)
# 序号用完整时分秒 (2026-08-28 修): 原来 %1000 只留「分钟个位+秒」, 同日同票同动作
# 第二条指令约 1/600 概率撞唯一键丢单一跳。撞了再退一步逐秒加一重试。
seq = int(now.strftime("%H%M%S")) seq = int(now.strftime("%H%M%S"))
iid = cs.make_instruction_id(ymd, c["ts_code"], c["action"], seq % 1000)
window = param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3) window = param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3)
pms_repo.insert_instruction( iid = None
instruction_id=iid, origin_type="proposal", origin_id=None, ts_code=c["ts_code"], last_err = None
action=c["action"], side=c["side"], qty=c["qty"], limit_price=None, for salt in range(3):
window_tdays=window, status=executor.ST_PROPOSED, try_iid = cs.make_instruction_id(ymd, c["ts_code"], c["action"], seq + salt)
progress={"deadline": str(td.window_deadline(now.date(), window)), try:
"is_command": False, "children": [], "auto": True, pms_repo.insert_instruction(
"reason": c["reason"]}) instruction_id=try_iid, origin_type="proposal", origin_id=None,
ts_code=c["ts_code"],
action=c["action"], side=c["side"], qty=c["qty"], limit_price=None,
window_tdays=window, status=executor.ST_PROPOSED,
progress={"deadline": str(td.window_deadline(now.date(), window)),
"is_command": False, "children": [], "auto": True,
"reason": c["reason"]})
iid = try_iid
break
except Exception as e:
last_err = e
if iid is None:
raise last_err
g = bump_once_guards(c["ts_code"], c["action"], c.get("hard_numbers"), now) or {} g = bump_once_guards(c["ts_code"], c["action"], c.get("hard_numbers"), now) or {}
if not g.get("ok"): if not g.get("ok"):
# 指令已经落表了, 不回滚; 但要在评审账本上留一条痕, 否则这条纪律失效没有任何记录 # 指令已经落表了, 不回滚; 但要在评审账本上留一条痕, 否则这条纪律失效没有任何记录
@ -538,11 +601,22 @@ def _make_proposal(c, price, verdict) -> str:
pid = f"PRP_{td.ymd()}_{c['ts_code'].replace('.', '')}_{c['action']}" pid = f"PRP_{td.ymd()}_{c['ts_code'].replace('.', '')}_{c['action']}"
hn = {**(c.get("hard_numbers") or {}), "price": price, "reason": c["reason"], hn = {**(c.get("hard_numbers") or {}), "price": price, "reason": c["reason"],
"needs_user_confirm": c.get("needs_user_confirm", False)} "needs_user_confirm": c.get("needs_user_confirm", False)}
pms_repo.insert_proposal( try:
proposal_id=pid, ts_code=c["ts_code"], action=c["action"], qty=c["qty"], pms_repo.insert_proposal(
hard_numbers=hn, expire_at=datetime.now() + timedelta(hours=ttl), proposal_id=pid, ts_code=c["ts_code"], action=c["action"], qty=c["qty"],
judge_verdict=verdict.get("verdict"), hard_numbers=hn, expire_at=datetime.now() + timedelta(hours=ttl),
judge_reason=(verdict.get("reason") or "")[:500]) judge_verdict=verdict.get("verdict"),
judge_reason=(verdict.get("reason") or "")[:500])
except Exception:
# 确定性编号被当日已终态 (过期/驳回) 的旧提议占着 —— 补时间后缀重试一次,
# 不让唯一键冲突把整轮扫描刷成 error (2026-08-28 审查修; 用户驳回的当日
# 不重提另有 _declined_today_keys 挡在扫描入口, 这里兜的是过期重生成)
pid = f"{pid}_{datetime.now().strftime('%H%M%S')}"
pms_repo.insert_proposal(
proposal_id=pid, ts_code=c["ts_code"], action=c["action"], qty=c["qty"],
hard_numbers=hn, expire_at=datetime.now() + timedelta(hours=ttl),
judge_verdict=verdict.get("verdict"),
judge_reason=(verdict.get("reason") or "")[:500])
return pid return pid
@ -638,6 +712,24 @@ def _judge_rejected_open_keys() -> dict:
for code, act in keys if act == ae.A_OPEN} for code, act in keys if act == ae.A_OPEN}
def _declined_today_keys() -> dict:
"""今天已被用户**驳回**的提议 {(代码, 动作): 原因} —— 当日不再重提 (2026-08-28)。
两个理由: 你上午刚说过"", 触发条件没变的话下午每分钟再问一遍是烦人不是尽责;
提议编号是日期+代码+动作的确定性编号, 驳回的行还占着编号, 同日重建必撞
唯一键, 整轮 scan_and_route 会被这个报错刷屏日切自动解除, 明天重新评估"""
keys = {}
today = str(td.ymd())
try:
for p in pms_repo.list_proposals(statuses=("DECLINED",), limit=200):
at = str(p.get("decided_at") or p.get("created_at") or "")
if at[:10].replace("-", "") == today or at[:10] == f"{today[:4]}-{today[4:6]}-{today[6:]}":
keys[(p["ts_code"], p["action"])] = "今天已被你驳回过, 当日不再重提 (明天重新评估)"
except Exception as e:
logger.warning("读当日已驳回提议失败 (按无): %s", e)
return keys
def _inflight_keys() -> dict: def _inflight_keys() -> dict:
"""已有在途提议或在途指令的 {(代码, 动作): 原因} —— 同一件事不重复提。 """已有在途提议或在途指令的 {(代码, 动作): 原因} —— 同一件事不重复提。

View File

@ -116,7 +116,11 @@ def consume(*, batch: int = 50, dry_run: bool = False) -> dict:
for db, key, parser in streams(): for db, key, parser in streams():
try: try:
msgs = _read(db, key, batch) # 试算走**只读**通道 (XREVRANGE), 绝不碰消费组 (2026-08-28 审查修):
# 消费组按 ">" 读过的消息进本消费者的待确认清单, 不确认就**永远不会再投递**
# —— 原来试算读了不确认, 恰有一条未消化的风控卖出时, 点一次试算就把它永久
# 吞掉了。正式消费则先把自己上次读了没确认的 (处理中途抛异常的) 捞回来重处理。
msgs = _peek(db, key, batch) if dry_run else _read(db, key, batch)
except Exception as e: except Exception as e:
out["errors"].append(f"{key} 读取失败: {type(e).__name__}: {e}") out["errors"].append(f"{key} 读取失败: {type(e).__name__}: {e}")
continue continue
@ -159,7 +163,7 @@ def _handle(sig, view, prm, seen, ymd, dry_run, out, strat_codes=frozenset()):
price_at=float(pos.get("price") or 0), price_at=float(pos.get("price") or 0),
hard_numbers={**d["hard_numbers"], "msg_id": sig.get("msg_id")}, hard_numbers={**d["hard_numbers"], "msg_id": sig.get("msg_id")},
reason="决策系统卖出未采纳(置信度低于门槛), 持仓票请留意: " + d["reason"][:400]) reason="决策系统卖出未采纳(置信度低于门槛), 持仓票请留意: " + d["reason"][:400])
seen.add(k) seen[k] = None
out.setdefault("held_sell_noted", []).append(code) out.setdefault("held_sell_noted", []).append(code)
except Exception as e: except Exception as e:
logger.warning("[信号消化] 持仓票卖出留痕失败 %s: %s", code, e) logger.warning("[信号消化] 持仓票卖出留痕失败 %s: %s", code, e)
@ -197,7 +201,7 @@ def _handle(sig, view, prm, seen, ymd, dry_run, out, strat_codes=frozenset()):
ts_code=code, action=SIGNAL_BUY_ACTION, arbiter="rule", verdict="NOTE", ts_code=code, action=SIGNAL_BUY_ACTION, arbiter="rule", verdict="NOTE",
price_at=px, hard_numbers={**d["hard_numbers"], "msg_id": sig.get("msg_id")}, price_at=px, hard_numbers={**d["hard_numbers"], "msg_id": sig.get("msg_id")},
reason=d["reason"][:500]) reason=d["reason"][:500])
seen.add(key) seen[key] = None
return return
key = sr.dedup_key(sig, ymd) key = sr.dedup_key(sig, ymd)
@ -224,7 +228,7 @@ def _handle(sig, view, prm, seen, ymd, dry_run, out, strat_codes=frozenset()):
{**brief, "dry_run": True}) {**brief, "dry_run": True})
return return
# 去重键必须**写成功之后**才落。原来是先 seen.add(key) 再落库, 一旦 _make_exit / # 去重键必须**写成功之后**才落。原来是先 seen[key] = None 再落库, 一旦 _make_exit /
# _make_proposal 抛异常 (DB 抖一下、下游拒一次), 上层 catch 住记进 out["errors"], # _make_proposal 抛异常 (DB 抖一下、下游拒一次), 上层 catch 住记进 out["errors"],
# 但这一天的去重键已经烧掉了 —— 同一条风控卖出信号后面再来多少次都被当成重复丢弃, # 但这一天的去重键已经烧掉了 —— 同一条风控卖出信号后面再来多少次都被当成重复丢弃,
# 指令一条都不会落。失败长得像成功: 页面只多一行 error, 而该卖的票就那么留着了。 # 指令一条都不会落。失败长得像成功: 页面只多一行 error, 而该卖的票就那么留着了。
@ -232,14 +236,14 @@ def _handle(sig, view, prm, seen, ymd, dry_run, out, strat_codes=frozenset()):
on_strategy = bool(code) and code in strat_codes on_strategy = bool(code) and code in strat_codes
if act == sr.ACT_EXIT and not on_strategy: if act == sr.ACT_EXIT and not on_strategy:
iid = _make_exit(code, d, pos) iid = _make_exit(code, d, pos)
seen.add(key) seen[key] = None
out["exits"].append({**brief, "instruction_id": iid}) out["exits"].append({**brief, "instruction_id": iid})
else: else:
# 挂了策略的票: 决策系统的风控卖出只提示、不自动清仓 (强制离场会推翻你特意设的策略); # 挂了策略的票: 决策系统的风控卖出只提示、不自动清仓 (强制离场会推翻你特意设的策略);
# 一律落提议进「等我拍板」由你定"维持 / 采纳即撤策略并清仓", 采纳的是全清 (as_exit)。 # 一律落提议进「等我拍板」由你定"维持 / 采纳即撤策略并清仓", 采纳的是全清 (as_exit)。
pid = _make_proposal(code, d, pos, sig, on_strategy=on_strategy, pid = _make_proposal(code, d, pos, sig, on_strategy=on_strategy,
as_exit=(on_strategy and act == sr.ACT_EXIT)) as_exit=(on_strategy and act == sr.ACT_EXIT))
seen.add(key) seen[key] = None
out["proposals"].append({**brief, "proposal_id": pid, "on_strategy": on_strategy}) out["proposals"].append({**brief, "proposal_id": pid, "on_strategy": on_strategy})
if on_strategy and not dry_run: if on_strategy and not dry_run:
# 同时暂停该票策略的买入这一侧 (不平仓、不动卖出、页面可恢复) —— 挡住"资金在流出、 # 同时暂停该票策略的买入这一侧 (不平仓、不动卖出、页面可恢复) —— 挡住"资金在流出、
@ -259,7 +263,8 @@ def _handle(sig, view, prm, seen, ymd, dry_run, out, strat_codes=frozenset()):
def _make_exit(code, d, pos) -> str: def _make_exit(code, d, pos) -> str:
"""高置信风控卖出 → 直接落卖出指令 (减持方向不设确认门槛)。""" """高置信风控卖出 → 直接落卖出指令 (减持方向不设确认门槛)。"""
now = datetime.now() now = datetime.now()
iid = cs.make_instruction_id(td.ymd(now), code, "EXIT", int(now.strftime("%H%M%S")) % 1000) # 序号用完整时分秒 (原来 %1000 同日同票同动作可能撞唯一键丢单; 2026-08-28 修)
iid = cs.make_instruction_id(td.ymd(now), code, "EXIT", int(now.strftime("%H%M%S")))
window = param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3) window = param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3)
pms_repo.insert_instruction( pms_repo.insert_instruction(
instruction_id=iid, origin_type="system", origin_id=d["hard_numbers"].get("source"), instruction_id=iid, origin_type="system", origin_id=d["hard_numbers"].get("source"),
@ -289,11 +294,20 @@ def _make_proposal(code, d, pos, sig, *, on_strategy=False, as_exit=False) -> st
"你定: 维持观察 / 采纳即撤策略并清仓。 —— " + reason) "你定: 维持观察 / 采纳即撤策略并清仓。 —— " + reason)
hn = {**d["hard_numbers"], "price": float((pos or {}).get("price") or 0), hn = {**d["hard_numbers"], "price": float((pos or {}).get("price") or 0),
"reason": reason, "signal_source": sig.get("source"), "on_strategy": on_strategy} "reason": reason, "signal_source": sig.get("source"), "on_strategy": on_strategy}
pms_repo.insert_proposal(proposal_id=pid, ts_code=code, action=action, qty=d["qty"], try:
hard_numbers=hn, pms_repo.insert_proposal(proposal_id=pid, ts_code=code, action=action, qty=d["qty"],
expire_at=datetime.now() + timedelta(hours=ttl), hard_numbers=hn,
judge_verdict=("STRATEGY_RISK" if on_strategy else None), expire_at=datetime.now() + timedelta(hours=ttl),
judge_reason=reason[:500]) judge_verdict=("STRATEGY_RISK" if on_strategy else None),
judge_reason=reason[:500])
except Exception:
# 确定性编号被当日已终态提议占着 → 补时间后缀重试一次 (2026-08-28 审查修)
pid = f"{pid}_{datetime.now().strftime('%H%M%S')}"
pms_repo.insert_proposal(proposal_id=pid, ts_code=code, action=action, qty=d["qty"],
hard_numbers=hn,
expire_at=datetime.now() + timedelta(hours=ttl),
judge_verdict=("STRATEGY_RISK" if on_strategy else None),
judge_reason=reason[:500])
return pid return pid
@ -307,13 +321,31 @@ def _read(db: int, key: str, batch: int) -> list:
except Exception as e: except Exception as e:
if "BUSYGROUP" not in str(e): if "BUSYGROUP" not in str(e):
raise raise
resp = c.xreadgroup(g, consumer, {key: ">"}, count=int(batch), block=100)
out = [] out = []
for _stream, messages in (resp or []): # 先捞**自己名下读了没确认的** (id="0" 只回本消费者的待确认消息): 上一跳处理中途
out.extend(messages) # 抛异常的消息会留在这里, 不捞的话它们永远不会再被投递 —— "读了不确认 = 下次还在"
# 是对消费组的误解, 2026-08-28 审查修。
try:
resp0 = c.xreadgroup(g, consumer, {key: "0"}, count=int(batch))
for _stream, messages in (resp0 or []):
out.extend(m for m in messages if m and m[1]) # 空体=已删消息, 跳过
except Exception as e:
logger.warning("[信号消化] 待确认消息回捞失败 (继续读新): %s", e)
left = int(batch) - len(out)
if left > 0:
resp = c.xreadgroup(g, consumer, {key: ">"}, count=left, block=100)
for _stream, messages in (resp or []):
out.extend(messages)
return out return out
def _peek(db: int, key: str, batch: int) -> list:
"""只读取样 (试算用): XREVRANGE 不进消费组、不产生任何投递痕迹。返回升序。"""
c = _client(db)
rows = c.xrevrange(key, count=int(batch)) or []
return list(reversed(rows))
def _ack(db: int, key: str, msg_id: str): def _ack(db: int, key: str, msg_id: str):
try: try:
_client(db).xack(key, group_name(), msg_id) _client(db).xack(key, group_name(), msg_id)
@ -322,20 +354,22 @@ def _ack(db: int, key: str, msg_id: str):
# ================================================================ 去重与助手 # ================================================================ 去重与助手
def _load_seen() -> set: def _load_seen():
"""当日去重集合。用 dict 当有序集合 (保持加入顺序) —— 超上限裁剪时裁掉**最旧的**,
而不是原来 sorted() 后按字典序裁 (裁掉的与时间无关, 被裁的键再来一条就会重复消化)"""
try: try:
raw = pms_repo.get_param(SEEN_KEY) raw = pms_repo.get_param(SEEN_KEY)
d = json.loads(raw) if raw else {} d = json.loads(raw) if raw else {}
if str(d.get("ymd")) != str(td.ymd()): if str(d.get("ymd")) != str(td.ymd()):
return set() return dict()
return set(d.get("keys") or []) return dict.fromkeys(d.get("keys") or [])
except Exception: except Exception:
return set() return dict()
def _save_seen(seen: set, ymd): def _save_seen(seen, ymd):
try: try:
pms_repo.set_param(SEEN_KEY, json.dumps({"ymd": ymd, "keys": sorted(seen)[-500:]}), pms_repo.set_param(SEEN_KEY, json.dumps({"ymd": ymd, "keys": list(seen)[-800:]}),
"system") "system")
except Exception as e: except Exception as e:
logger.warning("[信号消化] 去重集合写入失败: %s", e) logger.warning("[信号消化] 去重集合写入失败: %s", e)

View File

@ -73,15 +73,11 @@ _CN_TYPE = {"GRID": "网格", "TRAIL": "跟踪止盈", "T0": "做T"} # 页面
def lot_of(ts_code) -> int: def lot_of(ts_code) -> int:
"""最小申报单位: 科创板 (688/689) 200 股起, 其余 100。 """最小申报单位。2026-08-28 起全库统一委托给 sizer.lot_of —— planner / rule_gate /
executor / action_engine 都已按代码取最小申报数量, 这里不再单独维护一份口径
2026-08-25 实盘 dry-run 发现: 持仓里有 688802.SH, 全库其他地方一律按 100 股一手 (口径有两份, 迟早会分叉; 原先只在本模块兜的历史见 2026-08-25 DEVLOG)"""
(strategy_runner._round_lot / sizer), 科创板 200 股起买这条只在这里兜 自动网格 from app.core.sizer import lot_of as _lot_of
per_lot 若生成 100, runner 发单会被券商按无效数量拒掉买得起一手的判断与 return _lot_of(ts_code)
per_lot 下限都按这个数; 200 也是 100 的整数倍, 不会被 _round_lot 磨掉
runner 侧卖出数量的整百取整对科创板仍不完美 ( DEVLOG 欠账), 不在本模块修"""
s = str(ts_code or "")
return 200 if s.startswith(("688", "689")) else 100
def _f(v, d=None): def _f(v, d=None):
@ -417,10 +413,12 @@ def scan(*, dry_run: bool = False, now=None) -> dict:
codes = [p["ts_code"] for p in held] codes = [p["ts_code"] for p in held]
# ---- 信号与既有状态一次取齐 (任一失败只废对应的边, 不废整轮) ---- # ---- 信号与既有状态一次取齐 (任一失败只废对应的边, 不废整轮) ----
accum_ok = True # 取数成功与否要跟"某只票查无定性"分开: 前者按不动, 后者要停买
try: try:
accum = accum_of(codes) accum = accum_of(codes)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
accum = {} accum = {}
accum_ok = False
out["errors"].append(f"吸筹取数失败, 依赖它的判断本轮全部不做: {type(e).__name__}: {e}") out["errors"].append(f"吸筹取数失败, 依赖它的判断本轮全部不做: {type(e).__name__}: {e}")
try: try:
heat, heat_meta = heat_of(codes) heat, heat_meta = heat_of(codes)
@ -504,7 +502,7 @@ def scan(*, dry_run: bool = False, now=None) -> dict:
"strategy": (st or {}).get("type")}) "strategy": (st or {}).get("type")})
if st: if st:
_tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool, _tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool,
today, dry_run, out, strategy_service) today, dry_run, out, strategy_service, accum_ok=accum_ok)
continue continue
edge, why = plan_edge(cls=a.get("cls"), fresh=fresh, heat=hv, edge, why = plan_edge(cls=a.get("cls"), fresh=fresh, heat=hv,
cushion=p.get("cushion_pct"), prm=prm) cushion=p.get("cushion_pct"), prm=prm)
@ -524,7 +522,7 @@ def scan(*, dry_run: bool = False, now=None) -> dict:
def _tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool, today, def _tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool, today,
dry_run, out, strategy_service): dry_run, out, strategy_service, accum_ok=True):
"""已挂策略的票: 边三 (派发停买/回明确恢复) 与 边四 (接力切换)。""" """已挂策略的票: 边三 (派发停买/回明确恢复) 与 边四 (接力切换)。"""
code = p["ts_code"] code = p["ts_code"]
note = str(st.get("note") or "") note = str(st.get("note") or "")
@ -534,6 +532,13 @@ def _tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool, today,
out["skipped"].append({"ts_code": code, "why": out["skipped"].append({"ts_code": code, "why":
f"挂着人工策略({_CN_TYPE.get(stype, stype)}), 自动挂载不介入"}) f"挂着人工策略({_CN_TYPE.get(stype, stype)}), 自动挂载不介入"})
return return
if str(st.get("status") or "ACTIVE").upper() != "ACTIVE": # 缺省按 ACTIVE (真行必有值)
# 人为暂停 (PAUSED) 的策略自动挂载**不碰** (2026-08-28 审查修): 原来接力照样把
# 暂停的网格 CANCELLED 再挂一条 ACTIVE 止盈 —— 人特意按下的暂停被自动系统推翻,
# 正是模块头声明要杜绝的「人机拉锯」。查 PAUSED 只为防重复挂载, 不为替它做主。
out["skipped"].append({"ts_code": code,
"why": f"策略处于 {st.get('status')} (人为暂停), 自动挂载不动它"})
return
if stype == "GRID": if stype == "GRID":
# ---- 边四: 接力切换 (先于边三判 —— 都成立时说明已在拉升, 换止盈比停买更对) ---- # ---- 边四: 接力切换 (先于边三判 —— 都成立时说明已在拉升, 换止盈比停买更对) ----
ok_h, why_h = handoff_ready(price=p.get("price"), price_ok=p.get("price_ok"), ok_h, why_h = handoff_ready(price=p.get("price"), price_ok=p.get("price_ok"),
@ -557,10 +562,15 @@ def _tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool, today,
return return
cls = a.get("cls") cls = a.get("cls")
pause_ent = (buypause or {}).get(code) or {} pause_ent = (buypause or {}).get(code) or {}
if cls == CLS_DISTRIB or (cls == CLS_CLEAR and not fresh) or cls in ( # 「45 日窗口内整行缺失」是最彻底的一档失效 (票被移出决策系统覆盖), 原来反而不停
# (cls=None 不落任何分支) —— 比"仅缺 state 字段"还失效却继续逢跌买入 (2026-08-28 修)。
# 仅当取数本身成功 (accum_ok) 才把"查无此票"当失效: 整体取数失败按守成不动。
missing = accum_ok and not a
if cls == CLS_DISTRIB or (cls == CLS_CLEAR and not fresh) or missing or cls in (
CLS_NONE_SIGN, CLS_UNKNOWN, CLS_NOFIELD): CLS_NONE_SIGN, CLS_UNKNOWN, CLS_NOFIELD):
why = ("吸筹定性转高位派发" if cls == CLS_DISTRIB why = ("吸筹定性转高位派发" if cls == CLS_DISTRIB
else "吸筹标志消失或超日龄") else ("45 个交易日窗口内查无该票定性(已出决策系统覆盖), 网格失去定性支撑"
if missing else "吸筹标志消失或超日龄"))
if pause_ent: if pause_ent:
return # 已经停着 (accum 或风控来源), 不重复 return # 已经停着 (accum 或风控来源), 不重复
if dry_run: if dry_run:

View File

@ -224,6 +224,20 @@ def _reconcile(st: dict, state: dict, pos: dict):
return True return True
filled = int(ins.get("exec_qty") or 0) > 0 filled = int(ins.get("exec_qty") or 0) > 0
leg = pending.get("leg") leg = pending.get("leg")
if st.get("type") == "GRID" and str(leg or "").startswith("grid_sell"):
# 网格卖单当日未成交作废 → 把发单时弹掉的档位放回去 (2026-08-28 审查修)。
# 部分成交则按剩余量放回; 全成交不放。invested 同步加回放回部分。
exec_qty = int(ins.get("exec_qty") or 0)
info = dict(pending.get("grid_restore") or {})
idx = pending.get("grid_sell_idx")
left = int(_f(info.get("qty"))) - exec_qty
if idx is not None and info and left >= LOT:
fl = dict(state.get("filled_levels") or {})
fl[str(idx)] = {"price": _f(info.get("price")), "qty": left}
state["filled_levels"] = fl
state["invested"] = _f(state.get("invested")) + _f(info.get("price")) * left
logger.info("[strategy] %s 网格卖档 %s 未全成交 (成 %s), 档位按余量 %s 放回",
st.get("strategy_id"), idx, exec_qty, left)
if st.get("type") == "T0": if st.get("type") == "T0":
if leg == "open" and filled: if leg == "open" and filled:
state["open_leg"] = {"dir": pending.get("dir"), "qty": int(ins.get("exec_qty") or 0), state["open_leg"] = {"dir": pending.get("dir"), "qty": int(ins.get("exec_qty") or 0),
@ -275,7 +289,15 @@ def _eval_t0(st, pos, day, now, ctx):
pressure = _f(pos.get("pressure_ref")) pressure = _f(pos.get("pressure_ref"))
hit = price >= entry * (1 + round_target) or (pressure > 0 and price >= pressure * (1 - NEAR_BAND)) hit = price >= entry * (1 + round_target) or (pressure > 0 and price >= pressure * (1 - NEAR_BAND))
if hit and q > 0: if hit and q > 0:
return {"side": "sell", "action": A_T0, "qty": min(q, avail), "leg": "close", # 平回数量取整 (2026-08-28 审查修): avail 可能是零股 (其它卖出把可卖量
# 消耗成 137 之类), 非整百的部分卖出会被券商拒单并每跳重发。整百部分先平,
# 尾巴交 14:50 兜底与对账。
q_close = min(q, avail)
if q_close % LOT and q_close != int(pos.get("total_qty") or 0):
q_close = _round_lot(q_close)
if q_close <= 0:
return None
return {"side": "sell", "action": A_T0, "qty": q_close, "leg": "close",
"reason": f"正T平回: 现价 {price} 达目标 {entry * (1 + round_target):.2f}(买价 {entry})"} "reason": f"正T平回: 现价 {price} 达目标 {entry * (1 + round_target):.2f}(买价 {entry})"}
else: # 反T: 已卖, 等低买回 else: # 反T: 已卖, 等低买回
support = _f(pos.get("support_ref")) support = _f(pos.get("support_ref"))
@ -432,6 +454,14 @@ def _eval_grid(st, pos, day, now, ctx):
if k < 0: if k < 0:
state["last_band"] = cur state["last_band"] = cur
return None return None
# **只买中枢下方** (2026-08-28 审查修): 档位表覆盖整个区间, 原实现对跌破的任何档
# 都买 —— 价格先涨向上界再回落一档, 就在中枢上方接了一手; 上半区震荡还会把预算
# (按中枢以下档数摊的 per_lot) 先吃光, 真跌回吸筹区反而触顶停买。恰好高买低不买。
center = _f(params.get("center")) or ((lo + _f(params.get("upper"))) / 2
if _f(params.get("upper")) > 0 else 0.0)
if center > 0 and k < len(levels) and levels[k] >= center:
state["last_band"] = last - 1 # 中枢上方: 只随价下移, 不买
return None
if k in filled: if k in filled:
state["last_band"] = last - 1 # 已买, 只推进 state["last_band"] = last - 1 # 已买, 只推进
return None return None
@ -478,9 +508,19 @@ def _eval_trail(st, pos, day, now, ctx):
state["armed"] = True state["armed"] = True
ctx["notes"].append(f"{st['ts_code']} 跟踪止盈已启动(浮盈 {profit:.1%} ≥ 启动线 {start_line:.1%})") ctx["notes"].append(f"{st['ts_code']} 跟踪止盈已启动(浮盈 {profit:.1%} ≥ 启动线 {start_line:.1%})")
if avail < LOT: total = int(pos.get("total_qty") or 0)
return None # 无 T+1 可卖, 只更新高水位
mlot = _min_lot(st.get("ts_code")) mlot = _min_lot(st.get("ts_code"))
if avail < LOT:
# 零股尾巴的出口 (2026-08-28 审查修): 主板整百取整后剩的 <100 股, 原来在这里
# 永远 return None, 挂着策略又被动作引擎排除 —— 尾巴永久滞留。A股规则允许
# 零股**一次性全部卖出**, 所以当尾巴就是全部持仓、且硬目标或回落条件仍成立时,
# 按全清把尾巴一次清掉; 其余情形照旧只更新高水位。
tail_hit = (hard_target > 0 and profit >= hard_target) or (
state.get("armed") and hw > 0 and giveback > 0 and price <= hw * (1 - giveback))
if avail > 0 and avail == total and tail_hit:
return {"side": "sell", "action": A_EXIT, "qty": avail, "leg": "trail_hard",
"reason": f"跟踪止盈-零股收尾: 余 {avail} 股为全部持仓, 一次性清出"}
return None # 无 T+1 可卖 (或尾巴暂不能清), 只更新高水位
def _all_out(): def _all_out():
# 全清数量: 科创板持仓不足 200 股时按交易所例外一次性全卖, 否则整百 # 全清数量: 科创板持仓不足 200 股时按交易所例外一次性全卖, 否则整百
@ -491,8 +531,15 @@ def _eval_trail(st, pos, day, now, ctx):
return {"side": "sell", "action": A_EXIT, "qty": _all_out(), "leg": "trail_hard", return {"side": "sell", "action": A_EXIT, "qty": _all_out(), "leg": "trail_hard",
"reason": f"跟踪止盈-硬目标: 浮盈 {profit:.1%}{hard_target:.1%}, 全清 avail {avail}"} "reason": f"跟踪止盈-硬目标: 浮盈 {profit:.1%}{hard_target:.1%}, 全清 avail {avail}"}
# 已武装且从高点回落到设定比例 → 卖 # 已武装且从高点回落到设定比例 → 卖。
if state.get("armed") and hw > 0 and price <= hw * (1 - giveback) and giveback > 0: # **部分卖有一次性闩锁** (2026-08-28 审查修): "回撤 5% 卖 50%" 的本意是这轮回撤卖一次。
# 原来高水位只抬不降、armed 永不复位, 卖完上一单后条件仍成立, 每隔一单再卖剩余的一半,
# 几何级联直到卖光。闩锁记"已按哪个高水位卖过" (trail_fired_hw), 只有高水位**再创新高**
# 之后的下一轮回撤才允许再卖; 全清路径 (sell_ratio≥1 与硬目标) 不上锁 —— 清仓意图
# 失败了就该重试。
fired_hw = _f(state.get("trail_fired_hw"))
if state.get("armed") and hw > 0 and price <= hw * (1 - giveback) and giveback > 0 \
and (sell_ratio >= 1 or hw > fired_hw + 1e-9):
if sell_ratio < 1: if sell_ratio < 1:
q = _round_lot(avail * sell_ratio) q = _round_lot(avail * sell_ratio)
if mlot > LOT: if mlot > LOT:
@ -503,6 +550,8 @@ def _eval_trail(st, pos, day, now, ctx):
q = _all_out() q = _all_out()
if q >= mlot or (sell_ratio >= 1 and q > 0): if q >= mlot or (sell_ratio >= 1 and q > 0):
act = A_EXIT if sell_ratio >= 1 else A_SELL act = A_EXIT if sell_ratio >= 1 else A_SELL
if sell_ratio < 1:
state["trail_fired_hw"] = round(hw, 3)
return {"side": "sell", "action": act, "qty": q, "leg": "trail_sell", return {"side": "sell", "action": act, "qty": q, "leg": "trail_sell",
"reason": f"跟踪止盈: 现价 {price} 自高点 {hw} 回落 {1 - price / hw:.1%}{giveback:.1%}, 卖 {q}"} "reason": f"跟踪止盈: 现价 {price} 自高点 {hw} 回落 {1 - price / hw:.1%}{giveback:.1%}, 卖 {q}"}
return None return None
@ -639,7 +688,9 @@ def tick(*, now=None, dry_run: bool = False) -> dict:
else: else:
iid = _emit_instruction(st, dec, forced=False) iid = _emit_instruction(st, dec, forced=False)
state["pending"] = {"iid": iid, "leg": dec["leg"], "dir": dec.get("dir"), state["pending"] = {"iid": iid, "leg": dec["leg"], "dir": dec.get("dir"),
"qty": dec["qty"], "entry": dec.get("entry")} "qty": dec["qty"], "entry": dec.get("entry"),
"grid_sell_idx": dec.get("grid_sell_idx"),
"grid_restore": dec.get("_grid_restore")}
out["fired"].append({"strategy_id": st["strategy_id"], "instruction_id": iid, out["fired"].append({"strategy_id": st["strategy_id"], "instruction_id": iid,
"side": dec["side"], "qty": dec["qty"], "reason": dec["reason"]}) "side": dec["side"], "qty": dec["qty"], "reason": dec["reason"]})
else: else:
@ -657,7 +708,12 @@ def tick(*, now=None, dry_run: bool = False) -> dict:
def _apply_grid_state(state: dict, dec: dict): def _apply_grid_state(state: dict, dec: dict):
"""网格发单同时更新 filled_levels / invested (买入占用一档, 卖出释放一档)。""" """网格发单同时更新 filled_levels / invested (买入占用一档, 卖出释放一档)。
卖出弹掉的档位信息随手塞回 dec (_grid_restore): 委托当日一股没成交时,
_reconcile 要把这档**放回去** 原来发单即弹档未成交不回滚, 股票还在档位
记录没了, 下次跌破同档会再买一手 (同档双份), invested 也被提前扣掉导致上限
实际被突破一档 (2026-08-28 审查修)"""
if "grid_buy_idx" in dec: if "grid_buy_idx" in dec:
filled = dict(state.get("filled_levels") or {}) filled = dict(state.get("filled_levels") or {})
filled[str(dec["grid_buy_idx"])] = {"price": _f(dec.get("grid_buy_price")), filled[str(dec["grid_buy_idx"])] = {"price": _f(dec.get("grid_buy_price")),
@ -669,6 +725,7 @@ def _apply_grid_state(state: dict, dec: dict):
info = filled.pop(str(dec["grid_sell_idx"]), None) info = filled.pop(str(dec["grid_sell_idx"]), None)
state["filled_levels"] = filled state["filled_levels"] = filled
if info: if info:
dec["_grid_restore"] = dict(info)
state["invested"] = max(0.0, _f(state.get("invested")) - _f(info.get("price")) * int(info.get("qty") or 0)) state["invested"] = max(0.0, _f(state.get("invested")) - _f(info.get("price")) * int(info.get("qty") or 0))
@ -712,7 +769,11 @@ def force_t0_close(*, now=None) -> dict:
out["skipped"].append({"strategy_id": st["strategy_id"], "why": "残量不足一手"}) out["skipped"].append({"strategy_id": st["strategy_id"], "why": "残量不足一手"})
continue continue
if open_leg.get("dir") == "long": # 正T 已买 → 卖平 if open_leg.get("dir") == "long": # 正T 已买 → 卖平
dec = {"side": "sell", "action": A_T0, "qty": min(q, int(pos.get("avail_qty") or 0)), avail_now = int(pos.get("avail_qty") or 0)
q_close = min(q, avail_now)
if q_close % LOT and q_close != int(pos.get("total_qty") or 0):
q_close = _round_lot(q_close) # 零股平回会被拒单, 整百部分先平 (2026-08-28)
dec = {"side": "sell", "action": A_T0, "qty": q_close,
"leg": "close", "reason": "14:50 强制平回(正T)"} "leg": "close", "reason": "14:50 强制平回(正T)"}
else: # 反T 已卖 → 买平 else: # 反T 已卖 → 买平
dec = {"side": "buy", "action": A_T0, "qty": q, "leg": "close", dec = {"side": "buy", "action": A_T0, "qty": q, "leg": "close",

View File

@ -166,13 +166,24 @@ def _save_buypause(m: dict) -> dict:
def pause_buy(ts_code: str, *, reason: str = "", source: str = "signal") -> list: def pause_buy(ts_code: str, *, reason: str = "", source: str = "signal") -> list:
"""暂停某票所有 ACTIVE 策略的买入这一侧 (决策系统风控预警触发)。返回受影响的 strategy_id。 """暂停某票所有 ACTIVE 策略的买入这一侧 (决策系统风控预警触发)。返回受影响的 strategy_id。
只标记不平仓不动卖出; 由页面恢复买入(resume_buy) 解除""" 只标记不平仓不动卖出; 由页面恢复买入(resume_buy) 解除
**按来源分别记** (2026-08-28 审查修): 同一票可以同时被 accum (定性失效) signal
(风控预警) 两个来源暂停原来先写先赢后来的来源被吞 之后 advisor accum 来源
解除时, 会把风控停的也顺手放开条目里加 sources 子表, 顶层字段保持最近一次的值
(向后兼容只读顶层的消费方)"""
if not ts_code: if not ts_code:
return [] return []
m = buypause_map() m = buypause_map()
if ts_code not in m: ent = dict(m.get(ts_code) or {})
sources = dict(ent.get("sources") or {})
if not sources and ent.get("source"): # 旧格式条目: 迁移成 sources 子表
sources[ent["source"]] = {"reason": ent.get("reason") or "", "at": ent.get("at") or ""}
if source not in sources:
sources[source] = {"reason": (str(reason)[:300] if reason else ""),
"at": str(datetime.now())[:19]}
m[ts_code] = {"reason": (str(reason)[:300] if reason else ""), "source": source, m[ts_code] = {"reason": (str(reason)[:300] if reason else ""), "source": source,
"at": str(datetime.now())[:19]} "at": str(datetime.now())[:19], "sources": sources}
_save_buypause(m) _save_buypause(m)
try: try:
return [s.get("strategy_id") for s in return [s.get("strategy_id") for s in
@ -208,9 +219,25 @@ def clear_buypause(ts_code: str, only_source: str = None) -> dict:
ent = m.get(ts_code) ent = m.get(ts_code)
if not ent: if not ent:
return {"ok": True, "cleared": False} return {"ok": True, "cleared": False}
if only_source and ent.get("source") != only_source: sources = dict(ent.get("sources") or {})
return {"ok": True, "cleared": False, if not sources and ent.get("source"): # 旧格式条目
"why": f"暂停来源是 {ent.get('source')}, 不动 (只清 {only_source})"} sources[ent["source"]] = {"reason": ent.get("reason") or "", "at": ent.get("at") or ""}
if only_source:
if only_source not in sources:
return {"ok": True, "cleared": False,
"why": f"暂停来源是 {sorted(sources) or [ent.get('source')]}, "
f"不动 (只清 {only_source})"}
sources.pop(only_source, None)
if sources:
# 还有别的来源在停 (比如风控): 只摘掉自己的, 票保持暂停
left_src, left = sorted(sources.items())[0]
m[ts_code] = {"reason": left.get("reason") or "", "source": left_src,
"at": left.get("at") or "", "sources": sources}
r = _save_buypause(m)
if not r.get("ok"):
return {"ok": False, "cleared": False, "error": r.get("error")}
return {"ok": True, "cleared": True, "still_paused_by": sorted(sources),
"ts_code": ts_code}
m.pop(ts_code, None) m.pop(ts_code, None)
r = _save_buypause(m) r = _save_buypause(m)
if not r.get("ok"): if not r.get("ok"):

View File

@ -90,6 +90,18 @@ def ok(fn, *args, **kw):
return {"ok": False, "error": f"{type(e).__name__}: {e}"} return {"ok": False, "error": f"{type(e).__name__}: {e}"}
def _actor(request: Request, payload: dict = None) -> str:
"""操作人一律取**会话里的登录身份** (2026-08-28 审查修): 操作日志是问责凭证,
原来所有写接口的操作人取自请求体 payload.by 任何登录用户都能在 JSON 里写别人的
名字, 把下单撤单记到他人头上认证身份中间件已放在 request.state.user, 就用它;
关闭登录 (PMS_AUTH_ENABLED=False) 时才退回请求体与 "user" 兜底"""
u = getattr(request.state, "user", None) or {}
name = str(u.get("username") or u.get("phone") or "").strip()
if name:
return name
return str((payload or {}).get("by") or "user")
def _oplog(op, *, ok_flag, reason=None, ts_code=None, params=None, ref=None, by="user"): def _oplog(op, *, ok_flag, reason=None, ts_code=None, params=None, ref=None, by="user"):
"""交易员操作日志: 每个写操作落一行 (OK/BLOCKED 都记, 含原因)。日志失败不影响操作本体 (但记 logger)。""" """交易员操作日志: 每个写操作落一行 (OK/BLOCKED 都记, 含原因)。日志失败不影响操作本体 (但记 logger)。"""
try: try:
@ -118,7 +130,24 @@ def ok_logged(op, fn, *args, ts_code=None, params=None, by="user", **kw):
# ================================================================ 基础 # ================================================================ 基础
@app.get("/health") @app.get("/health")
def health(): def health():
"""容器健康检查 + 自证: 配置装载、库连通、参数来源、交易日历。""" """容器健康检查 (**免登录, 只回布尔级状态**)。
2026-08-28 审查修: 原来免登录就吐总操作规模各仓位上限自主档位和数据库错误串
(可能带内网地址) 对一套下真钱的系统, 这些是未认证不该看到的健康检查只需要
活不活; 完整自证挪到管理员专属的 /api/ops/health-detail"""
db = dbs.ping("proxy")
return {
"status": "ok" if db["ok"] else "degraded",
"version": VERSION, "service": "pms-web",
"now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"trade_day": td.is_trade_day(), "calendar_degraded": td.calendar_degraded(),
"db_ok": bool(db.get("ok")),
}
@app.get("/api/ops/health-detail")
def api_health_detail():
"""完整自证 (系统管理员): 配置装载、库连通、行业源、下发通道 —— 原 /health 的全量内容。"""
db = dbs.ping("proxy") db = dbs.ping("proxy")
return { return {
"status": "ok" if db["ok"] else "degraded", "status": "ok" if db["ok"] else "degraded",
@ -231,23 +260,38 @@ def api_params():
return ok(param_store.snapshot) return ok(param_store.snapshot)
# 只有系统管理员能改的开关 (2026-08-28 审查加): 把下发通道从影子切到实盘直连是
# 运维级动作, 不属于交易员的"参数微调"。其余键的边界维持用户定的原则不动。
_ADMIN_ONLY_PARAM_KEYS = {"PMS_DISPATCH_MODE"}
@app.post("/api/params") @app.post("/api/params")
def api_set_params(payload: dict = Body(...)): def api_set_params(request: Request, payload: dict = Body(...)):
"""单个 {key, value} 或批量 {items:[{key,value}...]}。逐项返回结果, 部分失败不整体回滚。""" """单个 {key, value} 或批量 {items:[{key,value}...]}。逐项返回结果, 部分失败不整体回滚。"""
items = payload.get("items") or [{"key": payload.get("key"), "value": payload.get("value")}] items = payload.get("items") or [{"key": payload.get("key"), "value": payload.get("value")}]
actor = _actor(request, payload)
sess = getattr(request.state, "user", None) or {}
is_admin = (not settings.PMS_AUTH_ENABLED) or authmod.is_admin(sess.get("roles"))
results = [] results = []
for it in items: for it in items:
if not isinstance(it, dict):
# 元素不是对象直接给出明确错误, 不再冒 500 (2026-08-28 审查修)
results.append({"ok": False, "error": f"条目格式非法 (应为 {{key, value}}): {it!r}"})
continue
k = it.get("key") k = it.get("key")
if not k: if not k:
results.append({"ok": False, "error": "缺少 key"}) results.append({"ok": False, "error": "缺少 key"})
continue continue
results.append(param_store.set_param(k, it.get("value"), if k in _ADMIN_ONLY_PARAM_KEYS and not is_admin:
updated_by=payload.get("by") or "user")) results.append({"ok": False, "error": f"{k} 需要系统管理员才能修改"})
continue
results.append(param_store.set_param(k, it.get("value"), updated_by=actor))
_okf = all(r.get("ok") for r in results) _okf = all(r.get("ok") for r in results)
_oplog("set_params", ok_flag=_okf, _oplog("set_params", ok_flag=_okf,
params={it.get("key"): it.get("value") for it in items}, params={(it.get("key") if isinstance(it, dict) else str(it)):
(it.get("value") if isinstance(it, dict) else None) for it in items},
reason=(None if _okf else "; ".join((r.get("error") or "") for r in results if not r.get("ok"))), reason=(None if _okf else "; ".join((r.get("error") or "") for r in results if not r.get("ok"))),
by=payload.get("by") or "user") by=actor)
return {"ok": _okf, "results": results} return {"ok": _okf, "results": results}
@ -283,13 +327,14 @@ def api_command_detail(command_id: str):
@app.post("/api/commands") @app.post("/api/commands")
def api_issue(payload: dict = Body(...)): def api_issue(request: Request, payload: dict = Body(...)):
actor = _actor(request, payload)
return ok_logged("issue_command:" + str(payload.get("cmd_type")), return ok_logged("issue_command:" + str(payload.get("cmd_type")),
command_service.issue, payload.get("cmd_type"), payload.get("params") or {}, command_service.issue, payload.get("cmd_type"), payload.get("params") or {},
note=payload.get("note"), issued_by=payload.get("by") or "user", note=payload.get("note"), issued_by=actor,
force_conflict=bool(payload.get("force")), force_conflict=bool(payload.get("force")),
ts_code=(payload.get("params") or {}).get("ts_code"), ts_code=(payload.get("params") or {}).get("ts_code"),
params=payload, by=payload.get("by") or "user") params=payload, by=actor)
@app.post("/api/commands/{command_id}/cancel") @app.post("/api/commands/{command_id}/cancel")
@ -359,7 +404,7 @@ def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100,
@app.post("/api/proposals/{proposal_id}/decide") @app.post("/api/proposals/{proposal_id}/decide")
def api_decide(proposal_id: str, payload: dict = Body(default={})): def api_decide(request: Request, proposal_id: str, payload: dict = Body(default={})):
"""采纳/驳回一条自主提议。采纳 = 先落指令表 (先记账后动作), 下发由择时执行器负责。""" """采纳/驳回一条自主提议。采纳 = 先落指令表 (先记账后动作), 下发由择时执行器负责。"""
decision = str(payload.get("decision") or "").upper() decision = str(payload.get("decision") or "").upper()
if decision not in ("ACCEPTED", "DECLINED"): if decision not in ("ACCEPTED", "DECLINED"):
@ -411,7 +456,7 @@ def api_decide(proposal_id: str, payload: dict = Body(default={})):
return {"ok": True, "decision": decision, "instruction_id": instruction_id} return {"ok": True, "decision": decision, "instruction_id": instruction_id}
return ok_logged("decide_proposal", _decide, return ok_logged("decide_proposal", _decide,
params={"proposal_id": proposal_id, "decision": decision}, params={"proposal_id": proposal_id, "decision": decision},
by=payload.get("by") or "user") by=_actor(request, payload))
@app.post("/api/proposals") @app.post("/api/proposals")
@ -771,24 +816,24 @@ def api_strategy_validate(payload: dict = Body(...)):
@app.post("/api/strategies") @app.post("/api/strategies")
def api_strategy_attach(payload: dict = Body(...)): def api_strategy_attach(request: Request, payload: dict = Body(...)):
"""挂载一条策略 (先校验再落库)。校验不过返回 {ok:false, errors}, 并落一条 BLOCKED 操作日志。""" """挂载一条策略 (先校验再落库)。校验不过返回 {ok:false, errors}, 并落一条 BLOCKED 操作日志。"""
from app.services import strategy_service from app.services import strategy_service
return ok_logged("attach_strategy:" + str(payload.get("type")), return ok_logged("attach_strategy:" + str(payload.get("type")),
strategy_service.attach, payload, strategy_service.attach, payload,
ts_code=payload.get("ts_code"), params=payload, ts_code=payload.get("ts_code"), params=payload,
by=payload.get("by") or "user") by=_actor(request, payload))
@app.post("/api/strategies/{strategy_id}/status") @app.post("/api/strategies/{strategy_id}/status")
def api_strategy_status(strategy_id: str, payload: dict = Body(...)): def api_strategy_status(request: Request, strategy_id: str, payload: dict = Body(...)):
"""暂停(PAUSED)/恢复(ACTIVE)/撤下(CANCELLED): payload {status}""" """暂停(PAUSED)/恢复(ACTIVE)/撤下(CANCELLED): payload {status}"""
from app.services import strategy_service from app.services import strategy_service
return ok_logged("set_strategy_status:" + str(payload.get("status")), return ok_logged("set_strategy_status:" + str(payload.get("status")),
strategy_service.set_status, strategy_id, strategy_service.set_status, strategy_id,
str(payload.get("status") or "").upper(), str(payload.get("status") or "").upper(),
params={"strategy_id": strategy_id, "status": payload.get("status")}, params={"strategy_id": strategy_id, "status": payload.get("status")},
by=payload.get("by") or "user") by=_actor(request, payload))
# ================================ 软归档: 把终态记录从在办/在途/待确认/策略列表移除 (不删行, 可恢复) # ================================ 软归档: 把终态记录从在办/在途/待确认/策略列表移除 (不删行, 可恢复)
@ -844,8 +889,8 @@ def api_op_log(limit: int = Query(200)):
@app.post("/api/strategies/{strategy_id}/resume-buy") @app.post("/api/strategies/{strategy_id}/resume-buy")
def api_strategy_resume_buy(strategy_id: str, payload: dict = Body(default={})): def api_strategy_resume_buy(request: Request, strategy_id: str, payload: dict = Body(default={})):
"""恢复该策略的买入 (决策系统风控预警触发的暂停由你手动解除; 只恢复买入, 不影响卖出/平回)。""" """恢复该策略的买入 (决策系统风控预警触发的暂停由你手动解除; 只恢复买入, 不影响卖出/平回)。"""
from app.services import strategy_service from app.services import strategy_service
return ok_logged("resume_strategy_buy", strategy_service.resume_buy, strategy_id, return ok_logged("resume_strategy_buy", strategy_service.resume_buy, strategy_id,
params={"strategy_id": strategy_id}, by=payload.get("by") or "user") params={"strategy_id": strategy_id}, by=_actor(request, payload))

View File

@ -786,8 +786,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</div> </div>
<div> <div>
<div class="exp-h">这只票今天的记录</div> <div class="exp-h">这只票今天的记录</div>
<div class="muted" v-if="!ledger.filter(x=>x.ts_code===s.row.ts_code).length" style="padding:4px 0">今天还没有记录</div> <div class="muted" v-if="!ledgerToday.filter(x=>x.ts_code===s.row.ts_code).length" style="padding:4px 0">今天还没有记录</div>
<div v-for="(l,i) in ledger.filter(x=>x.ts_code===s.row.ts_code)" :key="i" class="exp-led"> <div v-for="(l,i) in ledgerToday.filter(x=>x.ts_code===s.row.ts_code)" :key="i" class="exp-led">
<span class="muted mono">{{ (l.decided_at||'').slice(5,16) }}</span> <span class="muted mono">{{ (l.decided_at||'').slice(5,16) }}</span>
{{ tx('arbiter', l.arbiter) }}{{ tx('verdict', l.verdict) }} {{ tx('action', l.action) }}<span v-if="l.reason" class="muted"> —— {{ l.reason }}</span> {{ tx('arbiter', l.arbiter) }}{{ tx('verdict', l.verdict) }} {{ tx('action', l.action) }}<span v-if="l.reason" class="muted"> —— {{ l.reason }}</span>
</div> </div>
@ -906,12 +906,12 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<!-- 今天发生了什么: 账本 + 操作记录, 默认收起 --> <!-- 今天发生了什么: 账本 + 操作记录, 默认收起 -->
<div class="panel tblk"> <div class="panel tblk">
<h3 style="cursor:pointer" @click="showLedger=!showLedger">今天发生了什么 <h3 style="cursor:pointer" @click="showLedger=!showLedger">今天发生了什么
<span class="muted" style="font-weight:400">· {{ ledger.length }} 条</span> <span class="muted" style="font-weight:400">· {{ ledgerToday.length }} 条</span>
<span class="muted" style="font-size:12px;font-weight:normal">{{ showLedger?'▲ 收起':'▼ 展开' }}</span></h3> <span class="muted" style="font-size:12px;font-weight:normal">{{ showLedger?'▲ 收起':'▼ 展开' }}</span></h3>
<template v-if="showLedger"> <template v-if="showLedger">
<div class="muted" style="margin-bottom:6px">系统的每一次判断都留一行。</div> <div class="muted" style="margin-bottom:6px">系统的每一次判断都留一行。</div>
<div class="muted" v-if="!ledger.length">今天还没有记录。</div> <div class="muted" v-if="!ledgerToday.length">今天还没有记录。</div>
<div v-for="(l,i) in ledger" :key="i" class="feed-row"> <div v-for="(l,i) in ledgerToday" :key="i" class="feed-row">
<span class="muted mono">{{ (l.decided_at||'').slice(11,16) }}</span> <span class="muted mono">{{ (l.decided_at||'').slice(11,16) }}</span>
<b> {{ nm(l.ts_code) }}</b>{{ tx('arbiter', l.arbiter) }}{{ tx('verdict', l.verdict) }} {{ tx('action', l.action) }}<span v-if="l.reason" class="muted"> —— {{ l.reason }}</span> <b> {{ nm(l.ts_code) }}</b>{{ tx('arbiter', l.arbiter) }}{{ tx('verdict', l.verdict) }} {{ tx('action', l.action) }}<span v-if="l.reason" class="muted"> —— {{ l.reason }}</span>
</div> </div>
@ -990,7 +990,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div class="row" style="margin-bottom:8px"> <div class="row" style="margin-bottom:8px">
<el-checkbox v-model="showAllStrategies" @change="loadStrategies" size="small" style="font-weight:normal">显示已撤下和已完成</el-checkbox> <el-checkbox v-model="showAllStrategies" @change="loadStrategies" size="small" style="font-weight:normal">显示已撤下和已完成</el-checkbox>
<el-button size="small" :loading="autoScanBusy" @click="autoScanPreview">试算一遍</el-button> <el-button v-if="isAdmin" size="small" :loading="autoScanBusy" @click="autoScanPreview">试算一遍</el-button>
<span class="help" data-tip="按现在的行情和信号算一遍,看这一轮自动挂方案会做什么。只算给你看,不执行、不留任何记录。">?</span> <span class="help" data-tip="按现在的行情和信号算一遍,看这一轮自动挂方案会做什么。只算给你看,不执行、不留任何记录。">?</span>
</div> </div>
@ -1741,7 +1741,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<!-- 上游选股计划抽屉 --> <!-- 上游选股计划抽屉 -->
<el-drawer v-model="planDrawer" title="上游选股计划 (候选池的事实源)" size="56%"> <el-drawer v-model="planDrawer" title="上游选股计划 (候选池的事实源)" size="56%">
<div class="row" style="margin-bottom:8px"> <div class="row" style="margin-bottom:8px">
<el-button type="primary" :loading="planLoading" @click="refreshPlan"> <el-button v-if="isAdmin" type="primary" :loading="planLoading" @click="refreshPlan">
强刷 + 灌行业映射</el-button> 强刷 + 灌行业映射</el-button>
<el-button :loading="planLoading" @click="loadPlan">只重读</el-button> <el-button :loading="planLoading" @click="loadPlan">只重读</el-button>
<span class="muted" style="margin-left:8px"> <span class="muted" style="margin-left:8px">
@ -2043,7 +2043,9 @@ createApp({
return Math.abs(g) > 0.10 ? g : 0; return Math.abs(g) > 0.10 ? g : 0;
}); });
const wsc = computed(() => wsRaw.value.channel || {}); // 通道状态优先取 ws-channel (管理员接口), 取不到回落 dispatch-mode 里的 channel ——
// 纯交易员没有 ws-channel 权限, 原来「通道没连上」这类告警对他们永远不亮 (2026-08-28)。
const wsc = computed(() => wsRaw.value.channel || (dm.value || {}).channel || {});
const wst = computed(() => wsc.value.stat || {}); const wst = computed(() => wsc.value.stat || {});
const wsMode = computed(() => wsRaw.value.mode || ''); const wsMode = computed(() => wsRaw.value.mode || '');
const wsOrders = computed(() => wsRaw.value.orders || []); const wsOrders = computed(() => wsRaw.value.orders || []);
@ -2166,6 +2168,15 @@ createApp({
const heldPositions = computed(() => (positions.value || []).filter(p => (p.total_qty || 0) > 0)); const heldPositions = computed(() => (positions.value || []).filter(p => (p.total_qty || 0) > 0));
// ---- 今日在办分档 + 候选处置 (2026-08-12 重构新增) ---- // ---- 今日在办分档 + 候选处置 (2026-08-12 重构新增) ----
const todayYmd = computed(() => (String(health.value.now || '').slice(0, 10).replace(/-/g, '')) || ''); const todayYmd = computed(() => (String(health.value.now || '').slice(0, 10).replace(/-/g, '')) || '');
const todayDateStr = computed(() => String(health.value.now || '').slice(0, 10));
// 「今天发生了什么 / 这只票今天的记录」只收 decided_at 是今天的行 (2026-08-28):
// /api/ledger 按 id 倒序取近 60 条不带日期条件, 当天记录少时列表被前几天的行填满,
// 主面板又只显示时分 —— 昨天 14:31 的驳回看起来就是今天 14:31 发生的。
const ledgerToday = computed(() => {
const t = todayDateStr.value;
if (!t) return ledger.value || [];
return (ledger.value || []).filter(l => String(l.decided_at || '').slice(0, 10) === t);
});
// 是否处于连续交易时段 (09:30-11:30 / 13:00-15:00 的交易日)。缺实时价在盘中才是问题, // 是否处于连续交易时段 (09:30-11:30 / 13:00-15:00 的交易日)。缺实时价在盘中才是问题,
// 休市/非交易日本就没有实时行情, 不该报警 —— 2026-08-12 用户提。 // 休市/非交易日本就没有实时行情, 不该报警 —— 2026-08-12 用户提。
const marketOpen = computed(() => { const marketOpen = computed(() => {
@ -2179,7 +2190,10 @@ createApp({
const _LIVE_INS = ['PROPOSED', 'RULE_PASSED', 'JUDGE_PASSED', 'DISPATCHED']; const _LIVE_INS = ['PROPOSED', 'RULE_PASSED', 'JUDGE_PASSED', 'DISPATCHED'];
const _isArch = r => !!(r && r.archived_at); // 软归档: 有 archived_at = 已从视图移除 (行仍在库) const _isArch = r => !!(r && r.archived_at); // 软归档: 有 archived_at = 已从视图移除 (行仍在库)
const _sameDayIns = r => { const y = todayYmd.value; const _sameDayIns = r => { const y = todayYmd.value;
return !y || String(r.instruction_id || '').indexOf('INS_' + y + '_') === 0; }; const id = String(r.instruction_id || '');
// 策略指令的前缀是 STR{ymd} (strategy_runner), 不是 INS_ —— 原来它们一进终态就从
// 「今日成交/已经结束」和消息栏同时消失, 交易员眼看着自己的单子凭空不见 (2026-08-28)
return !y || id.indexOf('INS_' + y + '_') === 0 || id.indexOf('STR' + y) === 0; };
// 已归档的一律不进「今日在办」三档 —— 归档=从所有面板隐藏。 // 已归档的一律不进「今日在办」三档 —— 归档=从所有面板隐藏。
const insLive = computed(() => (instructions.value || []).filter(r => _LIVE_INS.includes(r.status) && !_isArch(r))); const insLive = computed(() => (instructions.value || []).filter(r => _LIVE_INS.includes(r.status) && !_isArch(r)));
const insDone = computed(() => (instructions.value || []).filter(r => r.status === 'CONFIRMED' && _sameDayIns(r) && !_isArch(r))); const insDone = computed(() => (instructions.value || []).filter(r => r.status === 'CONFIRMED' && _sameDayIns(r) && !_isArch(r)));
@ -2198,7 +2212,8 @@ createApp({
const stratArchivable = r => ['CANCELLED', 'DONE'].includes(r.status); const stratArchivable = r => ['CANCELLED', 'DONE'].includes(r.status);
// 每只候选今天为什么下单/没下单: 已落地的事实(指令/提议)优先, 其余看 /api/open-scan 的只读处置。 // 每只候选今天为什么下单/没下单: 已落地的事实(指令/提议)优先, 其余看 /api/open-scan 的只读处置。
function dispOf(code) { function dispOf(code) {
const inst = (instructions.value || []).find(i => i.ts_code === code && i.action === 'OPEN'); const inst = (instructions.value || []).find(i => i.ts_code === code && i.action === 'OPEN'
&& (_LIVE_INS.includes(i.status) || _sameDayIns(i))); // 旧的终态单不算「已下单」(2026-08-28)
if (inst) return { cls: 'done', label: '已下单', why: '在『今日在办』(' + tx('insStatus', inst.status) + '' }; if (inst) return { cls: 'done', label: '已下单', why: '在『今日在办』(' + tx('insStatus', inst.status) + '' };
const prop = (proposals.value || []).find(p => p.ts_code === code && p.action === 'OPEN'); const prop = (proposals.value || []).find(p => p.ts_code === code && p.action === 'OPEN');
if (prop) return { cls: 'queue', label: '待你确认', why: '已生成提议,在『等我拍板』等你点头' }; if (prop) return { cls: 'queue', label: '待你确认', why: '已生成提议,在『等我拍板』等你点头' };
@ -2266,20 +2281,35 @@ createApp({
return n; return n;
} catch (e) { return null; } } catch (e) { return null; }
} }
const quickBusy = ref(false); // 防重复下达: 双击会发出两条相同的资金命令 (2026-08-28)
async function quickCmd(cmd_type, params, confirmMsg) { async function quickCmd(cmd_type, params, confirmMsg) {
if (confirmMsg) { if (quickBusy.value) { ElementPlus.ElMessage.info('上一条命令还在下达,请稍候'); return; }
try { await ElementPlus.ElMessageBox.confirm(confirmMsg, '确认', { type: 'warning' }); } quickBusy.value = true;
catch (e) { return; } try {
} if (confirmMsg) {
let d = await call('post', '/api/commands', { cmd_type, params: params || {}, note: '交易员视图' }); try { await ElementPlus.ElMessageBox.confirm(confirmMsg, '确认', { type: 'warning' }); }
if (!d.ok && (d.conflicts || []).length) { catch (e) { return; }
try { }
await ElementPlus.ElMessageBox.confirm('和还没做完的命令有冲突,还是要下达吗?', '命令冲突', { type: 'warning' }); let d = await call('post', '/api/commands', { cmd_type, params: params || {}, note: '交易员视图' });
d = await call('post', '/api/commands', { cmd_type, params: params || {}, note: '交易员视图', force: true }); if (!d.ok && (d.conflicts || []).length) {
} catch (e) { return; } try {
} await ElementPlus.ElMessageBox.confirm('和还没做完的命令有冲突,还是要下达吗?', '命令冲突', { type: 'warning' });
if (d.ok) { ElementPlus.ElMessage.success('已下达 ' + (d.command_id || '')); await loadAll(); } d = await call('post', '/api/commands', { cmd_type, params: params || {}, note: '交易员视图', force: true });
else ElementPlus.ElMessage.error((d.errors || [d.error]).join('; ')); } catch (e) { return; }
}
if (d.ok && String(d.status || '') === 'CANCELLED') {
// 后端对「该产方案却一条没产」的命令回 ok:true + status=CANCELLED ——
// 原来只看 ok 报「已下达」, 用户以为风险在削减, 实际命令已自我作废 (2026-08-28)
const why = ((d.plan || {}).reject_summary) || ((d.plan || {}).notes || []).join('')
|| '没有产出任何可执行的方案';
ElementPlus.ElMessage.warning('命令没有生效(已自动作废):' + why);
await loadAll();
} else if (d.ok) {
ElementPlus.ElMessage.success('已下达 ' + (d.command_id || '')); await loadAll();
} else {
ElementPlus.ElMessage.error((d.errors || [d.error]).join('; '));
}
} finally { quickBusy.value = false; }
} }
// 个股 // 个股
function actExitStock(row) { quickCmd('EXIT_STOCK', { ts_code: row.ts_code }, '清仓 ' + nm(row.ts_code) + '?系统会在允许下单的时间段里挑时机把它卖光。'); } function actExitStock(row) { quickCmd('EXIT_STOCK', { ts_code: row.ts_code }, '清仓 ' + nm(row.ts_code) + '?系统会在允许下单的时间段里挑时机把它卖光。'); }
@ -2455,6 +2485,12 @@ createApp({
.filter(p => String(p.value) !== String(paramsRaw.value[p.key])) .filter(p => String(p.value) !== String(paramsRaw.value[p.key]))
.map(p => ({ key: p.key, value: p.value })); .map(p => ({ key: p.key, value: p.value }));
const d = await call('post', '/api/params', { items }); const d = await call('post', '/api/params', { items });
if (d.ok === false && !d.results) {
// 传输层失败 (超时/断网): 没有 results 不等于全成功 —— 原来这里报「已保存」
// 还立刻用服务器旧值把你刚改的输入冲掉 (2026-08-28)。保留输入, 由你重试。
ElementPlus.ElMessage.error('保存失败(网络或服务不可用):' + (d.error || '') + ',你的修改还在输入框里,请重试');
return;
}
const bad = (d.results || []).filter(r => !r.ok); const bad = (d.results || []).filter(r => !r.ok);
if (bad.length) ElementPlus.ElMessage.error(bad.map(b => b.error).join('; ')); if (bad.length) ElementPlus.ElMessage.error(bad.map(b => b.error).join('; '));
else ElementPlus.ElMessage.success('已保存 ' + items.length + ' 项'); else ElementPlus.ElMessage.success('已保存 ' + items.length + ' 项');
@ -2726,7 +2762,7 @@ createApp({
// 会把日志和连接数刷得很难看, 排查问题时反而碍事。 // 会把日志和连接数刷得很难看, 排查问题时反而碍事。
function wsPollSync() { function wsPollSync() {
const want = tab.value === 'ws' && wsAuto.value; const want = tab.value === 'ws' && wsAuto.value;
if (want && !wsTimer) { loadWs(); wsTimer = setInterval(loadWs, 3000); } if (want && !wsTimer) { loadWs(); wsTimer = setInterval(() => { if (authed.value) loadWs(); }, 3000); }
if (!want && wsTimer) { clearInterval(wsTimer); wsTimer = null; } if (!want && wsTimer) { clearInterval(wsTimer); wsTimer = null; }
} }
watch([tab, wsAuto], wsPollSync); watch([tab, wsAuto], wsPollSync);
@ -2736,7 +2772,11 @@ createApp({
loading.value = true; err.value = ''; loading.value = true; err.value = '';
await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(), await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(),
loadPositions(), loadInstructions(), loadLedger(), loadProposals(), loadPositions(), loadInstructions(), loadLedger(), loadProposals(),
loadDispatchMode(), loadWs(), loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream(), loadDispatchMode(),
// ws-channel 是管理员专属接口: 交易员不调它, 免得每次刷新都弹一条「无权限」
// (通道告警对交易员改由 dispatch-mode 里的 channel 兜底, 见 wsc 的注释)
(isAdmin.value ? loadWs() : Promise.resolve()),
loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream(),
loadMacro()]); loadMacro()]);
await loadNames(); await loadNames();
lastRefresh.value = new Date().toTimeString().slice(0, 8); lastRefresh.value = new Date().toTimeString().slice(0, 8);
@ -2816,8 +2856,11 @@ createApp({
} }
async function decide(row, decision) { async function decide(row, decision) {
const d = await call('post', '/api/proposals/' + row.proposal_id + '/decide', { decision }); const d = await call('post', '/api/proposals/' + row.proposal_id + '/decide', { decision });
if (d.ok) ElementPlus.ElMessage.success(decision === 'ACCEPTED' if (d.ok) {
? ('已采纳, 指令 ' + d.instruction_id) : '已驳回'); ElementPlus.ElMessage.success(decision === 'ACCEPTED'
? ('已采纳, 指令 ' + d.instruction_id) : '已驳回');
if (d.warning) ElementPlus.ElMessage.warning(d.warning); // 一次性纪律失效等提示 (2026-08-28)
}
else ElementPlus.ElMessage.error(d.error); else ElementPlus.ElMessage.error(d.error);
await Promise.all([loadProposals(), loadInstructions(), loadLedger()]); await Promise.all([loadProposals(), loadInstructions(), loadLedger()]);
} }
@ -2973,7 +3016,7 @@ createApp({
if (refreshTimer) return; if (refreshTimer) return;
// 固定 30s 自动刷新(开关开时)。不再按 marketOpen 分档 —— 交易日历对模拟/未来日期可能 // 固定 30s 自动刷新(开关开时)。不再按 marketOpen 分档 —— 交易日历对模拟/未来日期可能
// 误判成非交易日, 那样会把刷新降到 5 分钟、体感像"卡住不刷"。休市时多刷几次也无害。 // 误判成非交易日, 那样会把刷新降到 5 分钟、体感像"卡住不刷"。休市时多刷几次也无害。
refreshTimer = setInterval(() => { if (autoRefresh.value) refreshLive(); }, 30000); refreshTimer = setInterval(() => { if (autoRefresh.value && authed.value) refreshLive(); }, 30000);
} }
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer); }); onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer); });
onMounted(async () => { onMounted(async () => {
@ -3154,7 +3197,7 @@ createApp({
}); });
return { tab, loading, err, health, ov, params, catalog, commands, plans, plansOf, return { tab, loading, err, health, ov, params, catalog, commands, plans, plansOf,
positions, lots, lotsOf, instructions, ledger, proposals, report, reportDrawer, positions, lots, lotsOf, instructions, ledger, ledgerToday, proposals, report, reportDrawer,
opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm, opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm,
money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, insPct, money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, insPct,
scaleGap, scaleGap,

View File

@ -173,7 +173,6 @@ class WsRunner:
logger.error("通道表自检失败 (每 60 秒重试): %s", msg) logger.error("通道表自检失败 (每 60 秒重试): %s", msg)
return False return False
self._db_ready = True
try: try:
st = await _db(qmt_repo.get_state) st = await _db(qmt_repo.get_state)
stored = int(st.get("last_seq") or 0) stored = int(st.get("last_seq") or 0)
@ -189,8 +188,13 @@ class WsRunner:
# 只回 ack{duplicate:true} 带当前状态。幂等键就是为这一刻准备的。 # 只回 ack{duplicate:true} 带当前状态。幂等键就是为这一刻准备的。
logger.warning("%s 张委托上次卡在 SENDING, 已退回队列重发 (幂等键兜底)", n) logger.warning("%s 张委托上次卡在 SENDING, 已退回队列重发 (幂等键兜底)", n)
except Exception as e: except Exception as e:
logger.error("启动自检失败, 水位按 0 起算 (下轮重连会重试): %s", _brief_err(e)) # _db_ready 必须保持 False —— 原来在恢复块之前就置 True, 这里失败后
# 「下轮重连会重试」是句空话 (232 行按 _db_ready 短路), 进程会带着
# last_seq=0 上线, 把真缺口误判成冷启动 (2026-08-28 审查修)。
self._db_ready = False
logger.error("启动自检失败, 水位恢复未完成 (60 秒后重试): %s", _brief_err(e))
return False return False
self._db_ready = True
return True return True
def _operable(self) -> bool: def _operable(self) -> bool:
@ -209,7 +213,9 @@ class WsRunner:
try: try:
await self._refresh_params() await self._refresh_params()
if self._operable(): if self._operable():
await _db(qmt_repo.beat, self._stat) # 传浅拷贝快照: qmt_repo.beat 在工作线程里 json.dumps, 而 reader 协程
# 同时在往 _stat 插新键 —— 迭代中改字典会炸掉当轮心跳 (2026-08-28 修)
await _db(qmt_repo.beat, dict(self._stat))
except Exception as e: except Exception as e:
logger.warning("心跳写入失败 (库不可用?): %s", _brief_err(e)) logger.warning("心跳写入失败 (库不可用?): %s", _brief_err(e))
with contextlib.suppress(asyncio.TimeoutError): with contextlib.suppress(asyncio.TimeoutError):
@ -236,6 +242,7 @@ class WsRunner:
continue continue
url = self._p("url", settings.PMS_QMT_WS_URL) url = self._p("url", settings.PMS_QMT_WS_URL)
t_started = time.monotonic()
try: try:
await _db(qmt_repo.set_conn, "CONNECTING") await _db(qmt_repo.set_conn, "CONNECTING")
await self._session(url, seed, peer) await self._session(url, seed, peer)
@ -243,6 +250,11 @@ class WsRunner:
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as e: except Exception as e:
# 退避按**连续失败**计 (2026-08-28 审查修): 一段健康跑了 5 分钟以上的
# 会话断掉, 说明不是连不上, 计数归零从最短退避重来 —— 原来 attempt 按
# 进程生命周期只增不减, 常驻几天后每次普通抖动都要干等满 30 秒。
if time.monotonic() - t_started > 300:
attempt = 0
self._stat["reconnects"] += 1 self._stat["reconnects"] += 1
# 断开原因同样存一份: set_conn 的 last_error 会在下次连上时被覆盖, # 断开原因同样存一份: set_conn 的 last_error 会在下次连上时被覆盖,
# 断了又连的场景里那条线索活不过 30 秒, 而这正是最需要它的场景。 # 断了又连的场景里那条线索活不过 30 秒, 而这正是最需要它的场景。
@ -304,6 +316,13 @@ class WsRunner:
for t in tasks + [stopper]: for t in tasks + [stopper]:
t.cancel() t.cancel()
await asyncio.gather(*tasks, stopper, return_exceptions=True) await asyncio.gather(*tasks, stopper, return_exceptions=True)
# 停机路径的最后一次 ack 要在**这里**发 (2026-08-28 审查修): _shutdown 里
# 那两个 `if self._ws is not None` 块永远走不到 —— 到那时本 finally 早已把
# _ws 置 None、连接也关了, 是死代码。趁连接还在把待 ack 的水位刷出去,
# QMT 侧就不会在每次停机后多留一段已收未确认的消息。
if self._stop.is_set() and self._ws is not None:
with contextlib.suppress(Exception):
await asyncio.wait_for(self._flush_ack(), timeout=3)
self._ws = None self._ws = None
async def _handshake(self, seed: str, peer: str): async def _handshake(self, seed: str, peer: str):
@ -341,7 +360,11 @@ class WsRunner:
logger.error(self._warn) logger.error(self._warn)
else: else:
self._warn = "" self._warn = ""
await _db(qmt_repo.set_conn, "CONNECTING", server_seq=server_seq, resync=resync) # resync 标记**只置位, 不在这里清零** (2026-08-28 审查修): 传 None 表示不动该列。
# 原来每次干净握手都把 resync 写回 0 —— 缺口置位后几分钟一次正常重连, "须人工
# 全量对账"的持久标记就没了, 丢失区间的成交无人补账。清零只走页面的人工接口。
await _db(qmt_repo.set_conn, "CONNECTING", server_seq=server_seq,
resync=(True if resync else None))
if resync: if resync:
# §5.1 / §6.2: 对端补不齐我们要的区间 (日志已滚动)。此时**不能**装作没事 —— # §5.1 / §6.2: 对端补不齐我们要的区间 (日志已滚动)。此时**不能**装作没事 ——
# 中间那段成交我们永远拿不到了, 必须走全量快照对账, 对不齐就停一切自主动作。 # 中间那段成交我们永远拿不到了, 必须走全量快照对账, 对不齐就停一切自主动作。
@ -559,10 +582,20 @@ class WsRunner:
iid = pl.get("instruction_id") or env.get("corr_id") iid = pl.get("instruction_id") or env.get("corr_id")
try: try:
if type_ == wsc.T_ACK: if type_ == wsc.T_ACK:
n = await _db(qmt_repo.update_order, iid, # 终态保护 (2026-08-28 审查修, 与 _on_order_update 同一条纪律):
status=str(pl.get("status") or wsc.ST_ACCEPTED).upper(), # _boot 重发换来的 ack{duplicate:true} 是新 seq 的新消息, 不带 status 时
broker_order_id=pl.get("broker_order_id")) # 这里的 ACCEPTED 兜底会把已 FILLED 的委托改回在途 —— 幻影单从此常驻
self._note_orphan(n, type_, iid) # queue_depth 与撤单清单。已终态的委托, ack 一律只记日志不动状态。
row = await _db(qmt_repo.get_order, iid) or {}
cur = str(row.get("status") or "").upper()
if cur and wsc.is_final(cur):
logger.info("[ack] %s 已是终态 %s, ack(duplicate=%s) 不回退状态",
iid, cur, bool(pl.get("duplicate")))
else:
n = await _db(qmt_repo.update_order, iid,
status=str(pl.get("status") or wsc.ST_ACCEPTED).upper(),
broker_order_id=pl.get("broker_order_id"))
self._note_orphan(n, type_, iid)
if pl.get("duplicate"): if pl.get("duplicate"):
logger.info("[ack] %s 幂等命中 (对端已受理过), 当前状态 %s", logger.info("[ack] %s 幂等命中 (对端已受理过), 当前状态 %s",
iid, pl.get("status")) iid, pl.get("status"))

View File

@ -40,6 +40,19 @@ from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.db.session import fetch_all, DBUnavailable # noqa: E402 只读单表, 过单表守卫 from app.db.session import fetch_all, DBUnavailable # noqa: E402 只读单表, 过单表守卫
from app.core import tradedays as _td # noqa: E402 真交易日计数
def tdays_between(d0, d1) -> int:
"""(d0, d1] 内的交易日数 (同日买卖 = 0)。护栏档位 N 按交易日定义, 持有天数必须同尺 ——
自然日近似会把周五买周一卖 (1 个交易日) 算成 3 , N=1 档直接漏掉 (2026-08-28 )"""
n, cur, guard = 0, d0, 0
while cur < d1 and guard < 400:
cur += timedelta(days=1)
guard += 1
if _td.is_trade_day(cur):
n += 1
return n
# ------------------------------------------------------------------ 可调常量 # ------------------------------------------------------------------ 可调常量
MIN_SAMPLE = 5 # 少于这个数只报数不下结论 MIN_SAMPLE = 5 # 少于这个数只报数不下结论
@ -219,7 +232,7 @@ def trade_cost(notional: float, is_sell: bool) -> float:
# ------------------------------------------------------------------ 来回配对 # ------------------------------------------------------------------ 来回配对
def pair_round_trips(rows_by_code: dict) -> list: def pair_round_trips(rows_by_code: dict) -> list:
"""同一只票: 把"一次买"和其后"第一次卖"配成一个来回(粗配, 不做逐笔 FIFO)。 """同一只票: 把"一次买"和其后"第一次卖"配成一个来回(粗配, 不做逐笔 FIFO)。
返回每个来回: 入场/出场时间价持有交易日近似(自然日近似)来回毛收益是否决策系统驱动 返回每个来回: 入场/出场时间价持有交易日(按交易日历真算, 2026-08-28 )来回毛收益是否决策系统驱动
说明: 这是方向与量级的体检, 不是会计账; 精算逐笔在 pms_lot, 要精算另说""" 说明: 这是方向与量级的体检, 不是会计账; 精算逐笔在 pms_lot, 要精算另说"""
trips = [] trips = []
for code, rows in rows_by_code.items(): for code, rows in rows_by_code.items():
@ -234,12 +247,15 @@ def pair_round_trips(rows_by_code: dict) -> list:
bt, st = b["decided_at"], s["decided_at"] bt, st = b["decided_at"], s["decided_at"]
bt = bt if isinstance(bt, datetime) else datetime.fromisoformat(str(bt)) bt = bt if isinstance(bt, datetime) else datetime.fromisoformat(str(bt))
st = st if isinstance(st, datetime) else datetime.fromisoformat(str(st)) st = st if isinstance(st, datetime) else datetime.fromisoformat(str(st))
hold_days = (st.date() - bt.date()).days hold_days = tdays_between(bt.date(), st.date()) # 真交易日 (2026-08-28)
gross = _pct(b["price_at"], s["price_at"]) gross = _pct(b["price_at"], s["price_at"])
hn_b, hn_s = (b.get("hn") or {}), (s.get("hn") or {})
trips.append({ trips.append({
"ts_code": code, "buy_at": bt, "sell_at": st, "ts_code": code, "buy_at": bt, "sell_at": st,
"buy_price": _f(b["price_at"]), "sell_price": _f(s["price_at"]), "buy_price": _f(b["price_at"]), "sell_price": _f(s["price_at"]),
"hold_days": hold_days, "gross_ret": gross, "hold_days": hold_days, "gross_ret": gross,
"buy_amt": _f(hn_b.get("amount")) or None,
"sell_amt": _f(hn_s.get("amount")) or None,
"signal_driven": tag["signal_driven"], "sell_reason": s.get("reason"), "signal_driven": tag["signal_driven"], "sell_reason": s.get("reason"),
"cohort": tag.get("cohort"), "cohort": tag.get("cohort"),
}) })
@ -334,15 +350,17 @@ def section_cost(trips):
if not sig: if not sig:
print(" 无样本。") print(" 无样本。")
return return
# 无法拿到每笔真实金额时, 以"单位名义1"估相对成本率; 有 hard_numbers.amount 时用真实额
total_rate = 0.0
for t in sig:
rt_cost_rate = (COMMISSION_RATE * 2 + STAMP_RATE + TRANSFER_RATE * 2
+ SLIPPAGE_BPS / 10000.0 * 2)
total_rate += rt_cost_rate
print(f" 每个来回的往返摩擦约 {(_rt_cost_rate()):.3%} (佣金双边+印花+过户+滑点双边)") print(f" 每个来回的往返摩擦约 {(_rt_cost_rate()):.3%} (佣金双边+印花+过户+滑点双边)")
print(f" {len(sig)} 组来回累计摩擦 ≈ 名义规模的 {total_rate:.2%} " # 有真实金额 (账本 hard_numbers.amount) 的来回按真实额算, 含每边最低 5 元佣金;
f"(即平均毛收益要先跑赢这条线才算真挣到)") # 没有的只报口径, 不再打印"N×费率"那个等名义假设的合计 (它不是钱, 是比率和, 2026-08-28 修)
real = [t for t in sig if t.get("buy_amt") and t.get("sell_amt")]
if real:
yuan = sum(trade_cost(t["buy_amt"], False) + trade_cost(t["sell_amt"], True)
for t in real)
print(f" 其中 {len(real)} 组带真实金额: 累计摩擦 {yuan:,.0f} 元 (含每边最低 5 元佣金)")
if len(real) < len(sig):
print(f" 其余 {len(sig) - len(real)} 组账本未记金额, 只能按费率口径读: "
f"每组来回的毛收益要先跑赢 {_rt_cost_rate():.3%} 才算真挣到")
def _rt_cost_rate(): def _rt_cost_rate():
@ -359,13 +377,15 @@ def _guard_sweep(label, affected):
for t in affected: for t in affected:
if t["hold_days"] is None or t["hold_days"] > N: if t["hold_days"] is None or t["hold_days"] > N:
continue # 护栏只管"刚建仓 N 日内"的卖出 continue # 护栏只管"刚建仓 N 日内"的卖出
if (t["gross_ret"] or 0) <= HARD_RISK_DROP: if t["gross_ret"] is None:
continue # 卖价缺失: 不能当 0 收益混进样本 (2026-08-28)
if t["gross_ret"] <= HARD_RISK_DROP:
continue # 硬止损放行, 不受护栏拦 continue # 硬止损放行, 不受护栏拦
seq = _fwd_closes_after(t["ts_code"], t["sell_at"], n_max=GUARD_HOLD_TO) seq = _fwd_closes_after(t["ts_code"], t["sell_at"], n_max=GUARD_HOLD_TO)
if len(seq) < GUARD_HOLD_TO: if len(seq) < GUARD_HOLD_TO:
continue continue
# 实际(卖了): 拿到 gross_ret, 并付了一次卖出摩擦; 之后空仓 = 0 # 实际(卖了): 拿到 gross_ret, 并付了一次卖出摩擦; 之后空仓 = 0
actual = (t["gross_ret"] or 0) - _rt_cost_rate() / 2 actual = t["gross_ret"] - _rt_cost_rate() / 2
# 反事实(没卖, 持有到 T+H 再看): 用 T+H 相对入场的收益, 只付了买入侧摩擦 # 反事实(没卖, 持有到 T+H 再看): 用 T+H 相对入场的收益, 只付了买入侧摩擦
held = _pct(t["buy_price"], seq[GUARD_HOLD_TO - 1]) held = _pct(t["buy_price"], seq[GUARD_HOLD_TO - 1])
if held is None: if held is None:

View File

@ -44,6 +44,18 @@ from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.db.session import fetch_all, DBUnavailable # noqa: E402 只读单表, 过单表守卫 from app.db.session import fetch_all, DBUnavailable # noqa: E402 只读单表, 过单表守卫
from app.core import tradedays as _td # noqa: E402 真交易日计数
def tdays_between(d0, d1) -> int:
"""(d0, d1] 内的交易日数 (同日买卖 = 0) —— 与 backtest_churn 同口径 (2026-08-28)。"""
n, cur, guard = 0, d0, 0
while cur < d1 and guard < 400:
cur += timedelta(days=1)
guard += 1
if _td.is_trade_day(cur):
n += 1
return n
# ------------------------------------------------------------------ 可调常量 # ------------------------------------------------------------------ 可调常量
MIN_SAMPLE = 5 # 少于这个数只报数不下结论 MIN_SAMPLE = 5 # 少于这个数只报数不下结论
@ -65,7 +77,9 @@ TAKE_PROFIT_DOM = "take_profit" # 卖出 dominant_signal 是这个 = 止盈
QUICK_DAYS = 10 QUICK_DAYS = 10
FWD_HORIZONS = (1, 5, 20) # 入场后前向看这几个交易日 FWD_HORIZONS = (1, 5, 20) # 入场后前向看这几个交易日
# 入场过滤规则的阈值 (⑤ 反事实扫的候选闸门, 都只用买入日及之前的信息, 无未来函数) # 入场过滤规则的阈值 (⑤ 反事实扫的候选闸门)。口径 (2026-08-28 理清):
# 逆势买 / 放量阴线 只用买入**前一交易日**及更早的数据 —— 实盘上午就拿得到, 无前视;
# 追高 用买入日全天高低 —— 收盘才知道, 属事后画像口径, 它的反事实读数是乐观上限。
CHASE_TOP_FRAC = 0.70 # 追高: 买价落在当日振幅的顶部这个比例以上 (1.0=买在最高) CHASE_TOP_FRAC = 0.70 # 追高: 买价落在当日振幅的顶部这个比例以上 (1.0=买在最高)
DOWNTREND_5D = -0.03 # 逆势: 买入日相对前5个交易日收盘已跌超这个幅度 DOWNTREND_5D = -0.03 # 逆势: 买入日相对前5个交易日收盘已跌超这个幅度
REDVOL_VOL_MULT = 1.5 # 放量阴线: 买入日是阴线且量能≥前5日均量的这个倍数 REDVOL_VOL_MULT = 1.5 # 放量阴线: 买入日是阴线且量能≥前5日均量的这个倍数
@ -224,10 +238,12 @@ def forward_returns(ts_code: str, dt: datetime, base_price: float) -> dict:
def entry_context(ts_code: str, buy_dt: datetime, buy_price: float) -> dict: def entry_context(ts_code: str, buy_dt: datetime, buy_price: float) -> dict:
"""重建买入时点的价格情形。返回 {chase, day_chg, mom5, red_vol}, 缺数据的项为 None。 """重建买入时点的价格情形。返回 {chase, day_chg, mom5, red_vol}, 缺数据的项为 None。
chase 追高度 = (买价-当日最低)/(当日最高-当日最低), 1.0=买在最高, 越高越追 chase 追高度 = (买价-当日最低)/(当日最高-当日最低), 1.0=买在最高**事后口径**
day_chg 买入日涨跌 = 当日 percent(优先) 收盘/前收-1, =买在红盘 (全天高低要收盘才定), 只作画像与乐观上限, 不是实盘可复刻的闸门
mom5 前5日动量 = 买入日收盘/前5个交易日收盘-1, =买在已经下跌的票上(逆势) day_chg 买入日全天涨跌 同为事后口径
red_vol 放量阴线 = 买入日收盘<开盘 量能前5日均量×倍数 (True/False/None) mom5 前一交易日收盘 / 再前5个交易日收盘 - 1 **盘中可得** (2026-08-28 ),
=买在已经下跌的票上(逆势)
red_vol 前一交易日是阴线且量能更早5日均量×倍数 **盘中可得** (2026-08-28 )
""" """
bars = _bars(ts_code, buy_dt) bars = _bars(ts_code, buy_dt)
ctx = {"chase": None, "day_chg": None, "mom5": None, "red_vol": None} ctx = {"chase": None, "day_chg": None, "mom5": None, "red_vol": None}
@ -236,28 +252,31 @@ def entry_context(ts_code: str, buy_dt: datetime, buy_price: float) -> dict:
bd = buy_dt.date() bd = buy_dt.date()
bar = bars.get(bd) bar = bars.get(bd)
if bar is None: if bar is None:
# 账本时间戳那天没有行情(极少见), 用其后第一根近似 # 买入日没有行情 (极少见): **直接跳过该样本** —— 原来用"其后第一根"近似,
later = sorted(d for d in bars if d >= bd) # 那是买入之后那天的数据, 与"无未来函数"的承诺相悖 (2026-08-28 修)
if not later: return ctx
return ctx
bd = later[0]
bar = bars[bd]
hi, lo = bar["high"], bar["low"] hi, lo = bar["high"], bar["low"]
if buy_price > 0 and hi > lo: if buy_price > 0 and hi > lo:
# 追高度用当日全天高低点: 这是**事后口径** (买入时刻只知道到那一刻的高低),
# 结果是过滤效果的乐观上限, 读数时要打这个折 (2026-08-28 说明)
ctx["chase"] = max(0.0, min(1.0, (buy_price - lo) / (hi - lo))) ctx["chase"] = max(0.0, min(1.0, (buy_price - lo) / (hi - lo)))
if bar["pct"]: if bar["pct"]:
ctx["day_chg"] = bar["pct"] / 100.0 ctx["day_chg"] = bar["pct"] / 100.0 # 同为事后口径 (全天涨跌)
elif bar["pre_close"] > 0: elif bar["pre_close"] > 0:
ctx["day_chg"] = bar["close"] / bar["pre_close"] - 1.0 ctx["day_chg"] = bar["close"] / bar["pre_close"] - 1.0
prior = sorted((d, b) for d, b in bars.items() if d < bd) prior = sorted((d, b) for d, b in bars.items() if d < bd)
if len(prior) >= 5: # mom5 与 red_vol 改用**买入前一交易日及更早**的数据 (2026-08-28 修): 原来用买入日
c5 = prior[-5][1]["close"] # 收盘和全天量 —— 那要收盘才知道, 实盘过滤器在上午拿不到。改后这两条规则是真正
if c5 > 0: # 可实现的 (前一日收盘 / 前一日阴线放量), 反事实结果不再靠日内前视撑着。
ctx["mom5"] = bar["close"] / c5 - 1.0 if len(prior) >= 6:
vols = [b["vol"] for _, b in prior[-5:] if b["vol"] > 0] y = prior[-1][1] # 买入前一交易日
if vols and bar["vol"] > 0: c5 = prior[-6][1]["close"] # 前一日再往前 5 个交易日
if c5 > 0 and y["close"] > 0:
ctx["mom5"] = y["close"] / c5 - 1.0
vols = [b["vol"] for _, b in prior[-6:-1] if b["vol"] > 0]
if vols and y["vol"] > 0:
avgv = sum(vols) / len(vols) avgv = sum(vols) / len(vols)
ctx["red_vol"] = (bar["close"] < bar["open"]) and (bar["vol"] >= REDVOL_VOL_MULT * avgv) ctx["red_vol"] = (y["close"] < y["open"]) and (y["vol"] >= REDVOL_VOL_MULT * avgv)
return ctx return ctx
@ -290,7 +309,7 @@ def pair_round_trips(rows_by_code: dict) -> list:
bt, st = b["decided_at"], s["decided_at"] bt, st = b["decided_at"], s["decided_at"]
bt = bt if isinstance(bt, datetime) else datetime.fromisoformat(str(bt)) bt = bt if isinstance(bt, datetime) else datetime.fromisoformat(str(bt))
st = st if isinstance(st, datetime) else datetime.fromisoformat(str(st)) st = st if isinstance(st, datetime) else datetime.fromisoformat(str(st))
hold_days = (st.date() - bt.date()).days hold_days = tdays_between(bt.date(), st.date()) # 真交易日 (2026-08-28)
gross = _pct(b["price_at"], s["price_at"]) gross = _pct(b["price_at"], s["price_at"])
trips.append({ trips.append({
"ts_code": code, "buy_at": bt, "sell_at": st, "ts_code": code, "buy_at": bt, "sell_at": st,
@ -407,11 +426,12 @@ def _filters():
"""候选入场闸门: 每条是 (名称, 说明, 判定函数(trip,ctx)->bool 命中即"该拦")。 """候选入场闸门: 每条是 (名称, 说明, 判定函数(trip,ctx)->bool 命中即"该拦")。
只用买入日及之前的信息, 无未来函数""" 只用买入日及之前的信息, 无未来函数"""
return [ return [
("追高", f"买价落在当日振幅顶部{(1-CHASE_TOP_FRAC):.0%}以内(追高度≥{CHASE_TOP_FRAC})", ("追高[事后口径]", f"买价落在当日振幅顶部{(1-CHASE_TOP_FRAC):.0%}以内(追高度≥{CHASE_TOP_FRAC}; "
f"全天高低收盘才定, 此条是乐观上限)",
lambda t, c: c["chase"] is not None and c["chase"] >= CHASE_TOP_FRAC), lambda t, c: c["chase"] is not None and c["chase"] >= CHASE_TOP_FRAC),
("逆势买", f"买入日已较前5日跌超{abs(DOWNTREND_5D):.0%}(前5日动量≤{DOWNTREND_5D:.0%})", ("逆势买", f"前一交易日已较其前5日跌超{abs(DOWNTREND_5D):.0%}(盘中可得)",
lambda t, c: c["mom5"] is not None and c["mom5"] <= DOWNTREND_5D), lambda t, c: c["mom5"] is not None and c["mom5"] <= DOWNTREND_5D),
("放量阴线", f"买入日是阴线且量能≥前5日均量×{REDVOL_VOL_MULT}", ("放量阴线", f"前一交易日是阴线且量能≥更早5日均量×{REDVOL_VOL_MULT}(盘中可得)",
lambda t, c: c["red_vol"] is True), lambda t, c: c["red_vol"] is True),
] ]

View File

@ -79,8 +79,16 @@ def check_ws_channel():
else: else:
line("OK", f"对端公钥已配置 ({peer[:12]}...)") line("OK", f"对端公钥已配置 ({peer[:12]}...)")
if param_store.get_bool("PMS_QMT_SIGN_SEED_HEX") or param_store.get("PMS_QMT_SIGN_SEED_HEX"): # 自证必须**直查参数表** (2026-08-28 修): param_store.get 对 SECRET_KEYS 无条件回
fail("私钥出现在 ParamStore 可读路径 —— 违反协议 §10.1.1, 请检查 SECRET_KEYS") # 空串 (那正是堵读取的闸), 经它去验"表里有没有私钥"是永真检查 —— 谁把 seed 写进
# 参数表, 这里照样报 OK。
try:
from app.repo import pms_repo as _pr
_raw = _pr.all_params()
if any(_raw.get(k) for k in param_store.SECRET_KEYS):
fail("私钥出现在参数表 pms_runtime_param —— 违反协议 §10.1.1, 请删除该行并轮换密钥")
except Exception as _e:
warn(f"密钥落库自证跳过 (参数表读不了): {type(_e).__name__}: {_e}")
try: try:
ch = dispatcher.channel_status() ch = dispatcher.channel_status()

View File

@ -177,12 +177,19 @@ def check():
print(" 那一行 base64 粘进 .env 即可 (整段 PEM 有换行, 放不进 .env)") print(" 那一行 base64 粘进 .env 即可 (整段 PEM 有换行, 放不进 .env)")
print("\n[4] 密钥不落库自证 (协议 §10.1.1)") print("\n[4] 密钥不落库自证 (协议 §10.1.1)")
# 直查参数表 (2026-08-28 修): param_store.get 对密钥键恒回空串, 经它验证是永真检查
from app.services import param_store from app.services import param_store
leaked = [k for k in param_store.SECRET_KEYS if param_store.get(k)] try:
from app.repo import pms_repo as _pr
_raw = _pr.all_params()
leaked = [k for k in param_store.SECRET_KEYS if _raw.get(k)]
except Exception as _e:
leaked = []
print(f" (参数表读不了, 本项跳过: {type(_e).__name__}: {_e})")
if leaked: if leaked:
bad(f"密钥可经 ParamStore 读出: {leaked}") bad(f"密钥出现在参数表 pms_runtime_param: {leaked} —— 请删除该行并轮换密钥")
else: else:
ok("ParamStore 读不到密钥, 页面参数列表里也不会出现") ok("参数表里没有密钥行; ParamStore 读取路径也已封死")
print("\n" + "-" * 66) print("\n" + "-" * 66)
if failed: if failed:

View File

@ -96,8 +96,11 @@ def main():
print(f"以下 {len(failed)} 张表加列失败, 完整语句如下 —— 可直接拿到物理库 (my_quant_db) 执行:") print(f"以下 {len(failed)} 张表加列失败, 完整语句如下 —— 可直接拿到物理库 (my_quant_db) 执行:")
for t, s, err in failed: for t, s, err in failed:
print(f"\n### {t} ({err.splitlines()[0]})\n{s};") print(f"\n### {t} ({err.splitlines()[0]})\n{s};")
if failed or miss: if failed or miss or unknown:
print(f"\nFAILED: {len(failed) + len(miss)} 张表仍未就绪") # unknown (查列失败, 多为表不存在) 也算未就绪 (2026-08-28 修): 原来 todo 非空时
# 它被忘掉, 四张表迁了三张也报 ALL OK —— 缺列的表要等页面报 SQL 错才暴露
print(f"\nFAILED: {len(failed) + len(miss) + len(unknown)} 张表仍未就绪"
+ (f" (含查列失败 {len(unknown)} 张: {', '.join(unknown)})" if unknown else ""))
sys.exit(1) sys.exit(1)
print(f"ALL OK: {len(todo)} 张表已加 {COL} 列。页面的「移除 / 显示已完成」现在可用。") print(f"ALL OK: {len(todo)} 张表已加 {COL} 列。页面的「移除 / 显示已完成」现在可用。")

View File

@ -284,6 +284,11 @@ def main():
print("\n【四】候选池合格票与吸筹的重合 (拍板②「资格不放宽」的实测依据)") print("\n【四】候选池合格票与吸筹的重合 (拍板②「资格不放宽」的实测依据)")
try: try:
from app.services import command_service, plan_feed from app.services import command_service, plan_feed
# 只读承诺自证 (2026-08-28 修): plan_feed.get_plan 默认会落一行 pms_plan_snapshot
# (还可能 upsert 行业映射) —— 探测脚本提前落库会吞掉当天正式链路的榜单变化提示
# (probe_plan_api 早有注释点过这个坑)。本进程内把两个写函数替换成空操作。
plan_feed._snapshot_quiet = lambda *a, **kw: {"skipped": "probe 只读, 不落快照"}
plan_feed._sync_themes_quiet = lambda *a, **kw: {"skipped": "probe 只读, 不灌映射"}
try: try:
black = command_service.blacklist() black = command_service.blacklist()
except Exception: except Exception:

View File

@ -156,7 +156,17 @@ def main():
continue continue
n_traded += 1 n_traded += 1
buy_avg = b["amt"] / b["qty"] if b["qty"] else 0.0 buy_avg = b["amt"] / b["qty"] if b["qty"] else 0.0
realized = sl["amt"] - sl["qty"] * buy_avg if sl["qty"] else 0.0 if sl["qty"] and not b["qty"]:
# 只有卖出没有买入 (买入腿痕迹缺失/被对账冲销): 按零成本算会把整笔卖出额
# 报成利润, 判分虚高误导阈值决策 (2026-08-28 修) —— 这条不算差价, 单独点名。
print(f" {code} {sid} [{s.get('status')}]: 卖 {sl['qty']} 股/{sl['amt']:,.0f} 元, "
f"但**查无买入腿成交** —— 差价无法计算, 不计入合计, 请核对该策略的成交归属")
continue
sell_q = min(sl["qty"], b["qty"])
if sl["qty"] > b["qty"]:
print(f" {code} {sid}: 卖出 {sl['qty']} 股 > 买入 {b['qty']} 股, "
f"超出部分 {sl['qty'] - b['qty']} 股不属本策略买入, 差价只按配对部分算")
realized = (sl["amt"] * (sell_q / sl["qty"]) - sell_q * buy_avg) if sl["qty"] else 0.0
net_qty = b["qty"] - sl["qty"] net_qty = b["qty"] - sl["qty"]
cur = _f(px.get(code)) cur = _f(px.get(code))
floating = net_qty * (cur - buy_avg) if (net_qty > 0 and cur > 0 and buy_avg > 0) else None floating = net_qty * (cur - buy_avg) if (net_qty > 0 and cur > 0 and buy_avg > 0) else None

View File

@ -205,8 +205,10 @@ def main():
if args.reset_ws: if args.reset_ws:
try: try:
from datetime import datetime as _dt
execute("UPDATE pms_ws_state SET last_seq = 0, acked_seq = 0, server_seq = 0, " execute("UPDATE pms_ws_state SET last_seq = 0, acked_seq = 0, server_seq = 0, "
"resync_flag = 0, conn_state = 'INIT', updated_at = NOW() WHERE id = 1") "resync_flag = 0, conn_state = 'INIT', updated_at = :ts WHERE id = 1",
{"ts": _dt.now()}) # 库端 NOW() 是 UTC, 会写出倒退 8 小时的时间戳 (2026-08-28)
print(" OK pms_ws_state 水位归零 —— 重连后对端会从 seq 1 全量补发") print(" OK pms_ws_state 水位归零 —— 重连后对端会从 seq 1 全量补发")
except Exception as e: except Exception as e:
print(f" WARN pms_ws_state 归零失败 (表可能还没建): {type(e).__name__}: {e}") print(f" WARN pms_ws_state 归零失败 (表可能还没建): {type(e).__name__}: {e}")

View File

@ -34,8 +34,12 @@
判分脚本聚合与对照分组 (41 ) 判分脚本聚合与对照分组 (41 )
test_batch18_units.py 登录与权限: 角色判定/会话票签验/bshop 返回解析/ test_batch18_units.py 登录与权限: 角色判定/会话票签验/bshop 返回解析/
接口鉴权(维护类归管理员) (16 ) 接口鉴权(维护类归管理员) (16 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 ) test_batch19_units.py 2026-08-28 审查修复回归: 科创板最小申报统一口径/
560 取整与部分卖/同轮买卖互斥/信号百分制契约/除权核销
缩放/日历按年降级/网格中枢与止盈闩锁/买入暂停
按来源分记/宏观失败路径保留留痕 (17 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (66 )
585
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
""" """
import os import os
@ -50,7 +54,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py", "test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py",
"test_batch13_units.py", "test_batch14_units.py", "test_batch15_units.py", "test_batch13_units.py", "test_batch14_units.py", "test_batch15_units.py",
"test_batch16_units.py", "test_batch17_units.py", "test_batch16_units.py", "test_batch17_units.py",
"test_batch18_units.py", "test_wiring.py"] "test_batch18_units.py", "test_batch19_units.py", "test_wiring.py"]
def main(): def main():

View File

@ -456,8 +456,9 @@ def _():
from app.services import signal_service from app.services import signal_service
from app.core import signal_rules as sr from app.core import signal_rules as sr
install_fakes(prices={"600000.SH": 10.0}) install_fakes(prices={"600000.SH": 10.0})
seen, out = set(), {"ignored": 0, "recorded": 0, "exits": [], "proposals": [], # seen 自 2026-08-28 起是**有序 dict** (裁剪时裁最旧的, 不再按字典序), 用法同集合
"errors": []} seen, out = {}, {"ignored": 0, "recorded": 0, "exits": [], "proposals": [],
"errors": []}
sig = {"msg_id": "M1", "ts_code": "600000.SH", "action": "SELL", "confidence": 0.95, sig = {"msg_id": "M1", "ts_code": "600000.SH", "action": "SELL", "confidence": 0.95,
"source": "test", "reason": "风控"} "source": "test", "reason": "风控"}
view = {"positions": [{"ts_code": "600000.SH", "total_qty": 1000, "avail_qty": 1000, view = {"positions": [{"ts_code": "600000.SH", "total_qty": 1000, "avail_qty": 1000,
@ -488,8 +489,8 @@ def _():
from app.services import signal_service from app.services import signal_service
from app.core import signal_rules as sr from app.core import signal_rules as sr
install_fakes(prices={"600000.SH": 10.0}) install_fakes(prices={"600000.SH": 10.0})
seen, out = set(), {"ignored": 0, "recorded": 0, "exits": [], "proposals": [], seen, out = {}, {"ignored": 0, "recorded": 0, "exits": [], "proposals": [],
"errors": []} "errors": []}
sig = {"msg_id": "M1", "ts_code": "600000.SH", "action": "SELL", "confidence": 0.95, sig = {"msg_id": "M1", "ts_code": "600000.SH", "action": "SELL", "confidence": 0.95,
"source": "test", "reason": "风控"} "source": "test", "reason": "风控"}
view = {"positions": [{"ts_code": "600000.SH", "total_qty": 1000, "avail_qty": 1000, view = {"positions": [{"ts_code": "600000.SH", "total_qty": 1000, "avail_qty": 1000,
@ -621,7 +622,6 @@ def _():
@case("[J3] 自主提议给规则闸的 day 必须是真行情, 不是拿 price 拼出来的空壳") @case("[J3] 自主提议给规则闸的 day 必须是真行情, 不是拿 price 拼出来的空壳")
def _(): def _():
import inspect
from app.services import proposal_service from app.services import proposal_service
# 曾经是 {"vwap": price, "day_chg_from_open": None} —— 当日涨幅恒 None, 于是 # 曾经是 {"vwap": price, "day_chg_from_open": None} —— 当日涨幅恒 None, 于是
# "不追高(涨幅)"这一项对所有自主买入从来没有真正跑过。这里直接验行为: 造一只 # "不追高(涨幅)"这一项对所有自主买入从来没有真正跑过。这里直接验行为: 造一只

View File

@ -261,6 +261,10 @@ def _():
assert len(calls) == 1 and "冷却至" in r2["source"], (len(calls), r2) assert len(calls) == 1 and "冷却至" in r2["source"], (len(calls), r2)
r3 = _adecide(ea, now="10:40", prog=prog) # 冷却过后恢复咨询 r3 = _adecide(ea, now="10:40", prog=prog) # 冷却过后恢复咨询
assert len(calls) == 2, len(calls) assert len(calls) == 2, len(calls)
# 2026-08-28 补: 第二次咨询同样失败, r3 必须仍是"退实现B"的有效决策且重新挂上冷却
assert r3 and r3.get("action"), r3
assert "B" in str(r3.get("source") or ""), r3
assert prog["exec_advice"]["fail_until_min"] > 10 * 60 + 40 - 1, prog["exec_advice"]
@case("[C7] 对端回 UNAVAILABLE (给不出结论) → 退B + 冷却, 不当 FIRE 也不当 WAIT") @case("[C7] 对端回 UNAVAILABLE (给不出结论) → 退B + 冷却, 不当 FIRE 也不当 WAIT")

View File

@ -483,6 +483,9 @@ def _():
out2 = _scan_stubbed(rec2, held=held, accum=accum, heat={}, dry_run=False) out2 = _scan_stubbed(rec2, held=held, accum=accum, heat={}, dry_run=False)
assert len(rec2.attach_calls) == 2, rec2.attach_calls assert len(rec2.attach_calls) == 2, rec2.attach_calls
assert any("名额已满" in x.get("reason", "") for x in rec2.ledger), rec2.ledger assert any("名额已满" in x.get("reason", "") for x in rec2.ledger), rec2.ledger
# 2026-08-28 补: 真跑那半段的返回值也要锁住 (原来 out2 算了没断言)
assert out2["ok"], out2
assert len(out2["blocked"]) == 1 and "名额已满" in out2["blocked"][0]["why"], out2["blocked"]
@case("[冒烟] 排除项: 冻结票与热度停更都不挂; 词表外定性浮到 unknown_states") @case("[冒烟] 排除项: 冻结票与热度停更都不挂; 词表外定性浮到 unknown_states")

View File

@ -0,0 +1,398 @@
# -*- coding: utf-8 -*-
"""
第十九批: 2026-08-28 全库审查修复的纯逻辑回归 不联网不连库
=====================================================================
这一批钉住的都是当次审查改掉的真伤, 每条用例头上写清"原来错在哪":
1. 科创板最小申报统一口径 (sizer.lot_of / lot_qty 浮点容差 / split_batches 可行性线);
2. planner 取整与部分卖修正 (ceil_lot 小数截断 / _min_sell 200 股线);
3. 规则闸科创板买卖申报校验 ( <200 拒单 / 部分卖 <200 全清放行);
4. 动作引擎同轮买卖互斥 (TRIM 触发时买入侧让路, 不再自动对倒);
5. 信号口径 (_norm_conf_pct 百分制契约: 1 = 1% 不是 100%; digest 科创板卖量修正);
6. 除权调整已核销批次同步缩放 (recon.apply_ex_right);
7. 交易日历按年探测降级 (chinesecalendar 装了但没有当年数据);
8. 网格只买中枢下方 / 跟踪止盈部分卖一次性闩锁 (strategy_runner);
9. 策略买入暂停按来源分记 (strategy_service, accum signal 互不误伤);
10. 宏观失败路径保留当日动作留痕 (macro_service._upsert_unavailable)
运行: python scripts/test_batch19_units.py
"""
import os
import sys
import traceback
from datetime import date
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
# ================================================================
# [A] sizer: 最小申报数量的唯一出处
# ================================================================
@case("[A1] lot_of: 688/689 开头 200 股, 其余 100; 空值与 '含688不在开头' 不误判")
def _():
from app.core.sizer import lot_of
assert lot_of("688802.SH") == 200 and lot_of("689009.SH") == 200
assert lot_of("600000.SH") == 100 and lot_of("000001.SZ") == 100
assert lot_of("300688.SZ") == 100 # 688 在中间不算科创板
assert lot_of(None) == 100 and lot_of("") == 100
@case("[A2] lot_qty 浮点容差: 407 元买 4.07 元的票**恰好一手**, 不许被浮点误差算成零手")
def _():
from app.core.sizer import lot_qty
# 407/4.07 在浮点里是 99.999…, 原实现直接 int() 会把"恰好买得起一手"算成 0 手
assert lot_qty(407, 4.07) == 100, lot_qty(407, 4.07)
assert lot_qty(814, 4.07) == 200
assert lot_qty(1500, 10.0) == 100 and lot_qty(999, 10.0) == 0
assert lot_qty(0, 10.0) == 0 and lot_qty(1000, 0) == 0 and lot_qty(None, 5) == 0
assert lot_qty(4000, 10.0, 200) == 400 # lot=200 时按 200 的整数倍
@case("[A3] split_batches min_lot=200: 可行性线抬到科创板 200 股, 降档与失败话术都说清")
def _():
from app.core.sizer import split_batches
# 50/25/25 里 25% 批只有 100 股 (<200) → 自动降档到 60/40 (300/200 股, 都合法)
r = split_batches(6000, 10.0, min_lot=200)
assert r["ok"] and r["scheme"] == (0.6, 0.4), r
assert [b["qty"] for b in r["batches"]] == [300, 200], r
# 全部阶梯都买不足 200 股 → 失败, 原因里点名科创板
r2 = split_batches(1500, 10.0, min_lot=200)
assert not r2["ok"] and "科创板最少 200 股" in r2["reason"], r2
# 主板行为一字不变
r3 = split_batches(6000, 10.0)
assert r3["ok"] and r3["scheme"] == (0.5, 0.25, 0.25), r3
# ================================================================
# [B] planner: 取整与科创板部分卖
# ================================================================
@case("[B1] ceil_lot 小数向上取整: 100.5 → 200 (原实现先截断再进位, 缺口永远盖不掉)")
def _():
from app.core.planner import ceil_lot, floor_lot
assert ceil_lot(100.5) == 200, ceil_lot(100.5)
assert ceil_lot(101) == 200 and ceil_lot(100) == 100 and ceil_lot(0) == 0
assert ceil_lot(100.0000001) == 100 # 1e-9 容差防浮点噪声顶成 200
assert floor_lot(199) == 100 and floor_lot(-5) == 0
@case("[B2] _min_sell: 科创板部分卖 <200 时, 可卖够就抬到 200, 不够就本轮不切; 主板原样")
def _():
from app.core.planner import _min_sell
assert _min_sell(100, 1000, "688802.SH") == 200 # 抬到 200 (偏保守多卖一点)
assert _min_sell(100, 150, "688802.SH") == 0 # 可卖不足 200, 这票本轮不切部分卖
assert _min_sell(200, 1000, "688802.SH") == 200 # 已合法, 原样
assert _min_sell(100, 1000, "600000.SH") == 100 # 主板不动
assert _min_sell(0, 1000, "688802.SH") == 0
@case("[B3] plan_sector_exit 停牌票不许静默消失: 无价也下整票卖单 (金额 0, 留痕说明)")
def _():
from app.core.planner import plan_sector_exit
r = plan_sector_exit(sector="半导体", positions=[
{"ts_code": "600000.SH", "total_qty": 6000, "price": 10.0, "sector": "半导体"},
# 停牌票: 行情缺失, price_ok=False (positions_view 拿摊薄成本顶的价)
{"ts_code": "688111.SH", "total_qty": 3000, "price": 8.0, "price_ok": False,
"sector": "半导体"},
])
assert r["ok"], r
ex = {i["ts_code"]: i for i in r["items"] if i["action"] == "EXIT"}
assert set(ex) == {"600000.SH", "688111.SH"}, ex # 停牌票也在
assert ex["688111.SH"]["amount"] == 0.0 and ex["688111.SH"].get("need_price"), ex
assert ex["688111.SH"]["qty"] == 3000
assert any("取不到现价" in n for n in r["notes"]), r["notes"]
# ================================================================
# [C] 规则闸: 科创板申报数量
# ================================================================
@case("[C1] 规则闸买入: 科创板 <200 股必拒 (交易所会拒单); 主板整百照旧")
def _():
from app.core import rule_gate as rg
ctx = {"ts_code": "688802.SH", "position": {"total_qty": 0, "avail_qty": 0},
"caps": None, "params": {}, "flags": {},
"day": {"price": 10.0, "day_chg_from_open": 0.0, "ma5": 10.0}}
r = rg.check(side="buy", action="OPEN", qty=100, price=10.0, ctx=ctx)
assert any("科创板买入申报最少 200" in f for f in r["failed"]), r["failed"]
r2 = rg.check(side="buy", action="OPEN", qty=200, price=10.0, ctx=ctx)
assert not any("科创板" in f for f in r2["failed"]), r2["failed"]
ctx3 = dict(ctx, ts_code="600000.SH")
r3 = rg.check(side="buy", action="OPEN", qty=100, price=10.0, ctx=ctx3)
assert not any("LOT_INVALID" in f for f in r3["failed"]), r3["failed"]
@case("[C2] 规则闸卖出: 科创板部分卖 <200 拒; 余额不足 200 一次性清出放行 (零股全清)")
def _():
from app.core import rule_gate as rg
def sell(code, qty, total):
return rg.check(side="sell", action="TRIM", qty=qty, price=10.0,
ctx={"ts_code": code, "caps": None, "params": {}, "flags": {},
"position": {"total_qty": total, "avail_qty": total},
"day": {"price": 10.0}})
r = sell("688802.SH", 100, 1000)
assert any("科创板部分减持最少 200" in f for f in r["failed"]), r["failed"]
assert sell("688802.SH", 200, 1000)["passed"], "200 股部分卖合法"
assert sell("688802.SH", 150, 150)["passed"], "余额 150 一次性清出是交易所允许的例外"
assert sell("600000.SH", 100, 1000)["passed"], "主板 100 股照旧"
# ================================================================
# [D] 动作引擎: 同轮买卖互斥
# ================================================================
@case("[D1] 同一只票同轮 TRIM+ADD 同时成立 → 买入侧让路, 不再自动对倒空耗手续费")
def _():
from app.core import action_engine as ae
params = {"scale": 2000000, "cushion_solid": 0.03, "trim_peak": 0.06,
"trim_giveback": 0.5, "stock_target_default": 0.06}
# 峰值 8% 回吐到 3% (过半) → TRIM 成立; 垫 3% 且创 5 日新高 → ADD 也成立
# (峰值是全时段只增不减, 加仓看近 5 日窗口 —— 两套时间基准可以同时为真)
pos = {"ts_code": "600000.SH", "total_qty": 6000, "avail_qty": 6000,
"cushion_pct": 0.03, "cushion_peak": 0.08, "price": 10.3,
"market_value": 61800.0, "target_pct": 0.06}
mkt = {"600000.SH": {"high5": 10.3, "ma5": 10.3, "tdays_since_open": None,
"tdays_since_last_add": None}}
r = ae.scan(positions=[pos], params=params, market=mkt)
acts = [c["action"] for c in r["candidates"]]
assert acts == ["TRIM"], r["candidates"] # 只留减仓, 买入侧让路
assert any(s["action"] == "ADD" and "互斥" in s["why"] for s in r["skipped"]), r["skipped"]
# 对照: 峰值不足、TRIM 不触发时, ADD 照常产出 (互斥只在同轮同票双触发时生效)
pos2 = dict(pos, cushion_peak=0.04)
r2 = ae.scan(positions=[pos2], params=params, market=mkt)
assert [c["action"] for c in r2["candidates"]] == ["ADD"], r2["candidates"]
# ================================================================
# [E] 信号口径
# ================================================================
@case("[E1] _norm_conf_pct 百分制契约: 1 = 1% (原启发式把 1 当 100% 直接触发自动清仓)")
def _():
from app.core.signal_rules import _norm_conf_pct
assert abs(_norm_conf_pct(1) - 0.01) < 1e-12, _norm_conf_pct(1)
assert abs(_norm_conf_pct(0.9) - 0.009) < 1e-12 # 0~1% 噪声级, 不再漏缩放
assert abs(_norm_conf_pct(92) - 0.92) < 1e-12
assert _norm_conf_pct(150) == 1.0 and _norm_conf_pct(-5) == 0.0
assert _norm_conf_pct("abc") == 0.0 # 解析不了按 0, 落"低于门槛"档
@case("[E2] digest 科创板中置信减持: 量抬到 200 / 持仓不足 200 退化全卖; 主板不变")
def _():
from app.core import signal_rules as sr
prm = {"sell_conf_min": 0.75, "auto_exit_conf": 0.85, "trim_ratio": 1 / 3}
def d(code, held, conf=0.8):
sig = {"source": "risk_sell", "ts_code": code, "action": "SELL", "confidence": conf}
return sr.digest(sig, {"total_qty": held, "avail_qty": held}, prm)
r = d("688111.SH", 300)
assert r["action"] == sr.ACT_PROPOSE and r["qty"] == 200, r # 100 → 抬到 200
r2 = d("688111.SH", 150)
assert r2["qty"] == 150, r2 # 不足 200: 一次性全清是合法例外
r3 = d("600000.SH", 3000)
assert r3["qty"] == 1000, r3 # 主板 1/3 照旧
r4 = d("688111.SH", 3000)
assert r4["qty"] == 1000, r4 # 量本来就 ≥200, 不动
# ================================================================
# [F] 除权 / 日历
# ================================================================
@case("[F1] apply_ex_right: 已部分核销的批次, closed_qty 与核销均价同比例调 (单位不混算)")
def _():
from app.core.recon import apply_ex_right
lots = [{"id": 1, "qty": 500, "open_price": 20.0,
"closed_qty": 500, "close_avg_price": 22.0},
{"id": 2, "qty": 1000, "open_price": 18.0, "closed_qty": 0,
"close_avg_price": None}]
out = apply_ex_right(lots, 2.0) # 10 送 10
a, b = out[0], out[1]
assert a["qty"] == 1000 and abs(a["open_price"] - 10.0) < 1e-9, a
# 原来只调剩余数量: 剩余是新股数单位、已核销还是旧单位, 摊薄成本照样错
assert a["closed_qty"] == 1000 and abs(a["close_avg_price"] - 11.0) < 1e-9, a
assert b["qty"] == 2000 and b["closed_qty"] == 0 and b["close_avg_price"] is None, b
assert "除权调整" in a["note"]
@case("[F2] 交易日历按年探测: 库装了但没当年数据 → degraded=True, 工作日放行不静默跳")
def _():
import app.core.tradedays as td0
orig_has, orig_fn = td0._HAS_CAL, td0._is_workday
orig_cache = dict(td0._YEAR_OK)
try:
td0._HAS_CAL = True
def fake_workday(d):
if d.year >= 2027: # 模拟: 库只有 2026 及以前的数据
raise NotImplementedError("no data for 2027")
return True
td0._is_workday = fake_workday
td0._YEAR_OK.clear()
# 原来 degraded 只看"装没装": 年初库没升级时, 全年法定节假日都被当交易日,
# 页面却显示一切正常 —— 这正是最常见的降级场景。
assert td0.calendar_degraded(date(2026, 8, 28)) is False
assert td0.calendar_degraded(date(2027, 1, 15)) is True
assert td0.is_trade_day(date(2027, 1, 15)) is True # 周五: 降级按工作日放行
assert td0.is_trade_day(date(2027, 1, 16)) is False # 周六照样拦
assert td0._YEAR_OK.get(2027) is False and td0._YEAR_OK.get(2026) is True
finally:
td0._HAS_CAL, td0._is_workday = orig_has, orig_fn
td0._YEAR_OK.clear()
td0._YEAR_OK.update(orig_cache)
# ================================================================
# [G] 策略运行侧: 网格中枢 / 止盈闩锁
# ================================================================
@case("[G1] 网格只买中枢下方: 上半区回落一档不接盘, 档位照常推进 (不再高买低不买)")
def _():
from app.services import strategy_runner as srun
prm = {"lower": 9.0, "upper": 11.0, "center": 10.0, "step_pct": 0.02,
"per_lot": 100, "max_capital": 50000}
lv = srun._grid_levels(prm)
k = srun._band(lv, 10.6) # 中枢上方的一档
assert lv[k] >= 10.0, (k, lv)
st = {"last_band": k + 1, "filled_levels": {}}
d = srun._eval_grid({"ts_code": "600000.SH", "params": prm},
{"avail_qty": 0, "add_qty": 0}, {"price": 10.6}, None,
{"state": st, "notes": [], "buy_paused": False})
assert d is None and st["last_band"] == k, (d, st) # 不买, 但档位随价下移
# 中枢下方照常接 (与 batch17 科创板用例同一条路, 这里钉主板+显式中枢)
b0 = srun._band(lv, 9.5)
st2 = {"last_band": b0 + 1, "filled_levels": {}}
d2 = srun._eval_grid({"ts_code": "600000.SH", "params": prm},
{"avail_qty": 0, "add_qty": 0}, {"price": 9.5}, None,
{"state": st2, "notes": [], "buy_paused": False})
assert d2 and d2["side"] == "buy", d2
# 没显式配 center 时用 (下界+上界)/2 兜底, 行为一致
prm2 = {"lower": 9.0, "upper": 11.0, "step_pct": 0.02, "per_lot": 100}
st3 = {"last_band": k + 1, "filled_levels": {}}
d3 = srun._eval_grid({"ts_code": "600000.SH", "params": prm2},
{"avail_qty": 0, "add_qty": 0}, {"price": 10.6}, None,
{"state": st3, "notes": [], "buy_paused": False})
assert d3 is None and st3["last_band"] == k, (d3, st3)
@case("[G2] 跟踪止盈部分卖一次性闩锁: 同一高水位只卖一次, 创新高后才许再卖; 全清不上锁")
def _():
from app.services import strategy_runner as srun
def trail(avail, price, state, ratio=0.5):
# start_line 显式给, 别让缺省值走 param_store (这批单测不连库)
st = {"ts_code": "600000.SH", "params": {"giveback": 0.05, "sell_ratio": ratio,
"start_line": 0.03}}
pos = {"avg_cost": 8.0, "avail_qty": avail, "total_qty": avail * 2,
"cushion_pct": price / 8.0 - 1}
return srun._eval_trail(st, pos, {"price": price}, None,
{"state": state, "notes": []})
state = {"armed": True, "high_water": 12.0}
d1 = trail(1000, 10.0, state)
assert d1 and d1["qty"] == 500, d1 # 第一次回落: 卖一半
assert state.get("trail_fired_hw") == 12.0, state # 闩锁记下高水位
# 原 bug: 卖完条件仍成立, 每隔一单再卖剩余一半, 几何级联直到卖光
assert trail(500, 10.0, state) is None, "同一高水位不许再卖"
trail(500, 13.0, state) # 创新高 → 高水位抬到 13
assert state["high_water"] == 13.0, state
d3 = trail(500, 12.3, state) # 新一轮回落 ≥5% → 允许再卖
assert d3 and d3["qty"] == 200, d3
# 全清路径不上锁: 清仓意图失败了就该重试
state4 = {"armed": True, "high_water": 12.0, "trail_fired_hw": 12.0}
d4 = trail(1000, 10.0, state4, ratio=1.0)
assert d4 and d4["action"] == srun.A_EXIT, d4
# ================================================================
# [H] 策略买入暂停按来源分记
# ================================================================
@case("[H1] buypause 多来源并存: accum 解除只摘自己的, 不放开风控停的; 旧格式条目自动迁移")
def _():
from app.repo import pms_repo
from app.services import strategy_service as svc
store = {}
orig_get, orig_set = pms_repo.get_param, pms_repo.set_param
orig_ls = pms_repo.list_strategies
try:
pms_repo.get_param = lambda k: store.get(k)
pms_repo.set_param = lambda k, v, by="user": store.__setitem__(k, str(v)) or 1
pms_repo.list_strategies = (
lambda *, ts_code=None, statuses=None, limit=500, include_archived=False:
[{"strategy_id": "S1", "ts_code": ts_code}])
# 旧格式条目 (没有 sources 子表) 先躺在表里 → pause_buy 迁移成 sources
import json
store[svc.BUYPAUSE_KEY] = json.dumps(
{"600000.SH": {"reason": "旧风控", "source": "signal", "at": "2026-08-27"}})
assert svc.pause_buy("600000.SH", reason="定性失效", source="accum") == ["S1"]
m = svc.buypause_map()
assert set(m["600000.SH"]["sources"]) == {"signal", "accum"}, m
# 原 bug: 先写先赢、后来的来源被吞 —— advisor 按 accum 解除时把风控停的也放开了
r = svc.clear_buypause("600000.SH", only_source="accum")
assert r["ok"] and r["cleared"] and r["still_paused_by"] == ["signal"], r
m2 = svc.buypause_map()
assert "600000.SH" in m2 and set(m2["600000.SH"]["sources"]) == {"signal"}, m2
# 来源不匹配: 不动, 不算错
r2 = svc.clear_buypause("600000.SH", only_source="accum")
assert r2["ok"] and r2["cleared"] is False, r2
# 无 only_source: 整条解除
r3 = svc.clear_buypause("600000.SH")
assert r3["cleared"] and "600000.SH" not in svc.buypause_map(), r3
finally:
pms_repo.get_param, pms_repo.set_param = orig_get, orig_set
pms_repo.list_strategies = orig_ls
# ================================================================
# [I] 宏观失败路径不冲留痕
# ================================================================
@case("[I1] _upsert_unavailable: 重扫失败保留当日 CMD_ISSUED/建议/周期, 不再整行重写")
def _():
from app.repo import macro_repo
from app.core import macro_rules as mr
from app.services import macro_service as ms
got = {}
orig_get, orig_up = macro_repo.get_signal, macro_repo.upsert_signal
try:
macro_repo.get_signal = lambda key, d: {
"action": "CMD_ISSUED", "ref_id": "CMD_MACRO_1", "note": "已下减仓命令",
"detail": {"cycle": {"done_shift": 1}, "exit_acted": True, "advice": ""}}
macro_repo.upsert_signal = lambda **kw: got.update(kw) or 1
ms._upsert_unavailable("stock_fx_hedge", 20260828, "数据源超时")
# 原来失败路径按默认值整行覆盖: 上午的 CMD_ISSUED 被冲掉 → "当日不重复下命令"
# 判据失效, 次日取昨日周期也拿不到
assert got["action"] == "CMD_ISSUED" and got["ref_id"] == "CMD_MACRO_1", got
assert got["zone"] == mr.Z_UNAVAILABLE and got["value"] is None, got
assert got["detail"]["cycle"] == {"done_shift": 1}, got["detail"]
assert got["detail"]["exit_acted"] is True and got["detail"]["advice"] == "", got
assert "重扫失败" in got["note"] and "已下减仓命令" in got["note"], got["note"]
# 当日无留痕 (action=NONE) 时: 正常落 UNAVAILABLE, note 就是失败原因本身
got.clear()
macro_repo.get_signal = lambda key, d: None
ms._upsert_unavailable("stock_fx_hedge", 20260828, "数据源超时")
assert got["action"] == "NONE" and got["note"] == "数据源超时", got
finally:
macro_repo.get_signal, macro_repo.upsert_signal = orig_get, orig_up
def main():
passed, failed = 0, []
for name, fn in RESULTS:
try:
fn()
passed += 1
print(f"{name}")
except Exception as e:
failed.append((name, e))
print(f"{name}: {type(e).__name__}: {e}")
traceback.print_exc()
print()
if failed:
print(f"FAILED {len(failed)}/{len(RESULTS)}")
sys.exit(1)
print(f"ALL PASS ({passed} cases)")
if __name__ == "__main__":
main()

View File

@ -202,6 +202,7 @@ def _():
for i in r["items"]: for i in r["items"]:
if i["action"] in (pl.A_EXIT, pl.A_TRIM): if i["action"] in (pl.A_EXIT, pl.A_TRIM):
used[i["ts_code"]] = used.get(i["ts_code"], 0) + i["qty"] used[i["ts_code"]] = used.get(i["ts_code"], 0) + i["qty"]
assert used, "35 万档必须真的排出减持动作 (2026-08-28 补: 空计划曾能零断言通过)"
for c, q in used.items(): for c, q in used.items():
assert q <= hold[c], (c, q, hold[c]) assert q <= hold[c], (c, q, hold[c])

View File

@ -38,14 +38,21 @@ def day(**kw):
# ================================================================ exec_timing # ================================================================ exec_timing
@case("分日配额·整除/上取整到一手/最后一日全出/零股尾巴并入") @case("分日配额·整除/上取整到一手/最后一日全出/零股尾巴只对清仓并入")
def _(): def _():
assert et.daily_quota(6000, 3) == 2000 assert et.daily_quota(6000, 3) == 2000
assert et.daily_quota(5000, 3) == 1700 # 1666.7 → 上取整到一手 assert et.daily_quota(5000, 3) == 1700 # 1666.7 → 上取整到一手
assert et.daily_quota(1000, 1) == 1000 # 最后一日全出 assert et.daily_quota(1000, 1) == 1000 # 最后一日全出
assert et.daily_quota(150, 3) == 150 # 尾巴不足一手 → 一次出完 # 2026-08-28 口径修正: 部分减持 (allow_odd_tail=False) **不再并零股尾巴** ——
# 并进去整片变成非整百, 会被规则闸按 LOT_INVALID 整片拒掉, 连整数部分都卖不出。
# 150 剩量的部分减持: 本次出 100, 尾巴 50 留给窗口收口报部分完成。
assert et.daily_quota(150, 3) == 100
# 整票清仓 (allow_odd_tail=True) 才允许把零股尾巴并进本片一次出完
assert et.daily_quota(150, 3, allow_odd_tail=True) == 150
assert et.daily_quota(0, 3) == 0 assert et.daily_quota(0, 3) == 0
assert et.daily_quota(100, 5) == 100 assert et.daily_quota(100, 5) == 100
# 科创板: 按 lot=200 取整
assert et.daily_quota(1000, 3, lot=200) == 400
# 整票清仓允许零股 # 整票清仓允许零股
assert et.daily_quota(14050, 3, allow_odd_tail=True) == 4700 assert et.daily_quota(14050, 3, allow_odd_tail=True) == 4700
assert et.daily_quota(14050, 1, allow_odd_tail=True) == 14050 assert et.daily_quota(14050, 1, allow_odd_tail=True) == 14050

View File

@ -50,10 +50,18 @@ def _():
assert s["source"] == sr.SRC_RISK_SELL and s["action"] == "SELL" assert s["source"] == sr.SRC_RISK_SELL and s["action"] == "SELL"
assert abs(s["confidence"] - 0.88) < 1e-9, s # 88 → 0.88, 两条流尺度不同 assert abs(s["confidence"] - 0.88) < 1e-9, s # 88 → 0.88, 两条流尺度不同
assert s["dominant_signal"] == "破位" and "支撑" in s["reason"] assert s["dominant_signal"] == "破位" and "支撑" in s["reason"]
# 已经是 0~1 的也不会被再除一次 # 2026-08-28 口径修正: 风控流按文档**固定 0~100 制, 一律除以 100**, 不再做
# "大于 1 才除"的猜测 —— 原口径下 0~100 制里的 1 (即 1%) 会被当成 100% 直接清仓。
assert abs(sr.parse_risk_sell({"data": json.dumps({"ts_code": "x", "action": "SELL", assert abs(sr.parse_risk_sell({"data": json.dumps({"ts_code": "x", "action": "SELL",
"confidence": 0.9})})["confidence"] "confidence": 0.9})})["confidence"]
- 0.9) < 1e-9 - 0.009) < 1e-9
# 危险边界: 1 是 1%, 绝不能被解释成 100%
assert abs(sr.parse_risk_sell({"data": json.dumps({"ts_code": "x", "action": "SELL",
"confidence": 1})})["confidence"]
- 0.01) < 1e-9
# 盘中流 (0~1 制) 的口径不变: 0.9 就是 90%
assert abs(sr.parse_intraday({"ts_code": "x", "action": "SELL",
"confidence": "0.9"})["confidence"] - 0.9) < 1e-9
# 坏 JSON → 明确标记, 不抛异常 # 坏 JSON → 明确标记, 不抛异常
bad = sr.parse_risk_sell({"data": "{不是JSON"}) bad = sr.parse_risk_sell({"data": "{不是JSON"})
assert bad["ts_code"] == "" and "解析失败" in bad["parse_error"] assert bad["ts_code"] == "" and "解析失败" in bad["parse_error"]

View File

@ -621,6 +621,9 @@ def run():
@case("密钥不进 ParamStore (协议 §10.1.1)") @case("密钥不进 ParamStore (协议 §10.1.1)")
def _(): def _():
from app.services import param_store from app.services import param_store
# 2026-08-28 补: 名单本身要钉死 —— 原断言拿实现自己的 SECRET_KEYS 当预期,
# 有人把键从名单里删掉, 保护和测试会一起消失
assert {"PMS_QMT_SIGN_SEED_HEX", "PMS_QMT_PEER_PUBKEY_B64"} <= set(param_store.SECRET_KEYS), param_store.SECRET_KEYS
snap_keys = {p["key"] for p in [{"key": k} for k in param_store._editable_keys()]} snap_keys = {p["key"] for p in [{"key": k} for k in param_store._editable_keys()]}
for k in param_store.SECRET_KEYS: for k in param_store.SECRET_KEYS:
assert k not in snap_keys, f"{k} 不该出现在可调参数里" assert k not in snap_keys, f"{k} 不该出现在可调参数里"

View File

@ -224,6 +224,8 @@ def _():
pf.assert_fresh(p, max_stale_tdays=1, today="2026-08-05") pf.assert_fresh(p, max_stale_tdays=1, today="2026-08-05")
except pf.PlanFeedError as e: except pf.PlanFeedError as e:
assert "2026-07-29" in str(e) and "交易日" in str(e), str(e) assert "2026-07-29" in str(e) and "交易日" in str(e), str(e)
else:
raise AssertionError("超期计划没有抛 PlanFeedError (2026-08-28 补: 原来不抛也静默通过)")
# ================================================================ 筛选 # ================================================================ 筛选

View File

@ -34,7 +34,9 @@ class FakeRepo:
self.positions, self.lots, self.instructions = {}, [], {} self.positions, self.lots, self.instructions = {}, [], {}
self.proposals, self.ledger, self.reports, self.industry = {}, [], {}, {} self.proposals, self.ledger, self.reports, self.industry = {}, [], {}, {}
self.cash_flows = [] self.cash_flows = []
self.strategies = {}
self._lot_id = 0 self._lot_id = 0
self._strategy_id = 0
# --- runtime param --- # --- runtime param ---
def all_params(self): def all_params(self):
@ -74,10 +76,13 @@ class FakeRepo:
and (not cmd_class or c["cmd_class"] == cmd_class)] and (not cmd_class or c["cmd_class"] == cmd_class)]
return sorted(out, key=lambda c: -c["id"])[:limit] return sorted(out, key=lambda c: -c["id"])[:limit]
def update_command(self, cid, *, status=None, progress=None, done_at=None, note=None): def update_command(self, cid, *, status=None, progress=None, done_at=None, note=None,
only_if_status=None):
c = self.commands.get(cid) c = self.commands.get(cid)
if not c: if not c:
return 0 return 0
if only_if_status is not None and c.get("status") not in list(only_if_status):
return 0 # 条件更新没抢到 (与真 repo 的 WHERE status IN 同语义)
if status is not None: if status is not None:
c["status"] = status c["status"] = status
if progress is not None: if progress is not None:
@ -338,6 +343,62 @@ class FakeRepo:
self.industry[r["ts_code"]] = r["industry"] self.industry[r["ts_code"]] = r["industry"]
return len(rows) return len(rows)
# --- pms_strategy (个股交易方案) ---
# 2026-08-28 审查补: 桩里原来**一个策略函数都没有**, 于是所有走
# pms_repo.list_strategies / active_strategy_codes 的服务代码在单测里都命中
# 真 repo → 连库异常 → 被各自的 try/except 按"空集"吞掉 —— 策略相关的接线
# (清仓撤策略 / 风控暂停买入 / 动作引擎排除策略票) 从来没被装配自检真正跑过。
def list_strategies(self, *, ts_code=None, statuses=None, limit=500,
include_archived=False):
out = [dict(s) for s in self.strategies.values()
if (not ts_code or s["ts_code"] == ts_code)
and (not statuses or s["status"] in list(statuses))
and (include_archived or not s.get("archived_at"))]
return sorted(out, key=lambda s: -s["id"])[:int(limit)]
def get_strategy(self, strategy_id):
s = self.strategies.get(strategy_id)
return dict(s) if s else None
def active_strategies(self):
return self.list_strategies(statuses=["ACTIVE"], limit=1000)
def active_strategy_codes(self):
return {s["ts_code"] for s in self.strategies.values()
if s["status"] == "ACTIVE" and s.get("ts_code")}
def insert_strategy(self, *, strategy_id, ts_code, stype, autonomy="auto", params=None,
state=None, status="ACTIVE", note=None):
if strategy_id in self.strategies: # 真表 strategy_id 唯一键
raise Exception(f"Duplicate entry '{strategy_id}' for key 'strategy_id'")
self._strategy_id += 1
self.strategies[strategy_id] = {
"id": self._strategy_id, "strategy_id": strategy_id, "ts_code": ts_code,
"type": stype, "status": status, "autonomy": autonomy,
"params": dict(params or {}), "state": dict(state or {}), "note": note,
"archived_at": None, "created_at": datetime.now(), "updated_at": datetime.now()}
return 1
def update_strategy(self, strategy_id, **fields):
s = self.strategies.get(strategy_id)
if not s:
return 0
upd = dict(fields)
# 与真 repo 同义: params/state 传 dict 落 *_json 列; 其余按白名单更新
touched = False
for k in ("params", "state"):
if k in upd:
s[k] = dict(upd.pop(k) or {})
touched = True
for k in ("status", "autonomy", "note"):
if k in upd:
s[k] = upd[k]
touched = True
if not touched:
return 0
s["updated_at"] = datetime.now()
return 1
class FakeQmtRepo: class FakeQmtRepo:
"""ws 通道三表的内存替身 (pms_qmt_order / pms_qmt_inbox / pms_ws_state)。 """ws 通道三表的内存替身 (pms_qmt_order / pms_qmt_inbox / pms_ws_state)。
@ -376,10 +437,11 @@ class FakeQmtRepo:
def enqueue_order(self, *, instruction_id, parent_id, ts_code, side, qty, limit_price, def enqueue_order(self, *, instruction_id, parent_id, ts_code, side, qty, limit_price,
valid_until, intent="OPEN", note=None): valid_until, intent="OPEN", note=None):
self.orders[instruction_id] = { self.orders[instruction_id] = {
"instruction_id": instruction_id, "parent_id": parent_id, # 真表的列名是 parent_id (ddl_pms_v1.sql / qmt_repo.enqueue_order)。
# 真表的列名是 parent_instruction_id, consume_ws_trades 反查用的是它。 # 2026-08-28 修: 桩里原来多放了一个不存在的 parent_instruction_id 键,
# 桩里两个键都放, 少一个的话 SMOKE_ 那道闸在单测里永远"看起来没生效"。 # 还配了一句写反的注释 —— 恰好掩护了 consume_ws_trades 读错列名的真 bug
"parent_instruction_id": parent_id, "ts_code": ts_code, # (SMOKE 闸从未生效)。桩必须与真表同形, 只留 parent_id。
"instruction_id": instruction_id, "parent_id": parent_id, "ts_code": ts_code,
"side": side, "qty": int(qty), "limit_price": float(limit_price), "side": side, "qty": int(qty), "limit_price": float(limit_price),
"valid_until": int(valid_until), "intent": intent, "note": note, "valid_until": int(valid_until), "intent": intent, "note": note,
"status": "QUEUED", "cancel_state": "NONE", "cancel_id": None} "status": "QUEUED", "cancel_state": "NONE", "cancel_id": None}
@ -505,11 +567,12 @@ def install_fakes(prices=None, positions=None, params=None, high5=None, prev_clo
industry_repo.primary_industry_map = lambda codes, level="l3": {} industry_repo.primary_industry_map = lambda codes, level="l3": {}
industry_repo.invalidate = lambda: None industry_repo.invalidate = lambda: None
downstream_repo.latest_filled_order_id = lambda: "ANCHOR_0" downstream_repo.latest_filled_order_id = lambda: "ANCHOR_0"
# 回放游标预置成非空 —— 否则 replay_fills 会走「冷启动只对齐不追认」那条路 (见 downstream_repo.latest_filled_time = lambda: "2026-07-27 09:00:00"
# ledger_service._seed_cursor), 下面那几个回放用例就测不到入账。冷启动本身另有专门用例。 # 回放游标预置成 v2 时间游标 (从 epoch 起 = 什么都算新) —— 否则 replay_fills 会走
# 用 "0" 而不是随便一个字符串: next_cursor 只进不退, 且非数字 id 会退化成字典序比较, # 「冷启动只对齐不追认」那条路, 下面那几个回放用例就测不到入账。冷启动另有专门用例。
# 占位值若比真实 order_id 大 (比如 "SEED"), 游标就永远推不动了。 # 2026-08-28 起游标是 {"v":2,"t":时间,"seen":{order_id:时间}}, 不再按 order_id 字典序。
fake.params.setdefault("PMS_REPLAY_CURSOR", "0") fake.params.setdefault("PMS_REPLAY_CURSOR",
'{"v": 2, "t": "1970-01-01 00:00:00", "seen": {}}')
# 上游选股计划接口在单测里一律停用 (base 为空 → plan_feed 立即抛 PlanFeedError, # 上游选股计划接口在单测里一律停用 (base 为空 → plan_feed 立即抛 PlanFeedError,
# 不会发出任何 HTTP 请求)。要测候选池的用例自己 stub plan_feed.candidates。 # 不会发出任何 HTTP 请求)。要测候选池的用例自己 stub plan_feed.candidates。
fake.params.setdefault("PMS_PLAN_API_BASE", "") fake.params.setdefault("PMS_PLAN_API_BASE", "")
@ -790,11 +853,13 @@ def _():
assert fake.positions["600000.SH"]["base_qty"] == 6000 assert fake.positions["600000.SH"]["base_qty"] == 6000
assert abs(float(fake.positions["600000.SH"]["cushion_pct"]) - 0.10) < 1e-4 assert abs(float(fake.positions["600000.SH"]["cushion_pct"]) - 0.10) < 1e-4
assert fake.instructions["INS_A"]["status"] == "CONFIRMED" assert fake.instructions["INS_A"]["status"] == "CONFIRMED"
assert fake.params["PMS_REPLAY_CURSOR"] == "101" import json as _json
# 幂等: 游标已推进, 同一批不再重复入账 cur = _json.loads(fake.params["PMS_REPLAY_CURSOR"])
downstream_repo.fetch_filled_orders = lambda **kw: [] assert cur["t"] == "2026-07-27 09:40:00" and "101" in cur["seen"], cur
# 幂等: **同一批再喂一遍**也不重复入账 (seen 集合按 order_id 去重 ——
# 这才是真幂等; 旧口径只测了"喂空批不入账", 测不住重复消费)
r2 = ls.replay_fills() r2 = ls.replay_fills()
assert r2["fills"] == 0 and fake.positions["600000.SH"]["total_qty"] == 6000 assert r2["fills"] == 0 and fake.positions["600000.SH"]["total_qty"] == 6000, r2
finally: finally:
downstream_repo.fetch_filled_orders = orig downstream_repo.fetch_filled_orders = orig
@ -839,14 +904,15 @@ def _():
orig = downstream_repo.fetch_filled_orders orig = downstream_repo.fetch_filled_orders
try: try:
downstream_repo.fetch_filled_orders = lambda **kw: hist downstream_repo.fetch_filled_orders = lambda **kw: hist
downstream_repo.latest_filled_order_id = lambda: "SELL_ZZZ_999" downstream_repo.latest_filled_time = lambda: "2026-07-28 15:00:00"
r = ls.replay_fills() r = ls.replay_fills()
# 关键: 一条都不能入账。旧系统多年的历史成交若被并入 BASE, 摊薄成本与安全垫全错, # 关键: 一条都不能入账。旧系统多年的历史成交若被并入 BASE, 摊薄成本与安全垫全错,
# 而补仓/加仓/保垫减仓都挂在安全垫上 —— 一错就是整条纪律链。 # 而补仓/加仓/保垫减仓都挂在安全垫上 —— 一错就是整条纪律链。
assert r.get("seeded") and r["fills"] == 0 and r["actions"] == 0, r assert r.get("seeded") and r["fills"] == 0 and r["actions"] == 0, r
assert r["cursor"] == "SELL_ZZZ_999", r assert r["cursor"] == "2026-07-28 15:00:00", r
assert not fake.lots and not fake.positions, "冷启动不该产生任何批次或持仓" assert not fake.lots and not fake.positions, "冷启动不该产生任何批次或持仓"
assert fake.params["PMS_REPLAY_CURSOR"] == "SELL_ZZZ_999" import json as _json
assert _json.loads(fake.params["PMS_REPLAY_CURSOR"])["t"] == "2026-07-28 15:00:00"
# 游标就位后, 新成交照常入账 # 游标就位后, 新成交照常入账
new = [{"order_id": "ZZZ_NEW", "ts_code": "600000.SH", "side": "buy", "qty": 100, new = [{"order_id": "ZZZ_NEW", "ts_code": "600000.SH", "side": "buy", "qty": 100,
@ -1041,19 +1107,36 @@ def _():
assert fake.reports[rep["ymd"]]["ymd"] == rep["ymd"] assert fake.reports[rep["ymd"]]["ymd"] == rep["ymd"]
@case("账本服务·除权检测走通 (10送10 → 批次按比例调整)") @case("账本服务·除权检测走通 (10送10 → 批次按比例调整; 下游为检测源)")
def _(): def _():
# 2026-08-28 口径重写: 送转发生在券商账户, 账本数量在对账之前不会变 ——
# 检测必须比「昨日快照 vs 下游当前数量」, 而不是账本自己比自己 (那样永远检不出,
# 红股会被对账当普通差异吸收, 老批次成本价不除权, 摊薄成本虚高)。
from app.services import ledger_service as ls from app.services import ledger_service as ls
fake = install_fakes(prices={"600000.SH": 5.0}, params={"PMS_TOTAL_SCALE": "2000000"}, fake = install_fakes(prices={"600000.SH": 5.0}, params={"PMS_TOTAL_SCALE": "2000000"},
positions=[{"ts_code": "600000.SH", "total_qty": 2000, positions=[{"ts_code": "600000.SH", "total_qty": 1000,
"avg_cost": 10.0}]) "avg_cost": 10.0}])
fake.insert_lot(ts_code="600000.SH", lot_type="BASE", qty=2000, open_price=10.0, fake.insert_lot(ts_code="600000.SH", lot_type="BASE", qty=1000, open_price=10.0,
open_date="2026-07-01") open_date="2026-07-01")
fake.upsert_report(20260726, {"snapshot": {"600000.SH": {"qty": 1000, "price": 10.0}}}) fake.upsert_report(20260726, {"snapshot": {"600000.SH": {"qty": 1000, "price": 10.0}}})
r = ls.detect_and_apply_ex_right() orig_src = ls.positions_source
assert r["ex_rights"] and abs(r["ex_rights"][0]["ratio"] - 2.0) < 1e-6, r try:
lot = fake.lots[0] ls.positions_source = lambda: {"source": "table", "mode": "test", "alerts": [],
assert lot["qty"] == 4000 and abs(lot["open_price"] - 5.0) < 1e-6, lot "rows": [{"ts_code": "600000.SH", "qty": 2000}],
"columns": {"qty": "q"}, "raw_count": 1,
"as_of": 0, "age_sec": 0}
r = ls.detect_and_apply_ex_right()
assert r["ex_rights"] and abs(r["ex_rights"][0]["ratio"] - 2.0) < 1e-6, r
lot = fake.lots[0]
assert lot["qty"] == 2000 and abs(lot["open_price"] - 5.0) < 1e-6, lot
# 正常加仓日不误报: 账本数量已因今日成交变动 → 跳过, 不产生 MISMATCH
fake2_pos = fake.positions["600000.SH"]
fake2_pos["total_qty"] = 3000 # 账本已变 (今天有成交), 快照仍是 1000
r2 = ls.detect_and_apply_ex_right()
assert not r2["mismatches"] and not r2["ex_rights"], r2
finally:
ls.positions_source = orig_src
@case("盘前准备·T+1 可卖重置") @case("盘前准备·T+1 可卖重置")
@ -1616,22 +1699,39 @@ def _():
class FakeRedis: class FakeRedis:
"""Redis Stream 的最小替身 (消费组 + xreadgroup + ack)。""" """Redis Stream 的最小替身, 语义对齐真 Redis 消费组 (2026-08-28 扩):
- ">" 只投递**从未投递过**的消息, 读到即进本消费者的待确认清单 (delivered);
- "0" 只回自己名下**读了没确认**的消息 消费组的"不确认会重投"仅指这条路;
- xack 从待确认清单移除;
- xrevrange 是只读取样 (试算用), 不产生任何投递痕迹
原桩把"读了不确认"做成了"下次照常再读", 恰好掩护了 dry_run 吞消息的真 bug"""
def __init__(self, msgs=None): def __init__(self, msgs=None):
self.msgs = dict(msgs or {}) self.msgs = dict(msgs or {})
self.delivered = {} # key -> [(id, fields)] 已投递未确认
self.acked, self.groups = [], [] self.acked, self.groups = [], []
def xgroup_create(self, key, group, id="$", mkstream=False): def xgroup_create(self, key, group, id="$", mkstream=False):
self.groups.append((key, group)) self.groups.append((key, group))
def xreadgroup(self, group, consumer, streams, count=10, block=0): def xreadgroup(self, group, consumer, streams, count=10, block=0):
key = list(streams)[0] key, start = list(streams)[0], list(streams.values())[0]
m = self.msgs.pop(key, []) if str(start) == "0": # 回捞自己名下待确认的
m = list(self.delivered.get(key) or [])
return [(key, m)] if m else []
m = self.msgs.pop(key, []) # ">": 只投新消息, 且立刻进待确认清单
if m:
self.delivered.setdefault(key, []).extend(m)
return [(key, m)] if m else [] return [(key, m)] if m else []
def xrevrange(self, key, count=10):
rows = list(self.delivered.get(key) or []) + list(self.msgs.get(key) or [])
return list(reversed(rows))[:count]
def xack(self, key, group, msg_id): def xack(self, key, group, msg_id):
self.acked.append(msg_id) self.acked.append(msg_id)
self.delivered[key] = [(i, f) for i, f in (self.delivered.get(key) or [])
if i != msg_id]
def xlen(self, key): def xlen(self, key):
return len(self.msgs.get(key, [])) return len(self.msgs.get(key, []))
@ -1720,6 +1820,12 @@ def _():
by = _install_signal_fakes(fake2, sell_msgs=[_sell_msg("3-9", "600000.SH", 95)]) by = _install_signal_fakes(fake2, sell_msgs=[_sell_msg("3-9", "600000.SH", 95)])
r2 = ss.consume() r2 = ss.consume()
assert "skipped" in r2 and not fake2.instructions, r2 assert "skipped" in r2 and not fake2.instructions, r2
# 2026-08-28 补: 开关关着必须**碰都不碰**消费组 —— 读了不 ACK 的消息会永久滞留,
# 开关重开后再也补不回来 (原来 by 拿了没断言, 恰是这条最该断)
from config.settings import settings as _st
_r3c = by[_st.SIGNAL_REDIS_DB_ACTIONS]
assert _r3c.acked == [] and not _r3c.delivered, (_r3c.acked, _r3c.delivered)
assert _r3c.msgs.get("bionic:signals:llm_sell_actions"), "消息必须原封不动留在流里"
fake3 = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"}, fake3 = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
positions=[{"ts_code": "600000.SH", "total_qty": 6000, positions=[{"ts_code": "600000.SH", "total_qty": 6000,
@ -2054,6 +2160,222 @@ def _():
assert by["601111.SH"]["src"] == "buy_plan" and by["601111.SH"]["price"] == 9.0 assert by["601111.SH"]["src"] == "buy_plan" and by["601111.SH"]["price"] == 9.0
# ================================================================ 2026-08-28 审查补:
# 撤销链路 / 进度口径 / 策略接线 / 消化幂等 —— 这批用例钉住的都是当次审查修掉的真伤
@case("命令撤销·在途指令必须经下游撤回; 下游拒撤 → 命令保持在途可重撤")
def _():
from app.services import command_service as csvc, dispatcher
fake = install_fakes(
prices={"600000.SH": 10.0, "000001.SZ": 8.0},
params={"PMS_TOTAL_SCALE": "2000000"},
positions=[{"ts_code": "600000.SH", "total_qty": 14000, "base_qty": 7000,
"avg_cost": 8.93},
{"ts_code": "000001.SZ", "total_qty": 10000, "base_qty": 10000,
"avg_cost": 8.8}])
r = csvc.issue("REDUCE_EXPOSURE", {"pct": "5%", "window_tdays": 3})
assert r["ok"], r
cid = r["command_id"]
plans = fake.list_plans(command_id=cid)
assert plans, "方案未落表"
# 手工把一条方案物化成在途指令 (绕过择时, 只测「撤销必须过下游」这条链)
fake.insert_instruction(instruction_id="INS_CXL_1", origin_type="plan",
origin_id=plans[0]["plan_id"], ts_code=plans[0]["ts_code"],
action=plans[0]["action"], side="sell", qty=plans[0]["qty"],
limit_price=8.0, status="DISPATCHED")
orig = dispatcher.cancel
try:
dispatcher.cancel = lambda **kw: {"ok": False, "error": "ws 断连, 撤单没发出去"}
r2 = csvc.cancel(cid)
# 2026-07-31 教训的第五条路: 原来这里只把本端行标 CANCELLED, 下游委托继续挂着
# 继续成交, 页面却回"已撤销"。现在: 没撤成 → 命令**保持在途**继续被跟踪。
assert r2["ok"] is False and r2["failed"], r2
assert "仍在下游挂着" in r2["message"], r2["message"]
assert fake.commands[cid]["status"] == "EXECUTING", fake.commands[cid]
assert fake.instructions["INS_CXL_1"]["status"] == "DISPATCHED", \
"下游拒撤时指令不许在本端标终态"
finally:
dispatcher.cancel = orig
# 下游恢复后再点一次撤销: 方案已 CANCELLED 也要能匹配到在途指令 (匹配用**全部**方案,
# 不带状态过滤 —— 带了的话上一次撤到一半的在途指令会漏成孤儿)
r3 = csvc.cancel(cid)
assert r3["ok"] and "下游已确认" in r3["message"], r3
assert fake.instructions["INS_CXL_1"]["status"] == "CANCELLED"
assert fake.commands[cid]["status"] == "CANCELLED"
@case("命令进度·目标金额 0 但方案未出清 (盘前清仓无现价) 不判 DONE; 出清才完结")
def _():
from app.services import command_service as csvc
fake = install_fakes(
prices={}, # 盘前: 一只现价都取不到
params={"PMS_TOTAL_SCALE": "2000000"},
positions=[{"ts_code": "600000.SH", "total_qty": 6000, "avail_qty": 6000,
"avg_cost": 10.0},
{"ts_code": "000001.SZ", "total_qty": 3000, "avail_qty": 3000,
"avg_cost": 8.0}])
r = csvc.issue("LIQUIDATE_ALL", {"confirm": "YES"})
assert r["ok"] and r["status"] == "EXECUTING", r
cid = r["command_id"]
assert fake.commands[cid]["progress"]["target_amount"] == 0.0 # 全部票取不到价
exit_p = [p for p in fake.list_plans(command_id=cid) if p["action"] == "EXIT"]
assert len(exit_p) == 2 and all(float(p["amount"]) == 0.0 for p in exit_p), exit_p
assert all("取不到现价" in (p.get("reason") or "") for p in exit_p), exit_p
# 原来 settle 见 target<=0 直接 DONE —— 一股没卖、方案永不物化、页面显示"已完成"。
r2 = csvc.refresh_progress(cid)
assert r2["commands"][0]["status"] == "EXECUTING", r2
assert fake.commands[cid]["status"] == "EXECUTING", fake.commands[cid]
for p in exit_p:
fake.update_plan(p["plan_id"], status="DONE", filled_qty=p["qty"])
csvc.refresh_progress(cid)
assert fake.commands[cid]["status"] == "DONE", fake.commands[cid]
@case("命令进度·GATED 批金额不进完成分母 (BASE 出清即 DONE, 不再拖满窗口判 PARTIAL)")
def _():
from app.core import command_spec as cspec
from app.services import command_service as csvc
fake = install_fakes(params={"PMS_TOTAL_SCALE": "2000000"})
fake.insert_command(command_id="CMD_G1", cmd_class=cspec.CLS_TASK,
cmd_type="INCREASE_EXPOSURE", ts_code=None, params={},
status="EXECUTING",
progress={"target_amount": 100000.0, "deadline": "2099-01-01"})
fake.insert_plans([
{"plan_id": "PL_G1", "command_id": "CMD_G1", "ts_code": "600000.SH",
"action": "OPEN", "qty": 6000, "amount": 60000.0, "status": "DONE",
"filled_qty": 6000, "priority": 1, "deadline": "2099-01-01"},
{"plan_id": "PL_G2", "command_id": "CMD_G1", "ts_code": "600000.SH",
"action": "FILL", "qty": 4000, "amount": 40000.0, "status": "GATED",
"filled_qty": 0, "priority": 2, "deadline": "2099-01-01"},
])
csvc.refresh_progress("CMD_G1")
c = fake.commands["CMD_G1"]
# GATED 批没有解锁机制, 算进分母的话每条建仓命令必然拖满窗口被判 PARTIAL
assert c["status"] == "DONE", c
assert c["progress"]["done_amount"] == 60000.0, c["progress"]
assert c["progress"]["gated_amount"] == 40000.0, c["progress"]
@case("信号消化·挂着策略的票: 高置信风控卖出不清仓, 落等拍板提议 + 暂停策略买入")
def _():
from app.services import signal_service as ss, strategy_service
fake = install_fakes(prices={"600000.SH": 10.0},
params={"PMS_TOTAL_SCALE": "2000000"},
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
"avail_qty": 6000, "avg_cost": 10.0}])
fake.insert_strategy(strategy_id="STR_1", ts_code="600000.SH", stype="GRID",
params={"lower": 9.0, "upper": 11.0})
by = _install_signal_fakes(fake, sell_msgs=[_sell_msg("9-1", "600000.SH", 92)])
r = ss.consume()
assert r["ok"], r
# 强制离场会推翻你特意设的策略 —— 只提示、不自动清仓
assert not r["exits"], r
assert not any(i["action"] == "EXIT" for i in fake.instructions.values()), \
fake.instructions
assert r["proposals"] and r["proposals"][0]["on_strategy"] is True, r
assert r.get("strategy_buy_paused") == ["STR_1"], r
prop = list(fake.proposals.values())[0]
assert prop["action"] == "EXIT" and prop["qty"] == 6000, prop # 采纳 = 撤策略并**全**清
assert prop["status"] == "WAIT_USER" and prop["judge_verdict"] == "STRATEGY_RISK", prop
assert "策略" in prop["hard_numbers"]["reason"], prop
assert len(by[3].acked) == 1, by[3].acked
# 买入暂停表按来源落了 risk_sell 这一条 (卖出/平回不受影响, 页面可恢复)
m = strategy_service.buypause_map()
assert "600000.SH" in m, m
assert "risk_sell" in (m["600000.SH"].get("sources") or {}), m["600000.SH"]
@case("信号消化·试算不吞消息: 试算后正式消费, 同一条风控卖出照常转指令")
def _():
from app.services import signal_service as ss
fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
"avail_qty": 6000, "avg_cost": 10.0}])
by = _install_signal_fakes(fake, sell_msgs=[_sell_msg("7-1", "600000.SH", 95)])
key = "bionic:signals:llm_sell_actions"
r = ss.consume(dry_run=True)
assert r["exits"] and r["exits"][0].get("dry_run") is True, r
# 原 bug: 试算走消费组读了不 ACK → 消息进待确认清单**永不再投递**, 点一次试算就把
# 未消化的风控卖出永久吞掉。现在试算走只读 XREVRANGE, 不留任何投递痕迹。
assert not fake.instructions and by[3].acked == [] and not by[3].delivered, \
(fake.instructions, by[3].acked, by[3].delivered)
assert by[3].msgs.get(key), "试算后消息必须原封不动留在流里"
r2 = ss.consume() # 正式消费: 同一条消息真正入账
assert r2["exits"] and r2["exits"][0]["ts_code"] == "600000.SH", r2
ins = [i for i in fake.instructions.values() if i["action"] == "EXIT"]
assert ins and ins[0]["qty"] == 6000, ins
assert by[3].acked == ["7-1"], by[3].acked
@case("信号消化·上一跳读了没确认的消息 (中途崩溃), 下一跳自动捞回重消化")
def _():
from app.services import signal_service as ss
fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
"avail_qty": 6000, "avg_cost": 10.0}])
by = _install_signal_fakes(fake, sell_msgs=[_sell_msg("8-1", "600000.SH", 95)])
key = "bionic:signals:llm_sell_actions"
r3 = by[3]
# 模拟上一跳: 消息按 ">" 被读走 (进本消费者的待确认清单) 后处理中途崩溃, 没 ACK
got = r3.xreadgroup("g", "c", {key: ">"}, count=10)
assert got and not r3.msgs.get(key) and r3.delivered.get(key), (got, r3.delivered)
# 本跳 consume: _read 先按 "0" 回捞自己名下待确认的 → 正常消化 + ACK。
# "读了不确认 = 下次还在" 是对消费组的误解 —— 不回捞的话这条消息永远不会再投递。
r = ss.consume()
assert r["exits"] and r["exits"][0]["ts_code"] == "600000.SH", r
assert r3.acked == ["8-1"] and not r3.delivered.get(key), (r3.acked, r3.delivered)
ins = [i for i in fake.instructions.values() if i["action"] == "EXIT"]
assert ins and ins[0]["progress"]["from_signal"] is True, ins
@case("自主提议·当日涨幅超上限的加仓被 NO_CHASE_DAYUP 拦在整条链路上; 减持不受累")
def _():
from app.services import market, proposal_service as ps
fake = _prop_fakes(params={"PMS_AUTONOMY": "full"})
orig = market.day_snapshot
try:
# 造当日大涨 10% (> 默认上限 5%)。batch10 [J3] 钉的是 _market_ctx 取真快照,
# 这里钉整条行为链: scan_and_route → _route_one → 规则闸真的拿到涨幅并拦下。
market.day_snapshot = lambda c: {"price": 11.0, "vwap": 10.8, "open": 10.0,
"high": 11.1, "low": 10.0,
"day_chg_from_open": 0.10, "bars": 60}
r = ps.scan_and_route()
finally:
market.day_snapshot = orig
rej = {(x["ts_code"], x["action"], x["by"]) for x in r["rejected"]}
assert ("600000.SH", "ADD", "rule") in rej, r
assert any("NO_CHASE_DAYUP" in f for x in r["rejected"] for f in x["failed"]), \
r["rejected"]
assert not any(i["action"] == "ADD" for i in fake.instructions.values())
led = [x for x in fake.ledger
if x["verdict"] == "REJECT" and x["ts_code"] == "600000.SH"]
assert led and any("NO_CHASE_DAYUP" in f for f in led[0]["failed_checks"]), led
# 减持是离场保护, 不追高闸只拦买入侧
assert ("000001.SZ", "TRIM") in {(x["ts_code"], x["action"]) for x in r["executed"]}, r
@case("清仓覆盖冲突·一键清仓当场撤该票 ACTIVE 策略 (不等持仓归零) 并留痕")
def _():
from app.services import command_service as csvc
fake = install_fakes(prices={"600000.SH": 10.0},
params={"PMS_TOTAL_SCALE": "2000000"},
positions=[{"ts_code": "600000.SH", "total_qty": 6000,
"avail_qty": 6000, "avg_cost": 10.0}])
fake.insert_strategy(strategy_id="STR_LQ", ts_code="600000.SH", stype="GRID")
fake.insert_proposal(proposal_id="PRP_LQ", ts_code="600000.SH", action="ADD",
qty=1000, hard_numbers={}, expire_at=None)
r = csvc.issue("LIQUIDATE_ALL", {"confirm": "YES"})
assert r["ok"], r
# 2026-08-19 实机: 清仓命令在追一个自己还在被网格买进的持仓。命令下达即撤, 不等归零。
assert fake.strategies["STR_LQ"]["status"] == "CANCELLED", fake.strategies
assert fake.proposals["PRP_LQ"]["status"] == "DECLINED", fake.proposals
prog = fake.commands[r["command_id"]]["progress"]
stopped = prog["stopped_buyside"]
assert stopped["strategies"] == ["STR_LQ"], stopped
assert any("清仓覆盖冲突" in n for n in prog["notes"]), prog["notes"]
assert any(x["action"] == "CLEANUP" and x["ref_id"] == r["command_id"]
for x in fake.ledger), "撤策略必须在评审账本留痕"
def main(): def main():
# 静音日志: 本套里有好几条用例**故意**触发异常与告警来验证「守成」行为 # 静音日志: 本套里有好几条用例**故意**触发异常与告警来验证「守成」行为
# (调度守卫吞异常、外部成交告警、连续对账升级 ERROR、窗口耗尽告警), # (调度守卫吞异常、外部成交告警、连续对账升级 ERROR、窗口耗尽告警),

View File

@ -94,7 +94,8 @@ def head(now):
flag += " ⛔ 全局暂停执行" flag += " ⛔ 全局暂停执行"
elif halt_b: elif halt_b:
flag += " ⛔ 全局暂停买入" flag += " ⛔ 全局暂停买入"
if brake: from datetime import datetime as _dt
if brake and int(_dt.now().strftime("%Y%m%d")) < brake: # 到期的刹车不再常驻标题 (2026-08-28)
flag += f" ⛔ 刹车至 {brake}" flag += f" ⛔ 刹车至 {brake}"
print(f" PMS 联测监视 · {now} · 下发 {mode} · 自主 {auto}{flag}") print(f" PMS 联测监视 · {now} · 下发 {mode} · 自主 {auto}{flag}")
_rule() _rule()
@ -147,18 +148,35 @@ def account():
print(f" ⚠ 取不到现价: {', '.join(v['price_missing'][:6])}") print(f" ⚠ 取不到现价: {', '.join(v['price_missing'][:6])}")
def _left_today(ins: dict, used: int): def _left_today(ins: dict, kids: list, ymd: int):
"""本日剩余额度 = 当日配额 今日已投放。用的是执行器那两个纯函数, 口径一致。""" """今日 (投, 余, 废) 三个数, **与执行器逐字同口径** (2026-08-28 修):
紧急单与窗口末日单的已投放按在途量算 (_inflight_today), 普通多日单按
已成交+在途算 (_consumed_today) 原来一律按后者, 紧急清仓当天有成交就显示
0, 而执行器实际还会持续补单, 同一句话两种真相"""
from app.core import exec_timing as et, tradedays as td from app.core import exec_timing as et, tradedays as td
from app.core.sizer import lot_of
from app.services import executor
try: try:
remaining = max(0, int(ins.get("qty") or 0) - int(ins.get("exec_qty") or 0)) remaining = max(0, int(ins.get("qty") or 0) - int(ins.get("exec_qty") or 0))
dl = (ins.get("progress") or {}).get("deadline") prog = ins.get("progress") or {}
dl = prog.get("deadline")
left_days = td.trade_days_left(dl, None) if dl else 1 left_days = td.trade_days_left(dl, None) if dl else 1
quota = et.daily_quota(remaining, left_days, is_urgent = bool(prog.get("urgent"))
quota = et.daily_quota(remaining, left_days, lot=lot_of(ins.get("ts_code")),
allow_odd_tail=(ins.get("action") == "EXIT")) allow_odd_tail=(ins.get("action") == "EXIT"))
return max(0, quota - int(used)) try:
used = (executor._inflight_today(kids, ymd) if (is_urgent or left_days <= 1)
else executor._consumed_today(kids, ymd))
except Exception:
used = sum(int(c.get("qty") or 0) for c in kids)
try:
consumed = executor._consumed_today(kids, ymd)
except Exception:
consumed = used
void = sum(int(c.get("qty") or 0) for c in kids) - consumed
return used, max(0, quota - int(used)), void
except Exception: except Exception:
return "?" return "?", "?", "?"
def instructions(): def instructions():
@ -188,16 +206,8 @@ def instructions():
why = _short(d.get("reason") or "(本轮还没轮到它)", 24) why = _short(d.get("reason") or "(本轮还没轮到它)", 24)
at = d.get("at") or "" at = d.get("at") or ""
kids = [c for c in (prog.get("children") or []) if int(c.get("ymd") or 0) == ymd] kids = [c for c in (prog.get("children") or []) if int(c.get("ymd") or 0) == ymd]
try: # 「余」必须**现算**且三个数同源 (2026-08-03 教训), 口径委托给 _left_today
used = executor._consumed_today(kids, ymd) used, left, void = _left_today(r, kids, ymd)
except Exception:
used = sum(int(c.get("qty") or 0) for c in kids)
void = sum(int(c.get("qty") or 0) for c in kids) - used
# 「余」必须**现算**, 不能读 last_decision 里的 qty_hint —— 那是执行器上一跳
# 存下来的快照, 而投/废是此刻现算的。两者取自不同时刻, 会拼出自相矛盾的一行:
# 2026-08-03 实机 002518.SZ 显示 `0/0/200` —— 废了 200 却还说余 0, 因为执行器
# 那一跳时第二张分片还挂着(算已投), 等 watch 来看时它刚到期。三个数必须同源。
left = _left_today(r, used)
q = f"{used}/{left}/{void}" q = f"{used}/{left}/{void}"
print(" " + _pad(r["ts_code"], 11) print(" " + _pad(r["ts_code"], 11)
+ _pad("" if r["side"] == "buy" else "", 5) + _pad("" if r["side"] == "buy" else "", 5)
@ -266,7 +276,14 @@ def crosscheck():
return return
try: try:
orders = qmt_repo.list_orders(limit=200) orders = qmt_repo.list_orders(limit=200)
ins = {r["instruction_id"]: r for r in pms_repo.list_instructions(limit=200)} # 父指令全集要**含已归档**且窗口放大 (2026-08-28 修): 用户把已完成指令「移除」
# (软归档) 后, 其委托仍在近 200 张出口行里 —— 原来按未归档近 200 条查, 正常
# 归档就触发「本端找不到父指令」的重号误报, 真告警反被当噪音。
try:
ins = {r["instruction_id"]: r
for r in pms_repo.list_instructions(limit=1000, include_archived=True)}
except TypeError:
ins = {r["instruction_id"]: r for r in pms_repo.list_instructions(limit=1000)}
except Exception: except Exception:
return return
bad = [] bad = []

View File

@ -71,16 +71,19 @@ def cmd_status(args):
+ (f" · 挂起待人工 {ch['orphan_held']}" if ch.get("orphan_held") else "")) + (f" · 挂起待人工 {ch['orphan_held']}" if ch.get("orphan_held") else ""))
print(f" 出口队列 {ch['queue'] or ''}") print(f" 出口队列 {ch['queue'] or ''}")
# 快照是对账的事实源 (协议 §6.2), 拿不到就意味着账本建不起来 —— 必须一眼能看见 # 快照是对账的事实源 (协议 §6.2), 拿不到就意味着账本建不起来 —— 必须一眼能看见
snap_err = False
try: try:
snap = qmt_repo.latest_snapshot("positions") snap = qmt_repo.latest_snapshot("positions")
except Exception as e: except Exception as e:
snap = None snap, snap_err = None, True
print(f" 持仓快照 读取失败: {type(e).__name__}: {e}") print(f" 持仓快照 读取失败: {type(e).__name__}: {e} —— 先修本端库/表, "
f"别按「对端没发」去排查")
if snap: if snap:
items = (snap["payload"] or {}).get("items") items = (snap["payload"] or {}).get("items")
print(f" 持仓快照 {len(items) if isinstance(items, list) else '?'} 个条目 · " print(f" 持仓快照 {len(items) if isinstance(items, list) else '?'} 个条目 · "
f"{snap['age_sec']:.0f}s 前 (seq={snap['seq']})") f"{snap['age_sec']:.0f}s 前 (seq={snap['seq']})")
elif snap is None: elif snap is None and not snap_err:
# 读取失败时不打这句 (2026-08-28 修): "读不到"与"对端从未发过"排查方向完全不同
print(f" 持仓快照 **从未收到** (对账没有事实源, 账本建不起来)") print(f" 持仓快照 **从未收到** (对账没有事实源, 账本建不起来)")
s = st.get("stat") or {} s = st.get("stat") or {}
if s: if s: