tradingSystem/app/core/action_engine.py

398 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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))
# ================================================================ 扫描入口
def scan(*, positions: list, params: dict, market: dict, skip: set = None) -> dict:
"""扫描全部持仓, 产出候选动作。
positions: portfolio.positions_view()["held"] 的口径
market: {ts_code: {ma5, high5, tdays_since_open, tdays_since_last_add}}
skip: 已有在途提议/指令的 (ts_code, action) 集合 —— 不重复提
返回 {"candidates": [...], "skipped": [...]}
"""
skip = skip or set()
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:
if (code, action) in skip:
skipped.append({"ts_code": code, "action": action, "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.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 ''}), "
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: set = None) -> dict:
"""从上游候选池挑新票建仓。名额与金额**边走边扣**, 所以产出的这一组候选彼此不冲突。
为什么滚动必须在这里做, 而不是交给规则闸: `_route_one` 给每条候选调 `caps_ctx` 时,
拿的是**本轮开始时**的那一份组合快照, 不会因为前面几条已经落了指令而更新。于是一跳产出
五条新建仓, 每条单独看都不超上限、五条加起来超了, 规则闸一条也拦不住 —— 它每次看到的
都是同一个旧快照。命令驱动那条路没这个问题, 因为 planner 在循环里用 `_ctx_after` 滚动。
自主这条路照做。
**刻意没有每日开仓上限。** 上界由三层自己收敛: 这里的名额与金额边走边扣;
后面规则闸的资金与上限终检; 再后面择时的买入区间 (现价不在区间内一律等待、不追)。
slots: 还能开几只新仓 = 最大持仓数 当前持仓数 (由调用方算好)
room_amt: 还有多少钱可投, 已取「总仓上限×总规模 组合市值」与「真实可用资金」的小者
skip: 不再评估的 (代码, 动作) 集合 —— 在途提议/指令、当日已被规则闸或研判闸拒过的
"""
skip = skip or set()
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 的次序一致。
# 上游给过来本来就是排好的, 这里再排一次只是防调用方乱序, 不改变正常路径的结果。
for c in sorted(candidates or [],
key=lambda x: (-_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
if (code, A_OPEN) in skip:
skipped.append({"ts_code": code, "action": A_OPEN,
"why": "已有在途提议或指令, 或今日已被闸门拒过"})
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}