第零件甲组:PMS 目标价那条路的四个缺陷,加裁决理由服务端强制
独立审查(09-07)报的两条高严重度与两条中严重度,都在目标价这条路上,都违背 09-03 写下的「目标价必交人、系统不自动卖」。 一,跨轮减持抢在人前面卖。同轮只发一条减持只管一次扫描;下一分钟到价清仓已成在途、 按(代码,动作)被跳过,保垫减仓单独产出、不需确认、卖出方向又不走强制入队,于是 自动卖掉三分之一,人随后采纳的清仓单永远等不到可卖量。修法两处互为保险:动作引擎里 同票只要有任何一条减持在跳过集合里,本轮其余减持一律让路;提议服务的在途集合对减持侧 按代码去重,一条在途两种减持一起记。 二,到价提议挂着时高置信止损被吞。在途检查把等人拍板的到价清仓算作在途,止损整条不落。 到价提议的来源写在硬数字里,按来源区分:等人拍板的止盈不挡止损。止损落单后把还挂着的 到价提议作废并在账本记一行,免得人再点采纳对一只已清掉的票再发清仓。 三,到价当天有买入成交时清仓被整条拒。原先按总持仓报数,规则闸一句「卖出 > T+1 可卖」 拒掉再被当日去重挡住,人当天看不到。改按可卖量报数,与执行器口径对齐;可卖为零不产出。 采纳路径同样按拍板那一刻的可卖量重算。 四,挂了交易方案的票目标价永不触发。原先整只跳过,策略层又没有任何地方读目标价。 改成策略票仍评目标价,其余四类不评。目标价是用户的命令,优先级高于自动挂上的方案。 另收第五件里的一小项:裁决理由改为服务端强制,空理由直接拒绝请求。此前必填只在浏览器 里成立,任何脚本都能写出一条默认文案的裁决记录。 测试:第四批加三例(跨轮让路、策略票评目标价、按可卖量报数),第二十一批加两例 (到价提议不挡止损并被作废、非到价来源的在途仍挡),例数 655 到 660。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
9a45df38a3
commit
db2bca4d90
|
|
@ -344,13 +344,22 @@ def eval_target(p: dict, params: dict = None, mkt: dict = None):
|
|||
return None # 取不到现价, 宁可不动
|
||||
if price < target:
|
||||
return None # 还没到价
|
||||
qty = int(p.get("total_qty") or 0)
|
||||
total = int(p.get("total_qty") or 0)
|
||||
if total <= 0:
|
||||
return None
|
||||
# 按 T+1 可卖量报数, 不按全部持仓 (2026-09-07 审查修)。到价当天若有买入成交
|
||||
# (自动补仓、加仓、做 T 都会), 可卖量小于总持仓; 原先按总持仓报, 规则闸一句
|
||||
# 「卖出 > T+1 可卖」整条拒掉, 再被当日去重挡住, 人当天根本看不到这条到价提议,
|
||||
# 明天价格回落就错过。执行器那条路对卖出本来就按可卖量夹紧, 这里与它对齐。
|
||||
# 可卖量为零 (今天刚建的仓) 不产出: 明天可卖量重置后若仍到价, 自然再来。
|
||||
qty = clamp_sell_qty(total, p)
|
||||
if qty <= 0:
|
||||
return None
|
||||
note = "" if qty == total else f" (总持仓 {total} 股, 今天只能卖 T+1 可卖的 {qty} 股)"
|
||||
c = _cand(p, A_EXIT, SELL, qty,
|
||||
f"目标价到价: 现价 {price} 已到你设的目标价 {target}, "
|
||||
f"拟清仓 {qty} 股 (含零股); 卖不卖由你拍板, 系统不自动卖",
|
||||
{"target_price": target, "price": price, "total_qty": qty,
|
||||
f"拟清仓 {qty} 股 (含零股){note}; 卖不卖由你拍板, 系统不自动卖",
|
||||
{"target_price": target, "price": price, "total_qty": total,
|
||||
"avail_qty": int(p.get("avail_qty") or 0),
|
||||
"cushion_pct": p.get("cushion_pct"), "avg_cost": p.get("avg_cost")},
|
||||
confirm=True, source=SRC_TARGET_PRICE)
|
||||
|
|
@ -358,6 +367,22 @@ def eval_target(p: dict, params: dict = None, mkt: dict = None):
|
|||
return c
|
||||
|
||||
|
||||
def clamp_sell_qty(qty: int, pos: dict) -> int:
|
||||
"""卖出数量按 T+1 可卖量夹紧。可卖量缺失 (None) 时按总持仓, 不当成零。
|
||||
|
||||
页面采纳一条清仓提议时也走这里: 提议记的是扫描那一刻的股数, 等人拍板时持仓
|
||||
可能已经被别的路卖少了, 照原数落单会被卖出前的检查整条驳回, 人点了「清掉」
|
||||
结果一股没卖。按拍板那一刻的可卖量重算, 才能保证「人说卖, 就真的卖得出去」。
|
||||
"""
|
||||
q = int(qty or 0)
|
||||
if q <= 0:
|
||||
return 0
|
||||
avail = pos.get("avail_qty")
|
||||
if avail is None:
|
||||
return q
|
||||
return max(0, min(q, int(avail or 0)))
|
||||
|
||||
|
||||
EVALUATORS = ((A_TRIM, eval_trim), (A_EXIT, eval_target),
|
||||
(A_ADD, eval_add), (A_FILL, eval_fill), (A_DCA, eval_dca))
|
||||
|
||||
|
|
@ -410,11 +435,15 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
|
|||
code = p.get("ts_code")
|
||||
if not code or int(p.get("total_qty") or 0) <= 0:
|
||||
continue
|
||||
# 挂了 ACTIVE 交易方案(策略)的票由策略层接管, 动作引擎不自动提议 (设计 §四)
|
||||
if code in strategy_codes:
|
||||
# 挂了 ACTIVE 交易方案(策略)的票由策略层接管, 四类自主动作不评 (设计 §四)。
|
||||
# **但目标价那条照评** (2026-09-07 审查修): 目标价是用户下的命令, 优先级高于自动
|
||||
# 挂上的方案; 原先整只跳过, 结果是命令表显示生效中、到价那天什么都不发生,
|
||||
# 且策略层没有任何地方读目标价 —— 一条界面上点了会成功、实际永不触发的命令。
|
||||
on_strategy = code in strategy_codes
|
||||
if on_strategy:
|
||||
skipped.append({"ts_code": code, "action": "*",
|
||||
"why": "挂了交易方案(策略), 由策略层接管, 动作引擎不自动提议"})
|
||||
continue
|
||||
"why": "挂了交易方案(策略), 由策略层接管, 补足/加仓/补仓/保垫减仓不自动提议; "
|
||||
"你设的目标价仍照常看"})
|
||||
# 取不到现价的票整只跳过, **并且留痕**。上游 (portfolio.positions_view) 在拿不到
|
||||
# 行情时会用摊薄成本顶住 price 让市值还能算, 但那个价不是行情 —— 拿它评动作会得出
|
||||
# 「安全垫恰好 0」「现价恰好等于成本」这类看着正常、实则凭空的结论。
|
||||
|
|
@ -432,12 +461,26 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
|
|||
if sp:
|
||||
p = {**p, "target_price": sp.get("target_price"), "stop_price": sp.get("stop_price")}
|
||||
frozen = (p.get("frozen_reason") or "NONE") != "NONE"
|
||||
# 跨轮的减持让路 (2026-09-07 审查修)。同轮只发一条减持那条规矩 (见下) 只管一次扫描;
|
||||
# 下一分钟再扫时, 等人拍板的到价清仓已成在途、按 (代码, EXIT) 被跳过, 而保垫减仓
|
||||
# 单独产出、不需确认、卖出方向又不走强制入队 —— 系统刚说「卖不卖由你定」, 一分钟后
|
||||
# 自己卖了三分之一, 人随后采纳的清仓单永远等不到可卖量。
|
||||
# 修法: 这只票只要有任何一条减持在跳过集合里 (在途提议、在途指令、今日被拒过),
|
||||
# 本轮其余减持一律让路。宁可少卖一次, 不能抢在人前面卖。
|
||||
inflight_sell = next((a for a in SELL_SIDE_ACTIONS if skip_why(skip, (code, a))), None)
|
||||
cands_this = []
|
||||
for action, fn in EVALUATORS:
|
||||
why = skip_why(skip, (code, action))
|
||||
if why:
|
||||
skipped.append({"ts_code": code, "action": action, "why": why})
|
||||
continue
|
||||
if on_strategy and action != A_EXIT:
|
||||
continue # 策略票只看目标价, 原因已在上面记过
|
||||
if inflight_sell and action in SELL_SIDE_ACTIONS:
|
||||
skipped.append({"ts_code": code, "action": action,
|
||||
"why": f"这只票已有一条减持 ({inflight_sell}) 在处理中, "
|
||||
f"处理完之前不再另发减持, 免得抢在人前面卖"})
|
||||
continue
|
||||
# 冻结只禁增持, 减仓照评 (与规则闸同一口径, 这里先剪枝少算一遍)
|
||||
if frozen and action not in SELL_SIDE_ACTIONS:
|
||||
skipped.append({"ts_code": code, "action": action,
|
||||
|
|
|
|||
|
|
@ -867,16 +867,23 @@ def _inflight_keys() -> dict:
|
|||
是为了让人从 skipped 那一行就能接着往下查, 不必再去翻两张表。
|
||||
"""
|
||||
keys = {}
|
||||
# 减持侧按**代码**去重, 不按 (代码, 动作) (2026-09-07 审查修): 一只票有一条清仓在等人拍板时,
|
||||
# 保垫减仓若按另一个动作名单独放行, 会抢在人前面自动卖掉一部分。所以一条减持在途,
|
||||
# 这只票两种减持一起记进跳过集合。动作引擎那边还有一道同样的闸, 两处互为保险。
|
||||
def _mark(code, action, why):
|
||||
keys[(code, action)] = why
|
||||
if action in ae.SELL_SIDE_ACTIONS:
|
||||
for other in ae.SELL_SIDE_ACTIONS:
|
||||
keys.setdefault((code, other), why + " (同票另一种减持一并让路)")
|
||||
try:
|
||||
for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200):
|
||||
keys[(p["ts_code"], p["action"])] = \
|
||||
f"已有在途提议 {p.get('proposal_id')} 在等人确认"
|
||||
_mark(p["ts_code"], p["action"], f"已有在途提议 {p.get('proposal_id')} 在等人确认")
|
||||
except Exception as e:
|
||||
logger.warning("读提议队列失败: %s", e)
|
||||
try:
|
||||
for i in pms_repo.list_instructions(statuses=list(executor.LIVE), limit=300):
|
||||
keys[(i["ts_code"], i.get("action"))] = \
|
||||
f"已有在途指令 {i.get('instruction_id')} ({i.get('status')})"
|
||||
_mark(i["ts_code"], i.get("action"),
|
||||
f"已有在途指令 {i.get('instruction_id')} ({i.get('status')})")
|
||||
except Exception as e:
|
||||
logger.warning("读在途指令失败: %s", e)
|
||||
return keys
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import logging
|
|||
from datetime import datetime, timedelta
|
||||
|
||||
from config.settings import settings
|
||||
from app.core import action_engine as ae
|
||||
from app.core import command_spec as cs
|
||||
from app.core import signal_rules as sr
|
||||
from app.core import tradedays as td
|
||||
|
|
@ -284,6 +285,11 @@ def _make_exit(code, d, pos) -> str:
|
|||
hard_numbers=d["hard_numbers"], ref_id=iid,
|
||||
reason=d["reason"][:500])
|
||||
logger.warning("[信号消化] %s 转清仓指令 %s —— %s", code, iid, d["reason"])
|
||||
# 清仓指令已落, 这只票还挂着的到价提议作废 (2026-09-07): 不作废它会挂到次日日结,
|
||||
# 人再点采纳会对一只已经清掉的票再发一条清仓。
|
||||
retired = _retire_target_proposals(code, d["reason"])
|
||||
if retired:
|
||||
logger.warning("[信号消化] %s 到价提议 %s 已因止损直通作废", code, retired)
|
||||
return iid
|
||||
|
||||
|
||||
|
|
@ -379,18 +385,53 @@ def _save_seen(seen, ymd):
|
|||
|
||||
|
||||
def _has_inflight(code: str) -> bool:
|
||||
"""这只票是否已有在途的卖出, 有则高置信风控卖出不再另落指令。
|
||||
|
||||
2026-09-07 审查修: **到价提议不算在途**。目标价到价那条只是「等你拍板要不要止盈」,
|
||||
它挂着的时候正是急转向下、风控卖出高发的时候; 原先把它算作在途, 高置信止损整条被吞
|
||||
—— 不落单、不记账, 页面上只看到一张止盈提议, 看不出决策系统已判该走。
|
||||
到价提议的来源写在硬数字里 (source = target_price), 按它区分, 不按动作名。
|
||||
"""
|
||||
try:
|
||||
for i in pms_repo.list_instructions(statuses=list(executor.LIVE), ts_code=code, limit=20):
|
||||
if str(i.get("side")).lower() == "sell":
|
||||
return True
|
||||
for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200):
|
||||
if p["ts_code"] == code and p["action"] in ("TRIM", "EXIT"):
|
||||
if p["ts_code"] != code or p["action"] not in ("TRIM", "EXIT"):
|
||||
continue
|
||||
if str((p.get("hard_numbers") or {}).get("source") or "") == ae.SRC_TARGET_PRICE:
|
||||
continue # 等人拍板的止盈, 不挡止损
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("[信号消化] 在途检查失败(按无在途继续): %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _retire_target_proposals(code: str, why: str) -> list:
|
||||
"""风控高置信卖出已直通清仓后, 把这只票还挂着的到价提议作废并留痕。
|
||||
|
||||
不作废的话它会一直挂到次日日结, 人若再点采纳, 会对一只已经清掉的票再发一条清仓。
|
||||
作废写成 DECLINED 并在账本记一行 NOTE, 复盘时看得出是被止损顶掉的, 不是人驳回的。
|
||||
"""
|
||||
retired = []
|
||||
try:
|
||||
for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200):
|
||||
if p["ts_code"] != code:
|
||||
continue
|
||||
if str((p.get("hard_numbers") or {}).get("source") or "") != ae.SRC_TARGET_PRICE:
|
||||
continue
|
||||
if pms_repo.decide_proposal(p["proposal_id"], "DECLINED"):
|
||||
pms_repo.insert_ledger(
|
||||
ts_code=code, action=p["action"], arbiter="rule", verdict="NOTE",
|
||||
price_at=float((p.get("hard_numbers") or {}).get("price") or 0),
|
||||
hard_numbers=p.get("hard_numbers") or {}, ref_id=p["proposal_id"],
|
||||
reason=f"到价提议作废: 风控高置信卖出已直通清仓 ({why})"[:500])
|
||||
retired.append(p["proposal_id"])
|
||||
except Exception as e:
|
||||
logger.warning("[信号消化] 作废到价提议失败(清仓指令已落, 不影响): %s", e)
|
||||
return retired
|
||||
|
||||
|
||||
def _escalate_inflight_sells(code: str, reason: str) -> list:
|
||||
"""把该股在途的卖出**指令**升级为紧急直通 (确认即加速, 2026-08-18)。
|
||||
|
||||
|
|
|
|||
|
|
@ -437,8 +437,15 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default=
|
|||
decision = str(payload.get("decision") or "").upper()
|
||||
if decision not in ("ACCEPTED", "DECLINED"):
|
||||
return {"ok": False, "error": "decision 必须是 ACCEPTED 或 DECLINED"}
|
||||
# 理由在服务端强制 (2026-09-07): 页面 09-03 起就弹必填框, 但只在浏览器里成立 ——
|
||||
# 任何脚本、旧缓存页面都能发一条没有理由的裁决, 账本上落一句「页面人工裁决」的
|
||||
# 默认文案, 与真裁决看不出区别。复盘要的是「人为什么这么判」, 没理由的不收。
|
||||
reason = str(payload.get("reason") or "").strip()
|
||||
if not reason:
|
||||
return {"ok": False, "error": "采纳或驳回都要写理由, 空理由不收"}
|
||||
|
||||
def _decide():
|
||||
from app.core import action_engine as ae
|
||||
p = pms_repo.get_proposal(proposal_id)
|
||||
if not p:
|
||||
return {"ok": False, "error": "提议不存在"}
|
||||
|
|
@ -450,7 +457,7 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default=
|
|||
pms_repo.insert_ledger(ts_code=p["ts_code"], action=p["action"], arbiter="user",
|
||||
verdict="PASS" if decision == "ACCEPTED" else "REJECT",
|
||||
price_at=float(hn.get("price") or 0), hard_numbers=hn,
|
||||
ref_id=proposal_id, reason=payload.get("reason") or "页面人工裁决")
|
||||
ref_id=proposal_id, reason=reason)
|
||||
instruction_id = None
|
||||
if decision == "ACCEPTED":
|
||||
# 策略(confirm 档)提议: 两腿同 action、side 无法由 action 反推, 交策略层按腿谱发指令
|
||||
|
|
@ -464,9 +471,19 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default=
|
|||
instruction_id = cs.make_instruction_id(td.ymd(), p["ts_code"], p["action"], 1)
|
||||
side = "sell" if p["action"] in ("TRIM", "EXIT") else "buy"
|
||||
_win = param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3)
|
||||
qty = int(p["qty"] or 0)
|
||||
if side == "sell":
|
||||
# 按拍板这一刻的可卖量重算 (2026-09-07 审查修): 提议记的是扫描那一刻的股数,
|
||||
# 等人点采纳时持仓可能已被别的路卖少, 照原数落单会被卖出前的检查整条驳回,
|
||||
# 人点了「清掉」结果一股没卖, 且那条指令会永久在途、挡住后续所有清仓。
|
||||
cur = pms_repo.get_position(p["ts_code"]) or {}
|
||||
qty = ae.clamp_sell_qty(qty, cur)
|
||||
if qty <= 0:
|
||||
return {"ok": True, "decision": decision, "instruction_id": None,
|
||||
"note": "这只票现在没有可卖的股数 (已被清掉或今天刚买入), 未落单"}
|
||||
pms_repo.insert_instruction(
|
||||
instruction_id=instruction_id, origin_type="proposal", origin_id=proposal_id,
|
||||
ts_code=p["ts_code"], action=p["action"], side=side, qty=int(p["qty"] or 0),
|
||||
ts_code=p["ts_code"], action=p["action"], side=side, qty=qty,
|
||||
limit_price=hn.get("price"),
|
||||
window_tdays=_win,
|
||||
status="PROPOSED",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 / 用户止损价只披露不拦截 (26 例)
|
||||
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 +
|
||||
目标价到价产出清仓候选 (必定交人裁决) +
|
||||
同轮只发一条减持 (到价清仓优先于保垫减仓) (15 例)
|
||||
同轮只发一条减持 (到价清仓优先于保垫减仓) +
|
||||
跨轮减持让路 / 策略票仍评目标价 / 清仓按可卖量报数 (18 例)
|
||||
test_batch5_units.py 决策系统信号流解析与消化口径 (8 例)
|
||||
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/弃洞/DDL/逐笔入账 (68 例)
|
||||
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫/
|
||||
|
|
@ -52,16 +53,17 @@
|
|||
页面静态守卫 (17 例)
|
||||
test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/
|
||||
强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱
|
||||
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 例)
|
||||
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流/
|
||||
到价提议挂着时止损照落并作废该提议 (17 例)
|
||||
test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
|
||||
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
|
||||
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 例)
|
||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) +
|
||||
目标价到价必定入队 (档位 full 也不自动卖) +
|
||||
用户设的止损价与目标价单独成列显示 (70 例)
|
||||
共 655 例
|
||||
共 660 例
|
||||
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
|
||||
减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 现为 655)
|
||||
减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 到 655; 09-07 审查修复加了跨轮减持等五例, 现为 660)
|
||||
任一子集失败即整体失败 (退出码 1)。
|
||||
|
||||
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
|
||||
|
|
|
|||
|
|
@ -326,6 +326,66 @@ def _():
|
|||
assert ins["progress"]["is_command"] is False, ins
|
||||
|
||||
|
||||
@case("自动止损·到价提议挂着时高置信止损照落, 且把那条到价提议作废留痕 (审查 2026-09-07 第 2 条)")
|
||||
def _():
|
||||
# 修之前: 到价清仓 (等你拍板要不要止盈) 被在途检查算作在途, 高置信止损整条被吞 ——
|
||||
# 不落单不记账, 页面只看到一张止盈提议, 看不出决策系统已判该走。
|
||||
from app.core import action_engine as ae
|
||||
from app.repo import pms_repo
|
||||
from app.services import param_store, proposal_service as psvc, signal_service as ssvc
|
||||
got = {"instructions": [], "ledger": [], "decided": []}
|
||||
pending = [{"proposal_id": "PRP_TP", "ts_code": "600000.SH", "action": "EXIT",
|
||||
"status": "WAIT_USER", "qty": 3000,
|
||||
"hard_numbers": {"source": ae.SRC_TARGET_PRICE, "price": 12.5}}]
|
||||
with _Patch() as p:
|
||||
p(psvc, "_route_one", lambda *a, **kw: (_ for _ in ()).throw(AssertionError("不该走分流")))
|
||||
p(pms_repo, "list_instructions", lambda **kw: [])
|
||||
p(pms_repo, "list_proposals", lambda **kw: list(pending))
|
||||
p(pms_repo, "insert_instruction", lambda **kw: got["instructions"].append(kw) or 1)
|
||||
p(pms_repo, "insert_ledger", lambda **kw: got["ledger"].append(kw) or 1)
|
||||
p(pms_repo, "decide_proposal", lambda pid, st: got["decided"].append((pid, st)) or 1)
|
||||
_patch_params(p, param_store, PARAMS_BASE)
|
||||
pos = {"ts_code": "600000.SH", "total_qty": 3000, "avail_qty": 3000, "price": 9.5}
|
||||
view = {"positions": [pos], "held": [pos]}
|
||||
prm = {"sell_conf_min": 0.75, "auto_exit_conf": 0.85, "trim_ratio": 1 / 3}
|
||||
out = {"ok": True, "read": 0, "exits": [], "proposals": [], "recorded": 0,
|
||||
"ignored": 0, "errors": [], "dry_run": False}
|
||||
sig = {"ts_code": "600000.SH", "action": "SELL", "confidence": 0.92,
|
||||
"source": "bionic_risk", "reason": "风控: 急跌破位", "msg_id": "1-2"}
|
||||
ssvc._handle(sig, view, prm, {}, td.ymd(), False, out)
|
||||
assert len(out["exits"]) == 1 and out["ignored"] == 0, out
|
||||
assert len(got["instructions"]) == 1 and got["instructions"][0]["action"] == "EXIT", got
|
||||
assert got["decided"] == [("PRP_TP", "DECLINED")], got["decided"]
|
||||
notes = [l for l in got["ledger"] if l["verdict"] == "NOTE"]
|
||||
assert len(notes) == 1 and "到价提议作废" in notes[0]["reason"], notes
|
||||
|
||||
|
||||
@case("自动止损·等人拍板的保垫减仓 (非到价来源) 仍算在途, 老行为不变")
|
||||
def _():
|
||||
from app.repo import pms_repo
|
||||
from app.services import param_store, proposal_service as psvc, signal_service as ssvc
|
||||
got = {"instructions": []}
|
||||
pending = [{"proposal_id": "PRP_TRIM", "ts_code": "600000.SH", "action": "TRIM",
|
||||
"status": "WAIT_USER", "qty": 1000,
|
||||
"hard_numbers": {"source": "engine", "price": 9.8}}]
|
||||
with _Patch() as p:
|
||||
p(psvc, "_route_one", lambda *a, **kw: None)
|
||||
p(pms_repo, "list_instructions", lambda **kw: [])
|
||||
p(pms_repo, "list_proposals", lambda **kw: list(pending))
|
||||
p(pms_repo, "insert_instruction", lambda **kw: got["instructions"].append(kw) or 1)
|
||||
p(pms_repo, "insert_ledger", lambda **kw: 1)
|
||||
_patch_params(p, param_store, PARAMS_BASE)
|
||||
pos = {"ts_code": "600000.SH", "total_qty": 3000, "avail_qty": 3000, "price": 9.5}
|
||||
view = {"positions": [pos], "held": [pos]}
|
||||
prm = {"sell_conf_min": 0.75, "auto_exit_conf": 0.85, "trim_ratio": 1 / 3}
|
||||
out = {"ok": True, "read": 0, "exits": [], "proposals": [], "recorded": 0,
|
||||
"ignored": 0, "errors": [], "dry_run": False}
|
||||
sig = {"ts_code": "600000.SH", "action": "SELL", "confidence": 0.92,
|
||||
"source": "bionic_risk", "reason": "风控: 急跌破位", "msg_id": "1-3"}
|
||||
ssvc._handle(sig, view, prm, {}, td.ymd(), False, out)
|
||||
assert out["ignored"] == 1 and got["instructions"] == [], (out, got)
|
||||
|
||||
|
||||
@case("自动止损·中等置信的风控卖出照旧只落提议 (门槛分档没被这次改动碰到)")
|
||||
def _():
|
||||
from app.repo import pms_repo
|
||||
|
|
|
|||
|
|
@ -253,6 +253,75 @@ def _():
|
|||
assert r3["candidates"] == []
|
||||
|
||||
|
||||
@case("跨轮·同票已有减持在途时, 本轮其余减持一律让路 (审查 2026-09-07 第 1 条)")
|
||||
def _():
|
||||
# 第一分钟: 到价清仓 (等人拍板) + 保垫减仓同时触发, 同轮只留到价那条。
|
||||
# 第二分钟: 到价清仓已成在途、按 (代码, EXIT) 进了跳过集合 —— 修之前保垫减仓单独
|
||||
# 产出、不需确认、当场自动卖掉 1/3, 系统刚说「卖不卖由你定」一分钟后自己先卖了。
|
||||
ps = [pos(ts_code="600000.SH", price=13.0, total_qty=6000, avail_qty=6000,
|
||||
cushion_peak=0.20, cushion_pct=0.09, market_value=78_000)]
|
||||
m = {"600000.SH": mkt(ma5=12.0, high5=13.0)}
|
||||
sp = {"600000.SH": {"target_price": 12.5}}
|
||||
r1 = ae.scan(positions=ps, params=PARAMS, market=m, stock_params=sp)
|
||||
assert [c["action"] for c in r1["candidates"] if c["side"] == ae.SELL] == [ae.A_EXIT]
|
||||
# 第二轮: 模拟 proposal_service 把在途的到价清仓放进跳过集合
|
||||
r2 = ae.scan(positions=ps, params=PARAMS, market=m, stock_params=sp,
|
||||
skip={("600000.SH", ae.A_EXIT): "已有在途提议 PRP_x 在等人确认"})
|
||||
sells2 = [c for c in r2["candidates"] if c["side"] == ae.SELL]
|
||||
assert sells2 == [], f"在途清仓等人拍板时保垫减仓不许单独产出: {sells2}"
|
||||
whys = [s["why"] for s in r2["skipped"] if s["ts_code"] == "600000.SH"]
|
||||
assert any("在处理中" in w and "抢在人前面" in w for w in whys), whys
|
||||
# 在途的是 TRIM 时同理: 目标价到价也让路 (人已经在处理这只票的减持)
|
||||
r3 = ae.scan(positions=ps, params=PARAMS, market=m, stock_params=sp,
|
||||
skip={("600000.SH", ae.A_TRIM): "已有在途指令 INS_x (DISPATCHED)"})
|
||||
assert [c for c in r3["candidates"] if c["side"] == ae.SELL] == []
|
||||
# 跳过集合里只有买入侧时, 减持不受影响 (老行为)
|
||||
r4 = ae.scan(positions=ps, params=PARAMS, market=m, stock_params=sp,
|
||||
skip={("600000.SH", ae.A_ADD): "x"})
|
||||
assert [c["action"] for c in r4["candidates"] if c["side"] == ae.SELL] == [ae.A_EXIT]
|
||||
|
||||
|
||||
@case("策略票·挂了交易方案的票仍评目标价, 其余四类不评 (审查 2026-09-07 第 5 条)")
|
||||
def _():
|
||||
# 修之前 scan() 对策略票整只 continue, 目标价命令显示生效、到价那天什么都不发生。
|
||||
ps = [pos(ts_code="600000.SH", price=13.0, total_qty=6000, avail_qty=6000,
|
||||
cushion_peak=0.20, cushion_pct=0.09, market_value=78_000)]
|
||||
m = {"600000.SH": mkt(ma5=12.0, high5=13.0)}
|
||||
r = ae.scan(positions=ps, params=PARAMS, market=m, strategy_codes={"600000.SH"},
|
||||
stock_params={"600000.SH": {"target_price": 12.5}})
|
||||
acts = [c["action"] for c in r["candidates"]]
|
||||
assert acts == [ae.A_EXIT], f"策略票只该产出到价清仓: {acts}"
|
||||
assert r["candidates"][0]["needs_user_confirm"] is True
|
||||
assert any("你设的目标价仍照常看" in s["why"] for s in r["skipped"]), r["skipped"]
|
||||
# 没设目标价时策略票什么都不产出 (老行为一个字不变)
|
||||
r2 = ae.scan(positions=ps, params=PARAMS, market=m, strategy_codes={"600000.SH"})
|
||||
assert r2["candidates"] == [], r2["candidates"]
|
||||
|
||||
|
||||
@case("到价清仓·按 T+1 可卖量报数, 可卖为零不产出 (审查 2026-09-07 第 4 条)")
|
||||
def _():
|
||||
# 到价当天有买入成交时可卖量小于总持仓; 原先按总持仓报, 规则闸「卖出 > T+1 可卖」
|
||||
# 整条拒掉再被当日去重挡住, 人当天看不到这条到价提议。
|
||||
m = {"600000.SH": mkt(ma5=12.0, high5=13.0)}
|
||||
sp = {"600000.SH": {"target_price": 12.5}}
|
||||
p1 = pos(ts_code="600000.SH", price=13.0, total_qty=1000, avail_qty=800,
|
||||
cushion_pct=0.30, cushion_peak=0.30, market_value=13_000)
|
||||
r = ae.scan(positions=[p1], params=PARAMS, market=m, stock_params=sp)
|
||||
ex = [c for c in r["candidates"] if c["action"] == ae.A_EXIT]
|
||||
assert len(ex) == 1 and ex[0]["qty"] == 800, ex
|
||||
assert ex[0]["hard_numbers"]["total_qty"] == 1000 and ex[0]["hard_numbers"]["avail_qty"] == 800
|
||||
assert "今天只能卖 T+1 可卖的 800 股" in ex[0]["reason"], ex[0]["reason"]
|
||||
p0 = pos(ts_code="600000.SH", price=13.0, total_qty=1000, avail_qty=0,
|
||||
cushion_pct=0.30, cushion_peak=0.30, market_value=13_000)
|
||||
r0 = ae.scan(positions=[p0], params=PARAMS, market=m, stock_params=sp)
|
||||
assert not [c for c in r0["candidates"] if c["action"] == ae.A_EXIT], r0["candidates"]
|
||||
# 可卖量缺失 (None) 按总持仓, 不当成零 —— 旧数据行没有这个字段
|
||||
assert ae.clamp_sell_qty(6000, {"avail_qty": None}) == 6000
|
||||
assert ae.clamp_sell_qty(6000, {"avail_qty": 2000}) == 2000
|
||||
assert ae.clamp_sell_qty(6000, {"avail_qty": 0}) == 0
|
||||
assert ae.clamp_sell_qty(0, {"avail_qty": 9999}) == 0
|
||||
|
||||
|
||||
@case("扫描·批次额度与距目标空间口径")
|
||||
def _():
|
||||
p = pos(market_value=100_000)
|
||||
|
|
|
|||
Loading…
Reference in New Issue