tradingSystem/scripts/test_batch27_units.py

355 lines
16 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-11 方案第五节, 台账 008)。全部离线, 不连库不连网。
A 相位合成 (core/tech_rules): 四种无读数、九个相位逐格、震荡市抑制翻向信号、数翻向。
B 取数解析 (tech_service.parse_item): 代码归一 SH600000→600000.SH、字段映射、认不出返回 None。
C 分页取数 (fetch_all_pages): 多页拼接、matched 终止、状态非 OK 抛错 (注入 fetch, 不连网)。
D 映射 (build_map): 正常合成只覆盖有当天读数的相关票、读数陈旧整份按无读数 (桩掉库与网络)。
E 参数与口径: PMS_TECH_* 登记进 RUNTIME_EXTRA 与 _RANGES、交易日差口径。
F 落表 (pull): 接口状态非 OK 整轮不落表、正常落表并推进日期 (桩掉库)。
"""
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.services import param_store # noqa: E402
from app.services import tech_service as ts # 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 _row(dd, *, side="", pos=0.6, squeeze=False, bw=5.0, bbi="中性区",
flip=8, quality="OK", reanchor=1, sar=10.0):
"""造一行 pms_tech_daily 读数 (只填 synthesize 用得到的字段)。"""
return {"data_date": dd, "ts_code": "600000.SH", "sar_side": side, "boll_pos": pos,
"boll_squeeze": 1 if squeeze else 0, "boll_bw_pct": bw, "bbi_state": bbi,
"sar_flip_days": flip, "quality": quality, "reanchored": reanchor, "sar_value": sar}
# ================================================================ A 相位合成
@case("A 无读数: 没有当天行 → 弃权, 不折成看空")
def test_noread_empty():
r = tr.synthesize([])
assert r["stance"] == "无读数" and r["no_read_why"]
@case("A 无读数: 未除权重锚")
def test_noread_reanchor():
r = tr.synthesize([_row(20260910, reanchor=0)])
assert r["stance"] == "无读数" and "除权" in r["reason"]
@case("A 无读数: 数据质量非 OK")
def test_noread_quality():
r = tr.synthesize([_row(20260910, quality="STALE")])
assert r["stance"] == "无读数" and "质量" in r["reason"]
@case("A 无读数: 关键读数缺失 (SAR 方向)")
def test_noread_missing():
r = tr.synthesize([_row(20260910, side="")])
assert r["stance"] == "无读数"
@case("A 收口等待: 今天收口一律中性")
def test_squeeze():
r = tr.synthesize([_row(20260910, squeeze=True, side="", bbi="多头区")])
assert r["phase"] == "收口等待" and r["stance"] == "中性"
@case("A 趋势多: SAR 多 + 多空布林线多头区 = 看多强")
def test_trend_long_strong():
r = tr.synthesize([_row(20260910, side="", bbi="多头区", flip=8)])
assert r["phase"] == "趋势多" and r["stance"] == "看多" and r["strength"] == ""
@case("A 趋势多: SAR 多 + 中性区 = 看多弱")
def test_trend_long_weak():
r = tr.synthesize([_row(20260910, side="", bbi="中性区", flip=8)])
assert r["phase"] == "趋势多" and r["stance"] == "看多" and r["strength"] == ""
@case("A 趋势空: SAR 空 + 空头区 = 看空强")
def test_trend_short_strong():
r = tr.synthesize([_row(20260910, side="", bbi="空头区", flip=8)])
assert r["phase"] == "趋势空" and r["stance"] == "看空" and r["strength"] == ""
@case("A 趋势空: SAR 空 + 中性区 = 看空弱")
def test_trend_short_weak():
r = tr.synthesize([_row(20260910, side="", bbi="中性区", flip=8)])
assert r["phase"] == "趋势空" and r["stance"] == "看空" and r["strength"] == ""
@case("A 分歧: SAR 多 + 空头区 = 中性")
def test_diverge_long():
r = tr.synthesize([_row(20260910, side="", bbi="空头区", flip=8)])
assert r["phase"] == "分歧" and r["stance"] == "中性"
@case("A 分歧: SAR 空 + 多头区 = 中性")
def test_diverge_short():
r = tr.synthesize([_row(20260910, side="", bbi="多头区", flip=8)])
assert r["phase"] == "分歧" and r["stance"] == "中性"
@case("A 转多: SAR 刚翻多 + 多空布林线非空头 = 看多弱")
def test_flip_long():
r = tr.synthesize([_row(20260910, side="", bbi="中性区", flip=1)])
assert r["phase"] == "转多" and r["stance"] == "看多" and r["strength"] == ""
@case("A 转空: 刚翻空 + 非空头非贴轨 = 看空弱 (未确认)")
def test_flip_short_unconfirmed():
r = tr.synthesize([_row(20260910, side="", bbi="中性区", pos=0.5, flip=1)])
assert r["phase"] == "转空" and r["confirm"] is False and r["strength"] == ""
@case("A 转空: 刚翻空 + 空头区 = 看空强 (确认)")
def test_flip_short_confirmed():
r = tr.synthesize([_row(20260910, side="", bbi="空头区", flip=1)])
assert r["phase"] == "转空" and r["confirm"] is True and r["strength"] == ""
@case("A 开口向上: 收口后放开 + 上半部 + SAR 多 = 看多强")
def test_open_up():
rows = [_row(20260909, squeeze=True, bw=5.0, side="", bbi="中性区", flip=8),
_row(20260910, squeeze=False, bw=6.2, pos=0.6, side="", bbi="中性区", flip=8)]
r = tr.synthesize(rows)
assert r["phase"] == "开口向上" and r["stance"] == "看多" and r["strength"] == ""
@case("A 开口向下: 收口后放开 + 下半部 + SAR 空 = 看空强")
def test_open_down():
rows = [_row(20260909, squeeze=True, bw=5.0, side="", bbi="中性区", flip=8),
_row(20260910, squeeze=False, bw=6.2, pos=0.4, side="", bbi="中性区", flip=8)]
r = tr.synthesize(rows)
assert r["phase"] == "开口向下" and r["stance"] == "看空" and r["strength"] == ""
@case("A 带宽没扩够不算开口, 落回趋势")
def test_open_not_enough():
rows = [_row(20260909, squeeze=True, bw=5.0, side="", bbi="中性区", flip=8),
_row(20260910, squeeze=False, bw=5.5, pos=0.6, side="", bbi="中性区", flip=8)]
r = tr.synthesize(rows)
assert r["phase"] != "开口向上" and r["phase"] == "趋势多"
@case("A 震荡市里刚翻空不判转空 (SAR 翻向不当信号), 按趋势走")
def test_choppy_suppresses_flip():
# 20 行交替方向 → 翻向 19 次, 远超 4 → 震荡市; 今日刚翻空 (flip=1)
flippy = [_row(20260800 + i, side=("" if i % 2 == 0 else ""), bbi="中性区", flip=1)
for i in range(20)]
r = tr.synthesize(flippy)
assert r["choppy"] is True
assert r["phase"] != "转空", "震荡市里刚翻空不该判转空"
assert r["phase"] == "趋势空" and r["stance"] == "看空"
@case("A count_sar_flips: 数相邻方向变化")
def test_count_flips():
rows = [_row(i, side=("" if i % 2 == 0 else "")) for i in range(5)]
assert tr.count_sar_flips(rows) == 4
assert tr.count_sar_flips([_row(0, side=""), _row(1, side="")]) == 0
# ================================================================ B 取数解析
@case("B parse_item: 代码归一 + 字段映射 + 认不出返回 None")
def test_parse_item():
it = {"stock_code": "SH600000", "stock_name": "浦发银行",
"boll": {"upper": 9.4, "mid": 9.1, "lower": 8.9, "bandwidth_pct": 5.4,
"pos": 0.85, "state": "带内偏上", "squeeze": True},
"bbiboll": {"bbi": 9.2, "state": "多头区", "dist_pct": 1.0},
"sar": {"value": 9.1, "side": "", "flip_days": 8, "dist_pct": 2.5},
"reanchored": True, "in_pool": False, "quality": "OK",
"bars_used": 120, "last_bar_date": 20260910, "bars_lag": 0}
r = ts.parse_item(it, 20260910, "tech_view_v1")
assert r["ts_code"] == "600000.SH" and r["stock_name"] == "浦发银行"
assert r["boll_squeeze"] == 1 and r["reanchored"] == 1 and r["in_pool"] == 0
assert r["sar_side"] == "" and r["bbi_state"] == "多头区" and r["sar_flip_days"] == 8
assert r["algo_version"] == "tech_view_v1" and r["data_date"] == 20260910
assert ts.parse_item({"stock_code": ""}, 20260910) is None
assert ts.parse_item("x", 20260910) is None
# ================================================================ C 分页取数
@case("C fetch_all_pages: 分页拼接 + matched 终止 + 状态非 OK 抛错")
def test_fetch_pages():
pages = {0: [{"stock_code": "SH600000", "sar": {"side": ""}, "reanchored": True,
"quality": "OK", "boll": {"pos": 0.5}},
{"stock_code": "SZ000001", "sar": {"side": ""}, "reanchored": True,
"quality": "OK", "boll": {"pos": 0.4}}],
2: [{"stock_code": "SH600004", "sar": {"side": ""}, "reanchored": True,
"quality": "OK", "boll": {"pos": 0.6}}]}
def f(url, params, timeout):
return {"status": "OK", "data_date": 20260910, "matched": 3, "algo_version": "v1",
"items": pages.get(params["offset"], [])}
got = ts.fetch_all_pages(base="x", path="/y", timeout=5, page_size=2, fetch=f)
assert got["total"] == 3 and got["pages"] == 2, got
assert {r["ts_code"] for r in got["rows"]} == {"600000.SH", "000001.SZ", "600004.SH"}
def bad(url, params, timeout):
return {"status": "DEGRADED", "items": []}
try:
ts.fetch_all_pages(base="x", path="/y", timeout=5, fetch=bad)
assert False, "状态非 OK 应抛 TechFeedError"
except ts.TechFeedError:
pass
@case("C fetch_all_pages: 接口每页上限小于页大小时按 matched 拉完多页2026-09-11 真机抓到)")
def test_fetch_pages_capped():
# 接口每页最多给 2 条上限page_size 传 5matched 5 → 要拉 3 页 (2+2+1),不能第一页就停
def f(url, params, timeout):
off = params["offset"]
n = min(2, max(0, 5 - off))
items = [{"stock_code": "SH60000%d" % (off + i), "sar": {"side": ""},
"reanchored": True, "quality": "OK", "boll": {"pos": 0.5}} for i in range(n)]
return {"status": "OK", "data_date": 20260910, "matched": 5, "items": items}
got = ts.fetch_all_pages(base="x", path="/y", timeout=5, page_size=5, fetch=f)
assert got["total"] == 5 and got["pages"] == 3, got
# ================================================================ D 映射
@case("D build_map: 正常合成, 只覆盖有当天读数的相关票")
def test_build_map_ok():
P = {"enabled": True, "stale_tdays": 2, "keep_days": 40, "base": "x", "path": "/y",
"timeout": 20, "rules": dict(tr.DEFAULTS)}
saved = {}
with _Patch() as p:
p(ts, "_params", lambda: P)
p(ts.tech_repo, "latest_date", lambda: 20260910)
p(ts, "_relevant_codes", lambda: ["600000.SH", "000001.SZ"])
p(ts.tech_repo, "history_multi",
lambda codes, since=0: {"600000.SH": [_row(20260910, side="", bbi="多头区", flip=8)]})
p(ts, "_save_map", lambda m: (saved.update(m), {"ok": True})[1])
out = ts.build_map(now=datetime(2026, 9, 11, 8, 45))
assert out["ok"] and out["data_date"] == 20260910 and out["codes"] == 2
assert out["states"] == 1, "000001 今日无读数, 不进映射"
assert saved["states"]["600000.SH"]["stance"] == "看多"
@case("D build_map: 读数陈旧整份按无读数, 映射置空带原因")
def test_build_map_stale():
P = {"enabled": True, "stale_tdays": 2, "keep_days": 40, "base": "x", "path": "/y",
"timeout": 20, "rules": dict(tr.DEFAULTS)}
saved = {}
with _Patch() as p:
p(ts, "_params", lambda: P)
p(ts.tech_repo, "latest_date", lambda: 20260901) # 距 9/11 超过 2 个交易日
p(ts, "_relevant_codes", lambda: ["600000.SH"])
p(ts, "_save_map", lambda m: (saved.update(m), {"ok": True})[1])
out = ts.build_map(now=datetime(2026, 9, 11, 8, 45))
assert not out["ok"] and out["error"] == "读数陈旧"
assert saved.get("stale") is True and saved["states"] == {}
# ================================================================ E 参数与口径
@case("E 参数登记: PMS_TECH_* 在 RUNTIME_EXTRA 与 _RANGES")
def test_params_registered():
for k in ("PMS_TECH_ENABLED", "PMS_TECH_API_BASE", "PMS_TECH_API_PATH", "PMS_TECH_TIMEOUT",
"PMS_TECH_KEEP_DAYS", "PMS_TECH_STALE_TDAYS", "PMS_TECH_SQUEEZE_LOOKBACK",
"PMS_TECH_OPEN_BW_GROWTH", "PMS_TECH_CHOPPY_FLIPS", "PMS_TECH_FLIP_FRESH_DAYS",
"PMS_TECH_STATE_MAP"):
assert k in param_store.RUNTIME_EXTRA, k
for k in ("PMS_TECH_TIMEOUT", "PMS_TECH_KEEP_DAYS", "PMS_TECH_STALE_TDAYS",
"PMS_TECH_SQUEEZE_LOOKBACK", "PMS_TECH_OPEN_BW_GROWTH", "PMS_TECH_CHOPPY_FLIPS",
"PMS_TECH_FLIP_FRESH_DAYS"):
assert k in param_store._RANGES, k
@case("E _tdays_elapsed: 昨→今 1 个交易日, 同日 0")
def test_tdays():
assert ts._tdays_elapsed(20260910, 20260911) == 1
assert ts._tdays_elapsed(20260911, 20260911) == 0
# ================================================================ F 落表
@case("F pull: 接口状态非 OK 整轮不落表")
def test_pull_no_ok():
P = {"enabled": True, "base": "x", "path": "/y", "timeout": 5, "keep_days": 40,
"stale_tdays": 2, "rules": dict(tr.DEFAULTS)}
called = {"upsert": 0}
with _Patch() as p:
p(ts, "_params", lambda: P)
p(ts.tech_repo, "upsert_daily",
lambda rows: (called.__setitem__("upsert", called["upsert"] + 1), len(rows))[1])
out = ts.pull(fetch=lambda url, params, timeout: {"status": "ERR", "items": []})
assert not out["ok"] and called["upsert"] == 0, "状态非 OK 绝不落表"
@case("F pull: 正常落表并推进日期")
def test_pull_ok():
P = {"enabled": True, "base": "x", "path": "/y", "timeout": 5, "keep_days": 40,
"stale_tdays": 2, "rules": dict(tr.DEFAULTS)}
got = {"rows": None}
def good(url, params, timeout):
if params["offset"] == 0:
return {"status": "OK", "data_date": 20260910, "matched": 1, "algo_version": "v1",
"items": [{"stock_code": "SH600000", "boll": {"squeeze": False, "pos": 0.6},
"bbiboll": {"state": "多头区"},
"sar": {"side": "", "value": 9.1, "flip_days": 8},
"reanchored": True, "quality": "OK"}]}
return {"status": "OK", "data_date": 20260910, "matched": 1, "items": []}
with _Patch() as p:
p(ts, "_params", lambda: P)
p(ts.tech_repo, "upsert_daily", lambda rows: (got.__setitem__("rows", rows), len(rows))[1])
p(ts.tech_repo, "prune", lambda before: 0)
out = ts.pull(fetch=good)
assert out["ok"] and out["data_date"] == 20260910 and out["rows"] == 1
assert got["rows"][0]["ts_code"] == "600000.SH"
@case("F upsert SQL: ON DUPLICATE 子句用 VALUES() 不带绑定参数pymysql 批量陷阱, 2026-09-11 真机抓到)")
def test_upsert_odku_no_bind():
import inspect
from app.repo import tech_repo
src = inspect.getsource(tech_repo.upsert_daily)
assert "= VALUES(" in src, "ON DUPLICATE 子句要用 VALUES(列) 引用插入值"
assert 'f"{c} = :{c}"' not in src, "UPDATE 子句不该再用 :col 绑定参数pymysql executemany 多行合并会把它错位展开)"
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())