diff --git a/app/core/action_engine.py b/app/core/action_engine.py index 4e668e3..bc375ee 100644 --- a/app/core/action_engine.py +++ b/app/core/action_engine.py @@ -51,6 +51,9 @@ BUY, SELL = "buy", "sell" # 候选处置词 (2026-09-11 三源合议工作包二): 合议判「观察」的新建仓候选写这个 disp, # 页面把它渲染成「等技术面开口」。与既有的 would / deny 并列, 走 disposition_snapshot 那条路。 DISP_WAIT_TECH = "wait_tech" +# 盘中确认包 (2026-09-14): 开口向上未确认 / 收口等待时段外 的候选进观察, 处置词写这个, +# 页面渲染成「等盘中确认」。与 wait_tech 并列, 走 disposition_snapshot 同一条路。 +DISP_WAIT_CONFIRM = "wait_confirm" # 观察读数包 (2026-09-14, 台账 013): 合议相关跳过项打的类别标签, 值即 pms_consensus_stat.kind。 # consensus_stats.record_round 认这些标签落表。**只在合议开着 (con 非 None) 或增持门开着 @@ -59,7 +62,10 @@ DISP_WAIT_TECH = "wait_tech" TAG_OPEN_SKIP_FUND = "open_skip_fund" # 合议判跳过, 因没有买方评析 TAG_OPEN_SKIP_BEAR = "open_skip_bear" # 合议判跳过, 其余原因 (方向看空) TAG_OPEN_WAIT_TECH = "open_wait_tech" # 合议判观察 (等技术面开口/转向) +TAG_OPEN_WAIT_CONFIRM = "open_wait_confirm" # 盘中确认包: 开口未确认/时段外, 等盘中确认 TAG_GATE_BLOCK = "gate_block" # 持仓增持门拦下 (detail 里带 gate_kind: fill/add/dca) +# open_breakout (盘中收口突破视为开口) 不打在跳过项上 —— 突破的候选会被放行、不进 skipped, +# 由 proposal_service 记进 consensus_seen 的 intraday 字段, consensus_stats 据此落表。 # 同一轮里出现多条减持时留哪一条 (2026-09-03): 数字小的优先。用户自己设的目标价到价排在 # 系统按规则算出来的保垫减仓前面 —— 人已经说了到价就清, 这一轮就不该再自作主张先卖一部分。 @@ -930,9 +936,13 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float, "tag": TAG_OPEN_SKIP_FUND if "买方评析" in _rr else TAG_OPEN_SKIP_BEAR}) continue if con and con.get("route") == "观察": - skipped.append({"ts_code": code, "action": A_OPEN, "disp": DISP_WAIT_TECH, + # 处置词: con 带 disp 就用它 (盘中确认包给 opened_wait/outside_window 写 wait_confirm), + # 否则用 wait_tech (等技术面开口)。tag 随之分 open_wait_confirm / open_wait_tech。 + _disp = con.get("disp") or DISP_WAIT_TECH + _tag = TAG_OPEN_WAIT_CONFIRM if _disp == DISP_WAIT_CONFIRM else TAG_OPEN_WAIT_TECH + skipped.append({"ts_code": code, "action": A_OPEN, "disp": _disp, "why": con.get("route_reason") or "三源合议判为观察,等技术面开口", - "tag": TAG_OPEN_WAIT_TECH}) + "tag": _tag}) continue if slots <= 0: skipped.append({"ts_code": code, "action": A_OPEN, diff --git a/app/core/tech_rules.py b/app/core/tech_rules.py index e16e47c..d62995e 100644 --- a/app/core/tech_rules.py +++ b/app/core/tech_rules.py @@ -17,6 +17,11 @@ ddl_pms_v1.sql 的 pms_tech_daily), 最后一行是最新那天。数据新鲜 """ from __future__ import annotations +# 盘中确认包 (2026-09-14): 时段判断复用解锁重问那一份 in_window, 不另写 —— 台账 003 +# 「三处同源」的哨兵 (解锁重问、择时、盘中确认共用同一套时段与量比算法)。量比由调用方算, +# 也走 reask_rules.vol_ratio。这里只导入 in_window, 是同一个函数对象。 +from app.core.reask_rules import in_window + # 数翻向的窗口「最近 20 个交易日」(方案第三节)。窗口本身不设页面参数, 翻向次数阈值才设。 CHOPPY_WINDOW = 20 @@ -101,6 +106,7 @@ def synthesize(rows, *, params=None) -> dict: sar_value = _num(latest.get("sar_value")) base = {"sar_side": side, "sar_value": sar_value, "sar_flip_days": flip_days, + "boll_upper": _num(latest.get("boll_upper")), # 盘中确认包: 收口突破要拿昨日上轨 "data_date": latest.get("data_date"), "no_read_why": None} # ---- 第二步: 震荡市 ---- @@ -156,3 +162,54 @@ def synthesize(rows, *, params=None) -> dict: return out("中性", None, "震荡", f"近 20 日 SAR 翻向 {flips} 次,震荡市不表态") # 兜底: side 已限定在 {多, 空}, 上面各分支理应已覆盖全部组合; 到这里保守中性。 return out("中性", None, "未归类", "指标组合未归入任何相位,保守中性") + + +def intraday_confirm(state, *, price, vwap, upper_prev, vol_ratio, now, + window="0945-1430", vol_min=1.5, breakout_on=True) -> dict: + """给技术面这一票加盘中确认, **只用于入场** (方案第三节、附录乙)。纯逻辑, 零外部依赖。 + + state 是 synthesize 出的技术面块 (至少含 phase)。返回: + {"state": 新块或原块, "changed": bool, + "kind": None | "breakout" | "opened_ok" | "opened_wait" | "outside_window", + "why": str, "reads": {price, vwap, upper_prev, vol_ratio, at}} + + 判定次序 (附录乙): + 1 相位不是收口等待也不是开口向上 → 原样返回, kind 空。 + 2 不在时段内: 开口向上 → outside_window (进观察等确认); 收口等待 → 原样返回。 + 3 收口等待: breakout_on 且 现价/昨日上轨/量比都有值 且 现价>上轨 且 量比≥vol_min + → breakout, 立场看多、强、相位「盘中开口向上」; 任一不满足原样返回。 + 4 开口向上: 现价/均价/量比都有值 且 现价≥均价 且 量比≥vol_min → opened_ok (块不变); + 任一取不到或不满足 → opened_wait。**取不到按等待而不是放行** —— 入场的门, 方向保守。""" + st = state if isinstance(state, dict) else {} + phase = st.get("phase") + at = now.strftime("%H:%M") if now is not None else None + reads = {"price": price, "vwap": vwap, "upper_prev": upper_prev, + "vol_ratio": vol_ratio, "at": at} + + def result(new_state, changed, kind, why): + return {"state": new_state, "changed": changed, "kind": kind, "why": why, "reads": reads} + + if phase not in ("收口等待", "开口向上"): + return result(st, False, None, "") + if now is None or not in_window(now, window): + if phase == "开口向上": + return result(st, False, "outside_window", + "开口向上候选不在确认时段 %s 内,进观察等确认" % window) + return result(st, False, None, "") # 收口等待在时段外照旧观察 (原样) + if phase == "收口等待": + if (breakout_on and price is not None and upper_prev is not None + and vol_ratio is not None and price > upper_prev and vol_ratio >= vol_min): + new = dict(st) + new.update({"stance": "看多", "strength": "强", "phase": "盘中开口向上", + "reason": "盘中站上昨日布林上轨且放量:收口突破视为开口向上"}) + return result(new, True, "breakout", + "现价 %s 站上昨日上轨 %s、量比 %s≥%s:收口突破视为开口向上" + % (price, upper_prev, vol_ratio, vol_min)) + return result(st, False, None, "收口等待,盘中未突破") + # phase == 开口向上, 在时段内 + if (price is not None and vwap is not None and vol_ratio is not None + and price >= vwap and vol_ratio >= vol_min): + return result(st, False, "opened_ok", + "现价 %s≥当日均价 %s、量比 %s≥%s:开口确认" % (price, vwap, vol_ratio, vol_min)) + return result(st, False, "opened_wait", + "开口向上未确认(现价未站上均价或量能不足/取不到),进观察等确认") diff --git a/app/services/consensus_stats.py b/app/services/consensus_stats.py index 5f59e73..596a059 100644 --- a/app/services/consensus_stats.py +++ b/app/services/consensus_stats.py @@ -42,10 +42,12 @@ STAT_TIMES_KEY = "PMS_CONSENSUS_STAT_TIMES" K_OPEN_SKIP_FUND = ae.TAG_OPEN_SKIP_FUND K_OPEN_SKIP_BEAR = ae.TAG_OPEN_SKIP_BEAR K_OPEN_WAIT_TECH = ae.TAG_OPEN_WAIT_TECH +K_OPEN_WAIT_CONFIRM = ae.TAG_OPEN_WAIT_CONFIRM # 盘中确认包: 开口未确认/时段外 (跳过项 tag) K_GATE_BLOCK = ae.TAG_GATE_BLOCK # 从 consensus_seen / scanned / 映射重建派生的类别 (动作引擎不打这些 tag)。 K_OPEN_CONFIRM = "open_confirm" K_OPEN_PASS = "open_pass" +K_OPEN_BREAKOUT = "open_breakout" # 盘中确认包: 收口突破视为开口 (从 consensus_seen 的 intraday 派生) K_FUND_NOREAD = "fund_noread" K_TIMING_NOREAD = "timing_noread" K_TECH_EXIT = "tech_exit" @@ -120,6 +122,9 @@ def _seen_rows(out, day, now) -> list: rows.append(_row(day, code, K_FUND_NOREAD, reason="基本面无读数", now=now)) if s.get("timing") == "无读数": rows.append(_row(day, code, K_TIMING_NOREAD, reason="择时无读数", now=now)) + if s.get("intraday") == "breakout": # 盘中确认包: 收口突破视为开口 + rows.append(_row(day, code, K_OPEN_BREAKOUT, reason="盘中收口突破视为开口", + detail=detail, now=now)) return rows diff --git a/app/services/param_store.py b/app/services/param_store.py index c3a3df5..b2888e1 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -132,6 +132,12 @@ RUNTIME_EXTRA = { # 四位时刻逗号分隔; consensus_stats.record_round 只在这些时刻及其下一分钟写路由与装配两类 # (一天最多四次), 转空离场实弹随时写。设为空串即不写检查点行, 只留转空实弹。 "PMS_CONSENSUS_STAT_TIMES": ("0935,1030,1330,1445", str, "合议观察读数落表的检查点 (四位时刻逗号分隔; 空串=只记转空实弹)"), + # ── 2026-09-14 盘中确认包 (方案第三节, 台账 012): 给技术面这一票加盘中确认, 只用于入场。 + # 时段与量比与解锁重问同源 (台账 003)。总闸关掉两条规则都不走, 行为逐字如旧。 + "PMS_TECH_INTRADAY_CONFIRM": (True, bool, "盘中确认总闸 (关掉两条规则都不走, 入场行为逐字如旧)"), + "PMS_TECH_INTRADAY_BREAKOUT": (True, bool, "收口突破规则开关 (关掉只剩开口确认规则)"), + "PMS_TECH_VOL_CONFIRM": (1.5, float, "盘中确认的时段折算量比阈值 (与解锁重问 PMS_OPEN_REASK_VOL_MIN 同值同义)"), + "PMS_TECH_INTRADAY_WINDOW": ("0945-1430", str, "盘中确认时段 (与解锁重问 PMS_OPEN_REASK_WINDOW 同义; 时段外开口向上候选进观察等确认)"), } # **读不到时必须按"已暂停"处理的键 (fail-closed)。** @@ -512,6 +518,7 @@ _RANGES = { "PMS_FUND_STALE_DAYS": (0, 3650), "PMS_FUND_CONSENSUS_GOOD_MIN": (0, 10), "PMS_TECH_EXIT_TRIM_RATIO": (0, 1), "PMS_TECH_SAR_STOP_BUFFER": (0, 0.1), "PMS_TECH_TIGHT_TRAIL_GIVEBACK": (0.005, 0.5), "PMS_TECH_TIGHT_TRAIL_TARGET": (0.01, 1), + "PMS_TECH_VOL_CONFIRM": (0.5, 10), # 盘中确认量比阈值 (与 PMS_OPEN_REASK_VOL_MIN 同范围) # 宏观择时层 "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 e983990..c5f7700 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -33,10 +33,12 @@ import time from datetime import datetime, timedelta from app.core import action_engine as ae +from app.core import consensus from app.core import copy from app.core import command_spec as cs from app.core import rule_gate from app.core import signal_rules as sr +from app.core import tech_rules from app.core import tradedays as td from app.repo import pms_repo from app.services import (candidate_pub, command_service, consensus_service, executor, industry, @@ -188,7 +190,7 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict: out["skipped"].append({"action": ae.A_OPEN, "why": "新建仓档位 off, 不扫描候选池"}) else: try: - open_cands = _scan_open(view, params, stock_params, skip, mkt, out) + open_cands = _scan_open(view, params, stock_params, skip, mkt, out, now=now) except Exception as e: logger.exception("新建仓扫描失败") out["errors"].append(f"新建仓扫描失败: {type(e).__name__}: {e}") @@ -283,7 +285,7 @@ def _save_tech_exit_done(codes) -> None: # ================================================================ 新建仓的取数与筛选 -def _scan_open(view, params, stock_params, skip, mkt, out) -> list: +def _scan_open(view, params, stock_params, skip, mkt, out, now=None) -> list: """候选池 → 新建仓候选。产出的候选数天生不超过剩余名额 (名额与金额在纯逻辑里边走边扣)。 与命令驱动那条路 (command_service._candidates) 有意不同的两点: @@ -384,7 +386,7 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list: # 开关关掉整段不做, cands 原样进 scan_open —— 与接入前逐字相同。装配失败只记日志、不拦扫描 # (合议是加不改: 装不上就当没有合议, scan_open 里 con 为 None 自然退回旧路)。 if params.get("consensus_route"): - _attach_consensus(cands, out) + _attach_consensus(cands, out, now=now) res = ae.scan_open(candidates=cands, params=params, caps=portfolio.caps_ctx(view), room_amt=room, slots=slots, skip=skip) out["skipped"].extend(res["skipped"]) @@ -403,7 +405,73 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list: return res["candidates"] -def _attach_consensus(cands, out=None) -> None: +def _intraday_cfg() -> dict | None: + """盘中确认包的开关与阈值 (2026-09-14, 台账 012)。总闸关掉返回 None —— 整段不走, 逐字回旧。 + cparams 是合议参数, 收口突破改立场后要用它重新合议。""" + if not param_store.get_bool("PMS_TECH_INTRADAY_CONFIRM", True): + return None + return {"breakout_on": param_store.get_bool("PMS_TECH_INTRADAY_BREAKOUT", True), + "vol_min": param_store.get_float("PMS_TECH_VOL_CONFIRM", 1.5), + "window": param_store.get("PMS_TECH_INTRADAY_WINDOW", "0945-1430") or "0945-1430", + "cparams": consensus_service._params()} + + +def _intraday_market(code, cache) -> tuple: + """一只票的盘中读数 (现价/均价/时段折算量比)。本轮按代码缓存, 同一只不取两次。 + 量比走与解锁重问相同的算法 (reask_service._vol_ratio)。任一取数失败传 None (按取不到)。""" + if code in cache: + return cache[code] + price = vwap = vratio = None + try: + day = market.day_snapshot(code) or {} + price, vwap = day.get("price"), day.get("vwap") + except Exception as e: # noqa: BLE001 + logger.warning("[盘中确认] 取当日快照失败 %s: %s", code, e) + try: + vratio = reask_service._vol_ratio(code) + except Exception as e: # noqa: BLE001 + logger.warning("[盘中确认] 取量比失败 %s: %s", code, e) + cache[code] = (price, vwap, vratio) + return cache[code] + + +def _apply_intraday(code, blocks, cfg, now, cache) -> str | None: + """对一只候选跑盘中确认, 原地改 blocks。返回 intraday kind (或 None, 表示相位无关未跑)。 + 收口突破改技术面立场并重新合议; 开口未确认/时段外把路由改观察带 wait_confirm; 都在硬数字 tech 块记 intraday。""" + tech = blocks.get("tech") or {} + if tech.get("phase") not in ("收口等待", "开口向上"): + return None + price, vwap, vratio = _intraday_market(code, cache) + r = tech_rules.intraday_confirm(tech, price=price, vwap=vwap, + upper_prev=tech.get("boll_upper"), vol_ratio=vratio, now=now, + window=cfg["window"], vol_min=cfg["vol_min"], + breakout_on=cfg["breakout_on"]) + kind = r.get("kind") + if kind == "breakout": + new_tech = r["state"] + blocks["tech"] = new_tech + cp = cfg["cparams"] + blocks["consensus"] = consensus.decide( + (blocks.get("fund") or {}).get("stance") or "无读数", + new_tech.get("stance") or "无读数", + (blocks.get("timing") or {}).get("stance") or "无读数", + tech_phase=new_tech.get("phase"), + fund_required=(cp.get("fund_required") and cp.get("consensus_route")), + weak_confirm=cp.get("weak_confirm", True)) + new_tech["intraday"] = {"kind": kind, "reads": r["reads"]} + elif kind in ("opened_wait", "outside_window"): + con = blocks.get("consensus") or {} + con["route"] = "观察" + con["route_reason"] = r["why"] + con["disp"] = ae.DISP_WAIT_CONFIRM + blocks["consensus"] = con + tech["intraday"] = {"kind": kind, "reads": r["reads"]} + elif kind == "opened_ok": + tech["intraday"] = {"kind": kind, "reads": r["reads"]} + return kind + + +def _attach_consensus(cands, out=None, now=None) -> None: """给每只候选装配三源合议, 挂三样到候选上 (原地改): consensus —— 合议块 (direction / votes / strength / reason / route / route_reason), scan_open 分流读它; consensus_blocks —— 四块意见 (fund / tech / timing / consensus), scan_open 定档时取 fund/tech 喂 advise_v2; @@ -411,8 +479,13 @@ def _attach_consensus(cands, out=None) -> None: 批量取一次昨夜定性与技术面映射; 盘中转多留痕从候选自带的 sig_buy 取时刻。任何一步失败都不抛 —— 装不上就当这只票没有合议 (scan_open 里 con 为 None 自然退回旧路), 合议是加不改。 - 每装配一只候选往 out["consensus_seen"] 追加一条紧凑记录 (观察读数包用, 也给候选处置快照用): - ts_code / fund / tech / timing / direction / route / phase。out 为 None 或缺该键时只装配不留痕。""" + 盘中确认 (2026-09-14 盘中确认包): 装配完四块后, 若总闸开着且传了 now, 对收口等待/开口向上的 + 候选跑一遍 intraday_confirm —— 收口突破改立场重新合议, 开口未确认/时段外把路由改观察带 wait_confirm。 + 总闸关掉 (_intraday_cfg 为 None) 或没传 now 时整段不走, 与接入前逐字相同。 + + 每装配一只候选往 out["consensus_seen"] 追加一条紧凑记录 (观察读数/候选处置快照用): + ts_code / fund / tech / timing / direction / route / phase, 盘中突破的另记 intraday=breakout。 + out 为 None 或缺该键时只装配不留痕。""" if not cands: return seen = out.setdefault("consensus_seen", []) if isinstance(out, dict) else None @@ -423,6 +496,8 @@ def _attach_consensus(cands, out=None) -> None: except Exception as e: # noqa: BLE001 logger.warning("[合议] 批量取数失败, 本轮候选不装配合议: %s", e) return + icfg = _intraday_cfg() if now is not None else None + icache: dict = {} for c in cands: code = cs.normalize_code(c.get("ts_code") or "") if not code: @@ -431,16 +506,20 @@ def _attach_consensus(cands, out=None) -> None: flip_at = (c.get("sig_buy") or {}).get("at") or None blocks = consensus_service.assemble( c, nightly=nightly.get(code), tech_states=tech_states, flip_at=flip_at) + ikind = _apply_intraday(code, blocks, icfg, now, icache) if icfg else None c["consensus"] = blocks["consensus"] c["consensus_blocks"] = blocks c["consensus_hard"] = consensus_service.hard_keys(blocks) if seen is not None: con, fund, tech, tm = (blocks["consensus"], blocks["fund"], blocks["tech"], blocks["timing"]) - seen.append({"ts_code": code, "fund": fund.get("stance"), - "tech": tech.get("stance"), "timing": tm.get("stance"), - "direction": con.get("direction"), "route": con.get("route"), - "phase": tech.get("phase")}) + rec = {"ts_code": code, "fund": fund.get("stance"), + "tech": tech.get("stance"), "timing": tm.get("stance"), + "direction": con.get("direction"), "route": con.get("route"), + "phase": tech.get("phase")} + if ikind: + rec["intraday"] = ikind + seen.append(rec) except Exception as e: # noqa: BLE001 logger.warning("[合议] 装配失败 %s (本票按无合议): %s", code, e) @@ -612,7 +691,7 @@ def disposition_snapshot(now=None) -> dict: for _k in _inflight_keys(): # 在途; 前端多半已按指令/提议标成「已下单/待你确认」 skip[_k] = "系统已经在处理这只票(见『等我拍板』或『今日在办』)" sink = {"skipped": []} # 丢弃用: _scan_open 只往里 append/extend, 不读它 - would = _scan_open(view, params, stock_params, skip, {}, sink) + would = _scan_open(view, params, stock_params, skip, {}, sink, now=now) except Exception as e: logger.exception("候选处置快照失败") return {"ok": False, "ready": False, "error": f"{type(e).__name__}: {e}", diff --git a/app/services/tech_service.py b/app/services/tech_service.py index 942e8d8..ad5c8c9 100644 --- a/app/services/tech_service.py +++ b/app/services/tech_service.py @@ -274,6 +274,7 @@ def _compact(st: dict) -> dict: "phase": st.get("phase"), "confirm": st.get("confirm"), "sar_side": st.get("sar_side"), "sar_value": st.get("sar_value"), "sar_flip_days": st.get("sar_flip_days"), "choppy": st.get("choppy"), + "boll_upper": st.get("boll_upper"), # 盘中确认包: 收口突破从映射取昨日上轨, 不回库 "reason": st.get("reason"), "data_date": st.get("data_date")} diff --git a/app/web/static/index.html b/app/web/static/index.html index e8ef306..96e896a 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -2882,6 +2882,8 @@ createApp({ // 三源合议判观察 (2026-09-11): 等技术面开口, 是「在盯」不是「被否决」—— // 归到 watch 那一档而不是今天没建仓那一档, 因为它还会回来。 if (d.disp === 'wait_tech') return { cls: 'wait', label: '等技术面开口', why: d.why }; + // 盘中确认包 (2026-09-14): 开口未确认/时段外, 等盘中放量确认, 同样是「在盯」不是「被否决」。 + if (d.disp === 'wait_confirm') return { cls: 'wait', label: '等盘中确认', why: d.why }; return { cls: 'deny', label: '被否决', why: d.why }; } return { cls: 'wait', label: '在盯', why: '' }; diff --git a/scripts/run_tests.py b/scripts/run_tests.py index ebde2ac..8ae6566 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -93,16 +93,21 @@ 转空实弹随时写、试算不写、被闸拒不记、confirm 据动作名、空串检查点)/ record_map_cover 记覆盖与逐只无读数/日报小节失败不拖垮/upsert 更新子句 只用 VALUES 列不带绑定参数且单表合规/参数登记与检查点窗口/映射重建先清当日无读数 (21 例) + test_batch32_units.py 盘中确认包 (2026-09-14 台账 012): intraday_confirm 纯函数 (相位无关原样/ + 收口突破要价与量同时满足/突破开关关不升/上轨缺不升/开口确认要均价与量/ + 取不到按等待/时段外开口进观察)/in_window 与 reask_rules 同源哨兵/synthesize + 与 _compact 带 boll_upper/接线突破重合议为放行且硬数字带 intraday/开口未确认 + 路由改观察带 wait_confirm/总闸关掉 _intraday_cfg 为 None/参数登记与范围 (19 例) test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许 直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象 打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) + 目标价到价必定入队 (档位 full 也不自动卖) + 用户设的止损价与目标价单独成列显示 (70 例) - 共 847 例 + 共 866 例 (总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 到 655; 09-07 审查修复加了跨轮减持等五例, 到 660; 第二件低把握驳回交人一例, 到 661; 第三件逻辑状态接入第二十二批十五例, 到 676; 第四件安全边际整句透传一例, 到 677; 参考目标价一例, 到 678; 催化事件与定价状态透传一例, 到 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724; 三源合议工作包二纯逻辑第二十八批三十七例到 763; 接入下单链路第二十九批二十四例到 787; 工作包三离场纪律第三十批十八例到 805; part3 盘中 SAR 止损线补九例到 814; part4 弱基本面紧止盈补六例到 820; - 09-14 观察读数包第三十一批二十例到 840; 加固包给第二十八批加三例、第三十批加三例到 846; 评审修订第三十一批加一例, 现为 847) + 09-14 观察读数包第三十一批二十例到 840; 加固包给第二十八批加三例、第三十批加三例到 846; 评审修订第三十一批加一例到 847; 09-14 盘中确认包第三十二批十九例, 现为 866) 任一子集失败即整体失败 (退出码 1)。 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): @@ -157,6 +162,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py", "test_batch29_units.py", "test_batch30_units.py", "test_batch31_units.py", + "test_batch32_units.py", "test_page_enum_guard.py", "test_page_wiring_guard.py", "test_wiring.py"] diff --git a/scripts/test_batch32_units.py b/scripts/test_batch32_units.py new file mode 100644 index 0000000..80383ed --- /dev/null +++ b/scripts/test_batch32_units.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +"""盘中确认包 (2026-09-14 方案第三节, 台账 012)。全部离线, 不连库。 + +给技术面这一票加盘中确认, 只用于入场。时段与量比与解锁重问同源 (台账 003)。 + A intraday_confirm 纯函数: 相位无关原样/收口突破要价与量同时满足/突破开关关不升/取不到按等待/ + 开口确认要均价与量/时段外开口进观察。 + B 同源哨兵与映射带上轨: in_window 是 reask_rules 那一个; synthesize base 与 _compact 带 boll_upper。 + C 接线 (proposal_service._apply_intraday): 突破改立场重合议为放行、硬数字带 intraday; + 开口未确认路由改观察带 wait_confirm; 总闸关掉 _intraday_cfg 为 None (逐字回旧)。 + E 参数登记与范围。 +""" +import os +import sys +import traceback +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.core import tech_rules as tr # noqa: E402 +from app.core import reask_rules as rr # noqa: E402 +from app.services import param_store as ps # noqa: E402 + +RESULTS = [] + + +def case(name): + def deco(fn): + RESULTS.append((name, fn)) + return fn + return deco + + +def _st(phase, **kw): + """一个技术面立场块 (synthesize 的紧凑态)。""" + d = {"stance": "中性", "strength": None, "phase": phase, "sar_side": "多", + "boll_upper": 10.0, "reason": "x"} + d.update(kw) + return d + + +NOW = datetime(2026, 9, 15, 10, 0) # 在 0945-1430 时段内 + + +# ================================================================ A intraday_confirm 纯函数 +@case("A 相位无关 (趋势多) → 原样返回, kind 空, 不改") +def _(): + r = tr.intraday_confirm(_st("趋势多"), price=11, vwap=10, upper_prev=10, + vol_ratio=3, now=NOW) + assert r["kind"] is None and r["changed"] is False + + +@case("A 收口等待·价站上昨日上轨且量足 → breakout (看多/强/盘中开口向上)") +def _(): + r = tr.intraday_confirm(_st("收口等待"), price=10.5, vwap=10.0, upper_prev=10.2, + vol_ratio=2.0, now=NOW, vol_min=1.5) + assert r["kind"] == "breakout" and r["changed"] is True + assert r["state"]["stance"] == "看多" and r["state"]["strength"] == "强" + assert r["state"]["phase"] == "盘中开口向上" + + +@case("A 收口等待·价没过上轨 → 不升 (原样, kind 空)") +def _(): + r = tr.intraday_confirm(_st("收口等待"), price=10.1, vwap=10.0, upper_prev=10.2, + vol_ratio=2.0, now=NOW, vol_min=1.5) + assert r["kind"] is None and r["changed"] is False + + +@case("A 收口等待·量不足 → 不升") +def _(): + r = tr.intraday_confirm(_st("收口等待"), price=10.5, vwap=10.0, upper_prev=10.2, + vol_ratio=1.2, now=NOW, vol_min=1.5) + assert r["kind"] is None + + +@case("A 收口等待·突破开关关掉 → 不升 (breakout_on 假)") +def _(): + r = tr.intraday_confirm(_st("收口等待"), price=10.5, vwap=10.0, upper_prev=10.2, + vol_ratio=2.0, now=NOW, vol_min=1.5, breakout_on=False) + assert r["kind"] is None and r["changed"] is False + + +@case("A 收口等待·昨日上轨取不到 → 不升 (取不到不放行)") +def _(): + r = tr.intraday_confirm(_st("收口等待"), price=10.5, vwap=10.0, upper_prev=None, + vol_ratio=2.0, now=NOW, vol_min=1.5) + assert r["kind"] is None + + +@case("A 开口向上·价≥均价且量足 → opened_ok, 块不变") +def _(): + r = tr.intraday_confirm(_st("开口向上", stance="看多"), price=10.5, vwap=10.0, + upper_prev=10.2, vol_ratio=2.0, now=NOW, vol_min=1.5) + assert r["kind"] == "opened_ok" and r["changed"] is False + assert r["state"]["phase"] == "开口向上" and r["state"]["stance"] == "看多" + + +@case("A 开口向上·价低于均价 → opened_wait") +def _(): + r = tr.intraday_confirm(_st("开口向上", stance="看多"), price=9.8, vwap=10.0, + upper_prev=10.2, vol_ratio=2.0, now=NOW, vol_min=1.5) + assert r["kind"] == "opened_wait" and r["changed"] is False + + +@case("A 开口向上·量不足 → opened_wait") +def _(): + r = tr.intraday_confirm(_st("开口向上", stance="看多"), price=10.5, vwap=10.0, + upper_prev=10.2, vol_ratio=1.0, now=NOW, vol_min=1.5) + assert r["kind"] == "opened_wait" + + +@case("A 开口向上·读数取不到 (现价 None) → opened_wait (取不到按等待)") +def _(): + r = tr.intraday_confirm(_st("开口向上", stance="看多"), price=None, vwap=10.0, + upper_prev=10.2, vol_ratio=2.0, now=NOW, vol_min=1.5) + assert r["kind"] == "opened_wait" + + +@case("A 时段外·开口向上 → outside_window (进观察)") +def _(): + early = datetime(2026, 9, 15, 9, 40) # 早于 09:45 + r = tr.intraday_confirm(_st("开口向上", stance="看多"), price=10.5, vwap=10.0, + upper_prev=10.2, vol_ratio=2.0, now=early, window="0945-1430") + assert r["kind"] == "outside_window" and r["changed"] is False + + +@case("A 时段外·收口等待 → 原样 (kind 空, 不动)") +def _(): + early = datetime(2026, 9, 15, 9, 40) + r = tr.intraday_confirm(_st("收口等待"), price=10.5, vwap=10.0, upper_prev=10.2, + vol_ratio=2.0, now=early, window="0945-1430") + assert r["kind"] is None and r["changed"] is False + + +# ================================================================ B 同源哨兵与映射带上轨 +@case("B 同源哨兵·tech_rules 的 in_window 就是 reask_rules 那一个函数对象") +def _(): + assert tr.in_window is rr.in_window + + +@case("B 映射带上轨·synthesize base 与 _compact 都带 boll_upper") +def _(): + from app.services import tech_service as ts + rows = [{"data_date": 20260914, "reanchored": 1, "quality": "OK", "sar_side": "多", + "boll_pos": 0.6, "boll_upper": 12.34, "boll_squeeze": 1, "sar_flip_days": 5, + "bbi_state": "多头区", "sar_value": 11.0, "boll_bw_pct": 3.0}] + st = tr.synthesize(rows) + assert st.get("boll_upper") == 12.34 + assert "boll_upper" in ts._compact(st) and ts._compact(st)["boll_upper"] == 12.34 + + +# ================================================================ C 接线 (proposal_service._apply_intraday) +def _cfg(**kw): + c = {"breakout_on": True, "vol_min": 1.5, "window": "0945-1430", + "cparams": {"fund_required": True, "consensus_route": True, "weak_confirm": True}} + c.update(kw) + return c + + +@case("C 突破·改立场重合议为放行, 硬数字 tech 带 intraday") +def _(): + from app.services import proposal_service as pp + blocks = {"fund": {"stance": "看多"}, "timing": {"stance": "中性"}, + "tech": {"phase": "收口等待", "boll_upper": 10.2, "stance": "中性"}, + "consensus": {"route": "观察", "direction": "中性", "route_reason": "等技术面开口"}} + cache = {"600000.SH": (10.5, 10.0, 2.0)} # 价站上上轨、量足 + kind = pp._apply_intraday("600000.SH", blocks, _cfg(), NOW, cache) + assert kind == "breakout" + assert blocks["tech"]["stance"] == "看多" and blocks["tech"]["phase"] == "盘中开口向上" + assert blocks["tech"]["intraday"]["kind"] == "breakout" + assert blocks["consensus"]["direction"] == "看多" and blocks["consensus"]["route"] == "放行" + + +@case("C 开口未确认·路由改观察带 wait_confirm, 硬数字带 intraday") +def _(): + from app.services import proposal_service as pp + from app.core import action_engine as ae + blocks = {"fund": {"stance": "看多"}, "timing": {"stance": "看多"}, + "tech": {"phase": "开口向上", "boll_upper": 10.2, "stance": "看多"}, + "consensus": {"route": "放行", "direction": "看多"}} + cache = {"600000.SH": (9.5, 10.0, 2.0)} # 价低于均价 → 未确认 + kind = pp._apply_intraday("600000.SH", blocks, _cfg(), NOW, cache) + assert kind == "opened_wait" + assert blocks["consensus"]["route"] == "观察" + assert blocks["consensus"]["disp"] == ae.DISP_WAIT_CONFIRM + assert blocks["tech"]["intraday"]["kind"] == "opened_wait" + + +@case("C 总闸关掉·_intraday_cfg 返回 None (逐字回旧)") +def _(): + from app.services import proposal_service as pp + o = pp.param_store.get_bool + pp.param_store.get_bool = lambda k, d=False: (False if k == "PMS_TECH_INTRADAY_CONFIRM" + else o(k, d)) + try: + assert pp._intraday_cfg() is None + finally: + pp.param_store.get_bool = o + + +# ================================================================ E 参数 +@case("E 参数·盘中确认四键登记, 默认值对") +def _(): + assert ps.RUNTIME_EXTRA["PMS_TECH_INTRADAY_CONFIRM"][0] is True + assert ps.RUNTIME_EXTRA["PMS_TECH_INTRADAY_BREAKOUT"][0] is True + assert abs(ps.RUNTIME_EXTRA["PMS_TECH_VOL_CONFIRM"][0] - 1.5) < 1e-9 + assert ps.RUNTIME_EXTRA["PMS_TECH_INTRADAY_WINDOW"][0] == "0945-1430" + + +@case("E 参数·量比阈值范围校验 0.5 到 10") +def _(): + assert ps._range_check("PMS_TECH_VOL_CONFIRM", 0.3) # 低于下限 → 报错串 + assert ps._range_check("PMS_TECH_VOL_CONFIRM", 20) # 高于上限 → 报错串 + assert ps._range_check("PMS_TECH_VOL_CONFIRM", 1.5) is None + + +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())