tradingSystem/app/services/reask_service.py

205 lines
9.5 KiB
Python
Raw Permalink Normal View History

# -*- 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 (价升且量能确认) 就自动不成立, U1U2 照常可判
两种口径的当日涨幅都记 (台账 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]