tradingSystem/app/core/command_spec.py

441 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
命令目录 · 参数校验 · 命令状态机 (纯逻辑, 无外部依赖, 可单测)
================================================================
对应设计 POSITION_MGMT_DESIGN.md §3.1 (命令场景目录, 已定「不分期、全部实现」)
与 §11 (pms_command)、§13 (命令冲突由用户裁决)。
三类命令 (cls):
param 参数命令 —— 改变长期约束, 立即生效并持久化, 状态 EFFECTIVE/SUPERSEDED。
全局参数落 pms_runtime_param (param_key); 个股参数以 pms_command 最新
EFFECTIVE 记录为事实源 (设计 §11 原话), 同时投影到 pms_position 便于规则闸速读。
task 任务命令 —— 有生命周期的一次性使命, 状态 PENDING→PLANNING→EXECUTING→DONE/PARTIAL/CANCELLED。
task(instant=True) 即时任务 —— 有真实动作 (撤在途买入/改窗口/撤命令) 但无执行窗口,
规划完即 DONE。设计把「全局暂停买入」等归在任务命令表下, 故保留 task 归类。
本模块只做「目录 + 校验 + 状态迁移 + 冲突识别」, 不碰数据库、不生成方案 (方案见 planner.py)。
"""
from __future__ import annotations
CLS_PARAM = "param"
CLS_TASK = "task"
# ---- 字段类型 ----
F_MONEY = "money" # 金额 (元), > 0
F_PCT = "pct" # 比例, 0~1; 接受 "10%" 字符串或 0.1 浮点
F_INT = "int"
F_FLOAT = "float"
F_PRICE = "price" # 价格 (元), > 0
F_CODE = "code" # Tushare 点式 600000.SH
F_ENUM = "enum"
F_STR = "str"
# ---- 状态 ----
ST_PENDING = "PENDING"
ST_EFFECTIVE = "EFFECTIVE"
ST_SUPERSEDED = "SUPERSEDED"
ST_PLANNING = "PLANNING"
ST_EXECUTING = "EXECUTING"
ST_PARTIAL = "PARTIAL"
ST_DONE = "DONE"
ST_CANCELLED = "CANCELLED"
# 状态迁移表 (设计 §2: 待处理→规划中→执行中→完成/撤销)
TRANSITIONS = {
(CLS_PARAM, ST_PENDING): {ST_EFFECTIVE, ST_CANCELLED},
(CLS_PARAM, ST_EFFECTIVE): {ST_SUPERSEDED},
(CLS_PARAM, ST_SUPERSEDED): set(),
(CLS_PARAM, ST_CANCELLED): set(),
(CLS_TASK, ST_PENDING): {ST_PLANNING, ST_CANCELLED},
(CLS_TASK, ST_PLANNING): {ST_EXECUTING, ST_DONE, ST_CANCELLED},
(CLS_TASK, ST_EXECUTING): {ST_PARTIAL, ST_DONE, ST_CANCELLED},
(CLS_TASK, ST_PARTIAL): {ST_EXECUTING, ST_DONE, ST_CANCELLED}, # 顺延一日 → 回执行中
(CLS_TASK, ST_DONE): set(),
(CLS_TASK, ST_CANCELLED): set(),
}
TERMINAL = {ST_SUPERSEDED, ST_DONE, ST_CANCELLED}
ACTIVE_TASK_STATES = {ST_PENDING, ST_PLANNING, ST_EXECUTING, ST_PARTIAL}
def _f(ftype, required=True, **kw):
d = {"type": ftype, "required": required}
d.update(kw)
return d
# =====================================================================
# 命令目录 (设计 §3.1 A/B/C 三组全量)
# =====================================================================
SPECS = {
# ---------------- A. 资金与总体参数 (参数命令, 全局) ----------------
"SET_SCALE": {
"cls": CLS_PARAM, "label": "设定总规模", "group": "A", "scope": "global",
"fields": {"scale": _f(F_MONEY, min=0)},
"param_key": "PMS_TOTAL_SCALE", "value_field": "scale",
"note": "注资/抽资即改此值; 抽资导致超限由降仓提议消化, 不自动强平",
},
"SET_PORTFOLIO_CAP": {
"cls": CLS_PARAM, "label": "设定总仓上限", "group": "A", "scope": "global",
"fields": {"cap": _f(F_PCT, min=0, max=1)},
"param_key": "PMS_PORTFOLIO_CAP", "value_field": "cap",
"note": "调低后超限 → 生成降仓提议供确认",
},
"SET_STOCK_CAP": {
"cls": CLS_PARAM, "label": "设定单股上限", "group": "A", "scope": "global",
"fields": {"cap": _f(F_PCT, min=0, max=1)},
"param_key": "PMS_STOCK_CAP", "value_field": "cap",
},
"SET_STOCK_TARGET": {
"cls": CLS_PARAM, "label": "设定默认单股目标仓位", "group": "A", "scope": "global",
"fields": {"target": _f(F_PCT, min=0, max=1)},
"param_key": "PMS_STOCK_TARGET_DEFAULT", "value_field": "target",
},
"SET_MAX_NAMES": {
"cls": CLS_PARAM, "label": "设定最大持仓数", "group": "A", "scope": "global",
"fields": {"n": _f(F_INT, min=1, max=200)},
"param_key": "PMS_MAX_NAMES", "value_field": "n",
},
"SET_AUTONOMY": {
"cls": CLS_PARAM, "label": "设定自主档位", "group": "A", "scope": "global",
"fields": {"mode": _f(F_ENUM, choices=["full", "propose_only", "off"])},
"param_key": "PMS_AUTONOMY", "value_field": "mode",
},
"SET_CASH_RESERVE": {
"cls": CLS_PARAM, "label": "设定预留现金比例", "group": "A", "scope": "global",
"fields": {"ratio": _f(F_PCT, min=0, max=1)},
"param_key": "PMS_CASH_RESERVE", "value_field": "ratio",
"note": "与总仓上限双重约束",
},
# ---------------- B. 组合级动作 (任务命令) ----------------
"REDUCE_EXPOSURE": {
"cls": CLS_TASK, "label": "降仓", "group": "B", "scope": "global",
"fields": {"pct": _f(F_PCT, min=0, max=1),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "reduce_exposure",
"note": "释放金额 = 规模×pct; 优先级 停新买→清弱票→收利润→等比微减",
},
"INCREASE_EXPOSURE": {
"cls": CLS_TASK, "label": "升仓", "group": "B", "scope": "global",
"fields": {"pct": _f(F_PCT, min=0, max=1),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "increase_exposure",
"note": "来源: 既有持仓垫厚票补到目标 + 上游候选池新票建仓",
},
"HALT_BUY": {
"cls": CLS_TASK, "label": "全局暂停买入", "group": "B", "scope": "global",
"fields": {}, "planner": "halt_buy", "instant": True,
"switch_key": "PMS_GLOBAL_BUY_HALT", "switch_value": True,
"note": "停止一切新增买入并撤销在途买入指令; 卖出与止损不受影响",
},
"RESUME_BUY": {
"cls": CLS_TASK, "label": "恢复买入", "group": "B", "scope": "global",
"fields": {}, "planner": "noop", "instant": True,
"switch_key": "PMS_GLOBAL_BUY_HALT", "switch_value": False,
},
"HALT_ALL": {
"cls": CLS_TASK, "label": "全局暂停执行(休假模式)", "group": "B", "scope": "global",
"fields": {}, "planner": "halt_all", "instant": True,
"switch_key": "PMS_GLOBAL_EXEC_HALT", "switch_value": True,
"note": "所有自动动作暂停, 仅保留账本对账与日报",
},
"RESUME_ALL": {
"cls": CLS_TASK, "label": "恢复执行", "group": "B", "scope": "global",
"fields": {}, "planner": "noop", "instant": True,
"switch_key": "PMS_GLOBAL_EXEC_HALT", "switch_value": False,
},
"LIQUIDATE_ALL": {
"cls": CLS_TASK, "label": "一键清仓(紧急)", "group": "B", "scope": "global",
"fields": {"window_tdays": _f(F_INT, required=False, min=1, max=5, default=1),
"confirm": _f(F_ENUM, required=True, choices=["YES"])},
"planner": "liquidate_all", "danger": True,
"note": "全部持仓按最快节奏卖出, 不做择时优化; 页面二次确认 (confirm=YES)",
},
"SECTOR_EXIT": {
"cls": CLS_TASK, "label": "清仓某行业", "group": "B", "scope": "sector",
"fields": {"sector": _f(F_STR),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "sector_exit", "needs_sector_source": True,
},
"SECTOR_CAP": {
"cls": CLS_TASK, "label": "限制某行业上限", "group": "B", "scope": "sector",
"fields": {"sector": _f(F_STR), "cap": _f(F_PCT, min=0, max=1),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "sector_cap", "instant": False, "needs_sector_source": True,
"note": "写入行业上限参数; 当前已超限则同时生成减仓方案",
},
# ---------------- C. 个股级动作 ----------------
"OPEN_TARGET": {
"cls": CLS_TASK, "label": "建仓某股至目标%", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE), "target_pct": _f(F_PCT, min=0, max=1),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "open_target",
"note": "分批投放 50/25/25, 含一手合并; 上限与行业硬拦截在规划期即校验",
},
"EXIT_STOCK": {
"cls": CLS_TASK, "label": "清仓某股", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "exit_stock",
},
"REDUCE_STOCK": {
"cls": CLS_TASK, "label": "减至 X%", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE), "target_pct": _f(F_PCT, min=0, max=1),
"window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
"planner": "reduce_stock",
},
"FREEZE_STOCK": {
"cls": CLS_PARAM, "label": "冻结某股", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
"projection": {"frozen_reason": "COMMAND_HALT"},
"note": "停止该股一切增持(在途买入撤销), 卖出不受影响",
},
"UNFREEZE_STOCK": {
"cls": CLS_PARAM, "label": "解冻某股", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
"projection": {"frozen_reason": "NONE"},
},
"T0_ENABLE": {
"cls": CLS_PARAM, "label": "做T授权", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE), "t_ratio": _f(F_PCT, min=0, max=0.333)},
"projection": {"t0_enabled": 1},
"note": "T 仓 = 持仓 × t_ratio, 硬上限 1/3",
},
"T0_DISABLE": {
"cls": CLS_PARAM, "label": "取消做T授权", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
"projection": {"t0_enabled": 0},
},
"SET_STOP_PRICE": {
"cls": CLS_PARAM, "label": "设定某股止损价", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE), "price": _f(F_PRICE, min=0)},
"projection": {"stop_ref": "@price", "ref_source": "user"},
"note": "覆盖系统参考位; 触发即生成卖出方案",
},
"SET_TARGET_PRICE": {
"cls": CLS_PARAM, "label": "设定某股目标价", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE), "price": _f(F_PRICE, min=0)},
"note": "触发即生成止盈提议 (事实源为本命令最新 EFFECTIVE 记录)",
},
"BLACKLIST_ADD": {
"cls": CLS_PARAM, "label": "加入黑名单(永不买入)", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
},
"BLACKLIST_REMOVE": {
"cls": CLS_PARAM, "label": "移出黑名单", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
},
"WHITELIST_ADD": {
"cls": CLS_PARAM, "label": "加入白名单(优先规划)", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
},
"WHITELIST_REMOVE": {
"cls": CLS_PARAM, "label": "移出白名单", "group": "C", "scope": "stock",
"fields": {"ts_code": _f(F_CODE)},
},
# ---------------- C'. 对在途命令的操作 ----------------
"ADJUST_WINDOW": {
"cls": CLS_TASK, "label": "调整命令窗口", "group": "C", "scope": "command",
"fields": {"target_command_id": _f(F_STR), "window_tdays": _f(F_INT, min=1, max=20)},
"planner": "adjust_window", "instant": True,
},
"CANCEL_COMMAND": {
"cls": CLS_TASK, "label": "撤销命令", "group": "C", "scope": "command",
"fields": {"target_command_id": _f(F_STR)},
"planner": "cancel_command", "instant": True,
},
}
# 互斥/冲突规则: (A 类型集合, B 类型集合, 作用域, 说明)
# 同一作用域内 A 与 B 同时在途 → 冲突, 由用户在页面裁决 (设计 §13, 系统不代替仲裁)
_CONFLICT_PAIRS = [
({"REDUCE_EXPOSURE"}, {"INCREASE_EXPOSURE"}, "global", "组合降仓与升仓方向相反"),
({"LIQUIDATE_ALL"}, {"INCREASE_EXPOSURE", "OPEN_TARGET"}, "global", "一键清仓在途, 不应再建仓"),
({"HALT_BUY"}, {"INCREASE_EXPOSURE", "OPEN_TARGET"}, "global", "全局暂停买入生效中"),
({"HALT_ALL"}, {"REDUCE_EXPOSURE", "INCREASE_EXPOSURE", "OPEN_TARGET",
"EXIT_STOCK", "REDUCE_STOCK", "LIQUIDATE_ALL"}, "global", "休假模式生效中"),
({"OPEN_TARGET"}, {"EXIT_STOCK", "REDUCE_STOCK"}, "stock", "同股建仓与减持方向相反"),
({"EXIT_STOCK"}, {"OPEN_TARGET", "REDUCE_STOCK"}, "stock", "同股清仓在途"),
({"FREEZE_STOCK"}, {"OPEN_TARGET"}, "stock", "该股已被命令冻结(禁增持)"),
]
def list_commands(group=None, sector_source_ready=True):
"""页面命令台目录。sector_source_ready=False 时行业类命令置灰 (设计 §5)。"""
out = []
for t, s in SPECS.items():
if group and s["group"] != group:
continue
item = {
"cmd_type": t, "cls": s["cls"], "label": s["label"], "group": s["group"],
"scope": s["scope"], "fields": s["fields"], "note": s.get("note", ""),
"danger": bool(s.get("danger")), "instant": bool(s.get("instant")),
"disabled": bool(s.get("needs_sector_source")) and not sector_source_ready,
}
if item["disabled"]:
item["disabled_reason"] = "行业划分数据源未配置 (PMS_SECTOR_SOURCE 为空)"
out.append(item)
return out
# =====================================================================
# 参数校验与归一
# =====================================================================
def _norm_pct(v):
if isinstance(v, str):
s = v.strip()
if s.endswith("%"):
return float(s[:-1]) / 100.0
return float(s)
return float(v)
def normalize_code(code: str) -> str:
"""归一为 Tushare 点式。接受 600000.SH / SH600000 / 600000 (按交易所规则补后缀)。"""
c = (code or "").strip().upper()
if not c:
return ""
if "." in c:
num, mkt = c.split(".", 1)
return f"{num}.{mkt}"
if c[:2] in ("SH", "SZ", "BJ") and c[2:].isdigit():
return f"{c[2:]}.{c[:2]}"
if c.isdigit() and len(c) == 6:
if c[0] == "6":
return f"{c}.SH"
if c[0] in "03":
return f"{c}.SZ"
if c[0] in "48":
return f"{c}.BJ"
return c
def validate(cmd_type: str, params: dict) -> tuple:
"""校验并归一命令参数。返回 (normalized_params, errors)。errors 非空即拒绝下达。"""
spec = SPECS.get(cmd_type)
if not spec:
return {}, [f"UNKNOWN_CMD: 未知命令类型 {cmd_type}"]
params = params or {}
out, errs = {}, []
for name, f in spec["fields"].items():
raw = params.get(name)
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
if f.get("required", True):
errs.append(f"MISSING: 缺少参数 {name}")
elif "default" in f:
out[name] = f["default"]
continue
t = f["type"]
try:
if t == F_PCT:
v = _norm_pct(raw)
elif t in (F_MONEY, F_PRICE, F_FLOAT):
v = float(raw)
elif t == F_INT:
v = int(float(raw))
elif t == F_CODE:
v = normalize_code(str(raw))
if not v or "." not in v:
errs.append(f"BAD_CODE: 股票代码无法归一为点式 ({raw})")
continue
elif t == F_ENUM:
v = str(raw).strip()
if v not in f["choices"]:
errs.append(f"BAD_ENUM: {name} 应为 {f['choices']} 之一, 收到 {raw}")
continue
else:
v = str(raw).strip()
except (TypeError, ValueError):
errs.append(f"BAD_TYPE: {name} 无法解析为 {t} ({raw})")
continue
if isinstance(v, (int, float)) and not isinstance(v, bool):
if "min" in f and v < f["min"]:
errs.append(f"OUT_OF_RANGE: {name}={v} 小于下限 {f['min']}")
if "max" in f and v > f["max"]:
errs.append(f"OUT_OF_RANGE: {name}={v} 大于上限 {f['max']}")
if t in (F_MONEY, F_PRICE) and v <= 0:
errs.append(f"OUT_OF_RANGE: {name} 必须为正数")
if t == F_PCT and cmd_type in ("REDUCE_EXPOSURE", "INCREASE_EXPOSURE") and v <= 0:
errs.append(f"OUT_OF_RANGE: {name} 必须大于 0")
out[name] = v
unknown = set(params) - set(spec["fields"])
if unknown:
errs.append(f"UNKNOWN_FIELD: 多余参数 {sorted(unknown)}")
return out, errs
# =====================================================================
# 状态机
# =====================================================================
def can_transition(cls: str, frm: str, to: str) -> bool:
return to in TRANSITIONS.get((cls, frm), set())
def transition(cls: str, frm: str, to: str) -> str:
"""迁移状态; 非法迁移抛 ValueError (调用方须捕获并落 ERROR 日志)。"""
if not can_transition(cls, frm, to):
raise ValueError(f"非法状态迁移: {cls} {frm}{to}")
return to
def initial_status(cls: str) -> str:
return ST_PENDING
def settle_task_status(target_amount: float, done_amount: float, window_over: bool,
eps: float = 1e-6) -> str:
"""任务命令进度结算 (设计 §3.2 第4步): 达标 DONE; 窗口末仍未完成 PARTIAL; 否则继续 EXECUTING。"""
if target_amount is None or target_amount <= 0:
return ST_DONE
if done_amount + eps >= target_amount:
return ST_DONE
return ST_PARTIAL if window_over else ST_EXECUTING
# =====================================================================
# 冲突检测 (系统只识别与提示, 裁决权在用户 —— 设计 §13)
# =====================================================================
def detect_conflicts(new_cmd: dict, active_cmds: list) -> list:
"""new_cmd: {cmd_type, ts_code?, params?}; active_cmds: 在途/生效中的命令列表
(task 取 PENDING/PLANNING/EXECUTING/PARTIAL, param 取 EFFECTIVE)。
返回 [{"with_command_id","with_cmd_type","scope","reason"}...], 空 = 无冲突。"""
t = new_cmd.get("cmd_type")
code = normalize_code(new_cmd.get("ts_code") or "")
out = []
for a in active_cmds or []:
at = a.get("cmd_type")
acode = normalize_code(a.get("ts_code") or "")
for set_a, set_b, scope, why in _CONFLICT_PAIRS:
hit = (t in set_a and at in set_b) or (t in set_b and at in set_a)
if not hit:
continue
if scope == "stock" and (not code or code != acode):
continue
out.append({"with_command_id": a.get("command_id"), "with_cmd_type": at,
"scope": scope, "reason": why})
break
else:
# 同类型同标的重复下达 (参数命令不算冲突, 属于覆盖)
if t == at and SPECS.get(t, {}).get("cls") == CLS_TASK and code == acode:
out.append({"with_command_id": a.get("command_id"), "with_cmd_type": at,
"scope": SPECS[t]["scope"], "reason": "同类型命令已在途, 重复下达"})
return out
def make_command_id(ymd: int, seq: int) -> str:
return f"CMD_{ymd}_{seq:04d}"
def make_plan_id(command_id: str, seq: int) -> str:
return f"PLAN_{command_id}_{seq:03d}"
def make_instruction_id(ymd: int, ts_code: str, action: str, seq: int) -> str:
return f"INS_{ymd}_{ts_code.replace('.', '')}_{action}_{seq:03d}"