# -*- coding: utf-8 -*- """解锁重问的取数与落账 (2026-09-09 方案第四之一节, 台账 055)。薄壳: 判定全在 core/reask_rules。 两个出口, 职责分得很开: evaluate_all() 只读、零副作用。扫描每分钟一跳都会调它, 也给持仓页的处置说明用。 **它绝不写任何东西** —— 这是它能被每分钟调用的前提。 commit() 真的要发出研判请求的那一刻才调, 往评审账本写一行。写成功才算用掉 这只票当天那唯一一次重问名额。 为什么名额在"真发请求"时才算用掉, 而不是在解锁时: 解锁只是"允许被重新评估"。解锁之后 候选可能因为名额不够、金额不够而根本没产出, 也可能被规则闸拦下 —— 那些情况下什么都没 问过, 凭什么消耗机会。试算 (dry_run) 同理, 一律不落账、不带强制标记。 """ from __future__ import annotations import logging from datetime import datetime from app.core import reask_rules as rr from app.core import signal_rules as sr from app.repo import downstream_repo, pms_repo from app.services import market, param_store logger = logging.getLogger("pms.reask") REASK_ACTION = "OPEN_REASK" def params() -> dict: """七个阈值 + 总开关。全部登记在运行参数表里, 页面上能改、即时生效。""" return { "enabled": param_store.get_bool("PMS_OPEN_REASK_ENABLED", True), "min_gap_min": param_store.get_int("PMS_OPEN_REASK_MIN_GAP_MIN", 20), "max_per_day": param_store.get_int("PMS_OPEN_REASK_MAX_PER_DAY", 1), "window": str(param_store.get("PMS_OPEN_REASK_WINDOW", "0945-1430") or "0945-1430"), "ref_drift": param_store.get_float("PMS_OPEN_REASK_REF_DRIFT", 0.03), "up_min": param_store.get_float("PMS_OPEN_REASK_UP", 0.02), "vol_min": param_store.get_float("PMS_OPEN_REASK_VOL_MIN", 1.5), "vol_mult": param_store.get_float("PMS_OPEN_REASK_VOL_MULT", 1.3), } def base_snapshot(ts_code: str, price: float, judge_conf=None) -> dict: """驳回那一刻的基线快照, 落进驳回行的硬数字里 (键 reask_base)。 取数任何一项失败写 None, **不阻断驳回落账** —— 记账优先于留痕。 旧的驳回行没有这个块, 那时 U3 (价升且量能确认) 就自动不成立, U1、U2 照常可判。 两种口径的当日涨幅都记 (台账 055 第二条): 规则闸的不追高量的是**相对今日开盘**, 解锁条件量的是**相对昨收**。两个数并存, 复盘时才对得上账。 """ out = {"at": datetime.now().isoformat(timespec="seconds"), "price": float(price or 0), "chg_prev": None, "chg_open": None, "vol_ratio": None, "judge_conf": judge_conf, "y_signal": None, "support": None, "pressure": None} try: pc = market.get_last_close(ts_code) if pc and price: out["chg_prev"] = round(float(price) / float(pc) - 1.0, 6) day = market.day_snapshot(ts_code) or {} if day.get("day_chg_from_open") is not None: out["chg_open"] = round(float(day["day_chg_from_open"]), 6) except Exception as e: logger.warning("[重问] 基线涨幅取不到 [%s]: %s", ts_code, e) try: out["vol_ratio"] = _vol_ratio(ts_code) except Exception as e: logger.warning("[重问] 基线量比取不到 [%s]: %s", ts_code, e) try: refs = downstream_repo.fetch_refs(ts_code) or {} out["y_signal"] = refs.get("signal_type") out["support"] = _f(refs.get("support")) out["pressure"] = _f(refs.get("pressure")) except Exception as e: logger.warning("[重问] 基线底牌取不到 [%s]: %s", ts_code, e) return out def _f(v): try: return float(v) except (TypeError, ValueError): return None def _vol_ratio(ts_code: str): dv = market.day_volume(ts_code) or {} return rr.vol_ratio(dv.get("vol"), market.get_vol_base5(ts_code), dv.get("frac")) def _now_read(ts_code: str, buy_at: str) -> dict: """此刻的读数。每一项各自 try —— 一路取不到只让相关的那条解锁条件不成立, 不牵连其余。""" out = {"price": None, "vol_ratio": None, "y_signal": None, "support": None, "pressure": None, "bionic_buy_at": buy_at or ""} try: out["price"] = market.get_price(ts_code) except Exception as e: logger.warning("[重问] 现价取不到 [%s]: %s", ts_code, e) try: out["vol_ratio"] = _vol_ratio(ts_code) except Exception as e: logger.warning("[重问] 量比取不到 [%s]: %s", ts_code, e) try: refs = downstream_repo.fetch_refs(ts_code) or {} out["y_signal"] = refs.get("signal_type") out["support"] = _f(refs.get("support")) out["pressure"] = _f(refs.get("pressure")) except Exception as e: logger.warning("[重问] 底牌取不到 [%s]: %s", ts_code, e) return out def evaluate_all(judge_keys: dict, now=None) -> dict: """对当日被研判驳回的新建仓逐只判解锁。**只读, 零副作用。** 入参是 proposal_service._judge_rejected_open_keys() 的产出 {(代码, 动作): 原因}。 返回 {(代码, 动作): {"why", "hits", "base", "now"}} —— 只含判为解锁的那些。 解锁条件一条都不成立时返回空字典, 于是当日闸行为与今天逐字相同。 """ now = now or datetime.now() p = params() if not p["enabled"] or not judge_keys: return {} codes = [c for (c, a) in judge_keys] if not codes: return {} since = now.replace(hour=0, minute=0, second=0, microsecond=0) try: rejects = pms_repo.judge_rejected_open_rows(since) used = pms_repo.reask_count_today(since) except Exception as e: logger.warning("[重问] 读当日驳回行失败 (本轮不解锁任何票): %s", e) return {} try: buys = pms_repo.buy_signals_today(since) or {} except Exception as e: logger.warning("[重问] 读当日转多留痕失败 (U1 本轮不成立): %s", e) buys = {} out = {} for key in judge_keys: code, action = key row = rejects.get(code) or {} base = dict((row.get("hard") or {}).get("reask_base") or {}) if not base.get("at") and row.get("at"): # 旧驳回行没有基线块: 至少把时刻与价格补上, 让 G1 与 U3 的价格那半段能判 base.setdefault("at", str(row["at"]).replace(" ", "T")) base.setdefault("price", row.get("price")) b = buys.get(code) or {} buy_at = b.get("at") or "" if buy_at and not sr.is_bionic_buy_note(b.get("reason")): buy_at = "" # 不是决策系统发的转多留痕, 不算 U1 try: r = rr.evaluate(base=base, now_read=_now_read(code, str(buy_at).replace(" ", "T")), params=p, used_today=int(used.get(code) or 0), now=now) except Exception as e: logger.warning("[重问] 判定出错 [%s] (按不解锁): %s", code, e) continue if r.get("unlock"): out[key] = {"why": r.get("why"), "hits": r.get("hits") or [], "base": base, "now": _now_read(code, buy_at), "notes": r.get("notes") or []} logger.info("[重问] 解锁 %s: %s (命中 %s)", code, r.get("why"), ",".join(r.get("hits") or [])) return out def commit(ts_code: str, detail: dict, price) -> bool: """把这一次重问记进评审账本。写成功才算用掉当天那唯一一次名额。 verdict 用 NOTE 不用 PASS: 这一行不是一个判决, 是"当日闸在这里被解除了"这件事的留痕。 """ hits = ",".join((detail or {}).get("hits") or []) reason = "研判驳回闸当日解除,重问一次(命中 %s):%s" % (hits or "无", (detail or {}).get("why") or "") try: pms_repo.insert_ledger(ts_code=ts_code, action=REASK_ACTION, arbiter="rule", verdict="NOTE", price_at=price, hard_numbers={"reask": {"hits": (detail or {}).get("hits") or [], "base": (detail or {}).get("base") or {}, "now": (detail or {}).get("now") or {}}}, reason=reason[:500]) return True except Exception as e: logger.warning("[重问] 名额落账失败 [%s] (本轮不重问): %s", ts_code, e) return False def reask_text(detail: dict, seq: int = 1) -> str: """送研判时随请求带的一句整句, 让模型看得见"输入变了什么"。 与 09-02 把候选卡判决整句送进去是同一手法: 送人话, 不送原值字典。 """ d = detail or {} base, now = d.get("base") or {}, d.get("now") or {} bits = [] if base.get("at"): bits.append("%s 曾驳回于 %s" % (str(base["at"])[11:16], base.get("price"))) if now.get("price") is not None and base.get("price"): try: bits.append("现价 %.2f(%+.2f%%)" % (float(now["price"]), (float(now["price"]) / float(base["price"]) - 1) * 100)) except (TypeError, ValueError, ZeroDivisionError): pass if base.get("vol_ratio") is not None and now.get("vol_ratio") is not None: bits.append("时段折算量比 %s→%s" % (base["vol_ratio"], now["vol_ratio"])) return ("本次为当日第 %d 次重问:%s。解除当日闸的依据:%s" % (seq, ";".join(bits) or "输入已变化", d.get("why") or ""))[:600]