# -*- 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"]}