137 lines
5.8 KiB
Python
137 lines
5.8 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""
|
||
|
|
个股交易方案 (策略) 管理侧: 校验 / 挂载 / 暂停撤下 —— PER_STOCK_STRATEGY_PLAN.md §五/§十
|
||
|
|
运行侧 (每分钟评估、下单) 见 strategy_runner.py。
|
||
|
|
|
||
|
|
挂载前**先过约束校验** (validate): 违反仓位 / 存量 / 上限就挡下、给明确中文原因、**不写库**
|
||
|
|
—— 落实「理论上不允许违反, 无法操作要在页面提示」。真正下单时的合规由 rule_gate 再兜一道 (双保险)。
|
||
|
|
校验不写库、不抛异常; 读持仓失败按「暂不能校验」挡下 (不放行未校验的挂载)。
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
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}
|