205 lines
9.5 KiB
Python
205 lines
9.5 KiB
Python
# -*- 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]
|