tradingSystem/scripts/test_batch31_units.py

386 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""观察读数包 (2026-09-14 方案第三节, 台账 013)。全部离线, 不连库。
把每轮扫描里合议相关的判定落进 pms_consensus_stat, 让台账 006 到 011 的复核有据。
A 动作引擎打标签: 合议判跳过分 fund/bear、判观察打 wait_tech、增持门打 gate_block;
开关关掉时标签不出现 (逐字回旧); tag 值与 consensus_stats 认的 kind 对齐。
B 归类落表 record_round: 检查点写路由与装配两类、转空实弹随时写、试算不写、被闸拒不记、
确认与否据动作名; daily_summary 取数失败返回空不抛。
C 映射覆盖 record_map_cover: 记 map_cover 与逐只 tech_noread。
D 仓库 upsert: 更新子句只用 VALUES 列不带绑定参数 (pymysql 批量陷阱), SQL 单表合规。
E 参数登记与检查点窗口。
"""
import unit_test_guard # noqa: F401,单独运行也禁止联网
import json
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 action_engine as ae # noqa: E402
from app.services import consensus_stats as cst # noqa: E402
from app.repo import consensus_stat_repo as csr # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
# ================================================================ 公共桩
def _copen(code, route, rr, direction="中性"):
"""一条挂了合议块的候选 (scan_open 用)。"""
return {"ts_code": code, "score": 100, "rank": 1,
"consensus": {"route": route, "route_reason": rr, "direction": direction}}
def _caps():
return {"max_names": 20, "sector_source_ready": True}
def _pos_con(code, direction):
"""一条挂了合议块的持仓行 (scan 增持门用)。"""
return {"ts_code": code, "total_qty": 6000, "avail_qty": 6000, "price": 10.0,
"avg_cost": 10.0, "base_qty": 6000, "add_qty": 0, "dca_qty": 0,
"cushion_pct": 0.0, "cushion_peak": 0.0, "frozen_reason": "NONE",
"price_ok": True, "target_pct": 0.06,
"consensus_blocks": {"consensus": {"direction": direction},
"tech": {"stance": direction}, "fund": {"stance": direction}}}
def _sp(**kw):
p = {"cushion_solid": 0.03, "trim_peak": 0.06, "trim_giveback": 0.5,
"dca_triggers": (-0.08, -0.15), "dca_deep_confirm": -0.15, "dca_max_ratio": 0.5,
"no_chase_ma5": 0.06, "build_window_tdays": 10, "fill_max_loss": -0.03,
"batch_split": (0.5, 0.25, 0.25), "stock_target_default": 0.06, "scale": 2_000_000,
"logic_state_route": False, "tech_gate_increase": True}
p.update(kw)
return p
class _patched:
"""把 record_round 会碰的两处外部依赖换成桩: param_store.get 返回固定检查点串,
consensus_stat_repo.upsert_many 捕获落表行 (不连库)。with 结束自动还原。"""
def __init__(self, times="0935,1030,1330,1445"):
self.times = times
self.captured = []
def __enter__(self):
self._o_get = cst.param_store.get
self._o_up = cst.consensus_stat_repo.upsert_many
self._o_del = cst.consensus_stat_repo.delete_day_kind
cst.param_store.get = lambda k, d=None: (self.times if k == cst.STAT_TIMES_KEY
else self._o_get(k, d))
cst.consensus_stat_repo.upsert_many = lambda rows: (self.captured.extend(rows)
or len(rows))
cst.consensus_stat_repo.delete_day_kind = lambda d, k: 0 # 离线不打库
return self.captured
def __exit__(self, *a):
cst.param_store.get = self._o_get
cst.consensus_stat_repo.upsert_many = self._o_up
cst.consensus_stat_repo.delete_day_kind = self._o_del
return False
def _by_kind(rows):
out = {}
for r in rows:
out.setdefault(r["kind"], []).append(r)
return out
def _sample_out():
return {"dry_run": False, "rejected": [],
"consensus_seen": [
{"ts_code": "600000.SH", "fund": "看多", "tech": "看多", "timing": "看多",
"direction": "看多", "route": "放行", "phase": "趋势多"},
{"ts_code": "600001.SH", "fund": "无读数", "tech": "中性", "timing": "看多",
"direction": "看多", "route": "交人", "phase": None},
{"ts_code": "600010.SH", "fund": "看多", "tech": "看多", "timing": "无读数",
"direction": "看多", "route": "放行", "phase": "趋势多"}],
"skipped": [
{"ts_code": "600002.SH", "action": "OPEN", "why": "没有买方评析",
"tag": "open_skip_fund"},
{"ts_code": "600003.SH", "action": "OPEN", "why": "方向看空(…)",
"tag": "open_skip_bear"},
{"ts_code": "600004.SH", "action": "OPEN", "why": "等技术面开口",
"tag": "open_wait_tech", "disp": "wait_tech"},
{"ts_code": "600005.SH", "action": "FILL", "why": "技术面看空",
"tag": "gate_block", "detail": {"gate_kind": "fill"}},
{"ts_code": "600009.SH", "why": "宏观偏热闸生效"}]} # 无 tag, 应被忽略
def _sample_scanned():
return {"candidates": [
{"ts_code": "600006.SH", "action": "EXIT", "source": "tech_exit", "qty": 1000,
"reason": "确认转空清仓"},
{"ts_code": "600007.SH", "action": "TRIM", "source": "tech_exit", "qty": 300,
"reason": "未确认减三分之一"},
{"ts_code": "600008.SH", "action": "FILL", "source": "engine", "qty": 500}]}
# ================================================================ A 动作引擎打标签
@case("A scan_open·合议判跳过·没有买方评析 → open_skip_fund")
def _():
res = ae.scan_open(candidates=[_copen("600000.SH", "跳过", "没有买方评析")],
params={"consensus_route": True}, caps=_caps(),
room_amt=1e6, slots=10, skip={})
assert len(res["skipped"]) == 1
assert res["skipped"][0]["tag"] == "open_skip_fund"
assert res["skipped"][0]["ts_code"] == "600000.SH"
@case("A scan_open·合议判跳过·方向看空 → open_skip_bear")
def _():
res = ae.scan_open(candidates=[_copen("600000.SH", "跳过", "方向看空(基本面看空、技术面看空、择时中性)")],
params={"consensus_route": True}, caps=_caps(),
room_amt=1e6, slots=10, skip={})
assert res["skipped"][0]["tag"] == "open_skip_bear"
@case("A scan_open·合议判观察 → open_wait_tech 且 disp wait_tech")
def _():
res = ae.scan_open(candidates=[_copen("600000.SH", "观察", "等技术面开口")],
params={"consensus_route": True}, caps=_caps(),
room_amt=1e6, slots=10, skip={})
assert res["skipped"][0]["tag"] == "open_wait_tech"
assert res["skipped"][0]["disp"] == ae.DISP_WAIT_TECH
@case("A scan_open·合议开关关掉·跳过项不带 tag (开着才有, 逐字回旧)")
def _():
cand = _copen("600000.SH", "跳过", "没有买方评析")
on = ae.scan_open(candidates=[dict(cand)], params={"consensus_route": True},
caps=_caps(), room_amt=1e6, slots=10, skip={})
# 关掉合议: con 为 None, 那只候选不再被合议分流; 用 skip 在 eval_open 之前接住它, 免起重设备
off = ae.scan_open(candidates=[dict(cand)], params={"consensus_route": False},
caps=_caps(), room_amt=1e6, slots=10,
skip={("600000.SH", "OPEN"): "已在途"})
assert on["skipped"][0].get("tag") == "open_skip_fund"
assert all("tag" not in s for s in off["skipped"])
@case("A scan·增持门拦下 → gate_block 且 detail.gate_kind ∈ {fill,add,dca}")
def _():
res = ae.scan(positions=[_pos_con("600000.SH", "看空")], params=_sp(tech_gate_increase=True),
market={"600000.SH": {}}, skip={})
gb = [s for s in res["skipped"] if s.get("tag") == "gate_block"]
assert gb, "增持门看空应打 gate_block 标签"
assert all((s.get("detail") or {}).get("gate_kind") in ("fill", "add", "dca") for s in gb)
@case("A scan·增持门开关关掉 → 无 gate_block tag (逐字回旧)")
def _():
res = ae.scan(positions=[_pos_con("600000.SH", "看空")], params=_sp(tech_gate_increase=False),
market={"600000.SH": {}}, skip={})
assert all(s.get("tag") != "gate_block" for s in res["skipped"])
@case("A 哨兵·动作引擎 tag 与 consensus_stats 认的 kind 对齐")
def _():
assert cst.K_OPEN_SKIP_FUND == ae.TAG_OPEN_SKIP_FUND == "open_skip_fund"
assert cst.K_OPEN_SKIP_BEAR == ae.TAG_OPEN_SKIP_BEAR == "open_skip_bear"
assert cst.K_OPEN_WAIT_TECH == ae.TAG_OPEN_WAIT_TECH == "open_wait_tech"
assert cst.K_GATE_BLOCK == ae.TAG_GATE_BLOCK == "gate_block"
# ================================================================ B 归类落表 record_round
@case("B record_round·检查点写全部类别 (放行/交人/无读数/跳过/观察/增持门/转空实弹)")
def _():
with _patched() as cap:
r = cst.record_round(_sample_out(), _sample_scanned(), {}, datetime(2026, 9, 15, 9, 35))
assert r["ok"]
bk = _by_kind(cap)
assert {x["ts_code"] for x in bk["open_pass"]} == {"600000.SH", "600010.SH"}
assert {x["ts_code"] for x in bk["open_confirm"]} == {"600001.SH"}
assert {x["ts_code"] for x in bk["fund_noread"]} == {"600001.SH"}
assert {x["ts_code"] for x in bk["timing_noread"]} == {"600010.SH"}
assert {x["ts_code"] for x in bk["open_skip_fund"]} == {"600002.SH"}
assert {x["ts_code"] for x in bk["open_skip_bear"]} == {"600003.SH"}
assert {x["ts_code"] for x in bk["open_wait_tech"]} == {"600004.SH"}
assert {x["ts_code"] for x in bk["gate_block"]} == {"600005.SH"}
assert {x["ts_code"] for x in bk["tech_exit"]} == {"600006.SH", "600007.SH"}
assert all(x["stat_date"] == 20260915 for x in cap) # 北京日期
@case("B record_round·gate_block 带上 detail.gate_kind; 无 tag 的跳过项被忽略")
def _():
with _patched() as cap:
cst.record_round(_sample_out(), _sample_scanned(), {}, datetime(2026, 9, 15, 10, 30))
bk = _by_kind(cap)
assert json.loads(bk["gate_block"][0]["detail"])["gate_kind"] == "fill"
assert "600009.SH" not in {x["ts_code"] for x in cap} # 无 tag 的宏观闸跳过不落
@case("B record_round·转空实弹据动作名记 confirm (EXIT 真 / TRIM 假)")
def _():
with _patched() as cap:
cst.record_round(_sample_out(), _sample_scanned(), {}, datetime(2026, 9, 15, 9, 35))
ex = {x["ts_code"]: json.loads(x["detail"]) for x in _by_kind(cap)["tech_exit"]}
assert ex["600006.SH"]["confirm"] is True and ex["600006.SH"]["qty"] == 1000
assert ex["600007.SH"]["confirm"] is False and ex["600007.SH"]["qty"] == 300
@case("B record_round·非检查点只写转空实弹 (路由与装配两类不写)")
def _():
with _patched() as cap:
cst.record_round(_sample_out(), _sample_scanned(), {}, datetime(2026, 9, 15, 10, 0))
bk = _by_kind(cap)
assert set(bk) == {"tech_exit"}
assert {x["ts_code"] for x in bk["tech_exit"]} == {"600006.SH", "600007.SH"}
@case("B record_round·试算 (dry_run) 一律不写")
def _():
out = {**_sample_out(), "dry_run": True}
with _patched() as cap:
r = cst.record_round(out, _sample_scanned(), {}, datetime(2026, 9, 15, 9, 35))
assert cap == [] and r["rows"] == 0
@case("B record_round·转空离场被闸拒的不记 tech_exit")
def _():
out = {"dry_run": False, "consensus_seen": [], "skipped": [],
"rejected": [{"ts_code": "600006.SH", "action": "EXIT"}]}
with _patched() as cap:
cst.record_round(out, _sample_scanned(), {}, datetime(2026, 9, 15, 10, 0))
assert {x["ts_code"] for x in _by_kind(cap).get("tech_exit", [])} == {"600007.SH"}
@case("B record_round·空串检查点 → 只写转空实弹")
def _():
with _patched(times="") as cap:
cst.record_round(_sample_out(), _sample_scanned(), {}, datetime(2026, 9, 15, 9, 35))
assert set(_by_kind(cap)) == {"tech_exit"}
@case("B daily_summary·取数失败返回空 dict 不抛")
def _():
o = cst.consensus_stat_repo.count_by_kind
def _boom(a, b):
raise RuntimeError("db down")
cst.consensus_stat_repo.count_by_kind = _boom
try:
assert cst.daily_summary(20260915) == {}
finally:
cst.consensus_stat_repo.count_by_kind = o
# ================================================================ C 映射覆盖
@case("C record_map_cover·记 map_cover (ts_code=*) 与逐只 tech_noread")
def _():
with _patched() as cap:
cst.record_map_cover(relevant_codes=["600000.SH", "600001.SH", "600002.SH"],
state_codes={"600000.SH", "600001.SH"},
now=datetime(2026, 9, 15, 8, 40))
bk = _by_kind(cap)
mc = bk["map_cover"][0]
assert mc["ts_code"] == "*"
assert json.loads(mc["detail"]) == {"codes": 3, "states": 2}
assert {x["ts_code"] for x in bk["tech_noread"]} == {"600002.SH"}
@case("C record_map_cover·重建先清当日 tech_noread (以最新一次为准, 评审第三条)")
def _():
deleted, upserted = [], []
o_del, o_up = csr.delete_day_kind, csr.upsert_many
csr.delete_day_kind = lambda d, k: (deleted.append((d, k)), 0)[1]
csr.upsert_many = lambda rows: (upserted.extend(rows), len(rows))[1]
try:
cst.record_map_cover(relevant_codes=["600000.SH"], state_codes=set(),
now=datetime(2026, 9, 15, 8, 40))
finally:
csr.delete_day_kind, csr.upsert_many = o_del, o_up
assert (20260915, cst.K_TECH_NOREAD) in deleted # 先清当日 tech_noread
assert any(r["kind"] == cst.K_MAP_COVER for r in upserted) # 再写覆盖行
# ================================================================ D 仓库 upsert
@case("D upsert_many·更新子句只用 VALUES 列不带绑定参数; SQL 单表合规")
def _():
from app.db.session import assert_single_table
grabbed = {}
o = csr.execute_many
csr.execute_many = lambda sql, payload, **kw: (grabbed.update(sql=sql, payload=payload)
or len(payload))
try:
n = csr.upsert_many([{"stat_date": 20260915, "ts_code": "600000.SH", "kind": "open_pass",
"reason": "看多", "detail": None,
"first_at": datetime.now(), "last_at": datetime.now()}])
finally:
csr.execute_many = o
sql = grabbed["sql"]
upd = sql.split("ON DUPLICATE KEY UPDATE", 1)[1]
assert ":" not in upd, "更新子句不许有绑定参数 (:col) —— 2026-09-11 pymysql 批量陷阱"
assert "VALUES(reason)" in upd and "rounds = rounds + 1" in upd
assert_single_table(sql) # 不抛即单表合规 (守卫会摘掉 ON DUPLICATE 尾巴)
assert n == 1
@case("D upsert_many·缺主键三件套的行被丢掉")
def _():
grabbed = {}
o = csr.execute_many
csr.execute_many = lambda sql, payload, **kw: (grabbed.update(payload=payload)
or len(payload))
try:
# 只有一行齐全, 另两行分别缺 kind 与 ts_code
n = csr.upsert_many([
{"stat_date": 20260915, "ts_code": "600000.SH", "kind": "open_pass",
"first_at": datetime.now(), "last_at": datetime.now()},
{"stat_date": 20260915, "ts_code": "600001.SH"},
{"stat_date": 20260915, "kind": "open_pass"}])
finally:
csr.execute_many = o
assert n == 1 and len(grabbed["payload"]) == 1
# ================================================================ E 参数与检查点窗口
@case("E 参数·PMS_CONSENSUS_STAT_TIMES 登记, 默认四检查点, 类型 str")
def _():
from app.services import param_store as ps
assert cst.STAT_TIMES_KEY == "PMS_CONSENSUS_STAT_TIMES"
dv, typ, _desc = ps.RUNTIME_EXTRA[cst.STAT_TIMES_KEY]
assert dv == "0935,1030,1330,1445" and typ is str
@case("E _at_checkpoint·命中检查点及其下一分钟, 其余与空串不命中")
def _():
t = "0935,1030,1330,1445"
assert cst._at_checkpoint(datetime(2026, 9, 15, 9, 35), t) is True
assert cst._at_checkpoint(datetime(2026, 9, 15, 9, 36), t) is True # 下一分钟
assert cst._at_checkpoint(datetime(2026, 9, 15, 9, 37), t) is False
assert cst._at_checkpoint(datetime(2026, 9, 15, 14, 45), t) is True
assert cst._at_checkpoint(datetime(2026, 9, 15, 9, 35), "") is False # 空串无检查点
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())