tradingSystem/scripts/test_batch31_units.py

367 lines
16 KiB
Python

# -*- 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 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
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))
return self.captured
def __exit__(self, *a):
cst.param_store.get = self._o_get
cst.consensus_stat_repo.upsert_many = self._o_up
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"}
# ================================================================ 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())