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