220 lines
10 KiB
Python
220 lines
10 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 锁盈 | 纯规则自动执行 (减持方向不设确认门槛) |
|
|||
|
|
|
|||
|
|
本模块只回答「该不该动、动多少、为什么」, 不查库不下发:
|
|||
|
|
* 交易日相关的输入 (建仓天数、距上次加仓天数) 由 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}
|