tradingSystem/app/services/proposal_service.py

265 lines
12 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 -*-
"""
自主提议: 扫描 → 规则闸 → 研判闸 → 按自主档位分流 (设计 §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"]}