441 lines
20 KiB
Python
441 lines
20 KiB
Python
|
|
# -*- 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}"
|