Files
sanguo_vnpy_v2/scripts/data_platform/static_overlay_wash.py
T

446 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 每行 '<domain>/<file>'(日内幂等)。"""
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()