解锁重问:判定、取数、接线、参数、单测(台账 055 第二件之 PMS 侧)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-09 15:13:09 +08:00
parent e83422e7ab
commit 6f416ead6b
8 changed files with 676 additions and 14 deletions

View File

@ -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}}。

View File

@ -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

View File

@ -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),

View File

@ -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"))

View File

@ -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 (价升且量能确认) 就自动不成立, 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]

View File

@ -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:'买入信号',

View File

@ -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"]

View File

@ -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())