2026-07-27 17:12:09 +08:00
|
|
|
|
# -*- 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,
|
2026-07-28 09:10:07 +08:00
|
|
|
|
# GATED = 建仓的回踩补足/盈利加仓批, 不随命令立即执行,
|
|
|
|
|
|
# 等动作引擎按条件解锁后才转 PENDING (设计 §6)
|
|
|
|
|
|
"status": "GATED" if it.get("gated") else "PENDING",
|
2026-07-27 17:12:09 +08:00
|
|
|
|
"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}"]}
|
|
|
|
|
|
|
|
|
|
|
|
# 撤单类动作立刻执行 (撤销在途买入/全部在途指令)
|
2026-07-31 16:10:58 +08:00
|
|
|
|
cancel_r = _cancel_marked_instructions(items)
|
|
|
|
|
|
cancelled = cancel_r["cancelled"]
|
|
|
|
|
|
cancel_failed = cancel_r["failed"]
|
|
|
|
|
|
|
|
|
|
|
|
# 开关类命令写运行参数。
|
|
|
|
|
|
# **这行返回值绝对不能丢**: HALT_BUY / HALT_ALL 的"刹车"就是这一次写入 —— 规则闸
|
|
|
|
|
|
# 每一跳都去 param_store 读 PMS_GLOBAL_BUY_HALT / PMS_GLOBAL_EXEC_HALT, 参数没写进去
|
|
|
|
|
|
# 就等于刹车没踩。而这类命令 spec.instant=True, 原来无论写没写成都直接置 DONE 并回
|
|
|
|
|
|
# ok=True —— 页面上"全局暂停买入 已完成", 买单照下。宁可让命令报失败, 也不能让用户
|
|
|
|
|
|
# 以为已经停了。(2026-07-31 静默失败专项)
|
|
|
|
|
|
switch_err = None
|
2026-07-27 17:12:09 +08:00
|
|
|
|
if spec.get("switch_key") is not None:
|
2026-07-31 16:10:58 +08:00
|
|
|
|
w = param_store.set_param(spec["switch_key"], spec.get("switch_value"), "command") or {}
|
|
|
|
|
|
if not w.get("ok"):
|
|
|
|
|
|
switch_err = f"{spec['switch_key']} 写入失败: {w.get('error')}"
|
|
|
|
|
|
logger.error("[命令] %s %s 开关参数没写进去 —— **该命令没有生效**: %s",
|
|
|
|
|
|
command_id, cmd_type, switch_err)
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
2026-07-31 14:36:50 +08:00
|
|
|
|
rejects = result.get("rejects") or []
|
2026-07-31 16:10:58 +08:00
|
|
|
|
notes = list(result.get("notes", []))
|
|
|
|
|
|
if switch_err:
|
|
|
|
|
|
notes.insert(0, f"⚠ 开关未生效: {switch_err}")
|
|
|
|
|
|
# 撤单没撤干净必须顶到 notes 最前面 —— planner 写 notes 的时候还不知道撤单结果,
|
|
|
|
|
|
# 它数的是"点名了几条", 真撤掉几条只有这里知道。埋在 cancel_failed 里没人看。
|
|
|
|
|
|
if cancel_failed:
|
|
|
|
|
|
notes.insert(0, f"⚠ {len(cancel_failed)} 条在途指令**没撤掉, 仍在下游挂着**: "
|
|
|
|
|
|
+ "; ".join(f"{x['instruction_id']}({x['why']})" for x in cancel_failed[:5]))
|
2026-07-27 17:12:09 +08:00
|
|
|
|
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),
|
2026-07-31 16:10:58 +08:00
|
|
|
|
"notes": notes, "rejects": rejects,
|
2026-07-31 14:36:50 +08:00
|
|
|
|
# 一行话说清"为什么只有这么少 / 一条都没有"。planner 的 notes 只会说
|
|
|
|
|
|
# "候选与补仓空间不足, 缺口 X 元" —— 那读起来像"没票可买", 而真相往往是
|
|
|
|
|
|
# 有一堆候选、全被同一道闸拒了。不聚合出来就得去翻 rejects 原始清单。
|
|
|
|
|
|
"reject_summary": pl.summarize_rejects(rejects),
|
2026-07-31 16:10:58 +08:00
|
|
|
|
"cancelled_instructions": cancelled,
|
|
|
|
|
|
"cancel_failed": cancel_failed}
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
2026-07-31 14:36:50 +08:00
|
|
|
|
# 零方案的两种情形必须分开 (2026-07-31 修)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 开关类命令 (HALT_BUY / RESUME_ALL …) 本来就不产出方案, 那是 DONE。
|
|
|
|
|
|
# 但一条**该**产出方案的任务命令一条都没产出, 它不是"完成", 是"没发生" —— 原来两者
|
|
|
|
|
|
# 都走 DONE, 于是页面显示已完成、`cancel()` 又因为 DONE 不在 ACTIVE_TASK_STATES 里而
|
|
|
|
|
|
# 拒绝撤销, 用户既看不出没执行成、也退不回来。日报统计也会把它算成完成的命令。
|
|
|
|
|
|
# 次序要紧: `instant` 是**命令规格**的属性 (下达即完成), 优先于有没有方案 ——
|
|
|
|
|
|
# HALT_BUY 这类开关命令照样会产出撤单动作 (rows 非空), 但它下达完就该是 DONE。
|
2026-07-31 16:10:58 +08:00
|
|
|
|
# instant 命令自己失败了也不许算 DONE。`_adjust_window` / `_cancel_target` 靠返回
|
|
|
|
|
|
# result["ok"]=False 报"目标命令不存在 / 该命令不可撤销", 原来这个 ok 全库没人读 ——
|
|
|
|
|
|
# 用户看到"撤销命令 已完成", 而目标命令还在跑。开关写不进去同理。
|
|
|
|
|
|
note, errors = None, []
|
|
|
|
|
|
instant_failed = switch_err or (spec.get("instant") and result.get("ok") is False)
|
|
|
|
|
|
if instant_failed:
|
|
|
|
|
|
status = cs.ST_CANCELLED
|
|
|
|
|
|
note = ("命令未生效: " + (switch_err or "; ".join(
|
|
|
|
|
|
str(x) for x in (result.get("notes") or ["方案生成器报告失败"]))))[:280]
|
|
|
|
|
|
errors = [note]
|
|
|
|
|
|
logger.error("[命令] %s %s 未生效 → 置 CANCELLED。%s", command_id, cmd_type, note)
|
|
|
|
|
|
elif spec.get("instant"):
|
2026-07-31 14:36:50 +08:00
|
|
|
|
status = cs.ST_DONE
|
|
|
|
|
|
elif rows:
|
|
|
|
|
|
status = cs.ST_EXECUTING
|
|
|
|
|
|
else:
|
|
|
|
|
|
status = cs.ST_CANCELLED
|
|
|
|
|
|
note = ("未产出任何方案: " + (progress["reject_summary"] or "候选池为空"))[:280]
|
|
|
|
|
|
logger.warning("[命令] %s %s 未产出任何方案 → 置 CANCELLED。%s",
|
|
|
|
|
|
command_id, cmd_type, note)
|
|
|
|
|
|
pms_repo.update_command(command_id, status=status, progress=progress, note=note,
|
|
|
|
|
|
done_at=(datetime.now()
|
|
|
|
|
|
if status in (cs.ST_DONE, cs.ST_CANCELLED) else None))
|
|
|
|
|
|
|
|
|
|
|
|
_ledger_rejects(rejects, command_id)
|
2026-07-31 16:10:58 +08:00
|
|
|
|
return {"ok": not errors, "command_id": command_id, "status": status, "plan": progress,
|
|
|
|
|
|
"items": items, "errors": errors}
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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":
|
2026-07-31 16:10:58 +08:00
|
|
|
|
r = pl.plan_sector_cap(sector=p["sector"], cap=float(p["cap"]), positions=positions)
|
|
|
|
|
|
# 这条命令有两半: 减到线内 (方案) + 把线记下来 (参数)。参数没写上就只减了这一次,
|
|
|
|
|
|
# 上限并没有立住 —— 必须说出来, 不能默默只做一半。
|
|
|
|
|
|
w = param_store.set_param(f"PMS_SECTOR_CAP_{p['sector']}",
|
|
|
|
|
|
float(p["cap"]), "command") or {}
|
|
|
|
|
|
if not w.get("ok"):
|
|
|
|
|
|
logger.error("[命令] 行业上限参数没写进去 %s: %s", p["sector"], w.get("error"))
|
|
|
|
|
|
r = {**r, "notes": [f"⚠ 本次已减到线内, 但行业上限**没能记下来** "
|
|
|
|
|
|
f"({w.get('error')}), 下一轮不会自动守这条线"]
|
|
|
|
|
|
+ list(r.get("notes") or [])}
|
|
|
|
|
|
return r
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-08-03 09:36:36 +08:00
|
|
|
|
"""调度器每分钟一跳: 把 PENDING 的任务命令推进到 EXECUTING (幂等)。
|
|
|
|
|
|
|
|
|
|
|
|
**两个列表都空时要说清是哪一种空** (2026-08-03): 本函数只吃 `PENDING` 的**任务**命令,
|
|
|
|
|
|
它是「把已下达的命令排成方案」的泵, **自己不产生命令**。而 `{"planned": [], "failed": []}`
|
|
|
|
|
|
读起来像"排方案失败了", 实际绝大多数时候是"根本没有命令可排" —— 命令得先由人在页面
|
|
|
|
|
|
命令台下达 (或 POST /api/commands)。参数命令 (总规模、上限这类) 也不产出方案, 它们
|
|
|
|
|
|
是 CLS_PARAM, 压根不进这个查询。同 executor.materialize_plans 的毛病, 一并治。
|
|
|
|
|
|
"""
|
2026-07-27 17:12:09 +08:00
|
|
|
|
done, errs = [], []
|
2026-08-03 09:36:36 +08:00
|
|
|
|
rows = pms_repo.list_commands(statuses=[cs.ST_PENDING], cmd_class=cs.CLS_TASK,
|
|
|
|
|
|
limit=limit)
|
|
|
|
|
|
for c in rows:
|
2026-07-27 17:12:09 +08:00
|
|
|
|
r = plan_command(c)
|
|
|
|
|
|
(done if r.get("ok") else errs).append(c["command_id"])
|
2026-08-03 09:36:36 +08:00
|
|
|
|
out = {"planned": done, "failed": errs, "scanned": len(rows), "note": ""}
|
|
|
|
|
|
if not rows:
|
|
|
|
|
|
by = {}
|
|
|
|
|
|
for st in (cs.ST_PLANNING, cs.ST_EXECUTING, cs.ST_PARTIAL, cs.ST_DONE,
|
|
|
|
|
|
cs.ST_CANCELLED):
|
|
|
|
|
|
try:
|
|
|
|
|
|
by[st] = len(pms_repo.list_commands(statuses=[st], cmd_class=cs.CLS_TASK,
|
|
|
|
|
|
limit=200))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
out["commands_by_status"] = by
|
|
|
|
|
|
live = sum(v for k, v in by.items()
|
|
|
|
|
|
if k in (cs.ST_PLANNING, cs.ST_EXECUTING, cs.ST_PARTIAL))
|
|
|
|
|
|
out["note"] = (
|
|
|
|
|
|
"没有 PENDING 的任务命令可排 —— 这**不是**失败。本步只是把**你已经下达的**命令"
|
|
|
|
|
|
"排成方案, 它自己不产生命令。" +
|
|
|
|
|
|
(f"当前在途任务命令 {live} 条 (已排过方案, 看 make t-plans)。"
|
|
|
|
|
|
if live else
|
|
|
|
|
|
"一条在途任务命令都没有 —— 先去页面「命令台」下一条 (升仓/建仓/降仓…), "
|
|
|
|
|
|
"或 POST /api/commands。参数命令 (总规模、上限这类) 不产出方案, 不算在内。"))
|
|
|
|
|
|
return out
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-31 16:10:58 +08:00
|
|
|
|
def _cancel_marked_instructions(items: list) -> dict:
|
|
|
|
|
|
"""撤销命令点名的在途指令。返回 {"cancelled": [...], "failed": [{id, why}]}。
|
|
|
|
|
|
|
|
|
|
|
|
**必须走 executor.cancel_instruction, 不能只把本端的行标成 CANCELLED。**
|
|
|
|
|
|
2026-07-31 查出来: 原来这里只 `update_instruction(status="CANCELLED")`, 全仓库
|
|
|
|
|
|
`dispatcher.cancel` 在命令这条路上**一次都没被调用过**。切到 ws 之后, 「全局暂停买入」
|
|
|
|
|
|
会回一句「已撤销 N 条」, 而 `pms_qmt_order` 里的子单原封不动继续挂着、继续成交 ——
|
|
|
|
|
|
用户以为踩了刹车, 实际只是本端账面上把它划掉了。HALT_BUY / HALT_ALL / LIQUIDATE_ALL /
|
|
|
|
|
|
REDUCE_EXPOSURE / 撤销命令 五条路全中。
|
|
|
|
|
|
|
|
|
|
|
|
失败的要**单独列出来**, 不能吞成 warning 后照样报"已撤销 N 条" —— 那个 N 原来数的是
|
|
|
|
|
|
命令点名的条数, 不是真撤掉的条数。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.services import executor
|
|
|
|
|
|
ok, failed = [], []
|
2026-07-27 17:12:09 +08:00
|
|
|
|
for it in items or []:
|
|
|
|
|
|
iid = it.get("cancel_instruction_id")
|
|
|
|
|
|
if not iid:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
2026-07-31 16:10:58 +08:00
|
|
|
|
r = executor.cancel_instruction(iid, reason="命令撤销在途指令") or {}
|
|
|
|
|
|
if r.get("ok"):
|
|
|
|
|
|
ok.append(iid)
|
|
|
|
|
|
else:
|
|
|
|
|
|
failed.append({"instruction_id": iid,
|
|
|
|
|
|
"why": r.get("message") or r.get("error") or "撤销未成功"})
|
2026-07-27 17:12:09 +08:00
|
|
|
|
except Exception as e:
|
2026-07-31 16:10:58 +08:00
|
|
|
|
logger.exception("撤销指令失败 %s", iid)
|
|
|
|
|
|
failed.append({"instruction_id": iid, "why": f"{type(e).__name__}: {e}"})
|
|
|
|
|
|
if failed:
|
|
|
|
|
|
logger.error("[命令] %s 条在途指令没撤掉, **它们仍在下游挂着**: %s", len(failed), failed)
|
|
|
|
|
|
return {"cancelled": ok, "failed": failed}
|
2026-07-27 17:12:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-30 16:21:16 +08:00
|
|
|
|
"""升仓候选池 = 上游选股计划 (∪ 旧买入计划表) ∪ 白名单, 剔除黑名单/已持有。
|
|
|
|
|
|
|
|
|
|
|
|
来源由 `PMS_CANDIDATE_SOURCE` 决定: plan_api (默认, 上游 /plan 接口) / buy_plan
|
|
|
|
|
|
(旧 trading_buy_plan 表) / both (并集)。三条口径:
|
|
|
|
|
|
|
2026-07-31 09:17:54 +08:00
|
|
|
|
1. **计划不带价格** —— /plan 只回答「买什么、排第几」, 价格由 market.plan_price
|
|
|
|
|
|
现取: 实时价优先, 盘前回落昨收 (db13 存的是当日分钟线, 盘前那张 key 不存在,
|
|
|
|
|
|
2026-07-31 盘前实测前 30 只全部无实时价)。两者都没有才剔除并记 warning。
|
|
|
|
|
|
旧表的 target_price 若有则直接用。
|
2026-07-30 16:21:16 +08:00
|
|
|
|
2. **上游拿不到不回退旧表** —— 候选池宁可为空。那张表在目标架构下没有明确写入方,
|
|
|
|
|
|
拿它当事实源比没有候选更危险 (沿用「拿不到 ≠ 通过」的纪律)。
|
|
|
|
|
|
3. **白名单必须压过计划票** —— planner 只认 score 一把尺子, 而计划的 score 是 200+
|
|
|
|
|
|
量级 (旧表的 prob_thresh 是 0~1)。白名单是用户点名的票, 故给 max(池内分)+1,
|
|
|
|
|
|
写死 1.0 会让点名票沉到池底。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.services import market, plan_feed
|
2026-07-27 17:12:09 +08:00
|
|
|
|
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")}
|
2026-07-30 16:21:16 +08:00
|
|
|
|
src = (param_store.get("PMS_CANDIDATE_SOURCE", plan_feed.SRC_PLAN_API)
|
|
|
|
|
|
or plan_feed.SRC_PLAN_API).strip()
|
2026-07-31 09:17:54 +08:00
|
|
|
|
out, seen, noprice, fallback = [], set(), [], []
|
2026-07-30 16:21:16 +08:00
|
|
|
|
|
|
|
|
|
|
def _push(c, score, tag, *, price=None, theme=None):
|
2026-07-27 17:12:09 +08:00
|
|
|
|
if not c or c in held or c in black or c in seen:
|
2026-07-30 16:21:16 +08:00
|
|
|
|
return
|
2026-07-31 09:17:54 +08:00
|
|
|
|
px, psrc = float(price or 0), "given"
|
|
|
|
|
|
if px <= 0:
|
|
|
|
|
|
pp = market.plan_price(c)
|
|
|
|
|
|
px, psrc = float(pp["price"] or 0), pp["source"]
|
2026-07-30 16:21:16 +08:00
|
|
|
|
if px <= 0:
|
|
|
|
|
|
noprice.append(c)
|
|
|
|
|
|
return
|
2026-07-31 09:17:54 +08:00
|
|
|
|
if psrc == "prev_close":
|
|
|
|
|
|
fallback.append(c)
|
2026-07-27 17:12:09 +08:00
|
|
|
|
seen.add(c)
|
2026-07-30 16:21:16 +08:00
|
|
|
|
# sector 取已配置的行业源 (与持仓侧的 sector_*_map 同一套词表); theme 只作展示,
|
2026-07-31 09:17:54 +08:00
|
|
|
|
# 不参与约束 —— theme 是"传导主题"(事件驱动、天天变、覆盖率约三成), 拿它当行业标签
|
|
|
|
|
|
# 会让集中度约束跟着漂, 已于 2026-07-31 明确不再灌行业表 (见 UPSTREAM_PLAN_API.md §6)。
|
|
|
|
|
|
out.append({"ts_code": c, "price": px, "price_source": psrc, "score": float(score or 0),
|
2026-07-30 16:21:16 +08:00
|
|
|
|
"sector": industry.get(c), "theme": theme, "src": tag})
|
|
|
|
|
|
|
|
|
|
|
|
if src in (plan_feed.SRC_PLAN_API, plan_feed.SRC_BOTH):
|
|
|
|
|
|
try:
|
|
|
|
|
|
sel = plan_feed.candidates(held=held, black=black)
|
|
|
|
|
|
for r in sel["items"]:
|
|
|
|
|
|
_push(r["ts_code"], r["score"], "plan_api", theme=r.get("theme"))
|
|
|
|
|
|
logger.info("[候选池] 上游计划 %s: 排序池 %s → 合格 %s → 取 %d 只 (丢弃 %s)",
|
|
|
|
|
|
sel.get("date"), sel.get("considered"), sel.get("eligible"),
|
|
|
|
|
|
len(sel["items"]), sel.get("dropped"))
|
|
|
|
|
|
except plan_feed.PlanFeedError as e:
|
|
|
|
|
|
logger.error("[候选池] 上游计划不可用, 计划票一只不进池 (按纪律不回退旧表): %s", e)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.exception("[候选池] 上游计划处理异常: %s", e)
|
|
|
|
|
|
|
|
|
|
|
|
if src in (plan_feed.SRC_BUY_PLAN, plan_feed.SRC_BOTH):
|
|
|
|
|
|
from app.repo import downstream_repo
|
|
|
|
|
|
try:
|
|
|
|
|
|
for p in downstream_repo.fetch_buy_plans(is_active=7, limit=100):
|
|
|
|
|
|
_push(p["ts_code"], p.get("score"), "buy_plan", price=p.get("price"))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning("[候选池] 读旧买入计划表失败: %s", e)
|
|
|
|
|
|
|
|
|
|
|
|
white_score = max([c["score"] for c in out], default=0.0) + 1.0
|
2026-07-27 17:12:09 +08:00
|
|
|
|
for c, d in sp.items():
|
2026-07-30 16:21:16 +08:00
|
|
|
|
if d.get("white"):
|
|
|
|
|
|
_push(c, white_score, "whitelist")
|
|
|
|
|
|
|
2026-07-31 09:17:54 +08:00
|
|
|
|
if fallback:
|
|
|
|
|
|
logger.info("[候选池] %d 只候选无实时价, 已用昨收定量 (盘前正常): %s",
|
|
|
|
|
|
len(fallback), fallback[:10])
|
2026-07-30 16:21:16 +08:00
|
|
|
|
if noprice:
|
2026-07-31 09:17:54 +08:00
|
|
|
|
logger.warning("[候选池] %d 只候选实时价与昨收都取不到, 已剔除 (前 10): %s",
|
|
|
|
|
|
len(noprice), noprice[:10])
|
2026-07-27 17:12:09 +08:00
|
|
|
|
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)
|