tradingSystem/app/core/action_engine.py

220 lines
10 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 锁盈 | 纯规则自动执行 (减持方向不设确认门槛) |
本模块只回答「该不该动、动多少、为什么」, 不查库不下发:
* 交易日相关的输入 (建仓天数、距上次加仓天数) 由 services 层用交易日历算好传进来,
避免把日历依赖塞进纯逻辑。
* 上限/一手/冻结等硬约束**不在这里重复判**, 统一由规则闸终检 (职责单一, 口径唯一)。
这里只做「引擎自身的触发条件」与「批次额度」计算。
输出候选统一结构, 供 proposal_service 走 规则闸 → 研判闸 → 按自主档位分流。
"""
from __future__ import annotations
from app.core.cushion import dca_stage, trim_trigger
from app.core.sizer import LOT, lot_qty
A_FILL, A_ADD, A_DCA, A_TRIM = "FILL", "ADD", "DCA", "TRIM"
BUY, SELL = "buy", "sell"
# 需要送研判闸的动作 (设计 §7: 仅自主提议的补足/加仓/补仓/调仓)
JUDGE_ACTIONS = {A_FILL, A_ADD, A_DCA}
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
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}