Files
sanguo_vnpy_v2/scripts/data_platform/baostock_static_download.py
T
claude_dev 774170ec05 feat(data): 数据源融合 P0 补全 + 每日增量脚本
采集层(多源各下):
- baostock: 日线全字段全量(baostock_daily_fullmarket) + 15min全市场 + 静态(基础/复权/分红/季频/三表) + 成份股
- akshare: 静态(估值/龙虎榜/大宗/融资融券/北向/指数成分/行业/股本/解禁/业绩预告)
- xtdata(miniQMT): build_daily_from_xtdata + daily_update_xtdata

数据补全 P0:
- ETF全市场: universe 扩展 沪深A股∪ETF∪基金(7414), dividend_type='front' 前复权
- 历史成份股(治幸存者偏差): index_const_hist_download 深证/国证 adjust_cni 4指数 + 中证1000/2000快照 + 新浪交叉校验
- 退市K线: baostock_delisted_download + import_delisted_to_db(实证 Day1 fetch_all_stocks 已含退市)

灌库:
- import_baostock_to_db: daily_baostock_full(5537股/1826万行,18字段)+ bs_index_constituent + bs_adjust_factor
- INSERT OR REPLACE 幂等, WAL+busy_timeout, dbbardata 不碰

每日增量 #7(用户决策A: VPS直跑):
- daily_update_static: login探针防黑名单graceful skip + LOOKBACK7 + query_stock_basic含退市 + INSERT OR REPLACE + QUERY_COUNT守48000/天

设计文档: spec(13节三层融合) + P0 plan + 数据gap设计
2026-07-22 10:34:22 +08:00

796 lines
29 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""BaoStock 静态数据下载 (basic / adjust_factor / dividend 3 类, 只产 parquet)
目标:
在 Windows Server 2022 VPS (49.232.102.198) 上用 baostock 全量拉取 A 股
(含退市) 3 类静态数据:
1. basic - 基础信息 (全市场单文件, 不按只)
2. adjust_factor - 复权因子 (per-stock)
3. dividend - 分红送转 (per-stock, 循环年份 2020..2026)
输出 import-ready parquet, 后续数据平台直接读. 与 15min 下载脚本共用硬化机制.
与 baostock_15min_fullmarket_download.py 共用的硬约束 (踩过的坑):
1. 单进程单登录, 严禁并发 (baostock 并发会拉黑封 IP 6-24h)
2. 直连不走代理 (脚本开头 unset proxy)
3. 每次 baostock query 后 sleep BS_INTERVAL=0.4s 限速
4. baostock login 受 socket.setdefaulttimeout(30) 保护
(Windows 无 SIGALRM, 这是跨平台等价方案)
5. empty vs failed 区分:
- 空数据 (退市/无除权事件) → fetch 返空 df → status='empty' 中性, 不重试
- 真错误 → fetch raise → retry → status='failed' 计断路器
与 15min 脚本差异:
- 3 类 fetch 各自实现 (basic 单调用 / adjust_factor per-stock / dividend per-year)
- per-stock empty **也写 marker** (静态空 = 确定性"无除权事件",
重跑只是浪费 baostock 配额, 与 K 线 empty 不写 marker 的语义不同)
- 输出 3 个子目录: basic / adjust_factor / dividend
- 单次进程可顺序跑多类 (--types basic,adjust_factor,dividend)
- basic 单文件全量刷新, 不受 --limit / --codes 影响
⚠️ 构建期禁止任何 live baostock 调用 (VPS 15min 下载并行会封 IP).
本脚本只 py_compile + 代码 review, smoke 测试延后.
用法:
# 全跑 (basic + adjust_factor + dividend)
python baostock_static_download.py
# 只跑指定类型 (逗号分隔)
python baostock_static_download.py --types basic,adjust_factor
python baostock_static_download.py --types dividend
# 测试 (前 10 只, basic 不受影响)
python baostock_static_download.py --types adjust_factor --limit 10
# 指定股票 (6 位 code, 不带前缀)
python baostock_static_download.py --types dividend --codes 600519,000001
# 强制重下, 忽略 marker
python baostock_static_download.py --types basic --force
输出目录结构:
{OUT_DIR}/
├── basic/
│ ├── stock_basic.parquet
│ └── .stock_basic.baostock (marker)
├── adjust_factor/
│ ├── 600519.SH_factor.parquet
│ ├── .600519.SH_factor.baostock (marker)
│ └── ...
├── dividend/
│ ├── 600519.SH_dividend.parquet
│ ├── .600519.SH_dividend.baostock (marker)
│ └── ...
└── logs/baostock_static_YYYYMMDD_HHMMSS.log
退出码: 0=完成, 1=致命错误 (登录/列表拉取失败), 2=断路器触发 (可重试)
"""
import argparse
import datetime
import json
import logging
import os
import socket
import sys
import time
from functools import partial
from pathlib import Path
from typing import Callable, List, Optional, Tuple
# ======================== 硬约束: unset proxy + socket timeout ========================
# 必须在 import baostock 之前清理 (baostock 底层 urllib 会读 proxy 环境变量)
for _k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
os.environ.pop(_k, None)
# SIGALRM 跨平台替代: 全局 socket 超时 30s (Windows 无 SIGALRM)。
# baostock 的 socket 连接会继承这个超时, connect 卡死时抛 socket.timeout。
socket.setdefaulttimeout(30)
# stdout 行缓冲 (SSH-detached / pipe 重定向时也能看到实时进度)
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, ValueError):
pass
import baostock as bs # noqa: E402
import pandas as pd # noqa: E402
# ======================== 配置 (环境变量可覆盖) ========================
DEFAULT_OUT_DIR = r"C:\sanguo_vnpy_v2\data\static"
OUT_DIR = Path(os.environ.get("BS_STATIC_OUT_DIR", DEFAULT_OUT_DIR))
DEFAULT_LOG_DIR = OUT_DIR / "logs"
_log_dir_env = os.environ.get("BS_STATIC_LOG_DIR")
LOG_DIR = Path(_log_dir_env) if _log_dir_env else DEFAULT_LOG_DIR
START_DATE_DEFAULT = "2020-01-01"
# 分红查询年份范围 (含两端). 2020..2026 = 7 年, 与 START_DATE_DEFAULT 对齐。
DIVIDEND_YEAR_RANGE = (2020, 2026)
BS_INTERVAL = 0.4 # 每次 baostock query 后间隔秒 (防封 IP)
BS_MAX_RETRIES = 3 # 单次下载重试次数 (含强制重登)
PROGRESS_LOG_EVERY = 500 # 每 N 只打一次进度日志
RELOGIN_EVERY = 400 # 每 N 只定期重登保持连接
CIRCUIT_BREAKER = 30 # 连续失败 N 只 → 断路退出
# 子目录名 (同时也是 --types 合法值)
DIR_BASIC = "basic"
DIR_ADJUST = "adjust_factor"
DIR_DIVIDEND = "dividend"
# 文件名后缀 (parquet / marker stem 解析用)
SUFFIX_BASIC = "stock_basic" # basic 单文件 stem (无 per-stock)
SUFFIX_ADJUST = "_factor" # 600519.SH_factor
SUFFIX_DIVIDEND = "_dividend" # 600519.SH_dividend
VALID_TYPES = (DIR_BASIC, DIR_ADJUST, DIR_DIVIDEND)
# ======================== 日志 ========================
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"baostock_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, 返回 YYYY-MM-DD; 空串 → 今天。"""
s = (s or "").strip()
if not s:
return datetime.date.today().strftime("%Y-%m-%d")
if "-" in s:
return s
if len(s) == 8 and s.isdigit():
return f"{s[:4]}-{s[4:6]}-{s[6:8]}"
raise ValueError(f"无效日期格式: {s} (期望 YYYYMMDD 或 YYYY-MM-DD)")
def baostock_code_to_parts(bs_code: str) -> Tuple[str, str]:
"""sh.600519 → ('600519', 'SH'); sz.000001 → ('000001', 'SZ')。
前缀直接从 baostock code 拿, 不猜。
"""
if "." not in bs_code:
raise ValueError(f"无效 baostock code: {bs_code}")
prefix, num = bs_code.split(".", 1)
if prefix == "sh":
return num, "SH"
if prefix == "sz":
return num, "SZ"
raise ValueError(f"未知 baostock 前缀: {bs_code}")
def parts_to_baostock(code: str, exchange: str) -> str:
"""('600519', 'SH') → 'sh.600519'"""
pfx = "sh" if exchange == "SH" else "sz"
return f"{pfx}.{code}"
def guess_exchange_by_code(code: str) -> str:
"""6/68/51 开头 → SH, 其他 → SZ (无前缀代码用, 与 15min 模板一致)。"""
if code.startswith(("6", "68", "51")):
return "SH"
return "SZ"
def _rs_to_rows(rs) -> Tuple[List[str], List[List[str]]]:
"""读取 ResultData, 返回 (fields, rows)。
用 rs.fields 动态拿字段名, 不硬编码位置 (踩过坑: baostock 字段顺序与文档不一致)。
"""
fields = list(rs.fields)
rows: List[List[str]] = []
while rs.next():
rows.append(rs.get_row_data())
return fields, rows
# ======================== 股票列表 (baostock query_stock_basic) ========================
def fetch_all_stocks() -> List[Tuple[str, str]]:
"""baostock query_stock_basic 拉全市场 A 股列表 (含退市, type=='1')。
返回: [(code, exchange), ...], 例 [('600519', 'SH'), ('000001', 'SZ')]
过滤: type=='1' (股票); **不过滤 status** (保留退市, 避免生存偏差)。
baostock 0.9.3 实测字段顺序与文档不一致, 用 rs.fields 动态建索引按名取,
避免字段位置差异导致过滤错位 (照抄 15min 模板已验证逻辑)。
"""
rs = bs.query_stock_basic()
if rs.error_code != "0":
raise RuntimeError(
f"query_stock_basic 失败: code={rs.error_code} msg={rs.error_msg}"
)
fields = list(rs.fields)
idx = {name: i for i, name in enumerate(fields)}
logger.info("query_stock_basic fields=%s", fields)
out: List[Tuple[str, str]] = []
n_stock = 0
n_skip_type = 0
n_skip_code = 0
while rs.next():
r = rs.get_row_data()
type_ = r[idx["type"]] if "type" in idx and len(r) > idx["type"] else ""
if type_ != "1": # 只要股票 (排除指数/债券/其他)
n_skip_type += 1
continue
bs_code = r[idx["code"]] if "code" in idx and len(r) > idx["code"] else ""
try:
code, exchange = baostock_code_to_parts(bs_code)
except ValueError:
n_skip_code += 1
continue
if len(code) != 6 or not code.isdigit():
n_skip_code += 1
continue
out.append((code, exchange))
n_stock += 1
logger.info(
"股票列表: stocks(type=1)=%d skip_type=%d skip_code=%d (含退市, 不过滤 status)",
n_stock, n_skip_type, n_skip_code)
return out
# ======================== baostock 登录 ========================
def _login_once() -> bool:
"""bs.login() — 已被 socket.setdefaulttimeout(30) 保护。
connect 卡死会抛 socket.timeout, 这里捕获。
"""
try:
lg = bs.login()
if lg.error_code == "0":
return True
logger.error("bs.login() 失败: code=%s msg=%s", lg.error_code, lg.error_msg)
return False
except (socket.timeout, TimeoutError) as e:
logger.error("bs.login() 超时: %s (baostock 疑似冷却)", e)
return False
except Exception as e:
logger.error("bs.login() 异常: %s", e)
return False
def _relogin() -> bool:
"""强制重登: logout + login。失败等 2s 再试 1 次。"""
try:
bs.logout()
except Exception:
pass
if _login_once():
return True
time.sleep(2)
try:
bs.logout()
except Exception:
pass
return _login_once()
# ======================== 数据 fetch (3 类各自实现) ========================
def fetch_basic() -> pd.DataFrame:
"""拉全市场基础信息, 单次调用返 df。
返回列: baostock 原始字段 (code, code_name, ipoDate, outDate, type, status) +
派生列 code (6位纯数字) / exchange (SH/SZ) / baostock_code (sh.XXXXXX)
原始 code 列被覆盖为 6 位纯数字, baostock_code 保留 sh.XXXXXX 形式。
baostock 字段顺序与文档不一致时, 用 rs.fields 动态拿列名, 不硬编码位置。
"""
rs = bs.query_stock_basic()
if rs.error_code != "0":
raise RuntimeError(
f"query_stock_basic 失败: code={rs.error_code} msg={rs.error_msg}"
)
fields, rows = _rs_to_rows(rs)
extra_cols = ["code_6digit", "exchange", "baostock_code"]
if not rows:
return pd.DataFrame(columns=fields + extra_cols)
df = pd.DataFrame(rows, columns=fields)
# 派生列: 从原始 baostock code 拆出 6 位 + 交易所
df["baostock_code"] = df["code"]
parts = df["code"].apply(baostock_code_to_parts)
df["code_6digit"] = parts.str[0]
df["exchange"] = parts.str[1]
# 列重排: 把派生 6 位 code 放前面 (与原始 baostock code 区分, 避免歧义)
return df.reset_index(drop=True)
def fetch_adjust_factor(bs_code: str, start_date: str, end_date: str) -> pd.DataFrame:
"""拉单只复权因子, 返回 df (可能空)。
返回字段 (baostock 文档): code, dividOperateDate, foreAdjustFactor,
backAdjustFactor, adjustFactor
数值列 to_numeric; 空数据 (无除权事件的票) → empty df, 上层计 empty 中性。
字段顺序按 rs.fields 动态拿, 不硬编码位置 (容错 baostock 字段顺序差异)。
"""
rs = bs.query_adjust_factor(
code=bs_code, start_date=start_date, end_date=end_date,
)
if rs.error_code != "0":
raise RuntimeError(
f"query_adjust_factor {bs_code} 错误: {rs.error_code} {rs.error_msg}"
)
fields, rows = _rs_to_rows(rs)
if not rows:
return pd.DataFrame(columns=fields)
df = pd.DataFrame(rows, columns=fields)
# 数值列 to_numeric (baostock 返字符串)
for col in ("foreAdjustFactor", "backAdjustFactor", "adjustFactor"):
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
return df
def fetch_dividend(bs_code: str, year_range: Tuple[int, int]) -> pd.DataFrame:
"""拉单只分红送转, 循环年份 concat。返回 df (可能空)。
每年调用 bs.query_dividend_data(code, year, yearType="report"),
返回该票当年分红记录 (多行)。concat 所有年份。
**保留 baostock 返回的全部列** (用 rs.fields 动态拿, 不硬编码字段名)。
限速: 每次年份 query 后 sleep BS_INTERVAL (除最后一次, 由主循环负责)。
yearType="report" = 预案公告年份 (与用户指定一致)。
"""
years = list(range(year_range[0], year_range[1] + 1))
frames: List[pd.DataFrame] = []
fields_ref: Optional[List[str]] = None
for i, year in enumerate(years):
rs = bs.query_dividend_data(
code=bs_code, year=year, yearType="report",
)
if rs.error_code != "0":
raise RuntimeError(
f"query_dividend_data {bs_code} year={year} 错误: "
f"{rs.error_code} {rs.error_msg}"
)
fields, rows = _rs_to_rows(rs)
if fields_ref is None:
fields_ref = fields # 锁定首次响应的字段列表
if rows:
# 各年字段应一致; 以当前响应字段为准构造 df
frames.append(pd.DataFrame(rows, columns=fields))
# 限速: 非最后一年时 sleep (最后一次由主循环负责 stock 间隔)
if i < len(years) - 1:
time.sleep(BS_INTERVAL)
if not frames:
cols = fields_ref if fields_ref is not None else []
return pd.DataFrame(columns=cols)
return pd.concat(frames, ignore_index=True)
# ======================== 路径 / marker ========================
def subdir_for(data_type: str) -> Path:
"""data_type → OUT_DIR / <subdir>。"""
return OUT_DIR / data_type
def parquet_path_basic() -> Path:
"""basic 单文件 parquet 路径。"""
return subdir_for(DIR_BASIC) / f"{SUFFIX_BASIC}.parquet"
def marker_path_basic() -> Path:
"""basic 单文件 marker。"""
return subdir_for(DIR_BASIC) / f".{SUFFIX_BASIC}.baostock"
def parquet_path_per_stock(
code: str, exchange: str, data_type: str, suffix: str,
) -> Path:
"""('600519', 'SH', 'adjust_factor', '_factor')
→ OUT_DIR/adjust_factor/600519.SH_factor.parquet
"""
return subdir_for(data_type) / f"{code}.{exchange}{suffix}.parquet"
def marker_path_for(parquet_path: Path) -> Path:
"""parquet → 同目录 .{stem}.baostock marker。"""
return parquet_path.parent / f".{parquet_path.stem}.baostock"
def load_done_set_per_stock(data_type: str, suffix: str) -> set:
"""扫子目录 marker 构造已完成 (code, exchange) 集合 (真相源)。
marker 文件名格式: .600519.SH_factor.baostock
解析 stem = '600519.SH_factor', 按 suffix='_factor' 剥出 head='600519.SH',
再拆 code='600519' / exc='SH', 校验 6 位数字 + 交易所。
"""
done: set = set()
d = subdir_for(data_type)
if not d.exists():
return done
ext = ".baostock"
for marker in d.glob(f".*{ext}"):
name = marker.name
if not name.startswith(".") or not name.endswith(ext):
continue
stem = name[1:-len(ext)] # e.g. '600519.SH_factor'
if not suffix or not stem.endswith(suffix):
continue
head = stem[:-len(suffix)] if suffix else stem
# head: '600519.SH'
try:
code, exc = head.split(".", 1)
if len(code) == 6 and code.isdigit() and exc in ("SH", "SZ"):
done.add((code, exc))
except ValueError:
continue
return done
# ======================== 单只下载 ========================
def download_basic(force: bool) -> Tuple[str, int]:
"""basic 单文件全量刷新。返 (status, rows), status ∈ {'ok','skipped','failed'}。
basic 全市场不应为空 (5537 票级别), 空数据 = 失败 (与 per-stock 语义不同)。
"""
parquet_path = parquet_path_basic()
marker_path = marker_path_basic()
if not force and marker_path.exists():
return "skipped", 0
df: Optional[pd.DataFrame] = None
for attempt in range(BS_MAX_RETRIES):
try:
df = fetch_basic()
break # 成功 (df 空仍 break, 下方判 failed)
except (socket.timeout, TimeoutError, OSError) as e:
logger.warning("basic socket 异常重试 %d/%d: %s — 强制重登",
attempt + 1, BS_MAX_RETRIES, e)
if not _relogin():
logger.error("重登失败, 放弃 basic")
df = None
break
except Exception as e:
logger.warning("basic 异常重试 %d/%d: %s — 强制重登",
attempt + 1, BS_MAX_RETRIES, e)
if not _relogin():
logger.error("重登失败, 放弃 basic")
df = None
break
if df is None:
return "failed", 0
if df.empty:
# 全市场不应为空 — 视为失败 (重试或人工排查)
logger.error("basic 返回空 (异常, 全市场 type=1 不应为空)")
return "failed", 0
try:
d = subdir_for(DIR_BASIC)
d.mkdir(parents=True, exist_ok=True)
df.to_parquet(parquet_path, index=False)
marker_path.write_text(datetime.datetime.now().isoformat())
return "ok", len(df)
except Exception as e:
logger.error("写入 %s 失败: %s", parquet_path, e)
return "failed", 0
def download_one_per_stock(
code: str,
exchange: str,
data_type: str,
suffix: str,
fetch_fn: Callable[[str], pd.DataFrame],
force: bool,
) -> Tuple[str, int]:
"""通用 per-stock 下载 → 写 parquet + marker。
fetch_fn(bs_code) → df (可能空) 或 raise。返 (status, rows), status ∈
{'ok', 'skipped', 'empty', 'failed'}。
与 15min 模板差异: per-stock empty **也写 marker** (静态空 = "查过了确实无除权事件",
重跑只是浪费 baostock 配额; 与 K 线 empty "可能是延迟" 语义不同)。
"""
parquet_path = parquet_path_per_stock(code, exchange, data_type, suffix)
marker_path = marker_path_for(parquet_path)
if not force and marker_path.exists():
return "skipped", 0
bs_code = parts_to_baostock(code, exchange)
# retry 循环: fetch_fn 仅在真错误时抛 (走 retry/relogin);
# 空数据返空 df (合法, 不重试), 成功 break。
df: Optional[pd.DataFrame] = None
for attempt in range(BS_MAX_RETRIES):
try:
df = fetch_fn(bs_code)
break # fetch 成功 (df 可能空 = 合法无数据)
except (socket.timeout, TimeoutError, OSError) as e:
logger.warning(
"%s %s socket 异常重试 %d/%d: %s — 强制重登",
code, data_type, attempt + 1, BS_MAX_RETRIES, e,
)
if not _relogin():
logger.error("重登失败, 放弃 %s %s", code, data_type)
df = None
break
except Exception as e:
logger.warning(
"%s %s 下载异常重试 %d/%d: %s — 强制重登",
code, data_type, attempt + 1, BS_MAX_RETRIES, e,
)
if not _relogin():
logger.error("重登失败, 放弃 %s %s", code, data_type)
df = None
break
if df is None:
return "failed", 0 # 多次重试仍报错 (真失败, 计断路器)
# df 可能空 (合法) 或非空 — 两种都写 marker (静态数据语义, 见 docstring)
try:
d = subdir_for(data_type)
d.mkdir(parents=True, exist_ok=True)
df.to_parquet(parquet_path, index=False)
marker_path.write_text(datetime.datetime.now().isoformat())
return ("ok" if not df.empty else "empty"), len(df)
except Exception as e:
logger.error("写入 %s 失败: %s", parquet_path, e)
return "failed", 0
# ======================== per-stock 主循环 (通用) ========================
def run_per_stock_type(
data_type: str,
suffix: str,
fetch_fn: Callable[[str], pd.DataFrame],
all_codes: List[Tuple[str, str]],
args: argparse.Namespace,
skip_loop_sleep: bool,
) -> Tuple[dict, bool]:
"""运行 per-stock 类型 (adjust_factor / dividend)。
fetch_fn: 已绑定参数的 callable, 接受 bs_code, 返回 df (可能空) 或 raise。
skip_loop_sleep: True 时主循环不再 sleep (fetch_fn 内部已自限速, 如 dividend)。
返回 (stats, circuit_triggered)。
"""
# --codes 过滤 (无前缀, 按代码开头猜交易所, 与 15min 模板一致)
if args.codes:
todo_codes = [
(c.strip(), guess_exchange_by_code(c.strip()))
for c in args.codes.split(",") if c.strip()
]
else:
todo_codes = list(all_codes)
# --limit 截断
if args.limit > 0:
todo_codes = todo_codes[:args.limit]
logger.info("[%s] limit=%d 截断", data_type, args.limit)
# marker 断点续传
done_set = load_done_set_per_stock(data_type, suffix)
if args.force:
todo = todo_codes
else:
todo = [(c, e) for c, e in todo_codes if (c, e) not in done_set]
logger.info("[%s] 待处理: %d (已完成 %d)", data_type, len(todo), len(done_set))
stats = {"ok": 0, "skipped": 0, "empty": 0, "failed": 0, "rows": 0}
consec_fail = 0
circuit_triggered = False
t_start = time.time()
for i, (code, exc) in enumerate(todo):
# 定期重登保持连接
if i > 0 and i % RELOGIN_EVERY == 0:
logger.info("[%s] 定期重登 @ %d/%d", data_type, i, len(todo))
if not _relogin():
logger.warning("[%s] 定期重登失败, 继续 (单次失败不致命)", data_type)
try:
status, rows = download_one_per_stock(
code, exc, data_type, suffix, fetch_fn, args.force,
)
except Exception as e:
status, rows = "failed", 0
logger.debug("[%s] %s.%s 异常: %s", data_type, code, exc, e)
stats[status] = stats.get(status, 0) + 1
if status == "ok":
stats["rows"] += rows
consec_fail = 0
elif status == "failed":
consec_fail += 1
# skipped / empty 中性: 不重置也不递增
# 断路器: 连续 N 只全 failed → baostock 疑似不可达, 保存进度主动退出
if consec_fail >= CIRCUIT_BREAKER:
logger.error(
"[%s] [FATAL] 断路器触发: 连续 %d 只失败, baostock 疑似不可达, "
"退出 (done_set 不含 failed 票, 复跑会重试)",
data_type, consec_fail,
)
circuit_triggered = True
break
# 进度日志
if (i + 1) % PROGRESS_LOG_EVERY == 0:
elapsed = time.time() - t_start
logger.info(
"[%s] 进度 %d/%d ok=%d skipped=%d empty=%d failed=%d rows=%d (%.0f秒)",
data_type, i + 1, len(todo), stats["ok"], stats["skipped"],
stats["empty"], stats["failed"], stats["rows"], elapsed,
)
# 限速 (dividend 内部已 sleep, 跳过)
if not skip_loop_sleep and i < len(todo) - 1:
time.sleep(BS_INTERVAL)
elapsed = time.time() - t_start
logger.info(
"[%s] 完成, 耗时 %.1f 秒, 统计: %s",
data_type, elapsed, json.dumps(stats, ensure_ascii=False),
)
return stats, circuit_triggered
# ======================== CLI / main ========================
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="BaoStock 静态数据下载 (basic / adjust_factor / dividend)",
)
p.add_argument(
"--types", default=",".join(VALID_TYPES),
help=f"逗号分隔类型, 默认全部 ({','.join(VALID_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 (basic 不受影响)",
)
p.add_argument(
"--limit", type=int, default=0,
help="限制处理股票数, 测试用 (basic 不受影响)",
)
p.add_argument("--force", action="store_true", help="强制重下, 忽略 marker")
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 VALID_TYPES]
if bad:
raise SystemExit(f"未知 --types: {bad}, 可选 {list(VALID_TYPES)}")
if not parts:
return list(VALID_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 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("BaoStock 静态数据下载 (basic / adjust_factor / dividend)")
logger.info(" 输出目录: %s", OUT_DIR)
logger.info(" 日志文件: %s", LOG_FILE)
logger.info(" 日期范围: %s ~ %s", start_date, end_date)
logger.info(" 分红年份: %d..%d", *DIVIDEND_YEAR_RANGE)
logger.info(" 类型: %s", types)
logger.info(" socket.setdefaulttimeout(30) (SIGALRM 替代)")
logger.info(" 当前时间: %s", datetime.datetime.now().isoformat())
logger.info("=" * 60)
# 登录
if not _login_once():
logger.error("[FATAL] baostock 登录失败 (疑似冷却), 退出")
sys.exit(1)
logger.info("baostock 登录成功")
all_codes: Optional[List[Tuple[str, str]]] = None
any_circuit = False
try:
for t in types:
logger.info("-" * 50)
logger.info(">>> 类型: %s", t)
if t == DIR_BASIC:
status, rows = download_basic(args.force)
logger.info("[basic] status=%s rows=%d", status, rows)
if status == "failed":
any_circuit = True
logger.error("[basic] 失败, 跳过后续类型")
break
continue
# per-stock 类型: 需股票列表 (同进程内缓存, 不重复拉)
if all_codes is None:
try:
all_codes = fetch_all_stocks()
except Exception as e:
logger.error("[FATAL] 获取股票列表失败: %s", e)
any_circuit = True
break
logger.info("全市场 A 股 (含退市): %d", len(all_codes))
if t == DIR_ADJUST:
fetch_fn = partial(fetch_adjust_factor,
start_date=start_date, end_date=end_date)
_, circuit = run_per_stock_type(
DIR_ADJUST, SUFFIX_ADJUST, fetch_fn,
all_codes, args, skip_loop_sleep=False,
)
elif t == DIR_DIVIDEND:
# fetch_dividend 内部已对每年 query sleep, 主循环跳过 sleep
fetch_fn = partial(fetch_dividend,
year_range=DIVIDEND_YEAR_RANGE)
_, circuit = run_per_stock_type(
DIR_DIVIDEND, SUFFIX_DIVIDEND, fetch_fn,
all_codes, args, skip_loop_sleep=True,
)
else:
logger.error("未知类型 (跳过): %s", t)
continue
if circuit:
any_circuit = True
logger.error("[%s] 断路器触发, 跳过后续类型", t)
break
finally:
try:
bs.logout()
except Exception:
pass
if any_circuit:
sys.exit(2)
sys.exit(0)
if __name__ == "__main__":
main()