tradingSystem/app/services/reask_service.py

205 lines
9.5 KiB
Python
Raw Permalink 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 -*-
"""解锁重问的取数与落账 (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]