tradingSystem/app/services/strategy_advisor.py

704 lines
35 KiB
Python
Raw Normal View History

2026-08-25 13:45:51 +08:00
# -*- coding: utf-8 -*-
"""
策略自动挂载 · 个股打法状态机 (STRATEGY_AUTO_ATTACH_PLAN.md V2)
================================================================
每交易日 09:40 跑一次 (scheduler.strategy_attach)对每只持仓票判阶段:
阶段: 吸筹震荡 / 启动拉升 / 高位派发 / 深亏修复 / 中性
边一 中性吸筹震荡 明确吸筹且结论新鲜 挂网格
边二 中性启动拉升 热度超阈值且安全垫为正 挂跟踪止盈 (双命中也取止盈)
2026-08-25 14:07:42 +08:00
边三 吸筹震荡高位派发 定性转派发或标志失效 暂停网格的买入 (来源 accum, 回明确自动解除)
2026-08-25 13:45:51 +08:00
边四 吸筹震荡启动拉升 自动网格票站上区间上界且热度超阈值 撤网格换挂止盈 (接力)
**挂载和切换本身不下单** 真正的买卖仍由 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 = "[接力撤下]"
2026-08-26 10:01:30 +08:00
# 阶段名 (方案第三节五阶段; 页面显示与判分同用这份字面量)
STG_ACCUM, STG_LAUNCH = "吸筹震荡", "启动拉升"
STG_DISTRIB, STG_REPAIR, STG_NEUTRAL = "高位派发", "深亏修复", "中性"
2026-08-25 13:45:51 +08:00
ACCUM_WINDOW_DAYS = 45 # 每票取近 45 自然日内最新一条结论
HEAT_MAX_AGE_DAYS = 4 # 热度表末日落后超此自然日 → 热度信号本轮不可用
_RULE_OF_TYPE = {"GRID": R_GRID, "TRAIL": R_TRAIL}
2026-08-25 13:57:16 +08:00
def lot_of(ts_code) -> int:
"""最小申报单位: 科创板 (688/689) 200 股起, 其余 100。
2026-08-25 实盘 dry-run 发现: 持仓里有 688802.SH, 全库其他地方一律按 100 股一手
(strategy_runner._round_lot / sizer), 科创板 200 股起买这条只在这里兜 自动网格
per_lot 若生成 100, runner 发单会被券商按无效数量拒掉买得起一手的判断与
per_lot 下限都按这个数; 200 也是 100 的整数倍, 不会被 _round_lot 磨掉
2026-08-25 14:07:42 +08:00
runner 侧卖出数量的整百取整对科创板仍不完美 ( DEVLOG 欠账), 不在本模块修"""
2026-08-25 13:57:16 +08:00
s = str(ts_code or "")
return 200 if s.startswith(("688", "689")) else 100
2026-08-25 13:45:51 +08:00
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
2026-08-25 13:57:16 +08:00
def grid_params(*, price, support, pressure, band, step_pct, cap_room, cap_ratio,
lot=100):
2026-08-25 13:45:51 +08:00
"""网格参数自动生成 (方案附录二)。返回 (params, why); params=None 时 why 说明放弃原因。
区间优先锚支撑压力, 锚不住退百分比带; 任何一步不满足 0<下界<中枢<上界 就放弃不硬凑
2026-08-25 13:57:16 +08:00
lot=最小申报单位 (科创板 200, lot_of) 买得起一手与 per_lot 下限都按它算
2026-08-25 13:45:51 +08:00
"""
p = _f(price, 0.0)
if not p or p <= 0:
return None, "取不到实时价, 网格区间无从定"
2026-08-25 13:57:16 +08:00
lot = int(lot or 100)
2026-08-25 13:45:51 +08:00
cap = _f(cap_room, 0.0) or 0.0
max_capital = round(cap * _f(cap_ratio, 0.5), 2)
2026-08-25 13:57:16 +08:00
if max_capital < p * lot:
2026-08-25 13:45:51 +08:00
return None, (f"单股上限余量 {cap:,.0f} 元按投入比例折出 {max_capital:,.0f} 元, "
2026-08-25 13:57:16 +08:00
f"买不起一手({lot}股), 不挂")
2026-08-25 13:45:51 +08:00
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
2026-08-25 13:57:16 +08:00
if per_lot < lot:
per_lot = lot
2026-08-25 13:45:51 +08:00
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, ""
2026-08-26 10:01:30 +08:00
def stage_of(*, cls, fresh, heat, heat_th, cushion, stype=None, is_auto=False):
"""判定一只持仓票当前处在哪个阶段 (方案第三节的五阶段)。返回 (阶段, 一句话理由)。
已挂自动策略的票, 阶段以策略为准 策略本身就是阶段判断落了地无策略的票按
信号判, 次序与连边判断一致, 保守方向优先: 派发最先, 然后吸筹拉升深亏,
不是就是中性这个函数只读不写, 每次扫描对全部持仓各出一行, 页面按钮直接显示
"""
t = str(stype or "").upper()
if is_auto and t == "GRID":
return STG_ACCUM, "自动网格进行中, 区间内低买高卖"
if is_auto and t == "TRAIL":
return STG_LAUNCH, "自动跟踪止盈保护中, 创新高抬线回落即卖"
if cls == CLS_DISTRIB:
return STG_DISTRIB, "决策系统定性为高位派发"
if cls == CLS_CLEAR and fresh:
return STG_ACCUM, "明确吸筹且结论新鲜"
hot = heat is not None and _f(heat, 0.0) >= heat_th
if hot and cushion is not None and _f(cushion, 0.0) > 0:
return STG_LAUNCH, f"热度 {_f(heat):.2f} 达标且有浮盈"
if cushion is not None and _f(cushion, 0.0) < 0:
return STG_REPAIR, "安全垫为负, 交给补仓评估与清弱票, 不配策略"
return STG_NEUTRAL, "无明确信号, 留给动作引擎的常规动作"
2026-08-25 13:45:51 +08:00
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": [],
2026-08-26 10:01:30 +08:00
"unknown_states": [], "stages": []}
2026-08-25 13:45:51 +08:00
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 = {}
2026-08-25 14:07:42 +08:00
out["errors"].append(f"吸筹取数失败, 依赖它的判断本轮全部不做: {type(e).__name__}: {e}")
2026-08-25 13:45:51 +08:00
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 = {}
2026-08-25 14:07:42 +08:00
out["errors"].append(f"热度取数失败, 依赖它的判断本轮全部不做: {type(e).__name__}: {e}")
2026-08-25 13:45:51 +08:00
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)
2026-08-26 10:01:30 +08:00
is_auto = bool(st and str(st.get("note") or "").startswith("自动挂载"))
stg, stg_why = stage_of(cls=a.get("cls"), fresh=fresh, heat=hv,
heat_th=prm["heat_th"], cushion=p.get("cushion_pct"),
stype=(st or {}).get("type"), is_auto=is_auto)
if st and not is_auto:
stg_why += " (挂着人工策略, 自动挂载不碰)"
out["stages"].append({"ts_code": code, "stage": stg, "why": stg_why,
"accum_state": a.get("state"), "heat": hv,
"cushion_pct": p.get("cushion_pct"),
"strategy": (st or {}).get("type")})
2026-08-25 13:45:51 +08:00
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})
2026-08-25 14:07:42 +08:00
# ---- 边三: 派发/失效 → 暂停买入; 回明确 → 解除 accum 来源的暂停 ----
2026-08-25 13:45:51 +08:00
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
2026-08-25 14:07:42 +08:00
ids = strategy_service.pause_buy(code, reason=f"{why}, 网格暂停买入 (卖出照常)",
2026-08-25 13:45:51 +08:00
source="accum")
out["paused"].append({"ts_code": code, "strategies": ids, "why": why})
2026-08-25 14:07:42 +08:00
_ledger(code, "NOTE", f"{why} —— 网格已暂停买入, 卖出与已买的档位照常",
2026-08-25 13:45:51 +08:00
{"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})
2026-08-25 14:07:42 +08:00
_ledger(code, "NOTE", "定性回到明确吸筹, 网格恢复买入",
2026-08-25 13:45:51 +08:00
{"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":
2026-08-25 14:07:42 +08:00
out["skipped"].append({"ts_code": code, "why": "该股被冻结禁止增持, 网格买不进只剩卖出, 不挂"})
2026-08-25 13:45:51 +08:00
return
if code in black:
2026-08-25 14:07:42 +08:00
out["skipped"].append({"ts_code": code, "why": "该股在黑名单, 网格的买入必被规则闸拦下, 不挂"})
2026-08-25 13:45:51 +08:00
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"],
2026-08-25 13:57:16 +08:00
cap_room=cap_room, cap_ratio=prm["grid_cap_ratio"],
lot=lot_of(code))
2026-08-25 13:45:51 +08:00
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}")