tradingSystem/app/core/action_engine.py

853 lines
53 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""
动作引擎 · 自主提议扫描 (纯逻辑, 无外部依赖, 可单测)
=====================================================
设计 POSITION_MGMT_DESIGN.md §6 的四类自主动作每条规则的触发口径与约束都照抄设计表:
| 引擎 | 触发 | 关键约束 |
|---|---|---|
| FILL 回踩补足 | 建仓期(10 交易日)内回踩支撑带不破, 浮亏 < 3% | 每票 1 ; 补后 目标仓位 |
| ADD 盈利加仓 | 安全垫 +3% 且创 5 日新高或站上压力位 | 距上次 2 交易日; 单股上限; MA5 <+6% |
| DCA 补仓 | 浮亏触及 8%/15% 评估档 (各评估一次, 执行终身一次) | 底仓 50%; 15% 及更深永远需用户确认 |
| TRIM 保垫减仓 | 安全垫峰值 6% 且回吐过半 1/3 锁盈 | 纯规则自动执行 (减持方向不设确认门槛) |
| EXIT 目标价到价 | 现价 用户设定某股目标价命令里的价 | 全部持仓; **必定交人拍板, 绝不自动卖** |
2026-08-06 13:55:27 +08:00
| OPEN 新建仓 | 上游候选池里的新票, 且还有持仓名额与可投金额 | 名额与金额边走边扣; 只提底仓批 |
本模块只回答该不该动动多少为什么, 不查库不下发:
* 交易日相关的输入 (建仓天数距上次加仓天数) services 层用交易日历算好传进来,
避免把日历依赖塞进纯逻辑
* 上限/一手/冻结等硬约束**不在这里重复判**, 统一由规则闸终检 (职责单一, 口径唯一)
这里只做引擎自身的触发条件批次额度计算
2026-08-06 13:55:27 +08:00
**唯一的例外是新建仓**: 它一轮能产出多条候选, 而规则闸每条拿到的都是同一份本轮开始时
的组合快照, 于是"每条单独看都不超上限、加起来超了"这种情况它拦不住所以那条路的上限
校验必须在这里就滚动算一遍 用的仍然是规则闸那个 check_all_caps, 口径没有第二份
输出候选统一结构, proposal_service 规则闸 研判闸 按自主档位分流
2026-08-06 13:55:27 +08:00
扫描入口有两个, 输入不同, 互不影响:
scan() 输入是**已有持仓**, 产出 FILL/ADD/DCA/TRIM EXIT (目标价到价, 2026-09-03 新增)
2026-08-06 13:55:27 +08:00
scan_open() 输入是**上游候选池**, 产出 OPEN (2026-08-06 新增)
候选池取不到时 scan() 照常跑, 反之亦然 一条外部接口的故障不该让整轮扫描停摆
"""
from __future__ import annotations
from app.core.cushion import dca_stage, trim_trigger
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
from app.core import copy
2026-08-28 13:07:27 +08:00
from app.core.sizer import LOT, lot_of, lot_qty, split_batches
2026-08-06 13:55:27 +08:00
# 新建仓要在这里滚动校验上限, 用的必须是规则闸那一份 check_all_caps, 不能另写一套。
# _new_name_ctx / _ctx_after 是命令驱动建仓 (planner.plan_increase_exposure) 滚动更新组合
# 快照用的同两个函数, 一起借过来 —— 为的是让「自主建仓」与「命令建仓」的上限口径逐字一致。
# 带下划线的名字跨模块引用不好看, 但比复制一份口径出来强: 口径有两份, 迟早会分叉。
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"
2026-08-06 13:55:27 +08:00
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}
def _sell_priority(c: dict) -> int:
"""同一轮多条减持留哪一条 (数字小的优先; 2026-09-07 第三件起按动作加来源):
到价清仓 (人的意思) 研究证据走弱的减持 (必交人) 保垫减仓 (规则自动)"""
if c.get("action") == A_EXIT:
return 0
if c.get("source") == SRC_RESEARCH_WEAK:
return 1
return 2
# 候选来源 (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 起随计划接口每行下发; 旧版计划没有这个字段)。
# 候选 门槛全过、无硬风险、确认线在 —— 走现有的自动流程 (规则闸 → 研判闸 → 档位分流)
# 关注 门槛全过、无硬风险、但确认线缺失或陈旧 —— 这是上游说「无法判断」, 强制交人裁决
# 仅展示 只差覆盖或潜在吸筹 —— 不出提议, 只在候选处置里记一句原因
# 三个值是中文原文, 与上游接口逐字对应, 这里不做任何映射。
VERDICT_CANDIDATE, VERDICT_WATCH, VERDICT_DISPLAY = "候选", "关注", "仅展示"
WHY_WATCH_CONFIRM = "选股系统判为关注(无法判断),交人裁决"
WHY_DISPLAY_ONLY = "选股系统判为仅展示"
# 逻辑状态四态里唯一会触发动作的值 (2026-09-07 第三件)。四态是选股系统按「支撑这只票的研究证据
# 还在不在」算出来的跨日跟踪, 与候选卡判决是正交的两维; 只有「逻辑存疑」会改分流通道 —— 持仓停
# 增持侧自主动作、按开关出必交人的减持; 判决候选而逻辑存疑的新建仓强制人工确认。其余三个值
# (逻辑强化 / 逻辑成立 / 无法判断) 不产生任何动作。中文原文与上游逐字对应, 不做映射。
LOGIC_DOUBT = "逻辑存疑"
WHY_LOGIC_DOUBT_CONFIRM = "可交易口径成立,但研究证据在走弱(逻辑存疑),交人裁决"
WHY_LOGIC_DOUBT_PAUSE = "研究证据在走弱(逻辑存疑),停掉增持侧的自主动作"
def is_logic_doubt(logic_state) -> bool:
return isinstance(logic_state, dict) and str(logic_state.get("state") or "").strip() == LOGIC_DOUBT
def logic_doubt_text(logic_state, head: str) -> str:
"""把存疑的出处接在一句话后面: 第一条说明与证据截止日, 让人看得出是哪一路在走弱、证据是哪天的。"""
st = logic_state if isinstance(logic_state, dict) else {}
parts = [head]
reasons = [str(x) for x in (st.get("reasons") or []) if x]
if reasons:
parts.append(reasons[0][:120])
if st.get("as_of"):
parts.append(f"证据截止 {st.get('as_of')}")
return "".join(parts)
def logic_confirm_why(logic_state):
"""按逻辑状态要不要强制人工确认: 逻辑存疑回原因 (带出处), 其余一律 None。
verdict_confirm_why 并列: 判决管今天要不要买, 逻辑状态管研究证据还在不在"""
if not is_logic_doubt(logic_state):
return None
return logic_doubt_text(logic_state, WHY_LOGIC_DOUBT_CONFIRM)
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
# 冻结的三种来路,对交易员是三件不同的事:自己下的命令、自己手设的、系统刹车期间。
# 原先一句「该股 COMMAND_HALT, 禁增持」三种都不分,还把枚举值印在了页面上。
_FROZEN_WHY = {
"COMMAND_HALT": "你下过命令把这只票设成只卖不买",
"MANUAL": "你手动把这只票设成只卖不买",
"BRAKE": "组合刹车期间这只票只卖不买",
}
def frozen_why(reason) -> str:
"""这只票为什么不能加仓。认不出的来路说一句实话,不硬猜。"""
r = str(reason or "").strip()
return _FROZEN_WHY.get(r, "这只票现在只卖不买") + ",所以不评估加仓"
def display_only_why(row) -> str:
"""这一栏回答的是「为什么今天没买这只票」。所以它只写原因, 不写系统内部的判决名。
2026-09-04 改过一次改之前写的是选股系统判为仅展示, 不出提议: 门槛未过: started,
三处毛病: 仅展示不出提议是内部说法, 读的人不知道是什么意思; started 是门槛在
代码里的变量名, 不该出现在页面上; 整句话绕了两层还是没说清到底为什么不买
现在直接用判决依据 那是选股系统判决时当场写下的一句完整中文, 例如
当日涨幅 -3.2%, 没到 3% 的启动线择时决策系统昨夜给出卖出信号
旧版计划没有这个字段, 那时退回一句大白话, 不留空
"""
basis = (str(row.get("basis") or "").strip() if isinstance(row, dict) else "")
return basis or "选股系统今天不建议买入"
def verdict_confirm_why(verdict):
"""按判决要不要强制人工确认: 要则回原因, 不要回 None。
候选与没有判决 (旧版计划) 都回 None 走档位关注回固定文案仅展示不该走到这里
(scan_open 在更前面就跳过了), 真走到也按关注处理判决值认不出来 (上游改了词表)
一律强制交人: 宁可多问一次人, 也不拿一个看不懂的判决自动建仓
"""
v = (str(verdict).strip() if verdict is not None else "")
if not v or v == VERDICT_CANDIDATE:
return None
if v == VERDICT_WATCH:
return WHY_WATCH_CONFIRM
if v == VERDICT_DISPLAY:
return WHY_DISPLAY_ONLY + ",交人裁决"
return f"选股系统判决值无法识别({v}),交人裁决"
2026-08-06 13:55:27 +08:00
# **有资格**送研判闸的动作 (设计 §7: 自主提议的补足/加仓/补仓/调仓; 2026-08-06 加入新建仓)。
# 注意只是"有资格"——真正送不送由页面参数 PMS_JUDGE_ACTIONS 决定 (judge.request 第一行就按它
# 过滤)。两道门分开是有用的: 决策系统那侧的 OPEN 判据万一要退回去, 页面上摘掉一个词就行,
# 不改码、不部署、不重启。
JUDGE_ACTIONS = {A_FILL, A_ADD, A_DCA, A_OPEN}
def _f(v, d=0.0):
try:
return float(v)
except (TypeError, ValueError):
return d
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, "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:
"""第 idx 批 (0=底仓 1=回踩补足 2=盈利加仓) 的金额额度 = 目标仓位 × 该批比例。"""
scale = _f(params.get("scale"))
target_pct = _f(p.get("target_pct")) or _f(params.get("stock_target_default"), 0.06)
split = params.get("batch_split") or (0.5, 0.25, 0.25)
ratio = split[idx] if idx < len(split) else 0.0
return scale * target_pct * ratio
def room_to_target(p: dict, params: dict) -> float:
"""距目标仓位还差多少钱 (已达标返回 0)。"""
scale = _f(params.get("scale"))
target_pct = _f(p.get("target_pct")) or _f(params.get("stock_target_default"), 0.06)
return max(0.0, target_pct * scale - _f(p.get("market_value")))
# ================================================================ 四类动作
def eval_fill(p: dict, params: dict, mkt: dict):
"""回踩补足: 建仓期内回踩到支撑带但没破, 浮亏还浅 —— 把底仓补到位。"""
if int(p.get("fill_count") or 0) > 0:
return None # 每票 1 次
tdays = mkt.get("tdays_since_open")
if tdays is None or tdays > int(params.get("build_window_tdays", 10)):
return None # 只在建仓期内
cushion = p.get("cushion_pct")
if cushion is None:
return None
floor = _f(params.get("fill_max_loss"), -0.03)
if not (floor < _f(cushion) <= 0):
return None # 浮亏 <3% 且尚未转盈
support = _f(p.get("support_ref"))
price = _f(p.get("price"))
if support <= 0 or price <= 0:
return None # 没有支撑参考位就不做这件事
if price < support:
return None # 支撑已破, 不补
room = room_to_target(p, params)
amt = min(batch_amount(p, params, 1), room)
qty = lot_qty(amt, price)
2026-08-28 13:07:27 +08:00
if qty < lot_of(p.get("ts_code")): # 科创板买入 200 股起
return None
return _cand(p, A_FILL, BUY, qty,
f"回踩补足: 建仓第 {tdays} 个交易日, 浮亏 {_f(cushion):.2%} 未破支撑 "
f"{support}, 补 {qty} 股至目标仓位",
{"cushion_pct": cushion, "support": support, "price": price,
"tdays_since_open": tdays, "room_to_target": round(room, 2)})
def eval_add(p: dict, params: dict, mkt: dict):
"""盈利加仓: 垫子够厚 + 走出新高或站上压力位 —— 顺势加。"""
cushion = p.get("cushion_pct")
solid = _f(params.get("cushion_solid"), 0.03)
if cushion is None or _f(cushion) < solid:
return None # 垫子不厚不加
price = _f(p.get("price"))
if price <= 0:
return None
high5, pressure = _f(mkt.get("high5")), _f(p.get("pressure_ref"))
breakout = (high5 > 0 and price >= high5) or (pressure > 0 and price >= pressure)
if not breakout:
return None
since_add = mkt.get("tdays_since_last_add")
if since_add is not None and since_add < 2:
return None # 距上次加仓 ≥2 交易日
ma5 = _f(mkt.get("ma5"))
no_chase = _f(params.get("no_chase_ma5"), 0.06)
if ma5 > 0 and price / ma5 - 1 > no_chase:
return None # 不追高 (规则闸还会再拦一次)
room = room_to_target(p, params)
amt = min(batch_amount(p, params, 2), room) if room > 0 else batch_amount(p, params, 2)
qty = lot_qty(amt, price)
2026-08-28 13:07:27 +08:00
if qty < lot_of(p.get("ts_code")): # 科创板买入 200 股起
return None
why = "创 5 日新高" if (high5 > 0 and price >= high5) else "站上压力位"
return _cand(p, A_ADD, BUY, qty,
f"盈利加仓: 安全垫 {_f(cushion):.2%}{solid:.0%}{why} "
f"({price} vs 高点 {high5 or ''}/压力 {pressure or ''}), 加 {qty}",
{"cushion_pct": cushion, "price": price, "high5": high5,
"pressure": pressure, "ma5": ma5, "tdays_since_last_add": since_add})
def eval_dca(p: dict, params: dict, mkt: dict):
"""补仓: 浮亏触及评估档才评估, 各档评估一次、执行终身一次, 深档永远要用户点头。"""
cushion = p.get("cushion_pct")
if cushion is None or _f(cushion) >= 0:
return None
if int(p.get("dca_qty") or 0) > 0:
return None # 终身一次, 已执行过
triggers = params.get("dca_triggers") or (-0.08, -0.15)
stage = dca_stage(_f(cushion), triggers)
if stage <= 0:
return None
evaluated = int(p.get("dca_count") or 0)
if stage <= evaluated:
return None # 该档已评估过, 不重复提
price = _f(p.get("price"))
base_qty = int(p.get("base_qty") or 0)
if price <= 0 or base_qty <= 0:
return None
max_ratio = _f(params.get("dca_max_ratio"), 0.5)
qty = int(base_qty * max_ratio // LOT) * LOT
2026-08-28 13:07:27 +08:00
if qty < lot_of(p.get("ts_code")): # 科创板买入 200 股起
return None
deep = _f(params.get("dca_deep_confirm"), -0.15)
is_deep = _f(cushion) <= deep + 1e-12
depth = sorted(triggers, reverse=True)[stage - 1]
return _cand(p, A_DCA, BUY, qty,
f"补仓评估: 浮亏 {_f(cushion):.2%} 触及 {depth:.0%} 档 (第 {stage} 档), "
f"拟补 {qty} 股 (≤底仓 {max_ratio:.0%})"
+ ("; **深档: 必须用户确认**, 研判须回答杀逻辑还是杀情绪" if is_deep else ""),
{"cushion_pct": cushion, "stage": stage, "trigger": depth,
"base_qty": base_qty, "price": price, "stop_ref": p.get("stop_ref")},
confirm=is_deep)
def eval_trim(p: dict, params: dict, mkt: dict = None):
"""保垫减仓: 垫子冲高后回吐过半 —— 减 1/3 把利润锁住。减持不设确认门槛, 规则自动执行。"""
peak = _f(p.get("cushion_peak"))
now = p.get("cushion_pct")
if now is None:
return None
if not trim_trigger(peak, _f(now), _f(params.get("trim_peak"), 0.06),
_f(params.get("trim_giveback"), 0.5)):
return None
total = int(p.get("total_qty") or 0)
qty = int(total / 3 // LOT) * LOT
2026-08-28 13:07:27 +08:00
if qty < lot_of(p.get("ts_code")): # 科创板部分卖 200 股起 (不足就不自动减)
return None
return _cand(p, A_TRIM, SELL, qty,
f"保垫减仓: 安全垫峰值 {peak:.2%} 回吐至 {_f(now):.2%} (过半), "
f"{qty} 股锁盈",
{"cushion_peak": peak, "cushion_pct": now, "total_qty": total,
"price": p.get("price")})
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 # 还没到价
第零件甲组:PMS 目标价那条路的四个缺陷,加裁决理由服务端强制 独立审查(09-07)报的两条高严重度与两条中严重度,都在目标价这条路上,都违背 09-03 写下的「目标价必交人、系统不自动卖」。 一,跨轮减持抢在人前面卖。同轮只发一条减持只管一次扫描;下一分钟到价清仓已成在途、 按(代码,动作)被跳过,保垫减仓单独产出、不需确认、卖出方向又不走强制入队,于是 自动卖掉三分之一,人随后采纳的清仓单永远等不到可卖量。修法两处互为保险:动作引擎里 同票只要有任何一条减持在跳过集合里,本轮其余减持一律让路;提议服务的在途集合对减持侧 按代码去重,一条在途两种减持一起记。 二,到价提议挂着时高置信止损被吞。在途检查把等人拍板的到价清仓算作在途,止损整条不落。 到价提议的来源写在硬数字里,按来源区分:等人拍板的止盈不挡止损。止损落单后把还挂着的 到价提议作废并在账本记一行,免得人再点采纳对一只已清掉的票再发清仓。 三,到价当天有买入成交时清仓被整条拒。原先按总持仓报数,规则闸一句「卖出 > T+1 可卖」 拒掉再被当日去重挡住,人当天看不到。改按可卖量报数,与执行器口径对齐;可卖为零不产出。 采纳路径同样按拍板那一刻的可卖量重算。 四,挂了交易方案的票目标价永不触发。原先整只跳过,策略层又没有任何地方读目标价。 改成策略票仍评目标价,其余四类不评。目标价是用户的命令,优先级高于自动挂上的方案。 另收第五件里的一小项:裁决理由改为服务端强制,空理由直接拒绝请求。此前必填只在浏览器 里成立,任何脚本都能写出一条默认文案的裁决记录。 测试:第四批加三例(跨轮让路、策略票评目标价、按可卖量报数),第二十一批加两例 (到价提议不挡止损并被作废、非到价来源的在途仍挡),例数 655 到 660。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:22:05 +08:00
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
第零件甲组:PMS 目标价那条路的四个缺陷,加裁决理由服务端强制 独立审查(09-07)报的两条高严重度与两条中严重度,都在目标价这条路上,都违背 09-03 写下的「目标价必交人、系统不自动卖」。 一,跨轮减持抢在人前面卖。同轮只发一条减持只管一次扫描;下一分钟到价清仓已成在途、 按(代码,动作)被跳过,保垫减仓单独产出、不需确认、卖出方向又不走强制入队,于是 自动卖掉三分之一,人随后采纳的清仓单永远等不到可卖量。修法两处互为保险:动作引擎里 同票只要有任何一条减持在跳过集合里,本轮其余减持一律让路;提议服务的在途集合对减持侧 按代码去重,一条在途两种减持一起记。 二,到价提议挂着时高置信止损被吞。在途检查把等人拍板的到价清仓算作在途,止损整条不落。 到价提议的来源写在硬数字里,按来源区分:等人拍板的止盈不挡止损。止损落单后把还挂着的 到价提议作废并在账本记一行,免得人再点采纳对一只已清掉的票再发清仓。 三,到价当天有买入成交时清仓被整条拒。原先按总持仓报数,规则闸一句「卖出 > T+1 可卖」 拒掉再被当日去重挡住,人当天看不到。改按可卖量报数,与执行器口径对齐;可卖为零不产出。 采纳路径同样按拍板那一刻的可卖量重算。 四,挂了交易方案的票目标价永不触发。原先整只跳过,策略层又没有任何地方读目标价。 改成策略票仍评目标价,其余四类不评。目标价是用户的命令,优先级高于自动挂上的方案。 另收第五件里的一小项:裁决理由改为服务端强制,空理由直接拒绝请求。此前必填只在浏览器 里成立,任何脚本都能写出一条默认文案的裁决记录。 测试:第四批加三例(跨轮让路、策略票评目标价、按可卖量报数),第二十一批加两例 (到价提议不挡止损并被作废、非到价来源的在途仍挡),例数 655 到 660。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:22:05 +08:00
note = "" if qty == total else f" (总持仓 {total} 股, 今天只能卖 T+1 可卖的 {qty} 股)"
c = _cand(p, A_EXIT, SELL, qty,
f"目标价到价: 现价 {price} 已到你设的目标价 {target}, "
第零件甲组:PMS 目标价那条路的四个缺陷,加裁决理由服务端强制 独立审查(09-07)报的两条高严重度与两条中严重度,都在目标价这条路上,都违背 09-03 写下的「目标价必交人、系统不自动卖」。 一,跨轮减持抢在人前面卖。同轮只发一条减持只管一次扫描;下一分钟到价清仓已成在途、 按(代码,动作)被跳过,保垫减仓单独产出、不需确认、卖出方向又不走强制入队,于是 自动卖掉三分之一,人随后采纳的清仓单永远等不到可卖量。修法两处互为保险:动作引擎里 同票只要有任何一条减持在跳过集合里,本轮其余减持一律让路;提议服务的在途集合对减持侧 按代码去重,一条在途两种减持一起记。 二,到价提议挂着时高置信止损被吞。在途检查把等人拍板的到价清仓算作在途,止损整条不落。 到价提议的来源写在硬数字里,按来源区分:等人拍板的止盈不挡止损。止损落单后把还挂着的 到价提议作废并在账本记一行,免得人再点采纳对一只已清掉的票再发清仓。 三,到价当天有买入成交时清仓被整条拒。原先按总持仓报数,规则闸一句「卖出 > T+1 可卖」 拒掉再被当日去重挡住,人当天看不到。改按可卖量报数,与执行器口径对齐;可卖为零不产出。 采纳路径同样按拍板那一刻的可卖量重算。 四,挂了交易方案的票目标价永不触发。原先整只跳过,策略层又没有任何地方读目标价。 改成策略票仍评目标价,其余四类不评。目标价是用户的命令,优先级高于自动挂上的方案。 另收第五件里的一小项:裁决理由改为服务端强制,空理由直接拒绝请求。此前必填只在浏览器 里成立,任何脚本都能写出一条默认文案的裁决记录。 测试:第四批加三例(跨轮让路、策略票评目标价、按可卖量报数),第二十一批加两例 (到价提议不挡止损并被作废、非到价来源的在途仍挡),例数 655 到 660。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:22:05 +08:00
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)
c["confirm_why"] = WHY_TARGET_PRICE_CONFIRM
return c
def eval_weak_research(p: dict, params: dict, mkt: dict = None):
"""研究证据走弱的减持 (2026-09-07 第三件): 持仓行上的逻辑状态是「逻辑存疑」时, 提议减掉一部分。
这是 reduce_on_weak_research 那条预留通道的第一个上游三条口径一次定死:
开关 params["logic_doubt_trim"] (PMS_LOGIC_DOUBT_TRIM), **默认关** 先跑停增持一个月看误伤;
比例 params["logic_doubt_trim_ratio"] (PMS_LOGIC_DOUBT_TRIM_RATIO), 默认与保垫减仓相同的三分之一;
数量 T+1 可卖量夹紧, 不足最小申报数量不产出 (与保垫减仓到价清仓同口径)
产出的候选带 research_weak 来源与强制确认标记, 分流那一步一票否决入人工队列, 绝不自动卖
没有逻辑状态 (早上没查到) 不产出 没有读数不是证据走弱
"""
if not params.get("logic_doubt_trim"):
return None
st = p.get("logic_state")
if not is_logic_doubt(st):
return None
total = int(p.get("total_qty") or 0)
ratio = _f(params.get("logic_doubt_trim_ratio"), 1.0 / 3)
if ratio <= 0 or ratio > 1:
ratio = 1.0 / 3
qty = clamp_sell_qty(int(total * ratio // LOT) * LOT, p)
if qty < lot_of(p.get("ts_code")):
return None
why = logic_doubt_text(st, f"研究证据走弱: 拟减 {qty} 股 (持仓 {total} 股的 {ratio:.0%}), 卖不卖由你拍板")
return reduce_on_weak_research(
p["ts_code"], qty=qty, reason=why,
hard_numbers={"logic_state": st, "price": p.get("price"),
"cushion_pct": p.get("cushion_pct"), "total_qty": total,
"avail_qty": p.get("avail_qty"), "trim_ratio": round(ratio, 4)})
第零件甲组:PMS 目标价那条路的四个缺陷,加裁决理由服务端强制 独立审查(09-07)报的两条高严重度与两条中严重度,都在目标价这条路上,都违背 09-03 写下的「目标价必交人、系统不自动卖」。 一,跨轮减持抢在人前面卖。同轮只发一条减持只管一次扫描;下一分钟到价清仓已成在途、 按(代码,动作)被跳过,保垫减仓单独产出、不需确认、卖出方向又不走强制入队,于是 自动卖掉三分之一,人随后采纳的清仓单永远等不到可卖量。修法两处互为保险:动作引擎里 同票只要有任何一条减持在跳过集合里,本轮其余减持一律让路;提议服务的在途集合对减持侧 按代码去重,一条在途两种减持一起记。 二,到价提议挂着时高置信止损被吞。在途检查把等人拍板的到价清仓算作在途,止损整条不落。 到价提议的来源写在硬数字里,按来源区分:等人拍板的止盈不挡止损。止损落单后把还挂着的 到价提议作废并在账本记一行,免得人再点采纳对一只已清掉的票再发清仓。 三,到价当天有买入成交时清仓被整条拒。原先按总持仓报数,规则闸一句「卖出 > T+1 可卖」 拒掉再被当日去重挡住,人当天看不到。改按可卖量报数,与执行器口径对齐;可卖为零不产出。 采纳路径同样按拍板那一刻的可卖量重算。 四,挂了交易方案的票目标价永不触发。原先整只跳过,策略层又没有任何地方读目标价。 改成策略票仍评目标价,其余四类不评。目标价是用户的命令,优先级高于自动挂上的方案。 另收第五件里的一小项:裁决理由改为服务端强制,空理由直接拒绝请求。此前必填只在浏览器 里成立,任何脚本都能写出一条默认文案的裁决记录。 测试:第四批加三例(跨轮让路、策略票评目标价、按可卖量报数),第二十一批加两例 (到价提议不挡止损并被作废、非到价来源的在途仍挡),例数 655 到 660。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:22:05 +08:00
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)))
# 研究走弱的减持排在保垫减仓前面 (2026-09-07 第三件): 两者动作名都是 TRIM, 同轮同现时按
# _sell_priority 留研究走弱那条 (它必交人), 保垫减仓让路 —— 证据在走弱的票, 不该由规则自动先卖一刀。
EVALUATORS = ((A_TRIM, eval_weak_research), (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)
# ================================================================ 跳过原因
SKIP_WHY_FALLBACK = "已有在途提议或指令, 或今日已被闸门拒过"
def skip_why(skip, key):
"""这个 (代码, 动作) 为什么被跳过。不在跳过集合里返回 None。
skip 可以是 `{(代码, 动作): 原因}` 字典, 也可以是只有键的集合 (旧口径)
集合形式退回一句笼统的兜底话 这是 2026-08-06 之前的样子, 留着是为了兼容
既有单测与临时调用, 生产路径由 proposal_service 传字典进来
为什么值得专门改这一处: 那句兜底话把四种处境揉成了一句 有在途提议有在途指令
今天被规则闸拒过今天被研判闸驳回过前两种明天照样会被挡, 后两种日切就重新评估,
而它们在页面上长得一模一样, 看的人判断不了这只票明天还会不会再被评估
明着跳过了不该等于说不清为什么跳过
"""
if not skip or key not in skip:
return None
if isinstance(skip, dict):
return skip.get(key) or SKIP_WHY_FALLBACK
return SKIP_WHY_FALLBACK
# ================================================================ 扫描入口
2026-08-11 10:35:03 +08:00
def scan(*, positions: list, params: dict, market: dict, skip=None,
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 {}
2026-08-11 10:35:03 +08:00
strategy_codes = strategy_codes or set()
stock_params = stock_params or {}
out, skipped = [], []
for p in positions or []:
code = p.get("ts_code")
if not code or int(p.get("total_qty") or 0) <= 0:
continue
第零件甲组:PMS 目标价那条路的四个缺陷,加裁决理由服务端强制 独立审查(09-07)报的两条高严重度与两条中严重度,都在目标价这条路上,都违背 09-03 写下的「目标价必交人、系统不自动卖」。 一,跨轮减持抢在人前面卖。同轮只发一条减持只管一次扫描;下一分钟到价清仓已成在途、 按(代码,动作)被跳过,保垫减仓单独产出、不需确认、卖出方向又不走强制入队,于是 自动卖掉三分之一,人随后采纳的清仓单永远等不到可卖量。修法两处互为保险:动作引擎里 同票只要有任何一条减持在跳过集合里,本轮其余减持一律让路;提议服务的在途集合对减持侧 按代码去重,一条在途两种减持一起记。 二,到价提议挂着时高置信止损被吞。在途检查把等人拍板的到价清仓算作在途,止损整条不落。 到价提议的来源写在硬数字里,按来源区分:等人拍板的止盈不挡止损。止损落单后把还挂着的 到价提议作废并在账本记一行,免得人再点采纳对一只已清掉的票再发清仓。 三,到价当天有买入成交时清仓被整条拒。原先按总持仓报数,规则闸一句「卖出 > T+1 可卖」 拒掉再被当日去重挡住,人当天看不到。改按可卖量报数,与执行器口径对齐;可卖为零不产出。 采纳路径同样按拍板那一刻的可卖量重算。 四,挂了交易方案的票目标价永不触发。原先整只跳过,策略层又没有任何地方读目标价。 改成策略票仍评目标价,其余四类不评。目标价是用户的命令,优先级高于自动挂上的方案。 另收第五件里的一小项:裁决理由改为服务端强制,空理由直接拒绝请求。此前必填只在浏览器 里成立,任何脚本都能写出一条默认文案的裁决记录。 测试:第四批加三例(跨轮让路、策略票评目标价、按可卖量报数),第二十一批加两例 (到价提议不挡止损并被作废、非到价来源的在途仍挡),例数 655 到 660。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:22:05 +08:00
# 挂了 ACTIVE 交易方案(策略)的票由策略层接管, 四类自主动作不评 (设计 §四)。
# **但目标价那条照评** (2026-09-07 审查修): 目标价是用户下的命令, 优先级高于自动
# 挂上的方案; 原先整只跳过, 结果是命令表显示生效中、到价那天什么都不发生,
# 且策略层没有任何地方读目标价 —— 一条界面上点了会成功、实际永不触发的命令。
on_strategy = code in strategy_codes
if on_strategy:
2026-08-11 10:35:03 +08:00
skipped.append({"ts_code": code, "action": "*",
第零件甲组:PMS 目标价那条路的四个缺陷,加裁决理由服务端强制 独立审查(09-07)报的两条高严重度与两条中严重度,都在目标价这条路上,都违背 09-03 写下的「目标价必交人、系统不自动卖」。 一,跨轮减持抢在人前面卖。同轮只发一条减持只管一次扫描;下一分钟到价清仓已成在途、 按(代码,动作)被跳过,保垫减仓单独产出、不需确认、卖出方向又不走强制入队,于是 自动卖掉三分之一,人随后采纳的清仓单永远等不到可卖量。修法两处互为保险:动作引擎里 同票只要有任何一条减持在跳过集合里,本轮其余减持一律让路;提议服务的在途集合对减持侧 按代码去重,一条在途两种减持一起记。 二,到价提议挂着时高置信止损被吞。在途检查把等人拍板的到价清仓算作在途,止损整条不落。 到价提议的来源写在硬数字里,按来源区分:等人拍板的止盈不挡止损。止损落单后把还挂着的 到价提议作废并在账本记一行,免得人再点采纳对一只已清掉的票再发清仓。 三,到价当天有买入成交时清仓被整条拒。原先按总持仓报数,规则闸一句「卖出 > T+1 可卖」 拒掉再被当日去重挡住,人当天看不到。改按可卖量报数,与执行器口径对齐;可卖为零不产出。 采纳路径同样按拍板那一刻的可卖量重算。 四,挂了交易方案的票目标价永不触发。原先整只跳过,策略层又没有任何地方读目标价。 改成策略票仍评目标价,其余四类不评。目标价是用户的命令,优先级高于自动挂上的方案。 另收第五件里的一小项:裁决理由改为服务端强制,空理由直接拒绝请求。此前必填只在浏览器 里成立,任何脚本都能写出一条默认文案的裁决记录。 测试:第四批加三例(跨轮让路、策略票评目标价、按可卖量报数),第二十一批加两例 (到价提议不挡止损并被作废、非到价来源的在途仍挡),例数 655 到 660。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:22:05 +08:00
"why": "挂了交易方案(策略), 由策略层接管, 补足/加仓/补仓/保垫减仓不自动提议; "
"你设的目标价仍照常看"})
2026-07-31 16:10:58 +08:00
# 取不到现价的票整只跳过, **并且留痕**。上游 (portfolio.positions_view) 在拿不到
# 行情时会用摊薄成本顶住 price 让市值还能算, 但那个价不是行情 —— 拿它评动作会得出
# 「安全垫恰好 0」「现价恰好等于成本」这类看着正常、实则凭空的结论。
# 四个 evaluator 目前都会因为 cushion_pct is None 而自然返回 None, 但那是**碰巧**
# 兜住了: eval_fill 还会拿这个假价去比支撑位。所以在入口显式挡掉, 并写进 skipped ——
# 「什么都没发生」和「明着跳过了」在页面上必须是两回事。
if p.get("price_ok") is False:
skipped.append({"ts_code": code, "action": "*",
"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"
# 逻辑存疑停增持侧 (2026-09-07 第三件): 持仓行上挂着早上查回的逻辑状态 (logic_state_service.attach),
# 存疑的票补足 / 加仓 / 补仓一律不评, 减持侧照评。没挂这个键 = 没有读数, 不拦。
doubt = bool(params.get("logic_state_route")) and is_logic_doubt(p.get("logic_state"))
第零件甲组:PMS 目标价那条路的四个缺陷,加裁决理由服务端强制 独立审查(09-07)报的两条高严重度与两条中严重度,都在目标价这条路上,都违背 09-03 写下的「目标价必交人、系统不自动卖」。 一,跨轮减持抢在人前面卖。同轮只发一条减持只管一次扫描;下一分钟到价清仓已成在途、 按(代码,动作)被跳过,保垫减仓单独产出、不需确认、卖出方向又不走强制入队,于是 自动卖掉三分之一,人随后采纳的清仓单永远等不到可卖量。修法两处互为保险:动作引擎里 同票只要有任何一条减持在跳过集合里,本轮其余减持一律让路;提议服务的在途集合对减持侧 按代码去重,一条在途两种减持一起记。 二,到价提议挂着时高置信止损被吞。在途检查把等人拍板的到价清仓算作在途,止损整条不落。 到价提议的来源写在硬数字里,按来源区分:等人拍板的止盈不挡止损。止损落单后把还挂着的 到价提议作废并在账本记一行,免得人再点采纳对一只已清掉的票再发清仓。 三,到价当天有买入成交时清仓被整条拒。原先按总持仓报数,规则闸一句「卖出 > T+1 可卖」 拒掉再被当日去重挡住,人当天看不到。改按可卖量报数,与执行器口径对齐;可卖为零不产出。 采纳路径同样按拍板那一刻的可卖量重算。 四,挂了交易方案的票目标价永不触发。原先整只跳过,策略层又没有任何地方读目标价。 改成策略票仍评目标价,其余四类不评。目标价是用户的命令,优先级高于自动挂上的方案。 另收第五件里的一小项:裁决理由改为服务端强制,空理由直接拒绝请求。此前必填只在浏览器 里成立,任何脚本都能写出一条默认文案的裁决记录。 测试:第四批加三例(跨轮让路、策略票评目标价、按可卖量报数),第二十一批加两例 (到价提议不挡止损并被作废、非到价来源的在途仍挡),例数 655 到 660。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:22:05 +08:00
# 跨轮的减持让路 (2026-09-07 审查修)。同轮只发一条减持那条规矩 (见下) 只管一次扫描;
# 下一分钟再扫时, 等人拍板的到价清仓已成在途、按 (代码, EXIT) 被跳过, 而保垫减仓
# 单独产出、不需确认、卖出方向又不走强制入队 —— 系统刚说「卖不卖由你定」, 一分钟后
# 自己卖了三分之一, 人随后采纳的清仓单永远等不到可卖量。
# 修法: 这只票只要有任何一条减持在跳过集合里 (在途提议、在途指令、今日被拒过),
# 本轮其余减持一律让路。宁可少卖一次, 不能抢在人前面卖。
inflight_sell = next((a for a in SELL_SIDE_ACTIONS if skip_why(skip, (code, a))), None)
2026-08-28 13:07:27 +08:00
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
第零件甲组:PMS 目标价那条路的四个缺陷,加裁决理由服务端强制 独立审查(09-07)报的两条高严重度与两条中严重度,都在目标价这条路上,都违背 09-03 写下的「目标价必交人、系统不自动卖」。 一,跨轮减持抢在人前面卖。同轮只发一条减持只管一次扫描;下一分钟到价清仓已成在途、 按(代码,动作)被跳过,保垫减仓单独产出、不需确认、卖出方向又不走强制入队,于是 自动卖掉三分之一,人随后采纳的清仓单永远等不到可卖量。修法两处互为保险:动作引擎里 同票只要有任何一条减持在跳过集合里,本轮其余减持一律让路;提议服务的在途集合对减持侧 按代码去重,一条在途两种减持一起记。 二,到价提议挂着时高置信止损被吞。在途检查把等人拍板的到价清仓算作在途,止损整条不落。 到价提议的来源写在硬数字里,按来源区分:等人拍板的止盈不挡止损。止损落单后把还挂着的 到价提议作废并在账本记一行,免得人再点采纳对一只已清掉的票再发清仓。 三,到价当天有买入成交时清仓被整条拒。原先按总持仓报数,规则闸一句「卖出 > T+1 可卖」 拒掉再被当日去重挡住,人当天看不到。改按可卖量报数,与执行器口径对齐;可卖为零不产出。 采纳路径同样按拍板那一刻的可卖量重算。 四,挂了交易方案的票目标价永不触发。原先整只跳过,策略层又没有任何地方读目标价。 改成策略票仍评目标价,其余四类不评。目标价是用户的命令,优先级高于自动挂上的方案。 另收第五件里的一小项:裁决理由改为服务端强制,空理由直接拒绝请求。此前必填只在浏览器 里成立,任何脚本都能写出一条默认文案的裁决记录。 测试:第四批加三例(跨轮让路、策略票评目标价、按可卖量报数),第二十一批加两例 (到价提议不挡止损并被作废、非到价来源的在途仍挡),例数 655 到 660。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:22:05 +08:00
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,
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
"why": frozen_why(p.get("frozen_reason"))})
continue
if doubt and action not in SELL_SIDE_ACTIONS:
skipped.append({"ts_code": code, "action": action,
"why": logic_doubt_text(p.get("logic_state"), WHY_LOGIC_DOUBT_PAUSE)})
continue
try:
c = fn(p, params, mkt)
except Exception as e: # 单票异常不能拖垮整轮扫描
skipped.append({"ts_code": code, "action": action,
"why": f"评估异常 {type(e).__name__}: {e}"})
continue
if c:
2026-08-28 13:07:27 +08:00
cands_this.append(c)
# 同一只票同轮买卖互斥 (2026-08-28 审查修): 保垫减仓的峰值是**全时段**只增不减,
# 加仓判据看的是近 5 日窗口, 两套时间基准可以同时成立 —— 一轮里对同一只票
# 一边 TRIM 锁盈一边 ADD 加仓, 自动对倒空耗手续费。触发减持时买入侧让路
# (先落袋为安, 保守方向优先), 买入条件真成立的话下一轮不触发减持时自然会来。
# 2026-09-03: 判据从「有没有 TRIM」放宽成「有没有减持」—— 目标价到价那条也是减持,
# 一边提议清仓一边提议加仓同样是自相矛盾, 不该只有保垫减仓享受这条互斥。
sells_this = [c["action"] for c in cands_this if c["side"] == SELL]
if sells_this:
2026-08-28 13:07:27 +08:00
for c in cands_this:
if c["side"] == BUY:
skipped.append({"ts_code": code, "action": c["action"],
"why": f"同轮已触发减持({'/'.join(sells_this)}), "
f"买卖互斥, 买入侧让路"})
2026-08-28 13:07:27 +08:00
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=_sell_priority)
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]
2026-08-28 13:07:27 +08:00
out.extend(cands_this)
return {"candidates": out, "skipped": skipped}
2026-08-06 13:55:27 +08:00
# ================================================================ 新建仓 (OPEN)
def eval_open(c: dict, params: dict, caps: dict, room_amt: float):
"""一条候选票能不能从零建仓、建多少股。
返回 `(候选, None)` `(None, 跳过原因)` 与上面四个求值器只回 None 不同,
这里**必须给得出原因**: 候选池里明明有这只票却没被提, 页面上要看得出是名额满了
钱不够买不足一手, 还是被上限拦了只是"没出现"等于什么都没说
c: {ts_code, price, score, sector, theme, tier, upside, heat, rank, bucket, src}
price 必须是**实时价**规划用的昨收不能拿来下单 ( market.plan_price 的注释)
caps: 滚动中的组合上下文 (portfolio.caps_ctx 的产出, scan_open 边走边更新)
room_amt: 本轮还剩多少钱可投 (已取过仓位口径真实可用资金的小者)
"""
code = c.get("ts_code")
price = _f(c.get("price"))
if price <= 0:
return None, "取不到实时价, 不建仓 (规划用的昨收不能拿来下单)"
scale = _f(params.get("scale"))
if scale <= 0:
return None, "总规模未设置"
target_pct = _f(params.get("stock_target_default"), 0.06)
want = target_pct * scale
if want <= 0:
return None, "单股目标仓位为 0"
# **钱不够一整只就不开。** 这一条与命令驱动建仓有意不同: 那边是
# `want = min(单股目标, 命令剩余额度)`, 允许最后一只按剩下的钱缩水买 —— 那是用户
# 明确下了一条「投这么多」的命令, 缩水的那只是命令的收尾。
# 自主建仓没有这层意思: 开一只新仓要占掉一个持仓名额, 拿一个名额去换一只 0.5% 的
# 零头仓位是亏的 —— 它永远补不到目标, 却挡住了后面真正建得起来的票。
if want > room_amt:
return None, (f"剩余可投金额 {room_amt:,.0f} 元不足一只目标仓位 "
f"{want:,.0f} 元 ({target_pct:.0%}), 不开半截新仓")
sp = split_batches(want, price, splits=params.get("batch_split"),
2026-08-28 13:07:27 +08:00
merge=bool(params.get("min_lot_merge", True)),
min_lot=lot_of(code))
2026-08-06 13:55:27 +08:00
if not sp["ok"]:
return None, sp["reason"]
# 上限校验按**这只票的整只目标金额**算, 不是只按这次要买的底仓批。
# 决定「要不要开这只新仓」的时候就该把它将来要占的位置留出来 —— 只按底仓批算的话,
# 一跳能开出一堆将来永远补不满的半仓。这也与命令驱动建仓 (plan_increase_exposure
# 里的 `actual = sum(b["qty"] * price ...)`) 是同一个口径。
# 一句要说破的话: 这份预留只在**本轮**有效, 下一跳的组合快照是按实际市值重新取的。
full_amt = sum(b["qty"] * price for b in sp["batches"])
bad = check_all_caps(ts_code=code, add_amount=full_amt, ctx=_new_name_ctx(caps, c))
if bad:
PMS 文案第二批:规则闸、仓位约束、资金、计划取数 抽了一个公共的显示层函数 app/core/copy.py,把句首那个大写判据码剥掉再给人看。 这一步是有讲究的。那些判据码是承重的:账本靠它取去重键(同一条被拒指令当日只记一条), 命令进度靠它做原因聚类。2026-07-29 账本被同一条指令按分钟灌满,病根就是去重键失效。 所以码必须留在句首,只能在显示层剥。此前后端两处各写了一份剥前缀的逻辑,现在共用一份。 改写的文案: 规则闸六句。冻结原因原先写「该股处于 COMMAND_HALT, 禁止增持」,既印了枚举值,也没分开 三种来路——你下的命令、你手设的、系统刹车期间,对交易员是三件不同的事。昨夜信号原先写 「决策系统昨夜定性 SELL (形态恶化)」,SELL 是枚举值,而「形态恶化」是给三种信号硬安的 定性,回避未必是形态问题。 仓位约束六句,原先全是「加后总仓 82.3% > 上限 80%」这种电报体。大于号要人翻译,而且 没说清百分比是占什么。行业占比那条还漏了设计要点——两条都超才拦,不写会被当成误拦 来投诉。 资金四句改在源头。这个字段有三个下游都直接显示给人看,改一处三处一起干净。异常原文 只进日志。 计划取数两句会渲染成页面顶部整条红色横幅。原先网络抖一下就顶一条异常原文给基金经理 看。改成说清三件事:现在拿不到什么、多久之后重试有意义、还不行找谁。其中「过 1 分钟 再试」不能省——失败结果会被缓存一分钟,早于这个点重读拿到的还是同一条失败。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:26:57 +08:00
# 这一串是纯显示用途 —— 它只被塞进 by_code[].why 给页面看, 没有任何地方拿它
# 归类或做键 (做键的是 executor 那条路, 用的是另一份带前缀的原串)。所以在这里
# 剥掉大写判据码, 页面上就不会出现「PORTFOLIO_CAP: …」这种东西。
return None, copy.join_reasons(bad)
2026-08-06 13:55:27 +08:00
base = sp["batches"][0]
qty = int(base["qty"])
2026-08-28 13:07:27 +08:00
if qty < lot_of(code):
return None, f"底仓批 {qty} 股不足最小申报数量 ({lot_of(code)} 股)"
2026-08-06 13:55:27 +08:00
hard = {
# ---- 定性材料: 这只票凭什么被选出来。研判闸要看的就是这几项 ----
"price": price, "score": c.get("score"), "theme": c.get("theme"),
"tier": c.get("tier"), "upside": c.get("upside"), "heat": c.get("heat"),
"plan_rank": c.get("rank"), "plan_bucket": c.get("bucket"),
"plan_src": c.get("src"), "sector": c.get("sector"),
# 决策系统今天盘中判过这只票转多没有。这是**定性材料**不是仓位数字, 所以送研判时
# 也带过去 (见 judge.OPEN_JUDGE_KEYS) —— 等于当面告诉它「你自己今天判过这只票转多」,
# 它可以拿自己的结论去对照; 没有信号时这两项是 None, 不误导。
"intraday_buy_signal": bool(c.get("sig_buy")),
"intraday_buy_reason": (c.get("sig_buy") or {}).get("reason"),
# 上游候选卡的判决与理由 (2026-09-02 起下发; 旧版计划这五项都是 None)。同样是定性
# 材料: 送研判时一并带过去 (judge.OPEN_JUDGE_KEYS), 让决策系统逐条核对「这几条理由
# 到今天还成不成立」; 进评审账本与提议卡, 让人看得见系统为什么挑了它。
"verdict": c.get("verdict"), "reasons": c.get("reasons"),
"missing": c.get("missing"), "risk": c.get("risk"), "card_rank": c.get("card_rank"),
# 研究理由两键 (2026-09-03): 判决依据是上游对自己这条判决的一句话解释, 因果论断是
# 研报里说这家公司好在哪、出处是哪份研报哪一天。两者只进评审账本与提议卡给人看,
# 不参与任何判定, 也不送研判 —— 产业逻辑归数据基座与选股系统, 不归择时决策系统。
"basis": c.get("basis"), "logic": c.get("logic"),
# 逻辑状态四态 (2026-09-07 第三件): 入场那一刻「研究证据还在不在」的读数, 进账本与提议卡,
# 持仓视图的「当初为什么买」从这里回溯; 不送研判 (judge.OPEN_JUDGE_KEYS 不收它)。
"logic_state": c.get("logic_state"),
# 安全边际三情景的整句 (2026-09-07 第四件): 只给人看, 不送研判。
"valuation_text": c.get("valuation_text"),
# 催化事件与定价状态 (2026-09-08 量价研判链 3.5): 原值进账本与提议卡; 两句整句另送研判
# (它们是事件与量价, 不是产业逻辑, 见 judge.OPEN_JUDGE_KEYS)。不参与任何判定。
"events": c.get("events"), "events_text": c.get("events_text"),
"pricing_state": c.get("pricing_state"), "pricing_text": c.get("pricing_text"),
# 相关快讯 (2026-09-08 台账 050): 原值进账本与提议卡, 整句另送研判。只是上下文, 不进判定。
"news": c.get("news"), "news_text": c.get("news_text"),
# 行业催化 (2026-09-08 台账 051): 环节级, 原值进账本与提议卡; 产业逻辑不送研判。
"industry_catalyst": c.get("industry_catalyst"), "industry_catalyst_text": c.get("industry_catalyst_text"),
# 公司深度 (2026-09-09 接入方案): 质地档与评议摘要进账本与提议卡; 整句送研判 (白名单里, 开关可关)。
"company_review": c.get("company_review"), "company_review_text": c.get("company_review_text"),
"company_gate": c.get("company_gate"),
2026-08-06 13:55:27 +08:00
# ---- 仓位口径: 只进评审账本做判分锚。judge.py 送研判时会把这几项过滤掉,
# 理由见那边的 OPEN_JUDGE_KEYS —— 决策系统本来就不管仓位, 别送过去带偏它。
"target_pct": target_pct, "target_amount": round(full_amt, 2),
"base_amount": round(qty * price, 2),
"batch_scheme": ",".join(str(x) for x in (sp.get("scheme") or ())),
"names_before": caps.get("names_count"), "max_names": caps.get("max_names"),
"room_amt_before": round(_f(room_amt), 2),
}
reason = (f"新建仓: 候选池第 {c.get('rank') or ''} 名 (分数 {c.get('score') or ''}"
f"{', 主题 ' + str(c.get('theme')) if c.get('theme') else ''})"
+ ("; **决策系统今天盘中判过该股转多**" if c.get("sig_buy") else "")
+ f", 现价 {price}, 目标仓位 {target_pct:.0%}{full_amt:,.0f} 元, "
f"先建底仓 {qty} 股 (约 {qty * price:,.0f} 元)")
2026-08-06 13:55:27 +08:00
cand = _cand(c, A_OPEN, BUY, qty, reason, hard)
# 这两项是 OPEN 独有的, 供 proposal_service 用:
# price —— 新票在账本里没有行, _pos_of 拿不到现价, 取它会是 0 而被规则闸判 PRICE_MISSING
# sector —— caps_ctx 对新票带不出行业名, 不显式传的话行业集中度那道硬拦截会静默跳过
cand["price"] = price
cand["sector"] = c.get("sector")
cand["target_amount"] = round(full_amt, 2)
return cand, None
def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
slots: int, skip=None) -> dict:
2026-08-06 13:55:27 +08:00
"""从上游候选池挑新票建仓。名额与金额**边走边扣**, 所以产出的这一组候选彼此不冲突。
为什么滚动必须在这里做, 而不是交给规则闸: `_route_one` 给每条候选调 `caps_ctx` ,
拿的是**本轮开始时**的那一份组合快照, 不会因为前面几条已经落了指令而更新于是一跳产出
五条新建仓, 每条单独看都不超上限五条加起来超了, 规则闸一条也拦不住 它每次看到的
都是同一个旧快照命令驱动那条路没这个问题, 因为 planner 在循环里用 `_ctx_after` 滚动
自主这条路照做
**刻意没有每日开仓上限** 上界由三层自己收敛: 这里的名额与金额边走边扣;
后面规则闸的资金与上限终检; 再后面择时的买入区间 (现价不在区间内一律等待不追)
slots: 还能开几只新仓 = 最大持仓数 当前持仓数 (由调用方算好)
room_amt: 还有多少钱可投, 已取总仓上限×总规模 组合市值真实可用资金的小者
skip: 不再评估的 (代码, 动作) 字典时值是跳过原因 (在途提议 / 在途指令 /
今日被规则闸拒过 / 今日被研判闸驳回过, 这四种处境完全不同, 必须分得开)
按上游判决分流 (2026-09-03, params["open_route_by_verdict"] 为真时生效):
仅展示 不产出候选, 写一条跳过原因 (正常路径上 plan_feed 已在候选阶段剔掉, 这里是
兜底 直接调本函数的人也得到同一口径);
关注 照常产出候选, 但打上 needs_user_confirm 并带上原因 (confirm_why),
proposal_service._route_one 强制入人工队列;
候选 / 没有判决 一个字不改
落点选在这里而不是 eval_open: 判决决定的是这条候选往哪条路走, 建不建建多少
是两个问题; 放在产出候选的循环里, 仅展示的行连名额与金额都不扣, 而且开关关着时
这段代码一行都不执行 旧行为逐字保留
2026-08-06 13:55:27 +08:00
"""
skip = skip or {}
2026-08-06 13:55:27 +08:00
out, skipped = [], []
slots = int(slots or 0)
left = _f(room_amt)
route = bool(params.get("open_route_by_verdict"))
2026-08-06 13:55:27 +08:00
if slots <= 0:
return {"candidates": [], "skipped": [
{"ts_code": "*", "action": A_OPEN,
"why": f"持仓数已达上限 {caps.get('max_names')}, 没有新仓名额"}]}
if left <= 0:
return {"candidates": [], "skipped": [
{"ts_code": "*", "action": A_OPEN,
"why": "组合已到总仓上限, 或可用资金为零 —— 没有可投金额"}]}
ctx = dict(caps)
sector_on = bool(ctx.get("sector_source_ready", True))
# 排序: 今天被决策系统判过「盘中转多」的票排最前, 其余按分数降序、同分按榜内名次
# (与 plan_feed.select_candidates 的次序一致)。
#
# 为什么让信号插队而不是加分: 分数是昨夜算的静态排名, 「此刻转多」是盘中才有的新信息,
# 两者不同量纲, 折算成分数就得凭空定一个系数。而名额是稀缺的 —— 只剩两个名额时,
# 候选池第 25 名按分数永远轮不上, 哪怕决策系统刚刚判它转多。插队直接表达了这件事。
# **只影响先后, 不影响资格**: 不在候选池里的票根本不会走到这里 (调用方传进来的就是
# 候选池的产物), 候选层那一整套过滤一道都不绕。
# PMS_OPEN_SIGNAL_PRIORITY 关掉即退回纯分数排序, 页面可改, 不用改码。
sig_first = bool(params.get("open_signal_priority", True))
2026-08-06 13:55:27 +08:00
for c in sorted(candidates or [],
key=lambda x: (0 if (sig_first and x.get("sig_buy")) else 1,
-_f(x.get("score")), _f(x.get("rank"), 10 ** 9),
2026-08-06 13:55:27 +08:00
str(x.get("ts_code") or ""))):
code = c.get("ts_code")
if not code:
continue
if route and c.get("verdict") == VERDICT_DISPLAY:
# 仅展示的行不占名额、不占金额, 也不进后面任何一道闸; 放在名额判断之前,
# 是为了让它的跳过原因永远是「仅展示」而不是碰巧的「名额用完」。
skipped.append({"ts_code": code, "action": A_OPEN,
"why": display_only_why(c)})
continue
2026-08-06 13:55:27 +08:00
if slots <= 0:
skipped.append({"ts_code": code, "action": A_OPEN,
"why": "本轮新仓名额已用完 (下一跳按最新持仓数重算)"})
continue
if left <= 0:
skipped.append({"ts_code": code, "action": A_OPEN,
"why": "本轮可投金额已用完 (下一跳按最新组合市值重算)"})
continue
why_skip = skip_why(skip, (code, A_OPEN))
if why_skip:
skipped.append({"ts_code": code, "action": A_OPEN, "why": why_skip})
2026-08-06 13:55:27 +08:00
continue
# 建议方案 (2026-09-09 接入方案第六之二节): 先按质地、估值、赔率、逻辑状态、定价状态出仓位档与分批;
# 开关 PMS_PLAN_BY_ADVICE 开着时它就是评估参数, 关着时只并排显示。研判那一档在提议落账前补。
2026-08-06 13:55:27 +08:00
try:
from app.services import advice_service as _adv
adv = _adv.advise(c, params)
p_use = _adv.params_for(adv, params) if params.get("plan_by_advice") else params
except Exception as e: # noqa: BLE001 —— 建议算不出不拦评估
adv, p_use = {"tier": None, "text": f"建议方案算不出: {type(e).__name__}"}, params
try:
cand, why = eval_open(c, p_use, ctx, left)
2026-08-06 13:55:27 +08:00
except Exception as e: # 单票异常不能拖垮整轮扫描 (与 scan() 同口径)
skipped.append({"ts_code": code, "action": A_OPEN,
"why": f"评估异常 {type(e).__name__}: {e}"})
continue
if not cand:
skipped.append({"ts_code": code, "action": A_OPEN, "why": why or "未产出候选"})
continue
try:
cand.setdefault("hard_numbers", {})["advice"] = adv
cand["hard_numbers"]["advice_text"] = adv.get("text") if isinstance(adv, dict) else None
cand["hard_numbers"]["plan_by_advice"] = bool(params.get("plan_by_advice"))
if isinstance(adv, dict) and adv.get("tier") == "试探仓":
cand["needs_user_confirm"] = True
cand["confirm_why"] = (cand.get("confirm_why") or "") + "建议方案为试探仓,只在人工确认后下;"
except Exception: # noqa: BLE001
pass
if route:
confirm_why = verdict_confirm_why(c.get("verdict"))
if confirm_why:
# 关注 (或认不出的判决): 候选照常产出、照常占名额、照常过规则闸与研判闸,
# 只是最后一步不许自动落指令 —— 与深档补仓强制确认走的是同一个字段。
cand["needs_user_confirm"] = True
cand["confirm_why"] = confirm_why
# 判决候选而逻辑存疑 (2026-09-07 第三件): 可交易口径成立而研究证据在走弱, 这正是杀逻辑与
# 杀估值那条边界, 强制交人; 复用同一个强制确认字段, 与关注判决走同一条队列。已经要确认的
# (关注 / 认不出的判决) 保留原因, 不覆盖。开关 PMS_LOGIC_STATE_ROUTE 关着时一行都不执行。
if params.get("open_route_by_logic") and not cand.get("needs_user_confirm"):
lw = logic_confirm_why(c.get("logic_state"))
if lw:
cand["needs_user_confirm"] = True
cand["confirm_why"] = lw
2026-08-06 13:55:27 +08:00
out.append(cand)
used = _f(cand.get("target_amount"))
left -= used
slots -= 1
ctx = _ctx_after(ctx, used, is_new_name=True,
sector=(c.get("sector") if sector_on else None))
return {"candidates": out, "skipped": skipped}