774170ec05
采集层(多源各下): - 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设计
796 lines
29 KiB
Python
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()
|