解锁重问:判定、取数、接线、参数、单测(台账 055 第二件之 PMS 侧)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
e83422e7ab
commit
6f416ead6b
|
|
@ -618,6 +618,48 @@ def judge_rejected_today(since) -> set:
|
||||||
return {(r["ts_code"], r["action"]) for r in rows}
|
return {(r["ts_code"], r["action"]) for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def judge_rejected_open_rows(since) -> dict:
|
||||||
|
"""今天被研判闸驳回的**新建仓**, 每只票取当日**最新一条** → {代码: {at, price, hard}}。
|
||||||
|
|
||||||
|
与 judge_rejected_today 分开写而不是把它改成回整行 (2026-09-09): 那个函数的返回值
|
||||||
|
被当日闸直接当集合用, 换形状要改调用点; 这个只服务解锁判定, 加不改。
|
||||||
|
|
||||||
|
只取最新一条的理由: 解锁的四条护栏里有一条是"距驳回已过 20 分钟", 而重问后仍驳回会
|
||||||
|
再写一行 —— 要按**最近那次**驳回算间隔, 不是第一次。
|
||||||
|
"""
|
||||||
|
rows = fetch_all(
|
||||||
|
"SELECT ts_code, decided_at, price_at, hard_numbers_json FROM pms_action_ledger "
|
||||||
|
"WHERE verdict = 'REJECT' AND arbiter = 'judge' AND action = 'OPEN' "
|
||||||
|
"AND decided_at >= :d ORDER BY decided_at",
|
||||||
|
{"d": since})
|
||||||
|
out = {}
|
||||||
|
for r in rows: # 按时间升序扫, 后面的自然盖掉前面的
|
||||||
|
hard = r.get("hard_numbers_json")
|
||||||
|
if isinstance(hard, str):
|
||||||
|
try:
|
||||||
|
hard = json.loads(hard)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
hard = {}
|
||||||
|
out[r["ts_code"]] = {"at": str(r.get("decided_at") or ""),
|
||||||
|
"price": float(r.get("price_at") or 0),
|
||||||
|
"hard": hard if isinstance(hard, dict) else {}}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def reask_count_today(since) -> dict:
|
||||||
|
"""今天每只票已经用掉的重问次数 → {代码: 次数}。
|
||||||
|
|
||||||
|
动作名刻意叫 OPEN_REASK 而不是 OPEN: 既有的四个当日查询 (研判驳回、规则闸拒、
|
||||||
|
今天开了哪几只、转多留痕) 都按 action 精确匹配, 用 OPEN 会污染它们的判分锚 ——
|
||||||
|
与 SIGNAL_BUY 当年不叫 OPEN 是同一个理由。
|
||||||
|
"""
|
||||||
|
rows = fetch_all(
|
||||||
|
"SELECT ts_code, COUNT(*) AS n FROM pms_action_ledger "
|
||||||
|
"WHERE action = 'OPEN_REASK' AND decided_at >= :d GROUP BY ts_code",
|
||||||
|
{"d": since})
|
||||||
|
return {r["ts_code"]: int(r["n"] or 0) for r in rows}
|
||||||
|
|
||||||
|
|
||||||
def buy_signals_today(since) -> dict:
|
def buy_signals_today(since) -> dict:
|
||||||
"""今天决策系统判过「盘中转多」的票 → {代码: {reason, price, at}}。
|
"""今天决策系统判过「盘中转多」的票 → {代码: {reason, price, at}}。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,10 @@ OPEN_JUDGE_KEYS = ("price", "score", "theme", "tier", "upside", "heat",
|
||||||
"news_text",
|
"news_text",
|
||||||
# 公司深度整句 (2026-09-09 接入方案第四节): 质地档、买方论点一句、置信度、失效条件。照 09-02 送判决
|
# 公司深度整句 (2026-09-09 接入方案第四节): 质地档、买方论点一句、置信度、失效条件。照 09-02 送判决
|
||||||
# 与理由的先例——让研判拿自己的量价结论对照; 质地本身由选股系统判, 研判不重判。开关 PMS_JUDGE_COMPANY_TEXT。
|
# 与理由的先例——让研判拿自己的量价结论对照; 质地本身由选股系统判, 研判不重判。开关 PMS_JUDGE_COMPANY_TEXT。
|
||||||
"company_review_text")
|
"company_review_text",
|
||||||
|
# 重问时的那一句 (2026-09-09 台账 055): 曾在什么价被驳回、现在价量变成什么样。
|
||||||
|
# 只在解锁重问那一次带; 平时这个键根本不在硬数字里, 白名单收了也送不出去。
|
||||||
|
"reask_text")
|
||||||
|
|
||||||
# 新建仓送研判时的必答题 (2026-09-03)。候选卡的理由随硬数字送过去了, 研判要逐条回答
|
# 新建仓送研判时的必答题 (2026-09-03)。候选卡的理由随硬数字送过去了, 研判要逐条回答
|
||||||
# 「这条理由到今天还成不成立」—— 这是把上游的静态判决与决策系统的当日观点接起来的那一句。
|
# 「这条理由到今天还成不成立」—— 这是把上游的静态判决与决策系统的当日观点接起来的那一句。
|
||||||
|
|
@ -214,8 +217,17 @@ def must_answer_for(action: str) -> list:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def request(candidate: dict, context: dict = None, *, timeout: int = None) -> dict:
|
def request(candidate: dict, context: dict = None, *, timeout: int = None,
|
||||||
"""请求一次研判。任何异常/超时/未接通都返回 UNAVAILABLE (绝不把提议当成通过)。"""
|
force: bool = False, reask_text: str = None) -> dict:
|
||||||
|
"""请求一次研判。任何异常/超时/未接通都返回 UNAVAILABLE (绝不把提议当成通过)。
|
||||||
|
|
||||||
|
force (2026-09-09, 台账 055): 请求体里多带 force_refresh 与 reask_text 两个键。
|
||||||
|
决策系统那侧对同票同动作有一层三十分钟缓存, 而那层缓存的键里没有任何输入指纹 ——
|
||||||
|
价格、资金、量能全变了, 三十分钟内仍然回同一份裁决。解锁重问必须绕开它, 否则
|
||||||
|
"重新评估"评的还是半小时前那份。节流责任仍在那侧: 它自己有每票每日的强制次数预算。
|
||||||
|
|
||||||
|
**force 为假时请求体与今天逐字节相同** —— 这是"加不改"的机器证明, 单测钉着。
|
||||||
|
"""
|
||||||
action = str(candidate.get("action") or "").upper()
|
action = str(candidate.get("action") or "").upper()
|
||||||
if action not in judged_actions():
|
if action not in judged_actions():
|
||||||
return {"verdict": PASS, "reason": f"{action} 不在研判范围, 规则闸通过即可",
|
return {"verdict": PASS, "reason": f"{action} 不在研判范围, 规则闸通过即可",
|
||||||
|
|
@ -238,6 +250,10 @@ def request(candidate: dict, context: dict = None, *, timeout: int = None) -> di
|
||||||
# 现在候选卡的理由随硬数字送了过去, 问题就有了具体的靶子, 由 PMS 点名要它逐条回答。
|
# 现在候选卡的理由随硬数字送了过去, 问题就有了具体的靶子, 由 PMS 点名要它逐条回答。
|
||||||
"must_answer": must_answer_for(action),
|
"must_answer": must_answer_for(action),
|
||||||
}
|
}
|
||||||
|
if force:
|
||||||
|
payload["force_refresh"] = True
|
||||||
|
if reask_text:
|
||||||
|
payload["reask_text"] = str(reask_text)[:600]
|
||||||
to = int(timeout or param_store.get_int("PMS_JUDGE_TIMEOUT", 90))
|
to = int(timeout or param_store.get_int("PMS_JUDGE_TIMEOUT", 90))
|
||||||
url = base_url() + (param_store.get("PMS_JUDGE_PATH", "/api/intraday/pms_judge") or "")
|
url = base_url() + (param_store.get("PMS_JUDGE_PATH", "/api/intraday/pms_judge") or "")
|
||||||
payload = jsonable(payload) # 数据库类型 (Decimal/日期) 换成 JSON 认得的, 见 jsonable
|
payload = jsonable(payload) # 数据库类型 (Decimal/日期) 换成 JSON 认得的, 见 jsonable
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,18 @@ RUNTIME_EXTRA = {
|
||||||
# 2026-09-09: 点股票看它今天全部信号 (台账 055)。快照口径只留每类最新 N 条, 到下午
|
# 2026-09-09: 点股票看它今天全部信号 (台账 055)。快照口径只留每类最新 N 条, 到下午
|
||||||
# 一只活跃票早盘的信号早被挤掉; 按代码回扫是另一条路径, 上限单独可调。
|
# 一只活跃票早盘的信号早被挤掉; 按代码回扫是另一条路径, 上限单独可调。
|
||||||
"PMS_UPSTREAM_BYCODE_SCAN": (5000, int, "单票信号回扫上限: 点开一只票时每条上游流最多倒序回扫 N 条 (分页 500/次)。只在点击时触发, 不进轮询; 调大更全但更慢"),
|
"PMS_UPSTREAM_BYCODE_SCAN": (5000, int, "单票信号回扫上限: 点开一只票时每条上游流最多倒序回扫 N 条 (分页 500/次)。只在点击时触发, 不进轮询; 调大更全但更慢"),
|
||||||
|
# ── 2026-09-09 解锁重问 (台账 055): 研判驳回后的当日闸不再是死的, 输入确实变了就放一次。
|
||||||
|
# 八个键一次定死, 不按复盘读数回调 —— 要调先记台账。
|
||||||
|
"PMS_OPEN_REASK_ENABLED": (True, bool, "解锁重问总开关: 新建仓被研判驳回后, 盘中输入确实变了就解除当日闸、重问一次。关掉就回到「驳回即当天不再问」"),
|
||||||
|
"PMS_OPEN_REASK_MIN_GAP_MIN": (20, int, "距上次驳回至少多少分钟才准重问。20 分钟等于四根五分钟线, 够量能确认成形"),
|
||||||
|
"PMS_OPEN_REASK_MAX_PER_DAY": (1, int, "每只票每天最多重问几次。填 1 是硬约束: 两次以上等于取消当日闸, 而当日闸挡的是账本刷屏这个真问题"),
|
||||||
|
"PMS_OPEN_REASK_WINDOW": ("0945-1430", str, "重问时段。早于 09:45 时段折算量比不可靠; 晚于 14:30 即便放行也来不及走完提议到拍板到下单"),
|
||||||
|
"PMS_OPEN_REASK_REF_DRIFT": (0.03, float, "解锁条件二: 昨夜支撑或压力相对驳回时漂过这个比例就算底牌变了。与 PMS_OPEN_REF_DRIFT_MAX 同值同语义, 复盘能对照"),
|
||||||
|
"PMS_OPEN_REASK_UP": (0.02, float, "解锁条件三之一: 现价较驳回时至少涨这么多。低于 2% 是噪声"),
|
||||||
|
"PMS_OPEN_REASK_VOL_MIN": (1.5, float, "解锁条件三之二: 时段折算量比的绝对下限"),
|
||||||
|
"PMS_OPEN_REASK_VOL_MULT": (1.3, float, "解锁条件三之三: 量比要涨到驳回那一刻的几倍。保证是相对变强, 不是一整天都在放量"),
|
||||||
|
# 2026-09-08 就发现没登记, 代码一直走默认值 60, 页面上改不了也写不进去。补登记。
|
||||||
|
"PMS_JUDGE_REJECT_CONF_MIN": (60, int, "新建仓的低把握驳回转交人: 研判驳回但把握度低于这个数时, 按证据不足进你的待确认队列, 不记驳回"),
|
||||||
# 2026-08-18 宏观择时层: 闸状态由 macro_service 每次扫描写入, 扫描层与策略层只读。
|
# 2026-08-18 宏观择时层: 闸状态由 macro_service 每次扫描写入, 扫描层与策略层只读。
|
||||||
# 用运行参数承载 (不进策略 state、不另建表) —— 与 PMS_STRATEGY_BUYPAUSE 同一手法。
|
# 用运行参数承载 (不进策略 state、不另建表) —— 与 PMS_STRATEGY_BUYPAUSE 同一手法。
|
||||||
"PMS_MACRO_GATE_STATE": ("", str, "宏观闸状态 (macro_service 维护的 JSON: active/ymd/原因), 页面与扫描层只读, 勿手改"),
|
"PMS_MACRO_GATE_STATE": ("", str, "宏观闸状态 (macro_service 维护的 JSON: active/ymd/原因), 页面与扫描层只读, 勿手改"),
|
||||||
|
|
@ -446,6 +458,11 @@ _RANGES = {
|
||||||
"PMS_UPSTREAM_ALERT_SCAN": (1, 20000), "PMS_UPSTREAM_ALERT_PER_CAT": (1, 1000),
|
"PMS_UPSTREAM_ALERT_SCAN": (1, 20000), "PMS_UPSTREAM_ALERT_PER_CAT": (1, 1000),
|
||||||
"PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (0, 1440), # 0=不限龄, 上限 24 小时
|
"PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (0, 1440), # 0=不限龄, 上限 24 小时
|
||||||
"PMS_UPSTREAM_BYCODE_SCAN": (100, 50000), # 下限 100 保证至少扫得到一页
|
"PMS_UPSTREAM_BYCODE_SCAN": (100, 50000), # 下限 100 保证至少扫得到一页
|
||||||
|
# 解锁重问 (2026-09-09)
|
||||||
|
"PMS_OPEN_REASK_MIN_GAP_MIN": (5, 240), "PMS_OPEN_REASK_MAX_PER_DAY": (0, 5),
|
||||||
|
"PMS_OPEN_REASK_REF_DRIFT": (0.005, 0.3), "PMS_OPEN_REASK_UP": (0.005, 0.2),
|
||||||
|
"PMS_OPEN_REASK_VOL_MIN": (0.5, 10), "PMS_OPEN_REASK_VOL_MULT": (1.0, 5),
|
||||||
|
"PMS_JUDGE_REJECT_CONF_MIN": (0, 100),
|
||||||
# 宏观择时层
|
# 宏观择时层
|
||||||
"PMS_MACRO_HOT_TH": (0, 100), "PMS_MACRO_COLD_TH": (-100, 0),
|
"PMS_MACRO_HOT_TH": (0, 100), "PMS_MACRO_COLD_TH": (-100, 0),
|
||||||
"PMS_MACRO_EXIT_BAND": (0, 100), "PMS_MACRO_CONFIRM_DAYS": (1, 10),
|
"PMS_MACRO_EXIT_BAND": (0, 100), "PMS_MACRO_CONFIRM_DAYS": (1, 10),
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ from app.core import signal_rules as sr
|
||||||
from app.core import tradedays as td
|
from app.core import tradedays as td
|
||||||
from app.repo import pms_repo
|
from app.repo import pms_repo
|
||||||
from app.services import (command_service, executor, industry, judge, market, param_store,
|
from app.services import (command_service, executor, industry, judge, market, param_store,
|
||||||
|
reask_service,
|
||||||
plan_feed, portfolio)
|
plan_feed, portfolio)
|
||||||
|
|
||||||
logger = logging.getLogger("pms.proposal")
|
logger = logging.getLogger("pms.proposal")
|
||||||
|
|
@ -96,7 +97,20 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
|
||||||
# 这四种处境完全不同 —— 在途的明天照样被挡, 被拒的日切就重新评估。从前它们在
|
# 这四种处境完全不同 —— 在途的明天照样被挡, 被拒的日切就重新评估。从前它们在
|
||||||
# skipped 里长得一模一样, 看的人判断不了这只票明天还会不会再被评估。
|
# skipped 里长得一模一样, 看的人判断不了这只票明天还会不会再被评估。
|
||||||
# 一只票既有在途指令又今天被拒过时, 显示"有在途"更贴近它此刻的实际状态。
|
# 一只票既有在途指令又今天被拒过时, 显示"有在途"更贴近它此刻的实际状态。
|
||||||
skip = {**_judge_rejected_open_keys(), **_rejected_today_keys(),
|
# 研判驳回那一份先过一遍解锁判定 (2026-09-09, 台账 055): 满足解锁条件的从跳过集合里
|
||||||
|
# 剔掉, 让它今天能被重新评估一次。**只剔研判闸这一份** —— 规则闸拒、用户驳回、在途
|
||||||
|
# 三份一个字不动 (合并顺序天然保证这点)。没有任何票解锁时行为与今天逐字相同。
|
||||||
|
_jk = _judge_rejected_open_keys()
|
||||||
|
_unlocked = {}
|
||||||
|
try:
|
||||||
|
_unlocked = reask_service.evaluate_all(_jk, now)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[新建仓] 解锁判定出错 (本轮当日闸照旧全挡): %s", e)
|
||||||
|
for _k in _unlocked:
|
||||||
|
_jk.pop(_k, None)
|
||||||
|
out["reask_unlocked"] = [{"ts_code": c, "action": a, "why": v.get("why"),
|
||||||
|
"hits": v.get("hits")} for (c, a), v in _unlocked.items()]
|
||||||
|
skip = {**_jk, **_rejected_today_keys(),
|
||||||
**_declined_today_keys(), **_inflight_keys()}
|
**_declined_today_keys(), **_inflight_keys()}
|
||||||
# 挂了 ACTIVE 交易方案(策略)的票交策略层接管, 动作引擎不再对它自动提议
|
# 挂了 ACTIVE 交易方案(策略)的票交策略层接管, 动作引擎不再对它自动提议
|
||||||
# (读库失败按空集 —— 宁可这轮不排除, 也不能因读不到把全体持仓都排除)
|
# (读库失败按空集 —— 宁可这轮不排除, 也不能因读不到把全体持仓都排除)
|
||||||
|
|
@ -154,6 +168,9 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
|
||||||
|
|
||||||
for c in list(scanned["candidates"]) + open_cands:
|
for c in list(scanned["candidates"]) + open_cands:
|
||||||
try:
|
try:
|
||||||
|
_rk = _unlocked.get((c.get("ts_code"), c.get("action")))
|
||||||
|
if _rk:
|
||||||
|
c["reask"] = _rk
|
||||||
_route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
|
_route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
|
||||||
deadline=deadline)
|
deadline=deadline)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -424,9 +441,22 @@ def disposition_snapshot(now=None) -> dict:
|
||||||
# 落库顺序同实盘 (在途最后, 覆盖被拒)。
|
# 落库顺序同实盘 (在途最后, 覆盖被拒)。
|
||||||
_jr, _rr = _today_open_reject_reasons(now)
|
_jr, _rr = _today_open_reject_reasons(now)
|
||||||
skip = {}
|
skip = {}
|
||||||
for _k in _judge_rejected_open_keys():
|
_dj = _judge_rejected_open_keys()
|
||||||
|
# 解锁重问 (2026-09-09): 判定是只读的, 这里跟扫描那边用同一个函数、同一份口径,
|
||||||
|
# 所以页面上写的"已解锁"与扫描下一跳真会重问的是同一批票, 不会两处说法打架。
|
||||||
|
_un = {}
|
||||||
|
try:
|
||||||
|
_un = reask_service.evaluate_all(_dj) or {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[处置] 解锁判定出错 (按未解锁显示): %s", e)
|
||||||
|
for _k in _dj:
|
||||||
_w = _jr.get(_k[0])
|
_w = _jr.get(_k[0])
|
||||||
skip[_k] = ("决策系统判暂不建仓:" + _w) if _w else "决策系统今天判过暂不建仓,明天开盘会重新评估"
|
base = ("决策系统判暂不建仓:" + _w) if _w else "决策系统今天判过暂不建仓,明天开盘会重新评估"
|
||||||
|
if _k in _un:
|
||||||
|
skip[_k] = (base + "。不过盘中输入变了,这只票今天已解锁一次重问:"
|
||||||
|
+ str(_un[_k].get("why") or "") + "。重问放行的话会进你的待确认队列。")
|
||||||
|
else:
|
||||||
|
skip[_k] = base
|
||||||
for _k in _rejected_today_keys():
|
for _k in _rejected_today_keys():
|
||||||
_w = _rr.get(_k[0])
|
_w = _rr.get(_k[0])
|
||||||
skip[_k] = ("未通过合规检查:" + _w) if _w else "今天没通过合规检查,明天开盘会重新评估"
|
skip[_k] = ("未通过合规检查:" + _w) if _w else "今天没通过合规检查,明天开盘会重新评估"
|
||||||
|
|
@ -510,8 +540,20 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
|
||||||
f"(PMS_JUDGE_TICK_BUDGET_SEC="
|
f"(PMS_JUDGE_TICK_BUDGET_SEC="
|
||||||
f"{param_store.get_int('PMS_JUDGE_TICK_BUDGET_SEC', 150)}s)"})
|
f"{param_store.get_int('PMS_JUDGE_TICK_BUDGET_SEC', 150)}s)"})
|
||||||
return
|
return
|
||||||
|
# 解锁重问 (2026-09-09): 名额在**真的要发请求**这一刻才算用掉 —— 走到这里说明规则闸
|
||||||
|
# 已经过了, 候选也真的产出来了。解锁之后被名额或金额挡住、被规则闸拦下的那些, 什么都
|
||||||
|
# 没问过, 不该消耗这只票当天唯一的一次机会。试算一律不落账、不带强制标记。
|
||||||
|
_rk = c.get("reask") if not dry_run else None
|
||||||
|
_force, _rtext = False, None
|
||||||
|
if _rk:
|
||||||
|
if reask_service.commit(code, _rk, price):
|
||||||
|
_force = True
|
||||||
|
_rtext = reask_service.reask_text(_rk)
|
||||||
|
else:
|
||||||
|
c.pop("reask", None) # 名额没落上就不算重问, 这一跳按普通候选走
|
||||||
verdict = judge.request(c, context={"position": _judge_ctx(pos),
|
verdict = judge.request(c, context={"position": _judge_ctx(pos),
|
||||||
"recent_ledger": _recent_ledger(code)})
|
"recent_ledger": _recent_ledger(code)},
|
||||||
|
force=_force, reask_text=_rtext)
|
||||||
# 新建仓的低把握驳回改交人 (2026-09-07, 第二道保险)。择时决策系统的提示词已改成
|
# 新建仓的低把握驳回改交人 (2026-09-07, 第二道保险)。择时决策系统的提示词已改成
|
||||||
# 「证据不足以判断 → 不可用」, 但模型未必每次守得住; 它给的把握度是现成的读数,
|
# 「证据不足以判断 → 不可用」, 但模型未必每次守得住; 它给的把握度是现成的读数,
|
||||||
# 低于阈值的驳回按「不可用」处理 —— 进人的待确认队列, 不记驳回、不杀提议。
|
# 低于阈值的驳回按「不可用」处理 —— 进人的待确认队列, 不记驳回、不杀提议。
|
||||||
|
|
@ -529,11 +571,26 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
# 驳回也把把握度记进硬数字 (2026-09-08): 低把握驳回转交人那道保险 (PMS_JUDGE_REJECT_CONF_MIN)
|
# 驳回也把把握度记进硬数字 (2026-09-08): 低把握驳回转交人那道保险 (PMS_JUDGE_REJECT_CONF_MIN)
|
||||||
# 是否在起作用, 只能从驳回行的把握度分布看出来; 此前只有提议行记它, 驳回行没有, 复核无据。
|
# 是否在起作用, 只能从驳回行的把握度分布看出来; 此前只有提议行记它, 驳回行没有, 复核无据。
|
||||||
|
# 基线快照 (2026-09-09, 台账 055): 驳回那一刻的价、两个口径的当日涨幅、
|
||||||
|
# 时段折算量比、昨夜定性与支撑压力。解锁判定要拿它跟此刻的读数比 ——
|
||||||
|
# 没有这个块, "相对驳回那一刻变强了没有"就无从判起。取数失败写 None,
|
||||||
|
# 不阻断驳回落账: 记账优先于留痕。
|
||||||
|
_hn = {**(c["hard_numbers"] or {}), "judge_conf": verdict.get("confidence")}
|
||||||
|
try:
|
||||||
|
_hn["reask_base"] = reask_service.base_snapshot(
|
||||||
|
code, price, judge_conf=verdict.get("confidence"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[新建仓] 基线快照取不到 [%s] (驳回照常落账): %s", code, e)
|
||||||
|
_rsn = verdict.get("reason") or "决策系统驳回"
|
||||||
|
if c.get("reask"):
|
||||||
|
# 重问后仍驳回: **静默记账**, 页面不单列 (2026-09-09 拍板)。它就是账本里
|
||||||
|
# 又一行"决策系统驳回建新仓", 与别的驳回长得一样; 下一跳 G2 会挡住,
|
||||||
|
# 当日不再重问, 这条路彻底闭合。
|
||||||
|
_hn["reask_seq"] = 1
|
||||||
|
_rsn = "重问后仍驳回:" + _rsn
|
||||||
pms_repo.insert_ledger(ts_code=code, action=action, arbiter="judge",
|
pms_repo.insert_ledger(ts_code=code, action=action, arbiter="judge",
|
||||||
verdict="REJECT", price_at=price,
|
verdict="REJECT", price_at=price,
|
||||||
hard_numbers={**(c["hard_numbers"] or {}),
|
hard_numbers=_hn, reason=_rsn[:500])
|
||||||
"judge_conf": verdict.get("confidence")},
|
|
||||||
reason=verdict.get("reason") or "决策系统驳回")
|
|
||||||
return
|
return
|
||||||
if verdict.get("degraded"):
|
if verdict.get("degraded"):
|
||||||
out["degraded"] = True
|
out["degraded"] = True
|
||||||
|
|
@ -562,8 +619,17 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
|
||||||
# 保留下来的行为: 规则算出来的保垫减仓照旧自动执行 (它的 force_queue 是假), 风控高置信
|
# 保留下来的行为: 规则算出来的保垫减仓照旧自动执行 (它的 force_queue 是假), 风控高置信
|
||||||
# 卖出与命令清仓两条路一个字没碰。
|
# 卖出与命令清仓两条路一个字没碰。
|
||||||
src_why = ae.source_confirm_why(c.get("source"))
|
src_why = ae.source_confirm_why(c.get("source"))
|
||||||
|
# 重问放行的一律交人 (2026-09-09 拍板)。三条理由:
|
||||||
|
# 一, 重问放行的票多半现价已经高于择时买入区间上沿, 自动落指令会一路等到窗口作废,
|
||||||
|
# 期间还占着名额与预估金额, 把真正买得进的票挤掉 —— 这是实打实的损失。
|
||||||
|
# 二, "输入变了"和"证据更强了"是两回事: 解锁由代码按硬条件判, 定性由模型判, 而
|
||||||
|
# "这个变化够不够动用一个持仓名额"是边界情形, 按既有分工归人。
|
||||||
|
# 三, 这次要解决的是**连提议都不出**, 不是"一定要买到"。把牌摊在人面前, 人拍板;
|
||||||
|
# 拍了板买不上是择时区间和追高原则在起作用, 那两条一个字没改。
|
||||||
|
reask_why = ("盘中重问放行,交你拍板:" + str((c.get("reask") or {}).get("why") or "")
|
||||||
|
+ "。能不能买到由择时区间决定,可能当日买不上。") if c.get("reask") else None
|
||||||
force_queue = (bool(c.get("needs_user_confirm")) or bool(src_why)
|
force_queue = (bool(c.get("needs_user_confirm")) or bool(src_why)
|
||||||
or bool(verdict.get("degraded")))
|
or bool(verdict.get("degraded")) or bool(reask_why))
|
||||||
auto_exec = (not force_queue) and (side == "sell" or autonomy == AUTONOMY_FULL)
|
auto_exec = (not force_queue) and (side == "sell" or autonomy == AUTONOMY_FULL)
|
||||||
# 自动执行开关 (2026-09-03, PMS_OPEN_AUTO_EXEC_ON_VERDICT): 新建仓档位是 propose_only 时,
|
# 自动执行开关 (2026-09-03, PMS_OPEN_AUTO_EXEC_ON_VERDICT): 新建仓档位是 propose_only 时,
|
||||||
# 「判决候选 + 决策系统研判真回了通过 + 规则闸通过 (走到这里就是通过了) + 上游风险列表
|
# 「判决候选 + 决策系统研判真回了通过 + 规则闸通过 (走到这里就是通过了) + 上游风险列表
|
||||||
|
|
@ -599,7 +665,8 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
|
||||||
pid = _make_proposal(c, price, verdict)
|
pid = _make_proposal(c, price, verdict)
|
||||||
# 交人的原因按从具体到笼统取: 候选自带的 (关注判决等, 见 action_engine.verdict_confirm_why)
|
# 交人的原因按从具体到笼统取: 候选自带的 (关注判决等, 见 action_engine.verdict_confirm_why)
|
||||||
# → 来源强制的 (研究证据走弱那类减持) → 深档补仓那条老规矩 → 研判不可用 → 档位。
|
# → 来源强制的 (研究证据走弱那类减持) → 深档补仓那条老规矩 → 研判不可用 → 档位。
|
||||||
why = (c.get("confirm_why") or src_why
|
# 重问放行排在最前: 它是这张提议最需要人知道的那件事 (曾被驳回、什么变了、可能买不上)
|
||||||
|
why = (reask_why or c.get("confirm_why") or src_why
|
||||||
or ("深档补仓强制确认" if c.get("needs_user_confirm") else None)
|
or ("深档补仓强制确认" if c.get("needs_user_confirm") else None)
|
||||||
or ("研判不可用, 降级人工确认" if verdict.get("degraded") else None)
|
or ("研判不可用, 降级人工确认" if verdict.get("degraded") else None)
|
||||||
or ("新建仓档位 propose_only" if is_open else "档位 propose_only"))
|
or ("新建仓档位 propose_only" if is_open else "档位 propose_only"))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
# -*- 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]
|
||||||
|
|
@ -2408,7 +2408,7 @@ createApp({
|
||||||
const T = {
|
const T = {
|
||||||
// 账本的动作有十六个:八个真动作(planner.py:31 那批常量)加八个伪动作
|
// 账本的动作有十六个:八个真动作(planner.py:31 那批常量)加八个伪动作
|
||||||
// (账本里直写的字面量,例如对账修正、信号留痕)。少一个就会在页面上露原码。
|
// (账本里直写的字面量,例如对账修正、信号留痕)。少一个就会在页面上露原码。
|
||||||
action: { OPEN:'建新仓', FILL:'回落补足', ADD:'盈利加仓', DCA:'浮亏补仓', TRIM:'保盈减仓',
|
action: { OPEN:'建新仓', OPEN_REASK:'重问研判', FILL:'回落补足', ADD:'盈利加仓', DCA:'浮亏补仓', TRIM:'保盈减仓',
|
||||||
EXIT:'清仓', T0:'做T', T0_ROUND:'做T', SWITCH:'换仓', HALT:'停手',
|
EXIT:'清仓', T0:'做T', T0_ROUND:'做T', SWITCH:'换仓', HALT:'停手',
|
||||||
RECON:'对账修正', CLEANUP:'收尾清理', MACRO:'大盘冷热',
|
RECON:'对账修正', CLEANUP:'收尾清理', MACRO:'大盘冷热',
|
||||||
SIGNAL:'信号留痕', SIGNAL_BUY:'买入信号',
|
SIGNAL:'信号留痕', SIGNAL_BUY:'买入信号',
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@
|
||||||
强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱
|
强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱
|
||||||
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流/
|
来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流/
|
||||||
到价提议挂着时止损照落并作废该提议 (17 例)
|
到价提议挂着时止损照落并作废该提议 (17 例)
|
||||||
|
test_batch25_units.py 解锁重问 (2026-09-09 方案第四之一节): 时段占比与量比换算/四条护栏/
|
||||||
|
三条解锁条件与 fail-closed/请求体 force 为假时逐字节相同/名额只在真发
|
||||||
|
请求时用掉/判定零副作用/重问放行强制交人/九个参数登记 (14 例)
|
||||||
test_batch24_units.py 点股票看它今天全部信号 (2026-09-09 方案第三节): 代码归一与跨市场不误配/
|
test_batch24_units.py 点股票看它今天全部信号 (2026-09-09 方案第三节): 代码归一与跨市场不误配/
|
||||||
分页回扫边界与按今天零点截断/五源解析与超龄标陈旧/去重同源合并跨源不合并/
|
分页回扫边界与按今天零点截断/五源解析与超龄标陈旧/去重同源合并跨源不合并/
|
||||||
单源失败逐源标明与按票缓存/只读守卫/页面三处入口与层级 (9 例)
|
单源失败逐源标明与按票缓存/只读守卫/页面三处入口与层级 (9 例)
|
||||||
|
|
@ -116,7 +119,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
|
||||||
"test_batch16_units.py", "test_batch17_units.py",
|
"test_batch16_units.py", "test_batch17_units.py",
|
||||||
"test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py",
|
"test_batch18_units.py", "test_batch19_units.py", "test_batch20_units.py",
|
||||||
"test_batch21_units.py", "test_batch22_units.py", "test_batch23_units.py",
|
"test_batch21_units.py", "test_batch22_units.py", "test_batch23_units.py",
|
||||||
"test_batch24_units.py",
|
"test_batch24_units.py", "test_batch25_units.py",
|
||||||
"test_page_enum_guard.py",
|
"test_page_enum_guard.py",
|
||||||
"test_wiring.py"]
|
"test_wiring.py"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,313 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""解锁重问 (2026-09-09 方案第四之一节, 台账 055)。全部离线, 不连库不连 Redis 不调模型。
|
||||||
|
|
||||||
|
A 纯逻辑: 时段占比的四个边界、量比的单位换算与两条哨兵、窗口解析。
|
||||||
|
B 四条护栏: 总开关、二十分钟间隔、当日一次、时段窗口, 各自的 blocked_by。
|
||||||
|
C 三条解锁条件: U1 的先后与发送方、U2 的定性翻转与漂移边界、U3 的三条并列与 fail-closed。
|
||||||
|
D 请求体: force 为假时与今天逐字节相同; 为真时只多两个键; 白名单收了那一句。
|
||||||
|
E 名额与落账: 名额只在真发请求时用掉、试算不落账、规则闸拦下不消耗、重问后仍驳回加前缀。
|
||||||
|
F 分流: 重问放行强制交人, 哪怕档位 full。
|
||||||
|
G 参数与页面: 九个键登记齐、页面认得新动作名。
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.core import reask_rules as rr # noqa: E402
|
||||||
|
from app.services import param_store # noqa: E402
|
||||||
|
from app.services import reask_service as rs # noqa: E402
|
||||||
|
|
||||||
|
RESULTS = []
|
||||||
|
|
||||||
|
|
||||||
|
def case(name):
|
||||||
|
def deco(fn):
|
||||||
|
RESULTS.append((name, fn))
|
||||||
|
return fn
|
||||||
|
return deco
|
||||||
|
|
||||||
|
|
||||||
|
class _Patch:
|
||||||
|
def __init__(self): self._saved = []
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __call__(self, obj, name, value):
|
||||||
|
self._saved.append((obj, name, getattr(obj, name))); setattr(obj, name, value)
|
||||||
|
def __exit__(self, *a):
|
||||||
|
for obj, name, v in reversed(self._saved):
|
||||||
|
setattr(obj, name, v)
|
||||||
|
|
||||||
|
|
||||||
|
def _t(h, m):
|
||||||
|
return datetime(2026, 9, 9, h, m, 0)
|
||||||
|
|
||||||
|
|
||||||
|
P = {"enabled": True, "min_gap_min": 20, "max_per_day": 1, "window": "0945-1430",
|
||||||
|
"ref_drift": 0.03, "up_min": 0.02, "vol_min": 1.5, "vol_mult": 1.3}
|
||||||
|
|
||||||
|
|
||||||
|
def _base(**kw):
|
||||||
|
b = {"at": "2026-09-09T10:12:00", "price": 43.15, "chg_prev": 0.076, "chg_open": 0.041,
|
||||||
|
"vol_ratio": 1.10, "judge_conf": 72, "y_signal": "WATCH",
|
||||||
|
"support": 40.00, "pressure": 42.00}
|
||||||
|
b.update(kw)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def _now(**kw):
|
||||||
|
n = {"price": 43.15, "vol_ratio": 1.10, "y_signal": "WATCH", "support": 40.00,
|
||||||
|
"pressure": 42.00, "bionic_buy_at": ""}
|
||||||
|
n.update(kw)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
@case("A 时段占比: 盘前 0、上午末 0.5、午休不走、收盘 1")
|
||||||
|
def test_frac():
|
||||||
|
assert rr.elapsed_trading_frac(_t(9, 0)) == 0.0
|
||||||
|
assert rr.elapsed_trading_frac(_t(9, 30)) == 0.0
|
||||||
|
assert abs(rr.elapsed_trading_frac(_t(10, 30)) - 60 / 240.0) < 1e-9
|
||||||
|
assert abs(rr.elapsed_trading_frac(_t(11, 30)) - 0.5) < 1e-9
|
||||||
|
assert abs(rr.elapsed_trading_frac(_t(12, 0)) - 0.5) < 1e-9, "午休不该往前走"
|
||||||
|
assert abs(rr.elapsed_trading_frac(_t(13, 0)) - 0.5) < 1e-9
|
||||||
|
assert rr.elapsed_trading_frac(_t(15, 0)) == 1.0
|
||||||
|
assert rr.elapsed_trading_frac(_t(16, 0)) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@case("A 量比: 手与股差 100 倍的换算、两条哨兵、覆盖太短不判")
|
||||||
|
def test_vol_ratio():
|
||||||
|
# 1736 万股 = 17.36 万手; 五日均量 18.5 万手; 覆盖 0.829 → 1.128 (09-09 四方股份实测值)
|
||||||
|
assert rr.vol_ratio(17361626, 185554.7, 0.8292) == 1.128, rr.vol_ratio(17361626, 185554.7, 0.8292)
|
||||||
|
assert rr.vol_ratio(100 * 185554.7, 185554.7, 1.0) == 1.0, "整整一份日均量、走完全天 = 1.0"
|
||||||
|
assert rr.vol_ratio(17361626, 185554.7, 0.05) is None, "覆盖不足 8% 不判"
|
||||||
|
assert rr.vol_ratio(100 * 185554.7 * 31, 185554.7, 1.0) is None, "原始比超 30 判数据异常"
|
||||||
|
for bad in ((None, 1.0, 1.0), (1.0, None, 1.0), (1.0, 1.0, None), (1.0, 0, 1.0), (-1, 1.0, 1.0)):
|
||||||
|
assert rr.vol_ratio(*bad) is None, bad
|
||||||
|
|
||||||
|
|
||||||
|
@case("A 窗口解析: 边界含端点; 格式写坏了不堵路")
|
||||||
|
def test_window():
|
||||||
|
assert rr.in_window(_t(9, 45), "0945-1430") and rr.in_window(_t(14, 30), "0945-1430")
|
||||||
|
assert not rr.in_window(_t(9, 44), "0945-1430") and not rr.in_window(_t(14, 31), "0945-1430")
|
||||||
|
assert rr.in_window(_t(9, 0), "写坏了"), "格式错不该把整条路堵死"
|
||||||
|
|
||||||
|
|
||||||
|
@case("B 四条护栏各自挡住并说清是哪一条")
|
||||||
|
def test_gates():
|
||||||
|
off = dict(P, enabled=False)
|
||||||
|
r = rr.evaluate(base=_base(), now_read=_now(), params=off, used_today=0, now=_t(11, 0))
|
||||||
|
assert r["blocked_by"] == "G0" and not r["unlock"]
|
||||||
|
r = rr.evaluate(base=_base(), now_read=_now(), params=P, used_today=0, now=_t(10, 25))
|
||||||
|
assert r["blocked_by"] == "G1" and not r["unlock"], r
|
||||||
|
r = rr.evaluate(base=_base(), now_read=_now(), params=P, used_today=1, now=_t(11, 0))
|
||||||
|
assert r["blocked_by"] == "G2" and not r["unlock"], r
|
||||||
|
r = rr.evaluate(base=_base(), now_read=_now(), params=P, used_today=0, now=_t(14, 45))
|
||||||
|
assert r["blocked_by"] == "G3" and not r["unlock"], r
|
||||||
|
# 20 分钟整点即可
|
||||||
|
r = rr.evaluate(base=_base(), now_read=_now(), params=P, used_today=0, now=_t(10, 32))
|
||||||
|
assert r["blocked_by"] is None, r
|
||||||
|
|
||||||
|
|
||||||
|
@case("C U1: 晚于驳回才算; 早于驳回是循环论证; 没有留痕不成立")
|
||||||
|
def test_u1():
|
||||||
|
late = rr.evaluate(base=_base(), now_read=_now(bionic_buy_at="2026-09-09T10:40:00"),
|
||||||
|
params=P, used_today=0, now=_t(11, 0))
|
||||||
|
assert late["unlock"] and late["hits"] == ["U1"], late
|
||||||
|
early = rr.evaluate(base=_base(), now_read=_now(bionic_buy_at="2026-09-09T10:04:00"),
|
||||||
|
params=P, used_today=0, now=_t(11, 0))
|
||||||
|
assert not early["unlock"] and "循环论证" not in early["why"], early
|
||||||
|
none = rr.evaluate(base=_base(), now_read=_now(), params=P, used_today=0, now=_t(11, 0))
|
||||||
|
assert not none["unlock"]
|
||||||
|
# 驳回时刻读不到时判不了先后 → 不成立 (fail-closed)
|
||||||
|
nb = rr.evaluate(base=_base(at=""), now_read=_now(bionic_buy_at="2026-09-09T10:40:00"),
|
||||||
|
params=P, used_today=0, now=_t(11, 0))
|
||||||
|
assert not nb["unlock"] and nb["notes"], nb
|
||||||
|
|
||||||
|
|
||||||
|
@case("C U2: 定性翻成看多、支撑压力漂过 3%; 2.9% 不算")
|
||||||
|
def test_u2():
|
||||||
|
up = rr.evaluate(base=_base(), now_read=_now(y_signal="BUY"), params=P, used_today=0, now=_t(11, 0))
|
||||||
|
assert up["unlock"] and up["hits"] == ["U2"], up
|
||||||
|
assert not rr.evaluate(base=_base(y_signal="BUY"), now_read=_now(y_signal="BUY"),
|
||||||
|
params=P, used_today=0, now=_t(11, 0))["unlock"], "本来就是 BUY 不算改写"
|
||||||
|
d29 = rr.evaluate(base=_base(), now_read=_now(support=40.00 * 1.029), params=P, used_today=0, now=_t(11, 0))
|
||||||
|
assert not d29["unlock"], d29
|
||||||
|
d31 = rr.evaluate(base=_base(), now_read=_now(support=40.00 * 1.031), params=P, used_today=0, now=_t(11, 0))
|
||||||
|
assert d31["unlock"] and d31["hits"] == ["U2"], d31
|
||||||
|
dn = rr.evaluate(base=_base(), now_read=_now(pressure=42.00 * 0.96), params=P, used_today=0, now=_t(11, 0))
|
||||||
|
assert dn["unlock"], "往下漂同样是底牌变了"
|
||||||
|
|
||||||
|
|
||||||
|
@case("C U3: 三条并列才算; 少一条就不解锁; 读不到一律不成立")
|
||||||
|
def test_u3():
|
||||||
|
ok = rr.evaluate(base=_base(), now_read=_now(price=44.10, vol_ratio=1.60),
|
||||||
|
params=P, used_today=0, now=_t(11, 0))
|
||||||
|
assert ok["unlock"] and ok["hits"] == ["U3"], ok
|
||||||
|
# 只涨不放量
|
||||||
|
assert not rr.evaluate(base=_base(), now_read=_now(price=44.10, vol_ratio=1.20),
|
||||||
|
params=P, used_today=0, now=_t(11, 0))["unlock"]
|
||||||
|
# 放量但没涨够 (09-09 四方股份下午就是这个形态的反面: 量比 1.128 且价格还跌了)
|
||||||
|
assert not rr.evaluate(base=_base(), now_read=_now(price=43.50, vol_ratio=1.60),
|
||||||
|
params=P, used_today=0, now=_t(11, 0))["unlock"]
|
||||||
|
# 量比够 1.5 但没到驳回时的 1.3 倍
|
||||||
|
assert not rr.evaluate(base=_base(vol_ratio=1.40), now_read=_now(price=44.10, vol_ratio=1.55),
|
||||||
|
params=P, used_today=0, now=_t(11, 0))["unlock"]
|
||||||
|
# fail-closed: 量比读不到、驳回时没记量比、价格读不到, 三种都不成立
|
||||||
|
for nr, bs in ((_now(price=44.10, vol_ratio=None), _base()),
|
||||||
|
(_now(price=44.10, vol_ratio=1.60), _base(vol_ratio=None)),
|
||||||
|
(_now(price=None, vol_ratio=1.60), _base())):
|
||||||
|
r = rr.evaluate(base=bs, now_read=nr, params=P, used_today=0, now=_t(11, 0))
|
||||||
|
assert not r["unlock"], (r, nr, bs)
|
||||||
|
|
||||||
|
|
||||||
|
@case("C 多条同时成立时都记下来 (复盘要知道是哪几条一起促成的)")
|
||||||
|
def test_multi_hit():
|
||||||
|
r = rr.evaluate(base=_base(), now_read=_now(price=44.10, vol_ratio=1.60, y_signal="BUY",
|
||||||
|
bionic_buy_at="2026-09-09T10:40:00"),
|
||||||
|
params=P, used_today=0, now=_t(11, 0))
|
||||||
|
assert r["unlock"] and set(r["hits"]) == {"U1", "U2", "U3"}, r
|
||||||
|
|
||||||
|
|
||||||
|
@case("D 请求体: force 为假时与今天逐字节相同; 为真时只多两个键")
|
||||||
|
def test_payload():
|
||||||
|
from app.services import judge as jd
|
||||||
|
cand = {"ts_code": "601126.SH", "action": "OPEN", "qty": 100, "reason": "x",
|
||||||
|
"hard_numbers": {"price": 43.15}}
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
status_code = 200
|
||||||
|
def raise_for_status(self): pass
|
||||||
|
def json(self): return {"verdict": "PASS", "reason": "ok", "confidence": 80}
|
||||||
|
|
||||||
|
import types
|
||||||
|
fake_requests = types.SimpleNamespace(post=lambda url, json=None, timeout=None:
|
||||||
|
(seen.setdefault(len(seen), json), _Resp())[1])
|
||||||
|
with _Patch() as p:
|
||||||
|
p(jd, "available", lambda: True)
|
||||||
|
p(jd, "base_url", lambda: "http://x")
|
||||||
|
sys.modules["requests"] = fake_requests
|
||||||
|
jd.request(dict(cand))
|
||||||
|
jd.request(dict(cand), force=True, reask_text="曾驳回于 43.15,现价 44.10")
|
||||||
|
plain, forced = seen[0], seen[1]
|
||||||
|
assert "force_refresh" not in plain and "reask_text" not in plain, plain
|
||||||
|
assert forced["force_refresh"] is True and forced["reask_text"].startswith("曾驳回")
|
||||||
|
assert set(forced) - set(plain) == {"force_refresh", "reask_text"}, set(forced) - set(plain)
|
||||||
|
for k in plain:
|
||||||
|
assert plain[k] == forced[k] or k == "hard_numbers", k
|
||||||
|
assert "reask_text" in jd.OPEN_JUDGE_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
@case("E 名额: 真发请求才落账; 落账失败就不算重问; 动作名不与建新仓混")
|
||||||
|
def test_commit():
|
||||||
|
calls = []
|
||||||
|
with _Patch() as p:
|
||||||
|
p(rs.pms_repo, "insert_ledger", lambda **kw: calls.append(kw) or 1)
|
||||||
|
assert rs.commit("601126.SH", {"hits": ["U3"], "why": "价升量增"}, 44.1) is True
|
||||||
|
assert calls[0]["action"] == "OPEN_REASK" != "OPEN", calls[0]
|
||||||
|
assert calls[0]["verdict"] == "NOTE" and calls[0]["arbiter"] == "rule"
|
||||||
|
assert "U3" in calls[0]["reason"] and calls[0]["hard_numbers"]["reask"]["hits"] == ["U3"]
|
||||||
|
|
||||||
|
def boom(**kw): raise RuntimeError("表写不进")
|
||||||
|
with _Patch() as p:
|
||||||
|
p(rs.pms_repo, "insert_ledger", boom)
|
||||||
|
assert rs.commit("601126.SH", {}, 1.0) is False, "落账失败必须回 False, 让调用方别重问"
|
||||||
|
|
||||||
|
|
||||||
|
@case("E 判定零副作用: evaluate_all 一行都不写")
|
||||||
|
def test_readonly():
|
||||||
|
wrote = []
|
||||||
|
with _Patch() as p:
|
||||||
|
p(rs.pms_repo, "insert_ledger", lambda **kw: wrote.append(kw))
|
||||||
|
p(rs.pms_repo, "judge_rejected_open_rows",
|
||||||
|
lambda since: {"601126.SH": {"at": "2026-09-09 10:12:00", "price": 43.15,
|
||||||
|
"hard": {"reask_base": _base()}}})
|
||||||
|
p(rs.pms_repo, "reask_count_today", lambda since: {})
|
||||||
|
p(rs.pms_repo, "buy_signals_today", lambda since: {})
|
||||||
|
p(rs, "_now_read", lambda code, at: _now(price=44.10, vol_ratio=1.60))
|
||||||
|
p(rs, "params", lambda: P)
|
||||||
|
out = rs.evaluate_all({("601126.SH", "OPEN"): "驳回"}, now=_t(11, 0))
|
||||||
|
assert ("601126.SH", "OPEN") in out and out[("601126.SH", "OPEN")]["hits"] == ["U3"], out
|
||||||
|
assert not wrote, "解锁判定每分钟都在跑, 一行都不许写"
|
||||||
|
|
||||||
|
|
||||||
|
@case("E 读账本失败一律不解锁 (宁可漏, 不可误)")
|
||||||
|
def test_repo_fail():
|
||||||
|
def boom(since): raise RuntimeError("库抖")
|
||||||
|
with _Patch() as p:
|
||||||
|
p(rs.pms_repo, "judge_rejected_open_rows", boom)
|
||||||
|
p(rs, "params", lambda: P)
|
||||||
|
assert rs.evaluate_all({("601126.SH", "OPEN"): "驳回"}, now=_t(11, 0)) == {}
|
||||||
|
|
||||||
|
|
||||||
|
@case("E 只认决策系统发的转多留痕 (别的来源不算 U1)")
|
||||||
|
def test_u1_sender():
|
||||||
|
with _Patch() as p:
|
||||||
|
p(rs.pms_repo, "judge_rejected_open_rows",
|
||||||
|
lambda since: {"601126.SH": {"at": "2026-09-09 10:12:00", "price": 43.15,
|
||||||
|
"hard": {"reask_base": _base()}}})
|
||||||
|
p(rs.pms_repo, "reask_count_today", lambda since: {})
|
||||||
|
p(rs.pms_repo, "buy_signals_today",
|
||||||
|
lambda since: {"601126.SH": {"at": "2026-09-09 10:40:00", "reason": "别处发来的看多"}})
|
||||||
|
p(rs, "_now_read", lambda code, at: _now(bionic_buy_at=at))
|
||||||
|
p(rs, "params", lambda: P)
|
||||||
|
assert rs.evaluate_all({("601126.SH", "OPEN"): "驳回"}, now=_t(11, 0)) == {}, "发送方不对不算"
|
||||||
|
|
||||||
|
|
||||||
|
@case("F 重问放行强制交人; 重问仍驳回加前缀且带次序")
|
||||||
|
def test_route():
|
||||||
|
src = io.open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"app/services/proposal_service.py"), encoding="utf-8").read()
|
||||||
|
assert 'or bool(reask_why))' in src, "重问必须进 force_queue 一票否决"
|
||||||
|
assert 'reask_why or c.get("confirm_why")' in src, "重问的原因要排在最前, 人最需要知道它"
|
||||||
|
assert '"重问后仍驳回:" + _rsn' in src and '_hn["reask_seq"] = 1' in src
|
||||||
|
assert 'reask_service.commit(code, _rk, price)' in src
|
||||||
|
i = src.index("_rk = c.get(\"reask\") if not dry_run else None")
|
||||||
|
j = src.index("verdict = judge.request(c,")
|
||||||
|
assert "dry_run" in src[i:i + 60], "试算一律不落账不带强制标记"
|
||||||
|
assert j > i, "名额必须在发请求之前落"
|
||||||
|
# 规则闸在研判闸前面, 且被规则闸拦下的直接 return —— 不会走到落名额那一步
|
||||||
|
assert src.index('if not gate["passed"]:') < i
|
||||||
|
|
||||||
|
|
||||||
|
@case("G 九个参数登记齐、有范围、页面认得新动作名")
|
||||||
|
def test_params_and_page():
|
||||||
|
keys = ["PMS_OPEN_REASK_ENABLED", "PMS_OPEN_REASK_MIN_GAP_MIN", "PMS_OPEN_REASK_MAX_PER_DAY",
|
||||||
|
"PMS_OPEN_REASK_WINDOW", "PMS_OPEN_REASK_REF_DRIFT", "PMS_OPEN_REASK_UP",
|
||||||
|
"PMS_OPEN_REASK_VOL_MIN", "PMS_OPEN_REASK_VOL_MULT", "PMS_JUDGE_REJECT_CONF_MIN"]
|
||||||
|
for k in keys:
|
||||||
|
assert k in param_store.RUNTIME_EXTRA, k
|
||||||
|
assert param_store.RUNTIME_EXTRA[k][2], k + " 没有中文说明"
|
||||||
|
for k in keys:
|
||||||
|
if param_store.RUNTIME_EXTRA[k][1] in (int, float):
|
||||||
|
assert k in param_store._RANGES, k + " 没有范围"
|
||||||
|
p = rs.params()
|
||||||
|
assert set(p) == {"enabled", "min_gap_min", "max_per_day", "window", "ref_drift",
|
||||||
|
"up_min", "vol_min", "vol_mult"}, p
|
||||||
|
from app.web import main as web
|
||||||
|
html = io.open(os.path.join(web.STATIC_DIR, "index.html"), encoding="utf-8").read()
|
||||||
|
assert "OPEN_REASK:'重问研判'" in html, "页面不认这个动作名就会把英文原码印给人看"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ok = 0
|
||||||
|
for name, fn in RESULTS:
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
ok += 1
|
||||||
|
print(" ok " + name)
|
||||||
|
except Exception:
|
||||||
|
print(" FAIL " + name)
|
||||||
|
traceback.print_exc()
|
||||||
|
print("-" * 60)
|
||||||
|
if ok == len(RESULTS):
|
||||||
|
print("ALL PASS (%d cases)" % ok)
|
||||||
|
return 0
|
||||||
|
print("FAILED %d/%d" % (len(RESULTS) - ok, len(RESULTS)))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Reference in New Issue