第三批: 规则闸/择时执行器实现B/三模式下发通道/执行编排

This commit is contained in:
zlt 2026-07-28 09:10:07 +08:00
parent 9c2470df21
commit 4a83209dac
16 changed files with 1418 additions and 29 deletions

View File

@ -25,6 +25,8 @@ app/
command_spec.py 命令目录(A/B/C 全量 27 类) / 参数校验 / 双状态机 / 冲突识别 command_spec.py 命令目录(A/B/C 全量 27 类) / 参数校验 / 双状态机 / 冲突识别
planner.py 方案生成器: 降仓凑额四档 / 升仓 / 建仓 / 清仓 / 行业 / 撤单 planner.py 方案生成器: 降仓凑额四档 / 升仓 / 建仓 / 清仓 / 行业 / 撤单
recon.py 成交认领与入账映射 / 对账差异与修正 / 除权检测 / T+1 可用量 recon.py 成交认领与入账映射 / 对账差异与修正 / 除权检测 / T+1 可用量
rule_gate.py 规则闸终检: 上限/一手/可卖/冻结/刹车/行业/不追高 (减持只放行不阻拦)
exec_timing.py 择时实现B: 分日配额 / 分笔 / 买卖出手判定 / 14:45 兜底 / 窗口收口
tradedays.py 交易日历: 调度守卫与执行窗口计算 tradedays.py 交易日历: 调度守卫与执行窗口计算
db/session.py 三库连接 + **严格单表访问守卫** (JOIN/逗号连表/跨表子查询一律拒绝) db/session.py 三库连接 + **严格单表访问守卫** (JOIN/逗号连表/跨表子查询一律拒绝)
repo/ 单表数据访问: pms_repo (自有 10 表) / downstream_repo (下游只读三表) repo/ 单表数据访问: pms_repo (自有 10 表) / downstream_repo (下游只读三表)
@ -32,6 +34,8 @@ app/
param_store.py 运行参数中心 (表值优先于 settings 初值, 页面调参即时生效) param_store.py 运行参数中心 (表值优先于 settings 初值, 页面调参即时生效)
portfolio.py 组合快照 (账本+行情+行业 → 方案/规则闸/页面的统一输入) portfolio.py 组合快照 (账本+行情+行业 → 方案/规则闸/页面的统一输入)
command_service.py 命令下达→校验→冲突→生效/规划→进度推进→撤销 command_service.py 命令下达→校验→冲突→生效/规划→进度推进→撤销
executor.py 方案→指令→分日出手→窗口收口 (规则闸与择时的编排落点)
dispatcher.py 下发通道三适配器: shadow(默认) / plan_x / channel_y
ledger_service.py 成交回放 / 对账 / 除权 / 盘前 / 日终结算 / 运营日报 ledger_service.py 成交回放 / 对账 / 除权 / 盘前 / 日终结算 / 运营日报
market.py 行情 (Redis db13) 与参考位 (决策系统主口径 + 兜底自算) market.py 行情 (Redis db13) 与参考位 (决策系统主口径 + 兜底自算)
industry.py 行业划分可插拔适配器 (custom_table / gp_stock_category / 停用) industry.py 行业划分可插拔适配器 (custom_table / gp_stock_category / 停用)
@ -41,7 +45,8 @@ scripts/
run_tests.py 一次跑完全部单测 (见下方「Docker 部署」) run_tests.py 一次跑完全部单测 (见下方「Docker 部署」)
test_core_units.py 仓位与安全垫核心逻辑 14 例 test_core_units.py 仓位与安全垫核心逻辑 14 例
test_batch2_units.py 命令 / 方案 / 回放对账 纯逻辑 35 例 test_batch2_units.py 命令 / 方案 / 回放对账 纯逻辑 35 例
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 18 例 test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 18 例
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 25 例
init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练) init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练)
check_db.py 实机连通性与表结构自检 (需真实 .env) check_db.py 实机连通性与表结构自检 (需真实 .env)
``` ```
@ -91,7 +96,7 @@ git pull && docker compose build && docker compose up -d
| 盘前准备 | 交易日 08:50 | T+1 可卖重置 / 参考位取数 / 刹车结算 | ✅ | | 盘前准备 | 交易日 08:50 | T+1 可卖重置 / 参考位取数 / 刹车结算 | ✅ |
| 命令轮询 | 每 1 分钟(全天) | 新命令解析 → 方案生成 → 状态机推进 | ✅ | | 命令轮询 | 每 1 分钟(全天) | 新命令解析 → 方案生成 → 状态机推进 | ✅ |
| 成交回放 | 交易时段每 5 分钟 | `trading_order` 增量回放 + 盘中轻对账 | ✅ | | 成交回放 | 交易时段每 5 分钟 | `trading_order` 增量回放 + 盘中轻对账 | ✅ |
| 盘中执行 | 交易时段每 1 分钟 | 择时出手 + 自主提议扫描 | 🔜 下一批(当前只做在途指令超时收口 | | 盘中执行 | 交易时段每 1 分钟 | 方案转指令 → 择时出手(规则闸终检 → 下发 → 记子单) | ✅(自主提议扫描待下一批 |
| 信号消化 | 交易时段每 1 分钟 | 订阅决策系统盘中信号 | 🔜 下一批 | | 信号消化 | 交易时段每 1 分钟 | 订阅决策系统盘中信号 | 🔜 下一批 |
| T 仓平回 | 14:50 | 做T强制平回 | 🔜 二期(现只自证 T 仓为 0 | | T 仓平回 | 14:50 | 做T强制平回 | 🔜 二期(现只自证 T 仓为 0 |
| 日终结算 | 15:10 | 除权检测 / 全量对账 / 安全垫 / 命令进度日结 | ✅ | | 日终结算 | 15:10 | 除权检测 / 全量对账 / 安全垫 / 命令进度日结 | ✅ |
@ -99,11 +104,21 @@ git pull && docker compose build && docker compose up -d
调度器三条守卫:交易日守卫、故障即守成(任务内异常吞掉记 ERROR绝不因调度异常产生新指令、全局暂停执行休假模式下除对账与日报外全部跳过 调度器三条守卫:交易日守卫、故障即守成(任务内异常吞掉记 ERROR绝不因调度异常产生新指令、全局暂停执行休假模式下除对账与日报外全部跳过
## 指令下发通道(设计 §9参数 `PMS_DISPATCH_MODE`,默认 `shadow`
| 模式 | 行为 | 什么时候用 |
|---|---|---|
| `shadow`(默认) | 指令照常过规则闸、照常置 DISPATCHED但**不写下游**。你在 QMT 侧人工执行,成交由回放按 FIFO 认领回账本 | 通道协商完成前的一期口径(设计 §9命令类降仓/清仓由用户人工执行、PMS 记账跟踪) |
| `plan_x` | 买入写 `trading_buy_plan``is_active=6` 待挂单、署名 `approved_by='pms'`**卖出无对应通道,自动退回影子** | QMT 侧确认沿用旧通道过渡时 |
| `channel_y` | 写统一指令表 `pms_order_request`DDL 见需求清单 B1 | B1 协商落地、表建好之后 |
影子模式下的完整闭环:页面下命令 → 方案落表 → 方案转指令 → 择时按日配额给出「今天该出多少、什么价」→ 你照着在 QMT 下单 → 5 分钟一次的回放把成交认领回批次账本 → 命令进度自动推进。整条链路除了「人手下单」这一步,其余与实盘接管后完全一致。
## 已实现 / 待开发 ## 已实现 / 待开发
**已实现**:建表 DDL配置与运行参数中心仓位规划器与安全垫账命令系统27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);管理页面四块 + 运维/日报抽屉;调度器骨架;单测 67 例。 **已实现**:建表 DDL 与建表脚本配置与运行参数中心仓位规划器与安全垫账命令系统27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);**规则闸终检****择时执行器实现 B**分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口);**三模式下发通道**管理页面四块 + 运维/日报抽屉;调度器八个调度位;单测 92 例。
**待开发(下一批)**:择时执行器(实现 B 内置保守择时 + 分日配额 + 子指令、规则闸终检与指令下发通道、动作引擎FILL/ADD/DCA/TRIM 自主提议扫描、决策系统信号订阅与研判闸对接、T0 做T二期 **待开发(下一批)**动作引擎FILL/ADD/DCA/TRIM 自主提议扫描 + 提议入队)、决策系统信号订阅(风控 SELL / 止盈 / 反转 → 卖出方案)、研判闸对接与择时实现 A、T0 做T二期
**待外部协商**`QMT_INTERFACE_REQUIREMENTS.md` 的 A/B/C/D 各项——尤其 A1`trading_position` 完整 DDL 与可用数量列、A2`trading_order` 状态枚举与**来源标识**、B1统一指令通道。在来源标识到位前回放按「同股同向 + 下发早于成交 + FIFO」贪心认领认领不上即判外部成交并告警持仓数量列用候选名探测探测结果可经页面「运维 → 导出下游表结构」查看,也是回填 D1 的现成材料。 **待外部协商**`QMT_INTERFACE_REQUIREMENTS.md` 的 A/B/C/D 各项——尤其 A1`trading_position` 完整 DDL 与可用数量列、A2`trading_order` 状态枚举与**来源标识**、B1统一指令通道。在来源标识到位前回放按「同股同向 + 下发早于成交 + FIFO」贪心认领认领不上即判外部成交并告警持仓数量列用候选名探测探测结果可经页面「运维 → 导出下游表结构」查看,也是回填 D1 的现成材料。

177
app/core/exec_timing.py Normal file
View File

@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
"""
择时执行器 · 实现B内置保守择时(纯逻辑, 无外部依赖, 可单测)
================================================================
设计 POSITION_MGMT_DESIGN.md §8实现A (委托决策系统盘中择时研判) 走同一个
`decide()` 接口, 研判接通后在 services 层换实现即可, 本模块是兜底也是一期主力
规则原文与落点:
每日配额 = 剩余量 ÷ 剩余窗口天数, 向上取整到一手 daily_quota()
卖出: 避开开盘 30 分钟; 现价 当日均价(VWAP) 时分笔卖 decide() side=sell
14:45 未完成 现价 × 0.998 限价兜底
买入: 现价 当日均价或进入回踩带时买; 当日涨幅 > 5% 停止买入 (不追高)
窗口末日 14:45 强制限价完成或按命令属性作废
停牌/一字板当日跳过顺延; 窗口耗尽未完成 命令置部分完成并告警
时点一律用分钟数比较 (hm_to_min), 免去跨时区与字符串比较的坑
"""
from __future__ import annotations
from app.core.sizer import LOT
OPEN_MIN = 9 * 60 + 30 # 09:30 开盘
CLOSE_MIN = 15 * 60 # 15:00 收盘
LUNCH_START = 11 * 60 + 30
LUNCH_END = 13 * 60
# decide() 的动作词
ACT_FIRE = "FIRE" # 出手
ACT_WAIT = "WAIT" # 条件未到, 本轮不动
ACT_SKIP = "SKIP" # 当日跳过 (停牌/一字板), 顺延
ACT_STOP = "STOP" # 本日不再出手 (如买入触发不追高)
def hm_to_min(hm) -> int:
""""14:45" / datetime / (h, m) → 当日分钟数。"""
if hm is None:
return 0
if isinstance(hm, (tuple, list)):
return int(hm[0]) * 60 + int(hm[1])
if hasattr(hm, "hour"):
return hm.hour * 60 + hm.minute
s = str(hm).strip()
if ":" in s:
h, m = s.split(":")[:2]
return int(h) * 60 + int(m)
return int(s)
def in_session(now_min: int) -> bool:
return (OPEN_MIN <= now_min <= LUNCH_START) or (LUNCH_END <= now_min <= CLOSE_MIN)
def daily_quota(remaining_qty: int, tdays_left: int, *, total_qty: int = None,
lot: int = LOT, allow_odd_tail: bool = False) -> int:
"""当日配额 = 剩余量 ÷ 剩余交易日, 向上取整到一手。
* 最后一日 (tdays_left 1) 或剩余不足一手 全部剩余 (含零股尾巴)
* allow_odd_tail=True (整票清仓) 时不做整百取整, 零股一并出
"""
r = max(0, int(remaining_qty or 0))
if r <= 0:
return 0
d = max(1, int(tdays_left or 1))
if d <= 1 or r <= lot:
return r
raw = r / d
q = int(-(-raw // lot)) * lot # 向上取整到一手
q = min(q, r)
if not allow_odd_tail and q % lot and q != r:
q = (q // lot) * lot
# 若本次取整后剩下不足一手的尾巴, 并进本次一起出, 免得最后一天剩 30 股卡住
if 0 < r - q < lot:
q = r
return max(q, 0) if q > 0 else r
def slice_qty(quota: int, slices: int = 1, lot: int = LOT) -> list:
"""把当日配额切成 N 笔 (设计「分笔卖出配额」)。最后一笔兜底吃掉余数。"""
quota = max(0, int(quota or 0))
n = max(1, int(slices or 1))
if quota <= 0:
return []
if n == 1 or quota <= lot:
return [quota]
per = int(quota / n / lot) * lot
if per <= 0:
return [quota]
out = [per] * (n - 1)
out.append(quota - per * (n - 1))
return [q for q in out if q > 0]
def decide(*, side: str, now, day: dict, params: dict, is_last_day: bool,
fired_today: int = 0, quota: int = 0) -> dict:
"""单条指令在「此刻」该不该出手。
day: {price, vwap, halted, limit_up, limit_down, day_chg_from_open, support}
params: {sell_avoid_open_min, buy_halt_dayup, eod_force_time, eod_force_discount}
返回 {"action", "qty_hint", "limit_price", "reason", "forced"}
"""
now_min = hm_to_min(now)
price = float(day.get("price") or 0)
vwap = float(day.get("vwap") or 0)
eod_min = hm_to_min(params.get("eod_force_time") or "14:45")
disc = float(params.get("eod_force_discount") or 0.998)
left = max(0, int(quota) - int(fired_today))
def out(action, reason, limit=None, forced=False):
return {"action": action, "qty_hint": left, "limit_price": limit,
"reason": reason, "forced": forced}
if price <= 0:
return out(ACT_SKIP, "取不到现价, 当日跳过")
if day.get("halted"):
return out(ACT_SKIP, "停牌, 当日跳过顺延")
if left <= 0:
return out(ACT_WAIT, "当日配额已出完")
if not in_session(now_min):
return out(ACT_WAIT, "非交易时段")
if side == "sell":
if day.get("limit_down") and not is_last_day:
return out(ACT_SKIP, "跌停一字板, 当日跳过顺延")
avoid = int(params.get("sell_avoid_open_min") or 30)
if now_min < OPEN_MIN + avoid:
return out(ACT_WAIT, f"避开开盘 {avoid} 分钟 (至 {_fmt(OPEN_MIN + avoid)})")
if now_min >= eod_min:
return out(ACT_FIRE, f"{_fmt(eod_min)} 兜底: 限价 = 现价×{disc}",
limit=round(price * disc, 2), forced=True)
if vwap > 0 and price >= vwap:
return out(ACT_FIRE, f"现价 {price} ≥ 当日均价 {vwap}, 分笔卖出配额",
limit=round(price * disc, 2))
return out(ACT_WAIT, f"现价 {price} < 当日均价 {vwap or ''}, 等更好的价")
if side == "buy":
if day.get("limit_up"):
return out(ACT_SKIP, "涨停一字板, 当日跳过顺延")
dayup = day.get("day_chg_from_open")
cap = float(params.get("buy_halt_dayup") or 0.05)
if dayup is not None and float(dayup) > cap:
return out(ACT_STOP, f"当日涨幅 {float(dayup):.2%} > {cap:.0%}, 停止买入 (不追高)")
premium = round(2 - disc, 4) # 买入兜底与卖出对称: 0.998 → 1.002
if now_min >= eod_min:
if is_last_day:
return out(ACT_FIRE, f"窗口末日 {_fmt(eod_min)} 强制完成: 限价 = 现价×{premium}",
limit=round(price * premium, 2), forced=True)
return out(ACT_WAIT, f"{_fmt(eod_min)} 后不新开买单, 顺延次日")
support = float(day.get("support") or 0)
if vwap > 0 and price <= vwap:
return out(ACT_FIRE, f"现价 {price} ≤ 当日均价 {vwap}, 买入配额",
limit=round(price * premium, 2))
if support > 0 and price <= support * 1.01:
return out(ACT_FIRE, f"现价 {price} 进入回踩带 (支撑 {support}), 买入配额",
limit=round(price * premium, 2))
return out(ACT_WAIT, f"现价 {price} > 当日均价 {vwap or ''} 且未回踩, 等回调")
return out(ACT_SKIP, f"方向 {side!r} 非法")
def _fmt(m: int) -> str:
return f"{m // 60:02d}:{m % 60:02d}"
def window_verdict(*, remaining_qty: int, tdays_left: int, is_command: bool) -> dict:
"""窗口耗尽时的收口 (设计 §8 末句)。
还有剩余且窗口已尽 命令置部分完成并告警; 命令类保留人工兜底提示,
自主类直接作废
"""
if int(remaining_qty or 0) <= 0:
return {"verdict": "DONE", "note": "已足额完成"}
if int(tdays_left or 0) > 0:
return {"verdict": "RUNNING", "note": f"窗口内剩余 {tdays_left} 交易日"}
return {"verdict": "PARTIAL" if is_command else "EXPIRED",
"note": f"窗口耗尽仍剩 {remaining_qty} 股 —— "
+ ("命令置部分完成并告警, 请在页面决定顺延或人工完成"
if is_command else "自主指令作废")}

148
app/core/rule_gate.py Normal file
View File

@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
"""
规则闸 · 一级关口 (纯逻辑, 无外部依赖, 可单测)
================================================
设计 POSITION_MGMT_DESIGN.md §7:
全指令必过, PMS 纯代码下发前用最新数据终检 命令参数上限一手检查
T+1 可卖冻结状态刹车状态行业集中度(买入)不追高
任一不过或必要输入缺失 拒绝 (宁可不动), 未通过项落账本
两条容易搞反的口径, 在此钉死:
1. **冻结与刹车只挡增持, 不挡减持** 卖出止损任何时候都放行 (设计 §3.1/§5)
2. **命令驱动不受刹车限制** 刹车是给自主提议踩的, 用户命令优先, 只提示不拦
(设计 §5命令类不受限, 执行前提示)上限/一手/可卖量这类硬约束则一视同仁
输出结构固定为 {"passed", "failed", "warnings", "hard_numbers"}, 直接喂 pms_action_ledger
failed_checks_json / hard_numbers_json 拒了的后来涨了多少靠这份留痕做判分
"""
from __future__ import annotations
from app.core.sizer import LOT
from app.core.planner import check_all_caps
BUY, SELL = "buy", "sell"
def _num(v, default=0.0):
try:
return float(v)
except (TypeError, ValueError):
return default
def check(*, side: str, action: str, qty: int, price: float, ctx: dict) -> dict:
"""指令下发前终检。
ctx 由调用方备齐 (services/executor.py):
position {total_qty, avail_qty, frozen_reason} 该股账本快照
caps check_all_caps 所需组合上下文 (仅买入用)
day {price, vwap, ma5, day_chg_from_open, halted, limit_up, limit_down}
params {no_chase_ma5, buy_halt_dayup, sector_source_ready}
flags {buy_halt, exec_halt, brake_active, blacklisted, is_command}
"""
pos = ctx.get("position") or {}
day = ctx.get("day") or {}
prm = ctx.get("params") or {}
flg = ctx.get("flags") or {}
is_cmd = bool(flg.get("is_command"))
side = str(side or "").lower()
failed, warns = [], []
qty = int(qty or 0)
price = _num(price)
total_qty = int(pos.get("total_qty") or 0)
avail_qty = int(pos.get("avail_qty") or 0)
hard = {"side": side, "action": action, "qty": qty, "price": price,
"total_qty": total_qty, "avail_qty": avail_qty,
"frozen_reason": pos.get("frozen_reason") or "NONE",
"day_chg_from_open": day.get("day_chg_from_open"),
"vwap": day.get("vwap"), "ma5": day.get("ma5"), "is_command": is_cmd}
# ---- 通用: 必要输入缺失一律拒绝 (宁可不动) ----
if qty <= 0:
failed.append(f"QTY_INVALID: 数量 {qty} 非法")
if price <= 0:
failed.append("PRICE_MISSING: 取不到现价, 无法定限价与校验 (宁可不动)")
if day.get("halted"):
failed.append("HALTED: 停牌, 当日跳过顺延")
if flg.get("exec_halt"):
failed.append("EXEC_HALT: 全局暂停执行 (休假模式) 生效中")
if side == SELL:
_check_sell(failed, warns, qty, total_qty, avail_qty, day)
elif side == BUY:
_check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard)
else:
failed.append(f"SIDE_INVALID: 方向 {side!r} 非法")
return {"passed": not failed, "failed": failed, "warnings": warns, "hard_numbers": hard}
def _check_sell(failed, warns, qty, total_qty, avail_qty, day):
"""减持方向: 冻结/刹车/上限一概不拦, 只看「卖得出去吗」。"""
if qty > total_qty:
failed.append(f"OVER_SELL: 卖出 {qty} > 持仓 {total_qty}")
elif qty > avail_qty:
failed.append(f"T1_UNAVAILABLE: 卖出 {qty} > T+1 可卖 {avail_qty} (当日买入次日才可卖)")
# 清仓允许卖零股 (A股规则); 部分减持必须整百
if qty % LOT != 0 and qty != total_qty:
failed.append(f"LOT_INVALID: 部分减持 {qty} 股非整百 (零股只能在清仓时一次性卖出)")
if day.get("limit_down"):
warns.append("LIMIT_DOWN: 跌停封板, 大概率成交不了, 已按限价挂出")
def _check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard):
"""增持方向: 全部硬约束都要过。"""
if qty % LOT != 0:
failed.append(f"LOT_INVALID: 买入 {qty} 股非整百")
if (pos.get("frozen_reason") or "NONE") != "NONE":
failed.append(f"FROZEN: 该股处于 {pos['frozen_reason']}, 禁止增持")
if flg.get("buy_halt"):
failed.append("BUY_HALT: 全局暂停买入生效中")
if flg.get("blacklisted"):
failed.append("BLACKLIST: 该股在黑名单 (永不买入)")
if flg.get("brake_active"):
if is_cmd:
warns.append("BRAKE_ACTIVE: 组合刹车中 —— 命令驱动不受限, 仅提示")
else:
failed.append("BRAKE_ACTIVE: 组合刹车中, 自主增持暂停")
if day.get("limit_up"):
failed.append("LIMIT_UP: 涨停封板, 不追买")
# 不追高: 当日涨幅 与 距 MA5 两道
dayup = day.get("day_chg_from_open")
cap_dayup = _num(prm.get("buy_halt_dayup"), 0.05)
if dayup is not None and _num(dayup) > cap_dayup:
failed.append(f"NO_CHASE_DAYUP: 当日涨幅 {_num(dayup):.2%} > 上限 {cap_dayup:.0%}")
ma5 = _num(day.get("ma5"))
cap_ma5 = _num(prm.get("no_chase_ma5"), 0.06)
if ma5 > 0:
gap = price / ma5 - 1
hard["ma5_gap"] = round(gap, 4)
if gap > cap_ma5:
failed.append(f"NO_CHASE_MA5: 距 MA5 {gap:.2%} > 上限 {cap_ma5:.0%}")
else:
warns.append("MA5_MISSING: 取不到 MA5, 距均线一项未校验")
caps = ctx.get("caps")
if caps:
bad = check_all_caps(ts_code=ctx.get("ts_code") or "-", add_amount=qty * price, ctx=caps)
failed.extend(bad)
if not caps.get("sector_source_ready", True):
warns.append("SECTOR_OFF: 行业数据源未配置, 行业集中度未校验 (页面已提示)")
else:
failed.append("CAPS_MISSING: 缺组合上下文, 无法校验上限 (宁可不动)")
def summarize(results: list) -> dict:
"""一批指令的闸门统计 (日报关注区用)。"""
passed = [r for r in results if r.get("passed")]
reasons = {}
for r in results:
for f in r.get("failed") or []:
k = f.split(":")[0]
reasons[k] = reasons.get(k, 0) + 1
return {"total": len(results), "passed": len(passed),
"rejected": len(results) - len(passed), "by_reason": reasons}

View File

@ -132,10 +132,14 @@ 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)
def intraday_exec(): def intraday_exec():
"""盘中执行: 择时出手 + 自主提议扫描。**执行器与动作引擎为下一批交付**, """盘中执行: 方案转指令 → 择时出手 (规则闸终检 → 下发 → 记子单)。
本轮只做在途指令的超时收口 (设计 §13: 不自动重发)"""
from app.services import ledger_service 自主提议扫描 (FILL/ADD/DCA/TRIM 动作引擎) 仍为下一批交付
return {"expired": ledger_service.expire_stale_instructions(), "executor": "pending_batch3"} """
from app.services import executor
r = {"materialized": executor.materialize_plans()}
r.update(executor.run_tick())
return r
@celery_app.task(name="pms.signal_digest") @celery_app.task(name="pms.signal_digest")
@ -160,8 +164,18 @@ def t0_close():
@celery_app.task(name="pms.daily_settle") @celery_app.task(name="pms.daily_settle")
@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 ledger_service from app.services import executor, ledger_service
return ledger_service.daily_settle() out = ledger_service.daily_settle()
try:
out["steps"]["windows"] = executor.sweep_windows() # 窗口耗尽收口 + 方案成交回写
except Exception as e:
out.setdefault("errors", []).append(f"窗口收口失败: {e}")
out["ok"] = False
try:
out["steps"]["expired_instructions"] = ledger_service.expire_stale_instructions()
except Exception as e:
out.setdefault("errors", []).append(f"指令过期处理失败: {e}")
return out
@celery_app.task(name="pms.daily_report") @celery_app.task(name="pms.daily_report")

View File

@ -218,7 +218,9 @@ def plan_command(cmd: dict) -> dict:
"ts_code": it["ts_code"], "action": it["action"], "qty": it.get("qty"), "ts_code": it["ts_code"], "action": it["action"], "qty": it.get("qty"),
"amount": it.get("amount"), "priority": it.get("priority", 100), "amount": it.get("amount"), "priority": it.get("priority", 100),
"deadline": deadline, "deadline": deadline,
"status": "PENDING" if not it.get("gated") else "PENDING", # GATED = 建仓的回踩补足/盈利加仓批, 不随命令立即执行,
# 等动作引擎按条件解锁后才转 PENDING (设计 §6)
"status": "GATED" if it.get("gated") else "PENDING",
"reason": it.get("reason")}) "reason": it.get("reason")})
try: try:
if rows: if rows:

116
app/services/dispatcher.py Normal file
View File

@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""
指令下发通道 (设计 §9 权限移交的落点)
======================================
三个适配器, 由参数 `PMS_DISPATCH_MODE` 切换, **默认 shadow**:
shadow 影子运行 只记账不下发指令照常过规则闸照常置 DISPATCHED,
等用户人工在 QMT 侧执行, 成交由回放按 FIFO 认领回来
这是设计 §9 的一期口径:通道未通前由用户人工执行PMS 记账跟踪
plan_x 过渡兼容 买入沿用 `trading_buy_plan` ( is_active=6 待挂单,
署名 approved_by='pms')**卖出无对应通道**, 自动退回 shadow
列清单按下游现表推断, QMT 侧确认前请勿在实盘开启
channel_y 推荐方案 写统一指令表 `pms_order_request` (DDL 见需求清单 B1)
该表归属与形态仍在协商 (B1.1/B1.7), 表未建时会明确报错而非静默吞掉
无论哪种模式, **指令先落 pms_instruction 再下发** (先记账后动作), 本模块只负责
往下游递一手, 不改指令状态 状态由 executor 统一推进
"""
from __future__ import annotations
import logging
from datetime import datetime
from app.db.session import execute
from app.services import param_store
logger = logging.getLogger("pms.dispatch")
MODE_SHADOW, MODE_PLAN_X, MODE_CHANNEL_Y = "shadow", "plan_x", "channel_y"
MODES = (MODE_SHADOW, MODE_PLAN_X, MODE_CHANNEL_Y)
def mode() -> str:
m = (param_store.get("PMS_DISPATCH_MODE", MODE_SHADOW) or MODE_SHADOW).strip()
return m if m in MODES else MODE_SHADOW
def describe() -> dict:
m = mode()
return {"mode": m, "shadow": m == MODE_SHADOW, "modes": list(MODES),
"hint": {
MODE_SHADOW: "影子运行: 指令只记账不下发, 请在 QMT 侧人工执行, "
"成交由回放自动认领回账本",
MODE_PLAN_X: "过渡通道: 买入写 trading_buy_plan(is_active=6), 卖出退回影子",
MODE_CHANNEL_Y: "统一通道: 写 pms_order_request, 由下游轮询执行",
}[m]}
def dispatch(*, instruction_id: str, ts_code: str, side: str, qty: int, limit_price=None,
valid_until=None, stock_name=None) -> dict:
"""递一手给下游。返回 {ok, ref, mode, note, error}; ok=False 时 executor 不改指令状态。"""
m = mode()
try:
if m == MODE_CHANNEL_Y:
return _channel_y(instruction_id, ts_code, side, qty, limit_price, valid_until)
if m == MODE_PLAN_X and str(side).lower() == "buy":
return _plan_x(instruction_id, ts_code, qty, limit_price, stock_name)
if m == MODE_PLAN_X:
r = _shadow(instruction_id, side)
r["note"] = "plan_x 无卖出通道, 本单退回影子运行 (待 QMT B1 落地)"
return r
return _shadow(instruction_id, side)
except Exception as e:
logger.exception("下发失败 %s", instruction_id)
return {"ok": False, "ref": None, "mode": m, "note": "",
"error": f"{type(e).__name__}: {e}"}
def _shadow(instruction_id: str, side: str) -> dict:
return {"ok": True, "ref": f"manual:{instruction_id}", "mode": MODE_SHADOW,
"note": f"影子运行: 请在 QMT 侧人工{'买入' if side == 'buy' else '卖出'}, "
f"成交由回放认领", "error": None}
def _channel_y(instruction_id, ts_code, side, qty, limit_price, valid_until) -> dict:
now = datetime.now()
execute(
"INSERT INTO pms_order_request (instruction_id, ts_code, side, qty, limit_price, "
"valid_until, source, status, cancel_flag, create_time, update_time) VALUES "
"(:iid, :code, :side, :qty, :lp, :vu, 'pms', 'NEW', 0, :ts, :ts)",
{"iid": instruction_id, "code": ts_code, "side": str(side).lower(), "qty": int(qty),
"lp": limit_price, "vu": valid_until or now, "ts": now})
return {"ok": True, "ref": instruction_id, "mode": MODE_CHANNEL_Y,
"note": "已写入 pms_order_request, 等下游轮询执行", "error": None}
def _plan_x(instruction_id, ts_code, qty, limit_price, stock_name) -> dict:
"""买入走上游既有计划表。amount 由 数量×限价 反算 (下游按 buy_amount 挂单)。"""
px = float(limit_price or 0)
if px <= 0:
return {"ok": False, "ref": None, "mode": MODE_PLAN_X, "note": "",
"error": "plan_x 通道要求限价 (下游按 target_price 挂单)"}
now = datetime.now()
execute(
"INSERT INTO trading_buy_plan (stock_code, stock_name, target_price, buy_amount, "
"is_active, trading_time, create_time, update_time, approved_by, change_reason) "
"VALUES (:code, :name, :px, :amt, 6, :ts, :ts, :ts, 'pms', :rsn)",
{"code": ts_code, "name": stock_name or ts_code, "px": px,
"amt": round(qty * px, 2), "ts": now,
"rsn": f"PMS 指令 {instruction_id}"})
return {"ok": True, "ref": f"buy_plan:{instruction_id}", "mode": MODE_PLAN_X,
"note": "已写 trading_buy_plan(is_active=6) 待下游挂单", "error": None}
def cancel(*, instruction_id: str, dispatch_ref=None) -> dict:
"""请求撤单。shadow/plan_x 无撤单语义, 只回执由 executor 置本地状态。"""
m = mode()
if m != MODE_CHANNEL_Y:
return {"ok": True, "mode": m, "note": "本模式无下游撤单动作, 仅本地置撤销"}
try:
n = execute("UPDATE pms_order_request SET cancel_flag = 1, update_time = :ts "
"WHERE instruction_id = :iid AND status IN ('NEW', 'ACCEPTED', 'EXECUTING')",
{"iid": instruction_id, "ts": datetime.now()})
return {"ok": True, "mode": m, "note": f"已置 cancel_flag, 影响 {n}"}
except Exception as e:
return {"ok": False, "mode": m, "error": f"{type(e).__name__}: {e}"}

314
app/services/executor.py Normal file
View File

@ -0,0 +1,314 @@
# -*- coding: utf-8 -*-
"""
执行编排: 方案 指令 分日出手 回执 (设计 §7 / §8 / §9 的落地)
=====================================================================
纯逻辑在 core/rule_gate.py (规则闸) core/exec_timing.py (择时实现B), 本模块负责
取数落表与状态推进三个入口:
materialize_plans() 把命令方案里可执行的条目转成 pms_instruction (先记账后动作)
gated 方案 (建仓的 FILL/ADD ) 状态为 GATED, 由动作引擎解锁, 此处不动
run_tick() 盘中每分钟一跳: 配额 择时判定 规则闸终检 下发 记子单
sweep_windows() 窗口耗尽收口: 命令类置部分完成并告警, 自主类作废
指令与子单的关系: 一条 pms_instruction 承载一个方案条目的**总量**, 每日出手记一条子单到
progress_json.children[]; exec_qty 由回放认领回填 (账本以下游成交为准, 不拿下发量当成交量)
"""
from __future__ import annotations
import logging
from datetime import datetime
from app.core import command_spec as cs
from app.core import exec_timing as et
from app.core import rule_gate
from app.core import tradedays as td
from app.repo import pms_repo
from app.services import (command_service, dispatcher, industry, market, param_store,
portfolio)
logger = logging.getLogger("pms.exec")
# 指令状态
ST_PROPOSED, ST_RULE_PASSED, ST_DISPATCHED = "PROPOSED", "RULE_PASSED", "DISPATCHED"
ST_CONFIRMED, ST_REJECTED, ST_EXPIRED, ST_CANCELLED = ("CONFIRMED", "REJECTED", "EXPIRED",
"CANCELLED")
LIVE = (ST_PROPOSED, ST_RULE_PASSED, ST_DISPATCHED)
# 方案状态: GATED = 建仓的补足/加仓批, 等动作引擎按条件解锁 (DDL 注释已同步)
PLAN_PENDING, PLAN_GATED, PLAN_EXEC, PLAN_DONE = "PENDING", "GATED", "EXECUTING", "DONE"
SELL_ACTIONS = {"EXIT", "TRIM"}
BUY_ACTIONS = {"OPEN", "FILL", "ADD", "DCA"}
# ================================================================ 方案 → 指令
def materialize_plans(limit: int = 100) -> dict:
"""把在途命令的可执行方案条目转成指令。幂等: 已生成过指令的方案置 EXECUTING 不再重复。"""
out = {"created": [], "skipped": [], "errors": []}
plans = pms_repo.list_plans(statuses=[PLAN_PENDING], limit=limit)
if not plans:
return out
cmd_cache = {}
ymd = td.ymd()
seq = 0
for p in plans:
act = p.get("action")
if act == "HALT": # 撤单类在规划期已执行完毕
pms_repo.update_plan(p["plan_id"], status=PLAN_DONE)
out["skipped"].append({"plan_id": p["plan_id"], "why": "HALT 已在规划期执行"})
continue
side = "sell" if act in SELL_ACTIONS else ("buy" if act in BUY_ACTIONS else None)
if not side:
out["skipped"].append({"plan_id": p["plan_id"], "why": f"动作 {act} 无对应指令"})
continue
cid = p["command_id"]
if cid not in cmd_cache:
cmd_cache[cid] = pms_repo.get_command(cid)
cmd = cmd_cache[cid]
if not cmd or cmd["status"] not in (cs.ST_EXECUTING, cs.ST_PARTIAL):
out["skipped"].append({"plan_id": p["plan_id"],
"why": f"命令状态 {cmd['status'] if cmd else '缺失'}"})
continue
qty = int(p.get("qty") or 0)
if qty <= 0:
out["skipped"].append({"plan_id": p["plan_id"], "why": "数量为 0"})
continue
seq += 1
iid = cs.make_instruction_id(ymd, p["ts_code"], act, seq)
window = int((cmd.get("progress") or {}).get("window_tdays")
or param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3))
try:
pms_repo.insert_instruction(
instruction_id=iid, origin_type="plan", origin_id=p["plan_id"],
ts_code=p["ts_code"], action=act, side=side, qty=qty,
limit_price=None, window_tdays=window, status=ST_PROPOSED,
progress={"deadline": str(p.get("deadline") or ""), "command_id": cid,
"is_command": True, "children": []})
pms_repo.update_plan(p["plan_id"], status=PLAN_EXEC)
out["created"].append(iid)
except Exception as e:
logger.exception("方案转指令失败 %s", p["plan_id"])
out["errors"].append(f"{p['plan_id']}: {type(e).__name__}: {e}")
return out
# ================================================================ 出手 tick
def run_tick(*, now=None, dry_run: bool = False) -> dict:
"""盘中每分钟一跳。dry_run=True 时只算不发不落库 (页面「试算」用)。"""
now = now or datetime.now()
out = {"ok": True, "checked": 0, "fired": [], "waited": [], "rejected": [],
"errors": [], "mode": dispatcher.mode(), "dry_run": dry_run}
instrs = [i for i in pms_repo.list_instructions(statuses=list(LIVE), limit=200)
if int(i.get("qty") or 0) > int(i.get("exec_qty") or 0)]
if not instrs:
return out
view = portfolio.positions_view()
stock_params = command_service.effective_stock_params()
prm = view["params"]
exec_prm = {
"sell_avoid_open_min": param_store.get_int("PMS_SELL_AVOID_OPEN_MIN", 30),
"buy_halt_dayup": param_store.get_float("PMS_BUY_HALT_DAYUP", 0.05),
"eod_force_time": param_store.get("PMS_EOD_FORCE_TIME", "14:45"),
"eod_force_discount": param_store.get_float("PMS_EOD_FORCE_DISCOUNT", 0.998),
"no_chase_ma5": param_store.get_float("PMS_NO_CHASE_MA5", 0.06),
}
slices = param_store.get_int("PMS_EXEC_SLICES", 1)
brake_active = td.ymd() < param_store.get_int("PMS_BRAKE_UNTIL", 0)
ymd_today = td.ymd()
for ins in instrs:
out["checked"] += 1
code, side = ins["ts_code"], str(ins.get("side") or "").lower()
prog = dict(ins.get("progress") or {})
children = list(prog.get("children") or [])
remaining = int(ins["qty"]) - int(ins.get("exec_qty") or 0)
deadline = prog.get("deadline") or ""
tdays_left = td.trade_days_left(deadline, now) if deadline else 1
is_last = tdays_left <= 1
try:
day = market.day_snapshot(code)
pos = _pos_of(view, code)
quota = et.daily_quota(remaining, tdays_left,
allow_odd_tail=(ins.get("action") == "EXIT"))
fired_today = sum(int(c.get("qty") or 0) for c in children
if int(c.get("ymd") or 0) == ymd_today)
day_ctx = {**day, "support": pos.get("support_ref"),
"limit_up": _limit_up(day), "limit_down": _limit_down(day),
"halted": not day or not day.get("price")}
d = et.decide(side=side, now=now, day=day_ctx, params=exec_prm,
is_last_day=is_last, fired_today=fired_today, quota=quota)
if d["action"] != et.ACT_FIRE:
prog["last_decision"] = {"at": now.strftime("%H:%M"), **d}
if not dry_run:
pms_repo.update_instruction(ins["instruction_id"], progress=prog)
out["waited"].append({"instruction_id": ins["instruction_id"], "code": code,
"action": d["action"], "reason": d["reason"]})
continue
slice_list = et.slice_qty(min(d["qty_hint"], remaining), slices)
qty = slice_list[0] if slice_list else 0
if side == "sell":
qty = min(qty, int(pos.get("avail_qty") or 0))
if qty <= 0:
prog["last_decision"] = {"at": now.strftime("%H:%M"), "action": et.ACT_WAIT,
"reason": "可卖量不足, 顺延"}
if not dry_run:
pms_repo.update_instruction(ins["instruction_id"], progress=prog)
out["waited"].append({"instruction_id": ins["instruction_id"], "code": code,
"action": et.ACT_WAIT, "reason": "可卖量不足, 顺延"})
continue
ma5 = market.get_ma5(code) if side == "buy" else None
gate = rule_gate.check(
side=side, action=ins.get("action"), qty=qty, price=day_ctx.get("price"),
ctx={"ts_code": code, "position": pos, "day": {**day_ctx, "ma5": ma5},
"params": {**exec_prm, "sector_source_ready": view["sector_ready"]},
"caps": portfolio.caps_ctx(view, ts_code=code) if side == "buy" else None,
"flags": {"buy_halt": prm["buy_halt"], "exec_halt": prm["exec_halt"],
"brake_active": brake_active,
"blacklisted": bool(stock_params.get(code, {}).get("black")),
"is_command": bool(prog.get("is_command"))}})
if not gate["passed"]:
out["rejected"].append({"instruction_id": ins["instruction_id"], "code": code,
"failed": gate["failed"]})
if not dry_run:
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["last_decision"] = {"at": now.strftime("%H:%M"), "action": "REJECT",
"reason": "; ".join(gate["failed"])}
pms_repo.update_instruction(ins["instruction_id"], progress=prog)
continue
if dry_run:
out["fired"].append({"instruction_id": ins["instruction_id"], "code": code,
"qty": qty, "limit": d["limit_price"],
"reason": d["reason"], "dry_run": True})
continue
res = dispatcher.dispatch(
instruction_id=_child_id(ins["instruction_id"], len(children) + 1),
ts_code=code, side=side, qty=qty, limit_price=d["limit_price"],
valid_until=now)
if not res.get("ok"):
out["errors"].append(f"{ins['instruction_id']} 下发失败: {res.get('error')}")
continue
children.append({"ymd": ymd_today, "at": now.strftime("%H:%M:%S"), "qty": qty,
"limit": d["limit_price"], "mode": res["mode"], "ref": res["ref"],
"forced": d.get("forced", False), "reason": d["reason"]})
prog["children"] = children
prog.setdefault("dispatched_at", now.strftime("%Y-%m-%d %H:%M:%S"))
prog["last_decision"] = {"at": now.strftime("%H:%M"), "action": et.ACT_FIRE,
"reason": d["reason"]}
pms_repo.update_instruction(ins["instruction_id"], status=ST_DISPATCHED,
limit_price=None, dispatch_ref=res["ref"],
progress=prog)
pms_repo.insert_ledger(ts_code=code, action=ins.get("action"), arbiter="rule",
verdict="PASS", price_at=day_ctx.get("price") or 0,
hard_numbers={**gate["hard_numbers"], "limit": d["limit_price"],
"mode": res["mode"]},
ref_id=ins["instruction_id"], reason=d["reason"])
out["fired"].append({"instruction_id": ins["instruction_id"], "code": code,
"qty": qty, "limit": d["limit_price"], "mode": res["mode"],
"note": res.get("note"), "reason": d["reason"]})
except Exception as e:
logger.exception("出手处理失败 %s", ins.get("instruction_id"))
out["errors"].append(f"{ins.get('instruction_id')}: {type(e).__name__}: {e}")
out["ok"] = not out["errors"]
return out
# ================================================================ 窗口收口
def sweep_windows(*, now=None) -> dict:
"""窗口耗尽的指令收口, 并把成交量回写方案 (命令进度据此结算)。"""
now = now or datetime.now()
out = {"expired": [], "partial": [], "synced": 0, "errors": []}
for ins in pms_repo.list_instructions(statuses=list(LIVE), limit=300):
try:
prog = dict(ins.get("progress") or {})
exec_qty = int(ins.get("exec_qty") or 0)
remaining = int(ins["qty"]) - exec_qty
if ins.get("origin_type") == "plan" and ins.get("origin_id"):
pms_repo.update_plan(ins["origin_id"], filled_qty=exec_qty,
status=PLAN_DONE if remaining <= 0 else None)
out["synced"] += 1
if remaining <= 0:
pms_repo.update_instruction(ins["instruction_id"], status=ST_CONFIRMED)
continue
deadline = prog.get("deadline") or ""
if not deadline:
continue
left = td.trade_days_left(deadline, now)
v = et.window_verdict(remaining_qty=remaining, tdays_left=left,
is_command=bool(prog.get("is_command")))
if v["verdict"] == "RUNNING":
continue
prog["window_verdict"] = v
if v["verdict"] == "EXPIRED":
pms_repo.update_instruction(ins["instruction_id"], status=ST_EXPIRED,
progress=prog)
out["expired"].append(ins["instruction_id"])
else:
pms_repo.update_instruction(ins["instruction_id"], progress=prog)
out["partial"].append({"instruction_id": ins["instruction_id"],
"remaining": remaining, "note": v["note"]})
logger.warning("[窗口耗尽] %s 仍剩 %s 股 —— %s", ins["instruction_id"],
remaining, v["note"])
except Exception as e:
out["errors"].append(f"{ins.get('instruction_id')}: {type(e).__name__}: {e}")
try:
command_service.refresh_progress()
except Exception as e:
out["errors"].append(f"命令进度刷新失败: {e}")
return out
def cancel_instruction(instruction_id: str, reason: str = "页面人工撤销") -> dict:
ins = pms_repo.get_instruction(instruction_id)
if not ins:
return {"ok": False, "error": "指令不存在"}
if ins["status"] not in LIVE:
return {"ok": False, "error": f"指令处于 {ins['status']}, 不可撤销"}
r = dispatcher.cancel(instruction_id=instruction_id, dispatch_ref=ins.get("dispatch_ref"))
pms_repo.update_instruction(instruction_id, status=ST_CANCELLED)
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)
return {"ok": True, "downstream": r, "message": f"指令 {instruction_id} 已撤销"}
# ================================================================ 内部
def _pos_of(view: dict, ts_code: str) -> dict:
for x in view["positions"]:
if x["ts_code"] == ts_code:
return x
return {"ts_code": ts_code, "total_qty": 0, "avail_qty": 0, "frozen_reason": "NONE"}
def _child_id(instruction_id: str, n: int) -> str:
return f"{instruction_id}_D{n:02d}"
def _limit_up(day: dict) -> bool:
"""一字板粗判: 当日最高=最低=现价 且 涨幅为正 (无涨跌停价字段时的兜底口径)。"""
if not day:
return False
hi, lo, px = day.get("high"), day.get("low"), day.get("price")
chg = day.get("day_chg_from_open")
return bool(hi and lo and px and hi == lo == px and (chg or 0) >= 0 and day.get("bars", 0) > 3)
def _limit_down(day: dict) -> bool:
if not day:
return False
hi, lo, px = day.get("high"), day.get("low"), day.get("price")
chg = day.get("day_chg_from_open")
return bool(hi and lo and px and hi == lo == px and (chg or 0) <= 0 and day.get("bars", 0) > 3)

View File

@ -167,6 +167,28 @@ def self_calc_refs(ts_code: str, base_cost=None) -> dict:
"source": "self_calc", "bars": len(rows)} "source": "self_calc", "bars": len(rows)}
_ma_cache = {"day": None, "data": {}}
def get_ma5(ts_code: str):
"""MA5 (规则闸「不追高」用)。按日缓存 —— 因子分表是日频数据, 盘中重复查没意义。"""
today = datetime.now().strftime("%Y%m%d")
if _ma_cache["day"] != today:
_ma_cache.update({"day": today, "data": {}})
if ts_code in _ma_cache["data"]:
return _ma_cache["data"][ts_code]
val = None
try:
rows = _factor_rows(ts_code, days=10)
closes = [float(r["close_qfq"]) for r in rows if r.get("close_qfq")]
if len(closes) >= 5:
val = round(sum(closes[-5:]) / 5, 3)
except Exception as e:
logger.warning("MA5 取数失败 [%s]: %s", ts_code, e)
_ma_cache["data"][ts_code] = val
return val
def get_refs(ts_code: str, *, base_cost=None) -> dict: def get_refs(ts_code: str, *, base_cost=None) -> dict:
"""参考位: 决策系统主口径 → 日龄超期/缺失时兜底自算 → 都拿不到返回 source=none。""" """参考位: 决策系统主口径 → 日龄超期/缺失时兜底自算 → 都拿不到返回 source=none。"""
stale_days = param_store.get_int("PMS_REF_STALE_TDAYS", 3) stale_days = param_store.get_int("PMS_REF_STALE_TDAYS", 3)

View File

@ -64,6 +64,9 @@ DESC = {
"PMS_EOD_FORCE_TIME": "当日配额兜底时点", "PMS_EOD_FORCE_DISCOUNT": "兜底限价系数 (卖出)", "PMS_EOD_FORCE_TIME": "当日配额兜底时点", "PMS_EOD_FORCE_DISCOUNT": "兜底限价系数 (卖出)",
"PMS_MIN_LOT_MERGE": "一手检查: 批次自动合并", "PMS_MIN_LOT_MERGE": "一手检查: 批次自动合并",
"PMS_DISPATCH_EXPIRE_MIN": "指令下发后未被接受的过期时间 (分钟)", "PMS_DISPATCH_EXPIRE_MIN": "指令下发后未被接受的过期时间 (分钟)",
"PMS_DISPATCH_MODE": "下发通道: shadow=只记账待人工 / plan_x=买入走 trading_buy_plan / "
"channel_y=写 pms_order_request",
"PMS_EXEC_SLICES": "当日配额分几笔出手",
"PMS_RISK_WARN_ENTRY": "单笔敞口告警线 (占规模)", "PMS_RISK_WARN_PORTFOLIO": "组合敞口告警线", "PMS_RISK_WARN_ENTRY": "单笔敞口告警线 (占规模)", "PMS_RISK_WARN_PORTFOLIO": "组合敞口告警线",
"PMS_BRAKE_DRAWDOWN": "组合刹车: 自高水位回撤", "PMS_BRAKE_DAYS": "刹车持续交易日", "PMS_BRAKE_DRAWDOWN": "组合刹车: 自高水位回撤", "PMS_BRAKE_DAYS": "刹车持续交易日",
"PMS_STOP_ATR_MULT": "自算止损参考: 成本 N×ATR", "PMS_STOP_ATR_MULT": "自算止损参考: 成本 N×ATR",
@ -223,6 +226,8 @@ def _range_check(key, v):
return "PMS_AUTONOMY 只能是 full / propose_only / off" return "PMS_AUTONOMY 只能是 full / propose_only / off"
if key == "PMS_SECTOR_SOURCE" and v not in ("", "custom_table", "gp_stock_category"): if key == "PMS_SECTOR_SOURCE" and v not in ("", "custom_table", "gp_stock_category"):
return "PMS_SECTOR_SOURCE 只能是 空 / custom_table / gp_stock_category" return "PMS_SECTOR_SOURCE 只能是 空 / custom_table / gp_stock_category"
if key == "PMS_DISPATCH_MODE" and v not in ("shadow", "plan_x", "channel_y"):
return "PMS_DISPATCH_MODE 只能是 shadow / plan_x / channel_y"
lo_hi = _RANGES.get(key) lo_hi = _RANGES.get(key)
if lo_hi and isinstance(v, (int, float)) and not isinstance(v, bool): if lo_hi and isinstance(v, (int, float)) and not isinstance(v, bool):
lo, hi = lo_hi lo, hi = lo_hi

View File

@ -282,6 +282,39 @@ def api_plan_pending():
return ok(command_service.plan_pending) return ok(command_service.plan_pending)
@app.post("/api/ops/materialize")
def api_materialize():
"""方案 → 指令 (先记账后动作)。"""
from app.services import executor
return ok(executor.materialize_plans)
@app.post("/api/ops/exec-tick")
def api_exec_tick(dry_run: bool = Query(False)):
"""择时出手一跳。dry_run=true 只试算不下发, 用来在盘中先看「现在会怎么动」。"""
from app.services import executor
return ok(executor.run_tick, dry_run=dry_run)
@app.post("/api/ops/sweep-windows")
def api_sweep_windows():
from app.services import executor
return ok(executor.sweep_windows)
@app.post("/api/instructions/{instruction_id}/cancel")
def api_cancel_instruction(instruction_id: str, payload: dict = Body(default={})):
from app.services import executor
return ok(executor.cancel_instruction, instruction_id,
payload.get("reason") or "页面人工撤销")
@app.get("/api/dispatch-mode")
def api_dispatch_mode():
from app.services import dispatcher
return ok(dispatcher.describe)
@app.get("/api/ops/downstream-schema") @app.get("/api/ops/downstream-schema")
def api_downstream_schema(): def api_downstream_schema():
"""导出下游三表的实际列定义 —— 用于回填 QMT_INTERFACE_REQUIREMENTS D1。""" """导出下游三表的实际列定义 —— 用于回填 QMT_INTERFACE_REQUIREMENTS D1。"""

View File

@ -50,6 +50,8 @@
<el-tag :type="ov.exec_halt ? 'danger':'info'" effect="dark" size="small"> <el-tag :type="ov.exec_halt ? 'danger':'info'" effect="dark" size="small">
执行 {{ ov.exec_halt ? '已暂停':'正常' }}</el-tag> 执行 {{ ov.exec_halt ? '已暂停':'正常' }}</el-tag>
<el-tag type="warning" effect="dark" size="small">自主档位 {{ ov.autonomy || '-' }}</el-tag> <el-tag type="warning" effect="dark" size="small">自主档位 {{ ov.autonomy || '-' }}</el-tag>
<el-tag :type="dm.shadow ? 'info':'success'" effect="dark" size="small">
下发 {{ dm.mode || '-' }}</el-tag>
<el-button size="small" @click="openReport">日报</el-button> <el-button size="small" @click="openReport">日报</el-button>
<el-button size="small" @click="opsDrawer=true">运维</el-button> <el-button size="small" @click="opsDrawer=true">运维</el-button>
<el-button size="small" type="primary" :loading="loading" @click="loadAll">刷新</el-button> <el-button size="small" type="primary" :loading="loading" @click="loadAll">刷新</el-button>
@ -65,6 +67,8 @@
:title="'以下持仓取不到实时价, 已用摊薄成本兜底: ' + ov.price_missing.join(', ')"/> :title="'以下持仓取不到实时价, 已用摊薄成本兜底: ' + ov.price_missing.join(', ')"/>
<el-alert v-if="health.calendar_degraded" class="banner" type="warning" effect="dark" show-icon <el-alert v-if="health.calendar_degraded" class="banner" type="warning" effect="dark" show-icon
:closable="false" title="交易日历降级: 未安装 chinesecalendar, 节假日不可辨"/> :closable="false" title="交易日历降级: 未安装 chinesecalendar, 节假日不可辨"/>
<el-alert v-if="dm.shadow" class="banner" type="info" effect="dark" show-icon :closable="false"
:title="'影子运行中 —— ' + (dm.hint || '')"/>
<div class="metrics"> <div class="metrics">
<div class="m"><div class="k">总规模</div><div class="v">{{ money(ov.scale) }}</div></div> <div class="m"><div class="k">总规模</div><div class="v">{{ money(ov.scale) }}</div></div>
@ -290,20 +294,58 @@
<div class="panel"> <div class="panel">
<h3>在途指令</h3> <h3>在途指令</h3>
<el-table :data="instructions" size="small" border max-height="260"> <el-table :data="instructions" size="small" border max-height="320">
<el-table-column type="expand">
<template #default="s">
<div style="padding:6px 16px">
<div class="muted" v-if="(s.row.progress||{}).last_decision">
最近判定 [{{ s.row.progress.last_decision.at }}]
<b>{{ s.row.progress.last_decision.action }}</b>
{{ s.row.progress.last_decision.reason }}
</div>
<div class="muted" v-if="(s.row.progress||{}).deadline">
执行窗口截止: {{ s.row.progress.deadline }}
<template v-if="(s.row.progress||{}).window_verdict">
· {{ s.row.progress.window_verdict.note }}</template>
</div>
<el-table v-if="((s.row.progress||{}).children||[]).length"
:data="s.row.progress.children" size="small" border
style="margin-top:6px">
<el-table-column prop="ymd" label="日期" width="100"/>
<el-table-column prop="at" label="时点" width="90"/>
<el-table-column prop="qty" label="数量" width="90"/>
<el-table-column prop="limit" label="限价" width="90"/>
<el-table-column prop="mode" label="通道" width="100"/>
<el-table-column label="兜底" width="70">
<template #default="c">{{ c.row.forced ? '是' : '—' }}</template>
</el-table-column>
<el-table-column prop="reason" label="出手依据" min-width="260"/>
</el-table>
<div v-else class="muted" style="margin-top:6px">尚无出手记录</div>
</div>
</template>
</el-table-column>
<el-table-column prop="instruction_id" label="指令号" width="230" class-name="mono"/> <el-table-column prop="instruction_id" label="指令号" width="230" class-name="mono"/>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"/> <el-table-column prop="ts_code" label="股票" width="110" class-name="mono"/>
<el-table-column prop="action" label="动作" width="90"/> <el-table-column prop="action" label="动作" width="80"/>
<el-table-column prop="side" label="方向" width="70"/> <el-table-column prop="side" label="方向" width="70"/>
<el-table-column prop="qty" label="数量" width="90"/> <el-table-column prop="qty" label="数量" width="80"/>
<el-table-column prop="exec_qty" label="已成交" width="90"/> <el-table-column prop="exec_qty" label="已成交" width="80"/>
<el-table-column prop="limit_price" label="限价" width="90"/> <el-table-column label="进度" width="130">
<el-table-column prop="status" label="状态" width="120"/> <template #default="s">
<el-table-column prop="origin_type" label="来源" width="90"/> <el-progress :percentage="insPct(s.row)" :stroke-width="10" :text-inside="true"/>
<el-table-column prop="updated_at" label="更新" width="160" class-name="muted"/> </template>
</el-table-column>
<el-table-column prop="status" label="状态" width="115"/>
<el-table-column prop="origin_type" label="来源" width="80"/>
<el-table-column label="操作" width="90">
<template #default="s">
<el-button size="small" @click="cancelIns(s.row.instruction_id)">撤销</el-button>
</template>
</el-table-column>
</el-table> </el-table>
<div class="muted" style="margin-top:6px"> <div class="muted" style="margin-top:6px">
指令下发通道待 QMT 侧协商落地 (B1); 通道未通前减持类只记账不下发 (影子运行)。 {{ dm.hint }} 展开可看每日子单与择时依据; 成交量以下游回放为准, 不拿下发量当成交量
</div> </div>
</div> </div>
@ -367,14 +409,25 @@
调度器 (--profile sched) 会自动按调度总表执行; 此处按钮用于未接调度或临时验证时手动触发。 调度器 (--profile sched) 会自动按调度总表执行; 此处按钮用于未接调度或临时验证时手动触发。
</div> </div>
<div class="row"> <div class="row">
<el-button @click="ops('plan-pending')" :loading="opsLoading">推进待规划命令</el-button>
<el-button @click="ops('materialize')" :loading="opsLoading">方案转指令</el-button>
<el-button type="primary" @click="ops('exec-tick?dry_run=true')" :loading="opsLoading">
出手试算 (不下发)</el-button>
<el-button @click="ops('exec-tick')" :loading="opsLoading">出手一跳</el-button>
<el-button @click="ops('sweep-windows')" :loading="opsLoading">窗口收口</el-button>
</div>
<div class="row" style="margin-top:8px">
<el-button @click="ops('replay')" :loading="opsLoading">成交回放</el-button> <el-button @click="ops('replay')" :loading="opsLoading">成交回放</el-button>
<el-button @click="ops('reconcile')" :loading="opsLoading">账本对账</el-button> <el-button @click="ops('reconcile')" :loading="opsLoading">账本对账</el-button>
<el-button @click="ops('premarket')" :loading="opsLoading">盘前准备</el-button> <el-button @click="ops('premarket')" :loading="opsLoading">盘前准备</el-button>
<el-button @click="ops('daily-settle')" :loading="opsLoading">日终结算</el-button> <el-button @click="ops('daily-settle')" :loading="opsLoading">日终结算</el-button>
<el-button @click="ops('report')" :loading="opsLoading">生成日报</el-button> <el-button @click="ops('report')" :loading="opsLoading">生成日报</el-button>
<el-button @click="ops('plan-pending')" :loading="opsLoading">推进待规划命令</el-button>
<el-button @click="loadSchema" :loading="opsLoading">导出下游表结构</el-button> <el-button @click="loadSchema" :loading="opsLoading">导出下游表结构</el-button>
</div> </div>
<div class="muted" style="margin-top:10px">
当前下发通道: <b>{{ dm.mode }}</b> —— {{ dm.hint }}
(通道在「参数设置 → PMS_DISPATCH_MODE」切换)
</div>
<pre class="json" v-if="opsResult">{{ opsResult }}</pre> <pre class="json" v-if="opsResult">{{ opsResult }}</pre>
</el-drawer> </el-drawer>
@ -404,7 +457,7 @@ createApp({
const catalog = ref([]), commands = ref([]), plans = ref([]), plansOf = ref(''); const catalog = ref([]), commands = ref([]), plans = ref([]), plansOf = ref('');
const positions = ref([]), lots = ref([]), lotsOf = ref(''); const positions = ref([]), lots = ref([]), lotsOf = ref('');
const instructions = ref([]), ledger = ref([]), proposals = ref([]); const instructions = ref([]), ledger = ref([]), proposals = ref([]);
const report = ref({}), reportDrawer = ref(false); const report = ref({}), reportDrawer = ref(false), dm = ref({});
const opsDrawer = ref(false), opsResult = ref(''), opsLoading = ref(false); const opsDrawer = ref(false), opsResult = ref(''), opsLoading = ref(false);
const issuing = ref(false); const issuing = ref(false);
const form = reactive({ cmd_type: '', params: {}, note: '' }); const form = reactive({ cmd_type: '', params: {}, note: '' });
@ -433,6 +486,10 @@ createApp({
const t = Number(p.target_amount || 0), d = Number(p.done_amount || 0); const t = Number(p.target_amount || 0), d = Number(p.done_amount || 0);
return t > 0 ? Math.min(100, Math.round(d / t * 100)) : 0; return t > 0 ? Math.min(100, Math.round(d / t * 100)) : 0;
}; };
const insPct = r => {
const q = Number(r.qty || 0), e = Number(r.exec_qty || 0);
return q > 0 ? Math.min(100, Math.round(e / q * 100)) : 0;
};
async function call(method, url, body) { async function call(method, url, body) {
try { try {
@ -491,12 +548,25 @@ createApp({
lotsOf.value = code; lotsOf.value = code;
const d = await call('get', '/api/positions/' + code + '/lots?status='); lots.value = d.data || []; const d = await call('get', '/api/positions/' + code + '/lots?status='); lots.value = d.data || [];
} }
async function loadDispatchMode() {
const d = await call('get', '/api/dispatch-mode'); dm.value = d.data || d || {};
}
async function loadAll() { async function loadAll() {
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()]);
loading.value = false; loading.value = false;
} }
async function cancelIns(iid) {
try {
await ElementPlus.ElMessageBox.confirm('撤销指令 ' + iid + '?', '确认',
{ type: 'warning' });
} catch (e) { return; }
const d = await call('post', '/api/instructions/' + iid + '/cancel', {});
ElementPlus.ElMessage[(d.ok ? 'success' : 'error')](d.message || d.error);
await Promise.all([loadInstructions(), loadLedger()]);
}
function onCmdChange() { function onCmdChange() {
form.params = {}; form.params = {};
@ -585,10 +655,10 @@ createApp({
onMounted(loadAll); onMounted(loadAll);
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, proposals, report, reportDrawer,
opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm,
money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, insPct,
loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue, loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue,
cancelCmd, replan, decide, ops, loadSchema, openReport }; cancelCmd, replan, decide, ops, loadSchema, openReport, cancelIns };
} }
}).use(ElementPlus).mount('#app'); }).use(ElementPlus).mount('#app');
</script> </script>

View File

@ -84,6 +84,8 @@ class Settings(BaseSettings):
PMS_EOD_FORCE_DISCOUNT: float = 0.998 # 兜底限价 = 现价 × 此系数 (卖出) PMS_EOD_FORCE_DISCOUNT: float = 0.998 # 兜底限价 = 现价 × 此系数 (卖出)
PMS_MIN_LOT_MERGE: bool = True # 一手检查: 批次自动合并 PMS_MIN_LOT_MERGE: bool = True # 一手检查: 批次自动合并
PMS_DISPATCH_EXPIRE_MIN: int = 30 # 指令下发后未被接受的过期时间 PMS_DISPATCH_EXPIRE_MIN: int = 30 # 指令下发后未被接受的过期时间
PMS_DISPATCH_MODE: str = "shadow" # 下发通道: shadow(影子,默认) / plan_x / channel_y
PMS_EXEC_SLICES: int = 1 # 当日配额分几笔出手 (设计「分笔卖出配额」)
# --- 风险披露与刹车 --- # --- 风险披露与刹车 ---
PMS_RISK_WARN_ENTRY: float = 0.01 # 单笔敞口告警线 (占规模) PMS_RISK_WARN_ENTRY: float = 0.01 # 单笔敞口告警线 (占规模)

View File

@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS pms_plan (
priority INT NOT NULL DEFAULT 100 COMMENT '越小越先执行', priority INT NOT NULL DEFAULT 100 COMMENT '越小越先执行',
deadline DATE NULL COMMENT '执行窗口截止日', deadline DATE NULL COMMENT '执行窗口截止日',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' status VARCHAR(16) NOT NULL DEFAULT 'PENDING'
COMMENT 'PENDING/EXECUTING/DONE/PARTIAL/CANCELLED', COMMENT 'PENDING/GATED(建仓补足加仓批,待动作引擎解锁)/EXECUTING/DONE/PARTIAL/CANCELLED',
filled_qty INT NOT NULL DEFAULT 0, filled_qty INT NOT NULL DEFAULT 0,
reason VARCHAR(300) NULL COMMENT '进方案的理由 (弱票清仓/收利润/等比减 等)', reason VARCHAR(300) NULL COMMENT '进方案的理由 (弱票清仓/收利润/等比减 等)',
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,

View File

@ -7,7 +7,8 @@
包含: 包含:
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 ) test_core_units.py 仓位规划器 / 安全垫与成本账 (14 )
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 ) test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (18 ) test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 (18 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (25 )
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
""" """
import os import os
@ -16,7 +17,8 @@ import sys
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE) ROOT = os.path.dirname(HERE)
SUITES = ["test_core_units.py", "test_batch2_units.py", "test_wiring.py"] SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_wiring.py"]
def main(): def main():

View File

@ -0,0 +1,293 @@
# -*- coding: utf-8 -*-
"""
第三批模块单测 (实机运行, 零外部依赖)
======================================
运行: tradingSystem 仓库根目录执行 python scripts/test_batch3_units.py
覆盖: exec_timing 分日配额/分笔/买卖出手判定/兜底/顺延/窗口收口;
rule_gate 减持放行口径增持全约束命令与自主的刹车差别
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0
"""
import os
import sys
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core import exec_timing as et # noqa: E402
from app.core import rule_gate as rg # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
PRM = {"sell_avoid_open_min": 30, "buy_halt_dayup": 0.05,
"eod_force_time": "14:45", "eod_force_discount": 0.998}
def day(**kw):
d = {"price": 10.0, "vwap": 10.0, "high": 10.5, "low": 9.5, "open": 10.0,
"day_chg_from_open": 0.0, "bars": 60}
d.update(kw)
return d
# ================================================================ exec_timing
@case("分日配额·整除/上取整到一手/最后一日全出/零股尾巴并入")
def _():
assert et.daily_quota(6000, 3) == 2000
assert et.daily_quota(5000, 3) == 1700 # 1666.7 → 上取整到一手
assert et.daily_quota(1000, 1) == 1000 # 最后一日全出
assert et.daily_quota(150, 3) == 150 # 尾巴不足一手 → 一次出完
assert et.daily_quota(0, 3) == 0
assert et.daily_quota(100, 5) == 100
# 整票清仓允许零股
assert et.daily_quota(14050, 3, allow_odd_tail=True) == 4700
assert et.daily_quota(14050, 1, allow_odd_tail=True) == 14050
@case("分笔·配额切成 N 笔, 余数并入最后一笔")
def _():
assert et.slice_qty(3000, 3) == [1000, 1000, 1000]
assert et.slice_qty(1000, 3) == [300, 300, 400]
assert et.slice_qty(100, 3) == [100]
assert et.slice_qty(0, 3) == []
assert et.slice_qty(2500, 1) == [2500]
@case("卖出择时·避开开盘30分钟 → 站上均价才出手 → 限价打折")
def _():
r = et.decide(side="sell", now="09:45", day=day(), params=PRM, is_last_day=False, quota=2000)
assert r["action"] == et.ACT_WAIT and "避开开盘" in r["reason"], r
r = et.decide(side="sell", now="10:05", day=day(price=10.2, vwap=10.0), params=PRM,
is_last_day=False, quota=2000)
assert r["action"] == et.ACT_FIRE and r["limit_price"] == 10.18, r # 10.2×0.998
r = et.decide(side="sell", now="10:05", day=day(price=9.8, vwap=10.0), params=PRM,
is_last_day=False, quota=2000)
assert r["action"] == et.ACT_WAIT and "等更好的价" in r["reason"], r
@case("卖出择时·14:45 兜底强制出手 (不管均价)")
def _():
r = et.decide(side="sell", now="14:45", day=day(price=9.5, vwap=10.0), params=PRM,
is_last_day=False, quota=2000)
assert r["action"] == et.ACT_FIRE and r["forced"] is True, r
assert r["limit_price"] == 9.48, r # 9.5×0.998
assert et.decide(side="sell", now="14:44", day=day(price=9.5, vwap=10.0), params=PRM,
is_last_day=False, quota=2000)["action"] == et.ACT_WAIT
@case("买入择时·跌破均价才买 / 回踩带买 / 不追高停手")
def _():
r = et.decide(side="buy", now="10:05", day=day(price=9.8, vwap=10.0), params=PRM,
is_last_day=False, quota=3000)
assert r["action"] == et.ACT_FIRE and r["limit_price"] == 9.82, r # 9.8×1.002
r = et.decide(side="buy", now="10:05", day=day(price=10.3, vwap=10.0), params=PRM,
is_last_day=False, quota=3000)
assert r["action"] == et.ACT_WAIT and "等回调" in r["reason"], r
r = et.decide(side="buy", now="10:05", day=day(price=10.3, vwap=10.0, support=10.2),
params=PRM, is_last_day=False, quota=3000)
assert r["action"] == et.ACT_FIRE and "回踩带" in r["reason"], r
r = et.decide(side="buy", now="10:05", day=day(price=11.0, vwap=10.0,
day_chg_from_open=0.06),
params=PRM, is_last_day=False, quota=3000)
assert r["action"] == et.ACT_STOP and "不追高" in r["reason"], r
@case("买入择时·窗口末日 14:45 强制完成, 非末日则顺延")
def _():
r = et.decide(side="buy", now="14:50", day=day(price=10.5, vwap=10.0), params=PRM,
is_last_day=True, quota=3000)
assert r["action"] == et.ACT_FIRE and r["forced"] and r["limit_price"] == 10.52, r
r = et.decide(side="buy", now="14:50", day=day(price=10.5, vwap=10.0), params=PRM,
is_last_day=False, quota=3000)
assert r["action"] == et.ACT_WAIT and "顺延次日" in r["reason"], r
@case("择时·停牌/一字板/非交易时段/配额出完 一律不动")
def _():
assert et.decide(side="sell", now="10:05", day=day(price=0), params=PRM,
is_last_day=False, quota=100)["action"] == et.ACT_SKIP
assert et.decide(side="sell", now="10:05", day=day(halted=True), params=PRM,
is_last_day=False, quota=100)["action"] == et.ACT_SKIP
assert et.decide(side="buy", now="10:05", day=day(limit_up=True), params=PRM,
is_last_day=False, quota=100)["action"] == et.ACT_SKIP
r = et.decide(side="sell", now="10:05", day=day(limit_down=True), params=PRM,
is_last_day=False, quota=100)
assert r["action"] == et.ACT_SKIP and "顺延" in r["reason"], r
assert et.decide(side="sell", now="12:00", day=day(), params=PRM,
is_last_day=False, quota=100)["action"] == et.ACT_WAIT
r = et.decide(side="sell", now="10:05", day=day(price=10.2), params=PRM,
is_last_day=False, quota=1000, fired_today=1000)
assert r["action"] == et.ACT_WAIT and "配额已出完" in r["reason"], r
@case("择时·时点解析与交易时段判定")
def _():
assert et.hm_to_min("14:45") == 885 and et.hm_to_min((9, 30)) == 570
assert et.in_session(570) and et.in_session(690) and et.in_session(780)
assert not et.in_session(569) and not et.in_session(700) and not et.in_session(901)
@case("窗口收口·命令类置部分完成, 自主类作废, 未到期继续")
def _():
assert et.window_verdict(remaining_qty=0, tdays_left=0, is_command=True)["verdict"] == "DONE"
assert et.window_verdict(remaining_qty=500, tdays_left=2,
is_command=True)["verdict"] == "RUNNING"
v = et.window_verdict(remaining_qty=500, tdays_left=0, is_command=True)
assert v["verdict"] == "PARTIAL" and "告警" in v["note"], v
assert et.window_verdict(remaining_qty=500, tdays_left=0,
is_command=False)["verdict"] == "EXPIRED"
# ================================================================ rule_gate
def ctx(**kw):
d = {"ts_code": "600000.SH",
"position": {"total_qty": 6000, "avail_qty": 6000, "frozen_reason": "NONE"},
"day": {"price": 10.0, "vwap": 10.0, "ma5": 10.0, "day_chg_from_open": 0.01},
"params": {"no_chase_ma5": 0.06, "buy_halt_dayup": 0.05},
"flags": {"buy_halt": False, "exec_halt": False, "brake_active": False,
"blacklisted": False, "is_command": False},
"caps": dict(scale=2_000_000, portfolio_cap=0.60, stock_cap=0.08, max_names=15,
portfolio_mv=800_000, names_count=5, stock_mv=60_000, is_new_name=False,
sector=None, sector_names=0, sector_mv=0.0, sector_max_names=4,
sector_max_ratio=0.40, cash_reserve=0.0, sector_source_ready=True)}
for k, v in kw.items():
if isinstance(v, dict) and isinstance(d.get(k), dict):
d[k] = {**d[k], **v}
else:
d[k] = v
return d
@case("规则闸·卖出正常放行 + 硬数字留痕")
def _():
r = rg.check(side="sell", action="TRIM", qty=2000, price=10.0, ctx=ctx())
assert r["passed"] and r["failed"] == [], r
assert r["hard_numbers"]["avail_qty"] == 6000 and r["hard_numbers"]["qty"] == 2000
@case("规则闸·卖出超持仓/超可卖/零股非清仓 三种拦截")
def _():
r = rg.check(side="sell", action="EXIT", qty=9000, price=10.0, ctx=ctx())
assert any(x.startswith("OVER_SELL") for x in r["failed"]), r
r = rg.check(side="sell", action="TRIM", qty=5000, price=10.0,
ctx=ctx(position={"avail_qty": 3000}))
assert any(x.startswith("T1_UNAVAILABLE") for x in r["failed"]), r
r = rg.check(side="sell", action="TRIM", qty=1050, price=10.0, ctx=ctx())
assert any(x.startswith("LOT_INVALID") for x in r["failed"]), r
# 清仓允许零股
r = rg.check(side="sell", action="EXIT", qty=6050, price=10.0,
ctx=ctx(position={"total_qty": 6050, "avail_qty": 6050}))
assert r["passed"], r
@case("规则闸·减持不受冻结/刹车/上限影响 (只挡增持)")
def _():
c = ctx(position={"frozen_reason": "COMMAND_HALT"},
flags={"brake_active": True, "buy_halt": True})
r = rg.check(side="sell", action="EXIT", qty=6000, price=10.0, ctx=c)
assert r["passed"], r
r2 = rg.check(side="buy", action="ADD", qty=1000, price=10.0, ctx=c)
assert not r2["passed"] and any(x.startswith("FROZEN") for x in r2["failed"]), r2
@case("规则闸·买入 全局暂停/黑名单/非整百 拦截")
def _():
r = rg.check(side="buy", action="ADD", qty=1000, price=10.0,
ctx=ctx(flags={"buy_halt": True}))
assert any(x.startswith("BUY_HALT") for x in r["failed"]), r
r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0,
ctx=ctx(flags={"blacklisted": True}))
assert any(x.startswith("BLACKLIST") for x in r["failed"]), r
r = rg.check(side="buy", action="OPEN", qty=150, price=10.0, ctx=ctx())
assert any(x.startswith("LOT_INVALID") for x in r["failed"]), r
@case("规则闸·刹车对自主是拦截, 对命令只是提示 (命令至上)")
def _():
auto = rg.check(side="buy", action="ADD", qty=1000, price=10.0,
ctx=ctx(flags={"brake_active": True}))
assert not auto["passed"] and any(x.startswith("BRAKE_ACTIVE") for x in auto["failed"])
cmd = rg.check(side="buy", action="OPEN", qty=1000, price=10.0,
ctx=ctx(flags={"brake_active": True, "is_command": True}))
assert cmd["passed"], cmd
assert any(w.startswith("BRAKE_ACTIVE") for w in cmd["warnings"]), cmd
@case("规则闸·不追高两道 (当日涨幅 / 距 MA5) 与 MA5 缺失告警")
def _():
r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0,
ctx=ctx(day={"day_chg_from_open": 0.07}))
assert any(x.startswith("NO_CHASE_DAYUP") for x in r["failed"]), r
r = rg.check(side="buy", action="OPEN", qty=1000, price=11.0,
ctx=ctx(day={"ma5": 10.0}))
assert any(x.startswith("NO_CHASE_MA5") for x in r["failed"]), r # 距 MA5 10% > 6%
r = rg.check(side="buy", action="OPEN", qty=1000, price=10.3,
ctx=ctx(day={"ma5": 10.0}))
assert r["passed"], r # 3% 在容忍内
r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0, ctx=ctx(day={"ma5": None}))
assert r["passed"] and any(w.startswith("MA5_MISSING") for w in r["warnings"]), r
@case("规则闸·买入过组合上限 (复用 planner 同一份口径)")
def _():
r = rg.check(side="buy", action="ADD", qty=12000, price=10.0, ctx=ctx())
assert any(x.startswith("STOCK_CAP") for x in r["failed"]), r # 6万+12万 > 16万
r = rg.check(side="buy", action="ADD", qty=1000, price=10.0, ctx=ctx(caps=None))
assert any(x.startswith("CAPS_MISSING") for x in r["failed"]), r
@case("规则闸·必要输入缺失与全局暂停执行一律拒绝 (宁可不动)")
def _():
r = rg.check(side="sell", action="EXIT", qty=1000, price=0, ctx=ctx())
assert any(x.startswith("PRICE_MISSING") for x in r["failed"]), r
r = rg.check(side="sell", action="EXIT", qty=0, price=10.0, ctx=ctx())
assert any(x.startswith("QTY_INVALID") for x in r["failed"]), r
r = rg.check(side="sell", action="EXIT", qty=1000, price=10.0,
ctx=ctx(flags={"exec_halt": True}))
assert any(x.startswith("EXEC_HALT") for x in r["failed"]), r
r = rg.check(side="sell", action="EXIT", qty=1000, price=10.0,
ctx=ctx(day={"halted": True}))
assert any(x.startswith("HALTED") for x in r["failed"]), r
r = rg.check(side="hold", action="X", qty=100, price=10.0, ctx=ctx())
assert any(x.startswith("SIDE_INVALID") for x in r["failed"]), r
@case("规则闸·批量统计 (日报关注区用)")
def _():
rs = [rg.check(side="sell", action="EXIT", qty=1000, price=10.0, ctx=ctx()),
rg.check(side="buy", action="OPEN", qty=150, price=10.0, ctx=ctx()),
rg.check(side="buy", action="OPEN", qty=100, price=10.0,
ctx=ctx(flags={"buy_halt": True}))]
s = rg.summarize(rs)
assert s["total"] == 3 and s["passed"] == 1 and s["rejected"] == 2, s
assert s["by_reason"].get("LOT_INVALID") == 1 and s["by_reason"].get("BUY_HALT") == 1, s
# ---------------------------------------------------------------- runner
def main():
passed, failed = 0, 0
for name, fn in RESULTS:
try:
fn()
print(f" PASS {name}")
passed += 1
except Exception:
print(f" FAIL {name}")
traceback.print_exc()
failed += 1
print("-" * 60)
if failed:
print(f"FAILED: {failed} / {passed + failed}")
sys.exit(1)
print(f"ALL PASS ({passed} cases)")
if __name__ == "__main__":
main()

View File

@ -322,6 +322,10 @@ def install_fakes(prices=None, positions=None, params=None):
market.get_prices = lambda cs_: {c: (prices or {}).get(c) for c in (cs_ or [])} market.get_prices = lambda cs_: {c: (prices or {}).get(c) for c in (cs_ or [])}
market.get_refs = lambda c, **kw: {"support": None, "pressure": None, "stop": None, market.get_refs = lambda c, **kw: {"support": None, "pressure": None, "stop": None,
"source": "none"} "source": "none"}
market.get_ma5 = lambda c: (prices or {}).get(c)
market.day_snapshot = lambda c: ({} if not (prices or {}).get(c) else {
"price": prices[c], "vwap": prices[c], "open": prices[c], "high": prices[c] * 1.02,
"low": prices[c] * 0.98, "day_chg_from_open": 0.0, "bars": 60})
industry.get_many = lambda cs_: {c: None for c in (cs_ or [])} industry.get_many = lambda cs_: {c: None for c in (cs_ or [])}
industry.get = lambda c: None industry.get = lambda c: None
industry.ready = lambda: False industry.ready = lambda: False
@ -708,6 +712,178 @@ def _():
assert fake.positions["600000.SH"]["avail_qty"] == 6000 assert fake.positions["600000.SH"]["avail_qty"] == 6000
@case("执行器·方案转指令 (GATED 批不转, HALT 直接完结)")
def _():
from app.services import command_service as csvc, executor
fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"},
positions=[{"ts_code": "600000.SH", "total_qty": 14000,
"base_qty": 7000, "fill_qty": 3500, "add_qty": 3500,
"avail_qty": 14000, "avg_cost": 9.0}])
r = csvc.issue("REDUCE_EXPOSURE", {"pct": "3%"})
assert r["ok"], r
# 掺一条 GATED 方案 (建仓补足批) 与一条 HALT 方案
fake.insert_plans([
{"plan_id": "P_G", "command_id": r["command_id"], "ts_code": "600000.SH",
"action": "FILL", "qty": 3000, "amount": 30000, "priority": 20,
"deadline": "2026-07-30", "status": "GATED", "reason": "回踩补足批"},
{"plan_id": "P_H", "command_id": r["command_id"], "ts_code": "600000.SH",
"action": "HALT", "qty": 0, "amount": 0, "priority": 10,
"deadline": "2026-07-30", "status": "PENDING", "reason": "撤在途买入"}])
m = executor.materialize_plans()
assert m["created"], m
codes = {fake.instructions[i]["ts_code"] for i in m["created"]}
assert codes == {"600000.SH"}, codes
assert all(fake.instructions[i]["status"] == "PROPOSED" for i in m["created"])
gated = [p for p in fake.plans if p["plan_id"] == "P_G"][0]
assert gated["status"] == "GATED", gated # 未解锁的批次不转指令
halt = [p for p in fake.plans if p["plan_id"] == "P_H"][0]
assert halt["status"] == "DONE", halt
assert any("HALT" in s["why"] for s in m["skipped"]), m["skipped"]
# 幂等: 再跑一次不重复建指令
n1 = len(fake.instructions)
executor.materialize_plans()
assert len(fake.instructions) == n1
@case("执行器·出手一跳 (影子模式): 择时→规则闸→下发→子单→评审留痕")
def _():
from datetime import datetime as _dt
from app.services import executor
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": 9.0}])
fake.insert_instruction(instruction_id="INS_S", origin_type="plan", origin_id="P1",
ts_code="600000.SH", action="TRIM", side="sell", qty=3000,
window_tdays=3, status="PROPOSED",
progress={"deadline": "2026-07-29", "is_command": True,
"children": []})
# 周一 10:05, 距截止 3 个交易日 → 当日配额 1000
r = executor.run_tick(now=_dt(2026, 7, 27, 10, 5))
assert r["ok"] and len(r["fired"]) == 1, r
fired = r["fired"][0]
assert fired["qty"] == 1000 and fired["mode"] == "shadow", fired
ins = fake.instructions["INS_S"]
assert ins["status"] == "DISPATCHED"
assert len(ins["progress"]["children"]) == 1
assert ins["progress"]["children"][0]["qty"] == 1000
assert ins["progress"]["dispatched_at"]
assert any(x["verdict"] == "PASS" and x["arbiter"] == "rule" for x in fake.ledger)
# 同一天再跳一次: 配额已出完, 不重复下发
r2 = executor.run_tick(now=_dt(2026, 7, 27, 10, 6))
assert not r2["fired"] and r2["waited"], r2
assert len(fake.instructions["INS_S"]["progress"]["children"]) == 1
@case("执行器·规则闸拦截时不下发且落拒绝留痕")
def _():
from datetime import datetime as _dt
from app.services import executor
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": 9.0}])
fake.insert_instruction(instruction_id="INS_B", origin_type="plan", origin_id="P2",
ts_code="600000.SH", action="OPEN", side="buy", qty=1000,
window_tdays=1, status="PROPOSED",
progress={"deadline": "2026-07-27", "is_command": True,
"children": []})
from app.services import param_store
param_store.set_param("PMS_GLOBAL_BUY_HALT", True, "test")
r = executor.run_tick(now=_dt(2026, 7, 27, 10, 5))
assert not r["fired"] and r["rejected"], r
assert any("BUY_HALT" in f for f in r["rejected"][0]["failed"]), r["rejected"]
assert fake.instructions["INS_B"]["status"] == "PROPOSED" # 未下发
assert any(x["verdict"] == "REJECT" for x in fake.ledger)
@case("执行器·试算模式只算不发不落库")
def _():
from datetime import datetime as _dt
from app.services import executor
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": 9.0}])
fake.insert_instruction(instruction_id="INS_D", origin_type="plan", origin_id="P3",
ts_code="600000.SH", action="EXIT", side="sell", qty=2000,
window_tdays=1, status="PROPOSED",
progress={"deadline": "2026-07-27", "children": []})
r = executor.run_tick(now=_dt(2026, 7, 27, 10, 5), dry_run=True)
assert r["fired"] and r["fired"][0]["dry_run"] is True, r
assert r["fired"][0]["qty"] == 2000 # 末日全出
assert fake.instructions["INS_D"]["status"] == "PROPOSED"
assert not fake.instructions["INS_D"]["progress"]["children"]
assert not fake.ledger
@case("执行器·窗口收口: 成交回写方案 + 足额置确认 + 命令类部分完成")
def _():
from datetime import datetime as _dt
from app.services import executor
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": 9.0}])
fake.insert_plans([{"plan_id": "P_F", "command_id": "CMD_X", "ts_code": "600000.SH",
"action": "TRIM", "qty": 2000, "amount": 20000, "priority": 30,
"deadline": "2026-07-27", "status": "EXECUTING", "reason": "收利润"}])
fake.insert_instruction(instruction_id="INS_F", origin_type="plan", origin_id="P_F",
ts_code="600000.SH", action="TRIM", side="sell", qty=2000,
status="DISPATCHED", exec_qty=2000,
progress={"deadline": "2026-07-27", "is_command": True,
"children": []})
fake.insert_instruction(instruction_id="INS_P", origin_type="plan", origin_id="P_F",
ts_code="600000.SH", action="TRIM", side="sell", qty=2000,
status="DISPATCHED", exec_qty=500,
progress={"deadline": "2026-07-20", "is_command": True,
"children": []})
fake.insert_instruction(instruction_id="INS_A2", origin_type="proposal", origin_id="PR1",
ts_code="600000.SH", action="ADD", side="buy", qty=1000,
status="DISPATCHED", exec_qty=0,
progress={"deadline": "2026-07-20", "is_command": False,
"children": []})
r = executor.sweep_windows(now=_dt(2026, 7, 27, 15, 10))
assert fake.instructions["INS_F"]["status"] == "CONFIRMED", fake.instructions["INS_F"]
assert [p for p in fake.plans if p["plan_id"] == "P_F"][0]["filled_qty"] == 500
assert any(x["instruction_id"] == "INS_P" for x in r["partial"]), r
assert fake.instructions["INS_P"]["progress"]["window_verdict"]["verdict"] == "PARTIAL"
assert "INS_A2" in r["expired"], r # 自主类窗口耗尽即作废
assert fake.instructions["INS_A2"]["status"] == "EXPIRED"
@case("下发通道·三模式描述与影子回执; 撤销走本地置状态")
def _():
from app.services import dispatcher, executor, param_store
fake = install_fakes()
assert dispatcher.mode() == "shadow"
d = dispatcher.dispatch(instruction_id="INS_1", ts_code="600000.SH", side="sell",
qty=1000, limit_price=9.98)
assert d["ok"] and d["ref"] == "manual:INS_1" and "人工" in d["note"], d
assert param_store.set_param("PMS_DISPATCH_MODE", "bad_mode")["ok"] is False
assert param_store.set_param("PMS_DISPATCH_MODE", "plan_x")["ok"] is True
d2 = dispatcher.dispatch(instruction_id="INS_2", ts_code="600000.SH", side="sell",
qty=1000, limit_price=9.98)
assert d2["ok"] and d2["mode"] == "shadow" and "无卖出通道" in d2["note"], d2
param_store.set_param("PMS_DISPATCH_MODE", "shadow")
fake.insert_instruction(instruction_id="INS_C", origin_type="plan", origin_id="P1",
ts_code="600000.SH", action="TRIM", side="sell", qty=1000,
status="DISPATCHED", progress={"children": []})
r = executor.cancel_instruction("INS_C")
assert r["ok"] and fake.instructions["INS_C"]["status"] == "CANCELLED", r
assert executor.cancel_instruction("INS_C")["ok"] is False
@case("装配·执行相关路由与调度接线到位")
def _():
from app.web.main import app
from app import scheduler as sch
paths = {r.path for r in app.routes}
for p in ("/api/ops/materialize", "/api/ops/exec-tick", "/api/ops/sweep-windows",
"/api/instructions/{instruction_id}/cancel", "/api/dispatch-mode"):
assert p in paths, p
import inspect
src = inspect.getsource(sch.intraday_exec)
assert "executor" in src and "run_tick" in src, "调度器未接执行器"
# ---------------------------------------------------------------- runner # ---------------------------------------------------------------- runner
def main(): def main():
passed, failed = 0, 0 passed, failed = 0, 0