2026-07-28 09:10:07 +08:00
|
|
|
|
# -*- 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
|
2026-08-03 10:45:18 +08:00
|
|
|
|
from app.repo import pms_repo, qmt_repo
|
2026-08-03 12:49:01 +08:00
|
|
|
|
from app.services import (command_service, dispatcher, exec_advisor, industry, market,
|
|
|
|
|
|
param_store, portfolio)
|
2026-07-28 09:10:07 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-31 16:43:43 +08:00
|
|
|
|
"""把在途命令的可执行方案条目转成指令。幂等: 已生成过指令的方案置 EXECUTING 不再重复。
|
|
|
|
|
|
|
|
|
|
|
|
**三个列表全空必须能区分出是哪一种全空** (2026-07-31): 本函数只吃 PENDING 的方案,
|
|
|
|
|
|
而已转过指令的方案是 EXECUTING、等解锁的批是 GATED。三种情况输出一模一样都是
|
|
|
|
|
|
`{created: [], skipped: [], errors: []}`, 读起来像"一条都没转成", 实际多半是
|
|
|
|
|
|
"早就转完了"。加 `scanned` 与 `note` 把它说破 —— 这是同一类"失败长得像成功"的毛病,
|
|
|
|
|
|
只不过这里是"没事长得像出事", 一样会让人白排查半天。
|
|
|
|
|
|
"""
|
|
|
|
|
|
out = {"created": [], "skipped": [], "errors": [], "scanned": 0, "note": ""}
|
2026-07-28 09:10:07 +08:00
|
|
|
|
plans = pms_repo.list_plans(statuses=[PLAN_PENDING], limit=limit)
|
2026-07-31 16:43:43 +08:00
|
|
|
|
out["scanned"] = len(plans)
|
2026-07-28 09:10:07 +08:00
|
|
|
|
if not plans:
|
2026-07-31 16:43:43 +08:00
|
|
|
|
other = {}
|
|
|
|
|
|
for st in (PLAN_GATED, PLAN_EXEC, PLAN_DONE):
|
|
|
|
|
|
try:
|
|
|
|
|
|
other[st] = len(pms_repo.list_plans(statuses=[st], limit=limit))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
out["plans_by_status"] = other
|
|
|
|
|
|
out["note"] = (
|
|
|
|
|
|
"没有 PENDING 方案可转 —— 这**不是**失败。当前方案分布: "
|
|
|
|
|
|
+ (", ".join(f"{k} {v} 条" for k, v in other.items() if v) or "一条方案都没有")
|
|
|
|
|
|
+ "。EXECUTING = 已经转过指令了 (看 make t-ins); GATED = 等动作引擎解锁的批")
|
2026-07-28 09:10:07 +08:00
|
|
|
|
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))
|
2026-08-17 15:09:37 +08:00
|
|
|
|
prog0 = {"deadline": str(p.get("deadline") or ""), "command_id": cid,
|
|
|
|
|
|
"is_command": True, "children": []}
|
|
|
|
|
|
if cmd.get("cmd_type") == "LIQUIDATE_ALL":
|
|
|
|
|
|
# 一键清仓是 danger 级紧急命令, 方案注记写着"紧急, 不做择时优化" ——
|
|
|
|
|
|
# 这个语义从前没传到执行层, 紧急清仓照样等开盘半小时、等均价。
|
|
|
|
|
|
# urgent 标志由 hard_gate 消化: 直通出手、限价更激进 (2026-08-17)。
|
|
|
|
|
|
prog0["urgent"] = True
|
2026-07-28 09:10:07 +08:00
|
|
|
|
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,
|
2026-08-17 15:09:37 +08:00
|
|
|
|
progress=prog0)
|
2026-07-28 09:10:07 +08:00
|
|
|
|
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),
|
2026-08-03 12:49:01 +08:00
|
|
|
|
"advice_limit_band": param_store.get_float("PMS_EXEC_LIMIT_BAND", 0.10),
|
2026-08-17 15:09:37 +08:00
|
|
|
|
# 卖出分桶收口与紧急直通 (2026-08-17 清仓择时改造, 见 exec_timing._bucket_due)
|
|
|
|
|
|
"sell_bucket_times": param_store.get("PMS_SELL_BUCKET_TIMES", "11:30,14:00"),
|
|
|
|
|
|
"urgent_sell_discount": param_store.get_float("PMS_URGENT_SELL_DISCOUNT", 0.995),
|
2026-07-28 09:10:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
slices = param_store.get_int("PMS_EXEC_SLICES", 1)
|
2026-07-28 15:48:57 +08:00
|
|
|
|
order_ttl = param_store.get_int("PMS_ORDER_TTL_MIN", 10) # 单个分片挂单有效期(交易分钟)
|
2026-07-28 09:10:07 +08:00
|
|
|
|
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"))
|
2026-08-20 10:23:33 +08:00
|
|
|
|
# 当日配额的「今日已投放量」口径分两种:
|
|
|
|
|
|
# 普通多日单 —— _consumed_today (今日已成交 + 今日在途), 给多日出手节流用。
|
|
|
|
|
|
# 紧急单 / 窗口末日单 —— 只按「今日在途量」算 (_inflight_today)。这两类单的
|
|
|
|
|
|
# 配额本来就等于全部剩余量, 而剩余量 = 委托量 − 累计成交, 已经扣过成交一次;
|
|
|
|
|
|
# 若再从「已投放量」里把今日成交扣第二遍, 同一笔成交被扣两次, 当天成交越多,
|
|
|
|
|
|
# 「可再投放 = 配额 − 已投放」越快变成负数 —— 于是发一两笔、成交回来之后就误判
|
|
|
|
|
|
# 「当日配额已出完」, 当天不再补单, 连 14:45 强制兜底也被挡在这道判断前面,
|
|
|
|
|
|
# 要等次日配额清零才继续。这正是 2026-08-19 紧急清仓剩三支拖到次日早盘的根因。
|
|
|
|
|
|
# 只按在途量扣: 可再投放 = 剩余 − 在途, 永不重复扣, 紧急清仓当天会持续补单,
|
|
|
|
|
|
# 直到卖完或撞上 T+1 可卖为零 (可卖量那道闸在下面单独兜, 不会超卖/重复下单)。
|
|
|
|
|
|
is_urgent = bool(prog.get("urgent"))
|
|
|
|
|
|
fired_today = (_inflight_today(children, ymd_today) if (is_urgent or is_last)
|
|
|
|
|
|
else _consumed_today(children, ymd_today))
|
2026-07-28 09:10:07 +08:00
|
|
|
|
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")}
|
2026-08-03 12:49:01 +08:00
|
|
|
|
# 择时判定统一走 exec_advisor: PMS_EXEC_IMPL=B (默认) 时它就是 et.decide 原样;
|
|
|
|
|
|
# =A 时先过硬闸 (配额/兜底等 PMS 自留地), 再委托决策系统, 不可用退实现B。
|
|
|
|
|
|
# 咨询结论会写进 prog["exec_advice"], 随下面既有的 update_instruction 落表;
|
|
|
|
|
|
# dry_run 传 None —— 只算不落库, 试算不该占用/刷新研判缓存。
|
2026-08-06 15:25:39 +08:00
|
|
|
|
# is_command 必须一路传到 hard_gate: 窗口末日的强制完成只对命令驱动生效,
|
|
|
|
|
|
# 自主买入到期作废 (与 window_verdict 的口径对齐, 见 exec_timing 里那段说明)。
|
2026-08-03 12:49:01 +08:00
|
|
|
|
d = exec_advisor.decide(side=side, action=ins.get("action"), ts_code=code,
|
|
|
|
|
|
now=now, day=day_ctx, params=exec_prm,
|
2026-08-06 15:25:39 +08:00
|
|
|
|
is_last_day=is_last,
|
|
|
|
|
|
is_command=bool(prog.get("is_command")),
|
2026-08-17 15:09:37 +08:00
|
|
|
|
urgent=bool(prog.get("urgent")),
|
2026-08-06 15:25:39 +08:00
|
|
|
|
fired_today=fired_today,
|
2026-08-03 12:49:01 +08:00
|
|
|
|
quota=quota, pos=pos, tdays_left=tdays_left,
|
|
|
|
|
|
prog=(None if dry_run else prog))
|
2026-07-28 09:10:07 +08:00
|
|
|
|
|
|
|
|
|
|
if d["action"] != et.ACT_FIRE:
|
|
|
|
|
|
prog["last_decision"] = {"at": now.strftime("%H:%M"), **d}
|
2026-08-06 13:55:27 +08:00
|
|
|
|
# 参考位漂移 (只有新建仓会有) 在评审账本里记**一天一条**。
|
|
|
|
|
|
# 漂移一旦发生通常持续整天, 而这一跳是每分钟一次 —— 不去重的话一天能往账本
|
|
|
|
|
|
# 灌几百行一模一样的告警, 把有信息量的行淹掉 (2026-07-29 那个教训)。
|
|
|
|
|
|
# 想看当下状态去指令的 progress.last_decision, 那里每跳都有; 账本这一条是
|
|
|
|
|
|
# 留给事后判分的「这一天该票因为输入漂移没建成仓」。
|
|
|
|
|
|
if (not dry_run and d.get("ref_drift")
|
|
|
|
|
|
and int(prog.get("ref_drift_logged") or 0) != ymd_today):
|
|
|
|
|
|
prog["ref_drift_logged"] = ymd_today
|
|
|
|
|
|
try:
|
|
|
|
|
|
pms_repo.insert_ledger(
|
|
|
|
|
|
ts_code=code, action=ins.get("action"), arbiter="rule",
|
|
|
|
|
|
verdict="WARN", price_at=day_ctx.get("price") or 0,
|
|
|
|
|
|
hard_numbers={"ref_lock": prog.get("ref_lock"),
|
|
|
|
|
|
"advice": (prog.get("exec_advice") or {})},
|
|
|
|
|
|
ref_id=ins["instruction_id"], reason=d["ref_drift"])
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error("[参考位漂移] 留痕写入失败 %s: %s —— "
|
|
|
|
|
|
"本次漂移只剩指令 progress 里那一份, 账本查不到", code, e)
|
2026-07-28 09:10:07 +08:00
|
|
|
|
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")),
|
2026-08-05 11:27:43 +08:00
|
|
|
|
"is_command": bool(prog.get("is_command")),
|
|
|
|
|
|
# 决策系统昨夜对该股的定性, 由择时应答带回 (退实现B 时为 None)
|
|
|
|
|
|
"y_signal": d.get("y_signal")}})
|
2026-07-28 09:10:07 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-28 15:48:57 +08:00
|
|
|
|
dl_min = et.slice_deadline(now, ttl_min=order_ttl, forced=d.get("forced", False))
|
|
|
|
|
|
# intent / parent_id / note 是 ws 通道要的 (协议 §4.2): intent 只作归类,
|
|
|
|
|
|
# parent_id 让子单能回指父指令, note 原样落到 QMT 侧供人工看盘。
|
|
|
|
|
|
# shadow 模式忽略这三项, 传着不碍事 —— 两种模式共用同一个调用点。
|
2026-07-28 09:10:07 +08:00
|
|
|
|
res = dispatcher.dispatch(
|
|
|
|
|
|
instruction_id=_child_id(ins["instruction_id"], len(children) + 1),
|
2026-07-28 15:48:57 +08:00
|
|
|
|
parent_id=ins["instruction_id"], intent=ins.get("action"),
|
2026-07-28 09:10:07 +08:00
|
|
|
|
ts_code=code, side=side, qty=qty, limit_price=d["limit_price"],
|
2026-07-28 15:48:57 +08:00
|
|
|
|
note=d.get("reason"),
|
|
|
|
|
|
valid_until=now.replace(hour=dl_min // 60, minute=dl_min % 60,
|
|
|
|
|
|
second=0, microsecond=0))
|
2026-07-28 09:10:07 +08:00
|
|
|
|
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"],
|
2026-07-28 15:48:57 +08:00
|
|
|
|
"valid_until": et._fmt(dl_min),
|
2026-07-28 09:10:07 +08:00
|
|
|
|
"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,
|
2026-08-03 12:49:01 +08:00
|
|
|
|
"reason": d["reason"], "source": d.get("source")}
|
2026-08-03 10:28:09 +08:00
|
|
|
|
# 这里曾经多传了一个 limit_price=None (2026-08-03 实机): 真 repo 的
|
|
|
|
|
|
# update_instruction 没有这个形参 → TypeError → 被本函数外层的
|
|
|
|
|
|
# `except Exception` 吞成 out["errors"] 里的一条。后果是**单子已经发到 QMT
|
|
|
|
|
|
# 了, 而本端一个字没记**: 父指令停在 PROPOSED、children 空、dispatch_ref 空,
|
|
|
|
|
|
# 于是当日配额恒按 0 算, 下一跳会拿同一个子单号 _D01 再发一次 —— 出口表唯一
|
|
|
|
|
|
# 索引拦住之后就彻底卡死。单测全程没看见, 因为 FakeRepo.update_instruction
|
|
|
|
|
|
# 是 `**kw` 全收, 比真依赖宽松 (见 test_batch10 的 [M] 组)。
|
|
|
|
|
|
r = pms_repo.update_instruction(ins["instruction_id"], status=ST_DISPATCHED,
|
|
|
|
|
|
dispatch_ref=res["ref"], progress=prog)
|
|
|
|
|
|
if not r:
|
|
|
|
|
|
# 单子已经出去了, 这里回滚不了; 但绝不能让它悄悄过去
|
|
|
|
|
|
logger.error("[出手] %s 已下发到通道, 但父指令没更新到任何行 —— "
|
|
|
|
|
|
"本端与出口表将不一致, 请核对 pms_instruction",
|
|
|
|
|
|
ins["instruction_id"])
|
|
|
|
|
|
out["errors"].append(
|
|
|
|
|
|
f"{ins['instruction_id']} 已下发但父指令未更新 (影响 0 行) —— "
|
|
|
|
|
|
f"出口表与本端不一致")
|
2026-07-28 09:10:07 +08:00
|
|
|
|
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)
|
2026-08-17 15:09:37 +08:00
|
|
|
|
# side 必须传 (2026-08-17): 自主卖出窗口耗尽不作废 —— 信号清仓与保垫减仓
|
|
|
|
|
|
# 到期作废是把该降的风险留在账上, 见 window_verdict 里那段说明。
|
2026-07-28 09:10:07 +08:00
|
|
|
|
v = et.window_verdict(remaining_qty=remaining, tdays_left=left,
|
2026-08-17 15:09:37 +08:00
|
|
|
|
is_command=bool(prog.get("is_command")),
|
|
|
|
|
|
side=str(ins.get("side") or ""))
|
2026-07-28 09:10:07 +08:00
|
|
|
|
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']}, 不可撤销"}
|
2026-07-31 16:10:58 +08:00
|
|
|
|
r = dispatcher.cancel(instruction_id=instruction_id,
|
|
|
|
|
|
dispatch_ref=ins.get("dispatch_ref")) or {}
|
|
|
|
|
|
# **下游没撤成就不能在本端标 CANCELLED。** 原来 r 拿到手却从不检查, 一律回「已撤销」
|
|
|
|
|
|
# 并把父指令置终态 —— 而终态之后没有任何东西再跟踪它, 下游那张委托继续挂着、继续成交,
|
|
|
|
|
|
# 成交回来还会因为找不到在途父指令而变成孤儿。撤不掉就保持在途, 让它继续被择时/收口/
|
|
|
|
|
|
# 对账看见, 由人再处理。
|
|
|
|
|
|
if not r.get("ok"):
|
|
|
|
|
|
logger.error("[撤单] 下游未受理 %s: %s —— 指令保持在途, 下游委托可能仍挂着",
|
|
|
|
|
|
instruction_id, r.get("error") or r)
|
|
|
|
|
|
return {"ok": False, "downstream": r, "instruction_id": instruction_id,
|
|
|
|
|
|
"message": f"指令 {instruction_id} **未能撤销**: "
|
|
|
|
|
|
f"{r.get('error') or '下游未受理'} —— 指令仍在途, "
|
|
|
|
|
|
f"请在 QMT 侧确认该委托是否还挂着"}
|
2026-07-28 09:10:07 +08:00
|
|
|
|
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}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 10:45:18 +08:00
|
|
|
|
def _consumed_today(children: list, ymd_today: int) -> int:
|
|
|
|
|
|
"""今日已投放量 —— **到期/被撤/被拒且一股没成的分片不算数**。
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 实机: 002518.SZ 的分片挂到 TTL 到期没成交, 当天配额却显示「已出完」,
|
|
|
|
|
|
一股没买到而当日不再尝试。这跟 `exec_timing.slice_deadline` 自己写的设计本意直接
|
|
|
|
|
|
冲突 —— 那里说得很清楚:「普通分片只给 ttl_min 个交易分钟, run_tick 每分钟重评一次,
|
|
|
|
|
|
**撤掉重下比挂着更好**」。可只要作废的分片仍占着当日额度, 重下就永远轮不上。
|
|
|
|
|
|
|
|
|
|
|
|
配额的语义是「今天投放多少」, 但**已经作废且零成交的投放不该占额度** —— 那笔钱没花
|
|
|
|
|
|
出去, 敞口也没建立, 它什么都没发生。所以按委托的真实结局算:
|
|
|
|
|
|
* 还在途 (未终态) → 按下发量算, 它随时可能成交
|
|
|
|
|
|
* 已终态 → 只算真正成交的 cum_qty, 未成交部分释放回配额
|
|
|
|
|
|
* 查不到出口行 → 按下发量算 (影子模式没有出口行; 宁可少投也不重复投)
|
|
|
|
|
|
"""
|
|
|
|
|
|
total = 0
|
|
|
|
|
|
for c in children or []:
|
|
|
|
|
|
if int(c.get("ymd") or 0) != ymd_today:
|
|
|
|
|
|
continue
|
|
|
|
|
|
qty = int(c.get("qty") or 0)
|
|
|
|
|
|
ref = c.get("ref")
|
|
|
|
|
|
if not ref or str(ref).startswith("manual:"):
|
|
|
|
|
|
total += qty # 影子模式: 没有出口行可查
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
o = qmt_repo.get_order(ref)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.warning("[配额] 反查出口行失败 %s, 本轮按下发量计入", ref)
|
|
|
|
|
|
total += qty
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not o:
|
|
|
|
|
|
total += qty
|
|
|
|
|
|
continue
|
|
|
|
|
|
if str(o.get("status") or "").upper() in qmt_repo.FINAL:
|
|
|
|
|
|
done = int(o.get("cum_qty") or 0)
|
|
|
|
|
|
if done < qty:
|
|
|
|
|
|
logger.info("[配额] %s 已终态 %s, 成交 %s/%s —— 未成交部分释放回当日配额",
|
|
|
|
|
|
ref, o.get("status"), done, qty)
|
|
|
|
|
|
total += done
|
|
|
|
|
|
else:
|
|
|
|
|
|
total += qty
|
|
|
|
|
|
return total
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-20 10:23:33 +08:00
|
|
|
|
def _inflight_today(children: list, ymd_today: int) -> int:
|
|
|
|
|
|
"""今日已下发、但**还没有最终结局**的量 (仍挂在券商, 随时可能成交)。
|
|
|
|
|
|
|
|
|
|
|
|
与 _consumed_today 的差别只有一处: **已终态的委托一律算 0**, 不再把已成交的 cum_qty
|
|
|
|
|
|
计进来。用途见 run_tick 里那段说明 —— 紧急单 / 窗口末日单的「可再投放」= 剩余 − 在途,
|
|
|
|
|
|
而剩余量已经扣过累计成交, 所以这里绝不能再把今日成交算进「已投放」, 否则同一笔成交
|
|
|
|
|
|
被扣两次。三种结局的处理:
|
|
|
|
|
|
* 仍在途 (未终态) → 按下发量算 (它随时可能成交, 不该重复下单占掉这份量)
|
|
|
|
|
|
* 已终态 (成交/被拒/过期) → 算 0 (成交的已进 exec_qty→剩余量; 被拒/过期的一股没成)
|
|
|
|
|
|
* 查不到出口行 / 影子模式 → 按下发量算 (保守当在途, 宁可少投也不重复投)
|
|
|
|
|
|
"""
|
|
|
|
|
|
total = 0
|
|
|
|
|
|
for c in children or []:
|
|
|
|
|
|
if int(c.get("ymd") or 0) != ymd_today:
|
|
|
|
|
|
continue
|
|
|
|
|
|
qty = int(c.get("qty") or 0)
|
|
|
|
|
|
ref = c.get("ref")
|
|
|
|
|
|
if not ref or str(ref).startswith("manual:"):
|
|
|
|
|
|
total += qty
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
o = qmt_repo.get_order(ref)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.warning("[配额] 反查出口行失败 %s, 本轮按在途计入", ref)
|
|
|
|
|
|
total += qty
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not o:
|
|
|
|
|
|
total += qty
|
|
|
|
|
|
continue
|
|
|
|
|
|
if str(o.get("status") or "").upper() in qmt_repo.FINAL:
|
|
|
|
|
|
continue # 已终态: 成交的已在剩余量里, 被拒/过期的不占在途
|
|
|
|
|
|
total += qty # 仍挂在券商, 算在途
|
|
|
|
|
|
return total
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 09:10:07 +08:00
|
|
|
|
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)
|