240 lines
11 KiB
Python
240 lines
11 KiB
Python
"""逐票日频逻辑状态表的离线单测(不连库)。
|
||
|
||
钉住四件事:
|
||
一,落定的口径:第一次见到落定态等于原始态;进入逻辑存疑即刻成立;退出存疑要连续三个计划日
|
||
不再存疑;其余迁移要连续两个计划日同向;落定不改传入的原始结果。
|
||
二,历史读不到不断产:表没建、日期不合法都返回空字典,落定态退回原始态。
|
||
三,落库幂等:同一数据日重跑先删该日行再整批插,列数与 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("空值归一成 None(NaN 不当成状态)", 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()
|