tradingSystem/app/services/proposal_service.py

311 lines
15 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, 不再重取
# 跳过两类: ①已有在途提议或指令的 ②今天已被规则闸拒过的。
# 后者是 2026-07-29 的教训 —— 组合已超总仓上限时, 16 只深亏票的补仓候选每分钟被拒
# 一次, 一天往评审账本灌几千行一模一样的记录。闸门结论当天基本不会变, 记一次就够。
skip = _inflight_keys() | _rejected_today_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"]})
bump_once_guards(c["ts_code"], c["action"], c.get("hard_numbers"), now)
return iid
def bump_once_guards(ts_code: str, action: str, hard_numbers=None, now=None):
"""把「只做一次」的三个计数器写上。
设计 §6 写了三条一次性约束, 但它们的计数器此前**只被读、从没被写过** —— 也就是说这三条
纪律一直是失效的, 只是被「同一票同一动作有在途提议就不重复提」这条兜底遮住了, 而那条兜底
恰好在规则闸拒绝时失灵 (没生成提议 → 没东西可去重), 于是同一个候选每分钟重来一次:
fill_count 回踩补足「每票 1 次」
last_add_date 盈利加仓「距上次 ≥2 交易日」—— 恒 None 时永远算作"很久没加过"
dca_count 补仓「各档评估一次」—— 恒 0 时每轮都当第一次评估
写入时机取「动作真的要落地」这一刻 (落指令), 而不是产出候选那一刻: 候选被闸门拦下不算
做过, 用掉一次名额不合理。dca_count 记的是**档位**而不是次数, 所以取本次触及的档序。
"""
now = now or datetime.now()
fields = {}
if action == "FILL":
fields["fill_count"] = 1
elif action == "ADD":
fields["last_add_date"] = now.date()
elif action == "DCA":
fields["dca_count"] = int((hard_numbers or {}).get("stage") or 1)
if not fields:
return
try:
pms_repo.update_position(ts_code, **fields)
except Exception as e:
# 写不上只是让这条纪律退回原样 (可能重复提), 不该把已落表的指令带崩
logger.warning("一次性守卫计数器写入失败 %s %s: %s", ts_code, action, e)
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 _rejected_today_keys() -> set:
"""今天已被规则闸拒过的 (代码, 动作)。读不到就返回空集 —— 去重是降噪, 不是纪律,
读失败时宁可多记几行日志, 也不能因此漏扫一个本该评估的动作。"""
try:
return pms_repo.rule_rejected_today(datetime.now().replace(
hour=0, minute=0, second=0, microsecond=0))
except Exception as e:
logger.warning("读当日规则闸拒绝记录失败 (按未拒过继续扫描): %s", e)
return set()
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"]}