650 lines
31 KiB
Python
650 lines
31 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""
|
||
|
|
策略自动挂载 · 个股打法状态机 (STRATEGY_AUTO_ATTACH_PLAN.md V2)
|
||
|
|
================================================================
|
||
|
|
每交易日 09:40 跑一次 (scheduler.strategy_attach)。对每只持仓票判「阶段」、走「边」:
|
||
|
|
|
||
|
|
阶段: 吸筹震荡 / 启动拉升 / 高位派发 / 深亏修复 / 中性
|
||
|
|
边一 中性→吸筹震荡 明确吸筹且结论新鲜 → 挂网格
|
||
|
|
边二 中性→启动拉升 热度超阈值且安全垫为正 → 挂跟踪止盈 (双命中也取止盈)
|
||
|
|
边三 吸筹震荡→高位派发 定性转派发或标志失效 → 暂停网格买入腿 (来源 accum, 回明确自动解除)
|
||
|
|
边四 吸筹震荡→启动拉升 自动网格票站上区间上界且热度超阈值 → 撤网格换挂止盈 (接力)
|
||
|
|
|
||
|
|
**挂载和切换本身不下单。** 真正的买卖仍由 strategy_runner 每分钟评估、每一笔过规则闸与
|
||
|
|
全部熔断。本模块只做「配置动作」: 读信号、判阶段、调 strategy_service.attach / set_status,
|
||
|
|
全程留痕 (pms_action_ledger: ATTACH / HANDOFF / NOTE)。
|
||
|
|
|
||
|
|
两个信号源 (153 代理, 严格单表; 口径同源声明见方案第二节):
|
||
|
|
吸筹定性 strategy_daily_results.raw_logic_json 的 fund_flow.state —— 决策系统每晚产出。
|
||
|
|
判定用**子串包含**而不是前缀 (与数据底座 feed.py 的 _ACCUM_KEEP 同手法):
|
||
|
|
实测 state 会带前后缀修饰, 前缀匹配漏了 2026-08-25 探测里 62 只「词表外」。
|
||
|
|
含「派发」优先于含「明确吸筹」—— 两个词同现时按保守方向算派发。
|
||
|
|
词表外的 state 一律当无标志 (宁可不挂), 并在返回里报出来供核对契约。
|
||
|
|
热度分 stock_fund_heat_scores 最新交易日最大批次, 0~1。全市场约 5200 只,
|
||
|
|
阈值 0.80 约取前 4% (2026-08-25 探测: ≥0.8 共 194 只)。
|
||
|
|
|
||
|
|
自动策略的「身份」全靠 note 约定承载, **不加任何新表新参数状态** (可从策略表完整推导,
|
||
|
|
审计与测试都只看得见的东西):
|
||
|
|
"自动挂载: ..." 本模块常规挂出的 (计入每日新挂上限)
|
||
|
|
"自动挂载(接力): ..." 边四换挂出来的止盈 (不占每日上限 —— 它是换不是增)
|
||
|
|
note 里含 "[接力撤下]" 被边四撤掉的网格 (据此算接力冷却, 不算人工撤下)
|
||
|
|
由此派生的两种冷却 (方案第七节小口径):
|
||
|
|
人工撤下: 自动策略被撤、票还持有、note 无接力标记 → 同票同规则 N 个交易日不再自动挂
|
||
|
|
接力之后: 同票网格边 N 个交易日不再挂 (防区间上沿来回震把两种策略翻来覆去换)
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import logging
|
||
|
|
from datetime import datetime, timedelta
|
||
|
|
|
||
|
|
from app.core import tradedays as td
|
||
|
|
from app.db.session import fetch_all, fetch_one
|
||
|
|
from app.repo import pms_repo
|
||
|
|
from app.repo.downstream_repo import to_dot, to_prefix
|
||
|
|
from app.services import param_store
|
||
|
|
|
||
|
|
logger = logging.getLogger("pms.strategy_advisor")
|
||
|
|
|
||
|
|
# 决策系统定性词表 (契约)。判定看子串, 次序即优先级 —— 派发最先 (保守方向)。
|
||
|
|
CLS_DISTRIB = "高位派发"
|
||
|
|
CLS_CLEAR, CLS_MAYBE = "明确吸筹", "潜在吸筹"
|
||
|
|
CLS_NONE_SIGN, CLS_UNCLEAR = "无吸筹迹象", "信号不明"
|
||
|
|
CLS_UNKNOWN, CLS_NOFIELD = "词表外", "无字段"
|
||
|
|
_CLS_ORDER = (("派发", CLS_DISTRIB), ("明确吸筹", CLS_CLEAR), ("潜在吸筹", CLS_MAYBE),
|
||
|
|
("无吸筹迹象", CLS_NONE_SIGN), ("不明", CLS_UNCLEAR))
|
||
|
|
|
||
|
|
# 边 (规则注册表键名, PMS_AUTO_STRATEGY_RULES 里逗号列出即启用)
|
||
|
|
R_GRID, R_TRAIL, R_EXIT, R_HANDOFF = "accum_grid", "heat_trail", "accum_exit", "handoff"
|
||
|
|
|
||
|
|
# note 约定 (见模块头; 改这里必须同步改 test_batch17 钉住的字面量)
|
||
|
|
NOTE_AUTO = "自动挂载: "
|
||
|
|
NOTE_HANDOFF = "自动挂载(接力): "
|
||
|
|
MARK_HANDOFF_OUT = "[接力撤下]"
|
||
|
|
|
||
|
|
ACCUM_WINDOW_DAYS = 45 # 每票取近 45 自然日内最新一条结论
|
||
|
|
HEAT_MAX_AGE_DAYS = 4 # 热度表末日落后超此自然日 → 热度信号本轮不可用
|
||
|
|
_RULE_OF_TYPE = {"GRID": R_GRID, "TRAIL": R_TRAIL}
|
||
|
|
|
||
|
|
|
||
|
|
def _f(v, d=None):
|
||
|
|
try:
|
||
|
|
return float(v)
|
||
|
|
except (TypeError, ValueError):
|
||
|
|
return d
|
||
|
|
|
||
|
|
|
||
|
|
# ================================================================ 纯逻辑
|
||
|
|
def classify_accum(state) -> str:
|
||
|
|
"""定性字符串 → 档位。子串包含 + 固定优先级; 判不出的当词表外 (调用方按无标志处理)。"""
|
||
|
|
s = str(state or "").strip()
|
||
|
|
if not s:
|
||
|
|
return CLS_NOFIELD
|
||
|
|
for key, cls in _CLS_ORDER:
|
||
|
|
if key in s:
|
||
|
|
return cls
|
||
|
|
return CLS_UNKNOWN
|
||
|
|
|
||
|
|
|
||
|
|
def grid_params(*, price, support, pressure, band, step_pct, cap_room, cap_ratio):
|
||
|
|
"""网格参数自动生成 (方案附录二)。返回 (params, why); params=None 时 why 说明放弃原因。
|
||
|
|
|
||
|
|
区间优先锚支撑压力, 锚不住退百分比带; 任何一步不满足 0<下界<中枢<上界 就放弃不硬凑。
|
||
|
|
"""
|
||
|
|
p = _f(price, 0.0)
|
||
|
|
if not p or p <= 0:
|
||
|
|
return None, "取不到实时价, 网格区间无从定"
|
||
|
|
cap = _f(cap_room, 0.0) or 0.0
|
||
|
|
max_capital = round(cap * _f(cap_ratio, 0.5), 2)
|
||
|
|
if max_capital < p * 100:
|
||
|
|
return None, (f"单股上限余量 {cap:,.0f} 元按投入比例折出 {max_capital:,.0f} 元, "
|
||
|
|
f"买不起一手, 不挂")
|
||
|
|
r, s = _f(pressure, 0.0) or 0.0, _f(support, 0.0) or 0.0
|
||
|
|
upper = round(r * 1.01, 3) if r > p else round(p * (1 + band), 3)
|
||
|
|
lower = round(s * 0.99, 3) if 0 < s < p else round(p * (1 - band), 3)
|
||
|
|
if not (0 < lower < p < upper):
|
||
|
|
return None, (f"区间不成立 (下界 {lower} / 现价 {p} / 上界 {upper}), "
|
||
|
|
f"支撑压力形态不适合网格, 本轮放弃")
|
||
|
|
step = max(0.005, _f(step_pct, 0.02))
|
||
|
|
n_below = max(1, int((p - lower) / (p * step)))
|
||
|
|
per_lot = int(max_capital / n_below / p / 100) * 100
|
||
|
|
if per_lot < 100:
|
||
|
|
per_lot = 100
|
||
|
|
return ({"center": p, "lower": lower, "upper": upper, "step_pct": step,
|
||
|
|
"per_lot": per_lot, "max_capital": max_capital}, "")
|
||
|
|
|
||
|
|
|
||
|
|
def plan_edge(*, cls, fresh, heat, cushion, prm):
|
||
|
|
"""无策略持仓票该走哪条边。返回 (边|None, 原因)。
|
||
|
|
|
||
|
|
双命中取止盈 (保住利润优先于做波段); 负垫不挂止盈 (挂了永远不武装, 还把该票从
|
||
|
|
动作引擎排除, 白挡掉深亏补仓的评估) —— 方案第七节小口径。
|
||
|
|
"""
|
||
|
|
rules = prm["rules"]
|
||
|
|
hot = heat is not None and heat >= prm["heat_th"]
|
||
|
|
pos = cushion is not None and _f(cushion, 0.0) > 0
|
||
|
|
clear = (cls == CLS_CLEAR and fresh)
|
||
|
|
if R_TRAIL in rules and hot and pos:
|
||
|
|
return R_TRAIL, ("明确吸筹与高热度双命中, 按口径取跟踪止盈" if clear
|
||
|
|
else f"热度 {heat:.3f} 超阈值 {prm['heat_th']:.2f} 且有浮盈")
|
||
|
|
if R_GRID in rules and clear:
|
||
|
|
return R_GRID, "明确吸筹且结论新鲜, 适合网格吃震荡"
|
||
|
|
if hot and not pos:
|
||
|
|
return None, "热度够但安全垫不正, 不挂止盈 (把位置留给补仓评估)"
|
||
|
|
if cls == CLS_CLEAR and not fresh:
|
||
|
|
return None, "吸筹结论超日龄, 视为无标志"
|
||
|
|
return None, ""
|
||
|
|
|
||
|
|
|
||
|
|
def count_auto_today(rows, today_ymd: int) -> int:
|
||
|
|
"""今天已常规自动挂载几条 (接力挂出的不算 —— 它是换不是增)。rows=策略行 (含已归档)。"""
|
||
|
|
n = 0
|
||
|
|
for r in rows or []:
|
||
|
|
note = str(r.get("note") or "")
|
||
|
|
if not note.startswith(NOTE_AUTO):
|
||
|
|
continue
|
||
|
|
if _ymd_of(r.get("created_at")) == today_ymd:
|
||
|
|
n += 1
|
||
|
|
return n
|
||
|
|
|
||
|
|
|
||
|
|
def cooldowns_from_cancelled(rows, held_codes, today_ymd: int, *,
|
||
|
|
optout_tdays: int, handoff_tdays: int):
|
||
|
|
"""从已撤销的自动策略行推导两种冷却 (无状态, 全部可从表推导)。
|
||
|
|
|
||
|
|
返回 (optout: {(code, rule)}, handoff_cool: {code})。
|
||
|
|
人工撤下 = 自动策略被撤、票还持有、note 无接力标记 —— 撤它的可能是你、也可能是
|
||
|
|
清仓命令或清场; 后两种情形持仓多半已归零, 「票还持有」这一条把它们天然排除,
|
||
|
|
剩下的按"有人特意撤过"处理, 冷却期内不再自动挂, 杜绝人机拉锯。
|
||
|
|
交易日换算按自然日乘二宽松 (与全库惯例一致), 冷却只会偏长不会偏短。
|
||
|
|
"""
|
||
|
|
optout, hand = set(), set()
|
||
|
|
held = set(held_codes or ())
|
||
|
|
for r in rows or []:
|
||
|
|
note = str(r.get("note") or "")
|
||
|
|
if not note.startswith("自动挂载"):
|
||
|
|
continue
|
||
|
|
code = r.get("ts_code")
|
||
|
|
age = _age_days_of(r.get("updated_at"), today_ymd)
|
||
|
|
if age is None:
|
||
|
|
continue
|
||
|
|
if MARK_HANDOFF_OUT in note:
|
||
|
|
if age <= handoff_tdays * 2:
|
||
|
|
hand.add(code)
|
||
|
|
continue
|
||
|
|
rule = _RULE_OF_TYPE.get(str(r.get("type") or "").upper())
|
||
|
|
if rule and code in held and age <= optout_tdays * 2:
|
||
|
|
optout.add((code, rule))
|
||
|
|
return optout, hand
|
||
|
|
|
||
|
|
|
||
|
|
def handoff_ready(*, price, price_ok, upper, heat, cushion, prm):
|
||
|
|
"""边四触发判定 (不含「无在途委托」那条 —— 那要查库, 由编排层补)。返回 (bool, why)。"""
|
||
|
|
if not prm.get("handoff_enabled") or R_HANDOFF not in prm["rules"]:
|
||
|
|
return False, ""
|
||
|
|
if not price_ok or _f(price, 0.0) <= 0:
|
||
|
|
return False, ""
|
||
|
|
if _f(upper, 0.0) <= 0 or _f(price) < _f(upper):
|
||
|
|
return False, ""
|
||
|
|
if heat is None or heat < prm["heat_th"]:
|
||
|
|
return False, f"已站上网格上界但热度 {heat if heat is not None else '缺失'} 未达阈值, 继续网格"
|
||
|
|
if cushion is None or _f(cushion, 0.0) <= 0:
|
||
|
|
return False, "已站上网格上界但安全垫不正, 不接力"
|
||
|
|
return True, (f"现价 {price} 站上网格上界 {upper} 且热度 {heat:.3f} 超阈值 —— "
|
||
|
|
f"吸筹震荡期转启动拉升期, 网格换跟踪止盈")
|
||
|
|
|
||
|
|
|
||
|
|
def _ymd_of(ts) -> int:
|
||
|
|
try:
|
||
|
|
if hasattr(ts, "strftime"):
|
||
|
|
return int(ts.strftime("%Y%m%d"))
|
||
|
|
s = str(ts or "").strip().replace("-", "")[:8]
|
||
|
|
return int(s) if s.isdigit() and len(s) == 8 else 0
|
||
|
|
except (TypeError, ValueError):
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
def _age_days_of(ts, today_ymd: int):
|
||
|
|
y = _ymd_of(ts)
|
||
|
|
if not y:
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
a = datetime.strptime(str(y), "%Y%m%d").date()
|
||
|
|
b = datetime.strptime(str(today_ymd), "%Y%m%d").date()
|
||
|
|
return (b - a).days
|
||
|
|
except ValueError:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
# ================================================================ 取数 (153 代理, 单表)
|
||
|
|
def _variants(dot_codes):
|
||
|
|
rev = {}
|
||
|
|
for c in dot_codes or []:
|
||
|
|
d = to_dot(c)
|
||
|
|
if not d:
|
||
|
|
continue
|
||
|
|
rev[d] = d
|
||
|
|
rev[to_prefix(d)] = d
|
||
|
|
rev[d.split(".")[0]] = d
|
||
|
|
return rev
|
||
|
|
|
||
|
|
|
||
|
|
def _in(values, prefix, params):
|
||
|
|
keys = []
|
||
|
|
for i, v in enumerate(values):
|
||
|
|
keys.append(f":{prefix}{i}")
|
||
|
|
params[f"{prefix}{i}"] = v
|
||
|
|
return ", ".join(keys)
|
||
|
|
|
||
|
|
|
||
|
|
def accum_of(dot_codes) -> dict:
|
||
|
|
"""{点式: {cls, state, score, ymd, age_days}} —— 窗口内每票最新一条定性。"""
|
||
|
|
if not dot_codes:
|
||
|
|
return {}
|
||
|
|
rev = _variants(dot_codes)
|
||
|
|
since = int((datetime.now().date() - timedelta(days=ACCUM_WINDOW_DAYS)).strftime("%Y%m%d"))
|
||
|
|
p = {"since": since}
|
||
|
|
rows = fetch_all(
|
||
|
|
"SELECT stock_code, trade_date, raw_logic_json FROM strategy_daily_results "
|
||
|
|
f"WHERE trade_date >= :since AND stock_code IN ({_in(list(rev), 'c', p)})", p)
|
||
|
|
best = {}
|
||
|
|
for r in rows:
|
||
|
|
dot = rev.get(str(r.get("stock_code") or "").strip())
|
||
|
|
ymd = _ymd_of(r.get("trade_date"))
|
||
|
|
if not dot or not ymd or (dot in best and best[dot]["ymd"] >= ymd):
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
raw = r.get("raw_logic_json")
|
||
|
|
d = raw if isinstance(raw, dict) else json.loads(raw or "{}")
|
||
|
|
ff = d.get("fund_flow") or {}
|
||
|
|
except (ValueError, TypeError):
|
||
|
|
ff = {}
|
||
|
|
state = ff.get("state") if isinstance(ff, dict) else None
|
||
|
|
best[dot] = {"ymd": ymd, "age_days": _age_days_of(ymd, td.ymd()),
|
||
|
|
"cls": classify_accum(state), "state": state,
|
||
|
|
"score": _f((ff or {}).get("score"))}
|
||
|
|
return best
|
||
|
|
|
||
|
|
|
||
|
|
def heat_of(dot_codes):
|
||
|
|
"""({点式: score}, meta)。meta.stale=True 表示热度表停更, 调用方按热度不可用处理。"""
|
||
|
|
meta = {"td": None, "batch": None, "stale": True}
|
||
|
|
if not dot_codes:
|
||
|
|
return {}, meta
|
||
|
|
r = fetch_one("SELECT MAX(trade_date) AS td FROM stock_fund_heat_scores")
|
||
|
|
tdd = (r or {}).get("td")
|
||
|
|
if tdd is None:
|
||
|
|
return {}, meta
|
||
|
|
r2 = fetch_one("SELECT MAX(batch_no) AS b FROM stock_fund_heat_scores "
|
||
|
|
"WHERE trade_date = :td", {"td": tdd})
|
||
|
|
rev = _variants(dot_codes)
|
||
|
|
p = {"td": tdd, "b": (r2 or {}).get("b")}
|
||
|
|
rows = fetch_all(
|
||
|
|
"SELECT stock_code, score FROM stock_fund_heat_scores "
|
||
|
|
f"WHERE trade_date = :td AND batch_no = :b AND stock_code IN ({_in(list(rev), 'c', p)})",
|
||
|
|
p)
|
||
|
|
out = {}
|
||
|
|
for x in rows:
|
||
|
|
dot = rev.get(str(x.get("stock_code") or "").strip())
|
||
|
|
v = _f(x.get("score"))
|
||
|
|
if dot and v is not None:
|
||
|
|
out[dot] = v
|
||
|
|
age = _age_days_of(_ymd_of(tdd), td.ymd())
|
||
|
|
meta.update({"td": str(tdd), "batch": (r2 or {}).get("b"),
|
||
|
|
"stale": age is None or age > HEAT_MAX_AGE_DAYS, "age_days": age})
|
||
|
|
return out, meta
|
||
|
|
|
||
|
|
|
||
|
|
# ================================================================ 参数
|
||
|
|
def _params() -> dict:
|
||
|
|
g = param_store
|
||
|
|
return {
|
||
|
|
"enabled": g.get_bool("PMS_AUTO_STRATEGY_ENABLED", False),
|
||
|
|
"rules": set(g.get_list("PMS_AUTO_STRATEGY_RULES",
|
||
|
|
[R_GRID, R_TRAIL, R_EXIT, R_HANDOFF])),
|
||
|
|
"daily_max": g.get_int("PMS_AUTO_STRATEGY_DAILY_MAX", 2),
|
||
|
|
"accum_stale_tdays": g.get_int("PMS_AUTO_ACCUM_STALE_TDAYS", 3),
|
||
|
|
"grid_band": g.get_float("PMS_AUTO_GRID_BAND", 0.08),
|
||
|
|
"grid_step_pct": g.get_float("PMS_AUTO_GRID_STEP_PCT", 0.02),
|
||
|
|
"grid_cap_ratio": g.get_float("PMS_AUTO_GRID_CAP_RATIO", 0.5),
|
||
|
|
"heat_th": g.get_float("PMS_AUTO_HEAT_TH", 0.80),
|
||
|
|
"trail_giveback": g.get_float("PMS_AUTO_TRAIL_GIVEBACK", 0.05),
|
||
|
|
"trail_sell_ratio": g.get_float("PMS_AUTO_TRAIL_SELL_RATIO", 0.5),
|
||
|
|
"handoff_enabled": g.get_bool("PMS_AUTO_HANDOFF_ENABLED", True),
|
||
|
|
"handoff_cooldown_tdays": g.get_int("PMS_AUTO_HANDOFF_COOLDOWN_TDAYS", 10),
|
||
|
|
"optout_cooldown_tdays": g.get_int("PMS_AUTO_OPTOUT_COOLDOWN_TDAYS", 10),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# ================================================================ 编排
|
||
|
|
def scan(*, dry_run: bool = False, now=None) -> dict:
|
||
|
|
"""每交易日一跳 (09:40)。dry_run=True 只判不写 —— 不挂、不留痕、不动暂停表。"""
|
||
|
|
now = now or datetime.now()
|
||
|
|
today = td.ymd(now)
|
||
|
|
prm = _params()
|
||
|
|
out = {"ok": True, "enabled": prm["enabled"], "dry_run": dry_run, "checked": 0,
|
||
|
|
"attached": [], "handoffs": [], "paused": [], "resumed": [],
|
||
|
|
"blocked": [], "skipped": [], "errors": [],
|
||
|
|
"unknown_states": []}
|
||
|
|
if not prm["enabled"]:
|
||
|
|
out["skipped"].append({"why": "策略自动挂载总开关关闭 (PMS_AUTO_STRATEGY_ENABLED)"})
|
||
|
|
return out
|
||
|
|
if not param_store.get_bool("PMS_STRATEGY_ENABLED", False):
|
||
|
|
out["skipped"].append({"why": "策略层总开关关闭 (PMS_STRATEGY_ENABLED) —— "
|
||
|
|
"挂了也不会跑, 本轮不挂"})
|
||
|
|
return out
|
||
|
|
if param_store.get_bool("PMS_GLOBAL_EXEC_HALT", False):
|
||
|
|
out["skipped"].append({"why": "全局暂停执行 (休假模式)"})
|
||
|
|
return out
|
||
|
|
|
||
|
|
from app.services import command_service, portfolio, strategy_service
|
||
|
|
try:
|
||
|
|
view = portfolio.positions_view()
|
||
|
|
except Exception as e: # noqa: BLE001 —— 守成: 读不到持仓不动任何东西
|
||
|
|
return {**out, "ok": False, "errors": [f"读持仓失败: {type(e).__name__}: {e}"]}
|
||
|
|
held = [p for p in view["held"] if int(p.get("total_qty") or 0) > 0]
|
||
|
|
if not held:
|
||
|
|
out["skipped"].append({"why": "当前无持仓"})
|
||
|
|
return out
|
||
|
|
codes = [p["ts_code"] for p in held]
|
||
|
|
|
||
|
|
# ---- 信号与既有状态一次取齐 (任一失败只废对应的边, 不废整轮) ----
|
||
|
|
try:
|
||
|
|
accum = accum_of(codes)
|
||
|
|
except Exception as e: # noqa: BLE001
|
||
|
|
accum = {}
|
||
|
|
out["errors"].append(f"吸筹取数失败 (边一/三/四本轮不动): {type(e).__name__}: {e}")
|
||
|
|
try:
|
||
|
|
heat, heat_meta = heat_of(codes)
|
||
|
|
if heat_meta.get("stale"):
|
||
|
|
heat = {}
|
||
|
|
out["skipped"].append({"why": f"热度表停更 (最新 {heat_meta.get('td')}), "
|
||
|
|
f"热度相关的边本轮不动"})
|
||
|
|
except Exception as e: # noqa: BLE001
|
||
|
|
heat = {}
|
||
|
|
out["errors"].append(f"热度取数失败 (边二/四本轮不动): {type(e).__name__}: {e}")
|
||
|
|
for c in codes:
|
||
|
|
a = accum.get(c)
|
||
|
|
if a and a["cls"] == CLS_UNKNOWN:
|
||
|
|
out["unknown_states"].append({"ts_code": c, "state": a.get("state")})
|
||
|
|
if out["unknown_states"]:
|
||
|
|
logger.warning("[自动挂载] %s 只票的吸筹定性在词表外 (按无标志处理), 样本: %s —— "
|
||
|
|
"五档契约可能变了, 与决策系统核对", len(out["unknown_states"]),
|
||
|
|
out["unknown_states"][:3])
|
||
|
|
|
||
|
|
try:
|
||
|
|
strat_all = pms_repo.list_strategies(statuses=["ACTIVE", "PAUSED"], limit=500,
|
||
|
|
include_archived=True)
|
||
|
|
except Exception as e: # noqa: BLE001
|
||
|
|
return {**out, "ok": False, "errors": out["errors"] +
|
||
|
|
[f"读策略表失败, 整轮守成不动: {type(e).__name__}: {e}"]}
|
||
|
|
strat_by_code = {}
|
||
|
|
for s in strat_all:
|
||
|
|
strat_by_code.setdefault(s.get("ts_code"), s)
|
||
|
|
try:
|
||
|
|
cancelled = pms_repo.list_strategies(statuses=["CANCELLED"], limit=300,
|
||
|
|
include_archived=True)
|
||
|
|
except Exception as e: # noqa: BLE001
|
||
|
|
cancelled = []
|
||
|
|
out["errors"].append(f"读已撤策略失败 (冷却按无算): {e}")
|
||
|
|
optout, handoff_cool = cooldowns_from_cancelled(
|
||
|
|
cancelled, codes, today, optout_tdays=prm["optout_cooldown_tdays"],
|
||
|
|
handoff_tdays=prm["handoff_cooldown_tdays"])
|
||
|
|
attached_today = count_auto_today(
|
||
|
|
(list(strat_all) + list(cancelled)), today)
|
||
|
|
|
||
|
|
try:
|
||
|
|
black = command_service.blacklist()
|
||
|
|
except Exception: # noqa: BLE001
|
||
|
|
black = set()
|
||
|
|
try:
|
||
|
|
from app.services import executor
|
||
|
|
live_codes = {i.get("ts_code") for i in
|
||
|
|
pms_repo.list_instructions(statuses=list(executor.LIVE), limit=300)}
|
||
|
|
live_codes |= {pl.get("ts_code") for pl in
|
||
|
|
pms_repo.list_plans(statuses=["PENDING", "GATED", "EXECUTING"],
|
||
|
|
limit=300)}
|
||
|
|
except Exception as e: # noqa: BLE001
|
||
|
|
live_codes = set()
|
||
|
|
out["errors"].append(f"读在途失败 (在途排除按无算): {e}")
|
||
|
|
buypause = {}
|
||
|
|
try:
|
||
|
|
buypause = strategy_service.buypause_map()
|
||
|
|
except Exception: # noqa: BLE001
|
||
|
|
pass
|
||
|
|
|
||
|
|
# ---- 逐票走状态机 ----
|
||
|
|
for p in held:
|
||
|
|
out["checked"] += 1
|
||
|
|
code = p["ts_code"]
|
||
|
|
try:
|
||
|
|
a = accum.get(code) or {}
|
||
|
|
fresh = (a.get("age_days") is not None
|
||
|
|
and a["age_days"] <= prm["accum_stale_tdays"] * 2)
|
||
|
|
hv = heat.get(code)
|
||
|
|
st = strat_by_code.get(code)
|
||
|
|
if st:
|
||
|
|
_tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool,
|
||
|
|
today, dry_run, out, strategy_service)
|
||
|
|
continue
|
||
|
|
edge, why = plan_edge(cls=a.get("cls"), fresh=fresh, heat=hv,
|
||
|
|
cushion=p.get("cushion_pct"), prm=prm)
|
||
|
|
if not edge:
|
||
|
|
if why:
|
||
|
|
out["skipped"].append({"ts_code": code, "why": why})
|
||
|
|
continue
|
||
|
|
_attach_one(edge, why, p, a, hv, prm, view, black, live_codes, optout,
|
||
|
|
attached_today, today, dry_run, out, strategy_service)
|
||
|
|
attached_today = out["_attached_today"]
|
||
|
|
except Exception as e: # noqa: BLE001 —— 单票异常不拖垮整轮
|
||
|
|
logger.exception("[自动挂载] %s 处理失败", code)
|
||
|
|
out["errors"].append(f"{code}: {type(e).__name__}: {e}")
|
||
|
|
out.pop("_attached_today", None)
|
||
|
|
out["ok"] = not out["errors"]
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
def _tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool, today,
|
||
|
|
dry_run, out, strategy_service):
|
||
|
|
"""已挂策略的票: 边三 (派发停买/回明确恢复) 与 边四 (接力切换)。"""
|
||
|
|
code = p["ts_code"]
|
||
|
|
note = str(st.get("note") or "")
|
||
|
|
is_auto = note.startswith("自动挂载")
|
||
|
|
stype = str(st.get("type") or "").upper()
|
||
|
|
if not is_auto:
|
||
|
|
out["skipped"].append({"ts_code": code, "why": f"挂着人工策略 ({stype}), 自动挂载不碰它"})
|
||
|
|
return
|
||
|
|
if stype == "GRID":
|
||
|
|
# ---- 边四: 接力切换 (先于边三判 —— 都成立时说明已在拉升, 换止盈比停买更对) ----
|
||
|
|
ok_h, why_h = handoff_ready(price=p.get("price"), price_ok=p.get("price_ok"),
|
||
|
|
upper=(st.get("params") or {}).get("upper"),
|
||
|
|
heat=hv, cushion=p.get("cushion_pct"), prm=prm)
|
||
|
|
if ok_h and code in handoff_cool:
|
||
|
|
ok_h, why_h = False, "接力条件到了但在接力冷却期内, 不动"
|
||
|
|
if ok_h and _has_pending(st):
|
||
|
|
ok_h, why_h = False, "接力条件到了但策略有在途委托, 等它走完 (下一跳再看)"
|
||
|
|
if ok_h:
|
||
|
|
if dry_run:
|
||
|
|
out["handoffs"].append({"ts_code": code, "from": st["strategy_id"],
|
||
|
|
"dry_run": True, "why": why_h})
|
||
|
|
return
|
||
|
|
_do_handoff(st, p, hv, prm, why_h, out, strategy_service)
|
||
|
|
return
|
||
|
|
if why_h:
|
||
|
|
out["skipped"].append({"ts_code": code, "why": why_h})
|
||
|
|
# ---- 边三: 派发/失效 → 停买腿; 回明确 → 解除 accum 来源的暂停 ----
|
||
|
|
if R_EXIT not in prm["rules"]:
|
||
|
|
return
|
||
|
|
cls = a.get("cls")
|
||
|
|
pause_ent = (buypause or {}).get(code) or {}
|
||
|
|
if cls == CLS_DISTRIB or (cls == CLS_CLEAR and not fresh) or cls in (
|
||
|
|
CLS_NONE_SIGN, CLS_UNKNOWN, CLS_NOFIELD):
|
||
|
|
why = ("吸筹定性转高位派发" if cls == CLS_DISTRIB
|
||
|
|
else "吸筹标志消失或超日龄")
|
||
|
|
if pause_ent:
|
||
|
|
return # 已经停着 (accum 或风控来源), 不重复
|
||
|
|
if dry_run:
|
||
|
|
out["paused"].append({"ts_code": code, "dry_run": True, "why": why})
|
||
|
|
return
|
||
|
|
ids = strategy_service.pause_buy(code, reason=f"{why}, 网格买入腿暂停 (卖出照常)",
|
||
|
|
source="accum")
|
||
|
|
out["paused"].append({"ts_code": code, "strategies": ids, "why": why})
|
||
|
|
_ledger(code, "NOTE", f"边三: {why} —— 网格买入腿已暂停, 卖出与已买档位照常",
|
||
|
|
{"accum_state": a.get("state"), "accum_ymd": a.get("ymd")},
|
||
|
|
st["strategy_id"], out, action="NOTE")
|
||
|
|
elif cls == CLS_CLEAR and fresh and pause_ent.get("source") == "accum":
|
||
|
|
if dry_run:
|
||
|
|
out["resumed"].append({"ts_code": code, "dry_run": True})
|
||
|
|
return
|
||
|
|
r = strategy_service.clear_buypause(code, only_source="accum") or {}
|
||
|
|
if r.get("cleared"):
|
||
|
|
out["resumed"].append({"ts_code": code})
|
||
|
|
_ledger(code, "NOTE", "边三解除: 定性回到明确吸筹, 网格买入腿恢复",
|
||
|
|
{"accum_state": a.get("state")}, st["strategy_id"], out,
|
||
|
|
action="NOTE")
|
||
|
|
elif r.get("error"):
|
||
|
|
out["errors"].append(f"{code} 解除买入暂停失败: {r['error']}")
|
||
|
|
else:
|
||
|
|
out["skipped"].append({"ts_code": code, "why": f"已挂自动 {stype}, 本轮无事"})
|
||
|
|
|
||
|
|
|
||
|
|
def _attach_one(edge, why, p, a, hv, prm, view, black, live_codes, optout,
|
||
|
|
attached_today, today, dry_run, out, strategy_service):
|
||
|
|
"""无策略票走边一/边二: 排除项 → 参数生成 → 校验挂载 → 留痕。"""
|
||
|
|
code = p["ts_code"]
|
||
|
|
out["_attached_today"] = attached_today
|
||
|
|
if (code, edge) in optout:
|
||
|
|
out["skipped"].append({"ts_code": code, "why":
|
||
|
|
f"人工撤下过同类自动策略, 冷却期内不再自动挂 "
|
||
|
|
f"({prm['optout_cooldown_tdays']} 个交易日)"})
|
||
|
|
return
|
||
|
|
if code in live_codes:
|
||
|
|
out["skipped"].append({"ts_code": code, "why": "有在途指令或未完成方案, 缓到下一个扫描日"})
|
||
|
|
return
|
||
|
|
if edge == R_GRID:
|
||
|
|
if (p.get("frozen_reason") or "NONE") != "NONE":
|
||
|
|
out["skipped"].append({"ts_code": code, "why": "该股被冻结 (禁增持), 网格只剩半条腿, 不挂"})
|
||
|
|
return
|
||
|
|
if code in black:
|
||
|
|
out["skipped"].append({"ts_code": code, "why": "该股在黑名单, 网格买腿必被闸拦, 不挂"})
|
||
|
|
return
|
||
|
|
if attached_today >= prm["daily_max"]:
|
||
|
|
out["blocked"].append({"ts_code": code, "edge": edge,
|
||
|
|
"why": f"今日新挂名额已满 ({prm['daily_max']} 条), 留到明天"})
|
||
|
|
if not dry_run:
|
||
|
|
_ledger(code, "NOTE", f"想挂 {edge} 但今日新挂名额已满, 留到明天",
|
||
|
|
_snap(a, hv, p), None, out)
|
||
|
|
return
|
||
|
|
|
||
|
|
prm_view = view["params"]
|
||
|
|
if edge == R_GRID:
|
||
|
|
cap_room = max(0.0, _f(prm_view.get("stock_cap"), 0.08)
|
||
|
|
* _f(prm_view.get("scale"), 0.0)
|
||
|
|
- _f(p.get("market_value"), 0.0))
|
||
|
|
gp, gwhy = grid_params(price=(p.get("price") if p.get("price_ok") else None),
|
||
|
|
support=p.get("support_ref"), pressure=p.get("pressure_ref"),
|
||
|
|
band=prm["grid_band"], step_pct=prm["grid_step_pct"],
|
||
|
|
cap_room=cap_room, cap_ratio=prm["grid_cap_ratio"])
|
||
|
|
if not gp:
|
||
|
|
out["blocked"].append({"ts_code": code, "edge": edge, "why": gwhy})
|
||
|
|
if not dry_run:
|
||
|
|
_ledger(code, "NOTE", f"想挂网格但放弃: {gwhy}", _snap(a, hv, p), None, out)
|
||
|
|
return
|
||
|
|
stype, params = "GRID", gp
|
||
|
|
note = (f"{NOTE_AUTO}明确吸筹(评分 {a.get('score')}, 结论日 {a.get('ymd')}) → 网格 "
|
||
|
|
f"[{gp['lower']}~{gp['upper']}] 档距 {gp['step_pct']:.1%} "
|
||
|
|
f"上限 {gp['max_capital']:,.0f} 元")
|
||
|
|
else:
|
||
|
|
stype = "TRAIL"
|
||
|
|
params = {"giveback": prm["trail_giveback"], "sell_ratio": prm["trail_sell_ratio"]}
|
||
|
|
note = (f"{NOTE_AUTO}热度 {hv:.3f} 超阈值 {prm['heat_th']:.2f}"
|
||
|
|
+ (", 与明确吸筹双命中取止盈" if a.get("cls") == CLS_CLEAR else "")
|
||
|
|
+ f" → 跟踪止盈 回撤 {prm['trail_giveback']:.1%} 卖 {prm['trail_sell_ratio']:.0%}")
|
||
|
|
|
||
|
|
if dry_run:
|
||
|
|
out["attached"].append({"ts_code": code, "type": stype, "dry_run": True,
|
||
|
|
"why": why, "params": params})
|
||
|
|
out["_attached_today"] = attached_today + 1
|
||
|
|
return
|
||
|
|
r = strategy_service.attach({"ts_code": code, "type": stype, "autonomy": "auto",
|
||
|
|
"params": params, "note": note[:280]}, by="auto") or {}
|
||
|
|
if not r.get("ok"):
|
||
|
|
errs = "; ".join(str(x) for x in (r.get("errors") or ["挂载校验未过"]))
|
||
|
|
out["blocked"].append({"ts_code": code, "edge": edge, "why": errs})
|
||
|
|
_ledger(code, "NOTE", f"想挂 {stype} 被挂载校验挡下: {errs}", _snap(a, hv, p), None, out)
|
||
|
|
return
|
||
|
|
out["attached"].append({"ts_code": code, "type": stype,
|
||
|
|
"strategy_id": r.get("strategy_id"), "why": why})
|
||
|
|
out["_attached_today"] = attached_today + 1
|
||
|
|
_ledger(code, "PASS", f"{note[:200]} —— {why}",
|
||
|
|
{**_snap(a, hv, p), "params": params}, r.get("strategy_id"), out,
|
||
|
|
action="ATTACH")
|
||
|
|
logger.warning("[自动挂载] %s 挂 %s: %s", code, stype, why)
|
||
|
|
|
||
|
|
|
||
|
|
def _do_handoff(st, p, hv, prm, why, out, strategy_service):
|
||
|
|
"""边四落地: 撤网格 (打接力标记) → 挂止盈 → 高水位从当日高点起算 → 留痕。"""
|
||
|
|
code = p["ts_code"]
|
||
|
|
old_note = str(st.get("note") or "")
|
||
|
|
r1 = strategy_service.set_status(st["strategy_id"], "CANCELLED", by="auto") or {}
|
||
|
|
if not r1.get("ok"):
|
||
|
|
out["errors"].append(f"{code} 接力第一步撤网格失败: {r1.get('error')}")
|
||
|
|
return
|
||
|
|
n = pms_repo.update_strategy(st["strategy_id"],
|
||
|
|
note=(old_note + " " + MARK_HANDOFF_OUT)[:280])
|
||
|
|
if not n:
|
||
|
|
# 标记没打上: 冷却推导会把这次接力误判成人工撤下 —— 只是偏保守 (多冷却), 但要留痕
|
||
|
|
out["errors"].append(f"{code} 接力标记没写上 (影响 0 行), 冷却推导会偏保守")
|
||
|
|
params = {"giveback": prm["trail_giveback"], "sell_ratio": prm["trail_sell_ratio"]}
|
||
|
|
note = (f"{NOTE_HANDOFF}{why}"[:200] + f" 回撤 {prm['trail_giveback']:.1%}")
|
||
|
|
r2 = strategy_service.attach({"ts_code": code, "type": "TRAIL", "autonomy": "auto",
|
||
|
|
"params": params, "note": note[:280]}, by="auto") or {}
|
||
|
|
if not r2.get("ok"):
|
||
|
|
errs = "; ".join(str(x) for x in (r2.get("errors") or ["挂载校验未过"]))
|
||
|
|
out["errors"].append(f"{code} 接力第二步挂止盈失败 (网格已撤!): {errs} —— "
|
||
|
|
f"该票此刻无策略保护, 请人工处理")
|
||
|
|
_ledger(code, "REJECT", f"接力半途而废: 网格已撤但止盈没挂上 ({errs})",
|
||
|
|
{"heat": hv}, st["strategy_id"], out, action="HANDOFF")
|
||
|
|
return
|
||
|
|
hw0 = _f((p.get("price") if p.get("price_ok") else None), 0.0)
|
||
|
|
if hw0:
|
||
|
|
nn = pms_repo.update_strategy(r2["strategy_id"], state={"high_water": round(hw0, 3)})
|
||
|
|
if not nn:
|
||
|
|
out["errors"].append(f"{code} 止盈高水位初始化没写上 (影响 0 行), "
|
||
|
|
f"将从下一跳现价起算, 只偏保守")
|
||
|
|
out["handoffs"].append({"ts_code": code, "from": st["strategy_id"],
|
||
|
|
"to": r2.get("strategy_id"), "why": why})
|
||
|
|
_ledger(code, "PASS", f"接力切换: {why}",
|
||
|
|
{"heat": hv, "from": st["strategy_id"], "to": r2.get("strategy_id")},
|
||
|
|
r2.get("strategy_id"), out, action="HANDOFF")
|
||
|
|
logger.warning("[自动挂载] %s 接力: 网格 %s → 止盈 %s", code, st["strategy_id"],
|
||
|
|
r2.get("strategy_id"))
|
||
|
|
|
||
|
|
|
||
|
|
def _has_pending(st) -> bool:
|
||
|
|
"""策略上一笔委托还在途? 查不到按无在途 (接力多等一天没有代价, 误停一天有)。"""
|
||
|
|
pend = (st.get("state") or {}).get("pending") or {}
|
||
|
|
iid = pend.get("iid")
|
||
|
|
if not iid:
|
||
|
|
return False
|
||
|
|
try:
|
||
|
|
ins = pms_repo.get_instruction(iid)
|
||
|
|
except Exception: # noqa: BLE001
|
||
|
|
return False
|
||
|
|
return bool(ins and ins.get("status") in ("PROPOSED", "RULE_PASSED", "DISPATCHED"))
|
||
|
|
|
||
|
|
|
||
|
|
def _snap(a, hv, p) -> dict:
|
||
|
|
return {"accum_state": (a or {}).get("state"), "accum_score": (a or {}).get("score"),
|
||
|
|
"accum_ymd": (a or {}).get("ymd"), "heat": hv,
|
||
|
|
"price": (p or {}).get("price"), "cushion_pct": (p or {}).get("cushion_pct")}
|
||
|
|
|
||
|
|
|
||
|
|
def _ledger(code, verdict, reason, hard, ref_id, out, *, action="ATTACH"):
|
||
|
|
try:
|
||
|
|
pms_repo.insert_ledger(ts_code=code, action=action, arbiter="rule", verdict=verdict,
|
||
|
|
price_at=_f((hard or {}).get("price"), 0.0) or 0,
|
||
|
|
hard_numbers=hard, ref_id=ref_id, reason=str(reason)[:500])
|
||
|
|
except Exception as e: # noqa: BLE001 —— 留痕失败不拦动作本体, 但要说
|
||
|
|
out["errors"].append(f"{code} 留痕失败: {type(e).__name__}: {e}")
|