开始处理系统整体细节

This commit is contained in:
zlt 2026-08-17 15:09:37 +08:00
parent 2f0af23de9
commit 3a02bc00ff
8 changed files with 250 additions and 12 deletions

View File

@ -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,不置 forcedTTL 到期撤掉按新价重下)。动机:
「现价≥均价才卖」是只在强势时放行的条件,下跌日全天不满足,全部数量堆到 14:45
一笔打出。分桶后跌日的强制完成分散到多个时点。参数留空 = 不分桶,回到旧行为。
自主卖出窗口耗尽不作废window_verdict 增加 side 参数,自主类卖出(信号清仓、
保垫减仓)窗口耗尽置 PARTIAL 保持在途、按末日节奏继续出手,不再 EXPIRED——与
hard_gate 里「宁可买不上, 不能卖不掉」对齐口径。自主买入到期作废的口径不变。
**动了哪些文件**
app/core/exec_timing.pyparse_bucket_times / _bucket_due 新增hard_gate、decide 加
urgentwindow_verdict 加 sideapp/services/executor.pymaterialize_plans 给
LIQUIDATE_ALL 指令写 urgentrun_tick 传 urgent 与两个新参数sweep_windows 传 side
app/services/exec_advisor.pydecide 加 urgent 透传,紧急与分桶都在 hard_gate 消化,
实现A/B 一律生效app/services/signal_service.py_make_exit 写 urgent
config/settings.py 与 app/services/param_store.pyPMS_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 后各有一笔「分桶收口」子单;
③ 窗口耗尽的信号清仓不再变 EXPIREDprogress.window_verdict.note 带「不作废」。
**还欠着什么**
分桶份额目前按桶数等分,未按成交量 U 形加权;紧急直通没有跳过跌停一字板的顺延
(封板时挂单也成交不了,维持顺延是有意的,记录在此防止误会)。
---
## 2026-08-06 · 新建仓动作:方案已出,待拍板 ## 2026-08-06 · 新建仓动作:方案已出,待拍板
**做了什么** **做了什么**

View File

@ -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] 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, 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。 """事实性检查 (实现A/B 共用的前置)。命中返回决策 dict, 未命中返回 None。
包含: 无价/停牌/配额尽/非时段/一字板/买入不追高(当日涨幅)/14:45 兜底与兜底后 包含: 无价/停牌/配额尽/非时段/一字板/买入不追高(当日涨幅)/14:45 兜底与兜底后
不新开买单**不含**避开开盘 N 分钟与均价/回踩 那些是各实现自己的规则 不新开买单**不含**避开开盘 N 分钟与均价/回踩 那些是各实现自己的规则
卖出侧另有两条 PMS 自留地纪律 (2026-08-17, 实现A/B 一律生效): 紧急直通
(urgent=True 时不做择价博弈) 与分桶收口 ( _bucket_due 的说明)
与拆分前 decide() 的唯一语义差别: 卖出的 14:45 兜底现在排在避开开盘 30 分钟 与拆分前 decide() 的唯一语义差别: 卖出的 14:45 兜底现在排在避开开盘 30 分钟
之前判两者只在 eod_force_time 被改到 10:00 之前这种病态配置下才会同时成立, 之前判两者只在 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: if day.get("limit_down") and not is_last_day:
return out(ACT_SKIP, "跌停一字板, 当日跳过顺延") 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: if now_min >= eod_min:
return out(ACT_FIRE, f"{_fmt(eod_min)} 兜底: 限价 = 现价×{disc}", return out(ACT_FIRE, f"{_fmt(eod_min)} 兜底: 限价 = 现价×{disc}",
limit=round(price * disc, 2), forced=True) 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 return None
if side == "buy": 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, 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: 单条指令在「此刻」该不该出手 (= 硬闸 + 内置保守看法)。 """实现B: 单条指令在「此刻」该不该出手 (= 硬闸 + 内置保守看法)。
day: {price, vwap, halted, limit_up, limit_down, day_chg_from_open, support} 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, 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: if h is not None:
return h 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) 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 末句)。 """窗口耗尽时的收口 (设计 §8 末句)。
还有剩余且窗口已尽 命令置部分完成并告警; 命令类保留人工兜底提示, 还有剩余且窗口已尽 命令置部分完成并告警; 命令类保留人工兜底提示,
自主类直接作废 自主类**买入**直接作废
自主类**卖出不作废** (2026-08-17): 信号转来的清仓保垫减仓若窗口耗尽就
作废, 是把该降的风险留在账上 hard_gate 卖出分支"宁可买不上, 不能
卖不掉"是同一条口径, 这里把它从日内兜底延伸到跨日收口。指令保持在途并
告警, 之后每天都按"末日"节奏出手 (配额 = 全部剩余 + 14:45 兜底), 直到
卖完或人工撤销side 不传时沿用旧口径 (兼容既有调用与单测)
""" """
if int(remaining_qty or 0) <= 0: if int(remaining_qty or 0) <= 0:
return {"verdict": "DONE", "note": "已足额完成"} return {"verdict": "DONE", "note": "已足额完成"}
if int(tdays_left or 0) > 0: if int(tdays_left or 0) > 0:
return {"verdict": "RUNNING", "note": f"窗口内剩余 {tdays_left} 交易日"} 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", return {"verdict": "PARTIAL" if is_command else "EXPIRED",
"note": f"窗口耗尽仍剩 {remaining_qty} 股 —— " "note": f"窗口耗尽仍剩 {remaining_qty} 股 —— "
+ ("命令置部分完成并告警, 请在页面决定顺延或人工完成" + ("命令置部分完成并告警, 请在页面决定顺延或人工完成"

View File

@ -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, 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: pos: dict = None, tdays_left=None, prog: dict = None) -> dict:
"""择时判定统一入口 (executor.run_tick 的唯一调用点)。 """择时判定统一入口 (executor.run_tick 的唯一调用点)。
@ -117,13 +118,16 @@ def decide(*, side: str, action: str, ts_code: str, now, day: dict, params: dict
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,
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 d["source"] = IMPL_B
return d return d
left = max(0, int(quota) - int(fired_today)) 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, 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: if h is not None:
h["source"] = "guard" # 本地事实性检查 (配额/兜底等), 与实现无关 h["source"] = "guard" # 本地事实性检查 (配额/兜底等), 与实现无关
return h 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}" note = f"研判动作无法识别: {advice.get('verdict')!r}"
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,
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})" d["source"] = f"B(实现A不可用: {note})"
return d return d

View File

@ -96,13 +96,19 @@ def materialize_plans(limit: int = 100) -> dict:
iid = cs.make_instruction_id(ymd, p["ts_code"], act, seq) iid = cs.make_instruction_id(ymd, p["ts_code"], act, seq)
window = int((cmd.get("progress") or {}).get("window_tdays") window = int((cmd.get("progress") or {}).get("window_tdays")
or param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3)) 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: try:
pms_repo.insert_instruction( pms_repo.insert_instruction(
instruction_id=iid, origin_type="plan", origin_id=p["plan_id"], instruction_id=iid, origin_type="plan", origin_id=p["plan_id"],
ts_code=p["ts_code"], action=act, side=side, qty=qty, ts_code=p["ts_code"], action=act, side=side, qty=qty,
limit_price=None, window_tdays=window, status=ST_PROPOSED, limit_price=None, window_tdays=window, status=ST_PROPOSED,
progress={"deadline": str(p.get("deadline") or ""), "command_id": cid, progress=prog0)
"is_command": True, "children": []})
pms_repo.update_plan(p["plan_id"], status=PLAN_EXEC) pms_repo.update_plan(p["plan_id"], status=PLAN_EXEC)
out["created"].append(iid) out["created"].append(iid)
except Exception as e: 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), "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), "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), "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) slices = param_store.get_int("PMS_EXEC_SLICES", 1)
order_ttl = param_store.get_int("PMS_ORDER_TTL_MIN", 10) # 单个分片挂单有效期(交易分钟) 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, now=now, day=day_ctx, params=exec_prm,
is_last_day=is_last, is_last_day=is_last,
is_command=bool(prog.get("is_command")), is_command=bool(prog.get("is_command")),
urgent=bool(prog.get("urgent")),
fired_today=fired_today, fired_today=fired_today,
quota=quota, pos=pos, tdays_left=tdays_left, quota=quota, pos=pos, tdays_left=tdays_left,
prog=(None if dry_run else prog)) prog=(None if dry_run else prog))
@ -321,8 +331,11 @@ def sweep_windows(*, now=None) -> dict:
if not deadline: if not deadline:
continue continue
left = td.trade_days_left(deadline, now) left = td.trade_days_left(deadline, now)
# side 必须传 (2026-08-17): 自主卖出窗口耗尽不作废 —— 信号清仓与保垫减仓
# 到期作废是把该降的风险留在账上, 见 window_verdict 里那段说明。
v = et.window_verdict(remaining_qty=remaining, tdays_left=left, 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": if v["verdict"] == "RUNNING":
continue continue
prog["window_verdict"] = v prog["window_verdict"] = v

View File

@ -123,6 +123,8 @@ DESC = {
"PMS_DISPATCH_MODE": "下发通道: shadow=只记账待人工 / ws=WebSocket 直连 QMT (需先起 pms-ws)", "PMS_DISPATCH_MODE": "下发通道: shadow=只记账待人工 / ws=WebSocket 直连 QMT (需先起 pms-ws)",
"PMS_EXEC_SLICES": "当日配额分几笔出手", "PMS_EXEC_SLICES": "当日配额分几笔出手",
"PMS_ORDER_TTL_MIN": "单笔挂单有效期 (交易分钟), 到点下游自动撤; 兜底单不受限", "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_RISK_WARN_ENTRY": "单笔敞口告警线 (占规模)", "PMS_RISK_WARN_PORTFOLIO": "组合敞口告警线",
"PMS_BRAKE_DRAWDOWN": "组合刹车: 自高水位回撤", "PMS_BRAKE_DAYS": "刹车持续交易日", "PMS_BRAKE_DRAWDOWN": "组合刹车: 自高水位回撤", "PMS_BRAKE_DAYS": "刹车持续交易日",
"PMS_STOP_ATR_MULT": "自算止损参考: 成本 N×ATR", "PMS_STOP_ATR_MULT": "自算止损参考: 成本 N×ATR",

View File

@ -241,6 +241,9 @@ def _make_exit(code, d, pos) -> str:
window_tdays=window, status=executor.ST_PROPOSED, window_tdays=window, status=executor.ST_PROPOSED,
progress={"deadline": str(td.window_deadline(now.date(), window)), progress={"deadline": str(td.window_deadline(now.date(), window)),
"is_command": False, "children": [], "from_signal": True, "is_command": False, "children": [], "from_signal": True,
# 高置信风控清仓与一键清仓同为紧急语义 (2026-08-17): 择时不做择价
# 博弈, 直通出手、限价更激进 —— 见 exec_timing.hard_gate 的 urgent 分支
"urgent": True,
"reason": d["reason"]}) "reason": d["reason"]})
pms_repo.insert_ledger(ts_code=code, action="EXIT", arbiter="rule", verdict="PASS", pms_repo.insert_ledger(ts_code=code, action="EXIT", arbiter="rule", verdict="PASS",
price_at=float((pos or {}).get("price") or 0), price_at=float((pos or {}).get("price") or 0),

View File

@ -164,6 +164,13 @@ class Settings(BaseSettings):
PMS_DISPATCH_MODE: str = "shadow" # 下发通道: shadow(影子,默认) / ws(直连QMT,待实现) PMS_DISPATCH_MODE: str = "shadow" # 下发通道: shadow(影子,默认) / ws(直连QMT,待实现)
PMS_EXEC_SLICES: int = 1 # 当日配额分几笔出手 (设计「分笔卖出配额」) PMS_EXEC_SLICES: int = 1 # 当日配额分几笔出手 (设计「分笔卖出配额」)
PMS_ORDER_TTL_MIN: int = 10 # 单个下发分片的挂单有效期(交易分钟), 到点下游自动撤 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) --- # --- 择时实现A (委托决策系统, 待办 #9; 契约见 BIONIC_PMS_INTERFACE.md) ---
# 决策系统按它凌晨算好的支撑/压力给出执行区间, 盘中只回答「现价在不在区间内」; # 决策系统按它凌晨算好的支撑/压力给出执行区间, 盘中只回答「现价在不在区间内」;

View File

@ -145,6 +145,88 @@ def _():
is_command=False)["verdict"] == "EXPIRED" 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 # ================================================================ rule_gate
def ctx(**kw): def ctx(**kw):
d = {"ts_code": "600000.SH", d = {"ts_code": "600000.SH",