feat(data): 财务136 W3' dmsk双lane落地+spec§19.8实施记录 [nas]
CI/CD / test (push) Successful in 32s
CI/CD / nas-deploy (push) Successful in 0s
CI/CD / nas-verify (push) Successful in 11s

- fundamentals_dmsk_download.py 单脚本(2009→今backfill+近3期daily NOTIC_DATE>=T-7),corpus骨架六修正全带入
- marker修正: daily按日折叠(§19.4原文「一次永逸」=day-2同款坑,52756e3教训);backfill键=域+期(域缺席连带跳过,首测抓出)
- 次新期金丝雀防「filter失效vs空期」不分导致的整段静默空跑
- run_dmsk_standalone.sh(DSM 07:10 sanguo-fundamentals-daily,一次性容器同corpus款)+verify_dmsk.py(键唯一硬门)
- 21测全mock绿,全量295绿;W1'镜像对账全绿+vb2026 H1缺口入档
This commit is contained in:
2026-09-08 00:29:23 +08:00
parent 2dcbdbe6fc
commit 236bd30eb8
5 changed files with 1178 additions and 0 deletions
@@ -0,0 +1,697 @@
# -*- coding: utf-8 -*-
"""fundamentals_dmsk_download.py — NAS 财务三表 dmsk 补缺层采集唯一入口(spec §19.4)。
运行形态: 一次性容器 docker run --rm(wrapper=/volume1/stock/fundamentals/run_dmsk.sh),
与 corpus/5m 同款;落 datacenter-web(东财数据中心),与 corpus 的 search-api/np-listapi
分域,各自限速。
--lane backfill 2009Q1→最近已完期 全期回补(unit=(domain,REPORT_DATE) 一次永逸,
期内容历史不可变;≈70期×3表×~12页≈2200发≈1h)
--lane daily 近 3 期重扫: NOTICE_DATE>=T-7 客户端过滤+账本去重 → 披露次日即可用
(不等 VPS 月度洗库 5 周);marker 按日折叠(增量型 marker 必须带日期
维度——corpus day-2 坑 52756e3 教训,spec§19.4「一次永逸」原文据此修正)
--until HH:MM 墙钟自停: 完成当前 unit 后 checkpoint 退出(rc=3)
--limit N 冒烟: 每域最多 N unit, 绝不落 marker
落库: {DMSK_ROOT}/dmsk/{balance,income,cashflow}/dt=YYYY-MM-DD/part-N.parquet
57 列原样+SECURITY_CODE/REPORT_DATE 归一+fetch_date;行键 (SECURITY_CODE,
REPORT_DATE) 全局唯一(IdLedger);**绝不写 phase3 镜像树**(state/static 归
phase3 04:00 链所有,写=被次日冲掉+破坏 ≡VPS 不变量)。
下游读法(双层口径, spec §19.7 写死): 基线层=镜像树全列(≤5周延迟)为主;本层=57列
核心科目新鲜度补丁+退市/北交覆盖;键唯一由本层账本保证,读者可直 union。
断点续传: unit marker(state/markers/, tmp+rename 原子)。
退出码: 0=完成 1=致命(unit 失败/域冷却) 2=限流让路(429) 3=墙钟/锁让路。
单实例锁: fcntl.flock(state/dmsk.lock) — 跨容器内核级,进程死自动释放。
"""
import argparse
import datetime as dt
import fcntl
import hashlib
import json
import logging
import os
import random
import shutil
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
import requests
# ---------- 常量(测试可 monkeypatch) ----------
DMSK_ROOT = Path(os.environ.get("DMSK_ROOT", "/volume1/stock/fundamentals"))
DC_URL = "https://datacenter-web.eastmoney.com/api/data/v1/get"
DOMAINS = { # spec §19.2 实测契约: 全市场单期≈5223行/57列
"balance": "RPT_DMSK_FN_BALANCE",
"income": "RPT_DMSK_FN_INCOME",
"cashflow": "RPT_DMSK_FN_CASHFLOW",
}
ID_KEYS = {d: ["SECURITY_CODE", "REPORT_DATE"] for d in DOMAINS}
DC_PAGE_SIZE = 500 # 实测 5223 行≈12 页
DC_MAX_PAGES = 500 # 防失控硬顶
BACKFILL_FROM_YEAR = 2009 # 实测最早 2009 年报(akshare docstring「2010起」过时)
DAILY_RECENT_PERIODS = 3 # 近 3 期重扫窗
DAILY_NOTICE_WINDOW_DAYS = 7 # NOTICE_DATE >= T-7 视为新披露
BJ_PREFIXES = ("43", "83", "87", "88", "92") # 北交(920 新码+43/83/87/88 存量)
COOLDOWN_ERRORS = 10
FLUSH_EVERY = 10 # 账本/marker 批量提交周期(unit 计)
RATE = {"datacenter": 1.5} # 每域最小间隔(秒), 慢爬纪律
JITTER = (0.0, 0.3)
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
log = logging.getLogger("dmsk")
# ---------- 异常(契约同 corpus) ----------
class TransportError(Exception):
"""传输类失败(超时/5xx/连接): unit 不标 done, 计入域名连续错。"""
class DomainCooldown(Exception):
"""域名冷却: 连续错×10 或 429。rate_limited=True → 退出码走 2(限流让路)。"""
def __init__(self, domain, rate_limited=False):
super().__init__(f"domain {domain} cooled (rate_limited={rate_limited})")
self.rate_limited = rate_limited
class HttpDeterministicError(Exception):
"""确定性 4xx(非 429): 记日志跳过, 绝不烧重试。"""
def __init__(self, status, url=""):
super().__init__(f"HTTP {status} {url}")
self.status = status
class WallClockStop(Exception):
"""--until 墙钟到: 完成当前 unit 后抛出, 顶层收拾落盘并 rc=3。"""
# ---------- 单实例锁(flock, 跨容器) ----------
def acquire_lock():
lock = DMSK_ROOT / "state" / "dmsk.lock"
lock.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(lock), os.O_CREAT | os.O_RDWR, 0o666)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
os.close(fd)
return None
return fd
# ---------- 域名客户端(限速/连续错/429, 同 corpus) ----------
class DomainClient:
def __init__(self, name):
self.name = name
self.session = requests.Session()
self.session.trust_env = False # 直连不走代理
self.consecutive_errors = 0
self.rate_limited = False
self.cooled = False
self.n_requests = 0
self._last = 0.0
self._t_first = None
self._t_last = None
def _pace(self):
interval = RATE.get(self.name, 1.0) + random.uniform(*JITTER)
wait = self._last + interval - time.monotonic()
if wait > 0:
time.sleep(wait)
self._last = time.monotonic()
if self._t_first is None:
self._t_first = self._last
self._t_last = self._last
self.n_requests += 1
def avg_interval(self):
if self.n_requests < 2 or self._t_first is None:
return None
return (self._t_last - self._t_first) / (self.n_requests - 1)
def _transport_fail(self, msg):
self.consecutive_errors += 1
if self.consecutive_errors >= COOLDOWN_ERRORS:
self.cooled = True
raise DomainCooldown(self.name)
raise TransportError(msg)
def request(self, method, url, **kw):
if self.cooled:
raise DomainCooldown(self.name)
self._pace()
try:
r = self.session.request(method, url, timeout=25, **kw)
except requests.RequestException as e:
self._transport_fail(f"{type(e).__name__}: {e}")
if r.status_code == 429:
self.rate_limited = True
self.cooled = True
raise DomainCooldown(self.name, rate_limited=True)
if r.status_code >= 500:
self._transport_fail(f"HTTP {r.status_code}")
if r.status_code >= 400:
raise HttpDeterministicError(r.status_code, url)
self.consecutive_errors = 0
return r
# ---------- 期枚举(纯函数) ----------
def quarter_end_dates(start_year, before_date, count=None):
"""季度期末日序列, 新→旧(近年优先供 factor);只含 <= before_date 的已完期。
例: before=2026-09-08 → 2026-06-30, 2026-03-31, 2025-12-31, ..."""
out = []
for year in range(before_date.year, start_year - 1, -1):
for md in ("12-31", "09-30", "06-30", "03-31"):
d = dt.date(year, *(int(x) for x in md.split("-")))
if d <= before_date:
out.append(d)
if count is not None:
out = out[:count]
return out
def is_bj_code(code):
return bool(code) and str(code)[:2] in BJ_PREFIXES
# ---------- 归一化(纯函数) ----------
def norm_row(raw, fetch_date):
"""行键列归一(SECURITY_CODE 补零串/REPORT_DATE 截日),其余 57 列原样透传。"""
row = dict(raw)
code = str(raw.get("SECURITY_CODE") or "").strip()
if code.isdigit() and len(code) < 6:
code = code.zfill(6)
row["SECURITY_CODE"] = code
row["REPORT_DATE"] = str(raw.get("REPORT_DATE") or "")[:10]
row["fetch_date"] = fetch_date
return row
# ---------- id 去重账本(同 corpus IdLedger) ----------
def _hash_id(s):
return int.from_bytes(hashlib.blake2b(s.encode("utf-8"),
digest_size=8).digest(), "big") & (2**63 - 1)
def _row_key(domain, row):
return "|".join(str(row.get(c) or "") for c in ID_KEYS[domain])
class IdLedger:
"""全局行键账本(state/ids_{domain}.parquet, int64 hash 有序数组)。"""
def __init__(self, domain):
self.path = DMSK_ROOT / "state" / f"ids_{domain}.parquet"
if self.path.exists():
self._arr = pd.read_parquet(self.path)["h"].to_numpy()
else:
self._arr = np.empty(0, dtype=np.int64)
self._pending = []
def add(self, ids):
self._pending.extend(_hash_id(i) for i in ids)
def has_any(self, ids):
hs = np.array([_hash_id(i) for i in ids], dtype=np.int64)
out = []
for h in hs:
i = np.searchsorted(self._arr, h)
out.append(bool(i < len(self._arr) and self._arr[i] == h)
or h in self._pending)
return out
def flush(self):
if not self._pending:
return len(self._arr)
merged = np.unique(np.concatenate(
[self._arr, np.array(self._pending, dtype=np.int64)]))
tmp = self.path.with_suffix(".tmp")
pd.DataFrame({"h": merged}).to_parquet(tmp, index=False)
os.replace(tmp, self.path)
self._arr = merged
self._pending = []
return len(self._arr)
def _iter_parts(domain):
base = DMSK_ROOT / "dmsk" / domain
if not base.exists():
return []
return sorted(p for p in base.glob("dt=*/part-*.parquet") if p.is_file())
def reconcile_ledger_from_parts(domain, ledger):
"""启动对账: 分区文件即真相——part 有而账本无的键补进账本(崩溃窗口自愈,
corpus _reconcile_ledgers 的全量版: dmsk 分区=期目录非日历日, 无近窗概念)。"""
keys = []
for part in _iter_parts(domain):
df = pd.read_parquet(part, columns=ID_KEYS[domain])
keys.extend("|".join(str(r[c]) for c in ID_KEYS[domain])
for r in df.to_dict("records"))
if not keys:
return 0
known = ledger.has_any(keys)
missing = [k for k, has in zip(keys, known) if not has]
if missing:
ledger.add(missing)
ledger.flush()
log.info("ledger reconcile %s: +%d (分区对账)", domain, len(missing))
return len(missing)
# ---------- 分区追加(按期 part 文件, O(1)/unit) ----------
_PERIOD_BUFFERS = {}
def _period_dir(domain, report_date):
return DMSK_ROOT / "dmsk" / domain / f"dt={report_date}"
def append_rows(domain, report_date, rows):
"""进 (domain, 期) 内存缓冲; flush_all() 时各写一个新 part-N(tmp+rename)。
旧 part 永不重写;行键唯一由调用方(账本+批内 seen)保证。"""
if not rows:
return 0
key = (domain, report_date)
buf = _PERIOD_BUFFERS.get(key)
if buf is None:
buf = _PERIOD_BUFFERS[key] = PeriodBuffer(domain, report_date)
buf.rows.extend(rows)
return len(rows)
class PeriodBuffer:
"""期目录追加缓冲 → flush 落一个新 part 文件(批量, 原子)。"""
def __init__(self, domain, report_date):
self.domain = domain
self.report_date = report_date
self.rows = []
d = _period_dir(domain, report_date)
idx = -1
if d.exists():
for p in d.glob("part-*.parquet"):
try:
idx = max(idx, int(p.stem.split("-")[1]))
except (ValueError, IndexError):
continue
self.next_idx = idx + 1
def flush(self):
if not self.rows:
return 0
d = _period_dir(self.domain, self.report_date)
d.mkdir(parents=True, exist_ok=True)
n = len(self.rows)
tmp = d / f".part-{self.next_idx}.{os.getpid()}.tmp"
try:
pd.DataFrame(self.rows).to_parquet(tmp, index=False)
os.replace(tmp, d / f"part-{self.next_idx}.parquet")
except Exception:
tmp.unlink(missing_ok=True)
raise
self.next_idx += 1
self.rows = []
return n
def flush_all():
for buf in _PERIOD_BUFFERS.values():
buf.flush()
# ---------- unit marker ----------
def _marker_path(lane, stage, unit):
return DMSK_ROOT / "state" / "markers" / lane / stage / f"{unit}.done"
def is_done(lane, stage, unit):
return _marker_path(lane, stage, unit).exists()
def mark_done(lane, stage, unit):
p = _marker_path(lane, stage, unit)
p.parent.mkdir(parents=True, exist_ok=True)
tmp = p.with_suffix(".tmp")
tmp.write_text(dt.datetime.now().isoformat(), encoding="utf-8")
os.replace(tmp, p)
def _dunit(domain, report_date, day):
"""daily lane marker 单元名: 按日折叠(dt=YYYY-MM-DD/ 子目录)。
增量窗每日一新——无日期维度会让首日 done 后次日整段跳过(corpus day-2 坑)。
backfill 期 marker 保持平铺一次永逸(期内容历史不可变,语义不同)。"""
return f"dt={day.isoformat()}/{domain}_{report_date}"
def prune_daily_markers():
"""daily 日初清理: 只留今日 dt= 目录 + 清历史平铺 .done(防线同 corpus)。"""
today = dt.date.today().isoformat()
base = DMSK_ROOT / "state" / "markers" / "daily"
if not base.exists():
return
for stage_dir in base.iterdir():
if not stage_dir.is_dir():
continue
for child in stage_dir.iterdir():
if child.is_dir():
if child.name != f"dt={today}":
shutil.rmtree(child, ignore_errors=True)
elif child.suffix == ".done":
child.unlink(missing_ok=True)
# ---------- fetch 层 ----------
def fetch_period_rows(client, domain, report_date):
"""全市场单期全页。result=None/缺省=合法空期(2009Q1 等早期无数据),
返回 []由调用方标 done;翻页按 result.pages。"""
out, page = [], 1
while True:
params = {
"sortColumns": "SECURITY_CODE", "sortTypes": "1",
"pageSize": str(DC_PAGE_SIZE), "pageNum": str(page),
"reportName": DOMAINS[domain], "columns": "ALL",
"filter": f"(REPORT_DATE='{report_date}')",
}
r = client.request("GET", DC_URL, params=params, headers=UA)
try:
j = r.json()
except ValueError as e:
raise TransportError(f"json decode: {e}") from e
result = j.get("result") or {}
rows = result.get("data") or []
out.extend(rows)
pages = int(result.get("pages") or 0)
if not rows or page >= pages or page >= DC_MAX_PAGES:
return out
page += 1
# ---------- 运行上下文(同 corpus 纪律: 数据→账本→marker 次序) ----------
class Ctx:
def __init__(self, lane, until=None, limit=None):
self.lane = lane
self.limit = limit
self.deadline = None
if until:
hh, mm = until.split(":")
self.deadline = dt.datetime.combine(
dt.date.today(), dt.time(int(hh), int(mm)))
self.failed = 0
self.rate_limited = False
self.hard_cool = False
self.wallclock = False
self.units = 0
self.stage_units = 0
self.clients = {}
self.ledgers = None
self.pending_marks = []
def set_stores(self, ledgers):
self.ledgers = ledgers
def reset_stage(self):
"""--limit 预算按域独立(冒烟覆盖每域)。"""
self.stage_units = 0
def budget_exhausted(self):
return self.limit is not None and self.stage_units >= self.limit
def unit_done(self, stage, unit, mark=True):
self.units += 1
self.stage_units += 1
if mark and self.limit is None:
self.pending_marks.append((self.lane, stage, unit))
if self.units % FLUSH_EVERY == 0:
self.commit()
def commit(self):
"""数据分区 → id 账本 → unit marker 依次落盘(崩溃宁可重拉不产生洞)。"""
flush_all()
if self.ledgers:
for led in self.ledgers.values():
led.flush()
for lane, stage, unit in self.pending_marks:
mark_done(lane, stage, unit)
self.pending_marks.clear()
def stop_now(self):
if self.deadline and dt.datetime.now() >= self.deadline:
self.wallclock = True
raise WallClockStop()
def rc(self):
if self.failed or self.hard_cool:
return 1
if self.rate_limited:
return 2
if self.wallclock:
return 3
return 0
def _filter_new(domain, norm_rows, ledger):
"""账本已知键 + 批内 seen 双层截留(corpus 六修正之三: 批内去重)。"""
keys = [_row_key(domain, r) for r in norm_rows]
known = ledger.has_any(keys)
seen = set()
out = []
for r, k in zip(norm_rows, known):
if k or _row_key(domain, r) in seen:
continue
seen.add(_row_key(domain, r))
out.append(r)
return out
def _absorb_new(domain, report_date, norm_rows, ledgers):
new = _filter_new(domain, norm_rows, ledgers[domain])
if new:
append_rows(domain, report_date, new)
ledgers[domain].add([_row_key(domain, r) for r in new])
return new
# ---------- BJ 含性工件(spec §19.4 实施首日验证) ----------
def save_bj_inclusion(counts):
"""{domain: {period: bj_rows}} 跨 run 累积, 供北交所含性验证。"""
p = DMSK_ROOT / "state" / "bj_inclusion.json"
merged = {}
if p.exists():
try:
merged = json.loads(p.read_text(encoding="utf-8"))
except ValueError:
log.warning("bj_inclusion.json 损坏, 重写(幂等无损)")
for domain, per_period in counts.items():
slot = merged.setdefault(domain, {})
for period, n in per_period.items():
slot[period] = max(slot.get(period, 0), n)
tmp = p.with_suffix(".tmp")
tmp.write_text(json.dumps(merged, ensure_ascii=False, indent=1,
sort_keys=True), encoding="utf-8")
os.replace(tmp, p)
# ---------- backfill lane ----------
def _canary_period(periods):
"""次新期金丝雀: 东财把「过滤器失效/契约漂移」与「合法空期」都回 result=None
无法区分——次新期全市场已披露 3+ 个月必非空,它空=契约坏了。最新期不作金丝雀
(披露季初可合法 0 行)。"""
return periods[1] if len(periods) > 1 else periods[0]
def run_backfill(ctx, cl, ledgers, start_year=BACKFILL_FROM_YEAR):
today = dt.date.today()
fetch_date = today.isoformat()
periods = quarter_end_dates(start_year, today)
canary = _canary_period(periods)
bj_counts = {}
log.info("backfill periods: %d (%s%s), canary=%s",
len(periods), periods[-1], periods[0], canary)
try:
for domain in DOMAINS:
ctx.reset_stage()
bj_counts[domain] = {}
for report_date in periods:
unit = str(report_date)
mkey = f"{domain}_{unit}" # marker 键=域+期(域缺席会让后续
if ctx.budget_exhausted(): # 域被首域 marker 连带跳过)
break
if is_done(ctx.lane, "period", mkey):
continue
try:
rows = fetch_period_rows(cl, domain, unit)
except DomainCooldown as e:
log.warning("backfill %s 域冷却 @%s: %s", domain, unit, e)
ctx.rate_limited |= e.rate_limited
ctx.hard_cool |= not e.rate_limited
return
except (TransportError, HttpDeterministicError) as e:
ctx.failed += 1
log.warning("backfill %s %s 失败(不标done): %s",
domain, unit, e)
continue
if not rows and report_date == canary:
ctx.failed += 1
log.error("backfill %s 金丝雀期 %s 空(契约漂移疑云, "
"不标done不落空库): 检查 filter/URL 契约",
domain, unit)
continue
norm = [norm_row(r, fetch_date) for r in rows]
new = _absorb_new(domain, unit, norm, ledgers)
n_bj = sum(1 for r in new if is_bj_code(r["SECURITY_CODE"]))
bj_counts[domain][unit] = n_bj
log.info("backfill %s %s: +%d/%d (bj=%d)",
domain, unit, len(new), len(rows), n_bj)
ctx.unit_done("period", mkey, mark=ctx.limit is None)
ctx.stop_now()
finally:
if any(bj_counts.values()):
save_bj_inclusion(bj_counts)
# ---------- daily lane ----------
def run_daily(ctx, cl, ledgers):
today = dt.date.today()
fetch_date = today.isoformat()
cutoff = (today - dt.timedelta(days=DAILY_NOTICE_WINDOW_DAYS)).isoformat()
periods = quarter_end_dates(BACKFILL_FROM_YEAR, today,
count=DAILY_RECENT_PERIODS)
canary = _canary_period(periods)
prune_daily_markers()
log.info("daily periods: %s, notice_cutoff=%s, canary=%s",
periods, cutoff, canary)
for domain in DOMAINS:
ctx.reset_stage()
for report_date in periods:
unit = str(report_date)
du = _dunit(domain, unit, today)
if ctx.limit is None:
if is_done(ctx.lane, "period", du):
continue
elif ctx.budget_exhausted():
break
try:
rows = fetch_period_rows(cl, domain, unit)
except DomainCooldown as e:
log.warning("daily %s 域冷却 @%s: %s", domain, unit, e)
ctx.rate_limited |= e.rate_limited
ctx.hard_cool |= not e.rate_limited
return
except (TransportError, HttpDeterministicError) as e:
ctx.failed += 1
log.warning("daily %s %s 失败(不标done): %s", domain, unit, e)
continue
if not rows and report_date == canary:
ctx.failed += 1
log.error("daily %s 金丝雀期 %s 空(契约漂移疑云, 不标done): "
"检查 filter/URL 契约", domain, unit)
continue
norm = [norm_row(r, fetch_date) for r in rows]
fresh = [r for r in norm
if str(r.get("NOTICE_DATE") or "")[:10] >= cutoff]
new = _absorb_new(domain, unit, fresh, ledgers)
log.info("daily %s %s: +%d 新披露/%d",
domain, unit, len(new), len(rows))
ctx.unit_done("period", du, mark=ctx.limit is None)
ctx.stop_now()
# ---------- lane 入口 ----------
def _ensure_log():
if log.handlers:
return
(DMSK_ROOT / "logs").mkdir(parents=True, exist_ok=True)
ts = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
fh = logging.FileHandler(DMSK_ROOT / "logs" / f"dmsk_{ts}.log",
encoding="utf-8")
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
log.addHandler(fh)
log.addHandler(logging.StreamHandler())
log.setLevel(logging.INFO)
def run_lane(lane, until=None, limit=None, start_year=BACKFILL_FROM_YEAR):
_PERIOD_BUFFERS.clear()
_ensure_log()
ctx = Ctx(lane=lane, until=until, limit=limit)
ledgers = {d: IdLedger(d) for d in DOMAINS}
for d in DOMAINS:
reconcile_ledger_from_parts(d, ledgers[d])
cl = DomainClient("datacenter")
ctx.clients = {"datacenter": cl}
ctx.set_stores(ledgers)
log.info("lane=%s start: until=%s, limit=%s, start_year=%s",
lane, until, limit, start_year)
t0 = time.monotonic()
try:
if lane == "daily":
run_daily(ctx, cl, ledgers)
elif lane == "backfill":
run_backfill(ctx, cl, ledgers, start_year=start_year)
else:
log.error("unknown lane: %s", lane)
ctx.failed += 1
except WallClockStop:
log.info("墙钟到(--until %s): 完成当前 unit 后 checkpoint 退出", until)
finally:
ctx.commit()
for name, c in ctx.clients.items():
if c.n_requests:
avg = c.avg_interval()
log.info("stats %s: n_requests=%d avg_interval=%s%s",
name, c.n_requests,
f"{avg:.2f}s" if avg is not None else "n/a",
" [429]" if c.rate_limited else
" [cooled]" if c.cooled else "")
log.info("lane=%s done in %.0fs: units=%d failed=%d rc=%d",
lane, time.monotonic() - t0, ctx.units, ctx.failed, ctx.rc())
return ctx.rc()
def main():
ap = argparse.ArgumentParser(description="NAS 财务三表 dmsk 补缺层(spec §19.4)")
ap.add_argument("--lane", required=True, choices=["daily", "backfill"])
ap.add_argument("--until", default=None, help="HH:MM 墙钟自停")
ap.add_argument("--limit", type=int, default=None,
help="冒烟: 每域最多 N unit")
ap.add_argument("--start-year", type=int, default=BACKFILL_FROM_YEAR)
args = ap.parse_args()
fd = acquire_lock()
if fd is None:
print("另一实例持有锁, 让路退出", file=sys.stderr)
sys.exit(3)
try:
rc = run_lane(args.lane, until=args.until, limit=args.limit,
start_year=args.start_year)
sys.exit(rc)
finally:
os.close(fd)
if __name__ == "__main__":
main()
+57
View File
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""verify_dmsk.py — dmsk 补缺层只读验收器(spec §19.5 ①覆盖+键唯一硬门)。
用法: python3 verify_dmsk.py [--root /volume1/stock/fundamentals]
检查(退出码 0=PASS 1=FAIL):
1. 每域期目录数/part 数/行数/北交行数/最新 NOTICE_DATE;
2. 行键 (SECURITY_CODE,REPORT_DATE) 全局唯一(重键=FAIL, 同 corpus verify 纪律);
3. 退市股可查性(000003 三表至少一域有行 → 否则 WARNING 不 FAIL, 取决于回补深度);
4. 北交所含性(920/43/83/87/88 前缀行数>0 → 含; =0 → 单列说明不 FAIL)。
"""
import argparse
import json
import sys
from pathlib import Path
import pandas as pd
BJ_PREFIXES = ("43", "83", "87", "88", "92")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--root", default="/volume1/stock/fundamentals")
args = ap.parse_args()
root = Path(args.root) / "dmsk"
fails = []
report = {}
for domain in ("balance", "income", "cashflow"):
base = root / domain
parts = sorted(base.glob("dt=*/part-*.parquet")) if base.exists() else []
if not parts:
fails.append(f"{domain}: 零 part 文件")
continue
frames = [pd.read_parquet(p, columns=["SECURITY_CODE", "REPORT_DATE",
"NOTICE_DATE"]) for p in parts]
df = pd.concat(frames, ignore_index=True)
dups = int(df.duplicated(["SECURITY_CODE", "REPORT_DATE"]).sum())
n_bj = int(df["SECURITY_CODE"].astype(str).str[:2]
.isin(BJ_PREFIXES).sum())
delisted = int((df["SECURITY_CODE"].astype(str) == "000003").sum())
latest_notice = str(df["NOTICE_DATE"].astype(str).max())[:10]
report[domain] = dict(periods=len({p.parent.name for p in parts}),
parts=len(parts), rows=len(df), dup_keys=dups,
bj_rows=n_bj, delisted_000003_rows=delisted,
latest_notice_date=latest_notice)
if dups:
fails.append(f"{domain}: {dups} 重键行(键唯一硬门)")
print(json.dumps(report, ensure_ascii=False, indent=1))
if fails:
print("FAIL:", "; ".join(fails))
sys.exit(1)
print("PASS: 键唯一全过, 三域在库")
sys.exit(0)
if __name__ == "__main__":
main()
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# run_dmsk_standalone.sh — 财务三表 dmsk 补缺层 runner(2026-09-08 spec §19.4)
#
# 模式: 与 corpus/5m 完全同款的「DSM 任务(admin) → bash wrapper → 一次性容器」
# (docker run --rm 跑 lock-aligned 镜像,主容器重启/重建永不波及本进程)。
#
# 落库: /volume1/stock/fundamentals/dmsk/{balance,income,cashflow}/
# dt=<REPORT_DATE>/part-*.parquet —— **绝不写 phase3 镜像树**
# (/volume1/stock/sanguo_vnpy_v2/data/static 归 04:00 同步链所有, 写=被冲掉
# +破坏 ≡VPS 不变量; 双层读法见 spec §19.7)。
#
# 两 lane 串行(脚本内 flock 单实例锁天然互斥): daily --until 08:00
# (近3期×3域=9 unit≈110发≈4min; NOTICE_DATE>=T-7 新披露, 披露次日可用);
# rc=0|3 续走 backfill --until 17:30(2009→今≈2200发一次性回补, 完成后每日 no-op
# 全 done 秒退); rc=1|2 跳过 backfill 次日自愈。rc 聚合同 corpus: 真失败(1/2)
# 优先于让路(3)。
#
# 错峰: 07:10 晚于 corpus 06:00——东财端点分域(datacenter-web vs
# search-api/np-listapi)+各自限速 1.5s/1.0s, 并发重叠安全依据同 corpus(spec §19.3)。
#
# 部署位: NAS /volume1/stock/fundamentals/run_dsmk.sh(宿主文件,CI 不覆盖;
# 改本 repo 副本后 scp -O 手动同步, 同 corpus/5m 纪律):
# scp -O scripts/nas_sync/run_dmsk_standalone.sh \
# sanguo-nas:/volume1/stock/fundamentals/run_dsmk.sh
# DSM 任务(sanguo-fundamentals-daily, 每日 07:10, 用户=admin)命令:
# bash /volume1/stock/fundamentals/run_dsmk.sh
#
# --no-healthcheck: 镜像 HEALTHCHECK=curl :8000/health 是 web 服务专用(同 corpus)。
set -u
DOCKER=/var/packages/Docker/target/usr/bin/docker
ROOT=/volume1/stock/fundamentals
LOG=$ROOT/cron.log
APP=/volume1/homes/admin/.sanguo_projects/sanguo_vnpy_v2
UID_ADMIN=1024 # id admin (2026-09-06 实测)
GID_ADMIN=100 # users
GID_ADMINS=101 # administrators(Synology ACL 授权组, corpus 金丝雀实测必带)
mkdir -p "$ROOT"
{
echo "=== $(date '+%F %T') standalone-container dmsk run start ==="
"$DOCKER" run --rm --name sanguo-fund-dmsk --user "${UID_ADMIN}:${GID_ADMIN}" \
--group-add "${GID_ADMINS}" --no-healthcheck --entrypoint python \
-v /volume1/stock:/volume1/stock \
-v "$APP":/app:ro \
sanguo_vnpy_v2:lock-aligned \
/app/scripts/data_platform/fundamentals_dmsk_download.py --lane daily --until 08:00
rc_daily=$?
echo "=== $(date '+%F %T') dmsk daily lane exit=$rc_daily ==="
rc_back=0
if [ "$rc_daily" -eq 0 ] || [ "$rc_daily" -eq 3 ]; then
"$DOCKER" run --rm --name sanguo-fund-dmsk --user "${UID_ADMIN}:${GID_ADMIN}" \
--group-add "${GID_ADMINS}" --no-healthcheck --entrypoint python \
-v /volume1/stock:/volume1/stock \
-v "$APP":/app:ro \
sanguo_vnpy_v2:lock-aligned \
/app/scripts/data_platform/fundamentals_dmsk_download.py --lane backfill --until 17:30
rc_back=$?
echo "=== $(date '+%F %T') dmsk backfill lane exit=$rc_back ==="
else
echo "=== daily 非零($rc_daily), 跳过 backfill lane(次日自愈) ==="
fi
} >> "$LOG" 2>&1
# rc 聚合: 真失败(1/2)优先于让路(3);全让路=3;全零=0
if [ "${rc_back:-0}" -ne 0 ] && [ "${rc_back:-0}" -ne 3 ]; then
exit "$rc_back"
fi
if [ "${rc_daily:-1}" -ne 0 ] && [ "${rc_daily:-1}" -ne 3 ]; then
exit "$rc_daily"
fi
if [ "${rc_daily:-1}" -ne 0 ] || [ "${rc_back:-0}" -ne 0 ]; then
exit 3
fi
exit 0