# -*- 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 = "[接力撤下]" # 阶段名 (方案第三节五阶段; 页面显示与判分同用这份字面量) 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} 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 磨掉。 runner 侧卖出数量的整百取整对科创板仍不完美 (见 DEVLOG 欠账), 不在本模块修。""" s = str(ts_code or "") return 200 if s.startswith(("688", "689")) else 100 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( "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": [], "stages": []} 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) 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) 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"], 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}")