tradingSystem/app/services/command_service.py

497 lines
22 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""
命令服务: 下达 校验 冲突识别 生效/规划 进度推进
==========================================================
设计对应: §3 命令系统§3.2 降仓全流程§13 命令冲突由用户裁决
铁律落点:
* 命令至上 命令类动作只过规则闸 (上限/一手/行业), 不送研判闸
* 先记账后动作 命令与方案先落表, 指令下发由择时执行器另行负责 (下一批)
* 故障即守成 任何一步失败都返回 ok=False 并保留已落表内容, 不产生新指令
参数命令的事实源:
全局参数 pms_runtime_param (ParamStore 读取, 页面即时生效)
个股参数 pms_command 最新 EFFECTIVE 记录 (设计 §11 原话), 同时投影到 pms_position
"""
from __future__ import annotations
import logging
from datetime import datetime
from app.core import command_spec as cs
from app.core import planner as pl
from app.core import tradedays as td
from app.repo import pms_repo
from app.services import industry, param_store, portfolio
logger = logging.getLogger("pms.command")
# 在途 (可被撤销/冲突判定) 的指令状态
LIVE_INSTR = ("PROPOSED", "RULE_PASSED", "JUDGE_PASSED", "DISPATCHED")
STOCK_PARAM_TYPES = ("FREEZE_STOCK", "UNFREEZE_STOCK", "T0_ENABLE", "T0_DISABLE",
"SET_STOP_PRICE", "SET_TARGET_PRICE", "BLACKLIST_ADD", "BLACKLIST_REMOVE",
"WHITELIST_ADD", "WHITELIST_REMOVE")
# ================================================================ 查询
def active_commands() -> list:
"""在途任务命令 + 生效中参数命令 (冲突判定与页面「在途」列表共用)。"""
return pms_repo.list_commands(
statuses=[cs.ST_PENDING, cs.ST_PLANNING, cs.ST_EXECUTING, cs.ST_PARTIAL,
cs.ST_EFFECTIVE], limit=300)
def effective_stock_params() -> dict:
"""个股参数命令当前值: {ts_code: {frozen, t0, stop_price, target_price, black, white}}"""
rows = pms_repo.list_effective_stock_params(STOCK_PARAM_TYPES)
out = {}
for r in sorted(rows, key=lambda x: x["id"]): # 旧 → 新, 后者覆盖前者
code = r.get("ts_code")
if not code:
continue
d = out.setdefault(code, {})
t, p = r["cmd_type"], r.get("params") or {}
if t == "FREEZE_STOCK":
d["frozen"] = True
elif t == "UNFREEZE_STOCK":
d["frozen"] = False
elif t == "T0_ENABLE":
d["t0"] = True
d["t_ratio"] = p.get("t_ratio")
elif t == "T0_DISABLE":
d["t0"] = False
elif t == "SET_STOP_PRICE":
d["stop_price"] = p.get("price")
elif t == "SET_TARGET_PRICE":
d["target_price"] = p.get("price")
elif t == "BLACKLIST_ADD":
d["black"] = True
elif t == "BLACKLIST_REMOVE":
d["black"] = False
elif t == "WHITELIST_ADD":
d["white"] = True
elif t == "WHITELIST_REMOVE":
d["white"] = False
return out
def blacklist() -> set:
return {c for c, d in effective_stock_params().items() if d.get("black")}
def whitelist() -> set:
return {c for c, d in effective_stock_params().items() if d.get("white")}
# ================================================================ 下达
def issue(cmd_type: str, params: dict, *, note=None, issued_by="user",
force_conflict: bool = False) -> dict:
"""下达一条命令。返回 {ok, command_id, errors, conflicts, plan}。"""
spec = cs.SPECS.get(cmd_type)
if not spec:
return {"ok": False, "errors": [f"UNKNOWN_CMD: {cmd_type}"]}
norm, errors = cs.validate(cmd_type, params or {})
if errors:
return {"ok": False, "errors": errors}
if spec.get("needs_sector_source") and not industry.ready():
return {"ok": False, "errors": [
"SECTOR_SOURCE_OFF: 行业划分数据源未配置 (PMS_SECTOR_SOURCE 为空), 行业类命令不可用"]}
ts_code = norm.get("ts_code")
try:
conflicts = cs.detect_conflicts({"cmd_type": cmd_type, "ts_code": ts_code},
active_commands())
except Exception as e:
logger.warning("冲突检测失败(按无冲突继续): %s", e)
conflicts = []
if conflicts and not force_conflict:
return {"ok": False, "errors": ["CONFLICT: 与在途命令冲突, 请裁决后重试 "
"(确认要并行可带 force=true 重下)"],
"conflicts": conflicts}
ymd = td.ymd()
try:
seq = pms_repo.next_command_seq(ymd)
command_id = cs.make_command_id(ymd, seq)
pms_repo.insert_command(command_id=command_id, cmd_class=spec["cls"], cmd_type=cmd_type,
ts_code=ts_code, params=norm, status=cs.ST_PENDING,
issued_by=issued_by, note=note)
except Exception as e:
logger.exception("命令落表失败")
return {"ok": False, "errors": [f"DB_ERROR: 命令落表失败 {type(e).__name__}: {e}"]}
if spec["cls"] == cs.CLS_PARAM:
res = _apply_param(command_id, cmd_type, spec, norm)
else:
res = plan_command(pms_repo.get_command(command_id))
res.setdefault("command_id", command_id)
res["conflicts"] = conflicts
return res
def _apply_param(command_id: str, cmd_type: str, spec: dict, norm: dict) -> dict:
"""参数命令: 立即生效 + 旧记录置 SUPERSEDED + (全局)写运行参数 / (个股)投影到账本。"""
ts_code = norm.get("ts_code")
applied = {}
try:
if spec["scope"] == "global":
key, val = spec["param_key"], norm[spec["value_field"]]
r = param_store.set_param(key, val, updated_by="command")
if not r.get("ok"):
pms_repo.update_command(command_id, status=cs.ST_CANCELLED,
note=f"参数写入失败: {r.get('error')}")
return {"ok": False, "errors": [r.get("error")]}
applied[key] = val
else:
_project_stock_param(cmd_type, spec, norm, ts_code)
applied = dict(norm)
pms_repo.supersede_param_commands(cmd_type, ts_code, keep_command_id=command_id)
_supersede_opposites(cmd_type, ts_code)
pms_repo.update_command(command_id, status=cs.ST_EFFECTIVE, progress={"applied": applied},
done_at=datetime.now())
return {"ok": True, "status": cs.ST_EFFECTIVE, "applied": applied, "plan": None}
except Exception as e:
logger.exception("参数命令生效失败")
pms_repo.update_command(command_id, status=cs.ST_CANCELLED,
note=f"生效失败: {type(e).__name__}: {e}")
return {"ok": False, "errors": [f"APPLY_ERROR: {type(e).__name__}: {e}"]}
_OPPOSITE = {"FREEZE_STOCK": "UNFREEZE_STOCK", "UNFREEZE_STOCK": "FREEZE_STOCK",
"T0_ENABLE": "T0_DISABLE", "T0_DISABLE": "T0_ENABLE",
"BLACKLIST_ADD": "BLACKLIST_REMOVE", "BLACKLIST_REMOVE": "BLACKLIST_ADD",
"WHITELIST_ADD": "WHITELIST_REMOVE", "WHITELIST_REMOVE": "WHITELIST_ADD"}
def _supersede_opposites(cmd_type: str, ts_code):
opp = _OPPOSITE.get(cmd_type)
if opp and ts_code:
pms_repo.supersede_param_commands(opp, ts_code)
def _project_stock_param(cmd_type: str, spec: dict, norm: dict, ts_code: str):
"""把个股参数命令投影到 pms_position, 供规则闸速读 (事实源仍是命令表)。"""
proj = spec.get("projection")
if not proj or not ts_code:
return
pms_repo.ensure_position(ts_code)
fields = {}
for k, v in proj.items():
fields[k] = norm.get(str(v)[1:]) if isinstance(v, str) and v.startswith("@") else v
if cmd_type == "T0_ENABLE":
fields["t0_ratio"] = norm.get("t_ratio")
pms_repo.update_position(ts_code, **fields)
# ================================================================ 规划
def plan_command(cmd: dict) -> dict:
"""任务命令 → 方案落表。命令状态推进到 EXECUTING (即时任务直接 DONE)。"""
if not cmd:
return {"ok": False, "errors": ["命令不存在"]}
cmd_type, command_id = cmd["cmd_type"], cmd["command_id"]
spec = cs.SPECS.get(cmd_type, {})
p = cmd.get("params") or {}
try:
pms_repo.update_command(command_id, status=cs.ST_PLANNING)
except Exception as e:
return {"ok": False, "errors": [f"DB_ERROR: {e}"]}
try:
result = _dispatch_planner(cmd_type, p, cmd)
except Exception as e:
logger.exception("方案生成失败 %s", command_id)
pms_repo.update_command(command_id, status=cs.ST_CANCELLED,
note=f"方案生成失败: {type(e).__name__}: {e}")
return {"ok": False, "command_id": command_id,
"errors": [f"PLAN_ERROR: {type(e).__name__}: {e}"]}
items = result.get("items") or []
window = int(p.get("window_tdays") or param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3))
deadline = td.window_deadline(datetime.now().date(), window)
rows = []
for i, it in enumerate(items, start=1):
rows.append({"plan_id": cs.make_plan_id(command_id, i), "command_id": command_id,
"ts_code": it["ts_code"], "action": it["action"], "qty": it.get("qty"),
"amount": it.get("amount"), "priority": it.get("priority", 100),
"deadline": deadline,
# GATED = 建仓的回踩补足/盈利加仓批, 不随命令立即执行,
# 等动作引擎按条件解锁后才转 PENDING (设计 §6)
"status": "GATED" if it.get("gated") else "PENDING",
"reason": it.get("reason")})
try:
if rows:
pms_repo.insert_plans(rows)
except Exception as e:
logger.exception("方案落表失败 %s", command_id)
pms_repo.update_command(command_id, status=cs.ST_CANCELLED,
note=f"方案落表失败: {type(e).__name__}: {e}")
return {"ok": False, "command_id": command_id, "errors": [f"DB_ERROR: {e}"]}
# 撤单类动作立刻执行 (撤销在途买入/全部在途指令)
cancelled = _cancel_marked_instructions(items)
# 开关类命令写运行参数
if spec.get("switch_key") is not None:
param_store.set_param(spec["switch_key"], spec.get("switch_value"), "command")
progress = {"target_amount": result.get("target_amount", 0.0),
"planned_amount": result.get("planned_amount", 0.0),
"done_amount": 0.0, "gap": result.get("gap", 0.0),
"plan_count": len(rows), "deadline": str(deadline),
"notes": result.get("notes", []), "rejects": result.get("rejects", []),
"cancelled_instructions": cancelled}
instant = bool(spec.get("instant")) or not rows
status = cs.ST_DONE if instant else cs.ST_EXECUTING
pms_repo.update_command(command_id, status=status, progress=progress,
done_at=datetime.now() if status == cs.ST_DONE else None)
_ledger_rejects(result.get("rejects") or [], command_id)
return {"ok": True, "command_id": command_id, "status": status, "plan": progress,
"items": items, "errors": []}
def _dispatch_planner(cmd_type: str, p: dict, cmd: dict) -> dict:
"""按命令类型调用对应的方案生成器 (纯逻辑在 core/planner.py)。"""
view = portfolio.positions_view()
sp = view["params"]
positions = view["held"]
scale = float(sp["scale"] or 0)
exclude = _codes_with_live_plans()
if cmd_type == "REDUCE_EXPOSURE":
return pl.plan_reduce_exposure(
release_amount=scale * float(p["pct"]), positions=positions,
pending_buys=_pending_buys(), params={"weak_neg_days": sp["weak_neg_days"]},
exclude_codes=exclude)
if cmd_type == "INCREASE_EXPOSURE":
return pl.plan_increase_exposure(
add_amount=scale * float(p["pct"]), positions=positions,
candidates=_candidates(view), ctx=portfolio.caps_ctx(view), params=sp)
if cmd_type in ("HALT_BUY",):
return pl.plan_halt_buy(pending_buys=_pending_buys())
if cmd_type in ("HALT_ALL",):
return pl.plan_halt_all(pending_instructions=_pending_instructions())
if cmd_type in ("RESUME_BUY", "RESUME_ALL"):
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": ["开关已恢复"], "rejects": []}
if cmd_type == "LIQUIDATE_ALL":
return pl.plan_liquidate_all(positions=positions, pending_buys=_pending_buys())
if cmd_type == "SECTOR_EXIT":
return pl.plan_sector_exit(sector=p["sector"], positions=positions)
if cmd_type == "SECTOR_CAP":
param_store.set_param(f"PMS_SECTOR_CAP_{p['sector']}", float(p["cap"]), "command")
return pl.plan_sector_cap(sector=p["sector"], cap=float(p["cap"]), positions=positions)
if cmd_type == "OPEN_TARGET":
code = p["ts_code"]
px = _price_of(view, code)
if not px:
return {"ok": False, "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [], "rejects": [
{"ts_code": code, "reasons": ["PRICE_MISSING: 取不到现价, 本轮不建仓"]}]}
return pl.plan_open_target(ts_code=code, target_pct=float(p["target_pct"]), price=px,
ctx=portfolio.caps_ctx(view, ts_code=code), params=sp)
if cmd_type == "EXIT_STOCK":
return pl.plan_exit_stock(ts_code=p["ts_code"], position=_pos_of(view, p["ts_code"]))
if cmd_type == "REDUCE_STOCK":
return pl.plan_reduce_stock(ts_code=p["ts_code"], target_pct=float(p["target_pct"]),
position=_pos_of(view, p["ts_code"]), scale=scale)
if cmd_type == "ADJUST_WINDOW":
return _adjust_window(p)
if cmd_type == "CANCEL_COMMAND":
return _cancel_target(p)
return {"ok": False, "items": [], "target_amount": 0.0, "planned_amount": 0.0, "gap": 0.0,
"notes": [f"命令 {cmd_type} 暂无对应方案生成器"], "rejects": []}
def _adjust_window(p: dict) -> dict:
target = pms_repo.get_command(p["target_command_id"])
if not target:
return {"ok": False, "items": [], "notes": ["目标命令不存在"], "rejects": [],
"target_amount": 0.0, "planned_amount": 0.0, "gap": 0.0}
dl = td.window_deadline(datetime.now().date(), int(p["window_tdays"]))
pms_repo.set_plans_deadline(target["command_id"], dl)
prog = dict(target.get("progress") or {})
prog["deadline"] = str(dl)
pms_repo.update_command(target["command_id"], progress=prog)
return {"ok": True, "items": [], "target_amount": 0.0, "planned_amount": 0.0, "gap": 0.0,
"notes": [f"命令 {target['command_id']} 窗口调整为 {p['window_tdays']} 交易日 "
f"(截止 {dl})"], "rejects": []}
def _cancel_target(p: dict) -> dict:
r = cancel(p["target_command_id"])
return {"ok": r.get("ok", False), "items": [], "target_amount": 0.0, "planned_amount": 0.0,
"gap": 0.0, "notes": [r.get("message", "")], "rejects": []}
# ================================================================ 撤销与进度
def cancel(command_id: str) -> dict:
"""撤销在途任务命令: 命令置 CANCELLED, 方案作废, 在途指令撤回 (设计: 在途自主指令自动撤销)。"""
cmd = pms_repo.get_command(command_id)
if not cmd:
return {"ok": False, "message": f"命令 {command_id} 不存在"}
if cmd["cmd_class"] != cs.CLS_TASK:
return {"ok": False, "message": "参数命令不可撤销, 请下达新的参数命令覆盖"}
if cmd["status"] not in cs.ACTIVE_TASK_STATES:
return {"ok": False, "message": f"命令处于 {cmd['status']}, 不可撤销"}
plans = pms_repo.list_plans(command_id=command_id, statuses=["PENDING", "EXECUTING"])
n_plan = pms_repo.cancel_plans_of_command(command_id)
n_ins = 0
plan_ids = {p["plan_id"] for p in plans}
for ins in pms_repo.list_instructions(statuses=list(LIVE_INSTR), limit=500):
if ins.get("origin_type") == "plan" and ins.get("origin_id") in plan_ids:
pms_repo.update_instruction(ins["instruction_id"], status="CANCELLED")
n_ins += 1
pms_repo.update_command(command_id, status=cs.ST_CANCELLED, done_at=datetime.now())
return {"ok": True, "message": f"命令 {command_id} 已撤销 (作废方案 {n_plan} 条, "
f"撤回指令 {n_ins} 条)"}
def plan_pending(limit: int = 20) -> dict:
"""调度器每分钟一跳: 把 PENDING 的任务命令推进到 EXECUTING (幂等)。"""
done, errs = [], []
for c in pms_repo.list_commands(statuses=[cs.ST_PENDING], cmd_class=cs.CLS_TASK,
limit=limit):
r = plan_command(c)
(done if r.get("ok") else errs).append(c["command_id"])
return {"planned": done, "failed": errs}
def refresh_progress(command_id=None) -> dict:
"""按方案成交进度刷新命令进度与状态 (窗口末未达标 → PARTIAL)。"""
cmds = ([pms_repo.get_command(command_id)] if command_id else
pms_repo.list_commands(statuses=[cs.ST_EXECUTING, cs.ST_PARTIAL],
cmd_class=cs.CLS_TASK, limit=100))
out = []
today = datetime.now().date()
for c in [x for x in cmds if x]:
plans = pms_repo.list_plans(command_id=c["command_id"])
done_amt = 0.0
for p in plans:
qty, amt, filled = int(p.get("qty") or 0), float(p.get("amount") or 0), \
int(p.get("filled_qty") or 0)
if qty > 0 and filled > 0:
done_amt += amt * min(1.0, filled / qty)
prog = dict(c.get("progress") or {})
prog["done_amount"] = round(done_amt, 2)
dl = prog.get("deadline")
over = bool(dl) and str(today) > str(dl)[:10]
st = cs.settle_task_status(float(prog.get("target_amount") or 0), done_amt, over)
if st != c["status"] and cs.can_transition(cs.CLS_TASK, c["status"], st):
pms_repo.update_command(c["command_id"], status=st, progress=prog,
done_at=datetime.now() if st == cs.ST_DONE else None)
else:
pms_repo.update_command(c["command_id"], progress=prog)
out.append({"command_id": c["command_id"], "status": st,
"done_amount": prog["done_amount"],
"target_amount": prog.get("target_amount")})
return {"commands": out}
# ================================================================ 内部助手
def _pending_buys() -> list:
rows = pms_repo.list_instructions(statuses=list(LIVE_INSTR), side="buy", limit=500)
return [{"instruction_id": r["instruction_id"], "ts_code": r["ts_code"],
"qty": int(r.get("qty") or 0),
"amount": float(r.get("qty") or 0) * float(r.get("limit_price") or 0),
"side": "buy"} for r in rows]
def _pending_instructions() -> list:
rows = pms_repo.list_instructions(statuses=list(LIVE_INSTR), limit=500)
return [{"instruction_id": r["instruction_id"], "ts_code": r["ts_code"],
"qty": int(r.get("qty") or 0),
"amount": float(r.get("qty") or 0) * float(r.get("limit_price") or 0),
"side": r.get("side")} for r in rows]
def _cancel_marked_instructions(items: list) -> list:
out = []
for it in items or []:
iid = it.get("cancel_instruction_id")
if not iid:
continue
try:
pms_repo.update_instruction(iid, status="CANCELLED")
out.append(iid)
except Exception as e:
logger.warning("撤销指令失败 %s: %s", iid, e)
return out
def _codes_with_live_plans() -> list:
rows = pms_repo.list_plans(statuses=["PENDING", "EXECUTING"], limit=500)
return sorted({r["ts_code"] for r in rows})
def _pos_of(view: dict, ts_code: str) -> dict:
for x in view["positions"]:
if x["ts_code"] == ts_code:
return x
return {"ts_code": ts_code, "total_qty": 0, "price": 0.0}
def _price_of(view: dict, ts_code: str):
p = _pos_of(view, ts_code)
if p.get("price"):
return float(p["price"])
from app.services import market
return market.get_price(ts_code)
def _candidates(view: dict) -> list:
"""升仓候选池 = 上游买入计划 白名单, 剔除黑名单/已持有/冻结。"""
from app.repo import downstream_repo
held = {x["ts_code"] for x in view["held"]}
sp = effective_stock_params()
black = {c for c, d in sp.items() if d.get("black")}
out, seen = [], set()
try:
plans = downstream_repo.fetch_buy_plans(is_active=7, limit=100)
except Exception as e:
logger.warning("读上游买入计划失败: %s", e)
plans = []
for p in plans:
c = p["ts_code"]
if not c or c in held or c in black or c in seen:
continue
seen.add(c)
out.append({"ts_code": c, "price": p.get("price") or 0, "score": p.get("score") or 0,
"sector": industry.get(c), "src": "upstream"})
from app.services import market
for c, d in sp.items():
if not d.get("white") or c in held or c in black or c in seen:
continue
px = market.get_price(c)
if not px:
continue
seen.add(c)
out.append({"ts_code": c, "price": px, "score": 1.0, "sector": industry.get(c),
"src": "whitelist"})
return out
def _ledger_rejects(rejects: list, command_id: str):
"""被上限/行业拦下的候选也要留痕 —— 「拒了的后来涨了多少」是调参核心数据 (设计 §7)。"""
for r in rejects or []:
try:
pms_repo.insert_ledger(ts_code=r.get("ts_code") or "-", action="OPEN", arbiter="rule",
verdict="REJECT", price_at=0,
failed_checks=r.get("reasons"), ref_id=command_id,
reason="命令规划期规则闸拦截")
except Exception as e:
logger.warning("拒绝留痕失败: %s", e)