diff --git a/DEVLOG.md b/DEVLOG.md index 87df9a9..09896c0 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 补撤在途买单一层,进度回执多一项 instructions);scripts/test_batch15_units.py(原 7 例扩到 12 例:新增文案拆分四例、减仓剔策略腿一例,原撤策略例加断言撤在途买单);scripts/run_tests.py(batch15 计数改 12,总数 510)。 + +**部署方式** +桥机 factorevaluation 上 make deploy(源码打进镜像,必须重建容器),make test 见 ALL SUITES PASS(已含改后 batch15)。收盘后部署,让下一次清仓或减仓发生在你在场时。不新增参数,不动 .env。 + +**真机判收** +未判收。开发容器全量单测 ALL SUITES PASS(510 例,batch15 由 7 例扩到 12 例)。判收建议:下达减仓命令后,挂着策略的票在命令进度回执 stopped_buyside 里不再报撤策略买单,网格继续按档买卖;对某只挂策略的票下清仓某股,命令进度回执 stopped_buyside.instructions 里能看到那张在途买单被撤;盯盘时“已全部在途、等成交回来”与“当日配额已出完”两句话分得开。 + +**还欠着什么** +一,上面两处待定(刹车挡网格买、全局暂停买入挡做T买回)已于当日拍板:都保持现状不改,无需改码,详见上面“拍板”一段。二,减仓是否要更进一步、连策略本身也暂停(而不只是不撤在途买单),本次按“默认不动策略”处理;若你想要“减仓也暂停这些票的策略买入开腿”这种中间档,说一声再加。三,上一节欠的第三笔——持仓系统侧可卖量与券商侧可用持仓偶有不一致(出口委托里有 INSUFFICIENT_POSITION 拒单),仍未动,属对账口径,单独排查。 + +--- + ## 2026-08-20 · 紧急清仓修复:配额不重复扣今日成交 / 清仓命令即时撤策略 **背景** diff --git a/app/core/exec_timing.py b/app/core/exec_timing.py index 058b881..aea1011 100644 --- a/app/core/exec_timing.py +++ b/app/core/exec_timing.py @@ -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} 股"} +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, is_command: bool, fired_today: int = 0, quota: int = 0, 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"): return out(ACT_SKIP, "停牌, 当日跳过顺延") if left <= 0: - return out(ACT_WAIT, "当日配额已出完") + return out(ACT_WAIT, quota_wait_reason(urgent, is_last_day)) if not in_session(now_min): return out(ACT_WAIT, "非交易时段") diff --git a/app/services/command_service.py b/app/services/command_service.py index 44ee9d2..6bb67db 100644 --- a/app/services/command_service.py +++ b/app/services/command_service.py @@ -271,9 +271,10 @@ def plan_command(cmd: dict) -> dict: if cancel_failed: notes.insert(0, f"⚠ {len(cancel_failed)} 条在途指令**没撤掉, 仍在下游挂着**: " + "; ".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'])} 条、" - f"驳回买入提议 {len(stop_r['proposals'])} 条 (停止边卖边买)") + f"驳回买入提议 {len(stop_r['proposals'])} 条、" + f"撤在途买单 {len(stop_r.get('instructions') or [])} 条 (停止边卖边买)") if stop_r.get("errors"): notes.insert(0, f"⚠ 停买入侧有 {len(stop_r['errors'])} 处未成: " + "; ".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() if cmd_type == "REDUCE_EXPOSURE": + # 减仓不撤策略腿在途买单 (exclude_strategy=True): 整体降仓 ≠ 停网格/做T (设计: 清仓 + # 才最高优先级、覆盖一切; 减仓看情况、默认不动策略)。非策略的在途买入 (自主/提议/命令) + # 照撤 —— 正在减仓就别再往里投新钱。 return pl.plan_reduce_exposure( 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) 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) - return [{"instruction_id": r["instruction_id"], "ts_code": r["ts_code"], - "qty": int(r.get("qty") or 0), - "amount": float(r.get("qty") or 0) * float(r.get("limit_price") or 0), - "side": "buy"} for r in rows] + 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), + "amount": float(r.get("qty") or 0) * float(r.get("limit_price") or 0), + "side": "buy", "origin_type": r.get("origin_type")}) + return out 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: """全额清仓类命令 (一键清仓 / 清仓某股 / 清仓某行业) 下达时, 立刻停掉这些票的买入侧。 - 撤活跃策略 (网格/做T/跟踪) + 驳回待确认的买入提议。在途买单由 planner 的 HALT 撤单项 - 经 _cancel_marked_instructions 另行撤销, 这里补策略与提议两层 —— 三层合起来才真正做到 - 「命令一下, 这只票不再有任何新的买入」。幂等、不抛异常: 单只失败记进 errors, 不拖垮 - 命令本体 (故障即守成)。撤策略只改策略状态、不动持仓, 安全。""" - out = {"strategies": [], "proposals": [], "errors": []} + 三件事: 撤活跃策略 (网格/做T/跟踪) + 驳回待确认的买入提议 + **撤这些票当前在途的买入指令**。 + + 在途买单这一层原来只有一键清仓兜得住 —— 它的 planner 会给所有在途买入排 HALT 撤单项, + 经 _cancel_marked_instructions 撤掉。但清仓某股 (EXIT_STOCK) / 清仓某行业 (SECTOR_EXIT) + 的 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} if not codeset: return out @@ -719,9 +744,30 @@ def _stop_buyside_for_exit(codes: list, command_id: str) -> dict: out["proposals"].append(p["proposal_id"]) except Exception as e: out["errors"].append(f"{p.get('ts_code')} 驳回买入提议失败: {type(e).__name__}: {e}") - if out["strategies"] or out["proposals"]: - logger.warning("[命令] %s 清仓覆盖冲突: 撤策略 %s, 驳回买入提议 %s", - command_id, out["strategies"], out["proposals"]) + # 第三层: 撤这些票当前在途的买入指令 (EXIT_STOCK/SECTOR_EXIT 的 planner 不排 HALT, 全靠这里)。 + # 只查当前在途买入, 撤过的/已终态的不会回到这个集里, 所以一键清仓已 HALT 掉的这里空跑、不重撤。 + 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 diff --git a/app/services/exec_advisor.py b/app/services/exec_advisor.py index ea7877b..7d378c9 100644 --- a/app/services/exec_advisor.py +++ b/app/services/exec_advisor.py @@ -72,10 +72,15 @@ def _post(url: str, payload: dict, timeout: int) -> dict: 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 择时, 不做 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)) 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": "策略即时"} 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): return _o(et.ACT_WAIT, "非交易时段") 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)与其余来源一律走原有逻辑, 不受影响。 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(): d = et.decide(side=side, now=now, day=day, params=params, is_last_day=is_last_day, diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 9dedcbd..b769aff 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -23,9 +23,10 @@ test_batch14_units.py 宏观择时: 指数计算与对齐/区域迟滞/周期与对数映射/ 分方向触发/让路与冷却/宏观闸/建议采纳 (25 例) test_batch15_units.py 紧急清仓修复: 配额在途口径不重复扣/紧急清仓不被 - 误判配额已出完/清仓命令即时撤策略与买入提议 (7 例) + 误判配额已出完/清仓命令即时撤策略/买入提议/在途买单; + 在途与配额文案拆分; 减仓不掐策略腿 (12 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 505 例 + 共 510 例 任一子集失败即整体失败 (退出码 1)。 """ import os diff --git a/scripts/test_batch15_units.py b/scripts/test_batch15_units.py index 07fbe2f..298ad27 100644 --- a/scripts/test_batch15_units.py +++ b/scripts/test_batch15_units.py @@ -14,9 +14,13 @@ 本批覆盖: * _inflight_today 只算在途、已终态一律不算 (甲的口径修正); * 同一批 children 下, _consumed_today 与 _inflight_today 的差, 以及内置择时在两种口径下 - FIRE / 配额已出完 的分野 (直接钉死根因, 用真函数不打桩); - * _stop_buyside_for_exit 撤策略 + 驳回买入提议, 不动卖出提议与无关票 (乙); - * plan_command 下 LIQUIDATE_ALL 时确实撤掉了持仓票的活跃策略 (乙的接线, 真实走一遍). + FIRE / WAIT 的分野 (直接钉死根因, 用真函数不打桩); + * _stop_buyside_for_exit 撤策略 + 驳回买入提议 + 撤在途买单, 不动卖出提议与无关票 (乙); + * plan_command 下 LIQUIDATE_ALL 时确实撤掉了持仓票的活跃策略 (乙的接线, 真实走一遍); + * 丙(文案拆分): 紧急/末日单 left<=0 报"已全部在途, 等成交回来", 普通多日单才报"当日配额已出完", + hard_gate 与策略即时两条路各测一遍; + * 丁(减仓看情况): _pending_buys(exclude_strategy=True) 剔掉策略腿 —— 减仓不掐网格/做T, + 清仓与全局暂停买入仍全撤. """ import os import sys @@ -110,11 +114,13 @@ def _(): new = executor._inflight_today(children, YMD) # 0 + 900 = 900 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, is_last_day=True, is_command=True, urgent=True, 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 d_new = et.decide(side="sell", now=NOW, day=DAY, params=EXEC_PRM, is_last_day=True, is_command=True, urgent=True, @@ -158,15 +164,18 @@ class _Rec: self.proposals = [] self.cancelled = [] self.declined = [] + self.cancelled_ins = [] # 被撤的在途买入指令 id self.ledger = [] -def _patch_stop(rec, strategies, proposals): - from app.services import strategy_service +def _patch_stop(rec, strategies, proposals, instructions=()): + from app.services import strategy_service, executor as _ex 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_proposals = lambda **kw: list(proposals) + pms_repo.list_instructions = lambda **kw: list(instructions) def _decide(pid, decision): rec.declined.append((pid, decision)) @@ -179,14 +188,20 @@ def _patch_stop(rec, strategies, proposals): return {"ok": True, "status": status} 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): - 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.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 _(): rec = _Rec() 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"}, # 买, 驳 {"proposal_id": "P_sell", "ts_code": "002128.SZ", "action": "TRIM"}, # 卖, 留 {"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: out = csvc._stop_buyside_for_exit(["002128.SZ"], "CMD_T_0001") assert out["strategies"] == ["STR_A"], out # 只撤本票策略 assert out["proposals"] == ["P_buy"], out # 只驳本票买入提议 + assert out["instructions"] == ["INS_buy"], out # 只撤本票在途买单 assert rec.cancelled == [("STR_A", "CANCELLED", "command")], rec.cancelled 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 not out["errors"], out finally: @@ -210,11 +230,12 @@ def _(): @case("命令覆盖冲突·空票集不动任何东西") def _(): 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: out = csvc._stop_buyside_for_exit([], "CMD_T_0002") - assert out == {"strategies": [], "proposals": [], "errors": []}, out - assert not rec.cancelled + assert out == {"strategies": [], "proposals": [], "instructions": [], "errors": []}, out + assert not rec.cancelled and not rec.cancelled_ins finally: _unpatch_stop(rec) @@ -262,6 +283,63 @@ def _(): 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 def main(): passed = failed = 0