第三批: 规则闸/择时执行器实现B/三模式下发通道/执行编排
This commit is contained in:
parent
9c2470df21
commit
4a83209dac
23
README.md
23
README.md
|
|
@ -25,6 +25,8 @@ app/
|
|||
command_spec.py 命令目录(A/B/C 全量 27 类) / 参数校验 / 双状态机 / 冲突识别
|
||||
planner.py 方案生成器: 降仓凑额四档 / 升仓 / 建仓 / 清仓 / 行业 / 撤单
|
||||
recon.py 成交认领与入账映射 / 对账差异与修正 / 除权检测 / T+1 可用量
|
||||
rule_gate.py 规则闸终检: 上限/一手/可卖/冻结/刹车/行业/不追高 (减持只放行不阻拦)
|
||||
exec_timing.py 择时实现B: 分日配额 / 分笔 / 买卖出手判定 / 14:45 兜底 / 窗口收口
|
||||
tradedays.py 交易日历: 调度守卫与执行窗口计算
|
||||
db/session.py 三库连接 + **严格单表访问守卫** (JOIN/逗号连表/跨表子查询一律拒绝)
|
||||
repo/ 单表数据访问: pms_repo (自有 10 表) / downstream_repo (下游只读三表)
|
||||
|
|
@ -32,6 +34,8 @@ app/
|
|||
param_store.py 运行参数中心 (表值优先于 settings 初值, 页面调参即时生效)
|
||||
portfolio.py 组合快照 (账本+行情+行业 → 方案/规则闸/页面的统一输入)
|
||||
command_service.py 命令下达→校验→冲突→生效/规划→进度推进→撤销
|
||||
executor.py 方案→指令→分日出手→窗口收口 (规则闸与择时的编排落点)
|
||||
dispatcher.py 下发通道三适配器: shadow(默认) / plan_x / channel_y
|
||||
ledger_service.py 成交回放 / 对账 / 除权 / 盘前 / 日终结算 / 运营日报
|
||||
market.py 行情 (Redis db13) 与参考位 (决策系统主口径 + 兜底自算)
|
||||
industry.py 行业划分可插拔适配器 (custom_table / gp_stock_category / 停用)
|
||||
|
|
@ -41,7 +45,8 @@ scripts/
|
|||
run_tests.py 一次跑完全部单测 (见下方「Docker 部署」)
|
||||
test_core_units.py 仓位与安全垫核心逻辑 14 例
|
||||
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, 幂等, 默认演练)
|
||||
check_db.py 实机连通性与表结构自检 (需真实 .env)
|
||||
```
|
||||
|
|
@ -91,7 +96,7 @@ git pull && docker compose build && docker compose up -d
|
|||
| 盘前准备 | 交易日 08:50 | T+1 可卖重置 / 参考位取数 / 刹车结算 | ✅ |
|
||||
| 命令轮询 | 每 1 分钟(全天) | 新命令解析 → 方案生成 → 状态机推进 | ✅ |
|
||||
| 成交回放 | 交易时段每 5 分钟 | `trading_order` 增量回放 + 盘中轻对账 | ✅ |
|
||||
| 盘中执行 | 交易时段每 1 分钟 | 择时出手 + 自主提议扫描 | 🔜 下一批(当前只做在途指令超时收口) |
|
||||
| 盘中执行 | 交易时段每 1 分钟 | 方案转指令 → 择时出手(规则闸终检 → 下发 → 记子单) | ✅(自主提议扫描待下一批) |
|
||||
| 信号消化 | 交易时段每 1 分钟 | 订阅决策系统盘中信号 | 🔜 下一批 |
|
||||
| T 仓平回 | 14:50 | 做T强制平回 | 🔜 二期(现只自证 T 仓为 0) |
|
||||
| 日终结算 | 15:10 | 除权检测 / 全量对账 / 安全垫 / 命令进度日结 | ✅ |
|
||||
|
|
@ -99,11 +104,21 @@ git pull && docker compose build && docker compose up -d
|
|||
|
||||
调度器三条守卫:交易日守卫、故障即守成(任务内异常吞掉记 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 的现成材料。
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "自主指令作废")}
|
||||
|
|
@ -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}
|
||||
|
|
@ -132,10 +132,14 @@ def replay_fills():
|
|||
@celery_app.task(name="pms.intraday_exec")
|
||||
@guard(trade_day=True, session=True)
|
||||
def intraday_exec():
|
||||
"""盘中执行: 择时出手 + 自主提议扫描。**执行器与动作引擎为下一批交付**,
|
||||
本轮只做在途指令的超时收口 (设计 §13: 不自动重发)。"""
|
||||
from app.services import ledger_service
|
||||
return {"expired": ledger_service.expire_stale_instructions(), "executor": "pending_batch3"}
|
||||
"""盘中执行: 方案转指令 → 择时出手 (规则闸终检 → 下发 → 记子单)。
|
||||
|
||||
自主提议扫描 (FILL/ADD/DCA/TRIM 动作引擎) 仍为下一批交付。
|
||||
"""
|
||||
from app.services import executor
|
||||
r = {"materialized": executor.materialize_plans()}
|
||||
r.update(executor.run_tick())
|
||||
return r
|
||||
|
||||
|
||||
@celery_app.task(name="pms.signal_digest")
|
||||
|
|
@ -160,8 +164,18 @@ def t0_close():
|
|||
@celery_app.task(name="pms.daily_settle")
|
||||
@guard(trade_day=True, respect_exec_halt=False) # 对账属守成动作, 休假模式下照跑
|
||||
def daily_settle():
|
||||
from app.services import ledger_service
|
||||
return ledger_service.daily_settle()
|
||||
from app.services import executor, ledger_service
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -218,7 +218,9 @@ def plan_command(cmd: dict) -> dict:
|
|||
"ts_code": it["ts_code"], "action": it["action"], "qty": it.get("qty"),
|
||||
"amount": it.get("amount"), "priority": it.get("priority", 100),
|
||||
"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")})
|
||||
try:
|
||||
if rows:
|
||||
|
|
|
|||
|
|
@ -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}"}
|
||||
|
|
@ -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)
|
||||
|
|
@ -167,6 +167,28 @@ def self_calc_refs(ts_code: str, base_cost=None) -> dict:
|
|||
"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:
|
||||
"""参考位: 决策系统主口径 → 日龄超期/缺失时兜底自算 → 都拿不到返回 source=none。"""
|
||||
stale_days = param_store.get_int("PMS_REF_STALE_TDAYS", 3)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ DESC = {
|
|||
"PMS_EOD_FORCE_TIME": "当日配额兜底时点", "PMS_EOD_FORCE_DISCOUNT": "兜底限价系数 (卖出)",
|
||||
"PMS_MIN_LOT_MERGE": "一手检查: 批次自动合并",
|
||||
"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_BRAKE_DRAWDOWN": "组合刹车: 自高水位回撤", "PMS_BRAKE_DAYS": "刹车持续交易日",
|
||||
"PMS_STOP_ATR_MULT": "自算止损参考: 成本 − N×ATR",
|
||||
|
|
@ -223,6 +226,8 @@ def _range_check(key, v):
|
|||
return "PMS_AUTONOMY 只能是 full / propose_only / off"
|
||||
if key == "PMS_SECTOR_SOURCE" and v not in ("", "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)
|
||||
if lo_hi and isinstance(v, (int, float)) and not isinstance(v, bool):
|
||||
lo, hi = lo_hi
|
||||
|
|
|
|||
|
|
@ -282,6 +282,39 @@ def api_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")
|
||||
def api_downstream_schema():
|
||||
"""导出下游三表的实际列定义 —— 用于回填 QMT_INTERFACE_REQUIREMENTS D1。"""
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@
|
|||
<el-tag :type="ov.exec_halt ? 'danger':'info'" effect="dark" size="small">
|
||||
执行 {{ ov.exec_halt ? '已暂停':'正常' }}</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="opsDrawer=true">运维</el-button>
|
||||
<el-button size="small" type="primary" :loading="loading" @click="loadAll">刷新</el-button>
|
||||
|
|
@ -65,6 +67,8 @@
|
|||
:title="'以下持仓取不到实时价, 已用摊薄成本兜底: ' + ov.price_missing.join(', ')"/>
|
||||
<el-alert v-if="health.calendar_degraded" class="banner" type="warning" effect="dark" show-icon
|
||||
: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="m"><div class="k">总规模</div><div class="v">{{ money(ov.scale) }}</div></div>
|
||||
|
|
@ -290,20 +294,58 @@
|
|||
|
||||
<div class="panel">
|
||||
<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="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="qty" label="数量" width="90"/>
|
||||
<el-table-column prop="exec_qty" label="已成交" width="90"/>
|
||||
<el-table-column prop="limit_price" label="限价" width="90"/>
|
||||
<el-table-column prop="status" label="状态" width="120"/>
|
||||
<el-table-column prop="origin_type" label="来源" width="90"/>
|
||||
<el-table-column prop="updated_at" label="更新" width="160" class-name="muted"/>
|
||||
<el-table-column prop="qty" label="数量" width="80"/>
|
||||
<el-table-column prop="exec_qty" label="已成交" width="80"/>
|
||||
<el-table-column label="进度" width="130">
|
||||
<template #default="s">
|
||||
<el-progress :percentage="insPct(s.row)" :stroke-width="10" :text-inside="true"/>
|
||||
</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>
|
||||
<div class="muted" style="margin-top:6px">
|
||||
指令下发通道待 QMT 侧协商落地 (B1); 通道未通前减持类只记账不下发 (影子运行)。
|
||||
{{ dm.hint }} 展开可看每日子单与择时依据; 成交量以下游回放为准, 不拿下发量当成交量。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -367,14 +409,25 @@
|
|||
调度器 (--profile sched) 会自动按调度总表执行; 此处按钮用于未接调度或临时验证时手动触发。
|
||||
</div>
|
||||
<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('reconcile')" :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('report')" :loading="opsLoading">生成日报</el-button>
|
||||
<el-button @click="ops('plan-pending')" :loading="opsLoading">推进待规划命令</el-button>
|
||||
<el-button @click="loadSchema" :loading="opsLoading">导出下游表结构</el-button>
|
||||
</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>
|
||||
</el-drawer>
|
||||
|
||||
|
|
@ -404,7 +457,7 @@ createApp({
|
|||
const catalog = ref([]), commands = ref([]), plans = ref([]), plansOf = ref('');
|
||||
const positions = ref([]), lots = ref([]), lotsOf = 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 issuing = ref(false);
|
||||
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);
|
||||
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) {
|
||||
try {
|
||||
|
|
@ -491,12 +548,25 @@ createApp({
|
|||
lotsOf.value = code;
|
||||
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() {
|
||||
loading.value = true; err.value = '';
|
||||
await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(),
|
||||
loadPositions(), loadInstructions(), loadLedger(), loadProposals()]);
|
||||
loadPositions(), loadInstructions(), loadLedger(), loadProposals(),
|
||||
loadDispatchMode()]);
|
||||
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() {
|
||||
form.params = {};
|
||||
|
|
@ -585,10 +655,10 @@ createApp({
|
|||
onMounted(loadAll);
|
||||
return { tab, loading, err, health, ov, params, catalog, commands, plans, plansOf,
|
||||
positions, lots, lotsOf, instructions, ledger, proposals, report, reportDrawer,
|
||||
opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount,
|
||||
money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct,
|
||||
opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm,
|
||||
money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, insPct,
|
||||
loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue,
|
||||
cancelCmd, replan, decide, ops, loadSchema, openReport };
|
||||
cancelCmd, replan, decide, ops, loadSchema, openReport, cancelIns };
|
||||
}
|
||||
}).use(ElementPlus).mount('#app');
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ class Settings(BaseSettings):
|
|||
PMS_EOD_FORCE_DISCOUNT: float = 0.998 # 兜底限价 = 现价 × 此系数 (卖出)
|
||||
PMS_MIN_LOT_MERGE: bool = True # 一手检查: 批次自动合并
|
||||
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 # 单笔敞口告警线 (占规模)
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS pms_plan (
|
|||
priority INT NOT NULL DEFAULT 100 COMMENT '越小越先执行',
|
||||
deadline DATE NULL COMMENT '执行窗口截止日',
|
||||
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,
|
||||
reason VARCHAR(300) NULL COMMENT '进方案的理由 (弱票清仓/收利润/等比减 等)',
|
||||
created_at DATETIME NOT NULL,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
包含:
|
||||
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例)
|
||||
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例)
|
||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (18 例)
|
||||
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 (18 例)
|
||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (25 例)
|
||||
任一子集失败即整体失败 (退出码 1)。
|
||||
"""
|
||||
import os
|
||||
|
|
@ -16,7 +17,8 @@ import sys
|
|||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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_refs = lambda c, **kw: {"support": None, "pressure": None, "stop": 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 = lambda c: None
|
||||
industry.ready = lambda: False
|
||||
|
|
@ -708,6 +712,178 @@ def _():
|
|||
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
|
||||
def main():
|
||||
passed, failed = 0, 0
|
||||
|
|
|
|||
Loading…
Reference in New Issue