akg-factor-bridge/test_logic_state_daily.py

240 lines
11 KiB
Python
Raw Permalink Normal View History

"""逐票日频逻辑状态表的离线单测(不连库)。
钉住四件事
落定的口径第一次见到落定态等于原始态进入逻辑存疑即刻成立退出存疑要连续三个计划日
不再存疑其余迁移要连续两个计划日同向落定不改传入的原始结果
历史读不到不断产表没建日期不合法都返回空字典落定态退回原始态
落库幂等同一数据日重跑先删该日行再整批插列数与 COLUMNS 一致不追加重复行
表行与下游形状可往返写进去的顿号串与 JSON 串读回来仍是列表
落库那一路用一张内存里的假表接住真实的 SQL test_judgement_snapshot.py 同一手法
所以幂等测的是真语句不是桩开发机没有 pandas 与数据库驱动时只给缺席的模块装最小桩
跑法python3 test_logic_state_daily.py pytest test_logic_state_daily.py
"""
import sys
import types
_STUBS = ("pandas", "psycopg", "pymysql", "dotenv")
for _n in _STUBS:
if _n not in sys.modules:
try:
__import__(_n)
except ImportError:
_m = types.ModuleType(_n)
if _n == "pandas": # db.py 的函数签名在定义时引用这两个名字
_m.DataFrame = type("DataFrame", (), {})
_m.Series = type("Series", (), {})
sys.modules[_n] = _m
import config # noqa: E402
import logic_state as ls # noqa: E402
import logic_state_daily as lsd # noqa: E402
def t(name, cond):
assert cond, name
print(" ok", name)
DS = "2026-09-04"
TABLE = "t_akg_logic_state_daily_test"
def raw(state, why=None):
return {"state": state, "why": why, "as_of": "2026-09-03",
"usable": [ls.PATH_CLAIM, ls.PATH_BROKER], "missing": [ls.PATH_JUDGE, ls.PATH_EVENT],
"reasons": [f"{ls.PATH_CLAIM}2026-08-25最近一条利好"] * 8,
"paths": [{"path": ls.PATH_CLAIM, "signal": ls.SIG_UP, "as_of": "2026-08-25",
"why": "最近一条利好"}]}
def h(day, raw_state, state):
return {"trade_date": day, "raw_state": raw_state, "state": state}
def _boom(*a, **k):
raise OSError("connection refused")
# ---------------------------------------------------------------- 落定口径
def test_settle_one():
print("落定的口径")
r = raw(ls.STATE_HOLD)
s = lsd.settle_one("SH600000", r, None)
t("第一次见到:落定态等于原始态", s["state"] == ls.STATE_HOLD and s["raw_state"] == ls.STATE_HOLD)
t("上一次落定态为空、说明为维持", s["prev_state"] is None and s["settle_note"] == "维持")
t("不改传入的原始结果", "raw_state" not in r and r["state"] == ls.STATE_HOLD)
t("其余键原样保留", s["paths"] == r["paths"] and s["usable"] == r["usable"] and s["why"] is None)
s = lsd.settle_one("SH600000", raw(ls.STATE_DOUBT), [h("2026-09-03", ls.STATE_HOLD, ls.STATE_HOLD)])
t("进入逻辑存疑即刻成立", s["state"] == ls.STATE_DOUBT and s["prev_state"] == ls.STATE_HOLD)
hist = [h("2026-09-01", ls.STATE_DOUBT, ls.STATE_DOUBT), h("2026-09-02", ls.STATE_HOLD, ls.STATE_DOUBT)]
s = lsd.settle_one("SH600000", raw(ls.STATE_HOLD), hist)
t("退出存疑:原始态只有两天不存疑,落定仍是存疑",
s["state"] == ls.STATE_DOUBT and s["raw_state"] == ls.STATE_HOLD and "现在 2 天" in s["settle_note"])
hist.append(h("2026-09-03", ls.STATE_HOLD, ls.STATE_DOUBT))
s = lsd.settle_one("SH600000", raw(ls.STATE_HOLD), hist)
t("退出存疑:连续三个计划日不再存疑才放行", s["state"] == ls.STATE_HOLD)
s = lsd.settle_one("SH600000", raw(ls.STATE_STRONG), [h("2026-09-03", ls.STATE_HOLD, ls.STATE_HOLD)])
t("其余迁移:只有一天同向,先维持上一次落定态",
s["state"] == ls.STATE_HOLD and s["raw_state"] == ls.STATE_STRONG)
s = lsd.settle_one("SH600000", raw(ls.STATE_STRONG),
[h("2026-09-02", ls.STATE_HOLD, ls.STATE_HOLD), h("2026-09-03", ls.STATE_STRONG, ls.STATE_HOLD)])
t("其余迁移:连续两个计划日同向才迁移", s["state"] == ls.STATE_STRONG)
s = lsd.settle_one("SH600000", raw(ls.STATE_STRONG), [h("2026-09-03", ls.STATE_HOLD, ls.STATE_HOLD)],
confirm_days=1)
t("确认天数旋钮生效", s["state"] == ls.STATE_STRONG)
t("配置里的两个天数与 settle 的默认值同源",
config.LOGIC_SETTLE_CONFIRM_DAYS == 2 and config.LOGIC_SETTLE_EXIT_DAYS == 3)
# ---------------------------------------------------------------- 读
def test_history():
print("历史行的读取")
config.LOGIC_STATE_TABLE = TABLE
t("表还不存在时返回空字典、不抛错", lsd.history(DS, read_mysql=_boom) == {})
t("数据日不合法时也只是没有历史", lsd.history("不是日期", read_mysql=_boom) == {})
t("指定了票但一只都没有时不查库", lsd.history(DS, codes=[], read_mysql=_boom) == {})
seen = {}
def _reader(which, sql, params):
seen["sql"], seen["params"] = " ".join(sql.split()), params
return [{"trade_date": "2026-09-02", "code": "SH600000", "raw_state": ls.STATE_HOLD, "state": ls.STATE_HOLD},
{"trade_date": "2026-09-03", "code": "SH600000", "raw_state": ls.STATE_DOUBT, "state": ls.STATE_DOUBT},
{"trade_date": "2026-09-03", "code": "SZ000001", "raw_state": None, "state": float("nan")}]
hist = lsd.history(DS, codes=["SH600000", "SZ000001"], read_mysql=_reader)
t("只取严格早于数据日、回看窗口之内的行",
"trade_date >= %s AND trade_date < %s" in seen["sql"] and seen["params"][1] == DS
and seen["params"][0] == "2026-08-05")
t("指定票走 IN 子句", "code IN (%s,%s)" in seen["sql"] and seen["params"][2:] == ("SH600000", "SZ000001"))
t("按票索引、行按数据日升序", [r["trade_date"] for r in hist["SH600000"]] == ["2026-09-02", "2026-09-03"])
t("空值归一成 NoneNaN 不当成状态)", hist["SZ000001"][0]["state"] is None)
lsd.history(DS, read_mysql=_reader)
t("不指定票时不带 IN 子句", "IN (" not in seen["sql"])
t("当日行读失败返回空字典", lsd.lookup(["SH600000"], DS, read_mysql=_boom) == {})
t("当日行一只票都不要时不查库", lsd.lookup([], DS, read_mysql=_boom) == {})
# ---------------------------------------------------------------- 写
class _FakeCursor:
def __init__(self, store):
self.store = store
def __enter__(self):
return self
def __exit__(self, *a):
return False
def execute(self, sql, params=None):
s = " ".join(sql.split())
if s.upper().startswith("CREATE TABLE"):
assert TABLE in s and "PRIMARY KEY (trade_date, code)" in s, s
self.store["created"] += 1
return
if s.upper().startswith("DELETE"):
assert "WHERE trade_date = %s" in s, s
self.store["deleted"].append(params[0])
self.store["rows"] = [r for r in self.store["rows"] if r[0] != params[0]]
return
raise AssertionError(f"意外的语句: {s}")
def executemany(self, sql, rows):
s = " ".join(sql.split())
assert s.upper().startswith("INSERT INTO") and TABLE in s, s
assert s.count("%s") == len(lsd.COLUMNS), s
for r in rows:
assert len(r) == len(lsd.COLUMNS)
self.store["rows"].extend(list(rows))
class _FakeConn:
def __init__(self, store):
self.store = store
def __enter__(self):
return self
def __exit__(self, *a):
return False
def cursor(self):
return _FakeCursor(self.store)
def commit(self):
self.store["commits"] += 1
def _plan_rows():
"""计划快照里的行plan._row 的形状):两只有卡的、一只没卡的、一只重复的。"""
a = lsd.settle_one("SH600000", raw(ls.STATE_HOLD), None)
b = lsd.settle_one("SZ000001", raw(ls.STATE_STRONG),
[h("2026-09-03", ls.STATE_HOLD, ls.STATE_HOLD)]) # 被按住:原始强化、落定成立
return [{"code": "SH600000", "verdict": "候选", "card_rank": 3, "logic_state": a},
{"code": "SZ000001", "verdict": "仅展示", "card_rank": 40, "logic_state": b},
{"code": "SZ000002", "verdict": "仅展示", "card_rank": 41},
{"code": "SH600000", "verdict": "候选", "card_rank": 3, "logic_state": a}]
def test_build_and_save():
print("拼行与幂等落库")
config.LOGIC_STATE_TABLE = TABLE
rows = lsd.build_rows(DS, _plan_rows(), plan_version="abc1234", now="2026-09-07 07:01:00")
t("没有逻辑状态的行跳过、重复的票只留一条", [r["code"] for r in rows] == ["SH600000", "SZ000001"])
b = rows[1]
t("原始态与落定态分开存", b["raw_state"] == ls.STATE_STRONG and b["state"] == ls.STATE_HOLD
and b["prev_state"] == ls.STATE_HOLD and "先维持" in b["settle_note"])
t("判决、卡内序、代码版本、写入时刻都带上",
b["verdict"] == "仅展示" and b["card_rank"] == 40 and b["plan_version"] == "abc1234"
and b["snapshot_at"] == "2026-09-07 07:01:00")
t("四路名单存成顿号串", b["usable"] == f"{ls.PATH_CLAIM}{ls.PATH_BROKER}")
t("说明最多存六条", b["reasons"].count("最近一条利好") == 6)
store = {"rows": [], "deleted": [], "created": 0, "commits": 0}
def _write(day, rs):
lsd.save(day, rs, conn_factory=lambda: _FakeConn(store))
r1 = lsd.persist(DS, _plan_rows(), "abc1234", write=_write)
t("首次写两行", r1["rows"] == 2 and len(store["rows"]) == 2)
t("读数:按落定态计数、被按住一只、迁移零只",
r1["by_state"] == {ls.STATE_HOLD: 2} and r1["held"] == 1 and r1["moved"] == 0)
lsd.persist(DS, _plan_rows(), "abc1234", write=_write)
t("同一数据日重跑仍是两行,不追加重复行", len(store["rows"]) == 2)
t("重跑先删该日行(删的正是这个数据日)", store["deleted"] == [DS, DS])
t("建表语句每次都发(已存在就跳过)、删与插同一事务提交", store["created"] == 2 and store["commits"] == 4)
lsd.persist(DS, [], "abc1234", write=_write)
t("一张卡都没有时该日行被清空,不留残行", store["rows"] == [])
def test_roundtrip():
print("表行与下游形状往返")
rows = lsd.build_rows(DS, _plan_rows(), plan_version="abc1234", now="x")
out = lsd.row_to_out(rows[1])
t("状态三键、子因、截止日都在",
out["state"] == ls.STATE_HOLD and out["raw_state"] == ls.STATE_STRONG
and out["prev_state"] == ls.STATE_HOLD and out["why"] is None and out["as_of"] == "2026-09-03")
t("顿号串读回来是列表", out["usable"] == [ls.PATH_CLAIM, ls.PATH_BROKER]
and out["missing"] == [ls.PATH_JUDGE, ls.PATH_EVENT])
t("JSON 串读回来是列表", isinstance(out["paths"], list) and out["paths"][0]["path"] == ls.PATH_CLAIM
and len(out["reasons"]) == 6)
t("空行也能还原、不抛错", lsd.row_to_out({})["usable"] == [] and lsd.row_to_out({})["paths"] == [])
def main():
test_settle_one()
test_history()
test_build_and_save()
test_roundtrip()
print("ALL OK — 落定口径 / 历史读取不断产 / 幂等落库 / 往返形状 全部通过")
if __name__ == "__main__":
main()