tradingSystem/app/services/command_service.py

566 lines
26 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 -*-
"""
命令服务: 下达 → 校验 → 冲突识别 → 生效/规划 → 进度推进
==========================================================
设计对应: §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")
rejects = result.get("rejects") or []
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": rejects,
# 一行话说清"为什么只有这么少 / 一条都没有"。planner 的 notes 只会说
# "候选与补仓空间不足, 缺口 X 元" —— 那读起来像"没票可买", 而真相往往是
# 有一堆候选、全被同一道闸拒了。不聚合出来就得去翻 rejects 原始清单。
"reject_summary": pl.summarize_rejects(rejects),
"cancelled_instructions": cancelled}
# 零方案的两种情形必须分开 (2026-07-31 修)
# ------------------------------------------------------------------
# 开关类命令 (HALT_BUY / RESUME_ALL …) 本来就不产出方案, 那是 DONE。
# 但一条**该**产出方案的任务命令一条都没产出, 它不是"完成", 是"没发生" —— 原来两者
# 都走 DONE, 于是页面显示已完成、`cancel()` 又因为 DONE 不在 ACTIVE_TASK_STATES 里而
# 拒绝撤销, 用户既看不出没执行成、也退不回来。日报统计也会把它算成完成的命令。
# 次序要紧: `instant` 是**命令规格**的属性 (下达即完成), 优先于有没有方案 ——
# HALT_BUY 这类开关命令照样会产出撤单动作 (rows 非空), 但它下达完就该是 DONE。
if spec.get("instant"):
status = cs.ST_DONE
elif rows:
status = cs.ST_EXECUTING
else:
status = cs.ST_CANCELLED
note = None
if 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)
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:
"""升仓候选池 = 上游选股计划 ( 旧买入计划表) 白名单, 剔除黑名单/已持有。
来源由 `PMS_CANDIDATE_SOURCE` 决定: plan_api (默认, 上游 /plan 接口) / buy_plan
(旧 trading_buy_plan 表) / both (并集)。三条口径:
1. **计划不带价格** —— /plan 只回答「买什么、排第几」, 价格由 market.plan_price
现取: 实时价优先, 盘前回落昨收 (db13 存的是当日分钟线, 盘前那张 key 不存在,
2026-07-31 盘前实测前 30 只全部无实时价)。两者都没有才剔除并记 warning。
旧表的 target_price 若有则直接用。
2. **上游拿不到不回退旧表** —— 候选池宁可为空。那张表在目标架构下没有明确写入方,
拿它当事实源比没有候选更危险 (沿用「拿不到 ≠ 通过」的纪律)。
3. **白名单必须压过计划票** —— planner 只认 score 一把尺子, 而计划的 score 是 200+
量级 (旧表的 prob_thresh 是 0~1)。白名单是用户点名的票, 故给 max(池内分)+1,
写死 1.0 会让点名票沉到池底。
"""
from app.services import market, plan_feed
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")}
src = (param_store.get("PMS_CANDIDATE_SOURCE", plan_feed.SRC_PLAN_API)
or plan_feed.SRC_PLAN_API).strip()
out, seen, noprice, fallback = [], set(), [], []
def _push(c, score, tag, *, price=None, theme=None):
if not c or c in held or c in black or c in seen:
return
px, psrc = float(price or 0), "given"
if px <= 0:
pp = market.plan_price(c)
px, psrc = float(pp["price"] or 0), pp["source"]
if px <= 0:
noprice.append(c)
return
if psrc == "prev_close":
fallback.append(c)
seen.add(c)
# sector 取已配置的行业源 (与持仓侧的 sector_*_map 同一套词表); theme 只作展示,
# 不参与约束 —— theme 是"传导主题"(事件驱动、天天变、覆盖率约三成), 拿它当行业标签
# 会让集中度约束跟着漂, 已于 2026-07-31 明确不再灌行业表 (见 UPSTREAM_PLAN_API.md §6)。
out.append({"ts_code": c, "price": px, "price_source": psrc, "score": float(score or 0),
"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
for c, d in sp.items():
if d.get("white"):
_push(c, white_score, "whitelist")
if fallback:
logger.info("[候选池] %d 只候选无实时价, 已用昨收定量 (盘前正常): %s",
len(fallback), fallback[:10])
if noprice:
logger.warning("[候选池] %d 只候选实时价与昨收都取不到, 已剔除 (前 10): %s",
len(noprice), noprice[:10])
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)