# -*- coding: utf-8 -*- """static 三表事件驱动补洗(spec §20.11.7,2026-09-20)。 定位:新鲜度目标本体——dmsk 三表域 NOTICE_DATE 雷达命中股 → 逐股 F10(emweb) 拉近 N 期全列 → upsert 重写 per-stock parquet(static 树自身披露次日新鲜); 月度全量洗库(VPS ak-monthly)保留兜底扫漏;变更文件落 manifest,晚间 push_vps_mirror_daily.sh 按 manifest 推 VPS(单点采集+镜像分发)。 部署形态:挂 run_dmsk.sh daily lane 之后(一次性容器 /app:ro + 写 /volume1/stock/sanguo_vnpy_v2/data/static;NAS 容器无 akshare → 本脚本自带 emweb 薄客户端,akshare stock_*_sheet_by_report_em 的最小移植)。 雷达语义: - dmsk 近 PARTITION_LOOKBACK 个 dt= 分区 × NOTICE_DATE>=T-RADAR_WINDOW 的行 → (code, domain, REPORT_DATE)→notice;与 static 该股文件**同期**值比对 (per-REPORT_DATE 轴,09-20 review 精化——期级新披露/更正即使 notice 旧于 该股全局 max 亦命中),任一期 dmsk 更新 → 候选。static 缺文件的 code 跳过 (dmsk-only 覆盖段=§19.9 边界,不为北交/老退市造文件);static 文件损坏 →全期命中→重写自愈。 - 周日 sync_parquet.sh 全量覆盖式回冲=自愈:回冲后 static notice 回退 → 雷达 重新命中重洗(仅 RADAR_WINDOW 窗内;更旧回退归月度全量洗兜底)。 - pending 队列:拉取失败/F10 落库滞后(stale:写入后 max NOTICE_DATE 仍< 雷达值)次日重试,超 PENDING_MAX_AGE 天丢弃记日志。 写纪律(洗库族惯例):F10 返回零行不写不标(返 empty 进 pending 重试); tmp+os.replace 原子写;upsert=按 REPORT_DATE 整期替换(重述行天然胜出), 未命中期原样保留;三个日期列统一 'YYYY-MM-DD' 字符串。 rc:0=完赛(含限额耗尽);1=存在硬失败(优先);3=墙钟停(--until,未处理 候选留待次日雷达/pending 自愈)。 """ import argparse import glob import json import os import random import re import sys import time from datetime import datetime, timedelta import pandas as pd EMWEB = "https://emweb.securities.eastmoney.com/PC_HSF10/NewFinanceAnalysis" DOMAIN_ENDPOINT = {"balance": "zcfzb", "income": "lrb", "cashflow": "xjllb"} DOMAINS = ("income", "balance", "cashflow") EMWEB_MIN_INTERVAL = 1.0 # 东财 F10 web 族 1.0s 节流(akshare em 族纪律) PAGE_DATES = 5 # AjaxNew 每发 5 个报告期(akshare 原样) RADAR_WINDOW_DAYS = 14 # 雷达 NOTICE_DATE 窗(覆盖周末+pending 重试) PARTITION_LOOKBACK = 8 # dmsk 近 8 个 dt= 分区(2 年)足够容纳新披露 PENDING_MAX_AGE_DAYS = 30 DATE_COLS = ("REPORT_DATE", "NOTICE_DATE", "UPDATE_DATE") _BAD_DATES = ("", "NaT", "nan", "None", "none") _STATIC_ENV = "SANGUO_STATIC_ROOT" _DMSK_ENV = "SANGUO_FUND_DMSK_ROOT" _STATE_ENV = "SANGUO_STATIC_OVERLAY_STATE" _DEFAULTS = { "static": (r"C:\sanguo_vnpy_v2\data\static" if os.name == "nt" else "/volume1/stock/sanguo_vnpy_v2/data/static"), "dmsk": (r"C:\sanguo_vnpy_v2\data\fundamentals\dmsk" if os.name == "nt" else "/volume1/stock/fundamentals/dmsk"), "state": (r"C:\sanguo_vnpy_v2\data_backup\static_overlay" if os.name == "nt" else "/volume1/stock/sanguo_vnpy_v2/data_backup/static_overlay"), } def _norm_date_series(s: pd.Series) -> pd.Series: return s.astype(str).str[:10] def _valid_notice(s: pd.Series) -> pd.Series: v = _norm_date_series(s) return v[~v.isin(_BAD_DATES) & (v.str.len() == 10)] # ============ emweb F10 薄客户端(akshare 最小移植,fetcher 可注入测试) ============ class EmwebF10Client: """三表按报告期客户端。fetcher(url, params)->json dict / html str 可注入。""" def __init__(self, min_interval=EMWEB_MIN_INTERVAL, fetcher=None, timeout=20, retries=3, retry_sleep=5.0): self._last = [0.0] self.min_interval = min_interval self.timeout = timeout self.retries = retries self.retry_sleep = retry_sleep self._ctype_cache = {} if fetcher is None: import requests self._session = requests.Session() self._session.headers.update({"User-Agent": "Mozilla/5.0"}) self._fetch = self._http_get else: self._session = None self._fetch = fetcher def _throttle(self): wait = self.min_interval - (time.time() - self._last[0]) if wait > 0: time.sleep(wait + random.uniform(0.05, 0.3)) self._last[0] = time.time() def _http_get(self, url, params): r = self._session.get(url, params=params, timeout=self.timeout) r.raise_for_status() return r.json() if url.endswith("AjaxNew") else r.text def _get(self, url, params): err = None for _ in range(self.retries): self._throttle() try: return self._fetch(url, params) except Exception as e: # 网络/JSON 解析双失败统一重试 err = e time.sleep(self.retry_sleep) raise RuntimeError("emweb get fail %s %s: %s" % (url, params, err)) def company_type(self, code_prefixed: str) -> str: if code_prefixed in self._ctype_cache: return self._ctype_cache[code_prefixed] html = self._get(EMWEB + "/Index", {"type": "web", "code": code_prefixed.lower()}) m = (re.search(r'id="hidctype"[^>]*value="([^"]+)"', html) or re.search(r'value="([^"]+)"[^>]*id="hidctype"', html)) if not m: raise RuntimeError("hidctype not found for %s" % code_prefixed) self._ctype_cache[code_prefixed] = m.group(1) return m.group(1) def report_dates(self, domain: str, code_prefixed: str) -> list: ep = DOMAIN_ENDPOINT[domain] data = self._get("%s/%sDateAjaxNew" % (EMWEB, ep), {"companyType": self.company_type(code_prefixed), "reportDateType": "0", "code": code_prefixed}) rows = (data or {}).get("data") or [] return sorted({str(r.get("REPORT_DATE"))[:10] for r in rows if r.get("REPORT_DATE")}) def fetch_recent(self, domain: str, code_prefixed: str, recent_n: int) -> pd.DataFrame: """拉近 recent_n 期(日期降序取前 N,PAGE_DATES 期/发分页拼接)。""" ep = DOMAIN_ENDPOINT[domain] dates = sorted(self.report_dates(domain, code_prefixed), reverse=True)[:recent_n] frames = [] for i in range(0, len(dates), PAGE_DATES): chunk = dates[i:i + PAGE_DATES] data = self._get("%s/%sAjaxNew" % (EMWEB, ep), {"companyType": self.company_type(code_prefixed), "reportDateType": "0", "reportType": "1", "dates": ",".join(chunk), "code": code_prefixed}) rows = (data or {}).get("data") if not rows: continue frames.append(pd.DataFrame(rows)) if not frames: return pd.DataFrame() return pd.concat(frames, ignore_index=True) # ============ 雷达 ============ def scan_radar(dmsk_root: str, static_root: str, domains, window_days: int = RADAR_WINDOW_DAYS): """→ (candidates {(code, domain): dmsk_max_notice}, skipped [(code,domain)])。 比对轴=per-(code, REPORT_DATE)(09-20 factor review MINOR-2 精化):dmsk 侧 期级 notice 与 static 同期值比对——期级新披露/更正即使 notice 旧于该股全局 max 亦命中(分区名 dt= 即 REPORT_DATE,§20.11.3 契约=分区键同值)。static 文件损坏=cur_map 空→全期命中→重写自愈。candidates 值=该股窗内 dmsk notice 最大值(供 wash_unit stale 校验)。 """ cutoff = (datetime.now() - timedelta(days=window_days)).strftime("%Y-%m-%d") raw = {} # (code, domain, report_date) -> notice for domain in domains: parts = sorted(glob.glob(os.path.join(dmsk_root, domain, "dt=*"))) for pdir in parts[-PARTITION_LOOKBACK:]: rd = os.path.basename(pdir)[3:] for f in glob.glob(os.path.join(pdir, "*.parquet")): try: df = pd.read_parquet( f, columns=["SECURITY_CODE", "NOTICE_DATE"]) except Exception: continue notices = _norm_date_series(df["NOTICE_DATE"]) hit = df[~notices.isin(_BAD_DATES) & (notices >= cutoff).to_numpy() & (notices.str.len() == 10).to_numpy()] for code, notice in zip(hit["SECURITY_CODE"], notices[hit.index]): key = (str(code), domain, rd) if notice > raw.get(key, ""): raw[key] = notice by_stock = {} for (code, domain, rd), notice in raw.items(): by_stock.setdefault((code, domain), {})[rd] = notice candidates, skipped = {}, [] for (code, domain), rd_notices in sorted(by_stock.items()): files = glob.glob(os.path.join( static_root, domain, "%s.*_%s.parquet" % (code, domain))) if not files: skipped.append((code, domain)) continue try: cur = pd.read_parquet( files[0], columns=["REPORT_DATE", "NOTICE_DATE"]) except Exception: cur = None cur_map = {} if cur is not None and len(cur): rds = _norm_date_series(cur["REPORT_DATE"]) nts = _norm_date_series(cur["NOTICE_DATE"]) ok = (~rds.isin(_BAD_DATES) & (rds.str.len() == 10) & ~nts.isin(_BAD_DATES) & (nts.str.len() == 10)) for r, n in zip(rds[ok], nts[ok]): if n > cur_map.get(r, ""): cur_map[r] = n trig, stock_max = False, "" for rd, notice in rd_notices.items(): if notice > stock_max: stock_max = notice if notice > cur_map.get(rd, ""): trig = True if trig: candidates[(code, domain)] = stock_max return candidates, skipped # ============ pending / manifest ============ def load_pending(state_dir: str) -> dict: pend = os.path.join(state_dir, "pending.json") if not os.path.exists(pend): return {"units": []} try: with open(pend, encoding="utf-8") as f: return json.load(f) except Exception: return {"units": []} def save_pending(state_dir: str, pending: dict): os.makedirs(state_dir, exist_ok=True) with open(os.path.join(state_dir, "pending.json"), "w", encoding="utf-8") as f: json.dump(pending, f, ensure_ascii=False, indent=1) def append_manifest(state_dir: str, domain: str, filename: str): """变更清单:manifests/YYYYMMDD.list 每行 '/'(日内幂等)。""" mani = os.path.join(state_dir, "manifests") os.makedirs(mani, exist_ok=True) path = os.path.join(mani, datetime.now().strftime("%Y%m%d") + ".list") line = "%s/%s" % (domain, filename) existing = set() if os.path.exists(path): with open(path, encoding="utf-8") as f: existing = {x.strip() for x in f if x.strip()} if line in existing: return with open(path, "a", encoding="utf-8") as f: f.write(line + "\n") # ============ upsert 与单股洗 ============ def upsert_periods(existing: pd.DataFrame, fetched: pd.DataFrame) -> pd.DataFrame: """按 REPORT_DATE 整期替换(fetched 胜出=重述天然胜),未命中期原样保留。 出口三个日期列统一 'YYYY-MM-DD' 字符串(旧行一并归一,月度全量洗重写 亦同向;消费方 fund_pit/因子 loader 均按前缀归一读,两种历史形态兼容)。 """ if fetched is None or fetched.empty: return existing fetched = fetched.copy() for c in DATE_COLS: if c in fetched.columns: fetched[c] = _norm_date_series(fetched[c]) if existing is None or existing.empty: out = fetched else: hit = set(_norm_date_series(fetched["REPORT_DATE"])) keep = existing[~_norm_date_series( existing["REPORT_DATE"]).isin(hit)] out = pd.concat([keep, fetched], ignore_index=True) for c in DATE_COLS: if c in out.columns: out[c] = _norm_date_series(out[c]) return out def wash_unit(client: EmwebF10Client, static_root: str, state_dir: str, domain: str, code: str, recent_n: int, radar_notice: str = "") -> str: """单 (code, domain):拉→upsert→原子写→manifest。返 ok/stale/empty。 stale=写入成功但 max NOTICE_DATE 仍 < 雷达值(F10 落库滞后,pending 次日 重试);网络失败向上 raise 由调用方记 pending。 """ files = glob.glob(os.path.join( static_root, domain, "%s.*_%s.parquet" % (code, domain))) if not files: return "no_static_file" path = files[0] code_prefixed = ("SH" if code.startswith(("6", "9")) else "SZ") + code fetched = client.fetch_recent(domain, code_prefixed, recent_n) if fetched.empty: return "empty" try: existing = pd.read_parquet(path) except Exception: existing = pd.DataFrame() out = upsert_periods(existing, fetched) tmp = path + ".wash_tmp" out.to_parquet(tmp, index=False) os.replace(tmp, path) append_manifest(state_dir, domain, os.path.basename(path)) if "NOTICE_DATE" not in out.columns: return "ok" notices = _valid_notice(out["NOTICE_DATE"]) if not len(notices) or not radar_notice: return "ok" return "ok" if notices.max() >= radar_notice else "stale" # ============ 主流程 ============ def _wall_deadline(until: str) -> datetime: """--until HH:MM 当日墙钟;已过期=立即停(不滚明天——迟到的手动跑 不许越过当日窗口继续拉,corpus 语义同族)。""" h, m = until.split(":") now = datetime.now() d = now.replace(hour=int(h), minute=int(m), second=0, microsecond=0) return d if d >= now else now def run(args, client=None) -> int: static_root = (os.environ.get(_STATIC_ENV) or args.static_root or _DEFAULTS["static"]) dmsk_root = (os.environ.get(_DMSK_ENV) or args.dmsk_root or _DEFAULTS["dmsk"]) state_dir = (os.environ.get(_STATE_ENV) or args.state_dir or _DEFAULTS["state"]) domains = [d for d in args.domains.split(",") if d] deadline = _wall_deadline(args.until) candidates, skipped = scan_radar(dmsk_root, static_root, domains, args.radar_window) print("radar: candidates=%d dmsk_only_skipped=%d" % ( len(candidates), len(skipped))) pending = load_pending(state_dir) today = datetime.now().strftime("%Y-%m-%d") alive, pend_keys = [], set() for u in pending["units"]: age = (datetime.now() - datetime.strptime( u["first_seen"], "%Y-%m-%d")).days if age > PENDING_MAX_AGE_DAYS: print("pending expire: %s/%s (%dd)" % (u["code"], u["domain"], age)) continue alive.append(u) pend_keys.add((u["code"], u["domain"])) worklist = sorted(set(candidates) | pend_keys) if args.limit: worklist = worklist[:args.limit] print("worklist=%d (pending_carry=%d)" % (len(worklist), len(pend_keys))) client = client or EmwebF10Client() hard_fail, stale_units, done_ok = [], [], set() washed = 0 rc_wall = False for i, (code, domain) in enumerate(worklist): if datetime.now() >= deadline: print("wallclock stop at %d/%d" % (i, len(worklist))) rc_wall = True break radar_notice = candidates.get((code, domain), "") if args.dry_run: print("[dry] %s/%s radar_notice=%s" % (code, domain, radar_notice or "(pending)")) continue try: status = wash_unit(client, static_root, state_dir, domain, code, args.recent_periods, radar_notice) print("washed %s/%s -> %s" % (code, domain, status)) if status == "ok": washed += 1 done_ok.add((code, domain)) else: # stale/empty=次日重试;no_static_file 不该出现在 worklist stale_units.append({"code": code, "domain": domain}) except Exception as e: print("FAIL %s/%s: %s" % (code, domain, e)) hard_fail.append((code, domain)) # pending 落盘:ok 出队;未触达(墙钟停/限额)沿用;失败/stale 续留 if not args.dry_run: new_pend = {u["code"] + "/" + u["domain"]: u for u in alive if (u["code"], u["domain"]) not in done_ok} for u in stale_units: new_pend.setdefault(u["code"] + "/" + u["domain"], {"code": u["code"], "domain": u["domain"], "first_seen": today}) for code, domain in hard_fail: new_pend.setdefault(code + "/" + domain, {"code": code, "domain": domain, "first_seen": today}) save_pending(state_dir, {"units": sorted( new_pend.values(), key=lambda u: (u["code"], u["domain"]))}) print("summary: washed=%d fail=%d retry_pending=%d pending_total=%d" % ( washed, len(hard_fail), len(stale_units), 0 if args.dry_run else len(new_pend))) if hard_fail: return 1 if rc_wall: return 3 return 0 def main(): ap = argparse.ArgumentParser(description="static 三表事件驱动补洗 (§20.11.7)") ap.add_argument("--until", default="15:00", help="墙钟停 HH:MM") ap.add_argument("--limit", type=int, default=0, help="本次最多洗 N 单元(0=不限)") ap.add_argument("--recent-periods", type=int, default=12, help="每股域拉近 N 期(默认 12=3 年,深史归月度全量洗)") ap.add_argument("--radar-window", type=int, default=RADAR_WINDOW_DAYS) ap.add_argument("--domains", default=",".join(DOMAINS)) ap.add_argument("--static-root", default=None) ap.add_argument("--dmsk-root", default=None) ap.add_argument("--state-dir", default=None) ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() rc = run(args) print("static_overlay_wash rc=%d" % rc) sys.exit(rc) if __name__ == "__main__": main()