2026-07-28 09:29:53 +08:00
|
|
|
|
# -*- 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 锁盈 | 纯规则自动执行 (减持方向不设确认门槛) |
|
2026-09-03 16:06:27 +08:00
|
|
|
|
| EXIT 目标价到价 | 现价 ≥ 用户「设定某股目标价」命令里的价 | 全部持仓; **必定交人拍板, 绝不自动卖** |
|
2026-08-06 13:55:27 +08:00
|
|
|
|
| OPEN 新建仓 | 上游候选池里的新票, 且还有持仓名额与可投金额 | 名额与金额边走边扣; 只提底仓批 |
|
2026-07-28 09:29:53 +08:00
|
|
|
|
|
|
|
|
|
|
本模块只回答「该不该动、动多少、为什么」, 不查库不下发:
|
|
|
|
|
|
* 交易日相关的输入 (建仓天数、距上次加仓天数) 由 services 层用交易日历算好传进来,
|
|
|
|
|
|
避免把日历依赖塞进纯逻辑。
|
|
|
|
|
|
* 上限/一手/冻结等硬约束**不在这里重复判**, 统一由规则闸终检 (职责单一, 口径唯一)。
|
|
|
|
|
|
这里只做「引擎自身的触发条件」与「批次额度」计算。
|
2026-08-06 13:55:27 +08:00
|
|
|
|
**唯一的例外是新建仓**: 它一轮能产出多条候选, 而规则闸每条拿到的都是同一份本轮开始时
|
|
|
|
|
|
的组合快照, 于是"每条单独看都不超上限、加起来超了"这种情况它拦不住。所以那条路的上限
|
|
|
|
|
|
校验必须在这里就滚动算一遍 —— 用的仍然是规则闸那个 check_all_caps, 口径没有第二份。
|
2026-07-28 09:29:53 +08:00
|
|
|
|
|
|
|
|
|
|
输出候选统一结构, 供 proposal_service 走 规则闸 → 研判闸 → 按自主档位分流。
|
2026-08-06 13:55:27 +08:00
|
|
|
|
|
|
|
|
|
|
扫描入口有两个, 输入不同, 互不影响:
|
2026-09-03 16:06: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() 照常跑, 反之亦然 —— 一条外部接口的故障不该让整轮扫描停摆。
|
2026-07-28 09:29:53 +08:00
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.cushion import dca_stage, trim_trigger
|
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
|
2026-07-28 09:29:53 +08:00
|
|
|
|
|
|
|
|
|
|
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 同名同义)
|
2026-09-03 16:06:27 +08:00
|
|
|
|
# 清仓 (与 planner.A_EXIT、executor.SELL_ACTIONS、页面动作词表同名同义)。
|
|
|
|
|
|
# 目标价到价产出的退出提议用的就是它 —— 不新造动作名, 是为了让「采纳提议 → 落指令」那一步
|
|
|
|
|
|
# (web/main.py 按动作反推买卖方向) 与执行器的清仓口径 (允许零股一次性卖出) 直接复用现成的路。
|
|
|
|
|
|
A_EXIT = "EXIT"
|
2026-07-28 09:29:53 +08:00
|
|
|
|
BUY, SELL = "buy", "sell"
|
|
|
|
|
|
|
2026-09-03 16:06:27 +08:00
|
|
|
|
# 同一轮里出现多条减持时留哪一条 (2026-09-03): 数字小的优先。用户自己设的目标价到价排在
|
|
|
|
|
|
# 系统按规则算出来的保垫减仓前面 —— 人已经说了到价就清, 这一轮就不该再自作主张先卖一部分。
|
|
|
|
|
|
# 只在同一只票的同一轮里比较, 不影响不同票, 也不影响下一轮。
|
|
|
|
|
|
_SELL_PRIORITY = {A_EXIT: 0, A_TRIM: 1}
|
|
|
|
|
|
|
2026-09-07 14:37:35 +08:00
|
|
|
|
|
|
|
|
|
|
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 16:06:27 +08:00
|
|
|
|
# 候选来源 (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-03 11:51:42 +08:00
|
|
|
|
# 上游选股系统给每张候选卡的判决 (2026-09-02 起随计划接口每行下发; 旧版计划没有这个字段)。
|
|
|
|
|
|
# 候选 门槛全过、无硬风险、确认线在 —— 走现有的自动流程 (规则闸 → 研判闸 → 档位分流)
|
|
|
|
|
|
# 关注 门槛全过、无硬风险、但确认线缺失或陈旧 —— 这是上游说「无法判断」, 强制交人裁决
|
|
|
|
|
|
# 仅展示 只差覆盖或潜在吸筹 —— 不出提议, 只在候选处置里记一句原因
|
|
|
|
|
|
# 三个值是中文原文, 与上游接口逐字对应, 这里不做任何映射。
|
|
|
|
|
|
VERDICT_CANDIDATE, VERDICT_WATCH, VERDICT_DISPLAY = "候选", "关注", "仅展示"
|
|
|
|
|
|
WHY_WATCH_CONFIRM = "选股系统判为关注(无法判断),交人裁决"
|
|
|
|
|
|
WHY_DISPLAY_ONLY = "选股系统判为仅展示"
|
|
|
|
|
|
|
2026-09-07 14:37:35 +08:00
|
|
|
|
# 逻辑状态四态里唯一会触发动作的值 (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)
|
|
|
|
|
|
|
2026-09-03 11:51:42 +08:00
|
|
|
|
|
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, "这只票现在只卖不买") + ",所以不评估加仓"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-04 10:10:16 +08:00
|
|
|
|
def display_only_why(row) -> str:
|
2026-09-04 10:28:35 +08:00
|
|
|
|
"""这一栏回答的是「为什么今天没买这只票」。所以它只写原因, 不写系统内部的判决名。
|
2026-09-04 10:10:16 +08:00
|
|
|
|
|
2026-09-04 10:28:35 +08:00
|
|
|
|
2026-09-04 改过一次。改之前写的是「选股系统判为仅展示, 不出提议: 门槛未过: started」,
|
|
|
|
|
|
三处毛病: 「仅展示」「不出提议」是内部说法, 读的人不知道是什么意思; started 是门槛在
|
|
|
|
|
|
代码里的变量名, 不该出现在页面上; 整句话绕了两层还是没说清到底为什么不买。
|
2026-09-04 10:10:16 +08:00
|
|
|
|
|
2026-09-04 10:28:35 +08:00
|
|
|
|
现在直接用判决依据 —— 那是选股系统判决时当场写下的一句完整中文, 例如
|
|
|
|
|
|
「当日涨幅 -3.2%, 没到 3% 的启动线」或「择时决策系统昨夜给出卖出信号」。
|
|
|
|
|
|
旧版计划没有这个字段, 那时退回一句大白话, 不留空。
|
2026-09-04 10:10:16 +08:00
|
|
|
|
"""
|
|
|
|
|
|
basis = (str(row.get("basis") or "").strip() if isinstance(row, dict) else "")
|
2026-09-04 10:28:35 +08:00
|
|
|
|
return basis or "选股系统今天不建议买入"
|
2026-09-04 10:10:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 11:51:42 +08:00
|
|
|
|
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}
|
2026-07-28 09:29:53 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _f(v, d=0.0):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return float(v)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 16:06:27 +08:00
|
|
|
|
def _cand(p, action, side, qty, reason, hard, *, confirm=False, source=SRC_ENGINE):
|
2026-07-28 09:29:53 +08:00
|
|
|
|
return {"ts_code": p["ts_code"], "action": action, "side": side, "qty": int(qty),
|
|
|
|
|
|
"reason": reason, "hard_numbers": hard, "needs_user_confirm": bool(confirm),
|
2026-09-03 16:06:27 +08:00
|
|
|
|
"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
|
2026-07-28 09:29:53 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 股起
|
2026-07-28 09:29:53 +08:00
|
|
|
|
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 股起
|
2026-07-28 09:29:53 +08:00
|
|
|
|
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 股起
|
2026-07-28 09:29:53 +08:00
|
|
|
|
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 股起 (不足就不自动减)
|
2026-07-28 09:29:53 +08:00
|
|
|
|
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")})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 16:06:27 +08:00
|
|
|
|
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 # 还没到价
|
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)
|
2026-09-03 16:06:27 +08:00
|
|
|
|
if qty <= 0:
|
|
|
|
|
|
return None
|
2026-09-07 11:22:05 +08:00
|
|
|
|
note = "" if qty == total else f" (总持仓 {total} 股, 今天只能卖 T+1 可卖的 {qty} 股)"
|
2026-09-03 16:06:27 +08:00
|
|
|
|
c = _cand(p, A_EXIT, SELL, qty,
|
|
|
|
|
|
f"目标价到价: 现价 {price} 已到你设的目标价 {target}, "
|
2026-09-07 11:22:05 +08:00
|
|
|
|
f"拟清仓 {qty} 股 (含零股){note}; 卖不卖由你拍板, 系统不自动卖",
|
|
|
|
|
|
{"target_price": target, "price": price, "total_qty": total,
|
2026-09-03 16:06:27 +08:00
|
|
|
|
"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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 14:37:35 +08:00
|
|
|
|
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)})
|
|
|
|
|
|
|
|
|
|
|
|
|
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 14:37:35 +08:00
|
|
|
|
# 研究走弱的减持排在保垫减仓前面 (2026-09-07 第三件): 两者动作名都是 TRIM, 同轮同现时按
|
|
|
|
|
|
# _sell_priority 留研究走弱那条 (它必交人), 保垫减仓让路 —— 证据在走弱的票, 不该由规则自动先卖一刀。
|
|
|
|
|
|
EVALUATORS = ((A_TRIM, eval_weak_research), (A_TRIM, eval_trim), (A_EXIT, eval_target),
|
2026-09-03 16:06:27 +08:00
|
|
|
|
(A_ADD, eval_add), (A_FILL, eval_fill), (A_DCA, eval_dca))
|
|
|
|
|
|
|
|
|
|
|
|
# 减持方向的动作 —— 冻结只禁增持, 这几类照评 (与规则闸 _check_sell 同一口径)。
|
|
|
|
|
|
SELL_SIDE_ACTIONS = (A_TRIM, A_EXIT)
|
2026-07-28 09:29:53 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 15:57:52 +08:00
|
|
|
|
# ================================================================ 跳过原因
|
|
|
|
|
|
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-07-28 09:29:53 +08:00
|
|
|
|
# ================================================================ 扫描入口
|
2026-08-11 10:35:03 +08:00
|
|
|
|
def scan(*, positions: list, params: dict, market: dict, skip=None,
|
2026-09-03 16:06:27 +08:00
|
|
|
|
strategy_codes=None, stock_params=None) -> dict:
|
2026-07-28 09:29:53 +08:00
|
|
|
|
"""扫描全部持仓, 产出候选动作。
|
|
|
|
|
|
|
2026-09-03 16:06:27 +08:00
|
|
|
|
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() 给出。**事实源是命令表**,
|
|
|
|
|
|
不是持仓行上的投影 —— 投影每天盘前会被参考位取数覆盖, 拿它做判断会
|
|
|
|
|
|
在第二天早上悄悄失效。不传时目标价这条动作不评估, 其余四类行为不变。
|
2026-07-28 09:29:53 +08:00
|
|
|
|
返回 {"candidates": [...], "skipped": [...]}
|
|
|
|
|
|
"""
|
2026-08-06 15:57:52 +08:00
|
|
|
|
skip = skip or {}
|
2026-08-11 10:35:03 +08:00
|
|
|
|
strategy_codes = strategy_codes or set()
|
2026-09-03 16:06:27 +08:00
|
|
|
|
stock_params = stock_params or {}
|
2026-07-28 09:29:53 +08:00
|
|
|
|
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
|
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": "*",
|
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
|
2026-07-28 09:29:53 +08:00
|
|
|
|
mkt = (market or {}).get(code) or {}
|
2026-09-03 16:06:27 +08:00
|
|
|
|
# 个股参数命令的当前值并进这一票的快照, 供目标价那条动作读。只加不覆盖既有字段,
|
|
|
|
|
|
# 且是**这一轮的副本**, 不动调用方传进来的持仓行。
|
|
|
|
|
|
sp = stock_params.get(code) or {}
|
|
|
|
|
|
if sp:
|
|
|
|
|
|
p = {**p, "target_price": sp.get("target_price"), "stop_price": sp.get("stop_price")}
|
2026-07-28 09:29:53 +08:00
|
|
|
|
frozen = (p.get("frozen_reason") or "NONE") != "NONE"
|
2026-09-07 14:37:35 +08:00
|
|
|
|
# 逻辑存疑停增持侧 (2026-09-07 第三件): 持仓行上挂着早上查回的逻辑状态 (logic_state_service.attach),
|
|
|
|
|
|
# 存疑的票补足 / 加仓 / 补仓一律不评, 减持侧照评。没挂这个键 = 没有读数, 不拦。
|
|
|
|
|
|
doubt = bool(params.get("logic_state_route")) and is_logic_doubt(p.get("logic_state"))
|
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 = []
|
2026-07-28 09:29:53 +08:00
|
|
|
|
for action, fn in EVALUATORS:
|
2026-08-06 15:57:52 +08:00
|
|
|
|
why = skip_why(skip, (code, action))
|
|
|
|
|
|
if why:
|
|
|
|
|
|
skipped.append({"ts_code": code, "action": action, "why": why})
|
2026-07-28 09:29:53 +08:00
|
|
|
|
continue
|
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
|
2026-07-28 09:29:53 +08:00
|
|
|
|
# 冻结只禁增持, 减仓照评 (与规则闸同一口径, 这里先剪枝少算一遍)
|
2026-09-03 16:06:27 +08:00
|
|
|
|
if frozen and action not in SELL_SIDE_ACTIONS:
|
2026-07-28 09:29:53 +08:00
|
|
|
|
skipped.append({"ts_code": code, "action": action,
|
2026-09-04 13:26:57 +08:00
|
|
|
|
"why": frozen_why(p.get("frozen_reason"))})
|
2026-07-28 09:29:53 +08:00
|
|
|
|
continue
|
2026-09-07 14:37:35 +08:00
|
|
|
|
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
|
2026-07-28 09:29:53 +08:00
|
|
|
|
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 日窗口, 两套时间基准可以同时成立 —— 一轮里对同一只票
|
2026-09-03 16:06:27 +08:00
|
|
|
|
# 一边 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"],
|
2026-09-03 16:06:27 +08:00
|
|
|
|
"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 16:06:27 +08:00
|
|
|
|
# 同一轮里只留一条减持 (2026-09-03 修)。两条减持同时留下会让人的操作落空:
|
|
|
|
|
|
# 保垫减仓不需要确认、当场就卖掉一部分, 而到价清仓要等人拍板, 它记的股数是扫描
|
|
|
|
|
|
# 那一刻的持仓; 等人第二天点采纳, 持仓已经被前一条卖少了, 落指令时数量超过实际
|
|
|
|
|
|
# 持仓, 卖出前的检查会整条驳回 —— 人点了「清掉」, 结果一股没卖, 只在评审账本上
|
|
|
|
|
|
# 留下一条拒绝。留哪一条按「人的意思优先」: 用户自己设的目标价到价排在系统按规则
|
|
|
|
|
|
# 算出来的减仓前面。被让路的那条记进跳过原因, 下一轮条件仍成立时自然会再来。
|
|
|
|
|
|
sells_now = [c for c in cands_this if c["side"] == SELL]
|
|
|
|
|
|
if len(sells_now) > 1:
|
2026-09-07 14:37:35 +08:00
|
|
|
|
keep = min(sells_now, key=_sell_priority)
|
2026-09-03 16:06:27 +08:00
|
|
|
|
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)
|
2026-07-28 09:29:53 +08:00
|
|
|
|
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:
|
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"),
|
2026-08-06 14:41:59 +08:00
|
|
|
|
# 决策系统今天盘中判过这只票转多没有。这是**定性材料**不是仓位数字, 所以送研判时
|
|
|
|
|
|
# 也带过去 (见 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-03 11:51:42 +08:00
|
|
|
|
# 上游候选卡的判决与理由 (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 23:50:44 +08:00
|
|
|
|
# 研究理由两键 (2026-09-03): 判决依据是上游对自己这条判决的一句话解释, 因果论断是
|
|
|
|
|
|
# 研报里说这家公司好在哪、出处是哪份研报哪一天。两者只进评审账本与提议卡给人看,
|
|
|
|
|
|
# 不参与任何判定, 也不送研判 —— 产业逻辑归数据基座与选股系统, 不归择时决策系统。
|
|
|
|
|
|
"basis": c.get("basis"), "logic": c.get("logic"),
|
2026-09-07 14:37:35 +08:00
|
|
|
|
# 逻辑状态四态 (2026-09-07 第三件): 入场那一刻「研究证据还在不在」的读数, 进账本与提议卡,
|
|
|
|
|
|
# 持仓视图的「当初为什么买」从这里回溯; 不送研判 (judge.OPEN_JUDGE_KEYS 不收它)。
|
|
|
|
|
|
"logic_state": c.get("logic_state"),
|
2026-09-07 15:47:27 +08:00
|
|
|
|
# 安全边际三情景的整句 (2026-09-07 第四件): 只给人看, 不送研判。
|
|
|
|
|
|
"valuation_text": c.get("valuation_text"),
|
2026-09-08 11:44:33 +08:00
|
|
|
|
# 催化事件与定价状态 (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-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 '—'}"
|
2026-08-06 14:41:59 +08:00
|
|
|
|
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,
|
2026-08-06 15:57:52 +08:00
|
|
|
|
slots: int, skip=None) -> dict:
|
2026-08-06 13:55:27 +08:00
|
|
|
|
"""从上游候选池挑新票建仓。名额与金额**边走边扣**, 所以产出的这一组候选彼此不冲突。
|
|
|
|
|
|
|
|
|
|
|
|
为什么滚动必须在这里做, 而不是交给规则闸: `_route_one` 给每条候选调 `caps_ctx` 时,
|
|
|
|
|
|
拿的是**本轮开始时**的那一份组合快照, 不会因为前面几条已经落了指令而更新。于是一跳产出
|
|
|
|
|
|
五条新建仓, 每条单独看都不超上限、五条加起来超了, 规则闸一条也拦不住 —— 它每次看到的
|
|
|
|
|
|
都是同一个旧快照。命令驱动那条路没这个问题, 因为 planner 在循环里用 `_ctx_after` 滚动。
|
|
|
|
|
|
自主这条路照做。
|
|
|
|
|
|
|
|
|
|
|
|
**刻意没有每日开仓上限。** 上界由三层自己收敛: 这里的名额与金额边走边扣;
|
|
|
|
|
|
后面规则闸的资金与上限终检; 再后面择时的买入区间 (现价不在区间内一律等待、不追)。
|
|
|
|
|
|
|
|
|
|
|
|
slots: 还能开几只新仓 = 最大持仓数 − 当前持仓数 (由调用方算好)
|
|
|
|
|
|
room_amt: 还有多少钱可投, 已取「总仓上限×总规模 − 组合市值」与「真实可用资金」的小者
|
2026-08-06 15:57:52 +08:00
|
|
|
|
skip: 不再评估的 (代码, 动作) —— 字典时值是跳过原因 (在途提议 / 在途指令 /
|
|
|
|
|
|
今日被规则闸拒过 / 今日被研判闸驳回过, 这四种处境完全不同, 必须分得开)
|
2026-09-03 11:51:42 +08:00
|
|
|
|
|
|
|
|
|
|
按上游判决分流 (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
|
|
|
|
"""
|
2026-08-06 15:57:52 +08:00
|
|
|
|
skip = skip or {}
|
2026-08-06 13:55:27 +08:00
|
|
|
|
out, skipped = [], []
|
|
|
|
|
|
slots = int(slots or 0)
|
|
|
|
|
|
left = _f(room_amt)
|
2026-09-03 11:51:42 +08:00
|
|
|
|
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))
|
2026-08-06 14:41:59 +08:00
|
|
|
|
# 排序: 今天被决策系统判过「盘中转多」的票排最前, 其余按分数降序、同分按榜内名次
|
|
|
|
|
|
# (与 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 [],
|
2026-08-06 14:41:59 +08:00
|
|
|
|
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
|
2026-09-03 11:51:42 +08:00
|
|
|
|
if route and c.get("verdict") == VERDICT_DISPLAY:
|
|
|
|
|
|
# 仅展示的行不占名额、不占金额, 也不进后面任何一道闸; 放在名额判断之前,
|
|
|
|
|
|
# 是为了让它的跳过原因永远是「仅展示」而不是碰巧的「名额用完」。
|
|
|
|
|
|
skipped.append({"ts_code": code, "action": A_OPEN,
|
2026-09-04 10:10:16 +08:00
|
|
|
|
"why": display_only_why(c)})
|
2026-09-03 11:51:42 +08:00
|
|
|
|
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
|
2026-08-06 15:57:52 +08:00
|
|
|
|
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
|
|
|
|
|
|
try:
|
|
|
|
|
|
cand, why = eval_open(c, params, ctx, left)
|
|
|
|
|
|
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
|
2026-09-03 11:51:42 +08:00
|
|
|
|
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 14:37:35 +08:00
|
|
|
|
# 判决候选而逻辑存疑 (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}
|