PMS 两处安全修复:强制交人改为一票否决不再被卖出方向短路;目标价与止损价接通且必定交人
卖出侧:原判定把卖出方向放在或运算左边,强制交人的标记被整个短路。改为强制交人一票否决排在方向与档位之前。真正需要自动卖的两条路(风控高置信止损、用户命令清仓)不经提议分流,不受影响。 目标价:接通而非摘掉入口。到价产出清仓提议但必定交人,文案写明系统不自动卖;拿不到现价时不动。止损价参与规则检查,只告警不拦截。填 0 即取消,已写进命令说明与页面提示。 复核发现并修:同一轮可能同时产出保垫减仓与到价清仓,前者当场执行后者等人拍板,等人采纳时数量已超过实际持仓会被整条驳回。改为同轮只发一条减持,用户设的目标价优先。 测试:全套 ALL SUITES PASS,例数 626 增至 650。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
0610a7e328
commit
c6034a6220
|
|
@ -10,6 +10,7 @@
|
|||
| ADD 盈利加仓 | 安全垫 ≥ +3% 且创 5 日新高或站上压力位 | 距上次 ≥2 交易日; ≤ 单股上限; 距 MA5 <+6% |
|
||||
| DCA 补仓 | 浮亏触及 −8%/−15% 评估档 (各评估一次, 执行终身一次) | ≤ 底仓 50%; −15% 及更深永远需用户确认 |
|
||||
| TRIM 保垫减仓 | 安全垫峰值 ≥6% 且回吐过半 → 减 1/3 锁盈 | 纯规则自动执行 (减持方向不设确认门槛) |
|
||||
| EXIT 目标价到价 | 现价 ≥ 用户「设定某股目标价」命令里的价 | 全部持仓; **必定交人拍板, 绝不自动卖** |
|
||||
| OPEN 新建仓 | 上游候选池里的新票, 且还有持仓名额与可投金额 | 名额与金额边走边扣; 只提底仓批 |
|
||||
|
||||
本模块只回答「该不该动、动多少、为什么」, 不查库不下发:
|
||||
|
|
@ -24,7 +25,7 @@
|
|||
输出候选统一结构, 供 proposal_service 走 规则闸 → 研判闸 → 按自主档位分流。
|
||||
|
||||
扫描入口有两个, 输入不同, 互不影响:
|
||||
scan() 输入是**已有持仓**, 产出 FILL/ADD/DCA/TRIM (2026-08-06 之前就有的四类)
|
||||
scan() 输入是**已有持仓**, 产出 FILL/ADD/DCA/TRIM 与 EXIT (目标价到价, 2026-09-03 新增)
|
||||
scan_open() 输入是**上游候选池**, 产出 OPEN (2026-08-06 新增)
|
||||
候选池取不到时 scan() 照常跑, 反之亦然 —— 一条外部接口的故障不该让整轮扫描停摆。
|
||||
"""
|
||||
|
|
@ -40,8 +41,51 @@ from app.core.planner import check_all_caps, _ctx_after, _new_name_ctx
|
|||
|
||||
A_FILL, A_ADD, A_DCA, A_TRIM = "FILL", "ADD", "DCA", "TRIM"
|
||||
A_OPEN = "OPEN" # 新建仓 (与 planner.A_OPEN、executor.BUY_ACTIONS 同名同义)
|
||||
# 清仓 (与 planner.A_EXIT、executor.SELL_ACTIONS、页面动作词表同名同义)。
|
||||
# 目标价到价产出的退出提议用的就是它 —— 不新造动作名, 是为了让「采纳提议 → 落指令」那一步
|
||||
# (web/main.py 按动作反推买卖方向) 与执行器的清仓口径 (允许零股一次性卖出) 直接复用现成的路。
|
||||
A_EXIT = "EXIT"
|
||||
BUY, SELL = "buy", "sell"
|
||||
|
||||
# 同一轮里出现多条减持时留哪一条 (2026-09-03): 数字小的优先。用户自己设的目标价到价排在
|
||||
# 系统按规则算出来的保垫减仓前面 —— 人已经说了到价就清, 这一轮就不该再自作主张先卖一部分。
|
||||
# 只在同一只票的同一轮里比较, 不影响不同票, 也不影响下一轮。
|
||||
_SELL_PRIORITY = {A_EXIT: 0, A_TRIM: 1}
|
||||
|
||||
# 候选来源 (2026-09-03)。每条候选都带一个来源, 提议分流那一步拿它决定「这条减持能不能不等人」。
|
||||
#
|
||||
# 为什么需要这一项: 分流从前写的是「卖出方向一律自动执行」, 只看方向、不看来源。那条规矩
|
||||
# 的本意只有一条 —— 规则算出来的保垫减仓不该等人 —— 但写成只看方向之后, 将来任何一条卖出
|
||||
# 候选都会被同一条捷径放过去, 包括本该交人裁决的那些。来源把「哪种减持可以不等人」写明白,
|
||||
# 不再让方向替来源做这个决定。
|
||||
#
|
||||
# 要说清的边界: 决策系统高置信风控卖出的自动清仓**不走这条路** (signal_service 直接落卖出
|
||||
# 指令), 用户命令驱动的清仓也不走 (命令服务 → 方案生成器 → 执行器)。所以来源这一项管的
|
||||
# 只有动作引擎自己产出的候选, 以及将来接进提议分流的新来源。
|
||||
SRC_ENGINE = "engine" # 动作引擎按规则算出来的 (FILL / ADD / DCA / TRIM / OPEN)
|
||||
SRC_RESEARCH_WEAK = "research_weak" # 研究证据走弱触发的减持 —— 必定交人裁决, 绝不自动卖出
|
||||
SRC_TARGET_PRICE = "target_price" # 用户设的目标价到价 —— 必定交人裁决, 绝不自动卖出
|
||||
|
||||
WHY_RESEARCH_WEAK_CONFIRM = "研究证据走弱触发的减持,必须交人裁决,不自动卖出"
|
||||
WHY_TARGET_PRICE_CONFIRM = "你设的目标价到价,卖不卖由你决定,系统不自动卖出"
|
||||
|
||||
# 必定强制入人工队列的来源: {来源: 交给人的原因}。
|
||||
# 写成一份名单而不是一个 if, 是为了将来再出现「不许自动执行」的来源时加一行就够,
|
||||
# 提议分流那一段一个字都不用改。
|
||||
FORCE_QUEUE_SOURCES = {SRC_RESEARCH_WEAK: WHY_RESEARCH_WEAK_CONFIRM,
|
||||
SRC_TARGET_PRICE: WHY_TARGET_PRICE_CONFIRM}
|
||||
|
||||
|
||||
def source_confirm_why(source):
|
||||
"""这个来源要不要强制交人裁决: 要则回一句原因, 不要回 None。
|
||||
|
||||
认不出的来源回 None (走原有的方向与档位判定) —— 与判决值那条「认不出一律交人」相反,
|
||||
是因为两者问的不是同一件事: 判决是上游对这只票的结论, 词表变了说明我们看不懂它的意思;
|
||||
而来源是本系统内部给候选打的标记, 没打标记的就是动作引擎自己那几类老动作, 行为必须
|
||||
与从前逐字一致。新来源要不要强制交人, 由加它的人写进上面那份名单。
|
||||
"""
|
||||
return FORCE_QUEUE_SOURCES.get(str(source or "").strip()) or None
|
||||
|
||||
# 上游选股系统给每张候选卡的判决 (2026-09-02 起随计划接口每行下发; 旧版计划没有这个字段)。
|
||||
# 候选 门槛全过、无硬风险、确认线在 —— 走现有的自动流程 (规则闸 → 研判闸 → 档位分流)
|
||||
# 关注 门槛全过、无硬风险、但确认线缺失或陈旧 —— 这是上游说「无法判断」, 强制交人裁决
|
||||
|
|
@ -82,10 +126,26 @@ def _f(v, d=0.0):
|
|||
return d
|
||||
|
||||
|
||||
def _cand(p, action, side, qty, reason, hard, *, confirm=False):
|
||||
def _cand(p, action, side, qty, reason, hard, *, confirm=False, source=SRC_ENGINE):
|
||||
return {"ts_code": p["ts_code"], "action": action, "side": side, "qty": int(qty),
|
||||
"reason": reason, "hard_numbers": hard, "needs_user_confirm": bool(confirm),
|
||||
"judge_required": action in JUDGE_ACTIONS}
|
||||
"judge_required": action in JUDGE_ACTIONS, "source": source}
|
||||
|
||||
|
||||
def reduce_on_weak_research(ts_code, *, qty, reason, hard_numbers=None, action=A_TRIM):
|
||||
"""研究证据走弱触发的减持候选 —— **预留通道, 目前还没有上游在产出它** (2026-09-03)。
|
||||
|
||||
为什么空着也要先建: 减持这条路从前是「方向是卖就自动执行」, 研究证据走弱这类减持哪天
|
||||
接上来, 默认结果就是自动卖出; 而设计里这类减持必须交人裁决。通道与「必定入人工队列」
|
||||
的判定先钉在这里并由单测守住, 将来接上游时不必再想一遍, 也不会有人凭印象接错。
|
||||
|
||||
产出的候选与四类自主动作同结构, 可以直接送 proposal_service._route_one。它带三样东西:
|
||||
来源标记 (分流据此强制入队)、needs_user_confirm (与深档补仓同一个字段)、交人的原因。
|
||||
"""
|
||||
c = _cand({"ts_code": ts_code}, action, SELL, qty, reason, dict(hard_numbers or {}),
|
||||
confirm=True, source=SRC_RESEARCH_WEAK)
|
||||
c["confirm_why"] = WHY_RESEARCH_WEAK_CONFIRM
|
||||
return c
|
||||
|
||||
|
||||
def batch_amount(p: dict, params: dict, idx: int) -> float:
|
||||
|
|
@ -223,7 +283,55 @@ def eval_trim(p: dict, params: dict, mkt: dict = None):
|
|||
"price": p.get("price")})
|
||||
|
||||
|
||||
EVALUATORS = ((A_TRIM, eval_trim), (A_ADD, eval_add), (A_FILL, eval_fill), (A_DCA, eval_dca))
|
||||
def eval_target(p: dict, params: dict = None, mkt: dict = None):
|
||||
"""目标价到价: 现价摸到你设的目标价 —— 提议把这只票清掉, 交你拍板, 系统不自动卖。
|
||||
|
||||
这是「设定某股目标价」这条命令的落地处 (2026-09-03 接通)。在此之前这条命令只把价格
|
||||
存进命令表, 全库没有任何地方读它: 页面点得下去、状态会变成生效中、列表里看得见,
|
||||
而到价那天什么都不会发生 —— 设计里写的「触发即生成止盈提议」一直是空的。
|
||||
|
||||
三处口径是照抄现成的, 没有新阈值:
|
||||
触发 现价 ≥ 目标价。目标价是你自己填的数, 不是系统算的, 所以不设缓冲、不做平滑,
|
||||
也**不随收益读数回调** —— 系统在这件事上没有可调的旋钮。
|
||||
数量 全部持仓 (零股一并), 与「清仓某股」命令的 planner.plan_exit_stock 同一口径;
|
||||
动作名也用同一个 EXIT, 于是执行器的清仓择时 (允许零股一次性卖出) 直接复用。
|
||||
落点 与保垫减仓同层 —— 同样是动作引擎产出候选, 走 规则闸 → 提议分流。区别只有
|
||||
一条: 保垫减仓是纯规则算的、可以自动执行, 而目标价是**你的意思**, 到价只
|
||||
意味着「该问你了」, 不意味着「替你卖」。所以带上强制交人的来源标记, 分流
|
||||
那一步无论档位是不是 full 都必定落进等你拍板的队列。
|
||||
|
||||
没有现价 (停牌、行情断了) 时不产出: 扫描入口已经把 price_ok 为假的票整只挡掉了,
|
||||
这里再看一眼 price 本身, 是因为本函数也可能被单独调用。「拿不到价」绝不能折成「到价了」。
|
||||
"""
|
||||
target = _f(p.get("target_price"))
|
||||
if target <= 0:
|
||||
return None # 没设目标价, 这条动作根本不存在
|
||||
if p.get("price_ok") is False:
|
||||
return None # 价是拿摊薄成本顶的, 不是行情
|
||||
price = _f(p.get("price"))
|
||||
if price <= 0:
|
||||
return None # 取不到现价, 宁可不动
|
||||
if price < target:
|
||||
return None # 还没到价
|
||||
qty = int(p.get("total_qty") or 0)
|
||||
if qty <= 0:
|
||||
return None
|
||||
c = _cand(p, A_EXIT, SELL, qty,
|
||||
f"目标价到价: 现价 {price} 已到你设的目标价 {target}, "
|
||||
f"拟清仓 {qty} 股 (含零股); 卖不卖由你拍板, 系统不自动卖",
|
||||
{"target_price": target, "price": price, "total_qty": qty,
|
||||
"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)
|
||||
c["confirm_why"] = WHY_TARGET_PRICE_CONFIRM
|
||||
return c
|
||||
|
||||
|
||||
EVALUATORS = ((A_TRIM, eval_trim), (A_EXIT, eval_target),
|
||||
(A_ADD, eval_add), (A_FILL, eval_fill), (A_DCA, eval_dca))
|
||||
|
||||
# 减持方向的动作 —— 冻结只禁增持, 这几类照评 (与规则闸 _check_sell 同一口径)。
|
||||
SELL_SIDE_ACTIONS = (A_TRIM, A_EXIT)
|
||||
|
||||
|
||||
# ================================================================ 跳过原因
|
||||
|
|
@ -251,16 +359,21 @@ def skip_why(skip, key):
|
|||
|
||||
# ================================================================ 扫描入口
|
||||
def scan(*, positions: list, params: dict, market: dict, skip=None,
|
||||
strategy_codes=None) -> dict:
|
||||
strategy_codes=None, stock_params=None) -> dict:
|
||||
"""扫描全部持仓, 产出候选动作。
|
||||
|
||||
positions: portfolio.positions_view()["held"] 的口径
|
||||
market: {ts_code: {ma5, high5, tdays_since_open, tdays_since_last_add}}
|
||||
skip: 不再评估的 (代码, 动作) —— 字典时值是跳过原因, 集合时退回兜底话
|
||||
stock_params: 个股参数命令的当前值 {ts_code: {target_price, stop_price, black, ...}},
|
||||
由 command_service.effective_stock_params() 给出。**事实源是命令表**,
|
||||
不是持仓行上的投影 —— 投影每天盘前会被参考位取数覆盖, 拿它做判断会
|
||||
在第二天早上悄悄失效。不传时目标价这条动作不评估, 其余四类行为不变。
|
||||
返回 {"candidates": [...], "skipped": [...]}
|
||||
"""
|
||||
skip = skip or {}
|
||||
strategy_codes = strategy_codes or set()
|
||||
stock_params = stock_params or {}
|
||||
out, skipped = [], []
|
||||
for p in positions or []:
|
||||
code = p.get("ts_code")
|
||||
|
|
@ -282,6 +395,11 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
|
|||
"why": "取不到现价 (price 是拿摊薄成本顶的), 本轮不评估该票"})
|
||||
continue
|
||||
mkt = (market or {}).get(code) or {}
|
||||
# 个股参数命令的当前值并进这一票的快照, 供目标价那条动作读。只加不覆盖既有字段,
|
||||
# 且是**这一轮的副本**, 不动调用方传进来的持仓行。
|
||||
sp = stock_params.get(code) or {}
|
||||
if sp:
|
||||
p = {**p, "target_price": sp.get("target_price"), "stop_price": sp.get("stop_price")}
|
||||
frozen = (p.get("frozen_reason") or "NONE") != "NONE"
|
||||
cands_this = []
|
||||
for action, fn in EVALUATORS:
|
||||
|
|
@ -290,7 +408,7 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
|
|||
skipped.append({"ts_code": code, "action": action, "why": why})
|
||||
continue
|
||||
# 冻结只禁增持, 减仓照评 (与规则闸同一口径, 这里先剪枝少算一遍)
|
||||
if frozen and action != A_TRIM:
|
||||
if frozen and action not in SELL_SIDE_ACTIONS:
|
||||
skipped.append({"ts_code": code, "action": action,
|
||||
"why": f"该股 {p['frozen_reason']}, 禁增持"})
|
||||
continue
|
||||
|
|
@ -304,14 +422,33 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
|
|||
cands_this.append(c)
|
||||
# 同一只票同轮买卖互斥 (2026-08-28 审查修): 保垫减仓的峰值是**全时段**只增不减,
|
||||
# 加仓判据看的是近 5 日窗口, 两套时间基准可以同时成立 —— 一轮里对同一只票
|
||||
# 一边 TRIM 锁盈一边 ADD 加仓, 自动对倒空耗手续费。触发 TRIM 时买入侧让路
|
||||
# (先落袋为安, 保守方向优先), 买入条件真成立的话下一轮 TRIM 不触发时自然会来。
|
||||
if any(c["action"] == A_TRIM for c in cands_this):
|
||||
# 一边 TRIM 锁盈一边 ADD 加仓, 自动对倒空耗手续费。触发减持时买入侧让路
|
||||
# (先落袋为安, 保守方向优先), 买入条件真成立的话下一轮不触发减持时自然会来。
|
||||
# 2026-09-03: 判据从「有没有 TRIM」放宽成「有没有减持」—— 目标价到价那条也是减持,
|
||||
# 一边提议清仓一边提议加仓同样是自相矛盾, 不该只有保垫减仓享受这条互斥。
|
||||
sells_this = [c["action"] for c in cands_this if c["side"] == SELL]
|
||||
if sells_this:
|
||||
for c in cands_this:
|
||||
if c["side"] == BUY:
|
||||
skipped.append({"ts_code": code, "action": c["action"],
|
||||
"why": "同轮已触发保垫减仓(TRIM), 买卖互斥, 买入侧让路"})
|
||||
"why": f"同轮已触发减持({'/'.join(sells_this)}), "
|
||||
f"买卖互斥, 买入侧让路"})
|
||||
cands_this = [c for c in cands_this if c["side"] != BUY]
|
||||
# 同一轮里只留一条减持 (2026-09-03 修)。两条减持同时留下会让人的操作落空:
|
||||
# 保垫减仓不需要确认、当场就卖掉一部分, 而到价清仓要等人拍板, 它记的股数是扫描
|
||||
# 那一刻的持仓; 等人第二天点采纳, 持仓已经被前一条卖少了, 落指令时数量超过实际
|
||||
# 持仓, 卖出前的检查会整条驳回 —— 人点了「清掉」, 结果一股没卖, 只在评审账本上
|
||||
# 留下一条拒绝。留哪一条按「人的意思优先」: 用户自己设的目标价到价排在系统按规则
|
||||
# 算出来的减仓前面。被让路的那条记进跳过原因, 下一轮条件仍成立时自然会再来。
|
||||
sells_now = [c for c in cands_this if c["side"] == SELL]
|
||||
if len(sells_now) > 1:
|
||||
keep = min(sells_now, key=lambda c: _SELL_PRIORITY.get(c["action"], 99))
|
||||
for c in sells_now:
|
||||
if c is not keep:
|
||||
skipped.append({"ts_code": code, "action": c["action"],
|
||||
"why": f"同轮已有优先级更高的减持({keep['action']}), "
|
||||
f"一轮只发一条减持, 本条让路"})
|
||||
cands_this = [c for c in cands_this if c["side"] != SELL or c is keep]
|
||||
out.extend(cands_this)
|
||||
return {"candidates": out, "skipped": skipped}
|
||||
|
||||
|
|
|
|||
|
|
@ -212,12 +212,22 @@ SPECS = {
|
|||
"cls": CLS_PARAM, "label": "设定某股止损价", "group": "C", "scope": "stock",
|
||||
"fields": {"ts_code": _f(F_CODE), "price": _f(F_PRICE, min=0)},
|
||||
"projection": {"stop_ref": "@price", "ref_source": "user"},
|
||||
"note": "覆盖系统参考位; 触发即生成卖出方案",
|
||||
# 2026-09-03 改口径 (原文写的是「触发即生成卖出方案」, 而实现里从来没有这一步):
|
||||
# 止损价现在参与规则检查, 但**只披露不拦截、也不自动卖**。跌破时每条买入指令的
|
||||
# 评审账本上会带一句 STOP_BREACHED, 页面参考位列也显示它。要真卖请下清仓/减持命令。
|
||||
"note": "覆盖系统参考位; 跌破只在合规检查里告警留痕, 不自动卖出",
|
||||
},
|
||||
"SET_TARGET_PRICE": {
|
||||
"cls": CLS_PARAM, "label": "设定某股目标价", "group": "C", "scope": "stock",
|
||||
"fields": {"ts_code": _f(F_CODE), "price": _f(F_PRICE, min=0)},
|
||||
"note": "触发即生成止盈提议 (事实源为本命令最新 EFFECTIVE 记录)",
|
||||
# 2026-09-03 接通: 动作引擎每轮扫描读本命令最新 EFFECTIVE 记录, 现价到价即产出一条
|
||||
# 清仓提议 (动作 EXIT, 全部持仓), 且**必定进「等我拍板」队列** —— 自主档位是 full
|
||||
# 也不自动卖。落点见 action_engine.eval_target。
|
||||
# 取消的办法就是再下一次本命令、价格填 0 (字段下限是 0, 页面也接受)。填 0 之后
|
||||
# 目标价当作没设过, 不再产出到价提议。写在这里是因为没有单独的清除命令, 不写清楚
|
||||
# 的话被驳回的到价提议每天都会回到「等我拍板」, 而人找不到关掉它的办法。
|
||||
"note": "到价产出清仓提议交你拍板, 不自动卖; 再下一次本命令、价格填 0 即取消 "
|
||||
"(事实源为本命令最新 EFFECTIVE 记录)",
|
||||
},
|
||||
"BLACKLIST_ADD": {
|
||||
"cls": CLS_PARAM, "label": "加入黑名单(永不买入)", "group": "C", "scope": "stock",
|
||||
|
|
|
|||
|
|
@ -52,8 +52,12 @@ def check(*, side: str, action: str, qty: int, price: float, ctx: dict) -> dict:
|
|||
caps check_all_caps 所需组合上下文 (仅买入用)
|
||||
day {price, vwap, ma5, day_chg_from_open, halted, limit_up, limit_down}
|
||||
params {no_chase_ma5, buy_halt_dayup, sector_source_ready}
|
||||
flags {buy_halt, exec_halt, brake_active, blacklisted, is_command, y_signal}
|
||||
flags {buy_halt, exec_halt, brake_active, blacklisted, is_command, y_signal,
|
||||
stop_price}
|
||||
y_signal 是决策系统昨夜结论对该股的定性, 由择时应答带回 (可能为 None)
|
||||
stop_price 是用户「设定某股止损价」命令里的价 (可能为 None), 事实源是
|
||||
命令表 —— 持仓行上的 stop_ref 每天盘前会被参考位取数覆盖,
|
||||
拿投影做判断会在第二天早上悄悄失效
|
||||
"""
|
||||
pos = ctx.get("position") or {}
|
||||
day = ctx.get("day") or {}
|
||||
|
|
@ -84,6 +88,8 @@ def check(*, side: str, action: str, qty: int, price: float, ctx: dict) -> dict:
|
|||
if flg.get("exec_halt"):
|
||||
failed.append("EXEC_HALT: 全局暂停执行 (休假模式) 生效中")
|
||||
|
||||
_check_user_stop(warns, side, price, flg, hard)
|
||||
|
||||
ml = lot_of(ctx.get("ts_code")) # 最小申报数量 (科创板 200, 其余 100)
|
||||
if side == SELL:
|
||||
_check_sell(failed, warns, qty, total_qty, avail_qty, day, ml)
|
||||
|
|
@ -95,6 +101,34 @@ def check(*, side: str, action: str, qty: int, price: float, ctx: dict) -> dict:
|
|||
return {"passed": not failed, "failed": failed, "warnings": warns, "hard_numbers": hard}
|
||||
|
||||
|
||||
def _check_user_stop(warns, side, price, flg, hard):
|
||||
"""用户设的止损价: **只披露不拦截** (2026-09-03 接通「设定某股止损价」这条命令)。
|
||||
|
||||
在此之前这条命令的价格只存进命令表, 全库没有一处读它 —— 页面能设、状态是生效中,
|
||||
而跌破那天什么都不会发生。这里把它接进规则闸的告警层, 与既有的敞口披露、
|
||||
LIMIT_DOWN、CASH_ESTIMATED 同一层: 进 warnings, 同时无条件写进 hard_numbers,
|
||||
于是每一条指令的评审账本上都查得到「当时你设的止损价是多少、破没破」。
|
||||
|
||||
为什么只告警不拦: 止损价是**你的意思**, 不是一道硬约束。真按它自动卖出属于
|
||||
「系统替你决定卖」, 不在纪律允许的范围内; 而拿它去拦别的指令 (比如禁止在止损位
|
||||
之下买入) 是凭空多出一条没人拍过板的规则。所以这里只做一件事 —— 让它被看见。
|
||||
告警只在增持方向发一句: 在自己设的止损位之下还加仓, 是最该被人看见的一种情形;
|
||||
减持方向本来就是止损位该做的事, 不必再提示一遍。
|
||||
|
||||
**没有告警不等于没跌破** —— 取不到现价 (price<=0) 或没设止损价时这里什么都不做,
|
||||
hard_numbers 里 stop_price 会是 None, 与「设了且没破」在账本上分得开。
|
||||
"""
|
||||
stop = _num(flg.get("stop_price"))
|
||||
hard["stop_price"] = stop or None
|
||||
if stop <= 0 or price <= 0:
|
||||
return
|
||||
breached = price <= stop
|
||||
hard["stop_breached"] = breached
|
||||
if breached and side == BUY:
|
||||
warns.append(f"STOP_BREACHED: 现价 {price} 已跌破你设的止损价 {stop}, 本单仍是买入 "
|
||||
f"—— 只提示不拦截; 止损价也不会自动挂单卖出")
|
||||
|
||||
|
||||
def _check_sell(failed, warns, qty, total_qty, avail_qty, day, min_lot: int = LOT):
|
||||
"""减持方向: 冻结/刹车/上限一概不拦, 只看「卖得出去吗」。"""
|
||||
if qty > total_qty:
|
||||
|
|
|
|||
|
|
@ -281,6 +281,9 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict:
|
|||
"flags": {"buy_halt": prm["buy_halt"], "exec_halt": prm["exec_halt"],
|
||||
"brake_active": brake_active,
|
||||
"blacklisted": bool(stock_params.get(code, {}).get("black")),
|
||||
# 用户设的止损价, 与黑名单同一份事实源 (命令表)。规则闸拿它
|
||||
# 只告警不拦截, 告警随 hard_numbers 落进评审账本。
|
||||
"stop_price": stock_params.get(code, {}).get("stop_price"),
|
||||
"is_command": bool(prog.get("is_command")),
|
||||
# 决策系统昨夜对该股的定性, 由择时应答带回 (退实现B 时为 None)
|
||||
"y_signal": d.get("y_signal")}})
|
||||
|
|
|
|||
|
|
@ -90,6 +90,46 @@ FAIL_CLOSED = {
|
|||
"PMS_AUTO_STRATEGY_ENABLED": False,
|
||||
}
|
||||
|
||||
# ================================================================================
|
||||
# 页面能改、但代码里没有任何地方读它的参数 (2026-09-03 全库清点)
|
||||
# ================================================================================
|
||||
# 清点方法, 想复核照做即可: 拿 DESC / RUNTIME_EXTRA / settings 里 PMS_ 开头、页面能改的
|
||||
# 全部键名 (当日 179 个), 逐个在 app/ 下做整词检索, 并把两类**不算读取**的文本剔掉 ——
|
||||
# 一是本文件里 DESC、RUNTIME_EXTRA、SECRET_KEYS、FAIL_CLOSED、INFRA_PREFIX 这几处声明
|
||||
# (它们只是登记), 二是全库的注释与文档字符串 (说到不等于读到; PMS_T0_CLOSE_TIME 当初就是
|
||||
# 靠一句文档字符串把自己藏了一年)。剩下零命中的就是「能改但不生效」。
|
||||
# 只在 sizing_params() 里出现的键还要再跟一层短名 (它把 PMS_XXX 折成 batch_split 这类短名
|
||||
# 交给纯逻辑层), 当日三个 (分批比例、一手合并、清弱票天数) 都跟到了真实读取处, 不算在内。
|
||||
# 这份清点由 scripts/test_batch10_units.py 的 [P] 组守着 —— 名单变了单测就红, 逼人来这里
|
||||
# 记一笔, 不让新的「改了不生效」再悄悄长出来。
|
||||
#
|
||||
# 一、参数表里零读取处的四个 (页面可改, 改了什么都不会发生):
|
||||
# PMS_REPLAY_INTERVAL_MIN 说明写「成交回放间隔 (分钟)」, 而回放的调度位在 scheduler.py
|
||||
# 里写死是 crontab(minute="*") 每分钟一跳, 这个数根本没人读。
|
||||
# PMS_T0_CLOSE_TIME 说明写「T仓强制平回时点」, 同一种毛病: 调度位写死在
|
||||
# crontab(hour=14, minute=50)。页面把它改成 14:30 不会有任何
|
||||
# 效果, 而这是一条与过夜风险直接相关的时点。
|
||||
# 以上两个要么让调度位读它, 要么把它连同说明一起摘掉 —— 一条
|
||||
# 时点参数摆在页面上而调度位不认, 比没有这条参数更危险。
|
||||
# PMS_RISK_WARN_ENTRY 单笔敞口告警线。sizer.risk_warnings() / risk_exposure() 这对
|
||||
# PMS_RISK_WARN_PORTFOLIO 函数写完之后**从来没有被 app/ 里任何地方调用过** (只有单测在
|
||||
# 调), 于是这两条披露线是两个纯粹的摆设。风险披露要不要真做,
|
||||
# 是一件要拍板的事, 不是改个数就能生效的事。
|
||||
#
|
||||
# 二、个股参数命令里同一种毛病 (不在本表, 但病因一样, 一并记在这里):
|
||||
# 设定某股目标价 SET_TARGET_PRICE 2026-09-03 已接通 —— 动作引擎 eval_target 到价产出
|
||||
# 清仓提议, 必定交人拍板。接通前它只存进命令表没人读。
|
||||
# 设定某股止损价 SET_STOP_PRICE 2026-09-03 已接进规则闸的告警层 (只披露不拦截,
|
||||
# 不自动卖)。但它投影到持仓行的 stop_ref 每天盘前会被
|
||||
# ledger_service.premarket() 的参考位取数覆盖掉 ——
|
||||
# 页面参考位列第二天显示的就不再是你设的那个价了。
|
||||
# 所以判断一律读命令表 (事实源), 别读投影。这处覆盖
|
||||
# 本身还没修, 见交接里的遗留问题。
|
||||
# 做T授权 T0_ENABLE 只把 t0_enabled / t0_ratio 投影到持仓行给页面显示,
|
||||
# 没有任何代码把它当成「可以做T」的授权来检查 (真正的
|
||||
# 做T由单独挂的交易方案驱动)。要不要把它变成硬授权,
|
||||
# 需要拍板, 本次不动。
|
||||
#
|
||||
# 页面展示用的中文说明 (settings.py 用行尾注释, pydantic 取不到, 故在此集中维护)
|
||||
DESC = {
|
||||
"PMS_TOTAL_SCALE": "总操作规模 (元) —— 所有百分比约束的分母",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@
|
|||
off 不扫描
|
||||
|
||||
两条无条件覆盖档位的规矩:
|
||||
* **减持方向不设确认门槛** —— TRIM 保垫减仓属纯规则自动执行, 任何档位都直接落指令。
|
||||
* **规则算出来的减持不设确认门槛** —— TRIM 保垫减仓属纯规则自动执行, 任何档位都直接落
|
||||
指令。但这一条覆盖的是**档位**, 不覆盖「强制入人工队列」这个标记 (2026-09-03 修): 带了
|
||||
这个标记的减持一律交人, 方向是卖也不例外。哪种减持带标记由候选的来源说了算, 见
|
||||
`action_engine.source_confirm_why` 与 `_route_one` 里那段说明。
|
||||
* **−15% 及更深的补仓永远需用户确认** —— 即便档位是 full, 也强制入队。
|
||||
|
||||
研判闸不可用时 (决策系统未接通/超时), 按设计自动降级为 propose_only + ERROR 告警,
|
||||
|
|
@ -77,6 +80,9 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
|
|||
try:
|
||||
view = portfolio.positions_view()
|
||||
params = _scan_params(view)
|
||||
# 个股参数命令的当前值 (黑名单、目标价、止损价…)。取数提到扫描之前, 因为动作引擎
|
||||
# 现在要用它评「目标价到价」那条动作; 后面分流与规则闸用的是同一份, 不重复查。
|
||||
stock_params = command_service.effective_stock_params()
|
||||
mkt = _market_ctx(view["held"], now)
|
||||
params["_mkt"] = mkt # 规则闸要用同一份 MA5, 不再重取
|
||||
# 跳过三类: ①已有在途提议或指令的 ②今天已被规则闸拒过的 ③今天已被研判闸驳回的新建仓。
|
||||
|
|
@ -98,7 +104,7 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
|
|||
except Exception:
|
||||
strategy_codes = set()
|
||||
scanned = ae.scan(positions=view["held"], params=params, market=mkt, skip=skip,
|
||||
strategy_codes=strategy_codes)
|
||||
strategy_codes=strategy_codes, stock_params=stock_params)
|
||||
except Exception as e:
|
||||
logger.exception("提议扫描失败")
|
||||
return {**out, "ok": False, "errors": [f"扫描失败: {type(e).__name__}: {e}"]}
|
||||
|
|
@ -119,7 +125,6 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
|
|||
|
||||
out["candidates"] = len(scanned["candidates"])
|
||||
out["skipped"].extend(scanned["skipped"])
|
||||
stock_params = command_service.effective_stock_params()
|
||||
brake_active = td.ymd() < param_store.get_int("PMS_BRAKE_UNTIL", 0)
|
||||
|
||||
# ---- 新建仓: 候选池那一路 (取不到候选池不影响上面四类, 反之亦然) ----
|
||||
|
|
@ -435,6 +440,9 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
|
|||
"flags": {"buy_halt": params.get("buy_halt"), "exec_halt": params.get("exec_halt"),
|
||||
"brake_active": brake_active,
|
||||
"blacklisted": bool(stock_params.get(code, {}).get("black")),
|
||||
# 用户设的止损价, 与黑名单同一份事实源 (命令表)。规则闸拿它只告警
|
||||
# 不拦截, 见 rule_gate 里那段说明。
|
||||
"stop_price": stock_params.get(code, {}).get("stop_price"),
|
||||
"is_command": False}})
|
||||
if not gate["passed"]:
|
||||
out["rejected"].append({"ts_code": code, "action": action, "by": "rule",
|
||||
|
|
@ -474,11 +482,30 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
|
|||
# ---- 三级: 按档位分流 ----
|
||||
# 新建仓走自己的档位 (PMS_OPEN_AUTONOMY), 不跟随全局 —— 「新建仓要不要人点头」和
|
||||
# 「加仓要不要人点头」是两个不同的决定。全局 off 已经在 scan_and_route 入口拦掉了,
|
||||
# 走到这里说明总闸是开的。三条无条件覆盖档位的规矩对新建仓一样有效: 减持自动、
|
||||
# 深档补仓强制确认、研判不可用一律入队。
|
||||
# 走到这里说明总闸是开的。三条无条件覆盖档位的规矩对新建仓一样有效: 规则算出来的减持
|
||||
# 自动执行、深档补仓强制确认、研判不可用一律入队。
|
||||
autonomy = (out.get("open_autonomy") or AUTONOMY_FULL) if is_open else out["autonomy"]
|
||||
force_queue = bool(c.get("needs_user_confirm")) or verdict.get("degraded")
|
||||
auto_exec = (side == "sell") or (autonomy == AUTONOMY_FULL and not force_queue)
|
||||
# 强制入人工队列是**一票否决**, 排在方向与档位前面 (2026-09-03 修)。原来这一行是
|
||||
# auto_exec = (side == "sell") or (autonomy == AUTONOMY_FULL and not force_queue)
|
||||
# 卖出方向在或运算的左边, 把 force_queue 整个短路了 —— 方向是卖, 「强制入人工队列」
|
||||
# 这个标记就不起作用。今天还没出事, 是因为走到这里的卖出候选只有保垫减仓一种, 它既不
|
||||
# 强制确认也不送研判, force_queue 恒为假; 但设计里明确要求「研究证据走弱触发的减持必须
|
||||
# 交人裁决、绝不自动卖」, 那类减持一旦接上来, 结果会是自动卖出。
|
||||
#
|
||||
# 为什么选一票否决而不是按来源开白名单: 白名单要求「谁可以自动卖」有一份完整清单, 而
|
||||
# 这条路上真正需要自动卖的两条 —— 决策系统高置信风控卖出的自动止损、用户命令驱动的清仓
|
||||
# —— 根本不经过提议分流 (前者是 signal_service 直接落卖出指令, 后者是命令服务 → 方案
|
||||
# 生成器 → 执行器), 白名单在这里会是一份空转的清单。而 force_queue 这个名字本来就承诺了
|
||||
# 「强制入人工队列」, 让它对所有方向都算数, 是把这个名字兑现, 不是新加一条规矩。
|
||||
# 来源标记仍然要有, 但它的职责是让新来源能声明自己必须交人 (见 ae.source_confirm_why),
|
||||
# 不是去给已有的自动止损发通行证。
|
||||
#
|
||||
# 保留下来的行为: 规则算出来的保垫减仓照旧自动执行 (它的 force_queue 是假), 风控高置信
|
||||
# 卖出与命令清仓两条路一个字没碰。
|
||||
src_why = ae.source_confirm_why(c.get("source"))
|
||||
force_queue = (bool(c.get("needs_user_confirm")) or bool(src_why)
|
||||
or bool(verdict.get("degraded")))
|
||||
auto_exec = (not force_queue) and (side == "sell" or autonomy == AUTONOMY_FULL)
|
||||
# 自动执行开关 (2026-09-03, PMS_OPEN_AUTO_EXEC_ON_VERDICT): 新建仓档位是 propose_only 时,
|
||||
# 「判决候选 + 决策系统研判真回了通过 + 规则闸通过 (走到这里就是通过了) + 上游风险列表
|
||||
# 为空」四条齐, 这一条按 full 处理。强制入队的 (关注 / 深档补仓 / 研判不可用) 永远不走。
|
||||
|
|
@ -505,16 +532,18 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
|
|||
verdict="PASS", price_at=price, hard_numbers=c["hard_numbers"],
|
||||
ref_id=iid, reason=reason[:500])
|
||||
out["executed"].append({**_brief(c), "instruction_id": iid,
|
||||
"why": ("减持方向自动执行" if side == "sell"
|
||||
"why": ("减持自动执行 (规则触发, 来源没有要求交人裁决)"
|
||||
if side == "sell"
|
||||
else (auto_why or ("新建仓档位 full" if is_open
|
||||
else "档位 full")))})
|
||||
else:
|
||||
pid = _make_proposal(c, price, verdict)
|
||||
# 强制确认的原因: 候选自带的 (关注判决等, 见 action_engine.verdict_confirm_why)
|
||||
# 优先, 没有就是深档补仓那条老规矩。
|
||||
why = ((c.get("confirm_why") or "深档补仓强制确认") if c.get("needs_user_confirm")
|
||||
else ("研判不可用, 降级人工确认" if verdict.get("degraded")
|
||||
else ("新建仓档位 propose_only" if is_open else "档位 propose_only")))
|
||||
# 交人的原因按从具体到笼统取: 候选自带的 (关注判决等, 见 action_engine.verdict_confirm_why)
|
||||
# → 来源强制的 (研究证据走弱那类减持) → 深档补仓那条老规矩 → 研判不可用 → 档位。
|
||||
why = (c.get("confirm_why") or src_why
|
||||
or ("深档补仓强制确认" if c.get("needs_user_confirm") else None)
|
||||
or ("研判不可用, 降级人工确认" if verdict.get("degraded") else None)
|
||||
or ("新建仓档位 propose_only" if is_open else "档位 propose_only"))
|
||||
out["queued"].append({**_brief(c), "proposal_id": pid, "why": why})
|
||||
|
||||
|
||||
|
|
@ -647,6 +676,9 @@ def _make_proposal(c, price, verdict) -> str:
|
|||
pid = f"PRP_{td.ymd()}_{c['ts_code'].replace('.', '')}_{c['action']}"
|
||||
hn = {**(c.get("hard_numbers") or {}), "price": price, "reason": c["reason"],
|
||||
"needs_user_confirm": c.get("needs_user_confirm", False),
|
||||
# 候选来源 (2026-09-03): 人在「等我拍板」里要看得出这条减持是规则算的还是研究
|
||||
# 证据走弱推来的 —— 两者该不该点头是两回事。没有来源的按动作引擎自身处理。
|
||||
"source": c.get("source") or ae.SRC_ENGINE,
|
||||
# 研判应答的结论与置信度 (2026-09-03): 人裁决时要看得见决策系统怎么说、有多确定。
|
||||
# judge_reason 另有一列, 这两项进硬数字是为了随账本走 (采纳/驳回时原样落账)。
|
||||
"judge_verdict": verdict.get("verdict"), "judge_conf": verdict.get("confidence")}
|
||||
|
|
|
|||
|
|
@ -2392,12 +2392,14 @@ createApp({
|
|||
}
|
||||
async function actStop(row) {
|
||||
const v = await promptNum('现价 ' + row.price + ',成本 ' + (row.avg_cost || '—')
|
||||
+ '。跌破这个价就挂单卖出,填多少元?', '设止损 ' + nm(row.ts_code));
|
||||
+ '。止损价只做提醒:跌破了会在合规检查里留一条记录,系统不会替你挂单卖出。'
|
||||
+ '真要卖请下清仓或减持命令。填多少元?', '设止损 ' + nm(row.ts_code));
|
||||
if (v != null) quickCmd('SET_STOP_PRICE', { ts_code: row.ts_code, price: v });
|
||||
}
|
||||
async function actTargetPrice(row) {
|
||||
const v = await promptNum('现价 ' + row.price + ',成本 ' + (row.avg_cost || '—')
|
||||
+ '。目标价(元,到价生成止盈提议)', '设目标价 ' + nm(row.ts_code));
|
||||
+ '。目标价(元):到价后生成一条清仓提议放进「等我拍板」,卖不卖由你点头,'
|
||||
+ '系统不自动卖。不想要了就再设一次、填 0 即取消。', '设目标价 ' + nm(row.ts_code));
|
||||
if (v != null) quickCmd('SET_TARGET_PRICE', { ts_code: row.ts_code, price: v });
|
||||
}
|
||||
function actFreeze(row) {
|
||||
|
|
|
|||
|
|
@ -7,15 +7,18 @@
|
|||
包含 (例数按 2026-09-03 实跑校正; 此前写的 591 是过期数字, 当时实跑已是 608):
|
||||
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例)
|
||||
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例)
|
||||
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 (25 例)
|
||||
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 (11 例)
|
||||
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 / 用户止损价只披露不拦截 (26 例)
|
||||
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 +
|
||||
目标价到价产出清仓候选 (必定交人裁决) +
|
||||
同轮只发一条减持 (到价清仓优先于保垫减仓) (15 例)
|
||||
test_batch5_units.py 决策系统信号流解析与消化口径 (8 例)
|
||||
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/弃洞/DDL/逐笔入账 (68 例)
|
||||
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫/
|
||||
候选卡五键透传与截断/仅展示在候选阶段剔除 (35 例)
|
||||
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例)
|
||||
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 例)
|
||||
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (45 例)
|
||||
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 +
|
||||
页面能改的参数必须有人读 ([P] 组静态扫描) (47 例)
|
||||
test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 例)
|
||||
test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/
|
||||
转多信号插队与留痕/窗口末日只对命令/跳过原因分得开/
|
||||
|
|
@ -44,8 +47,12 @@
|
|||
开关四条件与任一缺失仍入队/信号来源区分/密钥名单
|
||||
含会话密钥/研判键放行与必答题/置信度进硬数字/
|
||||
页面静态守卫 (15 例)
|
||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (67 例)
|
||||
共 626 例
|
||||
test_batch21_units.py 减持的自动执行边界 (2026-09-03 安全修复): 保垫减仓照旧自动/
|
||||
强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱
|
||||
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流 (15 例)
|
||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) +
|
||||
目标价到价必定入队 (档位 full 也不自动卖) (69 例)
|
||||
共 649 例
|
||||
任一子集失败即整体失败 (退出码 1)。
|
||||
|
||||
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
|
||||
|
|
@ -64,6 +71,12 @@
|
|||
密钥名单 scripts/test_batch6_units.py 用例「密钥不进 ParamStore (协议 §10.1.1)」(约第
|
||||
642 行) 与 scripts/test_batch20_units.py 用例「密钥名单·会话密钥」——
|
||||
新增密钥类配置要同时进 param_store.SECRET_KEYS 与这两处断言
|
||||
死参数名单 scripts/test_batch10_units.py 的 KNOWN_DEAD ([P] 组) 与 param_store.py 顶部
|
||||
那段清点 —— 「页面能改而全库没人读」的参数只能同时改这两处。接通了、摘掉了、
|
||||
或者又添了一个摆设, 都要在这里过一遍手
|
||||
动作求值器 scripts/test_batch12_units.py 用例「回归·既有四类动作的判据与研判范围没被
|
||||
动过」—— action_engine.EVALUATORS 的名单与次序钉在那里 (次序有意义: 减持排
|
||||
在买入前面, 同轮买卖互斥才让得了路)
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
|
|
@ -78,6 +91,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
|
|||
"test_batch13_units.py", "test_batch14_units.py", "test_batch15_units.py",
|
||||
"test_batch16_units.py", "test_batch17_units.py",
|
||||
"test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py",
|
||||
"test_batch21_units.py",
|
||||
"test_wiring.py"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -960,6 +960,125 @@ def _():
|
|||
qmt_repo.get_order = orig
|
||||
|
||||
|
||||
# ================================================================
|
||||
# [P] 页面能改、但代码里没人读的参数 (静态扫描)
|
||||
# ================================================================
|
||||
# 与 [A] 组是同一种病的两张脸: [A] 管「写了没人接住」, [P] 管「改了没人读」。
|
||||
# 参数摆在设置页上、改完提示已保存、值也真进了表 —— 而全库没有一处读它。于是
|
||||
# 「把 T 仓强制平回改到 14:30」这件事什么都不会发生, 页面上还看不出来。
|
||||
#
|
||||
# 判据: 键名在 app/ 下**代码里**整词出现过一次就算有读取处。两类文本不算读取 ——
|
||||
# ① param_store 里 DESC / RUNTIME_EXTRA / SECRET_KEYS / FAIL_CLOSED / INFRA_PREFIX
|
||||
# 这几处声明 (它们是登记, 不是读取);
|
||||
# ② 全库的注释与文档字符串 (说到不等于读到 —— PMS_T0_CLOSE_TIME 当初就是靠
|
||||
# strategy_runner 里一句文档字符串把自己藏住的)。
|
||||
# 已知的死参数钉在 KNOWN_DEAD 里: 名单一变单测就红, 逼人去 param_store 那段清点里
|
||||
# 记一笔 —— 是接通了、是摘掉了、还是又新添了一个摆设。
|
||||
KNOWN_DEAD = {
|
||||
"PMS_REPLAY_INTERVAL_MIN", # 回放调度位写死 crontab(minute="*")
|
||||
"PMS_T0_CLOSE_TIME", # 平回调度位写死 crontab(hour=14, minute=50)
|
||||
"PMS_RISK_WARN_ENTRY", # sizer.risk_warnings() 从没被 app/ 调用过
|
||||
"PMS_RISK_WARN_PORTFOLIO", # 同上
|
||||
}
|
||||
_DECL_NAMES = ("DESC", "RUNTIME_EXTRA", "SECRET_KEYS", "FAIL_CLOSED", "INFRA_PREFIX")
|
||||
|
||||
|
||||
def _code_only(path, drop_param_decls=False):
|
||||
"""文件正文, 去掉注释与文档字符串 (非 .py 原样返回)。解析不了就退回原文 —— 宁可少报。"""
|
||||
import io
|
||||
import tokenize
|
||||
src = open(path, encoding="utf-8", errors="ignore").read()
|
||||
if not path.endswith(".py"):
|
||||
return src
|
||||
drop = set()
|
||||
try:
|
||||
tree = ast.parse(src)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef,
|
||||
ast.AsyncFunctionDef)):
|
||||
if ast.get_docstring(node, clean=False) is not None:
|
||||
first = node.body[0]
|
||||
drop.update(range(first.lineno, first.end_lineno + 1))
|
||||
if drop_param_decls:
|
||||
for node in tree.body:
|
||||
if (isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name)
|
||||
and node.targets[0].id in _DECL_NAMES):
|
||||
drop.update(range(node.lineno, node.end_lineno + 1))
|
||||
except SyntaxError:
|
||||
return src
|
||||
out = []
|
||||
try:
|
||||
for tok in tokenize.generate_tokens(io.StringIO(src).readline):
|
||||
if tok.type == tokenize.COMMENT or tok.start[0] in drop:
|
||||
continue
|
||||
out.append(tok.string)
|
||||
except (tokenize.TokenError, IndentationError):
|
||||
return src
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _unread_params():
|
||||
"""页面能改、而 app/ 里没有一处读它的参数键名 (排序后的列表)。"""
|
||||
import re
|
||||
from app.services import param_store as pstore
|
||||
from config.settings import settings
|
||||
ps_path = os.path.join(ROOT, "app/services/param_store.py")
|
||||
|
||||
keys = set(pstore.DESC) | set(pstore.RUNTIME_EXTRA)
|
||||
keys |= {n for n in type(settings).model_fields if n.startswith("PMS_")}
|
||||
# 与 param_store._editable_keys() 同一把尺子: 基础设施键与密钥页面本来就改不了
|
||||
keys = {k for k in keys
|
||||
if not any(k.startswith(p) for p in pstore.INFRA_PREFIX)
|
||||
and k not in pstore.SECRET_KEYS}
|
||||
|
||||
texts = []
|
||||
for d, _dirs, files in os.walk(os.path.join(ROOT, "app")):
|
||||
for f in sorted(files):
|
||||
if not f.endswith((".py", ".html", ".js")):
|
||||
continue
|
||||
p = os.path.join(d, f)
|
||||
texts.append(_code_only(p, drop_param_decls=(p == ps_path)))
|
||||
return sorted(k for k in keys
|
||||
if not any(re.search(r"\b" + k + r"\b", t) for t in texts))
|
||||
|
||||
|
||||
@case("[P1] 页面能改的参数必须有人读: 死参数名单只能照着 param_store 的清点走")
|
||||
def _():
|
||||
dead = set(_unread_params())
|
||||
added = sorted(dead - KNOWN_DEAD)
|
||||
gone = sorted(KNOWN_DEAD - dead)
|
||||
assert not added, ("这些参数页面能改, 而 app/ 里没有任何地方读它 —— 改了什么都不会发生, "
|
||||
"页面上还看不出来:\n " + "\n ".join(added)
|
||||
+ "\n要么接通它, 要么摘掉它; 确实要留着当摆设, 就写进 param_store.py "
|
||||
"顶部那段清点并同步 KNOWN_DEAD。")
|
||||
assert not gone, ("这些参数已经接通了 (或者被摘掉了), 但 KNOWN_DEAD 还留着它们:\n "
|
||||
+ "\n ".join(gone)
|
||||
+ "\n请同步 KNOWN_DEAD 与 param_store.py 顶部那段清点。")
|
||||
|
||||
|
||||
@case("[P2] 扫描器本身有效: 造一个只在注释/文档字符串里出现的参数, 必须算「没人读」")
|
||||
def _():
|
||||
# 守住守卫。注释与文档字符串一旦被当成读取处, [P1] 会永远绿 —— 那比没有守卫更糟,
|
||||
# 因为 PMS_T0_CLOSE_TIME 正是被一句文档字符串遮住的。
|
||||
import tempfile
|
||||
src = ('"""模块说明里提到 PMS_FAKE_DOC_ONLY。"""\n'
|
||||
"def f(param_store):\n"
|
||||
' """函数说明里提到 PMS_FAKE_DOC2。"""\n'
|
||||
" # 注释里提到 PMS_FAKE_COMMENT\n"
|
||||
' return param_store.get("PMS_FAKE_REAL")\n')
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False,
|
||||
encoding="utf-8") as fh:
|
||||
fh.write(src)
|
||||
tmp = fh.name
|
||||
try:
|
||||
body = _code_only(tmp)
|
||||
for gone in ("PMS_FAKE_DOC_ONLY", "PMS_FAKE_DOC2", "PMS_FAKE_COMMENT"):
|
||||
assert gone not in body, (gone, body)
|
||||
assert "PMS_FAKE_REAL" in body, body
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
|
||||
# ================================================================
|
||||
def main():
|
||||
ok = fail = 0
|
||||
|
|
|
|||
|
|
@ -362,7 +362,11 @@ def _():
|
|||
def _():
|
||||
assert ae.JUDGE_ACTIONS == {"FILL", "ADD", "DCA", "OPEN"}, ae.JUDGE_ACTIONS
|
||||
assert "TRIM" not in ae.JUDGE_ACTIONS, "减持永远不送研判"
|
||||
assert [a for a, _ in ae.EVALUATORS] == ["TRIM", "ADD", "FILL", "DCA"]
|
||||
# 2026-09-03 接通「设定某股目标价」后多了一条 EXIT (到价产出清仓提议)。它排在 TRIM 之后、
|
||||
# 三类买入之前 —— 顺序有意义: 同轮买卖互斥要先看到减持才让买入让路。四类老动作的相对
|
||||
# 次序与判据一个字没动, 减持照旧不送研判。
|
||||
assert [a for a, _ in ae.EVALUATORS] == ["TRIM", "EXIT", "ADD", "FILL", "DCA"]
|
||||
assert "EXIT" not in ae.JUDGE_ACTIONS, "减持永远不送研判 (目标价到价也是减持)"
|
||||
# 持仓那条扫描的输入与产出一个字没改: 空持仓进去, 空候选出来, 不会去碰候选池
|
||||
r = ae.scan(positions=[], params=params(), market={})
|
||||
assert r == {"candidates": [], "skipped": []}, r
|
||||
|
|
|
|||
|
|
@ -0,0 +1,428 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
第二十一批模块单测 (减持的自动执行边界, 零外部依赖, 不连库不触网)
|
||||
==================================================================
|
||||
运行: 在 tradingSystem 仓库根目录执行 python scripts/test_batch21_units.py
|
||||
|
||||
背景 (2026-09-03 安全修复): 提议分流原来把自动执行写成
|
||||
auto_exec = (side == "sell") or (autonomy == full and not force_queue)
|
||||
卖出方向在或运算的左边, 把 force_queue (强制入人工队列) 整个短路了 —— 方向是卖,
|
||||
这个标记就不起作用。当时没出事, 是因为走到分流的卖出候选只有保垫减仓一种, 它既不强制
|
||||
确认也不送研判, force_queue 恒为假。但设计里明确要求「研究证据走弱触发的减持必须交人
|
||||
裁决、绝不自动卖」, 那类减持一旦接上来, 结果会是自动卖出。
|
||||
|
||||
本批把修完之后的四件事钉死:
|
||||
* 规则算出来的保垫减仓仍然自动执行 (老行为一个字不变);
|
||||
* 带了强制入队标记的减持不再被方向短路 —— 强制入队是一票否决, 排在方向与档位之前;
|
||||
* 研究证据走弱这个来源必定入人工队列 (预留通道: 目前还没有上游在产出它);
|
||||
* 两条真正需要自动卖出的路一个字没碰 —— 决策系统高置信风控卖出的自动清仓、用户命令
|
||||
驱动的清仓, 它们根本不经过提议分流。这两条用「分流函数上装绊线」的办法证明。
|
||||
约定同前: 全过输出 "ALL PASS (n cases)" 退出码 0。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.core import action_engine as ae # noqa: E402
|
||||
from app.core import tradedays as td # noqa: E402
|
||||
|
||||
RESULTS = []
|
||||
|
||||
|
||||
def case(name):
|
||||
def deco(fn):
|
||||
RESULTS.append((name, fn))
|
||||
return fn
|
||||
return deco
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 3, 10, 30)
|
||||
|
||||
|
||||
# ================================================================ 夹具
|
||||
class _Patch:
|
||||
"""临时替换若干模块属性, 退出时原样还回去 —— 单测不连库不触网。"""
|
||||
|
||||
def __init__(self):
|
||||
self.saved = []
|
||||
|
||||
def __call__(self, mod, name, value):
|
||||
self.saved.append((mod, name, getattr(mod, name)))
|
||||
setattr(mod, name, value)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
for mod, name, old in reversed(self.saved):
|
||||
setattr(mod, name, old)
|
||||
return False
|
||||
|
||||
|
||||
def _patch_params(p, mod, values):
|
||||
"""把某模块引用的 param_store 取值函数换成一份字典 (口径同第二十批)。"""
|
||||
v = values
|
||||
|
||||
def _get_list(k, d=None, sep=","):
|
||||
x = v.get(k)
|
||||
if x in (None, ""):
|
||||
return list(d or [])
|
||||
return [s.strip() for s in str(x).split(sep) if s.strip()]
|
||||
p(mod, "get", lambda k, d=None: v.get(k, d))
|
||||
p(mod, "get_int", lambda k, d=0: int(v.get(k, d)))
|
||||
p(mod, "get_float", lambda k, d=0.0: float(v.get(k, d)))
|
||||
p(mod, "get_bool", lambda k, d=False: bool(v.get(k, d)))
|
||||
p(mod, "get_list", _get_list)
|
||||
|
||||
|
||||
PARAMS_BASE = {"PMS_EXEC_WINDOW_TDAYS": 3, "PMS_PROPOSAL_TTL_HOURS": 24,
|
||||
"PMS_JUDGE_TICK_BUDGET_SEC": 150}
|
||||
|
||||
# 研判不可用的应答 (judge.request 的降级形状)
|
||||
JUDGE_DEGRADED = {"verdict": "UNAVAILABLE", "reason": "决策系统未接通", "degraded": True,
|
||||
"raw": None, "confidence": None}
|
||||
JUDGE_PASS = {"verdict": "PASS", "reason": "理由仍成立", "degraded": False,
|
||||
"raw": {"verdict": "PASS"}, "confidence": 70.0}
|
||||
|
||||
|
||||
def trim_cand(qty=1000, price=10.0):
|
||||
"""一条真的由动作引擎算出来的保垫减仓候选 (不手搓, 口径与生产一致)。"""
|
||||
p = {"ts_code": "600000.SH", "cushion_peak": 0.10, "cushion_pct": 0.04,
|
||||
"total_qty": qty * 3, "price": price}
|
||||
c = ae.eval_trim(p, {"trim_peak": 0.06, "trim_giveback": 0.5})
|
||||
assert c and c["action"] == "TRIM" and c["side"] == "sell" and c["qty"] == qty, c
|
||||
return c
|
||||
|
||||
|
||||
def buy_cand(action="ADD", qty=1000, price=10.0, **kw):
|
||||
"""一条买入侧候选 (加仓)。判定表用它跟卖出侧对照。"""
|
||||
c = {"ts_code": "600000.SH", "action": action, "side": "buy", "qty": qty,
|
||||
"reason": "测试用加仓候选", "hard_numbers": {"price": price},
|
||||
"needs_user_confirm": False, "judge_required": False, "source": ae.SRC_ENGINE}
|
||||
c.update(kw)
|
||||
return c
|
||||
|
||||
|
||||
def _route(c, *, autonomy="propose_only", judge_resp=JUDGE_PASS, dry_run=False,
|
||||
values=None, price=10.0):
|
||||
"""把一条候选送进 proposal_service._route_one, 规则闸/研判/落表全换成桩。
|
||||
|
||||
回 (out, got): out 是分流结论, got 是这一路真的往库里写了什么。
|
||||
"""
|
||||
from app.core import rule_gate
|
||||
from app.repo import pms_repo
|
||||
from app.services import judge, param_store, portfolio, proposal_service as psvc
|
||||
got = {"ledger": [], "instructions": [], "proposals": [], "judge_calls": []}
|
||||
with _Patch() as p:
|
||||
p(rule_gate, "check", lambda **kw: {"passed": True, "failed": [], "warnings": []})
|
||||
p(judge, "request", lambda cand_, context=None, **kw: (
|
||||
got["judge_calls"].append(cand_.get("action")) or dict(judge_resp)))
|
||||
p(portfolio, "caps_ctx", lambda *a, **kw: {})
|
||||
p(pms_repo, "insert_ledger", lambda **kw: got["ledger"].append(kw) or 1)
|
||||
p(pms_repo, "insert_instruction", lambda **kw: got["instructions"].append(kw) or 1)
|
||||
p(pms_repo, "insert_proposal", lambda **kw: got["proposals"].append(kw) or 1)
|
||||
p(pms_repo, "list_ledger", lambda **kw: [])
|
||||
p(pms_repo, "update_position", lambda code, **kw: 1)
|
||||
_patch_params(p, param_store, {**PARAMS_BASE, **(values or {})})
|
||||
out = {"autonomy": autonomy, "open_autonomy": autonomy, "executed": [], "queued": [],
|
||||
"rejected": [], "skipped": [], "errors": [], "degraded": False}
|
||||
held = [{"ts_code": c["ts_code"], "total_qty": 3000, "avail_qty": 3000,
|
||||
"price": price, "frozen_reason": "NONE"}]
|
||||
view = {"positions": held, "held": held, "sector_ready": False,
|
||||
"params": {}, "totals": {}}
|
||||
psvc._route_one(c, view, {"_mkt": {}}, {}, False, NOW, dry_run, out)
|
||||
return out, got
|
||||
|
||||
|
||||
# ================================================================ 一, 老行为不变
|
||||
@case("保垫减仓·规则算出来的减持仍然自动执行 (两个档位都是, 老行为一个字不变)")
|
||||
def _():
|
||||
for autonomy in ("propose_only", "full"):
|
||||
out, got = _route(trim_cand(), autonomy=autonomy)
|
||||
assert len(out["executed"]) == 1 and not out["queued"], (autonomy, out)
|
||||
assert "减持自动执行" in out["executed"][0]["why"], out["executed"]
|
||||
assert len(got["instructions"]) == 1 and not got["proposals"], (autonomy, got)
|
||||
ins = got["instructions"][0]
|
||||
assert ins["action"] == "TRIM" and ins["side"] == "sell" and ins["qty"] == 1000, ins
|
||||
assert ins["progress"]["is_command"] is False and ins["progress"]["auto"] is True, ins
|
||||
# 账本记一条放行, 仲裁人是规则 (减仓不送研判, 见下条用例)
|
||||
assert got["ledger"][0]["verdict"] == "PASS" and got["ledger"][0]["arbiter"] == "rule"
|
||||
|
||||
|
||||
@case("保垫减仓·不送研判闸 (JUDGE_ACTIONS 只有买入侧四类), 分流不因此改判")
|
||||
def _():
|
||||
c = trim_cand()
|
||||
assert c["judge_required"] is False, c
|
||||
assert ae.A_TRIM not in ae.JUDGE_ACTIONS and ae.A_OPEN in ae.JUDGE_ACTIONS
|
||||
out, got = _route(c)
|
||||
assert got["judge_calls"] == [], got["judge_calls"]
|
||||
assert len(out["executed"]) == 1, out
|
||||
|
||||
|
||||
@case("判定表·买入侧四种组合逐条对照 (档位与强制入队的老口径一个字不变)")
|
||||
def _():
|
||||
table = [
|
||||
# (档位, 强制确认, 期望自动执行)
|
||||
("full", False, True),
|
||||
("full", True, False),
|
||||
("propose_only", False, False),
|
||||
("propose_only", True, False),
|
||||
]
|
||||
for autonomy, confirm, want_auto in table:
|
||||
c = buy_cand(needs_user_confirm=confirm)
|
||||
out, _ = _route(c, autonomy=autonomy)
|
||||
got_auto = bool(out["executed"])
|
||||
assert got_auto is want_auto, (autonomy, confirm, want_auto, out)
|
||||
|
||||
|
||||
# ================================================================ 二, 短路修掉了
|
||||
@case("修复要点·带强制确认标记的减持不再被方向短路 (从前 side==sell 直接自动卖)")
|
||||
def _():
|
||||
c = trim_cand()
|
||||
c["needs_user_confirm"] = True
|
||||
c["confirm_why"] = "测试用: 这条减持必须交人"
|
||||
for autonomy in ("propose_only", "full"):
|
||||
out, got = _route(c, autonomy=autonomy)
|
||||
assert not out["executed"] and len(out["queued"]) == 1, (autonomy, out)
|
||||
assert out["queued"][0]["why"] == "测试用: 这条减持必须交人", out["queued"]
|
||||
assert not got["instructions"] and len(got["proposals"]) == 1, (autonomy, got)
|
||||
pr = got["proposals"][0]
|
||||
assert pr["action"] == "TRIM" and pr["qty"] == 1000, pr
|
||||
assert pr["hard_numbers"]["needs_user_confirm"] is True, pr["hard_numbers"]
|
||||
|
||||
|
||||
@case("修复要点·研判不可用的减持同样入队 (卖出方向不再绕开降级入队这条规矩)")
|
||||
def _():
|
||||
# 今天减仓不送研判, 所以这条走的是「将来减仓也进研判范围」的那种处境:
|
||||
# 候选自己声明要研判, 而研判回不可用 —— 降级入队对卖出必须同样有效。
|
||||
c = trim_cand()
|
||||
c["judge_required"] = True
|
||||
out, got = _route(c, autonomy="full", judge_resp=JUDGE_DEGRADED)
|
||||
assert got["judge_calls"] == ["TRIM"], got["judge_calls"]
|
||||
assert not out["executed"] and len(out["queued"]) == 1, out
|
||||
assert out["queued"][0]["why"] == "研判不可用, 降级人工确认", out["queued"]
|
||||
assert not got["instructions"] and len(got["proposals"]) == 1, got
|
||||
# 研判真回了通过时照旧自动执行 (降级入队只针对拿不到结论)
|
||||
out2, got2 = _route(c, autonomy="full", judge_resp=JUDGE_PASS)
|
||||
assert len(out2["executed"]) == 1 and not out2["queued"], out2
|
||||
assert got2["ledger"][0]["arbiter"] == "judge", got2["ledger"]
|
||||
|
||||
|
||||
@case("修复要点·试算口径与真跑一致: 强制入队的减持在 dry_run 里也归入队一列")
|
||||
def _():
|
||||
c = trim_cand()
|
||||
c["needs_user_confirm"] = True
|
||||
out, got = _route(c, autonomy="full", dry_run=True)
|
||||
assert not out["executed"] and len(out["queued"]) == 1, out
|
||||
assert out["queued"][0]["route"] == "queue" and out["queued"][0]["dry_run"] is True, out
|
||||
assert not got["instructions"] and not got["proposals"], got # 试算滴水不写
|
||||
# 规则触发的那条在试算里仍是自动执行
|
||||
out2, _ = _route(trim_cand(), autonomy="full", dry_run=True)
|
||||
assert out2["executed"] and out2["executed"][0]["route"] == "auto", out2
|
||||
|
||||
|
||||
# ================================================================ 三, 研究证据走弱的预留通道
|
||||
@case("预留通道·来源常量与强制入队判定 (认不出的来源不影响老行为)")
|
||||
def _():
|
||||
assert ae.SRC_ENGINE == "engine" and ae.SRC_RESEARCH_WEAK == "research_weak"
|
||||
# 名单只查「研究证据走弱在里面、动作引擎自己不在里面」, 不查它一共有几条 ——
|
||||
# 这份名单本来就是给后来的来源加行用的, 断言写成全等于会拦住正当的新增。
|
||||
assert ae.FORCE_QUEUE_SOURCES[ae.SRC_RESEARCH_WEAK] == ae.WHY_RESEARCH_WEAK_CONFIRM
|
||||
assert ae.SRC_ENGINE not in ae.FORCE_QUEUE_SOURCES, ae.FORCE_QUEUE_SOURCES
|
||||
# 名单里的每一条都必须给得出一句交人的原因 (不许只登记键、原因留空)
|
||||
for src, why in ae.FORCE_QUEUE_SOURCES.items():
|
||||
assert isinstance(why, str) and why.strip(), (src, why)
|
||||
assert ae.source_confirm_why(src) == why, src
|
||||
assert ae.source_confirm_why(ae.SRC_RESEARCH_WEAK) == ae.WHY_RESEARCH_WEAK_CONFIRM
|
||||
assert ae.source_confirm_why(" research_weak ") == ae.WHY_RESEARCH_WEAK_CONFIRM
|
||||
# 没有来源 / 动作引擎自己 / 认不出的来源: 一律不强制, 走原有的方向与档位判定
|
||||
for x in (None, "", "engine", "signal", "某个将来的来源"):
|
||||
assert ae.source_confirm_why(x) is None, x
|
||||
assert "交人裁决" in ae.WHY_RESEARCH_WEAK_CONFIRM
|
||||
assert "不自动卖出" in ae.WHY_RESEARCH_WEAK_CONFIRM
|
||||
|
||||
|
||||
@case("预留通道·候选构造器产出的形状: 卖出方向 / 强制确认 / 带来源与交人原因")
|
||||
def _():
|
||||
c = ae.reduce_on_weak_research("600000.SH", qty=1000,
|
||||
reason="研究证据走弱: 三条买入理由有两条不再成立",
|
||||
hard_numbers={"price": 10.0})
|
||||
assert c["ts_code"] == "600000.SH" and c["action"] == "TRIM" and c["side"] == "sell"
|
||||
assert c["qty"] == 1000 and c["source"] == ae.SRC_RESEARCH_WEAK, c
|
||||
assert c["needs_user_confirm"] is True, c
|
||||
assert c["confirm_why"] == ae.WHY_RESEARCH_WEAK_CONFIRM, c
|
||||
assert c["hard_numbers"]["price"] == 10.0, c
|
||||
# 清仓口径也能用同一个构造器
|
||||
c2 = ae.reduce_on_weak_research("600000.SH", qty=3000, reason="清", action="EXIT")
|
||||
assert c2["action"] == "EXIT" and c2["side"] == "sell" and c2["source"] == ae.SRC_RESEARCH_WEAK
|
||||
|
||||
|
||||
@case("预留通道·研究证据走弱的减持必定入人工队列 (full 档位也不许自动卖)")
|
||||
def _():
|
||||
c = ae.reduce_on_weak_research("600000.SH", qty=1000, reason="研究证据走弱",
|
||||
hard_numbers={"price": 10.0})
|
||||
for autonomy in ("propose_only", "full"):
|
||||
out, got = _route(c, autonomy=autonomy)
|
||||
assert not out["executed"] and len(out["queued"]) == 1, (autonomy, out)
|
||||
assert out["queued"][0]["why"] == ae.WHY_RESEARCH_WEAK_CONFIRM, out["queued"]
|
||||
assert not got["instructions"] and len(got["proposals"]) == 1, (autonomy, got)
|
||||
assert got["proposals"][0]["hard_numbers"]["source"] == ae.SRC_RESEARCH_WEAK
|
||||
|
||||
|
||||
@case("预留通道·只带来源、没带强制确认标记的减持一样入队 (来源自己就是一票否决)")
|
||||
def _():
|
||||
c = trim_cand()
|
||||
c["source"] = ae.SRC_RESEARCH_WEAK # 只改来源, 强制确认标记仍是假
|
||||
assert c["needs_user_confirm"] is False and "confirm_why" not in c
|
||||
out, got = _route(c, autonomy="full")
|
||||
assert not out["executed"] and len(out["queued"]) == 1, out
|
||||
assert out["queued"][0]["why"] == ae.WHY_RESEARCH_WEAK_CONFIRM, out["queued"]
|
||||
assert not got["instructions"] and len(got["proposals"]) == 1, got
|
||||
|
||||
|
||||
@case("留痕·提议硬数字带来源, 人在等我拍板里看得出这条减持是哪来的")
|
||||
def _():
|
||||
c = trim_cand()
|
||||
c["needs_user_confirm"] = True # 先让它入队才有提议可看
|
||||
_, got = _route(c, autonomy="full")
|
||||
assert got["proposals"][0]["hard_numbers"]["source"] == ae.SRC_ENGINE, got["proposals"]
|
||||
c2 = ae.reduce_on_weak_research("600000.SH", qty=1000, reason="研究证据走弱")
|
||||
_, got2 = _route(c2, autonomy="full")
|
||||
assert got2["proposals"][0]["hard_numbers"]["source"] == ae.SRC_RESEARCH_WEAK
|
||||
|
||||
|
||||
# ================================================================ 四, 两条自动卖出的路没被碰到
|
||||
@case("自动止损·风控高置信卖出照旧直接落清仓指令, 且根本不经过提议分流")
|
||||
def _():
|
||||
from app.repo import pms_repo
|
||||
from app.services import param_store, proposal_service as psvc, signal_service as ssvc
|
||||
got = {"instructions": [], "ledger": [], "proposals": [], "routed": []}
|
||||
with _Patch() as p:
|
||||
# 绊线: 这条路要是走进了提议分流, 用例立刻炸 —— 这次改动改的就是那一段
|
||||
p(psvc, "_route_one", lambda *a, **kw: got["routed"].append(a) or None)
|
||||
p(pms_repo, "list_instructions", lambda **kw: [])
|
||||
p(pms_repo, "list_proposals", lambda **kw: [])
|
||||
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, "insert_proposal", lambda **kw: got["proposals"].append(kw) 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-1"}
|
||||
ssvc._handle(sig, view, prm, {}, td.ymd(), False, out)
|
||||
assert got["routed"] == [], "风控卖出不该经过提议分流"
|
||||
assert len(out["exits"]) == 1 and not out["proposals"], out
|
||||
assert len(got["instructions"]) == 1 and not got["proposals"], got
|
||||
ins = got["instructions"][0]
|
||||
assert ins["action"] == "EXIT" and ins["side"] == "sell" and ins["qty"] == 3000, ins
|
||||
assert ins["progress"]["urgent"] is True and ins["progress"]["from_signal"] is True, ins
|
||||
assert ins["progress"]["is_command"] is False, ins
|
||||
|
||||
|
||||
@case("自动止损·中等置信的风控卖出照旧只落提议 (门槛分档没被这次改动碰到)")
|
||||
def _():
|
||||
from app.repo import pms_repo
|
||||
from app.services import param_store, proposal_service as psvc, signal_service as ssvc
|
||||
got = {"instructions": [], "ledger": [], "proposals": [], "routed": []}
|
||||
with _Patch() as p:
|
||||
p(psvc, "_route_one", lambda *a, **kw: got["routed"].append(a) or None)
|
||||
p(pms_repo, "list_instructions", lambda **kw: [])
|
||||
p(pms_repo, "list_proposals", lambda **kw: [])
|
||||
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, "insert_proposal", lambda **kw: got["proposals"].append(kw) 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.80,
|
||||
"source": "bionic_risk", "reason": "风控: 量能转弱", "msg_id": "1-2"}
|
||||
ssvc._handle(sig, view, prm, {}, td.ymd(), False, out)
|
||||
assert got["routed"] == [], "风控卖出不该经过提议分流"
|
||||
assert not out["exits"] and len(out["proposals"]) == 1, out
|
||||
assert not got["instructions"] and len(got["proposals"]) == 1, got
|
||||
assert got["proposals"][0]["action"] == "TRIM" and got["proposals"][0]["qty"] == 1000
|
||||
|
||||
|
||||
@case("命令清仓·一键清仓照常出方案, 且根本不经过提议分流")
|
||||
def _():
|
||||
from app.core import command_spec as cs
|
||||
from app.repo import pms_repo
|
||||
from app.services import (command_service as csvc, param_store, portfolio,
|
||||
proposal_service as psvc, strategy_service)
|
||||
got = {"plans": [], "ledger": [], "routed": []}
|
||||
with _Patch() as p:
|
||||
p(psvc, "_route_one", lambda *a, **kw: got["routed"].append(a) or None)
|
||||
p(psvc, "scan_and_route", lambda **kw: got["routed"].append(("scan",)) or {})
|
||||
p(pms_repo, "update_command", lambda *a, **kw: 1)
|
||||
p(pms_repo, "insert_plans", lambda rows: got["plans"].extend(rows) or len(rows))
|
||||
p(pms_repo, "list_plans", lambda **kw: [])
|
||||
p(pms_repo, "list_instructions", lambda **kw: [])
|
||||
p(pms_repo, "list_strategies", lambda **kw: [])
|
||||
p(pms_repo, "list_proposals", lambda **kw: [])
|
||||
p(pms_repo, "insert_ledger", lambda **kw: got["ledger"].append(kw) or 1)
|
||||
p(strategy_service, "set_status",
|
||||
lambda sid, status, by="user": {"ok": True, "status": status})
|
||||
held = [{"ts_code": "600000.SH", "total_qty": 3000, "avail_qty": 3000, "price": 9.5,
|
||||
"price_ok": True, "base_qty": 3000}]
|
||||
p(portfolio, "positions_view", lambda **kw: {
|
||||
"held": held, "positions": held,
|
||||
"params": {"scale": 2_000_000, "weak_neg_days": 5},
|
||||
"totals": {"scale": 2_000_000}, "sector_ready": True})
|
||||
_patch_params(p, param_store, PARAMS_BASE)
|
||||
r = csvc.plan_command({"cmd_type": "LIQUIDATE_ALL",
|
||||
"command_id": "CMD_20260903_0001",
|
||||
"params": {"window_tdays": 1, "confirm": "YES"}})
|
||||
assert got["routed"] == [], "用户命令驱动的清仓不该经过提议分流"
|
||||
assert r["status"] == cs.ST_EXECUTING, r
|
||||
acts = {x["action"] for x in got["plans"]}
|
||||
assert "EXIT" in acts, got["plans"]
|
||||
exits = [x for x in got["plans"] if x["action"] == "EXIT"]
|
||||
assert exits[0]["ts_code"] == "600000.SH" and exits[0]["qty"] == 3000, exits
|
||||
|
||||
|
||||
# ================================================================ 五, 源码守卫
|
||||
@case("源码守卫·分流里不许再出现「卖出方向短路强制入队」那种写法")
|
||||
def _():
|
||||
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"app", "services", "proposal_service.py")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
# 只看真正的赋值行, 说明性注释里可以原样引用旧写法 (那段注释本身就是这次修复的说明)
|
||||
bad = [ln for ln in src.splitlines()
|
||||
if ln.strip().startswith('auto_exec = (side == "sell") or')]
|
||||
assert not bad, f"卖出方向又被放回或运算左边, 强制入队会再次失效: {bad}"
|
||||
assert "src_why = ae.source_confirm_why(c.get(\"source\"))" in src, "来源判定不见了"
|
||||
assert 'auto_exec = (not force_queue) and (side == "sell" or autonomy == AUTONOMY_FULL)' \
|
||||
in src, "强制入队不再是一票否决"
|
||||
|
||||
|
||||
# ================================================================ 跑
|
||||
def main():
|
||||
ok = fail = 0
|
||||
for name, fn in RESULTS:
|
||||
try:
|
||||
fn()
|
||||
ok += 1
|
||||
print(f" PASS {name}")
|
||||
except Exception as e:
|
||||
fail += 1
|
||||
print(f" FAIL {name}: {type(e).__name__}: {e}")
|
||||
traceback.print_exc()
|
||||
print(f"\n通过 {ok} 例, 失败 {fail} 例")
|
||||
if fail:
|
||||
sys.exit(1)
|
||||
print(f"ALL PASS ({ok} cases)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -254,6 +254,35 @@ def ctx(**kw):
|
|||
return d
|
||||
|
||||
|
||||
@case("规则闸·用户止损价只披露不拦截: 破位买入告警 / 卖出不吵 / 无价与未设分得开")
|
||||
def _():
|
||||
# 设了止损价 9.5, 现价 9.2 已跌破 —— 买入方向出告警, 但**不进 failed**, 照样放行
|
||||
r = rg.check(side="buy", action="ADD", qty=1000, price=9.2,
|
||||
ctx=ctx(flags={"stop_price": 9.5}, day={"price": 9.2}))
|
||||
assert r["passed"] and r["failed"] == [], r
|
||||
assert any(x.startswith("STOP_BREACHED") for x in r["warnings"]), r["warnings"]
|
||||
assert r["hard_numbers"]["stop_price"] == 9.5, r["hard_numbers"]
|
||||
assert r["hard_numbers"]["stop_breached"] is True, r["hard_numbers"]
|
||||
# 没破位: 留痕说得出「设了且没破」, 不发告警
|
||||
r2 = rg.check(side="buy", action="ADD", qty=1000, price=10.0,
|
||||
ctx=ctx(flags={"stop_price": 9.5}))
|
||||
assert not any(x.startswith("STOP_BREACHED") for x in r2["warnings"]), r2["warnings"]
|
||||
assert r2["hard_numbers"]["stop_breached"] is False, r2["hard_numbers"]
|
||||
# 卖出方向破位不吵 —— 那本来就是止损位该做的事; 但硬数字照留
|
||||
r3 = rg.check(side="sell", action="EXIT", qty=6000, price=9.2,
|
||||
ctx=ctx(flags={"stop_price": 9.5}))
|
||||
assert r3["passed"] and not any(x.startswith("STOP_BREACHED") for x in r3["warnings"]), r3
|
||||
assert r3["hard_numbers"]["stop_breached"] is True, r3["hard_numbers"]
|
||||
# 没设止损价 / 取不到现价: 一律不判破位, stop_price 留 None ——「没告警」不等于「没跌破」
|
||||
r4 = rg.check(side="buy", action="ADD", qty=1000, price=9.2, ctx=ctx())
|
||||
assert r4["hard_numbers"]["stop_price"] is None, r4["hard_numbers"]
|
||||
assert "stop_breached" not in r4["hard_numbers"], r4["hard_numbers"]
|
||||
r5 = rg.check(side="buy", action="ADD", qty=1000, price=0,
|
||||
ctx=ctx(flags={"stop_price": 9.5}))
|
||||
assert "stop_breached" not in r5["hard_numbers"], r5["hard_numbers"]
|
||||
assert not any(x.startswith("STOP_BREACHED") for x in r5["warnings"]), r5["warnings"]
|
||||
|
||||
|
||||
@case("规则闸·卖出正常放行 + 硬数字留痕")
|
||||
def _():
|
||||
r = rg.check(side="sell", action="TRIM", qty=2000, price=10.0, ctx=ctx())
|
||||
|
|
|
|||
|
|
@ -141,6 +141,81 @@ def _():
|
|||
PARAMS) is None # 1/3 不足一手
|
||||
|
||||
|
||||
# ================================================================ 目标价到价 (EXIT)
|
||||
@case("目标价·到价产出清仓候选: 全部持仓 / 不送研判 / 强制交人裁决")
|
||||
def _():
|
||||
c = ae.eval_target(pos(price=13.0, target_price=12.5, total_qty=6050,
|
||||
avail_qty=6050, cushion_pct=0.30), PARAMS)
|
||||
assert c and c["action"] == ae.A_EXIT and c["side"] == "sell", c
|
||||
assert c["qty"] == 6050, c # 全部持仓, 零股一并 (与清仓命令同口径)
|
||||
assert not c["judge_required"], c # 减持不送研判
|
||||
assert c["needs_user_confirm"] is True, c # 必定交人
|
||||
assert c["source"] == ae.SRC_TARGET_PRICE, c
|
||||
assert c["confirm_why"] == ae.WHY_TARGET_PRICE_CONFIRM, c
|
||||
assert c["hard_numbers"]["target_price"] == 12.5, c["hard_numbers"]
|
||||
# 恰好等于目标价也算到价 (不设缓冲、不做平滑 —— 那个数是用户自己填的)
|
||||
assert ae.eval_target(pos(price=12.5, target_price=12.5), PARAMS) is not None
|
||||
|
||||
|
||||
@case("目标价·未到价 / 没设 / 取不到现价 / 空仓 一律不产出")
|
||||
def _():
|
||||
assert ae.eval_target(pos(price=12.4, target_price=12.5), PARAMS) is None # 差一分不算
|
||||
assert ae.eval_target(pos(price=13.0), PARAMS) is None # 没设目标价
|
||||
assert ae.eval_target(pos(price=13.0, target_price=0), PARAMS) is None
|
||||
# 取不到现价: price 是拿摊薄成本顶的假价, 绝不能折成「到价了」
|
||||
assert ae.eval_target(pos(price=13.0, target_price=12.5, price_ok=False), PARAMS) is None
|
||||
assert ae.eval_target(pos(price=0, target_price=12.5), PARAMS) is None
|
||||
assert ae.eval_target(pos(price=None, target_price=12.5), PARAMS) is None
|
||||
assert ae.eval_target(pos(price=13.0, target_price=12.5, total_qty=0), PARAMS) is None
|
||||
|
||||
|
||||
@case("扫描·目标价从个股参数命令读 (不读持仓行投影) / 冻结不挡 / 同轮买入让路")
|
||||
def _():
|
||||
ps = [pos(ts_code="600000.SH", price=13.0, cushion_pct=0.30, market_value=78_000,
|
||||
frozen_reason="COMMAND_HALT")]
|
||||
m = {"600000.SH": mkt(ma5=12.0, high5=13.0)}
|
||||
# 不传 stock_params → 这条动作根本不评估, 其余四类行为与从前一字不差
|
||||
r0 = ae.scan(positions=ps, params=PARAMS, market=m)
|
||||
assert not any(c["action"] == ae.A_EXIT for c in r0["candidates"]), r0["candidates"]
|
||||
# 传了命令表里的目标价 → 到价产出清仓候选; 冻结只禁增持, 挡不住它
|
||||
r = ae.scan(positions=ps, params=PARAMS, market=m,
|
||||
stock_params={"600000.SH": {"target_price": 12.5}})
|
||||
acts = {(c["ts_code"], c["action"]) for c in r["candidates"]}
|
||||
assert ("600000.SH", ae.A_EXIT) in acts, acts
|
||||
# 同轮买卖互斥: 一边提议清仓一边提议加仓是自相矛盾, 买入侧让路
|
||||
ps2 = [pos(ts_code="000001.SZ", price=13.0, cushion_pct=0.30, market_value=78_000)]
|
||||
m2 = {"000001.SZ": mkt(ma5=12.5, high5=13.0)} # 不追高那道过得去, 才真会出 ADD
|
||||
r2 = ae.scan(positions=ps2, params=PARAMS, market=m2,
|
||||
stock_params={"000001.SZ": {"target_price": 12.5}})
|
||||
acts2 = {c["action"] for c in r2["candidates"]}
|
||||
assert ae.A_EXIT in acts2 and "ADD" not in acts2, acts2
|
||||
assert any("买卖互斥" in (s.get("why") or "") for s in r2["skipped"]), r2["skipped"]
|
||||
# 传进来的持仓行不许被改写 (合并出的是本轮副本)
|
||||
assert "target_price" not in ps2[0], ps2[0]
|
||||
|
||||
|
||||
@case("扫描·同轮只发一条减持: 到价清仓优先于保垫减仓, 免得人点了采纳却卖不成")
|
||||
def _():
|
||||
# 复现被修掉的那个场景: 峰值安全垫 20% 回落到 9% 会触发保垫减仓, 同时现价已到目标价。
|
||||
# 修之前两条会一起留下 —— 保垫减仓不用确认、当场卖掉一部分, 而到价清仓记的是扫描那一刻
|
||||
# 的全部持仓、要等人拍板; 等人第二天点采纳, 持仓已经少了, 落指令时数量超过实际持仓,
|
||||
# 卖出前的检查会整条驳回, 人点了「清掉」结果一股没卖。
|
||||
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,
|
||||
stock_params={"600000.SH": {"target_price": 12.5}})
|
||||
sells = [c for c in r["candidates"] if c["side"] == ae.SELL]
|
||||
assert len(sells) == 1, sells # 一轮只发一条
|
||||
assert sells[0]["action"] == ae.A_EXIT, sells # 留下的是人设的目标价那条
|
||||
assert sells[0]["qty"] == 6000, sells # 数量仍是全部持仓
|
||||
assert any("一轮只发一条减持" in (s.get("why") or "") for s in r["skipped"]), r["skipped"]
|
||||
# 没设目标价时保垫减仓照旧, 老行为一个字不变
|
||||
r2 = ae.scan(positions=ps, params=PARAMS, market=m)
|
||||
sells2 = [c for c in r2["candidates"] if c["side"] == ae.SELL]
|
||||
assert len(sells2) == 1 and sells2[0]["action"] == ae.A_TRIM, sells2
|
||||
|
||||
|
||||
# ================================================================ 扫描
|
||||
@case("扫描·冻结票只评减仓 / 已在途不重复提 / 单票异常不拖垮整轮")
|
||||
def _():
|
||||
|
|
|
|||
|
|
@ -1677,6 +1677,88 @@ def _():
|
|||
judge.request = orig
|
||||
|
||||
|
||||
def _target_fakes(price, target, **kw):
|
||||
"""一只持仓票 + 一条「设定某股目标价」命令。高点给得比现价高, 免得混进盈利加仓。"""
|
||||
from app.services import command_service as csvc
|
||||
fake = install_fakes(
|
||||
prices={"600000.SH": price}, high5={"600000.SH": price * 1.5},
|
||||
params={"PMS_TOTAL_SCALE": "2000000", **(kw.get("params") or {})},
|
||||
positions=[{"ts_code": "600000.SH", "total_qty": 6050, "avail_qty": 6050,
|
||||
"base_qty": 6000, "avg_cost": 10.0, "cushion_peak": 0.0,
|
||||
"target_pct": 0.06}])
|
||||
restore = _stub_sp(csvc, {"600000.SH": {"target_price": target}} if target else {})
|
||||
return fake, restore
|
||||
|
||||
|
||||
@case("自主提议·目标价到价: 必定入队等人拍板, 档位 full 也绝不自动卖")
|
||||
def _():
|
||||
from app.services import proposal_service as ps
|
||||
# 档位 full = 「闸门与研判通过即执行」, 而目标价这条必须无视档位入队
|
||||
fake, restore = _target_fakes(13.0, 12.5, params={"PMS_AUTONOMY": "full"})
|
||||
try:
|
||||
r = ps.scan_and_route()
|
||||
finally:
|
||||
restore()
|
||||
assert r["ok"], r
|
||||
assert not any(x["action"] == "EXIT" for x in r["executed"]), r["executed"]
|
||||
assert not any(i["action"] == "EXIT" for i in fake.instructions.values()), fake.instructions
|
||||
qd = {(x["ts_code"], x["action"]) for x in r["queued"]}
|
||||
assert ("600000.SH", "EXIT") in qd, r
|
||||
q = [x for x in r["queued"] if x["action"] == "EXIT"][0]
|
||||
assert "目标价" in (q["why"] or "") and "不自动卖" in (q["why"] or ""), q
|
||||
prop = [p for p in fake.proposals.values() if p["action"] == "EXIT"]
|
||||
assert prop and prop[0]["qty"] == 6050, prop # 全部持仓, 零股一并 (清仓口径)
|
||||
hn = prop[0]["hard_numbers"]
|
||||
assert hn["target_price"] == 12.5 and hn["price"] == 13.0, hn
|
||||
assert hn["needs_user_confirm"] is True, hn
|
||||
# 采纳时按动作反推方向那一步认得它 (web/main.py 的 EXIT → sell), 不会把清仓发成买入
|
||||
from app.core import action_engine as ae
|
||||
assert ae.A_EXIT in ("TRIM", "EXIT"), ae.A_EXIT
|
||||
|
||||
# 同一轮里已经有它的提议在等人 → 下一跳不重复提 (在途去重)
|
||||
from app.services import command_service as csvc
|
||||
restore2 = _stub_sp(csvc, {"600000.SH": {"target_price": 12.5}})
|
||||
try:
|
||||
r2 = ps.scan_and_route()
|
||||
finally:
|
||||
restore2()
|
||||
assert not any(x["action"] == "EXIT" for x in r2["queued"]), r2["queued"]
|
||||
assert any("在途提议" in (s.get("why") or "") for s in r2["skipped"]), r2["skipped"]
|
||||
|
||||
|
||||
@case("自主提议·目标价未到 / 没设 一律不产出 (拿不到价绝不折成到价)")
|
||||
def _():
|
||||
from app.services import proposal_service as ps
|
||||
fake, restore = _target_fakes(12.0, 12.5, params={"PMS_AUTONOMY": "full"})
|
||||
try:
|
||||
r = ps.scan_and_route()
|
||||
finally:
|
||||
restore()
|
||||
assert not any(x["action"] == "EXIT" for x in r["queued"] + r["executed"]), r
|
||||
assert not fake.proposals, fake.proposals
|
||||
|
||||
fake2, restore2 = _target_fakes(13.0, None, params={"PMS_AUTONOMY": "full"})
|
||||
try:
|
||||
r2 = ps.scan_and_route()
|
||||
finally:
|
||||
restore2()
|
||||
assert not any(x["action"] == "EXIT" for x in r2["queued"] + r2["executed"]), r2
|
||||
|
||||
# 取不到现价: 整只票跳过并留痕 —— 拿摊薄成本顶的那个价绝不能触发到价
|
||||
from app.services import command_service as csvc, market
|
||||
install_fakes(prices={}, params={"PMS_TOTAL_SCALE": "2000000", "PMS_AUTONOMY": "full"},
|
||||
positions=[{"ts_code": "600000.SH", "total_qty": 6050, "avail_qty": 6050,
|
||||
"base_qty": 6000, "avg_cost": 10.0, "target_pct": 0.06}])
|
||||
market.get_ma5 = lambda c: None
|
||||
restore3 = _stub_sp(csvc, {"600000.SH": {"target_price": 1.0}}) # 成本 10 远超目标 1
|
||||
try:
|
||||
r3 = ps.scan_and_route()
|
||||
finally:
|
||||
restore3()
|
||||
assert not any(x["action"] == "EXIT" for x in r3["queued"] + r3["executed"]), r3
|
||||
assert any("取不到现价" in (s.get("why") or "") for s in r3["skipped"]), r3["skipped"]
|
||||
|
||||
|
||||
@case("自主提议·研判驳回与规则闸拦截各自留痕")
|
||||
def _():
|
||||
from app.services import judge, proposal_service as ps
|
||||
|
|
|
|||
Loading…
Reference in New Issue