# -*- 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} # ================================================================ 买入暂停表 (决策系统风控预警) # 挂了策略的票, 决策系统的风控卖出只提示、不自动清仓 (强制离场会推翻你特意设的策略); # 这里把该票策略的**买入这一侧**暂停 —— 不平仓、不动卖出、可页面恢复。 # 用独立运行参数 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) 解除。""" if not ts_code: return [] m = buypause_map() if ts_code not in m: m[ts_code] = {"reason": (str(reason)[:300] if reason else ""), "source": source, "at": str(datetime.now())[:19]} _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}