670498ab01
write_parquet_and_marker 改原子写(tmp→os.replace→marker) kill 不产残缺 parquet; 新增 --repair 只重取 missing/empty/corrupt 忽略 marker(周度补漏不必等财报季 --force 全量); is_parquet_healthy 辅助。top_holders 修复无回归, 106 tests。
1110 lines
41 KiB
Python
1110 lines
41 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""AKShare A 股静态数据全量下载 (16 类, 四种模式, 只产 parquet)
|
||
|
||
目标:
|
||
在 Windows Server 2022 VPS (49.232.102.198) 上用 akshare 全量拉取 A 股
|
||
静态数据 (估值/北向/股本/十大股东/三大报表/财务摘要/龙虎榜/大宗/融资融券/
|
||
解禁/业绩预告/业绩快报/指数成分/申万行业), 输出 import-ready parquet.
|
||
|
||
四种模式:
|
||
- 模式A per-stock (5500股循环, 每股1调用) → <code>.<EXC>_<type>.parquet
|
||
- 模式B per-date (交易日循环, 每日1调用) → <date>_<type>.parquet
|
||
- 模式C per-period (报告期循环, 每期1调用) → <period>_<type>.parquet
|
||
- 模式D one-shot (单次调用) → <type>.parquet
|
||
|
||
硬约束 (踩过的坑 / 用户铁律):
|
||
1. 单线程串行, 严禁并发 (东财限流严, 并发会封 IP)
|
||
2. 每次 akshare 调用后 sleep AK_INTERVAL=0.8s (防封)
|
||
3. 每次 akshare 调用 30s 硬超时 (akshare 经常挂死, 用 ThreadPoolExecutor
|
||
单线程 + future.result(timeout=30) 保护, 超时计 failed)
|
||
4. 重试退避: 东财 ConnectionError 常见, 3 次重试, 指数退避 (2s/4s/8s)
|
||
5. 断路器: 连续 30 个单位 (stock/date/period) failed → exit 2
|
||
6. empty vs failed 区分:
|
||
- 空 df (无北向持仓 / 退市 / 节假日无龙虎榜) → status='empty' 中性
|
||
- 异常 / 超时 → status='failed' 计断路器
|
||
7. marker 断点续传 (per-unit): 只在成功写 parquet 后写 marker
|
||
8. 开头 unset proxy (akshare 底层 requests 读 proxy 环境变量)
|
||
|
||
与 baostock_static_download.py 差异:
|
||
- timeout 机制不同: akshare 用 ThreadPoolExecutor + future.result(timeout)
|
||
(baostock 用 socket.setdefaulttimeout, 对 akshare 不够: akshare 内部
|
||
requests 会重试很久, 必须 future-level kill)
|
||
- 四种模式 (baostock 只有 per-stock / per-year / one-shot 三种)
|
||
- symbol 格式适配 code_to_symbol(): 同一 code 在不同端点格式不同
|
||
(stock_value_em="600519", balance_sheet="SH600519",
|
||
top_holders="sh600519", share_change="600519")
|
||
- 重试退避指数 backoff (baostock 重试即重登, akshare 重试即等 2/4/8s)
|
||
|
||
⚠️ 本脚本只依赖 akshare, 不碰 baostock (可与 baostock 15min 下载并行)。
|
||
|
||
用法:
|
||
# 全跑 (16 类)
|
||
python akshare_static_download.py
|
||
# 只跑指定类型 (逗号分隔)
|
||
python akshare_static_download.py --types valuation,index_const
|
||
# 小样测试 (per-stock 类前 3 股)
|
||
python akshare_static_download.py --types valuation --limit 3
|
||
# 指定股票 (6 位 code, 自动猜交易所)
|
||
python akshare_static_download.py --types balance --codes 600519,000001
|
||
# 日期范围 (per-date 类用)
|
||
python akshare_static_download.py --types dragon_tiger --start 20260101 --end 20260715
|
||
# 强制重下
|
||
python akshare_static_download.py --types valuation --force
|
||
|
||
输出目录结构 (OUT_DIR/data/static):
|
||
{OUT_DIR}/
|
||
├── valuation/ (per-stock)
|
||
│ ├── 600519.SH_valuation.parquet
|
||
│ └── .600519.SH_valuation.akshare
|
||
├── balance/ (per-stock, 带交易所前缀调用)
|
||
│ ├── 600519.SH_balance.parquet
|
||
│ └── .600519.SH_balance.akshare
|
||
├── top_holders/ (per-stock × per-period)
|
||
│ ├── 600519.SH_2020930_top_holders.parquet
|
||
│ └── ...
|
||
├── dragon_tiger/ (per-date)
|
||
│ ├── 20260715_dragon_tiger.parquet
|
||
│ └── .20260715_dragon_tiger.akshare
|
||
├── forecast/ (per-period)
|
||
│ ├── 20251231_forecast.parquet
|
||
│ └── ...
|
||
├── index_const/ (one-shot)
|
||
│ ├── index_const.parquet
|
||
│ └── .index_const.akshare
|
||
└── logs/akshare_static_YYYYMMDD_HHMMSS.log
|
||
|
||
退出码: 0=完成, 1=致命错误 (akshare 装载/列表拉取失败), 2=断路器触发 (可重试)
|
||
"""
|
||
|
||
import argparse
|
||
import concurrent.futures
|
||
import datetime
|
||
import json
|
||
import logging
|
||
import os
|
||
import sys
|
||
import time
|
||
from functools import partial
|
||
from pathlib import Path
|
||
from typing import Any, Callable, List, Optional, Tuple
|
||
|
||
# ======================== 硬约束: unset proxy ========================
|
||
# 必须在 import akshare 之前清理 (akshare 底层 requests 读 proxy 环境变量)
|
||
for _k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
|
||
os.environ.pop(_k, None)
|
||
|
||
# stdout 行缓冲 (SSH-detached / pipe 重定向时也能看到实时进度)
|
||
try:
|
||
sys.stdout.reconfigure(line_buffering=True)
|
||
except (AttributeError, ValueError):
|
||
pass
|
||
|
||
import akshare as ak # noqa: E402
|
||
import pandas as pd # noqa: E402
|
||
|
||
|
||
# ======================== 配置 (环境变量可覆盖) ========================
|
||
|
||
DEFAULT_OUT_DIR = r"C:\sanguo_vnpy_v2\data\static"
|
||
OUT_DIR = Path(os.environ.get("AK_STATIC_OUT_DIR", DEFAULT_OUT_DIR))
|
||
DEFAULT_LOG_DIR = OUT_DIR / "logs"
|
||
_log_dir_env = os.environ.get("AK_STATIC_LOG_DIR")
|
||
LOG_DIR = Path(_log_dir_env) if _log_dir_env else DEFAULT_LOG_DIR
|
||
|
||
START_DATE_DEFAULT = "2020-01-01"
|
||
# 报告期循环范围 (近5年×4季, forecast/express/top_holders 用).
|
||
# top_holders per-stock × per-period 会有股票×20期组合.
|
||
REPORT_PERIODS = [
|
||
f"{y}{m:02d}{d:02d}"
|
||
for y in range(2020, datetime.date.today().year + 1)
|
||
for (m, d) in [(3, 31), (6, 30), (9, 30), (12, 31)]
|
||
if datetime.date(y, m, d) <= datetime.date.today()
|
||
]
|
||
|
||
AK_INTERVAL = 0.8 # 每次调用后间隔秒 (防封 IP)
|
||
AK_TIMEOUT = 30.0 # 单次调用硬超时秒 (akshare 经常挂死)
|
||
AK_MAX_RETRIES = 3 # 单次下载重试次数 (含指数退避)
|
||
RETRY_BACKOFF = [2, 4, 8] # 重试间隔秒 (指数退避)
|
||
PROGRESS_LOG_EVERY = 100 # 每 N 个单位打一次进度日志
|
||
CIRCUIT_BREAKER = 30 # 连续失败 N 个 → 断路退出
|
||
|
||
# 模式 A: per-stock 类型 (8 类)
|
||
PER_STOCK_TYPES = (
|
||
"valuation", # stock_value_em(symbol="600519")
|
||
"northbound", # stock_hsgt_individual_em(symbol="600519")
|
||
"share_capital", # stock_share_change_cninfo(symbol="600519")
|
||
"balance", # stock_balance_sheet_by_report_em(symbol="SH600519")
|
||
"income", # stock_profit_sheet_by_report_em(symbol="SH600519")
|
||
"cashflow", # stock_cash_flow_sheet_by_report_em(symbol="SH600519")
|
||
"financial_abstract", # stock_financial_abstract(symbol="600519")
|
||
# top_holders 单独 (per-stock × per-period)
|
||
)
|
||
# 模式 B: per-date 类型 (4 类, margin_szse 跳过)
|
||
PER_DATE_TYPES = (
|
||
"dragon_tiger", # stock_lhb_detail_em(start_date, end_date)
|
||
"block_trade", # stock_dzjy_mrmx(symbol="A股", start_date, end_date)
|
||
"margin_sse", # stock_margin_detail_sse(date)
|
||
"restricted", # stock_restricted_release_detail_em(start_date, end_date)
|
||
)
|
||
# 模式 C: per-period 类型 (2 类)
|
||
PER_PERIOD_TYPES = (
|
||
"forecast", # stock_yjyg_em(date=period)
|
||
"express", # stock_yjkb_em(date=period)
|
||
)
|
||
# 模式 D: one-shot 类型 (2 类)
|
||
ONE_SHOT_TYPES = (
|
||
"index_const", # index_stock_cons_csindex(symbol) × 3 合并
|
||
"industry", # sw_index_first_info()
|
||
)
|
||
# top_holders 特殊: per-stock × per-period
|
||
TOP_HOLDERS = "top_holders"
|
||
# 北交所代码段 (920新段 + 83/87/43历史段): akshare 东财 stock_gdfx_free_top_10_em
|
||
# 不支持北交所, build_top_holders_units 阶段直接跳过 (避免每只×20期×3retry 失败风暴).
|
||
BJ_PREFIXES = ("920", "83", "87", "43")
|
||
|
||
ALL_TYPES = (
|
||
PER_STOCK_TYPES
|
||
+ (TOP_HOLDERS,)
|
||
+ PER_DATE_TYPES
|
||
+ PER_PERIOD_TYPES
|
||
+ ONE_SHOT_TYPES
|
||
)
|
||
|
||
|
||
# ======================== 日志 ========================
|
||
|
||
def setup_logging() -> Tuple[logging.Logger, Path]:
|
||
"""配置 root logger: 同时写 stdout + 文件。返回 (logger, log_file)。"""
|
||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
log_file = LOG_DIR / f"akshare_static_{ts}.log"
|
||
|
||
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
||
root = logging.getLogger()
|
||
root.setLevel(logging.INFO)
|
||
for h in list(root.handlers):
|
||
root.removeHandler(h)
|
||
|
||
sh = logging.StreamHandler(sys.stdout)
|
||
sh.setFormatter(fmt)
|
||
root.addHandler(sh)
|
||
|
||
fh = logging.FileHandler(log_file, encoding="utf-8")
|
||
fh.setFormatter(fmt)
|
||
root.addHandler(fh)
|
||
|
||
return logging.getLogger(__name__), log_file
|
||
|
||
|
||
logger, LOG_FILE = setup_logging()
|
||
|
||
|
||
# ======================== 工具函数 ========================
|
||
|
||
def normalize_date(s: str) -> str:
|
||
"""接受 YYYYMMDD 或 YYYY-MM-DD, 返回 YYYYMMDD; 空串 → 今天。"""
|
||
s = (s or "").strip()
|
||
if not s:
|
||
return datetime.date.today().strftime("%Y%m%d")
|
||
s = s.replace("-", "")
|
||
if len(s) == 8 and s.isdigit():
|
||
return s
|
||
raise ValueError(f"无效日期格式: {s} (期望 YYYYMMDD 或 YYYY-MM-DD)")
|
||
|
||
|
||
def guess_exchange_by_code(code: str) -> str:
|
||
"""6/68/51/9 开头 → SH (含 9 开头 B 股), 其他 → SZ。
|
||
11/12 开头是可转债, 不应进入 (列表源 stock_info_a_code_name 只返股票)。"""
|
||
if code.startswith(("6", "68", "51", "9")):
|
||
return "SH"
|
||
return "SZ"
|
||
|
||
|
||
def code_to_symbol(code: str, exchange: str, endpoint: str) -> str:
|
||
"""同 code 在不同 akshare 端点格式不同。
|
||
|
||
- stock_value_em / northbound / financial_abstract / share_change_cninfo:
|
||
纯数字 "600519"
|
||
- balance/income/cashflow (三大报表): "SH600519" / "SZ000001" (大写前缀)
|
||
- top_holders (stock_gdfx_free_top_10_em): "sh600519" (小写前缀)
|
||
|
||
endpoint 取端点函数名 (作为标识符), 不区分大小写比较。
|
||
"""
|
||
e = endpoint.lower()
|
||
if e in ("balance_sheet", "income_sheet", "cashflow_sheet"):
|
||
# 三大报表: SH/SZ 大写前缀
|
||
return f"{exchange}{code}"
|
||
if e == "top_holders":
|
||
# 十大股东: 小写前缀
|
||
pfx = "sh" if exchange == "SH" else "sz"
|
||
return f"{pfx}{code}"
|
||
# 默认 (valuation / northbound / financial_abstract / share_capital): 纯数字
|
||
return code
|
||
|
||
|
||
# ======================== 超时 + 重试 ========================
|
||
|
||
def call_ak_with_timeout(
|
||
fn: Callable[..., pd.DataFrame],
|
||
*args: Any,
|
||
timeout: float = AK_TIMEOUT,
|
||
**kwargs: Any,
|
||
) -> pd.DataFrame:
|
||
"""单线程 ThreadPoolExecutor 包装 akshare 调用 + 硬超时。
|
||
|
||
akshare 内部 requests 在网络异常时会重试很久 (无总超时), 必须用
|
||
future-level timeout 才能保证不挂死。超时抛 concurrent.futures.TimeoutError
|
||
(上层捕获并计 failed)。
|
||
|
||
注意: 超时后 future 不可真正 kill (Python 线程不能强杀), 但 executor
|
||
退出后工作线程仍会在后台跑完或最终超时; 对进程主流程无影响 (我们不
|
||
等 join)。每次新建 executor 避免线程累积。
|
||
"""
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
|
||
future = ex.submit(fn, *args, **kwargs)
|
||
return future.result(timeout=timeout)
|
||
|
||
|
||
def call_ak_with_retry(
|
||
fn: Callable[..., pd.DataFrame],
|
||
unit_label: str,
|
||
*args: Any,
|
||
**kwargs: Any,
|
||
) -> Tuple[Optional[pd.DataFrame], str]:
|
||
"""带重试退避的 akshare 调用。返 (df_or_None, status)。
|
||
|
||
status ∈ {'ok', 'failed'}:
|
||
- 调用成功 (df 可能空) → ('ok' or 'empty', df)
|
||
- 重试耗尽仍异常/超时 → ('failed', None)
|
||
|
||
empty vs ok 的区分在调用方按 df.empty 判 (此处统一返 df, 调用方判 empty)。
|
||
重试退避: 第1次失败 sleep 2s, 第2次 4s, 第3次 8s (RETRY_BACKOFF)。
|
||
"""
|
||
last_err: Optional[Exception] = None
|
||
for attempt in range(AK_MAX_RETRIES):
|
||
try:
|
||
df = call_ak_with_timeout(fn, *args, **kwargs)
|
||
return df, "ok"
|
||
except concurrent.futures.TimeoutError as e:
|
||
last_err = e
|
||
logger.warning(
|
||
"%s 超时重试 %d/%d: (>%ds)",
|
||
unit_label, attempt + 1, AK_MAX_RETRIES, int(AK_TIMEOUT),
|
||
)
|
||
except Exception as e:
|
||
last_err = e
|
||
msg = str(e)[:200]
|
||
logger.warning(
|
||
"%s 异常重试 %d/%d: %s",
|
||
unit_label, attempt + 1, AK_MAX_RETRIES, msg,
|
||
)
|
||
# 指数退避
|
||
if attempt < AK_MAX_RETRIES - 1:
|
||
time.sleep(RETRY_BACKOFF[attempt])
|
||
logger.error("%s 重试 %d 次仍失败: %s", unit_label, AK_MAX_RETRIES, last_err)
|
||
return None, "failed"
|
||
|
||
|
||
# ======================== 股票列表 (akshare 轻量端点) ========================
|
||
|
||
def fetch_all_stocks() -> List[Tuple[str, str]]:
|
||
"""akshare stock_info_a_code_name() 拉全市场 A 股代码列表。
|
||
|
||
返回 [(code, exchange), ...], 例 [('600519', 'SH'), ('000001', 'SZ')]。
|
||
端点轻量, 单次调用返 ~5500 行 (code + name 两列), 不取行情。
|
||
exchange 按 code 开头猜 (6/68/51/9 → SH, 其他 → SZ)。
|
||
"""
|
||
df, status = call_ak_with_retry(
|
||
ak.stock_info_a_code_name, "all_stocks",
|
||
)
|
||
if status == "failed" or df is None:
|
||
raise RuntimeError("stock_info_a_code_name 拉取失败")
|
||
if df.empty:
|
||
raise RuntimeError("stock_info_a_code_name 返空 (异常)")
|
||
|
||
# akshare 返回列名: code, name (stock_info_a_code_name)
|
||
out: List[Tuple[str, str]] = []
|
||
n_skip = 0
|
||
for code in df["code"].tolist():
|
||
code = str(code).strip()
|
||
if len(code) != 6 or not code.isdigit():
|
||
n_skip += 1
|
||
continue
|
||
exchange = guess_exchange_by_code(code)
|
||
out.append((code, exchange))
|
||
logger.info(
|
||
"股票列表: 总 %d 只 (跳过 %d 非法 code), 来自 stock_info_a_code_name",
|
||
len(out), n_skip,
|
||
)
|
||
return out
|
||
|
||
|
||
# ======================== 路径 / marker ========================
|
||
|
||
def subdir_for(data_type: str) -> Path:
|
||
"""data_type → OUT_DIR / <subdir>。"""
|
||
return OUT_DIR / data_type
|
||
|
||
|
||
def parquet_path_per_unit(data_type: str, unit_id: str) -> Path:
|
||
"""通用 parquet 路径: <data_type>/<unit_id>.parquet。
|
||
|
||
unit_id 由各模式自拼:
|
||
- per-stock: '600519.SH_valuation'
|
||
- per-stock × per-period (top_holders): '600519.SH_2020930_top_holders'
|
||
- per-date: '20260715_dragon_tiger'
|
||
- per-period: '20251231_forecast'
|
||
- one-shot: 'index_const'
|
||
"""
|
||
return subdir_for(data_type) / f"{unit_id}.parquet"
|
||
|
||
|
||
def marker_path_for(parquet_path: Path) -> Path:
|
||
"""parquet → 同目录 .{stem}.akshare marker。"""
|
||
return parquet_path.parent / f".{parquet_path.stem}.akshare"
|
||
|
||
|
||
def load_done_units(data_type: str) -> set:
|
||
"""扫子目录 marker 构造已完成 unit_id 集合 (真相源)。
|
||
|
||
marker 文件名格式: .{unit_id}.akshare
|
||
直接用 stem (= unit_id) 作为 key, 不解析 (各模式 unit_id 规则不同, 统一字符串匹配)。
|
||
"""
|
||
done: set = set()
|
||
d = subdir_for(data_type)
|
||
if not d.exists():
|
||
return done
|
||
ext = ".akshare"
|
||
for marker in d.glob(f".*{ext}"):
|
||
name = marker.name
|
||
if not name.startswith(".") or not name.endswith(ext):
|
||
continue
|
||
unit_id = name[1:-len(ext)]
|
||
if unit_id:
|
||
done.add(unit_id)
|
||
return done
|
||
|
||
|
||
# 空 parquet 阈值: 文件 <1KB 视为 empty/corrupt (有效 parquet 哪怕 0 行也 >1KB
|
||
# 因为有 schema/metadata; 真正 0 字节或几十字节的肯定是异常).
|
||
EMPTY_PARQUET_MIN_BYTES = 1024
|
||
|
||
|
||
def is_parquet_healthy(parquet_path: Path) -> bool:
|
||
"""判断 parquet 是否值得保留 (有数据)。返 True 健康 / False 需重取。
|
||
|
||
unhealthy 条件 (任一):
|
||
- 文件不存在 (orphan marker / 被删)
|
||
- size < EMPTY_PARQUET_MIN_BYTES (残缺或 0 字节)
|
||
- pd.read_parquet 抛异常 (corrupt magic byte 等)
|
||
- 读后 df.empty (空数据, 如北交所 akshare 不覆盖)
|
||
|
||
--repair 模式下用此函数决定是否忽略 marker 强制重取。
|
||
"""
|
||
try:
|
||
if not parquet_path.exists():
|
||
return False
|
||
if parquet_path.stat().st_size < EMPTY_PARQUET_MIN_BYTES:
|
||
return False
|
||
df = pd.read_parquet(parquet_path)
|
||
return not df.empty
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def write_parquet_and_marker(
|
||
df: pd.DataFrame,
|
||
parquet_path: Path,
|
||
) -> bool:
|
||
"""原子写 parquet + marker。返 True 成功 / False 失败。
|
||
|
||
原子语义: 先写 .parquet.tmp → os.replace 到正式路径 → 写 marker。
|
||
进程被 kill / 断电时:
|
||
- to_parquet 中断: 只留 .tmp (正式路径未触碰, 旧版本数据保留)
|
||
- os.replace 中断: 同上 (replace 是原子操作, 要么完成要么没发生)
|
||
- marker 未写: 下次非 --force 会重试 (marker 是断点续传真相源)
|
||
失败时清理 .tmp 残骸 (replace 失败时 .tmp 还在; 成功后 .tmp 已消失)。
|
||
"""
|
||
tmp_path = parquet_path.with_suffix(parquet_path.suffix + ".tmp")
|
||
try:
|
||
parquet_path.parent.mkdir(parents=True, exist_ok=True)
|
||
df.to_parquet(tmp_path, index=False)
|
||
os.replace(tmp_path, parquet_path)
|
||
# marker 仅在 replace 成功后写 (replace 是 Linux/Windows 上的原子操作)
|
||
marker_path_for(parquet_path).write_text(
|
||
datetime.datetime.now().isoformat()
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
logger.error("写入 %s 失败: %s", parquet_path, e)
|
||
# 清理 .tmp 残骸 (replace 失败时它还在; 成功后它已消失)
|
||
try:
|
||
tmp_path.unlink(missing_ok=True)
|
||
except OSError:
|
||
pass
|
||
return False
|
||
|
||
|
||
def _df_or_empty(result: Tuple[Optional[pd.DataFrame], str]) -> pd.DataFrame:
|
||
"""把 call_ak_with_retry 的返回 (df_or_None, status) 转成非 None df。
|
||
|
||
必须 None 显式判断 (不能 `df or pd.DataFrame()`, DataFrame 的 truth value
|
||
ambiguous, 会抛 "The truth value of a DataFrame is ambiguous"。
|
||
"""
|
||
df = result[0]
|
||
return df if df is not None else pd.DataFrame()
|
||
|
||
|
||
# ======================== per-stock fetch 函数 (8 类) ========================
|
||
|
||
def fetch_valuation(symbol: str) -> pd.DataFrame:
|
||
"""stock_value_em(symbol='600519') — 估值 (PE/PB/市值等13列, ~2000行/股)。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.stock_value_em, f"valuation/{symbol}", symbol=symbol,
|
||
))
|
||
|
||
|
||
def fetch_northbound(symbol: str) -> pd.DataFrame:
|
||
"""stock_hsgt_individual_em(symbol='600519') — 北向持股 (~1700行/股)。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.stock_hsgt_individual_em, f"northbound/{symbol}", symbol=symbol,
|
||
))
|
||
|
||
|
||
def fetch_share_capital(symbol: str) -> pd.DataFrame:
|
||
"""stock_share_change_cninfo(symbol='600519') — 股本变动 (44列)。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.stock_share_change_cninfo, f"share_capital/{symbol}", symbol=symbol,
|
||
))
|
||
|
||
|
||
def fetch_balance_sheet(symbol: str) -> pd.DataFrame:
|
||
"""stock_balance_sheet_by_report_em(symbol='SH600519') — 资产负债表 (319列)。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.stock_balance_sheet_by_report_em, f"balance/{symbol}", symbol=symbol,
|
||
))
|
||
|
||
|
||
def fetch_income_sheet(symbol: str) -> pd.DataFrame:
|
||
"""stock_profit_sheet_by_report_em(symbol='SH600519') — 利润表 (203列)。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.stock_profit_sheet_by_report_em, f"income/{symbol}", symbol=symbol,
|
||
))
|
||
|
||
|
||
def fetch_cashflow_sheet(symbol: str) -> pd.DataFrame:
|
||
"""stock_cash_flow_sheet_by_report_em(symbol='SH600519') — 现金流量表 (254列)。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.stock_cash_flow_sheet_by_report_em, f"cashflow/{symbol}", symbol=symbol,
|
||
))
|
||
|
||
|
||
def fetch_financial_abstract(symbol: str) -> pd.DataFrame:
|
||
"""stock_financial_abstract(symbol='600519') — 财务摘要 (80指标)。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.stock_financial_abstract, f"financial_abstract/{symbol}", symbol=symbol,
|
||
))
|
||
|
||
|
||
# ======================== top_holders (per-stock × per-period) ========================
|
||
|
||
# akshare stock_gdfx_free_top_10_em 成功时返回的 8 列 schema
|
||
# (源码 stock_gdfx_em.py 实测). 报告期未披露 (sdltgd=[]) 时 akshare 抛
|
||
# ValueError("Length mismatch"), 这里捕获后用此 schema 返空 df,
|
||
# 保证 parquet 列与有效期一致 (downstream reader 不会列数漂移).
|
||
TOP_HOLDERS_COLUMNS = [
|
||
"名次", "股东名称", "股东性质", "股份类型",
|
||
"持股数", "占总流通股本持股比例", "增减", "变动比率",
|
||
]
|
||
|
||
|
||
def _safe_top_10_em(symbol: str, date: str) -> pd.DataFrame:
|
||
"""akshare stock_gdfx_free_top_10_em 包装: 容忍空/缺字段响应。
|
||
|
||
bug 根因 (实证 akshare 1.18.x stock_gdfx_em.py):
|
||
- 旧版本 sdltgd=[] (报告期未披露) 时 pd.DataFrame([]).reset_index() 得 1 列 df,
|
||
columns=[12 列] 抛 ValueError("Length mismatch: ..."). 子串匹配稳定。
|
||
- 新版本部分标的不支持 (如北交所 920xxx) 时返缺 sdltgd 字段, 抛 KeyError('sdltgd')。
|
||
KeyError 不是 ValueError 子类, 旧版只 except ValueError 捕不到。
|
||
两者都是确定性无数据 (非瞬时故障), 不应消耗重试配额 (call_ak_with_retry 会
|
||
当网络错重试 3 次 14s 退避 + 噪声 ERROR 日志)。
|
||
|
||
本包装预判这两类确定性无数据 (子串匹配错误消息, 稳定):
|
||
- Length mismatch (旧版 ValueError) → 返空 df (带 TOP_HOLDERS_COLUMNS schema), 不抛
|
||
- 'sdltgd' (新版 KeyError, str(KeyError('sdltgd')) == "'sdltgd'" 含引号) → 同上
|
||
- 其他 ValueError/KeyError/ConnectionError → 透传给 call_ak_with_retry 走重试
|
||
"""
|
||
try:
|
||
return ak.stock_gdfx_free_top_10_em(symbol=symbol, date=date)
|
||
except (ValueError, KeyError) as e:
|
||
s = str(e)
|
||
if "Length mismatch" in s or "sdltgd" in s:
|
||
logger.debug(
|
||
"stock_gdfx_free_top_10_em(%s, %s) → 空 (报告期未披露或标的不支持), 返空 df",
|
||
symbol, date,
|
||
)
|
||
return pd.DataFrame(columns=TOP_HOLDERS_COLUMNS)
|
||
raise
|
||
|
||
|
||
def fetch_top_holders_one_period(
|
||
symbol: str, period: str,
|
||
) -> pd.DataFrame:
|
||
"""stock_gdfx_free_top_10_em(symbol='sh600519', date='20250930') 单期。
|
||
symbol 小写前缀, period YYYYMMDD.
|
||
|
||
通过 _safe_top_10_em 包装: 报告期未披露 (sdltgd=[]) 时 akshare 抛
|
||
Length mismatch ValueError, 这里捕获返空 df (避免 3 次无意义重试, 确定性
|
||
无数据不应消耗断路器配额)。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
_safe_top_10_em,
|
||
f"top_holders/{symbol}/{period}",
|
||
symbol=symbol, date=period,
|
||
))
|
||
|
||
|
||
# ======================== per-date fetch 函数 (4 类) ========================
|
||
|
||
def fetch_dragon_tiger(date: str) -> pd.DataFrame:
|
||
"""stock_lhb_detail_em(start_date=end_date=date) — 单日龙虎榜。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.stock_lhb_detail_em, f"dragon_tiger/{date}",
|
||
start_date=date, end_date=date,
|
||
))
|
||
|
||
|
||
def fetch_block_trade(date: str) -> pd.DataFrame:
|
||
"""stock_dzjy_mrmx(symbol='A股', start_date=end_date=date) — 大宗交易明细。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.stock_dzjy_mrmx, f"block_trade/{date}",
|
||
symbol="A股", start_date=date, end_date=date,
|
||
))
|
||
|
||
|
||
def fetch_margin_sse(date: str) -> pd.DataFrame:
|
||
"""stock_margin_detail_sse(date=date) — 沪市融资融券明细。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.stock_margin_detail_sse, f"margin_sse/{date}", date=date,
|
||
))
|
||
|
||
|
||
def fetch_restricted(date: str) -> pd.DataFrame:
|
||
"""stock_restricted_release_detail_em(start_date=end_date=date) — 解禁明细。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.stock_restricted_release_detail_em, f"restricted/{date}",
|
||
start_date=date, end_date=date,
|
||
))
|
||
|
||
|
||
# ======================== per-period fetch 函数 (2 类) ========================
|
||
|
||
def fetch_forecast(period: str) -> pd.DataFrame:
|
||
"""stock_yjyg_em(date=period) — 业绩预告 (全市场, 单期单调用)。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.stock_yjyg_em, f"forecast/{period}", date=period,
|
||
))
|
||
|
||
|
||
def fetch_express(period: str) -> pd.DataFrame:
|
||
"""stock_yjkb_em(date=period) — 业绩快报 (全市场, 单期单调用)。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.stock_yjkb_em, f"express/{period}", date=period,
|
||
))
|
||
|
||
|
||
# ======================== one-shot fetch 函数 (2 类) ========================
|
||
|
||
def fetch_index_const() -> pd.DataFrame:
|
||
"""index_stock_cons_csindex 循环 ["000300","000905","000852"] 合并。
|
||
|
||
300/500/1000 三大指数成分, 合并返单 df, 加 index_code 列标识来源。
|
||
"""
|
||
index_codes = ["000300", "000905", "000852"]
|
||
frames: List[pd.DataFrame] = []
|
||
for idx in index_codes:
|
||
df, status = call_ak_with_retry(
|
||
ak.index_stock_cons_csindex, f"index_const/{idx}", symbol=idx,
|
||
)
|
||
if status == "failed" or df is None:
|
||
logger.warning("index_const %s 失败, 跳过该指数", idx)
|
||
continue
|
||
if df.empty:
|
||
logger.warning("index_const %s 返空, 跳过", idx)
|
||
continue
|
||
df = df.copy()
|
||
df["index_code"] = idx
|
||
frames.append(df)
|
||
time.sleep(AK_INTERVAL) # 三次调用之间也限速
|
||
if not frames:
|
||
return pd.DataFrame()
|
||
return pd.concat(frames, ignore_index=True)
|
||
|
||
|
||
def fetch_industry() -> pd.DataFrame:
|
||
"""sw_index_first_info() — 申万一级行业列表 (东财接口 ConnectionError, 申万替代)。"""
|
||
return _df_or_empty(call_ak_with_retry(
|
||
ak.sw_index_first_info, "industry",
|
||
))
|
||
|
||
|
||
# ======================== 通用下载单元 (写 parquet + marker) ========================
|
||
|
||
def download_one_unit(
|
||
data_type: str,
|
||
unit_id: str,
|
||
fetch_fn: Callable[[], pd.DataFrame],
|
||
force: bool,
|
||
repair: bool = False,
|
||
) -> Tuple[str, int]:
|
||
"""通用单 unit 下载: 拉 df → 写 parquet + marker。
|
||
|
||
fetch_fn() → df (可能空) 或 raise (call_ak_with_retry 已吞异常返 None,
|
||
各 fetch_xxx 已把 None 转空 df; 这里 df 永远非 None 但可能空)。
|
||
|
||
返 (status, rows), status ∈ {'ok', 'skipped', 'empty', 'failed'}。
|
||
|
||
跳过策略 (优先级从高到低):
|
||
- force=True: 总是重取 (忽略 marker 与 repair)
|
||
- repair=True: marker 在但 parquet unhealthy (missing/empty/corrupt) 仍重取;
|
||
marker 在且 parquet 健康 → skip
|
||
- 默认 (force=repair=False): marker 在 → skip (空文件也标 done, 避免全量重跑)
|
||
"""
|
||
parquet_path = parquet_path_per_unit(data_type, unit_id)
|
||
marker_path = marker_path_for(parquet_path)
|
||
|
||
if not force and marker_path.exists():
|
||
if repair and not is_parquet_healthy(parquet_path):
|
||
logger.info(
|
||
"[%s] %s --repair: parquet unhealthy (missing/empty/corrupt), 重取",
|
||
data_type, unit_id,
|
||
)
|
||
else:
|
||
return "skipped", 0
|
||
|
||
try:
|
||
df = fetch_fn()
|
||
except Exception as e:
|
||
# 兜底: call_ak_with_retry 内部已重试, 这里理论上不应触发,
|
||
# 但保险 (各 fetch_xxx 转 None→空 df, 这里捕获意外异常)
|
||
logger.error("[%s] %s fetch 异常: %s", data_type, unit_id, e)
|
||
return "failed", 0
|
||
|
||
if df is None:
|
||
# fetch_xxx 保证返非 None, 但保险
|
||
return "failed", 0
|
||
|
||
# 写 parquet + marker (空 df 也写, 静态语义: "查过了确实无数据")
|
||
if not write_parquet_and_marker(df, parquet_path):
|
||
return "failed", 0
|
||
return ("ok" if not df.empty else "empty"), len(df)
|
||
|
||
|
||
# ======================== 主循环 (通用, 适用所有四种模式) ========================
|
||
|
||
def run_one_type(
|
||
data_type: str,
|
||
units: List[Tuple[str, Callable[[], pd.DataFrame]]],
|
||
args: argparse.Namespace,
|
||
) -> Tuple[dict, bool]:
|
||
"""通用类型主循环: 按顺序处理每个 unit (unit_id, fetch_fn)。
|
||
|
||
返 (stats, circuit_triggered)。
|
||
|
||
每个 unit 之间 sleep AK_INTERVAL 限速 (最后一个不 sleep)。
|
||
"""
|
||
# marker 断点续传
|
||
done_set = load_done_units(data_type)
|
||
if args.force:
|
||
todo = [(uid, fn) for uid, fn in units]
|
||
elif getattr(args, "repair", False):
|
||
# repair 模式: 只处理 missing/empty/corrupt 的 unit
|
||
# (marker 在但 parquet unhealthy → 重取; marker 在且健康 → 跳过)
|
||
todo = [
|
||
(uid, fn) for uid, fn in units
|
||
if not (uid in done_set
|
||
and is_parquet_healthy(parquet_path_per_unit(data_type, uid)))
|
||
]
|
||
else:
|
||
todo = [(uid, fn) for uid, fn in units if uid not in done_set]
|
||
logger.info(
|
||
"[%s] 待处理 %d (已完成 %d, 总 %d%s)",
|
||
data_type, len(todo), len(done_set), len(units),
|
||
", repair 模式" if getattr(args, "repair", False) else "",
|
||
)
|
||
|
||
stats = {"ok": 0, "skipped": 0, "empty": 0, "failed": 0, "rows": 0}
|
||
consec_fail = 0
|
||
circuit_triggered = False
|
||
t_start = time.time()
|
||
total_bytes = 0
|
||
|
||
for i, (uid, fn) in enumerate(todo):
|
||
try:
|
||
status, rows = download_one_unit(
|
||
data_type, uid, fn, args.force,
|
||
repair=getattr(args, "repair", False),
|
||
)
|
||
except Exception as e:
|
||
status, rows = "failed", 0
|
||
logger.debug("[%s] %s 异常: %s", data_type, uid, e)
|
||
|
||
stats[status] = stats.get(status, 0) + 1
|
||
if status == "ok":
|
||
stats["rows"] += rows
|
||
consec_fail = 0
|
||
# 统计 parquet 大小
|
||
try:
|
||
total_bytes += parquet_path_per_unit(data_type, uid).stat().st_size
|
||
except OSError:
|
||
pass
|
||
elif status == "empty":
|
||
consec_fail = 0
|
||
elif status == "failed":
|
||
consec_fail += 1
|
||
# skipped 不重置也不递增
|
||
|
||
# 断路器
|
||
if consec_fail >= CIRCUIT_BREAKER:
|
||
logger.error(
|
||
"[%s] [FATAL] 断路器触发: 连续 %d 个 unit 失败, "
|
||
"akshare 疑似不可达, 退出 (done_set 不含 failed, 复跑会重试)",
|
||
data_type, consec_fail,
|
||
)
|
||
circuit_triggered = True
|
||
break
|
||
|
||
# 进度日志
|
||
if (i + 1) % PROGRESS_LOG_EVERY == 0 or (i + 1) == len(todo):
|
||
elapsed = time.time() - t_start
|
||
logger.info(
|
||
"[%s] 进度 %d/%d ok=%d empty=%d failed=%d skipped=%d "
|
||
"rows=%d size=%.1fMB (%.0f秒)",
|
||
data_type, i + 1, len(todo),
|
||
stats["ok"], stats["empty"], stats["failed"], stats["skipped"],
|
||
stats["rows"], total_bytes / 1024 / 1024, elapsed,
|
||
)
|
||
|
||
# 限速
|
||
if i < len(todo) - 1:
|
||
time.sleep(AK_INTERVAL)
|
||
|
||
elapsed = time.time() - t_start
|
||
logger.info(
|
||
"[%s] 完成, 耗时 %.1f 秒 (%.1f分), 统计: %s, 总 parquet 大小 %.1fMB",
|
||
data_type, elapsed, elapsed / 60,
|
||
json.dumps(stats, ensure_ascii=False),
|
||
total_bytes / 1024 / 1024,
|
||
)
|
||
return stats, circuit_triggered
|
||
|
||
|
||
# ======================== 各模式 units 构造 ========================
|
||
|
||
def build_per_stock_units(
|
||
data_type: str,
|
||
endpoint: str,
|
||
fetch_binder: Callable[[str], pd.DataFrame],
|
||
all_codes: List[Tuple[str, str]],
|
||
args: argparse.Namespace,
|
||
) -> List[Tuple[str, Callable[[], pd.DataFrame]]]:
|
||
"""构造 per-stock units: [(unit_id, fetch_fn), ...]。
|
||
|
||
unit_id = '{code}.{EXC}_{data_type}', 例 '600519.SH_valuation'
|
||
fetch_fn 闭包绑定 symbol (按 endpoint 格式)。
|
||
|
||
endpoint 决定 symbol 格式 (见 code_to_symbol)。
|
||
"""
|
||
# --codes 过滤
|
||
if args.codes:
|
||
codes_set = {c.strip() for c in args.codes.split(",") if c.strip()}
|
||
todo_codes = [(c, guess_exchange_by_code(c)) for c in codes_set]
|
||
else:
|
||
todo_codes = list(all_codes)
|
||
# --limit 截断
|
||
if args.limit > 0:
|
||
todo_codes = todo_codes[: args.limit]
|
||
|
||
units: List[Tuple[str, Callable[[], pd.DataFrame]]] = []
|
||
for code, exc in todo_codes:
|
||
symbol = code_to_symbol(code, exc, endpoint)
|
||
unit_id = f"{code}.{exc}_{data_type}"
|
||
# partial 绑定 symbol (lambda 闭包易 late-bind, 用 partial 安全)
|
||
fn = partial(fetch_binder, symbol)
|
||
units.append((unit_id, fn))
|
||
return units
|
||
|
||
|
||
def build_top_holders_units(
|
||
all_codes: List[Tuple[str, str]],
|
||
args: argparse.Namespace,
|
||
) -> List[Tuple[str, Callable[[], pd.DataFrame]]]:
|
||
"""top_holders 特殊: per-stock × per-period (近5年×4季 = 20期).
|
||
|
||
unit_id = '{code}.{EXC}_{period}_top_holders', 例 '600519.SH_2020930_top_holders'
|
||
"""
|
||
if args.codes:
|
||
codes_set = {c.strip() for c in args.codes.split(",") if c.strip()}
|
||
todo_codes = [(c, guess_exchange_by_code(c)) for c in codes_set]
|
||
else:
|
||
todo_codes = list(all_codes)
|
||
if args.limit > 0:
|
||
todo_codes = todo_codes[: args.limit]
|
||
|
||
# 报告期取近 5 年×4季 (REPORT_PERIODS 已含至今所有)
|
||
periods = REPORT_PERIODS[-20:] if len(REPORT_PERIODS) >= 20 else REPORT_PERIODS
|
||
|
||
units: List[Tuple[str, Callable[[], pd.DataFrame]]] = []
|
||
skipped_bj = 0
|
||
for code, exc in todo_codes:
|
||
if code.startswith(BJ_PREFIXES):
|
||
skipped_bj += 1
|
||
continue # 跳北交所 (akshare 东财 stock_gdfx_free_top_10_em 不支持,
|
||
# 避免每只×20期×3retry 失败风暴, _safe_top_10_em 是双保险)
|
||
symbol = code_to_symbol(code, exc, "top_holders")
|
||
for period in periods:
|
||
unit_id = f"{code}.{exc}_{period}_{TOP_HOLDERS}"
|
||
fn = partial(fetch_top_holders_one_period, symbol, period)
|
||
units.append((unit_id, fn))
|
||
if skipped_bj:
|
||
logger.info("[top_holders] 跳过北交所 %d 票 (akshare 东财不支持)", skipped_bj)
|
||
logger.info(
|
||
"[top_holders] %d 票 × %d 期 = %d units",
|
||
len(todo_codes) - skipped_bj, len(periods), len(units),
|
||
)
|
||
return units
|
||
|
||
|
||
def build_per_date_units(
|
||
data_type: str,
|
||
fetch_fn: Callable[[str], pd.DataFrame],
|
||
args: argparse.Namespace,
|
||
) -> List[Tuple[str, Callable[[], pd.DataFrame]]]:
|
||
"""构造 per-date units: 每个交易日 1 个 unit。
|
||
|
||
unit_id = '{date}_{data_type}', 例 '20260715_dragon_tiger'
|
||
|
||
交易日简单生成 (周一到周五), 排除节假日策略: 拉到空就 skip (计 empty,
|
||
不计 failed); 不依赖节假日表 (节假日多日无数据, 拉空即合法)。
|
||
"""
|
||
start = normalize_date(args.start)
|
||
end = normalize_date(args.end)
|
||
start_dt = datetime.datetime.strptime(start, "%Y%m%d").date()
|
||
end_dt = datetime.datetime.strptime(end, "%Y%m%d").date()
|
||
if start_dt > end_dt:
|
||
raise ValueError(f"--start {start} > --end {end}")
|
||
|
||
units: List[Tuple[str, Callable[[], pd.DataFrame]]] = []
|
||
d = start_dt
|
||
while d <= end_dt:
|
||
# 周一到周五 (周末无交易, 不入列)
|
||
if d.weekday() < 5:
|
||
date_str = d.strftime("%Y%m%d")
|
||
unit_id = f"{date_str}_{data_type}"
|
||
units.append((unit_id, partial(fetch_fn, date_str)))
|
||
d += datetime.timedelta(days=1)
|
||
logger.info(
|
||
"[%s] 日期范围 %s ~ %s, 工作日 %d 天 (节假日拉空计 empty)",
|
||
data_type, start, end, len(units),
|
||
)
|
||
return units
|
||
|
||
|
||
def build_per_period_units(
|
||
data_type: str,
|
||
fetch_fn: Callable[[str], pd.DataFrame],
|
||
) -> List[Tuple[str, Callable[[], pd.DataFrame]]]:
|
||
"""构造 per-period units: 每个报告期 1 个 unit。
|
||
|
||
unit_id = '{period}_{data_type}', 例 '20251231_forecast'
|
||
报告期取 REPORT_PERIODS (近 5 年×4季)。
|
||
"""
|
||
units: List[Tuple[str, Callable[[], pd.DataFrame]]] = []
|
||
for period in REPORT_PERIODS:
|
||
unit_id = f"{period}_{data_type}"
|
||
units.append((unit_id, partial(fetch_fn, period)))
|
||
logger.info(
|
||
"[%s] 报告期 %d 个 (近5年×4季, %s..%s)",
|
||
data_type, len(units), REPORT_PERIODS[0], REPORT_PERIODS[-1],
|
||
)
|
||
return units
|
||
|
||
|
||
def build_one_shot_units(
|
||
data_type: str,
|
||
fetch_fn: Callable[[], pd.DataFrame],
|
||
) -> List[Tuple[str, Callable[[], pd.DataFrame]]]:
|
||
"""构造 one-shot units: 单个 unit。
|
||
|
||
unit_id = data_type, 例 'index_const'
|
||
"""
|
||
return [(data_type, fetch_fn)]
|
||
|
||
|
||
# ======================== CLI / main ========================
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
p = argparse.ArgumentParser(
|
||
description="AKShare A 股静态数据全量下载 (16 类, 四种模式)",
|
||
)
|
||
p.add_argument(
|
||
"--types", default=",".join(ALL_TYPES),
|
||
help=f"逗号分隔类型, 默认全部 ({','.join(ALL_TYPES)})",
|
||
)
|
||
p.add_argument(
|
||
"--start", default=START_DATE_DEFAULT,
|
||
help=f"起始日期 YYYYMMDD 或 YYYY-MM-DD, 默认 {START_DATE_DEFAULT}",
|
||
)
|
||
p.add_argument("--end", default="", help="结束日期, 默认今天")
|
||
p.add_argument(
|
||
"--codes",
|
||
help="指定代码逗号分隔 (6 位无前缀), 如 600519,000001 (仅 per-stock 类生效)",
|
||
)
|
||
p.add_argument(
|
||
"--limit", type=int, default=0,
|
||
help="限制处理股票数 (per-stock 类生效), 测试用",
|
||
)
|
||
p.add_argument("--force", action="store_true", help="强制重下, 忽略 marker")
|
||
p.add_argument(
|
||
"--repair", action="store_true",
|
||
help="只重取 missing/empty(size<1KB 或 df.empty)/corrupt 的 parquet, "
|
||
"忽略其 marker。适合周度补漏, 不必等财报季 --force 全量重跑",
|
||
)
|
||
return p.parse_args()
|
||
|
||
|
||
def resolve_types(s: str) -> List[str]:
|
||
"""解析 --types 字符串 → 去重保序的类型列表。无效类型 SystemExit。"""
|
||
parts = [t.strip() for t in s.split(",") if t.strip()]
|
||
bad = [t for t in parts if t not in ALL_TYPES]
|
||
if bad:
|
||
raise SystemExit(f"未知 --types: {bad}, 可选 {list(ALL_TYPES)}")
|
||
if not parts:
|
||
return list(ALL_TYPES)
|
||
seen: set = set()
|
||
out: List[str] = []
|
||
for t in parts:
|
||
if t not in seen:
|
||
out.append(t)
|
||
seen.add(t)
|
||
return out
|
||
|
||
|
||
def run_type_dispatch(
|
||
t: str,
|
||
all_codes: List[Tuple[str, str]],
|
||
args: argparse.Namespace,
|
||
) -> Tuple[dict, bool]:
|
||
"""按类型路由到对应模式 + fetch 函数。返 (stats, circuit_triggered)。"""
|
||
if t in PER_STOCK_TYPES:
|
||
endpoint_map = {
|
||
"valuation": ("valuation", fetch_valuation),
|
||
"northbound": ("northbound", fetch_northbound),
|
||
"share_capital": ("share_capital", fetch_share_capital),
|
||
"balance": ("balance_sheet", fetch_balance_sheet),
|
||
"income": ("income_sheet", fetch_income_sheet),
|
||
"cashflow": ("cashflow_sheet", fetch_cashflow_sheet),
|
||
"financial_abstract": ("financial_abstract", fetch_financial_abstract),
|
||
}
|
||
endpoint, fetch_fn = endpoint_map[t]
|
||
units = build_per_stock_units(t, endpoint, fetch_fn, all_codes, args)
|
||
return run_one_type(t, units, args)
|
||
|
||
if t == TOP_HOLDERS:
|
||
units = build_top_holders_units(all_codes, args)
|
||
return run_one_type(t, units, args)
|
||
|
||
if t in PER_DATE_TYPES:
|
||
fetch_map = {
|
||
"dragon_tiger": fetch_dragon_tiger,
|
||
"block_trade": fetch_block_trade,
|
||
"margin_sse": fetch_margin_sse,
|
||
"restricted": fetch_restricted,
|
||
}
|
||
units = build_per_date_units(t, fetch_map[t], args)
|
||
return run_one_type(t, units, args)
|
||
|
||
if t in PER_PERIOD_TYPES:
|
||
fetch_map = {
|
||
"forecast": fetch_forecast,
|
||
"express": fetch_express,
|
||
}
|
||
units = build_per_period_units(t, fetch_map[t])
|
||
return run_one_type(t, units, args)
|
||
|
||
if t in ONE_SHOT_TYPES:
|
||
fetch_map = {
|
||
"index_const": fetch_index_const,
|
||
"industry": fetch_industry,
|
||
}
|
||
units = build_one_shot_units(t, fetch_map[t])
|
||
return run_one_type(t, units, args)
|
||
|
||
logger.error("未知类型 (跳过): %s", t)
|
||
return {"ok": 0, "skipped": 0, "empty": 0, "failed": 0, "rows": 0}, False
|
||
|
||
|
||
def main() -> None:
|
||
args = parse_args()
|
||
start_date = normalize_date(args.start)
|
||
end_date = normalize_date(args.end)
|
||
types = resolve_types(args.types)
|
||
|
||
logger.info("=" * 60)
|
||
logger.info("AKShare A 股静态数据全量下载 (16 类, 四种模式)")
|
||
logger.info(" 输出目录: %s", OUT_DIR)
|
||
logger.info(" 日志文件: %s", LOG_FILE)
|
||
logger.info(" 日期范围: %s ~ %s", start_date, end_date)
|
||
logger.info(" 报告期数: %d (%s..%s)",
|
||
len(REPORT_PERIODS),
|
||
REPORT_PERIODS[0] if REPORT_PERIODS else "-",
|
||
REPORT_PERIODS[-1] if REPORT_PERIODS else "-")
|
||
logger.info(" 类型: %s", types)
|
||
logger.info(" 限速: 单线程串行, AK_INTERVAL=%.1fs, AK_TIMEOUT=%.0fs",
|
||
AK_INTERVAL, AK_TIMEOUT)
|
||
logger.info(" 当前时间: %s", datetime.datetime.now().isoformat())
|
||
logger.info("=" * 60)
|
||
|
||
# 检测 akshare 版本 (日志)
|
||
try:
|
||
logger.info("akshare 版本: %s", ak.__version__)
|
||
except AttributeError:
|
||
logger.info("akshare 版本: 未知 (无 __version__ 属性)")
|
||
|
||
any_circuit = False
|
||
# 是否需要股票列表
|
||
need_codes = any(
|
||
t in PER_STOCK_TYPES or t == TOP_HOLDERS for t in types
|
||
)
|
||
all_codes: List[Tuple[str, str]] = []
|
||
|
||
if need_codes:
|
||
try:
|
||
all_codes = fetch_all_stocks()
|
||
except Exception as e:
|
||
logger.error("[FATAL] 获取股票列表失败: %s", e)
|
||
sys.exit(1)
|
||
|
||
for t in types:
|
||
logger.info("-" * 50)
|
||
logger.info(">>> 类型: %s", t)
|
||
try:
|
||
stats, circuit = run_type_dispatch(t, all_codes, args)
|
||
except Exception as e:
|
||
logger.exception("[%s] 类型执行异常: %s", t, e)
|
||
any_circuit = True
|
||
break
|
||
if circuit:
|
||
any_circuit = True
|
||
logger.error("[%s] 断路器触发, 跳过后续类型", t)
|
||
break
|
||
|
||
logger.info("=" * 60)
|
||
logger.info("全部完成, 退出码 %d", 2 if any_circuit else 0)
|
||
logger.info("=" * 60)
|
||
sys.exit(2 if any_circuit else 0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|