# -*- 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}"