tradingSystem/app/services/executor.py

503 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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, qmt_repo
from app.services import (command_service, dispatcher, exec_advisor, 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 不再重复。
**三个列表全空必须能区分出是哪一种全空** (2026-07-31): 本函数只吃 PENDING 的方案,
而已转过指令的方案是 EXECUTING、等解锁的批是 GATED。三种情况输出一模一样都是
`{created: [], skipped: [], errors: []}`, 读起来像"一条都没转成", 实际多半是
"早就转完了"。加 `scanned` 与 `note` 把它说破 —— 这是同一类"失败长得像成功"的毛病,
只不过这里是"没事长得像出事", 一样会让人白排查半天。
"""
out = {"created": [], "skipped": [], "errors": [], "scanned": 0, "note": ""}
plans = pms_repo.list_plans(statuses=[PLAN_PENDING], limit=limit)
out["scanned"] = len(plans)
if not plans:
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 = 等动作引擎解锁的批")
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))
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
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=prog0)
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),
"advice_limit_band": param_store.get_float("PMS_EXEC_LIMIT_BAND", 0.10),
# 卖出分桶收口与紧急直通 (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),
}
slices = param_store.get_int("PMS_EXEC_SLICES", 1)
order_ttl = param_store.get_int("PMS_ORDER_TTL_MIN", 10) # 单个分片挂单有效期(交易分钟)
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"))
# 当日配额的「今日已投放量」口径分两种:
# 普通多日单 —— _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))
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")}
# 择时判定统一走 exec_advisor: PMS_EXEC_IMPL=B (默认) 时它就是 et.decide 原样;
# =A 时先过硬闸 (配额/兜底等 PMS 自留地), 再委托决策系统, 不可用退实现B。
# 咨询结论会写进 prog["exec_advice"], 随下面既有的 update_instruction 落表;
# dry_run 传 None —— 只算不落库, 试算不该占用/刷新研判缓存。
# is_command 必须一路传到 hard_gate: 窗口末日的强制完成只对命令驱动生效,
# 自主买入到期作废 (与 window_verdict 的口径对齐, 见 exec_timing 里那段说明)。
d = exec_advisor.decide(side=side, action=ins.get("action"), ts_code=code,
now=now, day=day_ctx, params=exec_prm,
is_last_day=is_last,
is_command=bool(prog.get("is_command")),
urgent=bool(prog.get("urgent")),
fired_today=fired_today,
quota=quota, pos=pos, tdays_left=tdays_left,
prog=(None if dry_run else prog))
if d["action"] != et.ACT_FIRE:
prog["last_decision"] = {"at": now.strftime("%H:%M"), **d}
# 参考位漂移 (只有新建仓会有) 在评审账本里记**一天一条**。
# 漂移一旦发生通常持续整天, 而这一跳是每分钟一次 —— 不去重的话一天能往账本
# 灌几百行一模一样的告警, 把有信息量的行淹掉 (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)
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")),
# 决策系统昨夜对该股的定性, 由择时应答带回 (退实现B 时为 None)
"y_signal": d.get("y_signal")}})
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
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 模式忽略这三项, 传着不碍事 —— 两种模式共用同一个调用点。
res = dispatcher.dispatch(
instruction_id=_child_id(ins["instruction_id"], len(children) + 1),
parent_id=ins["instruction_id"], intent=ins.get("action"),
ts_code=code, side=side, qty=qty, limit_price=d["limit_price"],
note=d.get("reason"),
valid_until=now.replace(hour=dl_min // 60, minute=dl_min % 60,
second=0, microsecond=0))
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"],
"valid_until": et._fmt(dl_min),
"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"], "source": d.get("source")}
# 这里曾经多传了一个 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"出口表与本端不一致")
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)
# side 必须传 (2026-08-17): 自主卖出窗口耗尽不作废 —— 信号清仓与保垫减仓
# 到期作废是把该降的风险留在账上, 见 window_verdict 里那段说明。
v = et.window_verdict(remaining_qty=remaining, tdays_left=left,
is_command=bool(prog.get("is_command")),
side=str(ins.get("side") or ""))
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")) 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 侧确认该委托是否还挂着"}
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 _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
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
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)