# -*- coding: utf-8 -*- """ 个股交易方案 (策略) 管理侧: 校验 / 挂载 / 暂停撤下 —— PER_STOCK_STRATEGY_PLAN.md §五/§十 运行侧 (每分钟评估、下单) 见 strategy_runner.py。 挂载前**先过约束校验** (validate): 违反仓位 / 存量 / 上限就挡下、给明确中文原因、**不写库** —— 落实「理论上不允许违反, 无法操作要在页面提示」。真正下单时的合规由 rule_gate 再兜一道 (双保险)。 校验不写库、不抛异常; 读持仓失败按「暂不能校验」挡下 (不放行未校验的挂载)。 """ from __future__ import annotations import json import logging from datetime import datetime from app.core import tradedays as td from app.repo import pms_repo from app.services import param_store, portfolio logger = logging.getLogger("pms.strategy_svc") TYPES = {"T0", "GRID", "TRAIL"} AUTONOMY = {"auto", "confirm"} STATUSES = {"ACTIVE", "PAUSED", "CANCELLED", "DONE"} def _f(v, d=0.0): try: return float(v) except (TypeError, ValueError): return d def _pos(view, code): for x in view["positions"]: if x["ts_code"] == code: return x return None def validate(cfg: dict, view=None) -> dict: """校验一条策略配置。返回 {ok, reasons:[中文原因]}。不写库、不抛异常。""" cfg = cfg or {} reasons = [] code = cfg.get("ts_code") typ = cfg.get("type") autonomy = cfg.get("autonomy") or "auto" params = cfg.get("params") or {} if not code: reasons.append("未指定股票") if typ not in TYPES: reasons.append(f"未知策略类型 {typ}(仅支持 做T=T0 / 网格=GRID / 跟踪止盈=TRAIL)") if autonomy not in AUTONOMY: reasons.append(f"自主档 {autonomy} 非法(仅 auto / confirm)") if reasons: return {"ok": False, "reasons": reasons} try: view = view or portfolio.positions_view() except Exception as e: # noqa: BLE001 —— 读不到持仓不能放行未校验的挂载 return {"ok": False, "reasons": [f"读持仓失败, 暂不能校验(不放行): {type(e).__name__}: {e}"]} pos = _pos(view, code) if not pos or int(pos.get("total_qty") or 0) <= 0: return {"ok": False, "reasons": [ "该股当前没有持仓 —— 交易方案要在已有底仓上做差价(A股 T+1, 当日买入不可当日卖)"]} # 同股已有 ACTIVE 策略 → 不重复挂 try: dup = pms_repo.list_strategies(ts_code=code, statuses=["ACTIVE"], limit=5) except Exception: # noqa: BLE001 dup = [] if dup: reasons.append(f"该股已挂着一条 {dup[0].get('type')} 策略(生效中), 先撤下再挂新的") prm = view["params"] scale = _f(prm.get("scale")) stock_cap = _f(prm.get("stock_cap")) mv = _f(pos.get("market_value")) avail = int(pos.get("avail_qty") or 0) cap_room = max(0.0, stock_cap * scale - mv) # 这只票离单股上限还差多少钱 if typ == "T0": tr = _f(params.get("t_ratio")) if not (0 < tr <= 0.3334): reasons.append("做T 的 T 仓比例 t_ratio 必须在 0~1/3 之间(硬上限 1/3)") if avail <= 0: reasons.append("做T 需要 T+1 可卖的存量股(当前可卖为 0), 无法在存量上做差价") elif typ == "GRID": lo, hi = _f(params.get("lower")), _f(params.get("upper")) mid = _f(params.get("center")) or _f(pos.get("price")) step = _f(params.get("step")) max_cap = _f(params.get("max_capital")) if not (0 < lo < mid < hi): reasons.append("网格上下界不成立: 需 0 < 下界 < 中枢 < 上界") if step <= 0 and _f(params.get("step_pct")) <= 0: reasons.append("网格档距必须 > 0 (绝对档距 step 或百分比档距 step_pct 至少给一个)") if max_cap <= 0: reasons.append("网格最大投入额必须 > 0") elif max_cap > cap_room + 1e-6: reasons.append( f"网格最大投入 {max_cap:,.0f} 元超过该股离单股上限的余量 {cap_room:,.0f} 元" f"(单股上限 {stock_cap:.0%}×规模 {scale:,.0f}, 已占市值 {mv:,.0f})") elif typ == "TRAIL": gb = _f(params.get("giveback")) if not (0 < gb < 1): reasons.append("跟踪止盈的回撤比例 giveback 必须在 0~1 之间(如 0.05=从高点回落 5% 就卖)") if avail <= 0: reasons.append("跟踪止盈触发时要卖出, 但当前 T+1 可卖为 0") return {"ok": not reasons, "reasons": reasons} def attach(cfg: dict, by: str = "user") -> dict: """挂载一条策略。先校验, 违反返回 {ok:false, errors}; 通过则写 pms_strategy(ACTIVE)。""" v = validate(cfg) if not v["ok"]: return {"ok": False, "errors": v["reasons"]} code = cfg["ts_code"] ymd = td.ymd() sid = f"STR_{ymd}_{code.replace('.', '')}_{int(datetime.now().timestamp()) % 1000000}" pms_repo.insert_strategy(strategy_id=sid, ts_code=code, stype=cfg["type"], autonomy=cfg.get("autonomy") or "auto", params=cfg.get("params") or {}, state={}, status="ACTIVE", note=cfg.get("note")) return {"ok": True, "strategy_id": sid, "ts_code": code} def set_status(strategy_id: str, status: str, by: str = "user") -> dict: """暂停(PAUSED) / 恢复(ACTIVE) / 撤下(CANCELLED)。""" status = (status or "").upper() if status not in STATUSES: return {"ok": False, "error": f"非法状态 {status}"} st = pms_repo.get_strategy(strategy_id) if not st: return {"ok": False, "error": "策略不存在"} pms_repo.update_strategy(strategy_id, status=status) return {"ok": True, "strategy_id": strategy_id, "ts_code": st.get("ts_code"), "status": status} def cancel_by_code(ts_code: str, reason: str = "") -> dict: """停掉某只票的买入侧 (2026-09-14 系统自决包; 同日评审补齐): 与命令清仓那条 command_service._stop_buyside_for_exit 同样的三件事 —— 撤该票 ACTIVE/PAUSED 的全部交易方案、 驳回该票待确认的买入提议、撤该票当前在途的买入指令。 系统自决到价自动清仓前调它: 该票若挂着网格, 网格的买入腿会与清仓单对倒 (规格附录乙那处冲突)。 只改方案状态不够 —— 已经下发到券商的那张网格买单还挂着继续成交, 命令清仓那条早就为此补过 「撤在途买单」这一层 (2026-09-08), 这里照做。三步各自幂等、失败只记进 errors 不抛 —— 撤不成也不该拦住清仓 (清仓单落表后由执行器照卖; 下游没撤成的买单单独列出来别吞)。 返回 {ok, ts_code, cancelled: 方案编号, proposals: 驳回的提议, instructions: 撤掉的在途买单, errors}。 """ out = {"ok": True, "ts_code": ts_code, "cancelled": [], "proposals": [], "instructions": [], "errors": []} why = reason or "系统到价自动清仓: 先撤该票交易方案与在途买单, 免得清仓单与买入腿对倒" # 一, 撤方案 (ACTIVE 与 PAUSED 都撤: 暂停的网格恢复后照样买) try: actives = pms_repo.list_strategies(ts_code=ts_code, statuses=["ACTIVE", "PAUSED"], limit=20) except Exception as e: # noqa: BLE001 actives = [] out["ok"] = False out["errors"].append(f"读策略失败: {type(e).__name__}: {e}") for s in actives: try: r = set_status(s["strategy_id"], "CANCELLED", by="system") or {} if r.get("ok"): out["cancelled"].append(s["strategy_id"]) try: pms_repo.insert_ledger( ts_code=ts_code, action="CLEANUP", arbiter="system", verdict="PASS", price_at=0, ref_id=s["strategy_id"], hard_numbers={"strategy_id": s["strategy_id"]}, reason=why[:500]) except Exception as e: # noqa: BLE001 logger.warning("[自决清仓] 撤方案留痕失败 %s: %s", ts_code, e) else: out["ok"] = False out["errors"].append(f"{s.get('strategy_id')} 撤下未成: {r.get('error')}") except Exception as e: # noqa: BLE001 out["ok"] = False out["errors"].append(f"{s.get('strategy_id')} 撤下失败: {type(e).__name__}: {e}") # 二, 驳回该票待确认的买入提议 (清仓了还等人点头买, 是自相矛盾) from app.services import command_service, executor try: props = [p for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=500) if p.get("ts_code") == ts_code and p.get("action") in command_service.BUILD_ACTIONS] except Exception as e: # noqa: BLE001 props = [] out["ok"] = False out["errors"].append(f"读提议失败: {type(e).__name__}: {e}") for p in props: try: if pms_repo.decide_proposal(p["proposal_id"], "DECLINED"): out["proposals"].append(p["proposal_id"]) except Exception as e: # noqa: BLE001 out["ok"] = False out["errors"].append(f"驳回买入提议 {p.get('proposal_id')} 失败: {type(e).__name__}: {e}") # 三, 撤该票当前在途的买入指令 (已下发到券商的网格/补仓买单, 撤了方案它还挂着) try: live_buys = [i for i in pms_repo.list_instructions( statuses=list(command_service.LIVE_INSTR), side="buy", limit=500) if i.get("ts_code") == ts_code] except Exception as e: # noqa: BLE001 live_buys = [] out["ok"] = False out["errors"].append(f"读在途买单失败: {type(e).__name__}: {e}") for i in live_buys: iid = i.get("instruction_id") try: r = executor.cancel_instruction(iid, reason=why) or {} if r.get("ok"): out["instructions"].append(iid) else: out["ok"] = False out["errors"].append(f"撤在途买单 {iid} 未成: {r.get('error') or r.get('message')}") except Exception as e: # noqa: BLE001 out["ok"] = False out["errors"].append(f"撤在途买单 {iid} 失败: {type(e).__name__}: {e}") if out["cancelled"] or out["proposals"] or out["instructions"]: logger.warning("[自决清仓] %s 停买入侧: 撤方案 %s, 驳回买入提议 %s, 撤在途买单 %s", ts_code, out["cancelled"], out["proposals"], out["instructions"]) return out # ================================================================ 买入暂停表 (决策系统风控预警) # 挂了策略的票, 决策系统的风控卖出只提示、不自动清仓 (强制离场会推翻你特意设的策略); # 这里把该票策略的**买入这一侧**暂停 —— 不平仓、不动卖出、可页面恢复。 # 用独立运行参数 PMS_STRATEGY_BUYPAUSE (按 ts_code 映射), **不放进策略 state_json**: # 那份 state 每跳被 strategy_runner 重写, 放进去会被并发的 signal_digest / intraday_exec # 互相覆盖 (与 portfolio.neg_streak_map 用独立参数、避开读改写竞态同一个道理)。 BUYPAUSE_KEY = "PMS_STRATEGY_BUYPAUSE" def buypause_map() -> dict: """当前被暂停买入的票 {ts_code: {reason, source, at}}。读失败按空 (不误暂停)。""" try: raw = pms_repo.get_param(BUYPAUSE_KEY) return json.loads(raw) if raw else {} except Exception: # noqa: BLE001 return {} def _save_buypause(m: dict) -> dict: try: pms_repo.set_param(BUYPAUSE_KEY, json.dumps(m, ensure_ascii=False), "system") return {"ok": True} except Exception as e: # noqa: BLE001 —— 写不进要吭声, 别让"已暂停"变成静默没暂停 logger.error("[strategy] 买入暂停表写入失败: %s —— 该票策略买入可能仍在跑", e) return {"ok": False, "error": f"{type(e).__name__}: {e}"} def pause_buy(ts_code: str, *, reason: str = "", source: str = "signal") -> list: """暂停某票所有 ACTIVE 策略的买入这一侧 (决策系统风控预警触发)。返回受影响的 strategy_id。 只标记、不平仓、不动卖出; 由页面「恢复买入」(resume_buy) 解除。 **按来源分别记** (2026-08-28 审查修): 同一票可以同时被 accum (定性失效) 和 signal (风控预警) 两个来源暂停。原来先写先赢、后来的来源被吞 —— 之后 advisor 按 accum 来源 解除时, 会把风控停的也顺手放开。条目里加 sources 子表, 顶层字段保持最近一次的值 (向后兼容只读顶层的消费方)。""" if not ts_code: return [] m = buypause_map() ent = dict(m.get(ts_code) or {}) sources = dict(ent.get("sources") or {}) if not sources and ent.get("source"): # 旧格式条目: 迁移成 sources 子表 sources[ent["source"]] = {"reason": ent.get("reason") or "", "at": ent.get("at") or ""} if source not in sources: sources[source] = {"reason": (str(reason)[:300] if reason else ""), "at": str(datetime.now())[:19]} m[ts_code] = {"reason": (str(reason)[:300] if reason else ""), "source": source, "at": str(datetime.now())[:19], "sources": sources} _save_buypause(m) try: return [s.get("strategy_id") for s in pms_repo.list_strategies(ts_code=ts_code, statuses=["ACTIVE"], limit=10)] except Exception: # noqa: BLE001 return [] def resume_buy(strategy_id: str, by: str = "user") -> dict: """恢复买入 (按策略解除该票的买入暂停)。只恢复买入, 不影响卖出/平回。""" st = pms_repo.get_strategy(strategy_id) if not st: return {"ok": False, "error": "策略不存在"} code = st.get("ts_code") m = buypause_map() if code in m: m.pop(code, None) r = _save_buypause(m) if not r.get("ok"): return {"ok": False, "error": r.get("error")} return {"ok": True, "strategy_id": strategy_id, "ts_code": code} def clear_buypause(ts_code: str, only_source: str = None) -> dict: """按票解除买入暂停 (strategy_advisor 边三恢复用, 2026-08-25)。 only_source 给了就只清对应来源的暂停项 —— advisor 传 "accum", 这样它只解除 自己停的, 不会把决策系统风控 (source="signal") 停的顺手放开; 页面上人停的同理。 cleared=False 且 ok=True 表示没有可清的项 (没停过, 或来源不匹配), 不算错。""" if not ts_code: return {"ok": False, "cleared": False, "error": "ts_code 为空"} m = buypause_map() ent = m.get(ts_code) if not ent: return {"ok": True, "cleared": False} sources = dict(ent.get("sources") or {}) if not sources and ent.get("source"): # 旧格式条目 sources[ent["source"]] = {"reason": ent.get("reason") or "", "at": ent.get("at") or ""} if only_source: if only_source not in sources: return {"ok": True, "cleared": False, "why": f"暂停来源是 {sorted(sources) or [ent.get('source')]}, " f"不动 (只清 {only_source})"} sources.pop(only_source, None) if sources: # 还有别的来源在停 (比如风控): 只摘掉自己的, 票保持暂停 left_src, left = sorted(sources.items())[0] m[ts_code] = {"reason": left.get("reason") or "", "source": left_src, "at": left.get("at") or "", "sources": sources} r = _save_buypause(m) if not r.get("ok"): return {"ok": False, "cleared": False, "error": r.get("error")} return {"ok": True, "cleared": True, "still_paused_by": sorted(sources), "ts_code": ts_code} m.pop(ts_code, None) r = _save_buypause(m) if not r.get("ok"): return {"ok": False, "cleared": False, "error": r.get("error")} return {"ok": True, "cleared": True, "ts_code": ts_code}