tradingSystem/app/services/strategy_advisor.py

871 lines
45 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 T_DAILY, 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 = "[接力撤下]"
# 阶段名 (方案第三节五阶段; 页面显示与判分同用这份字面量)
STG_ACCUM, STG_LAUNCH = "吸筹震荡", "启动拉升"
STG_DISTRIB, STG_REPAIR, STG_NEUTRAL = "高位派发", "深亏修复", "中性"
ACCUM_WINDOW_DAYS = 45 # 每票取近 45 自然日内最新一条结论
HEAT_MAX_AGE_DAYS = 4 # 热度表末日落后超此自然日 → 热度信号本轮不可用
_RULE_OF_TYPE = {"GRID": R_GRID, "TRAIL": R_TRAIL}
_CN_TYPE = {"GRID": "网格", "TRAIL": "跟踪止盈", "T0": "做T"} # 页面文案用中文名, 不给交易员看代码
def lot_of(ts_code) -> int:
"""最小申报单位。2026-08-28 起全库统一委托给 sizer.lot_of —— planner / rule_gate /
executor / action_engine 都已按代码取最小申报数量, 这里不再单独维护一份口径
(口径有两份, 迟早会分叉; 原先只在本模块兜的历史见 2026-08-25 DEVLOG)。"""
from app.core.sizer import lot_of as _lot_of
return _lot_of(ts_code)
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,
lot=100):
"""网格参数自动生成 (方案附录二)。返回 (params, why); params=None 时 why 说明放弃原因。
区间优先锚支撑压力, 锚不住退百分比带; 任何一步不满足 0<下界<中枢<上界 就放弃不硬凑。
lot=最小申报单位 (科创板 200, 见 lot_of) —— 买得起一手与 per_lot 下限都按它算。
"""
p = _f(price, 0.0)
if not p or p <= 0:
return None, "取不到实时价, 网格区间无从定"
lot = int(lot or 100)
cap = _f(cap_room, 0.0) or 0.0
max_capital = round(cap * _f(cap_ratio, 0.5), 2)
if max_capital < p * lot:
return None, (f"单股上限余量 {cap:,.0f} 元按投入比例折出 {max_capital:,.0f} 元, "
f"买不起一手({lot}股), 不挂")
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 < lot:
per_lot = lot
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 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, "无明确信号, 留给动作引擎的常规动作"
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(
f"SELECT stock_code, trade_date, raw_logic_json FROM {T_DAILY} "
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 _record_scan(out: dict, dry_run: bool, now) -> dict:
"""真跑 (非试算) 的每个出口都经过这里: 把本跳摘要写进运行参数 PMS_AUTO_LAST_SCAN,
页面据此回答「今天的正式一跳跑没跑、做了什么」—— 零动作的一跳在台账上没有痕迹,
没有这份摘要就没人说得清它到底跑过没有。写失败只告警不拦返回: 摘要是仪表不是账。"""
if dry_run:
return out
try:
summ = {"ymd": td.ymd(now), "at": str(now)[:19], "ok": out.get("ok", True),
"enabled": out.get("enabled"), "checked": out.get("checked", 0),
"counts": {k: len(out.get(k) or []) for k in
("attached", "handoffs", "paused", "resumed",
"blocked", "skipped", "errors")}}
r = param_store.set_param("PMS_AUTO_LAST_SCAN",
json.dumps(summ, ensure_ascii=False), "system")
if not isinstance(r, dict) or not r.get("ok"):
logger.warning("[自动挂载] 本跳摘要没写进参数表: %s",
r.get("error") if isinstance(r, dict) else r)
except Exception as e: # noqa: BLE001
logger.warning("[自动挂载] 本跳摘要没写进参数表: %s", e)
return out
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": [], "stages": []}
if not prm["enabled"]:
out["skipped"].append({"why": "策略自动挂载总开关关闭 (PMS_AUTO_STRATEGY_ENABLED)"})
return _record_scan(out, dry_run, now)
if not param_store.get_bool("PMS_STRATEGY_ENABLED", False):
out["skipped"].append({"why": "策略层总开关关闭 (PMS_STRATEGY_ENABLED) —— "
"挂了也不会跑, 本轮不挂"})
return _record_scan(out, dry_run, now)
if param_store.get_bool("PMS_GLOBAL_EXEC_HALT", False):
out["skipped"].append({"why": "全局暂停执行 (休假模式)"})
return _record_scan(out, dry_run, now)
from app.services import command_service, portfolio, strategy_service
try:
view = portfolio.positions_view()
except Exception as e: # noqa: BLE001 —— 守成: 读不到持仓不动任何东西
return _record_scan({**out, "ok": False,
"errors": [f"读持仓失败: {type(e).__name__}: {e}"]},
dry_run, now)
held = [p for p in view["held"] if int(p.get("total_qty") or 0) > 0]
if not held:
out["skipped"].append({"why": "当前无持仓"})
return _record_scan(out, dry_run, now)
codes = [p["ts_code"] for p in held]
# ---- 信号与既有状态一次取齐 (任一失败只废对应的边, 不废整轮) ----
accum_ok = True # 取数成功与否要跟"某只票查无定性"分开: 前者按不动, 后者要停买
try:
accum = accum_of(codes)
except Exception as e: # noqa: BLE001
accum = {}
accum_ok = False
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 _record_scan({**out, "ok": False, "errors": out["errors"] +
[f"读策略表失败, 整轮守成不动: {type(e).__name__}: {e}"]},
dry_run, now)
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
# 盘中 SAR 止损线的每日刷新 (2026-09-11 工作包三 part 3): 把技术面映射里的 SAR 值刷进每条
# 自动挂载的跟踪止盈的 params.sar_line, 供 strategy_runner._eval_trail 的盘中 SAR 止损用。
# 开关 PMS_TECH_SAR_STOP_ON_TRAIL 关掉不刷; 试算不写。取不到读数就撤掉旧 SAR 线 (不拿旧读数当今天)。
if param_store.get_bool("PMS_TECH_SAR_STOP_ON_TRAIL", True) and not dry_run:
try:
_refresh_sar_lines(strat_all, out)
except Exception as e: # noqa: BLE001 —— 刷 SAR 线失败不拖垮整轮挂载
out["errors"].append(f"SAR 线刷新失败: {type(e).__name__}: {e}")
# ---- 逐票走状态机 ----
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)
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")})
if st:
_tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool,
today, dry_run, out, strategy_service, accum_ok=accum_ok)
continue
# 弱基本面试探仓的紧止盈自动挂载 (2026-09-11 工作包三 part 4): 无策略票里, 基本面看空加
# 技术面看多的试探仓次日自动挂紧止盈, 不占每日名额。挂了 (或占位让路) 就不再走常规边。
if _attach_tight_trail(p, prm, live_codes, optout, 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 _record_scan(out, dry_run, now)
def _refresh_sar_lines(strat_all, out) -> None:
"""把技术面映射里的 SAR 值刷进每条自动挂载的跟踪止盈的 params.sar_line (2026-09-11 工作包三 part 3)。
只动自动挂载 (note 以「自动挂载」开头) 且 ACTIVE 的跟踪止盈; 人工策略与网格不碰。
取不到某票的 SAR (无读数、映射停更) 就撤掉它旧的 sar_line —— 不拿旧读数当今天的止损线。
值没变就不写库。写失败只记 errors, 不拦整轮。
"""
from app.services import tech_service
tmap = tech_service.state_map() # 取不到抛给调用方的 try 兜住
n = 0
for s in strat_all:
if str(s.get("type") or "").upper() != "TRAIL":
continue
if not str(s.get("note") or "").startswith("自动挂载"):
continue
if str(s.get("status") or "ACTIVE").upper() != "ACTIVE":
continue
params = dict(s.get("params") or {})
old = params.get("sar_line")
sar = _f((tmap.get(s.get("ts_code")) or {}).get("sar_value"))
if sar and sar > 0:
params["sar_line"] = round(sar, 3)
else:
params.pop("sar_line", None) # 无读数: 撤掉旧线, 这道 SAR 止损本日不生效
if params.get("sar_line") != old:
if pms_repo.update_strategy(s["strategy_id"], params=params):
n += 1
else:
out["errors"].append(f"{s.get('ts_code')} SAR 线刷新影响 0 行")
if n:
out["sar_refreshed"] = n
def _entry_tight_trail(code) -> bool:
"""这只持仓是不是「基本面看空加技术面看多的试探仓」(2026-09-11 工作包三 part 4)。
判据是入场那条账本记录里 advice.tight_trail 为真 —— 那是 advise_v2 给这类试探仓打的标记
(方案附录乙: 只给试探仓一批、交人、配紧止盈)。顺着首批未平批次 → 指令 → 账本放行记录找。
读不到、认不出一律按否 —— 认不出就不自动挂紧止盈, 交给常规边或人工 (加不改)。
"""
try:
lots = pms_repo.list_lots(code, status="OPEN", limit=50)
if not lots:
return False
iid = lots[0].get("instruction_id")
if not iid:
return False
refs = [str(iid)]
ins = pms_repo.get_instruction(str(iid)) or {}
for k in (ins.get("origin_id"), (ins.get("progress") or {}).get("from_proposal")):
if k and str(k) not in refs:
refs.append(str(k))
for r in pms_repo.ledger_by_ref(refs):
if r.get("verdict") != "PASS":
continue
adv = (r.get("hard_numbers") or {}).get("advice")
if isinstance(adv, dict) and adv.get("tight_trail"):
return True
except Exception as e: # noqa: BLE001
logger.warning("[自动挂载] 查入场紧止盈标记失败 %s (按否): %s", code, e)
return False
def _attach_tight_trail(p, prm, live_codes, optout, dry_run, out, strategy_service) -> bool:
"""弱基本面试探仓的紧止盈自动挂载 (2026-09-11 工作包三 part 4, 台账 011)。
基本面看空加技术面看多的试探仓, 次日 09:40 自动挂一条回撤 3%、硬目标 8%、带 SAR 线的跟踪止盈,
**不占每日新挂名额** (它是给弱基本面试探仓配的保护, 不是常规吸筹/高热挂载)。挂上后这只票就有
策略了, 下一跳 st 不为空、不再重挂。返回 True 表示「这只票已按紧止盈处理」(占位, 不再走常规边)。
冷却与在途照常让路: 人工撤下过就等冷却, 有在途就缓一天。挂载参数与 SAR 线由本函数生成,
SAR 值取当轮技术面映射; 取不到就不带 SAR 线 (回撤与硬目标照常, 加不改)。
"""
code = p["ts_code"]
if not _entry_tight_trail(code):
return False
if (code, R_TRAIL) in optout:
out["skipped"].append({"ts_code": code, "why":
f"弱基本面试探仓想挂紧止盈, 但人工撤下同类冷却期内 "
f"({prm['optout_cooldown_tdays']} 个交易日)"})
return True
if code in live_codes:
out["skipped"].append({"ts_code": code, "why": "弱基本面试探仓想挂紧止盈, 但有在途, 缓到下一个扫描日"})
return True
gb = param_store.get_float("PMS_TECH_TIGHT_TRAIL_GIVEBACK", 0.03)
ht = param_store.get_float("PMS_TECH_TIGHT_TRAIL_TARGET", 0.08)
params = {"giveback": gb, "sell_ratio": 1.0, "hard_target": ht}
try:
from app.services import tech_service
sar = _f((tech_service.state_map().get(code) or {}).get("sar_value"))
if sar and sar > 0:
params["sar_line"] = round(sar, 3)
except Exception: # noqa: BLE001 —— 取不到 SAR 不拦, 回撤与硬目标照常
pass
note = (f"{NOTE_AUTO}基本面看空加技术面看多的试探仓 → 紧止盈 "
f"回撤 {gb:.0%} 硬目标 {ht:.0%}" + ("、带 SAR 线" if params.get("sar_line") else ""))
if dry_run:
out["attached"].append({"ts_code": code, "type": "TRAIL", "dry_run": True,
"why": "弱基本面试探仓紧止盈 (不占每日名额)", "params": params})
return True
r = strategy_service.attach({"ts_code": code, "type": "TRAIL", "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": R_TRAIL, "why": errs})
_ledger(code, "NOTE", f"想挂弱基本面紧止盈被挂载校验挡下: {errs}",
{"price": p.get("price")}, None, out)
return True
out["attached"].append({"ts_code": code, "type": "TRAIL", "strategy_id": r.get("strategy_id"),
"why": "弱基本面试探仓紧止盈 (不占每日名额)"})
_ledger(code, "PASS", note[:200], {"price": p.get("price"), "sar_line": params.get("sar_line")},
r.get("strategy_id"), out, action="ATTACH")
logger.warning("[自动挂载] %s 挂弱基本面紧止盈: 回撤 %.0f%% 硬目标 %.0f%%", code, gb * 100, ht * 100)
return True
def _tend_existing(st, p, a, fresh, hv, prm, buypause, handoff_cool, today,
dry_run, out, strategy_service, accum_ok=True):
"""已挂策略的票: 边三 (派发停买/回明确恢复) 与 边四 (接力切换)。"""
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"挂着人工策略({_CN_TYPE.get(stype, stype)}), 自动挂载不介入"})
return
if str(st.get("status") or "ACTIVE").upper() != "ACTIVE": # 缺省按 ACTIVE (真行必有值)
# 人为暂停 (PAUSED) 的策略自动挂载**不碰** (2026-08-28 审查修): 原来接力照样把
# 暂停的网格 CANCELLED 再挂一条 ACTIVE 止盈 —— 人特意按下的暂停被自动系统推翻,
# 正是模块头声明要杜绝的「人机拉锯」。查 PAUSED 只为防重复挂载, 不为替它做主。
out["skipped"].append({"ts_code": code,
"why": f"策略处于 {st.get('status')} (人为暂停), 自动挂载不动它"})
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 {}
# 「45 日窗口内整行缺失」是最彻底的一档失效 (票被移出决策系统覆盖), 原来反而不停
# (cls=None 不落任何分支) —— 比"仅缺 state 字段"还失效却继续逢跌买入 (2026-08-28 修)。
# 仅当取数本身成功 (accum_ok) 才把"查无此票"当失效: 整体取数失败按守成不动。
missing = accum_ok and not a
if cls == CLS_DISTRIB or (cls == CLS_CLEAR and not fresh) or missing or cls in (
CLS_NONE_SIGN, CLS_UNKNOWN, CLS_NOFIELD):
why = ("吸筹定性转高位派发" if cls == CLS_DISTRIB
else ("45 个交易日窗口内查无该票定性(已出决策系统覆盖), 网格失去定性支撑"
if missing 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"已挂自动{_CN_TYPE.get(stype, 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"],
lot=lot_of(code))
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}")