265 lines
12 KiB
Python
265 lines
12 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""
|
|||
|
|
自主提议: 扫描 → 规则闸 → 研判闸 → 按自主档位分流 (设计 §6 / §7)
|
|||
|
|
==================================================================
|
|||
|
|
分流规则 (设计原文):
|
|||
|
|
full 闸门与研判通过即执行 → 直接落指令
|
|||
|
|
propose_only 增持类全部待用户确认 (一期默认) → 落 pms_proposal 队列
|
|||
|
|
off 不扫描
|
|||
|
|
|
|||
|
|
两条无条件覆盖档位的规矩:
|
|||
|
|
* **减持方向不设确认门槛** —— TRIM 保垫减仓属纯规则自动执行, 任何档位都直接落指令。
|
|||
|
|
* **−15% 及更深的补仓永远需用户确认** —— 即便档位是 full, 也强制入队。
|
|||
|
|
|
|||
|
|
研判闸不可用时 (决策系统未接通/超时), 按设计自动降级为 propose_only + ERROR 告警,
|
|||
|
|
**绝不把「研判拿不到」当成「研判通过」**。
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
|
|||
|
|
from app.core import action_engine as ae
|
|||
|
|
from app.core import command_spec as cs
|
|||
|
|
from app.core import rule_gate
|
|||
|
|
from app.core import tradedays as td
|
|||
|
|
from app.repo import pms_repo
|
|||
|
|
from app.services import (command_service, executor, judge, market, param_store, portfolio)
|
|||
|
|
|
|||
|
|
logger = logging.getLogger("pms.proposal")
|
|||
|
|
|
|||
|
|
AUTONOMY_FULL, AUTONOMY_PROPOSE, AUTONOMY_OFF = "full", "propose_only", "off"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
|
|||
|
|
"""自主提议扫描一轮。dry_run=True 只出候选与判定, 不落任何表。"""
|
|||
|
|
now = now or datetime.now()
|
|||
|
|
out = {"ok": True, "autonomy": None, "candidates": 0, "executed": [], "queued": [],
|
|||
|
|
"rejected": [], "skipped": [], "errors": [], "degraded": False, "dry_run": dry_run}
|
|||
|
|
|
|||
|
|
autonomy = param_store.get("PMS_AUTONOMY", AUTONOMY_PROPOSE)
|
|||
|
|
out["autonomy"] = autonomy
|
|||
|
|
if autonomy == AUTONOMY_OFF:
|
|||
|
|
out["skipped"].append({"why": "自主档位 off, 不扫描"})
|
|||
|
|
return out
|
|||
|
|
if param_store.get_bool("PMS_GLOBAL_EXEC_HALT", False):
|
|||
|
|
out["skipped"].append({"why": "全局暂停执行 (休假模式)"})
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
view = portfolio.positions_view()
|
|||
|
|
params = _scan_params(view)
|
|||
|
|
mkt = _market_ctx(view["held"], now)
|
|||
|
|
params["_mkt"] = mkt # 规则闸要用同一份 MA5, 不再重取
|
|||
|
|
skip = _inflight_keys()
|
|||
|
|
scanned = ae.scan(positions=view["held"], params=params, market=mkt, skip=skip)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.exception("提议扫描失败")
|
|||
|
|
return {**out, "ok": False, "errors": [f"扫描失败: {type(e).__name__}: {e}"]}
|
|||
|
|
|
|||
|
|
out["candidates"] = len(scanned["candidates"])
|
|||
|
|
out["skipped"].extend(scanned["skipped"])
|
|||
|
|
stock_params = command_service.effective_stock_params()
|
|||
|
|
brake_active = td.ymd() < param_store.get_int("PMS_BRAKE_UNTIL", 0)
|
|||
|
|
|
|||
|
|
for c in scanned["candidates"]:
|
|||
|
|
try:
|
|||
|
|
_route_one(c, view, params, stock_params, brake_active, now, dry_run, out)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.exception("提议分流失败 %s", c.get("ts_code"))
|
|||
|
|
out["errors"].append(f"{c.get('ts_code')} {c.get('action')}: "
|
|||
|
|
f"{type(e).__name__}: {e}")
|
|||
|
|
out["ok"] = not out["errors"]
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out):
|
|||
|
|
code, action, side = c["ts_code"], c["action"], c["side"]
|
|||
|
|
pos = _pos_of(view, code)
|
|||
|
|
price = float(pos.get("price") or 0)
|
|||
|
|
|
|||
|
|
# ---- 一级: 规则闸 (自主动作受刹车约束, is_command=False) ----
|
|||
|
|
gate = rule_gate.check(
|
|||
|
|
side=side, action=action, qty=c["qty"], price=price,
|
|||
|
|
ctx={"ts_code": code, "position": pos,
|
|||
|
|
"day": {"price": price, "vwap": price,
|
|||
|
|
"ma5": (params.get("_mkt") or {}).get(code, {}).get("ma5"),
|
|||
|
|
"day_chg_from_open": None},
|
|||
|
|
"params": {"no_chase_ma5": params.get("no_chase_ma5"),
|
|||
|
|
"buy_halt_dayup": params.get("buy_halt_dayup"),
|
|||
|
|
"sector_source_ready": view["sector_ready"]},
|
|||
|
|
"caps": portfolio.caps_ctx(view, ts_code=code) if side == "buy" else None,
|
|||
|
|
"flags": {"buy_halt": params.get("buy_halt"), "exec_halt": params.get("exec_halt"),
|
|||
|
|
"brake_active": brake_active,
|
|||
|
|
"blacklisted": bool(stock_params.get(code, {}).get("black")),
|
|||
|
|
"is_command": False}})
|
|||
|
|
if not gate["passed"]:
|
|||
|
|
out["rejected"].append({"ts_code": code, "action": action, "by": "rule",
|
|||
|
|
"failed": gate["failed"]})
|
|||
|
|
if not dry_run:
|
|||
|
|
pms_repo.insert_ledger(ts_code=code, action=action, arbiter="rule", verdict="REJECT",
|
|||
|
|
price_at=price, hard_numbers=c["hard_numbers"],
|
|||
|
|
failed_checks=gate["failed"], reason="自主提议未过规则闸")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# ---- 二级: 研判闸 (仅补足/加仓/补仓; 减持不送研判) ----
|
|||
|
|
verdict = {"verdict": judge.PASS, "reason": "", "degraded": False}
|
|||
|
|
if c.get("judge_required"):
|
|||
|
|
verdict = judge.request(c, context={"position": _judge_ctx(pos),
|
|||
|
|
"recent_ledger": _recent_ledger(code)})
|
|||
|
|
if verdict["verdict"] == judge.REJECT:
|
|||
|
|
out["rejected"].append({"ts_code": code, "action": action, "by": "judge",
|
|||
|
|
"failed": [verdict.get("reason") or "研判驳回"]})
|
|||
|
|
if not dry_run:
|
|||
|
|
pms_repo.insert_ledger(ts_code=code, action=action, arbiter="judge",
|
|||
|
|
verdict="REJECT", price_at=price,
|
|||
|
|
hard_numbers=c["hard_numbers"],
|
|||
|
|
reason=verdict.get("reason") or "研判驳回")
|
|||
|
|
return
|
|||
|
|
if verdict.get("degraded"):
|
|||
|
|
out["degraded"] = True
|
|||
|
|
|
|||
|
|
# ---- 三级: 按档位分流 ----
|
|||
|
|
autonomy = out["autonomy"]
|
|||
|
|
force_queue = bool(c.get("needs_user_confirm")) or verdict.get("degraded")
|
|||
|
|
auto_exec = (side == "sell") or (autonomy == AUTONOMY_FULL and not force_queue)
|
|||
|
|
|
|||
|
|
if dry_run:
|
|||
|
|
(out["executed"] if auto_exec else out["queued"]).append(
|
|||
|
|
{**_brief(c), "route": "auto" if auto_exec else "queue",
|
|||
|
|
"judge": verdict["verdict"], "dry_run": True})
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if auto_exec:
|
|||
|
|
iid = _make_instruction(c, price, now)
|
|||
|
|
pms_repo.insert_ledger(ts_code=code, action=action,
|
|||
|
|
arbiter="judge" if c.get("judge_required") else "rule",
|
|||
|
|
verdict="PASS", price_at=price, hard_numbers=c["hard_numbers"],
|
|||
|
|
ref_id=iid,
|
|||
|
|
reason=(verdict.get("reason") or c["reason"])[:500])
|
|||
|
|
out["executed"].append({**_brief(c), "instruction_id": iid,
|
|||
|
|
"why": "减持方向自动执行" if side == "sell" else "档位 full"})
|
|||
|
|
else:
|
|||
|
|
pid = _make_proposal(c, price, verdict)
|
|||
|
|
why = ("深档补仓强制确认" if c.get("needs_user_confirm")
|
|||
|
|
else ("研判不可用, 降级人工确认" if verdict.get("degraded")
|
|||
|
|
else "档位 propose_only"))
|
|||
|
|
out["queued"].append({**_brief(c), "proposal_id": pid, "why": why})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ================================================================ 落表
|
|||
|
|
def _make_instruction(c, price, now) -> str:
|
|||
|
|
ymd = td.ymd(now)
|
|||
|
|
seq = int(now.strftime("%H%M%S"))
|
|||
|
|
iid = cs.make_instruction_id(ymd, c["ts_code"], c["action"], seq % 1000)
|
|||
|
|
window = param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3)
|
|||
|
|
pms_repo.insert_instruction(
|
|||
|
|
instruction_id=iid, origin_type="proposal", origin_id=None, ts_code=c["ts_code"],
|
|||
|
|
action=c["action"], side=c["side"], qty=c["qty"], limit_price=None,
|
|||
|
|
window_tdays=window, status=executor.ST_PROPOSED,
|
|||
|
|
progress={"deadline": str(td.window_deadline(now.date(), window)),
|
|||
|
|
"is_command": False, "children": [], "auto": True,
|
|||
|
|
"reason": c["reason"]})
|
|||
|
|
return iid
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _make_proposal(c, price, verdict) -> str:
|
|||
|
|
ttl = param_store.get_int("PMS_PROPOSAL_TTL_HOURS", 24)
|
|||
|
|
pid = f"PRP_{td.ymd()}_{c['ts_code'].replace('.', '')}_{c['action']}"
|
|||
|
|
hn = {**(c.get("hard_numbers") or {}), "price": price, "reason": c["reason"],
|
|||
|
|
"needs_user_confirm": c.get("needs_user_confirm", False)}
|
|||
|
|
pms_repo.insert_proposal(
|
|||
|
|
proposal_id=pid, ts_code=c["ts_code"], action=c["action"], qty=c["qty"],
|
|||
|
|
hard_numbers=hn, expire_at=datetime.now() + timedelta(hours=ttl),
|
|||
|
|
judge_verdict=verdict.get("verdict"),
|
|||
|
|
judge_reason=(verdict.get("reason") or "")[:500])
|
|||
|
|
return pid
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ================================================================ 上下文
|
|||
|
|
def _scan_params(view: dict) -> dict:
|
|||
|
|
p = dict(view["params"])
|
|||
|
|
p.update({
|
|||
|
|
"cushion_solid": param_store.get_float("PMS_CUSHION_SOLID", 0.03),
|
|||
|
|
"trim_peak": param_store.get_float("PMS_TRIM_PEAK", 0.06),
|
|||
|
|
"trim_giveback": param_store.get_float("PMS_TRIM_GIVEBACK", 0.5),
|
|||
|
|
"dca_triggers": param_store.get_tuple_floats("PMS_DCA_TRIGGERS", (-0.08, -0.15)),
|
|||
|
|
"dca_deep_confirm": param_store.get_float("PMS_DCA_DEEP_CONFIRM", -0.15),
|
|||
|
|
"dca_max_ratio": param_store.get_float("PMS_DCA_MAX_RATIO", 0.5),
|
|||
|
|
"no_chase_ma5": param_store.get_float("PMS_NO_CHASE_MA5", 0.06),
|
|||
|
|
"buy_halt_dayup": param_store.get_float("PMS_BUY_HALT_DAYUP", 0.05),
|
|||
|
|
"build_window_tdays": param_store.get_int("PMS_BUILD_WINDOW_TDAYS", 10),
|
|||
|
|
"fill_max_loss": param_store.get_float("PMS_FILL_MAX_LOSS", -0.03),
|
|||
|
|
})
|
|||
|
|
return p
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _market_ctx(held: list, now) -> dict:
|
|||
|
|
"""每票的 MA5 / 5日高点 / 建仓天数 / 距上次加仓天数 (交易日口径)。"""
|
|||
|
|
out = {}
|
|||
|
|
today = now.date() if hasattr(now, "date") else now
|
|||
|
|
for p in held or []:
|
|||
|
|
code = p["ts_code"]
|
|||
|
|
d = {"ma5": market.get_ma5(code), "high5": market.get_high5(code)}
|
|||
|
|
opened, last_add = p.get("opened_date"), p.get("last_add_date")
|
|||
|
|
d["tdays_since_open"] = _tdays_between(opened, today)
|
|||
|
|
d["tdays_since_last_add"] = _tdays_between(last_add, today)
|
|||
|
|
out[code] = d
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _tdays_between(start, today):
|
|||
|
|
"""start(含) 到 today 的交易日数; start 为空返回 None (调用方按「无约束」处理)。"""
|
|||
|
|
if not start:
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
return max(0, td.trade_days_left(today, start) - 1)
|
|||
|
|
except Exception:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _inflight_keys() -> set:
|
|||
|
|
"""已有在途提议或在途指令的 (代码, 动作) —— 同一件事不重复提。"""
|
|||
|
|
keys = set()
|
|||
|
|
try:
|
|||
|
|
for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200):
|
|||
|
|
keys.add((p["ts_code"], p["action"]))
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning("读提议队列失败: %s", e)
|
|||
|
|
try:
|
|||
|
|
for i in pms_repo.list_instructions(statuses=list(executor.LIVE), limit=300):
|
|||
|
|
keys.add((i["ts_code"], i.get("action")))
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning("读在途指令失败: %s", e)
|
|||
|
|
return keys
|
|||
|
|
|
|||
|
|
|
|||
|
|
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, "avail_qty": 0, "frozen_reason": "NONE"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _judge_ctx(pos: dict) -> dict:
|
|||
|
|
"""送研判的账本快照 (设计 §7: PMS 备齐硬数字与账本流水作为 context)。"""
|
|||
|
|
keep = ("ts_code", "price", "avg_cost", "total_qty", "base_qty", "add_qty", "dca_qty",
|
|||
|
|
"cushion_pct", "cushion_peak", "pct_of_scale", "target_pct", "support_ref",
|
|||
|
|
"pressure_ref", "stop_ref", "ref_source", "sector", "neg_cushion_days")
|
|||
|
|
return {k: pos.get(k) for k in keep}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _recent_ledger(ts_code: str, limit: int = 10) -> list:
|
|||
|
|
try:
|
|||
|
|
rows = pms_repo.list_ledger(ts_code=ts_code, limit=limit)
|
|||
|
|
except Exception:
|
|||
|
|
return []
|
|||
|
|
return [{"at": str(r.get("decided_at")), "action": r.get("action"),
|
|||
|
|
"arbiter": r.get("arbiter"), "verdict": r.get("verdict"),
|
|||
|
|
"price": r.get("price_at"), "reason": r.get("reason")} for r in rows]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _brief(c: dict) -> dict:
|
|||
|
|
return {"ts_code": c["ts_code"], "action": c["action"], "side": c["side"],
|
|||
|
|
"qty": c["qty"], "reason": c["reason"]}
|