处理命令冲突的问题

This commit is contained in:
zlt 2026-08-20 11:09:33 +08:00
parent be92b2be8a
commit 9a1b93a6d2
6 changed files with 216 additions and 38 deletions

View File

@ -30,6 +30,40 @@
--- ---
## 2026-08-20· 清仓与减仓对策略的优先级理顺:减仓不掐网格、清仓补撤在途买单、在途文案分开
**背景**
上一节紧急清仓修复留了三笔尾账这次结掉前两笔。同时按用户要求排查一件更要紧的事有没有哪里会让网格建仓、做T这类个股策略被误挡失效。用户给了判定尺子——清仓是优先级最高的可以覆盖一切减仓则要看情况默认不该动策略。全部改动都在持仓系统的执行层与命令层其他系统不动。
**做了什么(三处,都带单测)**
减仓命令不再撤掉策略腿的在途买单。原来整体降仓命令会把所有在途买入指令一律撤掉其中就包括网格和做T挂出的买单等于一执行减仓、网格就被掐断。现在减仓只撤非策略的在途买入自主、提议、命令三种照撤因为减仓时确实不该再往里投新钱策略腿留着继续跑。清仓类命令仍然全撤、一股不留因为清仓优先级最高。补一个理由减仓命令只物化一次就算撤了策略的在途买单策略下一分钟又照发一张撤了等于没撤只是白撤一笔、还在页面上报“撤了几条策略买单”误导人。
清仓某股和清仓某行业补上撤在途买单这一层。上一节给三类清仓命令都加了即时撤策略但只有一键清仓的方案生成器会顺带把在途买单排进撤单项。清仓某股和清仓某行业的方案生成器只排一条卖出不排撤买单。结果是撤策略只改了策略状态已经下发到券商的那张网格或做T买单还挂着继续成交——你正清仓这只票它却又买进来一笔当天卖不掉的货。现在即时停买入侧这一步里对被清仓的票直接撤掉当前在途买入指令。一键清仓时这些单多半已被撤过、不在在途集里补这道只是空跑幂等无害。
三,把“已全部在途、等成交”和“当日配额已出完”这两句话分开。上一节修好配额口径之后,紧急清仓和窗口末日单在“该出的量都挂在券商正等成交”这种情形下,仍复用“当日配额已出完”这句话,行为对但措辞会让人误以为又犯了老毛病。现在紧急单和末日单在可再投放为零时,改报“已全部在途,等成交回来”;普通多日单才报“当日配额已出完”。两条择时路径(内置择时的硬闸、策略即时决策)各改一处,用同一个函数出这句话,口径不串。
**排查结论网格建仓与做T有没有被误挡**
通读了策略层和规则闸,结论是没有系统性失效,但有两处值得拍板的地方,这次先不擅自改,列出来等你定。
先说没问题的三点。昨夜决策系统看空某只票不会挡住这只票的网格买入因为策略即时那条路本就不带看空定性网格照常逐档买。宏观偏热闸和买入暂停都只挡买入开腿卖出、平回、跟踪止盈一律照常符合设计。做T的平回腿在熔断和买入暂停下都放行不会把一条开着的T仓卡住。
待定第一处组合刹车熔断触发时网格的买入腿会被规则闸挡下。原因是网格买单在系统里标为“非命令”规则闸对非命令的自主增持在刹车期一律拦对命令只提示不拦。可网格恰恰是逢跌买入跌得狠正是刹车最容易触发的时候于是刹车一响、网格买入全停。做T不受这条影响因为做T标为命令、刹车只对它提示。问题是网格算“自主”还是算“你特意下的命令”。若你认为网格也该像做T那样在刹车期只提示不拦我再改这是安全阀的行为改动不敢擅动。
待定第二处全局暂停买入你手动踩的买入总闸打开时做T的买回腿也会被规则闸挡下。反T是先卖后买、要靠买回来平掉买回被挡就意味着这一轮T只卖不买、把底仓卖出去没接回来连14:50强制平回也会被同一道闸挡住。这多半是你的本意——既然全局停买就连做T买回也别买但副作用是反T会留下没接回的敞口。要不要给做T的平回腿在全局暂停买入下开个口子请你定。
**拍板2026-08-20**上面两处都保持现状不改。刹车熔断照挡网格买入腿——刹车是组合级熔断网格逢跌买入正撞在最该拦的时候先不放开更稳。全局暂停买入照挡做T买回腿——买入总闸的语义要一致代价是反T会留一小截没接回的敞口属已知取舍。两处都无需改码当前行为即最终行为。
**动了哪些文件**
app/core/exec_timing.py新增 quota_wait_reason 出“在途/配额已出完”两句话,硬闸 left<=0 分支改用它app/services/exec_advisor.py策略即时决策同样按在途口径出话签名加 is_last_day 与 urgent 透传app/services/command_service.py减仓走 _pending_buys(exclude_strategy=True) 剔策略腿_stop_buyside_for_exit 补撤在途买单一层,进度回执多一项 instructionsscripts/test_batch15_units.py原 7 例扩到 12 例新增文案拆分四例、减仓剔策略腿一例原撤策略例加断言撤在途买单scripts/run_tests.pybatch15 计数改 12总数 510
**部署方式**
桥机 factorevaluation 上 make deploy源码打进镜像必须重建容器make test 见 ALL SUITES PASS已含改后 batch15。收盘后部署让下一次清仓或减仓发生在你在场时。不新增参数不动 .env。
**真机判收**
未判收。开发容器全量单测 ALL SUITES PASS510 例batch15 由 7 例扩到 12 例)。判收建议:下达减仓命令后,挂着策略的票在命令进度回执 stopped_buyside 里不再报撤策略买单,网格继续按档买卖;对某只挂策略的票下清仓某股,命令进度回执 stopped_buyside.instructions 里能看到那张在途买单被撤;盯盘时“已全部在途、等成交回来”与“当日配额已出完”两句话分得开。
**还欠着什么**
上面两处待定刹车挡网格买、全局暂停买入挡做T买回已于当日拍板都保持现状不改无需改码详见上面“拍板”一段。二减仓是否要更进一步、连策略本身也暂停而不只是不撤在途买单本次按“默认不动策略”处理若你想要“减仓也暂停这些票的策略买入开腿”这种中间档说一声再加。三上一节欠的第三笔——持仓系统侧可卖量与券商侧可用持仓偶有不一致出口委托里有 INSUFFICIENT_POSITION 拒单),仍未动,属对账口径,单独排查。
---
## 2026-08-20 · 紧急清仓修复:配额不重复扣今日成交 / 清仓命令即时撤策略 ## 2026-08-20 · 紧急清仓修复:配额不重复扣今日成交 / 清仓命令即时撤策略
**背景** **背景**

View File

@ -150,6 +150,19 @@ def _bucket_due(*, now_min: int, quota: int, fired: int, params: dict, eod_min:
f"累计应出 {due} 股、实出 {int(fired or 0)} 股, 补齐 {qty}"} f"累计应出 {due} 股、实出 {int(fired or 0)} 股, 补齐 {qty}"}
def quota_wait_reason(urgent: bool = False, is_last_day: bool = False) -> str:
"""left<=0 (可再投放为零) 这一句该怎么说 —— 同一个数, 两种完全相反的情形:
* 紧急单 / 窗口末日单: 今日已投放量**在途量** (run_tick _inflight_today),
于是 left=0 的含义是该出的量全挂在券商正等成交回来, **不是**今天不再出手
* 普通多日单: 今日已投放量按今日成交+在途算 (_consumed_today), left=0 才是真的
当日节流到顶今天不再补
行为两者一样 ( ACT_WAIT), 但话必须分开 2026-08-03/08-19 两次盘中都是盯着
当日配额已出完这一句反复排查, 真相却是单子正挂着等成交措辞分开, 一眼看得出下场"""
return "已全部在途, 等成交回来" if (urgent or is_last_day) else "当日配额已出完"
def hard_gate(*, side: str, now, day: dict, params: dict, is_last_day: bool, def hard_gate(*, side: str, now, day: dict, params: dict, is_last_day: bool,
is_command: bool, fired_today: int = 0, quota: int = 0, is_command: bool, fired_today: int = 0, quota: int = 0,
urgent: bool = False): urgent: bool = False):
@ -182,7 +195,7 @@ def hard_gate(*, side: str, now, day: dict, params: dict, is_last_day: bool,
if day.get("halted"): if day.get("halted"):
return out(ACT_SKIP, "停牌, 当日跳过顺延") return out(ACT_SKIP, "停牌, 当日跳过顺延")
if left <= 0: if left <= 0:
return out(ACT_WAIT, "当日配额已出完") return out(ACT_WAIT, quota_wait_reason(urgent, is_last_day))
if not in_session(now_min): if not in_session(now_min):
return out(ACT_WAIT, "非交易时段") return out(ACT_WAIT, "非交易时段")

View File

@ -271,9 +271,10 @@ def plan_command(cmd: dict) -> dict:
if cancel_failed: if cancel_failed:
notes.insert(0, f"{len(cancel_failed)} 条在途指令**没撤掉, 仍在下游挂着**: " notes.insert(0, f"{len(cancel_failed)} 条在途指令**没撤掉, 仍在下游挂着**: "
+ "; ".join(f"{x['instruction_id']}({x['why']})" for x in cancel_failed[:5])) + "; ".join(f"{x['instruction_id']}({x['why']})" for x in cancel_failed[:5]))
if stop_r.get("strategies") or stop_r.get("proposals"): if stop_r.get("strategies") or stop_r.get("proposals") or stop_r.get("instructions"):
notes.insert(0, f"清仓覆盖冲突: 已撤策略 {len(stop_r['strategies'])} 条、" notes.insert(0, f"清仓覆盖冲突: 已撤策略 {len(stop_r['strategies'])} 条、"
f"驳回买入提议 {len(stop_r['proposals'])} 条 (停止边卖边买)") f"驳回买入提议 {len(stop_r['proposals'])} 条、"
f"撤在途买单 {len(stop_r.get('instructions') or [])} 条 (停止边卖边买)")
if stop_r.get("errors"): if stop_r.get("errors"):
notes.insert(0, f"⚠ 停买入侧有 {len(stop_r['errors'])} 处未成: " notes.insert(0, f"⚠ 停买入侧有 {len(stop_r['errors'])} 处未成: "
+ "; ".join(str(x) for x in stop_r["errors"][:5])) + "; ".join(str(x) for x in stop_r["errors"][:5]))
@ -336,9 +337,13 @@ def _dispatch_planner(cmd_type: str, p: dict, cmd: dict) -> dict:
exclude = _codes_with_live_plans() exclude = _codes_with_live_plans()
if cmd_type == "REDUCE_EXPOSURE": if cmd_type == "REDUCE_EXPOSURE":
# 减仓不撤策略腿在途买单 (exclude_strategy=True): 整体降仓 ≠ 停网格/做T (设计: 清仓
# 才最高优先级、覆盖一切; 减仓看情况、默认不动策略)。非策略的在途买入 (自主/提议/命令)
# 照撤 —— 正在减仓就别再往里投新钱。
return pl.plan_reduce_exposure( return pl.plan_reduce_exposure(
release_amount=scale * float(p["pct"]), positions=positions, release_amount=scale * float(p["pct"]), positions=positions,
pending_buys=_pending_buys(), params={"weak_neg_days": sp["weak_neg_days"]}, pending_buys=_pending_buys(exclude_strategy=True),
params={"weak_neg_days": sp["weak_neg_days"]},
exclude_codes=exclude) exclude_codes=exclude)
if cmd_type == "INCREASE_EXPOSURE": if cmd_type == "INCREASE_EXPOSURE":
@ -511,12 +516,25 @@ def refresh_progress(command_id=None) -> dict:
# ================================================================ 内部助手 # ================================================================ 内部助手
def _pending_buys() -> list: def _pending_buys(exclude_strategy: bool = False) -> list:
"""在途买入指令 (供 planner 生成撤单项)。
exclude_strategy=True **剔除策略腿** (origin_type='strategy') 减仓命令用:
整体降仓是把仓位压下来, 不该顺手把你为个股特意设的网格 / 做T 一并掐掉设计铁律里
清仓 (LIQUIDATE_ALL / EXIT_STOCK / SECTOR_EXIT) 才是最高优先级覆盖一切 (那几条仍
传默认 False, 策略连同在途买单一律撤); 减仓看情况默认不动策略
另一层原因: 减仓只发一次 (命令物化一次), 就算这里撤了策略的在途买单, 策略下一跳
又照发一张 撤了等于没撤, 只是白撤一笔还在页面上报"撤了 N 条策略买单"误导人"""
rows = pms_repo.list_instructions(statuses=list(LIVE_INSTR), side="buy", limit=500) rows = pms_repo.list_instructions(statuses=list(LIVE_INSTR), side="buy", limit=500)
return [{"instruction_id": r["instruction_id"], "ts_code": r["ts_code"], out = []
for r in rows:
if exclude_strategy and r.get("origin_type") == "strategy":
continue
out.append({"instruction_id": r["instruction_id"], "ts_code": r["ts_code"],
"qty": int(r.get("qty") or 0), "qty": int(r.get("qty") or 0),
"amount": float(r.get("qty") or 0) * float(r.get("limit_price") or 0), "amount": float(r.get("qty") or 0) * float(r.get("limit_price") or 0),
"side": "buy"} for r in rows] "side": "buy", "origin_type": r.get("origin_type")})
return out
def _pending_instructions() -> list: def _pending_instructions() -> list:
@ -675,11 +693,18 @@ def _ledger_rejects(rejects: list, command_id: str):
def _stop_buyside_for_exit(codes: list, command_id: str) -> dict: def _stop_buyside_for_exit(codes: list, command_id: str) -> dict:
"""全额清仓类命令 (一键清仓 / 清仓某股 / 清仓某行业) 下达时, 立刻停掉这些票的买入侧。 """全额清仓类命令 (一键清仓 / 清仓某股 / 清仓某行业) 下达时, 立刻停掉这些票的买入侧。
撤活跃策略 (网格/做T/跟踪) + 驳回待确认的买入提议在途买单由 planner HALT 撤单项 三件事: 撤活跃策略 (网格/做T/跟踪) + 驳回待确认的买入提议 + **撤这些票当前在途的买入指令**
_cancel_marked_instructions 另行撤销, 这里补策略与提议两层 三层合起来才真正做到
命令一下, 这只票不再有任何新的买入幂等不抛异常: 单只失败记进 errors, 不拖垮 在途买单这一层原来只有一键清仓兜得住 它的 planner 会给所有在途买入排 HALT 撤单项,
命令本体 (故障即守成)撤策略只改策略状态不动持仓, 安全""" _cancel_marked_instructions 撤掉但清仓某股 (EXIT_STOCK) / 清仓某行业 (SECTOR_EXIT)
out = {"strategies": [], "proposals": [], "errors": []} planner **不传 pending_buys**, 只排一条卖出, 于是撤策略只改了策略状态**已经下发到
券商的那张网格/做T 买单还挂着继续成交** 你正清仓这只票, 它却又买进来一笔 T+1 锁死的货
所以这里对被清仓的票再补一道: 直接撤掉当前在途买入指令一键清仓时这些单多半已被 HALT
撤过不在在途集里, 补这道也只是空跑, 幂等无害
幂等不抛异常: 单只失败记进 errors, 不拖垮命令本体 (故障即守成)撤策略只改策略状态
撤在途买单走 executor.cancel_instruction (下游没撤成不硬标终态), 都不动持仓, 安全"""
out = {"strategies": [], "proposals": [], "instructions": [], "errors": []}
codeset = {c for c in (codes or []) if c} codeset = {c for c in (codes or []) if c}
if not codeset: if not codeset:
return out return out
@ -719,9 +744,30 @@ def _stop_buyside_for_exit(codes: list, command_id: str) -> dict:
out["proposals"].append(p["proposal_id"]) out["proposals"].append(p["proposal_id"])
except Exception as e: except Exception as e:
out["errors"].append(f"{p.get('ts_code')} 驳回买入提议失败: {type(e).__name__}: {e}") out["errors"].append(f"{p.get('ts_code')} 驳回买入提议失败: {type(e).__name__}: {e}")
if out["strategies"] or out["proposals"]: # 第三层: 撤这些票当前在途的买入指令 (EXIT_STOCK/SECTOR_EXIT 的 planner 不排 HALT, 全靠这里)。
logger.warning("[命令] %s 清仓覆盖冲突: 撤策略 %s, 驳回买入提议 %s", # 只查当前在途买入, 撤过的/已终态的不会回到这个集里, 所以一键清仓已 HALT 掉的这里空跑、不重撤。
command_id, out["strategies"], out["proposals"]) from app.services import executor
try:
live_buys = [i for i in pms_repo.list_instructions(statuses=list(LIVE_INSTR), side="buy",
limit=500)
if i.get("ts_code") in codeset]
except Exception as e:
live_buys = []
out["errors"].append(f"读在途买单失败: {type(e).__name__}: {e}")
for i in live_buys:
iid = i.get("instruction_id")
try:
r = executor.cancel_instruction(iid, reason="清仓命令下达: 撤该票在途买入 (命令覆盖冲突)") or {}
if r.get("ok"):
out["instructions"].append(iid)
else:
# 下游没撤成: 指令仍在途, 单独列出来别吞 (与 _cancel_marked_instructions 同纪律)
out["errors"].append(f"{i.get('ts_code')} 撤在途买单 {iid} 未成: {r.get('error') or r.get('message')}")
except Exception as e:
out["errors"].append(f"{i.get('ts_code')} 撤在途买单 {iid} 失败: {type(e).__name__}: {e}")
if out["strategies"] or out["proposals"] or out["instructions"]:
logger.warning("[命令] %s 清仓覆盖冲突: 撤策略 %s, 驳回买入提议 %s, 撤在途买单 %s",
command_id, out["strategies"], out["proposals"], out["instructions"])
return out return out

View File

@ -72,10 +72,15 @@ def _post(url: str, payload: dict, timeout: int) -> dict:
return r.json() or {} return r.json() or {}
def _strategy_immediate(*, side, now, day, quota, fired_today) -> dict: def _strategy_immediate(*, side, now, day, quota, fired_today,
is_last_day: bool = True, urgent: bool = False) -> dict:
"""网格/跟踪止盈的到价即成交决策: 只做硬事实检查(配额/时段/停牌/一字板), 不做 vwap 择时, """网格/跟踪止盈的到价即成交决策: 只做硬事实检查(配额/时段/停牌/一字板), 不做 vwap 择时,
不做 14:45 强制真正的合规(单股上限/T+1/暂停买入等) run_tick 随后的 rule_gate 终检兜底 不做 14:45 强制真正的合规(单股上限/T+1/暂停买入等) run_tick 随后的 rule_gate 终检兜底
这样网格是"到档位价才成交、当日没成交则由窗口收口作废", 不会像命令那样被收盘强平成市价单""" 这样网格是"到档位价才成交、当日没成交则由窗口收口作废", 不会像命令那样被收盘强平成市价单
is_last_day 默认 True: 网格/跟踪止盈都是 window_tdays=1 的短窗口单, run_tick 一律按在途口径
(_inflight_today) 扣当日投放量, 所以这里 left<=0 = 该出的档全挂着等成交, 文案跟着走
quota_wait_reason 那句"已全部在途", 而不是误报"当日配额已出完""""
left = max(0, int(quota) - int(fired_today)) left = max(0, int(quota) - int(fired_today))
now_min = et.hm_to_min(now) now_min = et.hm_to_min(now)
@ -83,7 +88,7 @@ def _strategy_immediate(*, side, now, day, quota, fired_today) -> dict:
return {"action": a, "qty_hint": left, "limit_price": limit, "reason": r, "source": "策略即时"} return {"action": a, "qty_hint": left, "limit_price": limit, "reason": r, "source": "策略即时"}
if left <= 0: if left <= 0:
return _o(et.ACT_WAIT, "当日配额已出完") return _o(et.ACT_WAIT, et.quota_wait_reason(urgent, is_last_day))
if not et.in_session(now_min): if not et.in_session(now_min):
return _o(et.ACT_WAIT, "非交易时段") return _o(et.ACT_WAIT, "非交易时段")
price = _f(day.get("price")) price = _f(day.get("price"))
@ -114,7 +119,8 @@ def decide(*, side: str, action: str, ts_code: str, now, day: dict, params: dict
# 网格/跟踪止盈: 策略层已按档位触发, 这里不做择时博弈, 到价即时成交、收盘不强制。 # 网格/跟踪止盈: 策略层已按档位触发, 这里不做择时博弈, 到价即时成交、收盘不强制。
# 只识别策略来源且类型为 GRID/TRAIL 的指令; 做T(T0)与其余来源一律走原有逻辑, 不受影响。 # 只识别策略来源且类型为 GRID/TRAIL 的指令; 做T(T0)与其余来源一律走原有逻辑, 不受影响。
if (prog or {}).get("origin") == "strategy" and str((prog or {}).get("stype")) in ("GRID", "TRAIL"): if (prog or {}).get("origin") == "strategy" and str((prog or {}).get("stype")) in ("GRID", "TRAIL"):
return _strategy_immediate(side=side, now=now, day=day, quota=quota, fired_today=fired_today) return _strategy_immediate(side=side, now=now, day=day, quota=quota, fired_today=fired_today,
is_last_day=is_last_day, urgent=urgent)
if not available(): if not available():
d = et.decide(side=side, now=now, day=day, params=params, is_last_day=is_last_day, d = et.decide(side=side, now=now, day=day, params=params, is_last_day=is_last_day,

View File

@ -23,9 +23,10 @@
test_batch14_units.py 宏观择时: 指数计算与对齐/区域迟滞/周期与对数映射/ test_batch14_units.py 宏观择时: 指数计算与对齐/区域迟滞/周期与对数映射/
分方向触发/让路与冷却/宏观闸/建议采纳 (25 ) 分方向触发/让路与冷却/宏观闸/建议采纳 (25 )
test_batch15_units.py 紧急清仓修复: 配额在途口径不重复扣/紧急清仓不被 test_batch15_units.py 紧急清仓修复: 配额在途口径不重复扣/紧急清仓不被
误判配额已出完/清仓命令即时撤策略与买入提议 (7 ) 误判配额已出完/清仓命令即时撤策略/买入提议/在途买单;
在途与配额文案拆分; 减仓不掐策略腿 (12 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 ) test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 )
505 510
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
""" """
import os import os

View File

@ -14,9 +14,13 @@
本批覆盖: 本批覆盖:
* _inflight_today 只算在途已终态一律不算 (甲的口径修正); * _inflight_today 只算在途已终态一律不算 (甲的口径修正);
* 同一批 children , _consumed_today _inflight_today 的差, 以及内置择时在两种口径下 * 同一批 children , _consumed_today _inflight_today 的差, 以及内置择时在两种口径下
FIRE / 配额已出完 的分野 (直接钉死根因, 用真函数不打桩); FIRE / WAIT 的分野 (直接钉死根因, 用真函数不打桩);
* _stop_buyside_for_exit 撤策略 + 驳回买入提议, 不动卖出提议与无关票 (); * _stop_buyside_for_exit 撤策略 + 驳回买入提议 + 撤在途买单, 不动卖出提议与无关票 ();
* plan_command LIQUIDATE_ALL 时确实撤掉了持仓票的活跃策略 (乙的接线, 真实走一遍). * plan_command LIQUIDATE_ALL 时确实撤掉了持仓票的活跃策略 (乙的接线, 真实走一遍);
* (文案拆分): 紧急/末日单 left<=0 "已全部在途, 等成交回来", 普通多日单才报"当日配额已出完",
hard_gate 与策略即时两条路各测一遍;
* (减仓看情况): _pending_buys(exclude_strategy=True) 剔掉策略腿 减仓不掐网格/做T,
清仓与全局暂停买入仍全撤.
""" """
import os import os
import sys import sys
@ -110,11 +114,13 @@ def _():
new = executor._inflight_today(children, YMD) # 0 + 900 = 900 new = executor._inflight_today(children, YMD) # 0 + 900 = 900
assert old == 1800 and new == 900, (old, new) assert old == 1800 and new == 900, (old, new)
# 老口径: left = 1400 - 1800 < 0 → "当日配额已出完", 紧急也被挡 (bug) # 老口径: left = 1400 - 1800 < 0 → 直接 WAIT, 紧急清仓被挡在这道判断前 (bug)。
# (文案这批已拆分: 紧急/末日单 left<=0 报"已全部在途", 普通多日单才报"当日配额已出完";
# 这里 urgent=True 走前者。要钉的是 action=WAIT —— 老口径把紧急清仓 benched 了。)
d_old = et.decide(side="sell", now=NOW, day=DAY, params=EXEC_PRM, d_old = et.decide(side="sell", now=NOW, day=DAY, params=EXEC_PRM,
is_last_day=True, is_command=True, urgent=True, is_last_day=True, is_command=True, urgent=True,
fired_today=old, quota=quota) fired_today=old, quota=quota)
assert d_old["action"] == et.ACT_WAIT and "配额已出完" in d_old["reason"], d_old assert d_old["action"] == et.ACT_WAIT and "在途" in d_old["reason"], d_old
# 新口径: left = 1400 - 900 = 500 > 0 → 紧急直通 FIRE, 还能再投 500 # 新口径: left = 1400 - 900 = 500 > 0 → 紧急直通 FIRE, 还能再投 500
d_new = et.decide(side="sell", now=NOW, day=DAY, params=EXEC_PRM, d_new = et.decide(side="sell", now=NOW, day=DAY, params=EXEC_PRM,
is_last_day=True, is_command=True, urgent=True, is_last_day=True, is_command=True, urgent=True,
@ -158,15 +164,18 @@ class _Rec:
self.proposals = [] self.proposals = []
self.cancelled = [] self.cancelled = []
self.declined = [] self.declined = []
self.cancelled_ins = [] # 被撤的在途买入指令 id
self.ledger = [] self.ledger = []
def _patch_stop(rec, strategies, proposals): def _patch_stop(rec, strategies, proposals, instructions=()):
from app.services import strategy_service from app.services import strategy_service, executor as _ex
rec._orig = (pms_repo.list_strategies, pms_repo.list_proposals, pms_repo.decide_proposal, rec._orig = (pms_repo.list_strategies, pms_repo.list_proposals, pms_repo.decide_proposal,
pms_repo.insert_ledger, strategy_service.set_status) pms_repo.insert_ledger, pms_repo.list_instructions,
strategy_service.set_status, _ex.cancel_instruction)
pms_repo.list_strategies = lambda **kw: list(strategies) pms_repo.list_strategies = lambda **kw: list(strategies)
pms_repo.list_proposals = lambda **kw: list(proposals) pms_repo.list_proposals = lambda **kw: list(proposals)
pms_repo.list_instructions = lambda **kw: list(instructions)
def _decide(pid, decision): def _decide(pid, decision):
rec.declined.append((pid, decision)) rec.declined.append((pid, decision))
@ -179,14 +188,20 @@ def _patch_stop(rec, strategies, proposals):
return {"ok": True, "status": status} return {"ok": True, "status": status}
strategy_service.set_status = _setst strategy_service.set_status = _setst
def _cancel_ins(iid, reason="页面人工撤销"):
rec.cancelled_ins.append(iid)
return {"ok": True}
_ex.cancel_instruction = _cancel_ins
def _unpatch_stop(rec): def _unpatch_stop(rec):
from app.services import strategy_service from app.services import strategy_service, executor as _ex
(pms_repo.list_strategies, pms_repo.list_proposals, pms_repo.decide_proposal, (pms_repo.list_strategies, pms_repo.list_proposals, pms_repo.decide_proposal,
pms_repo.insert_ledger, strategy_service.set_status) = rec._orig pms_repo.insert_ledger, pms_repo.list_instructions,
strategy_service.set_status, _ex.cancel_instruction) = rec._orig
@case("命令覆盖冲突·撤活跃策略 + 驳回买入提议, 不动卖出提议与无关票") @case("命令覆盖冲突·撤活跃策略 + 驳回买入提议 + 撤在途买单, 不动卖出提议与无关票")
def _(): def _():
rec = _Rec() rec = _Rec()
strategies = [{"strategy_id": "STR_A", "ts_code": "002128.SZ", "type": "GRID"}, strategies = [{"strategy_id": "STR_A", "ts_code": "002128.SZ", "type": "GRID"},
@ -194,13 +209,18 @@ def _():
proposals = [{"proposal_id": "P_buy", "ts_code": "002128.SZ", "action": "DCA"}, # 买, 驳 proposals = [{"proposal_id": "P_buy", "ts_code": "002128.SZ", "action": "DCA"}, # 买, 驳
{"proposal_id": "P_sell", "ts_code": "002128.SZ", "action": "TRIM"}, # 卖, 留 {"proposal_id": "P_sell", "ts_code": "002128.SZ", "action": "TRIM"}, # 卖, 留
{"proposal_id": "P_other", "ts_code": "600000.SH", "action": "OPEN"}] # 无关票 {"proposal_id": "P_other", "ts_code": "600000.SH", "action": "OPEN"}] # 无关票
_patch_stop(rec, strategies, proposals) # 在途买单: 本票一张 (要撤) + 无关票一张 (要留)
instructions = [{"instruction_id": "INS_buy", "ts_code": "002128.SZ", "side": "buy"},
{"instruction_id": "INS_other", "ts_code": "600000.SH", "side": "buy"}]
_patch_stop(rec, strategies, proposals, instructions=instructions)
try: try:
out = csvc._stop_buyside_for_exit(["002128.SZ"], "CMD_T_0001") out = csvc._stop_buyside_for_exit(["002128.SZ"], "CMD_T_0001")
assert out["strategies"] == ["STR_A"], out # 只撤本票策略 assert out["strategies"] == ["STR_A"], out # 只撤本票策略
assert out["proposals"] == ["P_buy"], out # 只驳本票买入提议 assert out["proposals"] == ["P_buy"], out # 只驳本票买入提议
assert out["instructions"] == ["INS_buy"], out # 只撤本票在途买单
assert rec.cancelled == [("STR_A", "CANCELLED", "command")], rec.cancelled assert rec.cancelled == [("STR_A", "CANCELLED", "command")], rec.cancelled
assert rec.declined == [("P_buy", "DECLINED")], rec.declined assert rec.declined == [("P_buy", "DECLINED")], rec.declined
assert rec.cancelled_ins == ["INS_buy"], rec.cancelled_ins # 无关票在途买单不动
assert any(x.get("action") == "CLEANUP" for x in rec.ledger) # 留痕 assert any(x.get("action") == "CLEANUP" for x in rec.ledger) # 留痕
assert not out["errors"], out assert not out["errors"], out
finally: finally:
@ -210,11 +230,12 @@ def _():
@case("命令覆盖冲突·空票集不动任何东西") @case("命令覆盖冲突·空票集不动任何东西")
def _(): def _():
rec = _Rec() rec = _Rec()
_patch_stop(rec, [{"strategy_id": "STR_A", "ts_code": "002128.SZ"}], []) _patch_stop(rec, [{"strategy_id": "STR_A", "ts_code": "002128.SZ"}], [],
instructions=[{"instruction_id": "INS_x", "ts_code": "002128.SZ", "side": "buy"}])
try: try:
out = csvc._stop_buyside_for_exit([], "CMD_T_0002") out = csvc._stop_buyside_for_exit([], "CMD_T_0002")
assert out == {"strategies": [], "proposals": [], "errors": []}, out assert out == {"strategies": [], "proposals": [], "instructions": [], "errors": []}, out
assert not rec.cancelled assert not rec.cancelled and not rec.cancelled_ins
finally: finally:
_unpatch_stop(rec) _unpatch_stop(rec)
@ -262,6 +283,63 @@ def _():
csvc.param_store.get_int) = orig csvc.param_store.get_int) = orig
# ================================================================ 丙: 文案拆分 (在途 / 配额已出完)
@case("文案拆分·quota_wait_reason: 紧急或末日报'已全部在途', 普通多日报'当日配额已出完'")
def _():
assert et.quota_wait_reason(urgent=True, is_last_day=False) == "已全部在途, 等成交回来"
assert et.quota_wait_reason(urgent=False, is_last_day=True) == "已全部在途, 等成交回来"
assert et.quota_wait_reason(urgent=False, is_last_day=False) == "当日配额已出完"
@case("文案拆分·普通多日单 left<=0 仍报'当日配额已出完' (行为不变)")
def _():
d = et.decide(side="sell", now=NOW, day=DAY, params=EXEC_PRM,
is_last_day=False, is_command=True, urgent=False,
fired_today=1000, quota=1000) # 今日已投放=配额 → left=0
assert d["action"] == et.ACT_WAIT and d["reason"] == "当日配额已出完", d
@case("文案拆分·紧急/末日单 left<=0 改报'已全部在途, 等成交回来'")
def _():
d = et.decide(side="sell", now=NOW, day=DAY, params=EXEC_PRM,
is_last_day=True, is_command=True, urgent=True,
fired_today=1000, quota=1000) # 该出的全挂在券商, left=0
assert d["action"] == et.ACT_WAIT and d["reason"] == "已全部在途, 等成交回来", d
@case("文案拆分·策略即时(网格)同样分口径: 末日报在途, 非末日报配额到顶")
def _():
from app.services import exec_advisor
# 网格是 window=1 末日单, left<=0 = 该出的档都挂着等成交
d1 = exec_advisor._strategy_immediate(side="buy", now=NOW, day=DAY, quota=100,
fired_today=100, is_last_day=True)
assert d1["action"] == et.ACT_WAIT and d1["reason"] == "已全部在途, 等成交回来", d1
# 退一步防串味: 若某笔不是末日单, 该报配额到顶
d2 = exec_advisor._strategy_immediate(side="buy", now=NOW, day=DAY, quota=100,
fired_today=100, is_last_day=False, urgent=False)
assert d2["reason"] == "当日配额已出完", d2
# ================================================================ 丁: 减仓不撤策略腿在途买单
@case("减仓口径·_pending_buys(exclude_strategy=True) 剔策略腿, 默认(清仓/暂停买入)全带")
def _():
orig = pms_repo.list_instructions
rows = [{"instruction_id": "I_auto", "ts_code": "600000.SH", "qty": 100,
"limit_price": 10.0, "origin_type": "plan"},
{"instruction_id": "I_prop", "ts_code": "600000.SH", "qty": 200,
"limit_price": 10.0, "origin_type": "proposal"},
{"instruction_id": "I_grid", "ts_code": "002128.SZ", "qty": 300,
"limit_price": 27.6, "origin_type": "strategy"}]
try:
pms_repo.list_instructions = lambda **kw: list(rows)
keep_all = {b["instruction_id"] for b in csvc._pending_buys()}
no_strat = {b["instruction_id"] for b in csvc._pending_buys(exclude_strategy=True)}
assert keep_all == {"I_auto", "I_prop", "I_grid"}, keep_all # 清仓/全局暂停买入: 全撤
assert no_strat == {"I_auto", "I_prop"}, no_strat # 减仓: 网格/做T 腿留着继续跑
finally:
pms_repo.list_instructions = orig
# ================================================================ runner # ================================================================ runner
def main(): def main():
passed = failed = 0 passed = failed = 0