diff --git a/app/repo/pms_repo.py b/app/repo/pms_repo.py index e1f2318..61212ed 100644 --- a/app/repo/pms_repo.py +++ b/app/repo/pms_repo.py @@ -618,6 +618,48 @@ def judge_rejected_today(since) -> set: 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: """今天决策系统判过「盘中转多」的票 → {代码: {reason, price, at}}。 diff --git a/app/services/judge.py b/app/services/judge.py index 0b16682..5e33cc5 100644 --- a/app/services/judge.py +++ b/app/services/judge.py @@ -54,7 +54,10 @@ OPEN_JUDGE_KEYS = ("price", "score", "theme", "tier", "upside", "heat", "news_text", # 公司深度整句 (2026-09-09 接入方案第四节): 质地档、买方论点一句、置信度、失效条件。照 09-02 送判决 # 与理由的先例——让研判拿自己的量价结论对照; 质地本身由选股系统判, 研判不重判。开关 PMS_JUDGE_COMPANY_TEXT。 - "company_review_text") + "company_review_text", + # 重问时的那一句 (2026-09-09 台账 055): 曾在什么价被驳回、现在价量变成什么样。 + # 只在解锁重问那一次带; 平时这个键根本不在硬数字里, 白名单收了也送不出去。 + "reask_text") # 新建仓送研判时的必答题 (2026-09-03)。候选卡的理由随硬数字送过去了, 研判要逐条回答 # 「这条理由到今天还成不成立」—— 这是把上游的静态判决与决策系统的当日观点接起来的那一句。 @@ -214,8 +217,17 @@ def must_answer_for(action: str) -> list: return [] -def request(candidate: dict, context: dict = None, *, timeout: int = None) -> dict: - """请求一次研判。任何异常/超时/未接通都返回 UNAVAILABLE (绝不把提议当成通过)。""" +def request(candidate: dict, context: dict = None, *, timeout: int = None, + 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() if action not in judged_actions(): return {"verdict": PASS, "reason": f"{action} 不在研判范围, 规则闸通过即可", @@ -238,6 +250,10 @@ def request(candidate: dict, context: dict = None, *, timeout: int = None) -> di # 现在候选卡的理由随硬数字送了过去, 问题就有了具体的靶子, 由 PMS 点名要它逐条回答。 "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)) url = base_url() + (param_store.get("PMS_JUDGE_PATH", "/api/intraday/pms_judge") or "") payload = jsonable(payload) # 数据库类型 (Decimal/日期) 换成 JSON 认得的, 见 jsonable diff --git a/app/services/param_store.py b/app/services/param_store.py index d54cc5b..cd1d09b 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -58,6 +58,18 @@ RUNTIME_EXTRA = { # 2026-09-09: 点股票看它今天全部信号 (台账 055)。快照口径只留每类最新 N 条, 到下午 # 一只活跃票早盘的信号早被挤掉; 按代码回扫是另一条路径, 上限单独可调。 "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 每次扫描写入, 扫描层与策略层只读。 # 用运行参数承载 (不进策略 state、不另建表) —— 与 PMS_STRATEGY_BUYPAUSE 同一手法。 "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_MAX_AGE_MIN": (0, 1440), # 0=不限龄, 上限 24 小时 "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_EXIT_BAND": (0, 100), "PMS_MACRO_CONFIRM_DAYS": (1, 10), diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index f0283fe..9ad5e08 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -40,6 +40,7 @@ from app.core import signal_rules as sr from app.core import tradedays as td from app.repo import pms_repo from app.services import (command_service, executor, industry, judge, market, param_store, + reask_service, plan_feed, portfolio) logger = logging.getLogger("pms.proposal") @@ -96,7 +97,20 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict: # 这四种处境完全不同 —— 在途的明天照样被挡, 被拒的日切就重新评估。从前它们在 # 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()} # 挂了 ACTIVE 交易方案(策略)的票交策略层接管, 动作引擎不再对它自动提议 # (读库失败按空集 —— 宁可这轮不排除, 也不能因读不到把全体持仓都排除) @@ -154,6 +168,9 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict: for c in list(scanned["candidates"]) + open_cands: 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, deadline=deadline) except Exception as e: @@ -424,9 +441,22 @@ def disposition_snapshot(now=None) -> dict: # 落库顺序同实盘 (在途最后, 覆盖被拒)。 _jr, _rr = _today_open_reject_reasons(now) 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]) - 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(): _w = _rr.get(_k[0]) 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"{param_store.get_int('PMS_JUDGE_TICK_BUDGET_SEC', 150)}s)"}) 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), - "recent_ledger": _recent_ledger(code)}) + "recent_ledger": _recent_ledger(code)}, + force=_force, reask_text=_rtext) # 新建仓的低把握驳回改交人 (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: # 驳回也把把握度记进硬数字 (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", verdict="REJECT", price_at=price, - hard_numbers={**(c["hard_numbers"] or {}), - "judge_conf": verdict.get("confidence")}, - reason=verdict.get("reason") or "决策系统驳回") + hard_numbers=_hn, reason=_rsn[:500]) return if verdict.get("degraded"): out["degraded"] = True @@ -562,8 +619,17 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out, # 保留下来的行为: 规则算出来的保垫减仓照旧自动执行 (它的 force_queue 是假), 风控高置信 # 卖出与命令清仓两条路一个字没碰。 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) - 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) # 自动执行开关 (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) # 交人的原因按从具体到笼统取: 候选自带的 (关注判决等, 见 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 verdict.get("degraded") else None) or ("新建仓档位 propose_only" if is_open else "档位 propose_only")) diff --git a/app/services/reask_service.py b/app/services/reask_service.py new file mode 100644 index 0000000..07ac4e5 --- /dev/null +++ b/app/services/reask_service.py @@ -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] diff --git a/app/web/static/index.html b/app/web/static/index.html index cc94bd0..1d18935 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -2408,7 +2408,7 @@ createApp({ const T = { // 账本的动作有十六个:八个真动作(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:'停手', RECON:'对账修正', CLEANUP:'收尾清理', MACRO:'大盘冷热', SIGNAL:'信号留痕', SIGNAL_BUY:'买入信号', diff --git a/scripts/run_tests.py b/scripts/run_tests.py index a3636d2..caef3ad 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -56,6 +56,9 @@ 强制入队对卖出同样有效/研判不可用的减持入队/研究证据走弱 来源必定交人裁决/风控高置信卖出与命令清仓不经提议分流/ 到价提议挂着时止损照落并作废该提议 (17 例) + test_batch25_units.py 解锁重问 (2026-09-09 方案第四之一节): 时段占比与量比换算/四条护栏/ + 三条解锁条件与 fail-closed/请求体 force 为假时逐字节相同/名额只在真发 + 请求时用掉/判定零副作用/重问放行强制交人/九个参数登记 (14 例) test_batch24_units.py 点股票看它今天全部信号 (2026-09-09 方案第三节): 代码归一与跨市场不误配/ 分页回扫边界与按今天零点截断/五源解析与超龄标陈旧/去重同源合并跨源不合并/ 单源失败逐源标明与按票缓存/只读守卫/页面三处入口与层级 (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_batch18_units.py", "test_batch19_units.py", "test_batch20_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_wiring.py"] diff --git a/scripts/test_batch25_units.py b/scripts/test_batch25_units.py new file mode 100644 index 0000000..468d867 --- /dev/null +++ b/scripts/test_batch25_units.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())