"""逐票日频逻辑状态表:每个数据日为档位表里的每只票记一行原始态与落定态,攒版本史并做抗抖动。 ## 为什么要有它(2026-09-07,下一阶段方案第三件桥侧前置) 逻辑状态四态(logic_state.py)在计划装配里已经随每张卡产出,但有两件事它自己做不到: 一,抗抖动。settle 那个函数写好了,可它要看"昨天落定的是什么、近几天原始态是什么", 这些只有存下来才有。不存,四态一天一变,PMS 按它分流就会跟着一天一变。 二,给持仓票用。四态唯一真正有用的地方是持仓,而持仓票在候选筛选第一步就被整行剔掉, /plan 里根本没有它。接口 /logic_state 要能回答"这只在持的票今天证据还在不在", 就得有一张按票、按日可查的表。 ## 落点、表名、键 写在平台因子库(写 t_factor_akg_* 的同一个 MySQL),与行业观点快照 t_akg_judgement_snapshot 同库同理由:桥对数据基座只有只读账号,这张表又是选股系统自己的派生记录。表名默认 t_akg_logic_state_daily,不带 t_factor_ 前缀,免得平台的因子清单把它当因子表收进去。 键是(数据日,前缀码)。数据日就是计划文件名里那个日期(plan_),与 t_factor_akg_* 的 trade_date 同口径——不是行业观点快照的 plan_date(那个是写入当天)。两张表的日期口径不同, 读的时候别混:计划在 D+1 凌晨构建、数据日是 D,这张表记的是 D。 ## 只在早上那一次生成里写 写入点只有 plan.generate(run.py plan,调度中心的默认三步之一)。/plan 每次实时重算会读这张表 做抗抖动,但不写——实时重算一天几十次,写进去会把"当天落定态"改成"最后一次有人查时的状态", 派生数据就不可回溯了。重跑同一天幂等:先删该日行再整批插。 ## 落定的口径(settle_one) 上一次落定态 = 表里这只票严格早于数据日、回看窗口之内最近一行的 state。 近几日原始态 = 那几行的 raw_state 加上今天的原始态,最新的在最后。 落定规则在 logic_state.settle:进入逻辑存疑即刻成立;退出存疑要连续几个计划日不再存疑; 其余迁移要连续几个计划日同向。天数由 config 给,一次定死,不按复盘读数回调。 表里没有这只票(第一次见到、或超出回看窗口)时,落定态就是原始态——没有历史就没有抖动可抗。 ## 持仓票不在档位表里时的局限(写进设计不回避) 这张表只记档位表里的票。一只在持的票掉出档位表之后,每天没有新行,接口现算时能用的历史只有 它掉出去之前那几行;掉出去超过回看窗口,落定态就等于原始态。要让持仓票也逐日攒历史, 得让桥知道 PMS 的持仓,那是跨层的事,本阶段不做,先在接口返回里用 source=computed 标明。 离线单测见 test_logic_state_daily.py,不连库。 """ from __future__ import annotations import datetime as dt import json import config import db import judgement import logic_state import sources # 落库列的顺序(插入语句按此顺序拼参数)。 COLUMNS = ("trade_date", "code", "raw_state", "raw_why", "state", "settle_note", "prev_state", "as_of", "usable", "missing", "paths", "reasons", "verdict", "card_rank", "plan_version", "snapshot_at") # 主键(数据日,前缀码):一天一票最多一行,重跑覆盖;另建(前缀码,数据日)索引,按票回看走它。 _CREATE_TABLE = """ CREATE TABLE IF NOT EXISTS {t} ( trade_date DATE NOT NULL, code VARCHAR(16) NOT NULL, raw_state VARCHAR(16), raw_why VARCHAR(16), state VARCHAR(16), settle_note VARCHAR(128), prev_state VARCHAR(16), as_of DATE, usable VARCHAR(64), missing VARCHAR(64), paths TEXT, reasons TEXT, verdict VARCHAR(16), card_rank INT, plan_version VARCHAR(32), snapshot_at DATETIME, PRIMARY KEY (trade_date, code), KEY idx_code (code, trade_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """ # 四路各自的分隔:usable / missing 两列存"研报论断、券商行动"这种顿号串,读回来再拆。 _SEP = "、" # ============================================================================ # 抗抖动:一只票的原始态配上历史,得到落定态 # ============================================================================ def settle_one(code: str, raw: dict, hist: list | None, *, confirm_days: int | None = None, exit_days: int | None = None) -> dict: """纯函数:把一只票今天的原始合成结果,配上它在本表里的近日行,落定成今天的状态。 raw 是 logic_state.compose 的返回(state 是原始态)。hist 是这只票严格早于今天、 按数据日升序的近日行(history 的返回),每行至少有 raw_state 与 state 两列; 没有历史传 None 或空列表,那时落定态就是原始态。 返回一个新字典:state 改成落定态,原始态挪到 raw_state,另带 settle_note 与 prev_state; 其余键(why、paths、usable、missing、reasons、as_of)原样保留。不改传入的 raw。 """ confirm = int(config.LOGIC_SETTLE_CONFIRM_DAYS if confirm_days is None else confirm_days) exit_n = int(config.LOGIC_SETTLE_EXIT_DAYS if exit_days is None else exit_days) rows = [r for r in (hist or []) if isinstance(r, dict)] raw_state = raw.get("state") if isinstance(raw, dict) else None prev_state = rows[-1].get("state") if rows else None need = max(confirm, exit_n) - 1 recent = [r.get("raw_state") for r in (rows[-need:] if need > 0 else [])] + [raw_state] settled, note = logic_state.settle(prev_state, raw_state, recent, confirm_days=confirm, exit_doubt_days=exit_n) out = dict(raw or {}) out.update(raw_state=raw_state, state=settled, settle_note=note, prev_state=prev_state) return out # ============================================================================ # 读:历史行(做抗抖动)、当日行(给接口) # ============================================================================ def _in_clause(codes) -> tuple[str, tuple]: codes = [str(c).strip() for c in (codes or []) if str(c).strip()] return ",".join(["%s"] * len(codes)), tuple(codes) def history(ds: str, codes=None, read_mysql=None) -> dict: """每只票严格早于数据日、回看窗口之内的行,按前缀码索引、行按数据日升序。 codes 不传就取全部票(计划装配一轮要看一千多只,一次查询取回,不逐票查);传了就只取 这几只(接口用)。只取抗抖动要读的几列。表还没建、或读不到,返回空字典并打印一行原因: 那样全部票按第一次见到处理,落定态等于原始态,计划照出不断产。 """ table = config.LOGIC_STATE_TABLE reader = read_mysql or db.read_mysql try: start = (dt.date.fromisoformat(ds) - dt.timedelta(days=config.LOGIC_STATE_LOOKBACK_DAYS)).isoformat() except ValueError: print(f" (数据日 {ds!r} 不是合法日期,逻辑状态不做抗抖动)") return {} sql = (f"SELECT trade_date, code, raw_state, state FROM {table} " f"WHERE trade_date >= %s AND trade_date < %s") params: tuple = (start, ds) if codes is not None: marks, vals = _in_clause(codes) if not vals: return {} sql += f" AND code IN ({marks})" params = params + vals try: rows = sources._records(reader("factor", sql + " ORDER BY code, trade_date", params)) # noqa: SLF001 except Exception as e: # noqa: BLE001 —— 头一次跑时表还不存在,属正常 print(f" ({table} 读不到历史行,逻辑状态本次不做抗抖动,落定态等于原始态: {e!r})") return {} out: dict = {} for r in rows: k = str(r.get("code") or "").strip() if k: out.setdefault(k, []).append({ "trade_date": sources._ymd(r.get("trade_date")), # noqa: SLF001 "raw_state": judgement._blank_to_none(r.get("raw_state")), # noqa: SLF001 "state": judgement._blank_to_none(r.get("state"))}) # noqa: SLF001 return out def lookup(codes, ds: str, read_mysql=None) -> dict: """这几只票在数据日当天的行,按前缀码索引;没写过、表没建、读不到都返回空字典。""" table = config.LOGIC_STATE_TABLE reader = read_mysql or db.read_mysql marks, vals = _in_clause(codes) if not vals: return {} try: rows = sources._records(reader( # noqa: SLF001 "factor", f"SELECT * FROM {table} WHERE trade_date = %s AND code IN ({marks})", (ds,) + vals)) except Exception as e: # noqa: BLE001 print(f" ({table} 当日行读取失败: {e!r})") return {} out = {} for r in rows: k = str(r.get("code") or "").strip() if k: out[k] = {c: judgement._blank_to_none(v) for c, v in dict(r).items()} # noqa: SLF001 return out def row_to_out(row: dict) -> dict: """把表里的一行还原成发给下游的形状(与 plan._state_out 同形,多带 raw_state 等三键)。""" def _split(v): s = str(v or "").strip() return [x for x in s.split(_SEP) if x] if s else [] def _loads(v, default): if v is None or v == "": return default try: return json.loads(v) if isinstance(v, str) else v except (TypeError, ValueError): return default return {"state": row.get("state"), "raw_state": row.get("raw_state"), "why": row.get("raw_why"), "settle_note": row.get("settle_note"), "prev_state": row.get("prev_state"), "as_of": sources._ymd(row.get("as_of")), # noqa: SLF001 "usable": _split(row.get("usable")), "missing": _split(row.get("missing")), "reasons": _loads(row.get("reasons"), []), "paths": _loads(row.get("paths"), [])} # ============================================================================ # 写:只在 plan.generate 里调 # ============================================================================ def build_rows(ds: str, plan_rows: list, plan_version: str | None = None, now: str | None = None) -> list: """纯函数:把计划快照的行(plan.collect 里 _row 的输出,带 logic_state)拼成可落库的行。 没有 logic_state 的行(档位表里有票但没装配出卡)跳过;同一只票出现多次只留最后一条。""" stamp = now or dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") by_code = {} for r in plan_rows or []: st = r.get("logic_state") if isinstance(r, dict) else None code = str((r or {}).get("code") or "").strip() if not code or not isinstance(st, dict): continue by_code[code] = (r, st) out = [] for code in sorted(by_code): r, st = by_code[code] out.append({ "trade_date": ds, "code": code[:16], "raw_state": st.get("raw_state") or st.get("state"), "raw_why": st.get("why"), "state": st.get("state"), "settle_note": (st.get("settle_note") or "")[:128] or None, "prev_state": st.get("prev_state"), "as_of": st.get("as_of"), "usable": _SEP.join(st.get("usable") or [])[:64] or None, "missing": _SEP.join(st.get("missing") or [])[:64] or None, "paths": json.dumps(st.get("paths") or [], ensure_ascii=False, default=str), "reasons": json.dumps((st.get("reasons") or [])[:6], ensure_ascii=False, default=str), "verdict": r.get("verdict"), "card_rank": r.get("card_rank"), "plan_version": (plan_version or "")[:32] or None, "snapshot_at": stamp, }) return out def save(ds: str, rows: list, conn_factory=None) -> None: """幂等落库:建表(已存在就跳过)、删该日行、整批插,删与插同一事务。与 judgement.save 同范式。 rows 为空也照样删该日行:那表示这一天一张卡都没装配出来,不该留上一次重跑的残行。""" table = config.LOGIC_STATE_TABLE factory = conn_factory or db.factor_conn cols = ",".join(COLUMNS) marks = ",".join(["%s"] * len(COLUMNS)) payload = [tuple(r.get(c) for c in COLUMNS) for r in rows] with factory() as conn: with conn.cursor() as cur: cur.execute(_CREATE_TABLE.format(t=table)) conn.commit() with conn.cursor() as cur: cur.execute(f"DELETE FROM {table} WHERE trade_date = %s", (ds,)) if payload: cur.executemany(f"INSERT INTO {table} ({cols}) VALUES ({marks})", payload) conn.commit() def persist(ds: str, plan_rows: list, plan_version: str | None = None, write=None) -> dict: """plan.generate 的一步:从快照行拼表行、幂等写该日、打印读数。write 可注入(离线单测)。""" rows = build_rows(ds, plan_rows, plan_version) (write or save)(ds, rows) by_state: dict = {} for r in rows: by_state[r["state"] or "空"] = by_state.get(r["state"] or "空", 0) + 1 moved = [r for r in rows if r["prev_state"] and r["prev_state"] != r["state"]] held = [r for r in rows if r["raw_state"] != r["state"]] print(f"逐票逻辑状态 {ds}:写入 {len(rows)} 行" f"({'、'.join(f'{k} {v}' for k, v in sorted(by_state.items())) or '无'})" f",落定态迁移 {len(moved)} 只,被抗抖动按住 {len(held)} 只 -> {config.LOGIC_STATE_TABLE}") for r in moved[:10]: print(f" 迁移:{r['code']} {r['prev_state']} -> {r['state']}({r['settle_note']})") return {"date": ds, "rows": len(rows), "by_state": by_state, "moved": len(moved), "held": len(held), "table": config.LOGIC_STATE_TABLE}