diff --git a/DEVLOG.md b/DEVLOG.md index 094d751..a080c80 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -30,6 +30,50 @@ --- +## 2026-08-17 · 清仓择时改造:紧急直通 + 卖出分桶收口 + 自主卖出窗口不作废 + +**做了什么** +清仓的执行节奏按用户拍板改造,三件事一次落地。一,紧急直通:一键清仓(LIQUIDATE_ALL) +与高置信风控清仓(信号消化 ≥ PMS_SIGNAL_AUTO_EXIT_CONF 转出的 EXIT)带 urgent 标志, +择时不再避开开盘半小时、不再等均价,有价在时段有配额即出手,限价用更激进的 +PMS_URGENT_SELL_DISCOUNT(默认 0.995),过了兜底时点自动转 forced 挂到收盘。此前 +一键清仓的方案注记写着「紧急, 不做择时优化」而执行层不认识这个语义,文案与行为不一致。 +二,卖出分桶收口:日内按 PMS_SELL_BUCKET_TIMES(默认 11:30,14:00,加兜底共三桶) +给每桶分一份当日配额,桶内照旧择价(价好就多卖),桶收口时累计应出量没跟上就无条件 +补齐差额(限价 = 现价×0.998,不置 forced,TTL 到期撤掉按新价重下)。动机: +「现价≥均价才卖」是只在强势时放行的条件,下跌日全天不满足,全部数量堆到 14:45 +一笔打出。分桶后跌日的强制完成分散到多个时点。参数留空 = 不分桶,回到旧行为。 +三,自主卖出窗口耗尽不作废:window_verdict 增加 side 参数,自主类卖出(信号清仓、 +保垫减仓)窗口耗尽置 PARTIAL 保持在途、按末日节奏继续出手,不再 EXPIRED——与 +hard_gate 里「宁可买不上, 不能卖不掉」对齐口径。自主买入到期作废的口径不变。 + +**动了哪些文件** +app/core/exec_timing.py(parse_bucket_times / _bucket_due 新增;hard_gate、decide 加 +urgent;window_verdict 加 side);app/services/executor.py(materialize_plans 给 +LIQUIDATE_ALL 指令写 urgent;run_tick 传 urgent 与两个新参数;sweep_windows 传 side); +app/services/exec_advisor.py(decide 加 urgent 透传,紧急与分桶都在 hard_gate 消化, +实现A/B 一律生效);app/services/signal_service.py(_make_exit 写 urgent); +config/settings.py 与 app/services/param_store.py(PMS_SELL_BUCKET_TIMES / +PMS_URGENT_SELL_DISCOUNT 两个新参数与页面描述);scripts/test_batch3_units.py +(新增三组用例:紧急直通、分桶收口、自主卖出不作废)。 + +**部署方式** +factorevaluation 上 `make deploy`(源码打进镜像,restart 无效),跑 `make test` +见 ALL SUITES PASS。两个新参数页面可调、即时生效;PMS_SELL_BUCKET_TIMES 清空 +即整体退回旧行为,不用回滚代码。 + +**真机判收** +未判收。开发机全量单测 ALL SUITES PASS(含新增三组用例)。待真机看三样: +① 下一条紧急清仓的指令 progress.last_decision.reason 出现「紧急卖出直通」; +② 下跌日的普通清仓在 11:30 后、14:00 后各有一笔「分桶收口」子单; +③ 窗口耗尽的信号清仓不再变 EXPIRED,progress.window_verdict.note 带「不作废」。 + +**还欠着什么** +分桶份额目前按桶数等分,未按成交量 U 形加权;紧急直通没有跳过跌停一字板的顺延 +(封板时挂单也成交不了,维持顺延是有意的,记录在此防止误会)。 + +--- + ## 2026-08-06 · 新建仓动作:方案已出,待拍板 **做了什么** diff --git a/app/core/exec_timing.py b/app/core/exec_timing.py index bf7483f..058b881 100644 --- a/app/core/exec_timing.py +++ b/app/core/exec_timing.py @@ -100,12 +100,65 @@ def slice_qty(quota: int, slices: int = 1, lot: int = LOT) -> list: return [q for q in out if q > 0] +def parse_bucket_times(raw) -> list: + """"11:30,14:00" → 升序的当日分钟数列表。空串或解析不出 = 不分桶 (旧行为)。 + + 只收落在开收盘之间的时点; 去重排序, 保证桶序稳定。""" + out = set() + for part in str(raw or "").split(","): + part = part.strip() + if not part: + continue + try: + m = hm_to_min(part) + except (TypeError, ValueError): + continue + if OPEN_MIN < m < CLOSE_MIN: + out.add(m) + return sorted(out) + + +def _bucket_due(*, now_min: int, quota: int, fired: int, params: dict, eod_min: int): + """卖出分桶收口 (2026-08-17 清仓择时改造): 已收口的桶累计应出量没跟上 → 补齐差额。 + + 由来: 「现价不低于均价才卖」是只在强势时放行的条件, 而清仓多发生在弱势时。 + 下跌日这个条件全天不满足, 全部数量堆到 14:45 一笔打出 —— 冲击最大, 兜底限价 + 还追不上下跌。把日内切成几个时段桶, 每桶分一份当日配额: 桶内照旧择价 (价好 + 就提前多卖), 桶收口时无条件补齐该桶份额。跌日的强制完成由此分散到多个时点, + 涨日的择价好处一点不丢。 + + 口径三条: ① `PMS_SELL_BUCKET_TIMES` 留空 = 不分桶, 行为与改造前逐字一致; + ② 最后一桶恒为 14:45 兜底 (它管收尾, 本函数只管中途的桶); ③ 补齐量向下取整 + 到一手 —— 部分减持必须整百 (规则闸会拦零股), 不足一手的差额留给下一桶或兜底。 + """ + closes = [m for m in parse_bucket_times(params.get("sell_bucket_times")) if m < eod_min] + if not closes or int(quota or 0) <= 0: + return None + n = len(closes) + 1 # 兜底时点是最后一桶 + passed = sum(1 for m in closes if now_min >= m) + if passed <= 0: + return None + due = int(int(quota) * passed / n) + short = due - int(fired or 0) + if short < LOT: + return None + qty = min(int(quota) - int(fired or 0), (short // LOT) * LOT) + if qty < LOT: + return None + return {"qty": qty, + "reason": f"分桶收口: 第 {passed}/{n} 桶已过 {_fmt(closes[passed - 1])}, " + f"累计应出 {due} 股、实出 {int(fired or 0)} 股, 补齐 {qty} 股"} + + 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): """事实性检查 (实现A/B 共用的前置)。命中返回决策 dict, 未命中返回 None。 包含: 无价/停牌/配额尽/非时段/一字板/买入不追高(当日涨幅)/14:45 兜底与「兜底后 不新开买单」。**不含**避开开盘 N 分钟与均价/回踩 —— 那些是各实现自己的规则。 + 卖出侧另有两条 PMS 自留地纪律 (2026-08-17, 实现A/B 一律生效): 紧急直通 + (urgent=True 时不做择价博弈) 与分桶收口 (见 _bucket_due 的说明)。 与拆分前 decide() 的唯一语义差别: 卖出的 14:45 兜底现在排在「避开开盘 30 分钟」 之前判。两者只在 eod_force_time 被改到 10:00 之前这种病态配置下才会同时成立, @@ -140,9 +193,24 @@ def hard_gate(*, side: str, now, day: dict, params: dict, is_last_day: bool, # 宁可买不上, 但不能卖不掉。减持方向不设门槛这条口径, 在这里同样成立。 if day.get("limit_down") and not is_last_day: return out(ACT_SKIP, "跌停一字板, 当日跳过顺延") + if urgent: + # 紧急卖出直通 (2026-08-17): 一键清仓的方案注记本来就写着"紧急, 不做择时 + # 优化", 高置信风控清仓同理 —— 但这个语义从前没传到执行层, 紧急清仓照样 + # 避开开盘半小时、照样等均价。现在: 有价、在时段、有配额, 就出手。 + # 限价用更激进的紧急系数, 且**不置 forced** —— 分片 TTL 到期撤掉重下, + # 每分钟按新现价重定限价, 下跌中追着走; 过了兜底时点才转 forced (挂到收盘)。 + udisc = float(params.get("urgent_sell_discount") or 0.995) + return out(ACT_FIRE, f"紧急卖出直通: 限价 = 现价×{udisc}", + limit=round(price * udisc, 2), forced=(now_min >= eod_min)) if now_min >= eod_min: return out(ACT_FIRE, f"{_fmt(eod_min)} 兜底: 限价 = 现价×{disc}", limit=round(price * disc, 2), forced=True) + b = _bucket_due(now_min=now_min, quota=int(quota or 0), + fired=int(fired_today or 0), params=params, eod_min=eod_min) + if b is not None: + return {"action": ACT_FIRE, "qty_hint": b["qty"], + "limit_price": round(price * disc, 2), + "reason": b["reason"], "forced": False} return None if side == "buy": @@ -183,7 +251,8 @@ def hard_gate(*, side: str, now, day: dict, params: dict, is_last_day: bool, def decide(*, side: str, now, day: dict, params: dict, is_last_day: bool, - is_command: bool = True, fired_today: int = 0, quota: int = 0) -> dict: + is_command: bool = True, fired_today: int = 0, quota: int = 0, + urgent: bool = False) -> dict: """实现B: 单条指令在「此刻」该不该出手 (= 硬闸 + 内置保守看法)。 day: {price, vwap, halted, limit_up, limit_down, day_chg_from_open, support} @@ -195,7 +264,8 @@ def decide(*, side: str, now, day: dict, params: dict, is_last_day: bool, 这里给默认值只是为了让既有单测与临时试算不必逐个改口径。 """ h = hard_gate(side=side, now=now, day=day, params=params, is_last_day=is_last_day, - is_command=is_command, fired_today=fired_today, quota=quota) + is_command=is_command, fired_today=fired_today, quota=quota, + urgent=urgent) if h is not None: return h @@ -298,16 +368,28 @@ def slice_deadline(now, *, ttl_min: int = 10, forced: bool = False) -> int: return add_trade_minutes(hm_to_min(now), ttl_min) -def window_verdict(*, remaining_qty: int, tdays_left: int, is_command: bool) -> dict: +def window_verdict(*, remaining_qty: int, tdays_left: int, is_command: bool, + side: str = None) -> dict: """窗口耗尽时的收口 (设计 §8 末句)。 还有剩余且窗口已尽 → 命令置「部分完成」并告警; 命令类保留人工兜底提示, - 自主类直接作废。 + 自主类**买入**直接作废。 + + 自主类**卖出不作废** (2026-08-17): 信号转来的清仓、保垫减仓若窗口耗尽就 + 作废, 是把该降的风险留在账上 —— 与 hard_gate 卖出分支"宁可买不上, 不能 + 卖不掉"是同一条口径, 这里把它从日内兜底延伸到跨日收口。指令保持在途并 + 告警, 之后每天都按"末日"节奏出手 (配额 = 全部剩余 + 14:45 兜底), 直到 + 卖完或人工撤销。side 不传时沿用旧口径 (兼容既有调用与单测)。 """ 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} 交易日"} + if not is_command and str(side or "").lower() == "sell": + return {"verdict": "PARTIAL", + "note": f"窗口耗尽仍剩 {remaining_qty} 股 —— 卖出指令不作废, " + f"继续按末日节奏出手并告警; 请关注可卖量、停牌与跌停状态, " + f"不想再卖请人工撤销该指令"} return {"verdict": "PARTIAL" if is_command else "EXPIRED", "note": f"窗口耗尽仍剩 {remaining_qty} 股 —— " + ("命令置部分完成并告警, 请在页面决定顺延或人工完成" diff --git a/app/services/exec_advisor.py b/app/services/exec_advisor.py index 80622e3..ea7877b 100644 --- a/app/services/exec_advisor.py +++ b/app/services/exec_advisor.py @@ -98,7 +98,8 @@ def _strategy_immediate(*, side, now, day, quota, fired_today) -> dict: def decide(*, side: str, action: str, ts_code: str, now, day: dict, params: dict, - is_last_day: bool, is_command: bool = True, fired_today: int = 0, quota: int = 0, + is_last_day: bool, is_command: bool = True, urgent: bool = False, + fired_today: int = 0, quota: int = 0, pos: dict = None, tdays_left=None, prog: dict = None) -> dict: """择时判定统一入口 (executor.run_tick 的唯一调用点)。 @@ -117,13 +118,16 @@ def decide(*, side: str, action: str, ts_code: str, now, day: dict, params: dict if not available(): d = et.decide(side=side, now=now, day=day, params=params, is_last_day=is_last_day, - is_command=is_command, fired_today=fired_today, quota=quota) + is_command=is_command, fired_today=fired_today, quota=quota, + urgent=urgent) d["source"] = IMPL_B return d left = max(0, int(quota) - int(fired_today)) + # urgent 与分桶收口都在 hard_gate 里消化 —— 它们是 PMS 自留地纪律, 实现A/B 一律生效 h = et.hard_gate(side=side, now=now, day=day, params=params, is_last_day=is_last_day, - is_command=is_command, fired_today=fired_today, quota=quota) + is_command=is_command, fired_today=fired_today, quota=quota, + urgent=urgent) if h is not None: h["source"] = "guard" # 本地事实性检查 (配额/兜底等), 与实现无关 return h @@ -146,7 +150,8 @@ def decide(*, side: str, action: str, ts_code: str, now, day: dict, params: dict note = f"研判动作无法识别: {advice.get('verdict')!r}" d = et.decide(side=side, now=now, day=day, params=params, is_last_day=is_last_day, - is_command=is_command, fired_today=fired_today, quota=quota) + is_command=is_command, fired_today=fired_today, quota=quota, + urgent=urgent) d["source"] = f"B(实现A不可用: {note})" return d diff --git a/app/services/executor.py b/app/services/executor.py index 9cd377c..23191ba 100644 --- a/app/services/executor.py +++ b/app/services/executor.py @@ -96,13 +96,19 @@ def materialize_plans(limit: int = 100) -> dict: 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={"deadline": str(p.get("deadline") or ""), "command_id": cid, - "is_command": True, "children": []}) + progress=prog0) pms_repo.update_plan(p["plan_id"], status=PLAN_EXEC) out["created"].append(iid) except Exception as e: @@ -133,6 +139,9 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict: "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) # 单个分片挂单有效期(交易分钟) @@ -168,6 +177,7 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict: 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)) @@ -321,8 +331,11 @@ def sweep_windows(*, now=None) -> dict: 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"))) + is_command=bool(prog.get("is_command")), + side=str(ins.get("side") or "")) if v["verdict"] == "RUNNING": continue prog["window_verdict"] = v diff --git a/app/services/param_store.py b/app/services/param_store.py index f4fa522..e023296 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -123,6 +123,8 @@ DESC = { "PMS_DISPATCH_MODE": "下发通道: shadow=只记账待人工 / ws=WebSocket 直连 QMT (需先起 pms-ws)", "PMS_EXEC_SLICES": "当日配额分几笔出手", "PMS_ORDER_TTL_MIN": "单笔挂单有效期 (交易分钟), 到点下游自动撤; 兜底单不受限", + "PMS_SELL_BUCKET_TIMES": "卖出分桶收口时点 (逗号分隔, 如 11:30,14:00): 桶内照旧择价, 桶收口无条件补齐该桶份额, 把跌日的强制卖出从 14:45 一根针分散到多个时点; 留空=不分桶回到旧行为", + "PMS_URGENT_SELL_DISCOUNT": "紧急卖出限价系数 (一键清仓/高置信风控清仓直通出手, 比兜底 0.998 更激进, 每分钟按新现价重定)", "PMS_RISK_WARN_ENTRY": "单笔敞口告警线 (占规模)", "PMS_RISK_WARN_PORTFOLIO": "组合敞口告警线", "PMS_BRAKE_DRAWDOWN": "组合刹车: 自高水位回撤", "PMS_BRAKE_DAYS": "刹车持续交易日", "PMS_STOP_ATR_MULT": "自算止损参考: 成本 − N×ATR", diff --git a/app/services/signal_service.py b/app/services/signal_service.py index 911a23d..743b309 100644 --- a/app/services/signal_service.py +++ b/app/services/signal_service.py @@ -241,6 +241,9 @@ def _make_exit(code, d, pos) -> str: window_tdays=window, status=executor.ST_PROPOSED, progress={"deadline": str(td.window_deadline(now.date(), window)), "is_command": False, "children": [], "from_signal": True, + # 高置信风控清仓与一键清仓同为紧急语义 (2026-08-17): 择时不做择价 + # 博弈, 直通出手、限价更激进 —— 见 exec_timing.hard_gate 的 urgent 分支 + "urgent": True, "reason": d["reason"]}) pms_repo.insert_ledger(ts_code=code, action="EXIT", arbiter="rule", verdict="PASS", price_at=float((pos or {}).get("price") or 0), diff --git a/config/settings.py b/config/settings.py index 7c794f8..091cba6 100644 --- a/config/settings.py +++ b/config/settings.py @@ -164,6 +164,13 @@ class Settings(BaseSettings): PMS_DISPATCH_MODE: str = "shadow" # 下发通道: shadow(影子,默认) / ws(直连QMT,待实现) PMS_EXEC_SLICES: int = 1 # 当日配额分几笔出手 (设计「分笔卖出配额」) PMS_ORDER_TTL_MIN: int = 10 # 单个下发分片的挂单有效期(交易分钟), 到点下游自动撤 + # --- 卖出分桶收口与紧急直通 (2026-08-17 清仓择时改造) --- + # 「现价不低于均价才卖」是只在强势时放行的条件, 下跌日全天不满足, 全部数量会 + # 堆到 14:45 一笔打出。分桶: 桶内照旧择价, 桶收口无条件补齐该桶份额, 把跌日的 + # 强制完成分散到多个时点。留空 = 不分桶, 行为与改造前一致。最后一桶恒为兜底时点。 + PMS_SELL_BUCKET_TIMES: str = "11:30,14:00" + PMS_URGENT_SELL_DISCOUNT: float = 0.995 # 紧急卖出限价系数 (一键清仓/高置信风控清仓 + # 直通出手用, 比兜底的 0.998 更激进; 每分钟按新现价重定, 下跌中追着走) # --- 择时实现A (委托决策系统, 待办 #9; 契约见 BIONIC_PMS_INTERFACE.md) --- # 决策系统按它凌晨算好的支撑/压力给出执行区间, 盘中只回答「现价在不在区间内」; diff --git a/scripts/test_batch3_units.py b/scripts/test_batch3_units.py index 240f3c6..48932ae 100644 --- a/scripts/test_batch3_units.py +++ b/scripts/test_batch3_units.py @@ -145,6 +145,88 @@ def _(): is_command=False)["verdict"] == "EXPIRED" +@case("窗口收口·自主卖出不作废 (2026-08-17: 宁可买不上, 不能卖不掉)") +def _(): + # 信号清仓/保垫减仓窗口耗尽 → 保持在途按末日节奏继续, 不作废 + v = et.window_verdict(remaining_qty=500, tdays_left=0, is_command=False, side="sell") + assert v["verdict"] == "PARTIAL" and "不作废" in v["note"], v + # 自主买入照旧作废; 命令类两个方向都置部分完成 (口径不变) + assert et.window_verdict(remaining_qty=500, tdays_left=0, + is_command=False, side="buy")["verdict"] == "EXPIRED" + assert et.window_verdict(remaining_qty=500, tdays_left=0, + is_command=True, side="sell")["verdict"] == "PARTIAL" + # side 不传 = 旧口径 (兼容既有调用) + assert et.window_verdict(remaining_qty=500, tdays_left=0, + is_command=False)["verdict"] == "EXPIRED" + + +@case("紧急卖出直通·不避开盘不等均价, 限价更激进, 兜底时点后转 forced") +def _(): + prm = {**PRM, "urgent_sell_discount": 0.995} + # 09:35 开盘半小时内 + 现价低于均价 —— 普通卖出会 WAIT, 紧急直接 FIRE + r = et.decide(side="sell", now="09:35", day=day(price=9.8, vwap=10.0), params=prm, + is_last_day=False, quota=2000, urgent=True) + assert r["action"] == et.ACT_FIRE and r["forced"] is False, r + assert r["limit_price"] == 9.75, r # 9.8×0.995=9.751 → round 9.75 + assert "紧急" in r["reason"], r + # 14:50 (兜底时点后) 紧急单转 forced —— 挂到收盘不被 TTL 撤回 + r = et.decide(side="sell", now="14:50", day=day(price=9.8, vwap=10.0), params=prm, + is_last_day=False, quota=2000, urgent=True) + assert r["action"] == et.ACT_FIRE and r["forced"] is True, r + # 跌停一字板照旧顺延 (紧急也卖不出去, 别把单堆在封单后面空耗) + r = et.decide(side="sell", now="10:30", + day=day(price=9.0, vwap=10.0, high=9.0, low=9.0, + day_chg_from_open=-0.1, limit_down=True), + params=prm, is_last_day=False, quota=2000, urgent=True) + assert r["action"] == et.ACT_SKIP, r + # 配额出完照旧等 (紧急不越过配额纪律) + r = et.decide(side="sell", now="10:30", day=day(price=9.8, vwap=10.0), params=prm, + is_last_day=False, quota=2000, fired_today=2000, urgent=True) + assert r["action"] == et.ACT_WAIT, r + + +@case("分桶收口·桶时点解析与累计应出量补齐") +def _(): + assert et.parse_bucket_times("11:30,14:00") == [690, 840] + assert et.parse_bucket_times(" 14:00 , 11:30 ,") == [690, 840] + assert et.parse_bucket_times("") == [] and et.parse_bucket_times(None) == [] + assert et.parse_bucket_times("09:30,15:00,abc") == [] # 开收盘边界与坏值都不收 + prm = {**PRM, "sell_bucket_times": "11:30,14:00"} + # 桶未到 (10:30) 且价低于均价 → 仍是等待 (桶内保留择价条件) + r = et.decide(side="sell", now="10:30", day=day(price=9.8, vwap=10.0), params=prm, + is_last_day=False, quota=3000) + assert r["action"] == et.ACT_WAIT, r + # 第一桶 (11:30) 已过、一股没出 → 补齐 3000×1/3=1000 股, 不置 forced + r = et.decide(side="sell", now="13:05", day=day(price=9.8, vwap=10.0), params=prm, + is_last_day=False, quota=3000, fired_today=0) + assert r["action"] == et.ACT_FIRE and r["qty_hint"] == 1000, r + assert r["forced"] is False and "分桶收口" in r["reason"], r + # 桶内已按择价卖过 1000 → 第一桶份额已够, 继续等更好的价 + r = et.decide(side="sell", now="13:05", day=day(price=9.8, vwap=10.0), params=prm, + is_last_day=False, quota=3000, fired_today=1000) + assert r["action"] == et.ACT_WAIT, r + # 第二桶 (14:00) 已过、只出了 1000 → 应出 2000, 补齐 1000 + r = et.decide(side="sell", now="14:05", day=day(price=9.8, vwap=10.0), params=prm, + is_last_day=False, quota=3000, fired_today=1000) + assert r["action"] == et.ACT_FIRE and r["qty_hint"] == 1000, r + # 差额不足一手 → 留给下一桶或兜底, 不发零股卖单 (规则闸会拦) + r = et.decide(side="sell", now="13:05", day=day(price=9.8, vwap=10.0), params=prm, + is_last_day=False, quota=250, fired_today=0) + assert r["action"] == et.ACT_WAIT, r + # 14:45 兜底优先于分桶 (兜底管收尾, 桶只管中途) + r = et.decide(side="sell", now="14:45", day=day(price=9.8, vwap=10.0), params=prm, + is_last_day=False, quota=3000, fired_today=2000) + assert r["action"] == et.ACT_FIRE and r["forced"] is True, r + # 不配桶参数 = 旧行为: 13:05 价低于均价一路等到兜底 + r = et.decide(side="sell", now="13:05", day=day(price=9.8, vwap=10.0), params=PRM, + is_last_day=False, quota=3000, fired_today=0) + assert r["action"] == et.ACT_WAIT, r + # 站上均价时照旧全量择价卖出, 不受桶份额限制 (价好就多卖) + r = et.decide(side="sell", now="13:05", day=day(price=10.2, vwap=10.0), params=prm, + is_last_day=False, quota=3000, fired_today=1000) + assert r["action"] == et.ACT_FIRE and r["qty_hint"] == 2000, r + + # ================================================================ rule_gate def ctx(**kw): d = {"ts_code": "600000.SH",