438 lines
24 KiB
Python
438 lines
24 KiB
Python
# -*- 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 锁盈 | 纯规则自动执行 (减持方向不设确认门槛) |
|
||
| OPEN 新建仓 | 上游候选池里的新票, 且还有持仓名额与可投金额 | 名额与金额边走边扣; 只提底仓批 |
|
||
|
||
本模块只回答「该不该动、动多少、为什么」, 不查库不下发:
|
||
* 交易日相关的输入 (建仓天数、距上次加仓天数) 由 services 层用交易日历算好传进来,
|
||
避免把日历依赖塞进纯逻辑。
|
||
* 上限/一手/冻结等硬约束**不在这里重复判**, 统一由规则闸终检 (职责单一, 口径唯一)。
|
||
这里只做「引擎自身的触发条件」与「批次额度」计算。
|
||
**唯一的例外是新建仓**: 它一轮能产出多条候选, 而规则闸每条拿到的都是同一份本轮开始时
|
||
的组合快照, 于是"每条单独看都不超上限、加起来超了"这种情况它拦不住。所以那条路的上限
|
||
校验必须在这里就滚动算一遍 —— 用的仍然是规则闸那个 check_all_caps, 口径没有第二份。
|
||
|
||
输出候选统一结构, 供 proposal_service 走 规则闸 → 研判闸 → 按自主档位分流。
|
||
|
||
扫描入口有两个, 输入不同, 互不影响:
|
||
scan() 输入是**已有持仓**, 产出 FILL/ADD/DCA/TRIM (2026-08-06 之前就有的四类)
|
||
scan_open() 输入是**上游候选池**, 产出 OPEN (2026-08-06 新增)
|
||
候选池取不到时 scan() 照常跑, 反之亦然 —— 一条外部接口的故障不该让整轮扫描停摆。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from app.core.cushion import dca_stage, trim_trigger
|
||
from app.core.sizer import LOT, lot_qty, split_batches
|
||
# 新建仓要在这里滚动校验上限, 用的必须是规则闸那一份 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"
|
||
A_OPEN = "OPEN" # 新建仓 (与 planner.A_OPEN、executor.BUY_ACTIONS 同名同义)
|
||
BUY, SELL = "buy", "sell"
|
||
|
||
# **有资格**送研判闸的动作 (设计 §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):
|
||
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}
|
||
|
||
|
||
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)
|
||
if qty < LOT:
|
||
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)
|
||
if qty < LOT:
|
||
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
|
||
if qty < LOT:
|
||
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
|
||
if qty < LOT:
|
||
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")})
|
||
|
||
|
||
EVALUATORS = ((A_TRIM, eval_trim), (A_ADD, eval_add), (A_FILL, eval_fill), (A_DCA, eval_dca))
|
||
|
||
|
||
# ================================================================ 跳过原因
|
||
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
|
||
|
||
|
||
# ================================================================ 扫描入口
|
||
def scan(*, positions: list, params: dict, market: dict, skip=None) -> dict:
|
||
"""扫描全部持仓, 产出候选动作。
|
||
|
||
positions: portfolio.positions_view()["held"] 的口径
|
||
market: {ts_code: {ma5, high5, tdays_since_open, tdays_since_last_add}}
|
||
skip: 不再评估的 (代码, 动作) —— 字典时值是跳过原因, 集合时退回兜底话
|
||
返回 {"candidates": [...], "skipped": [...]}
|
||
"""
|
||
skip = skip 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
|
||
# 取不到现价的票整只跳过, **并且留痕**。上游 (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 {}
|
||
frozen = (p.get("frozen_reason") or "NONE") != "NONE"
|
||
for action, fn in EVALUATORS:
|
||
why = skip_why(skip, (code, action))
|
||
if why:
|
||
skipped.append({"ts_code": code, "action": action, "why": why})
|
||
continue
|
||
# 冻结只禁增持, 减仓照评 (与规则闸同一口径, 这里先剪枝少算一遍)
|
||
if frozen and action != A_TRIM:
|
||
skipped.append({"ts_code": code, "action": action,
|
||
"why": f"该股 {p['frozen_reason']}, 禁增持"})
|
||
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:
|
||
out.append(c)
|
||
return {"candidates": out, "skipped": skipped}
|
||
|
||
|
||
# ================================================================ 新建仓 (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"),
|
||
merge=bool(params.get("min_lot_merge", True)))
|
||
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:
|
||
return None, "; ".join(bad)
|
||
|
||
base = sp["batches"][0]
|
||
qty = int(base["qty"])
|
||
if qty < LOT:
|
||
return None, f"底仓批 {qty} 股不足一手"
|
||
|
||
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"),
|
||
# ---- 仓位口径: 只进评审账本做判分锚。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} 元)")
|
||
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:
|
||
"""从上游候选池挑新票建仓。名额与金额**边走边扣**, 所以产出的这一组候选彼此不冲突。
|
||
|
||
为什么滚动必须在这里做, 而不是交给规则闸: `_route_one` 给每条候选调 `caps_ctx` 时,
|
||
拿的是**本轮开始时**的那一份组合快照, 不会因为前面几条已经落了指令而更新。于是一跳产出
|
||
五条新建仓, 每条单独看都不超上限、五条加起来超了, 规则闸一条也拦不住 —— 它每次看到的
|
||
都是同一个旧快照。命令驱动那条路没这个问题, 因为 planner 在循环里用 `_ctx_after` 滚动。
|
||
自主这条路照做。
|
||
|
||
**刻意没有每日开仓上限。** 上界由三层自己收敛: 这里的名额与金额边走边扣;
|
||
后面规则闸的资金与上限终检; 再后面择时的买入区间 (现价不在区间内一律等待、不追)。
|
||
|
||
slots: 还能开几只新仓 = 最大持仓数 − 当前持仓数 (由调用方算好)
|
||
room_amt: 还有多少钱可投, 已取「总仓上限×总规模 − 组合市值」与「真实可用资金」的小者
|
||
skip: 不再评估的 (代码, 动作) —— 字典时值是跳过原因 (在途提议 / 在途指令 /
|
||
今日被规则闸拒过 / 今日被研判闸驳回过, 这四种处境完全不同, 必须分得开)
|
||
"""
|
||
skip = skip or {}
|
||
out, skipped = [], []
|
||
slots = int(slots or 0)
|
||
left = _f(room_amt)
|
||
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))
|
||
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),
|
||
str(x.get("ts_code") or ""))):
|
||
code = c.get("ts_code")
|
||
if not code:
|
||
continue
|
||
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})
|
||
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
|
||
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}
|