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设计
This commit is contained in:
2026-07-22 10:34:22 +08:00
parent c4042a50d1
commit 774170ec05
36 changed files with 7762 additions and 3 deletions
+82
View File
@@ -0,0 +1,82 @@
# LocalParquetProvider V1 数据缺口记录
> V1 已通过 VPS 真实数据验证(2026-07-21):
> fundamentals 字段值合理(茅台市值 18433亿/PE 23.6/ROE 0.19/净利率 0.51)、
> get_price/get_index_stocks/trade_days/all_securities 全通、B_mean 趋势信号正常、
> 回测出完整 JSON(117 交易日, 0.4s/月, 无 baostock 卡死)。
>
> **0 交易根因(非 provider bug)**: `_pick_big_universe` 选股 target=[]
> = `big` filter 8 条件 AND 过严 + `roic_big` 用 roic(V1 NaN) + bm market_cap 100-900亿
> 不匹配 hs300 大盘 + B_mean<0 时兜底海外 ETF(无 K 线)。补 roic + 调 filter 阈值即出交易。
>
> 以下缺口不阻塞 MVP 链路验证,但全市场正式回测前需补齐。
## 缺口 1: 历史成分股(治幸存者偏差,重要 ⚠️)
**现状**: VPS `static/index_const/index_const.parquet` 仅 **2026-07-17 最新一期**快照。
`get_index_stocks(index, date)``date` 参数当前被忽略(无历史数据可读)。
**影响**: 回测 2020 年选股池 = "现在还在 hs300/zz500 里的股票" → 幸存者偏差(结果虚高)。
`max_pool` 小范围验证影响相对小(只取前 N 只),**全市场轮动回测前必须补**。
**补齐方案**(任选,不用 baostock online):
- akshare `index_stock_cons_csindex(symbol="000300")` 按调仓日拉历史成分(csindex 源)
- 中证指数官网 csindex.com.cn 历史成分下载
- 用户侧(数据补全 session)补到 `static/index_const_history/` 多期 parquet, provider 加日期过滤
## 缺口 2: gross_profit_margin(V1 NaN)
**现状**: akshare income 表无明确"营业成本(COGS)"列(有 OPERATE_INCOME 营收、OPERATE_EXPENSE 营业总成本,但非纯 COGS)。
V1 `gross_profit_margin` 置 NaN,策略 filter 该阈值失效(不过滤毛利率)。
**补齐方案**: 从 `static/financial_abstract/{code}_*.parquet` 读现成"销售毛利率"
(宽表 指标×季度,含 1990-2026)。解析:找指标行"销售毛利率",取最新季度列。
## 缺口 3: roic(V1 NaN)
**现状**: ROIC = NOPAT / (权益 + 有息负债 - 现金),需有息负债拆分。
V1 置 NaN。balance 表有 BORROW_FUND/BOND_PAYABLE 等字段可算。
**补齐方案**: balance 读 BORROW_FUND(短期借款) + BOND_PAYABLE(应付债券) + SUBBOND_PAYABLE
+ 现金(CASH_DEPOSIT_PBC 附近字段),算 roic。NOPAT = 营业利润 ×(1 - 税率)。
## V1 单位口径备忘(VPS 实测验证合理 ✅)
| 字段 | VPS 源单位 | 转换 | 验证值(2024-06) |
|---|---|---|---|
| market_cap | 总市值(元) | /1e8 转亿 | 茅台 18433 亿 ✅ |
| circulating_market_cap | 流通市值(元) | /1e8 | ✅ |
| pe_ratio | PE(TTM) 数值 | 直接 | 茅台 23.6 ✅ |
| pb_ratio | 市净率 数值 | 直接 | 茅台 7.69 ✅ |
| ps_ratio/pcf_ratio | 市销率/市现率 | 直接 | ✅ |
| eps | BASIC_EPS 元 | 直接 | 茅台 33.19 ✅ |
| roe | 归母净利润/归母权益 | 小数(单期非TTM) | 茅台 0.19 ✅ |
| roa | 净利润/总资产 | 小数 | ✅ |
| net_profit_margin | 归母净利润/营收 | 小数 | 茅台 0.51 ✅ |
| inc_revenue_yoy | OPERATE_INCOME_YOY 百分数 | /100 | 茅台 +0.18 ✅ |
| total_liability | TOTAL_LIABILITIES 元 | /1e8 | 浦发 85000 亿 ✅ |
| total_sheet_owner_equities | TOTAL_PARENT_EQUITY 元 | /1e8 | ✅ |
| retained_profit | SURPLUS_RESERVE+UNASSIGN_RPOFIT | /1e8 | ✅ |
## VPS 两种代码格式(已适配,备忘)
VPS `data/` 下代码格式**不统一**:
- **K 线** `qfq/{年}/` `raw/{年}/`: baostock 风格 `sh600000_daily.parquet`(sh/sz 前缀无点)
`jq_to_kline_code("600000.XSHG") = "sh600000"`
- **三表/估值** `static/{table}/`: jq 后缀 `000001.SZ_balance.parquet`
`jq_to_file_code("000001.XSHE") = "000001.SZ"`
`get_price``jq_to_kline_code`,`get_fundamentals_df``jq_to_file_code`
## V1 已验证可用的接口
| 方法 | 状态 | 备注 |
|---|---|---|
| get_price | ✅ | qfq 日线, 单股 index=date / 多股 panel |
| get_fundamentals_df | ✅ | 19 列对齐 _FUNDAMENTAL_COLUMNS, 字段值合理 |
| get_security_info | ✅ | valuation 最新行 |
| get_trade_days | ✅ | sh600000 K 线 date 列 |
| get_all_securities | ✅ | 5528 股 |
| get_index_stocks | ⚠️ | 仅当前快照(缺口 1) |
| get_current_tick | ✅ | valuation 推算 close + 涨跌停(主板±10%) |
| get_split_dividend | ✅ | 占位返空(qfq 已复权) |
+105
View File
@@ -0,0 +1,105 @@
# A股静态数据全量缓存到 VPS — 设计与采集计划
> 2026-07-19 立。目标:全市场 A股静态/基本面/参考数据全量缓存到 VPS 本地(parquet),作选股(基本面)与回测数据源。
## 原则(用户钦定)
1. **尽量多缓存**——能下的全下,避免限流/网络依赖。
2. **串行可,等待长可接受**——不追求并发速度,稳定性优先。
3. **准确性第一**——下错不如不下,每类数据必须验证。
4. **建立每日自动更新**——历史一次灌满 + 每日增量。
5. 用途:基本面选股 + 回测。盘中实时当日数据(实盘)未来再做。
## 为什么缓存优于实时取(背景)
历史静态数据(含"日频"的历史部分)永不改变。本地缓存:秒级读盘/零网络依赖/可复现快照/不触发限流。实时取历史:慢/不稳/不可复现/反复触发封 IP。**唯一非静态是"今天未收盘/未公布"的部分,日终收盘后即变静态。**
## 范围(全量 ~2GB)
| 组 | 类别 | 频率 | 源 | 估算 |
|---|---|---|---|---|
| A 基础元数据 | 基础信息(代码/名称/交易所/板块/上市退市/状态) | 静态 | baostock | 1MB |
| | 行业分类(申万/中信/概念) | 静态 | akshare | 30MB |
| | 指数成分+权重(300/500/1000/国证2000) | 月 | akshare | 50MB |
| B 财务 | 三大报表(资产/利润/现金流) | 季 | baostock | 150MB |
| | 季频衍生指标(ROE/EPS/毛利率/净利增速/负债率/杜邦) | 季 | baostock | 200MB |
| | 业绩预告/快报 | 季事件 | akshare | 30MB |
| C 股本/公司行为 | 股本结构变动 | 事件 | akshare | 50MB |
| | 十大股东+十大流通股东 | 季 | akshare | 250MB |
| | 分红送转配股 | 事件 | baostock | 40MB |
| | 限售解禁 | 事件 | akshare | 20MB |
| D 估值/复权(日频) | 估值快照(PE/PB/PS/PF/股息率/市值/流通市值) | 日 | akshare | 300MB |
| | 复权因子(qfq/hfq) | 日 | baostock | 150MB |
| E 市场参考(日频,可选) | 龙虎榜/大宗交易/融资融券/北向/ST停复牌 | 日 | akshare | ~360MB |
## 存储格式与目录
**parquet(每类一个目录)**,VPS `C:\sanguo_vnpy_v2\data\static\<type>\`。匹配现有 15min/daily parquet 模式,回测 pandas 直读,增量 append/overwrite 幂等。
```
data/static/
basic/ 基础信息(全量刷新,1文件 or per-stock)
industry/ 行业分类
index_const/ 指数成分
balance/ 资产负债表(per-stock parquet)
income/ 利润表
cashflow/ 现金流量表
indicator/ 季频财务指标(ROE/EPS/...)
forecast/ 业绩预告/快报
share_capital/ 股本结构
top_holders/ 十大股东
dividend/ 分红送转
lockup_release/ 限售解禁
valuation/ 估值日频(PE/PB/市值)
adjust_factor/ 复权因子
dragon_tiger/ 龙虎榜
block_trade/ 大宗交易
margin/ 融资融券
northbound/ 北向资金
```
## 数据源映射 + 串行约束(关键)
| 源 | 数据 | 并发约束 |
|---|---|---|
| **baostock** | 基础信息/复权因子/分红/季频指标/三表 | **单登录串行,跟15min共用登录→必须等15min跑完才能开**(并发=IP封6-24h) |
| **akshare** | 估值/龙虎榜/大宗/融资融券/北向/指数成分/行业/股本/十大股东/解禁/业绩预告 | 不同源,可与baostock错峰;东财源要限速防反爬 |
## 准确性协议(每类数据强制)
1. **断点续传 marker + 失败/空数据区分**(复用 15min 的 empty-vs-failed 修复)
2. **下载后抽样验证(≥10只)**:字段完整 / 日期覆盖(回溯到2020) / 值合理性(PE>0、ROE合理区间、volume≥0、OHLC 自洽)
3. **行数 + 覆盖率统计**写入日志
4. **幂等写入**(per-stock parquet overwrite;INSERT OR REPLACE 若入 DB),staging 隔离→验证→合并(用户铁律:绝不直写主库/主目录未验)
5. (可选)跨源抽检:baostock 季频财务 vs miniQMT PershareIndex 抽几只对一对
## 每日自动更新机制
Windows schtask `sanguo-static-daily`,每日盘后 **16:30**`daily_update_static.py`:
- **日频类(估值/龙虎榜/大宗/融资融券/北向)**:追加当日(或近N日补漏)
- **小表全量刷新**:基础信息/行业/指数成分/分红(事件少,全量省得算增量)
- **财报季(4/8/10月底后)**:追加新季报(三表/季频/十大股东)
- **复权因子**:每日刷新(除权事件会改累计因子)
- 失败告警 + 断点续传 + **绝不破坏既有数据**(只 append/replace 单日单股)
- 串行 baostock 部分 + 限速 akshare 部分,单进程跑完
## 执行阶段
- **Phase 0(进行中)**:15min baostock,ETA 07-19 ~22:30
- **Phase 1**:baostock 静态下载脚本(基础/复权/分红/季频/三表)— **构建 now,运行须等15min完**
- **Phase 2**:akshare 静态下载脚本(估值/龙虎榜/大宗/融资融券/北向/指数成分/行业/股本/十大股东/解禁/业绩预告)— **构建+可now起**(不同源)
- **Phase 3**:每类抽样验证 → 修问题
- **Phase 4**:每日更新 schtask + 验证增量
## akshare 调查结果(2026-07-19 确认,15类端点实证)
**15类中 11 类端点直接可用,4 类有替代。财务类全部"单股一次拉全历史"(完美绕开 baostock per-quarter 百万调用)。全量 ~2.5-3GB,单线程 17-22h(可挂机,不同源可与 baostock 并行)。**
确认端点(4种模式):
- **per-stock(5500股×1调用)**:估值`stock_value_em` / 北向`stock_hsgt_individual_em` / 股本`stock_share_change_cninfo` / 十大流通`stock_gdfx_free_top_10_em`(×20报告期) / **三大报表`stock_balance/profit/cash_flow_sheet_by_report_em`(319/203/254列全历史)** / 财务摘要`stock_financial_abstract`
- **per-date(交易日×1调用)**:龙虎榜`stock_lhb_detail_em` / 大宗`stock_dzjy_mrmx(symbol="A股")` / 融资融券沪`stock_margin_detail_sse` / 解禁`stock_restricted_release_detail_em`
- **per-period(报告期×1调用)**:业绩预告`stock_yjyg_em` / 业绩快报`stock_yjkb_em`
- **one-shot**:指数成分`index_stock_cons_csindex`(300/500/1000) / 行业`sw_index_first_info`(申万,东财`stock_board_industry_name_em`ConnectionError 弃用)
**有问题/替代**:`stock_margin_detail_szse`(深融资融券)超时频繁→先跳过;`stock_gdfx_holding_detail_em(date)`按日全市场超时→改个股循环;`stock_a_indicator_lg`新版删→用`stock_value_em`
**大小明细**:估值150MB / 三大报表1.2GB / 财务摘要300MB / 北向120MB / 融资融券500MB / 十大流通60MB / 其余<100MB各。**总~2.5-3GB**。
**优先级**:P0 三大报表+财务摘要(~1.5GB,~10h,核心财务)→ P1 估值+北向+融资融券(~770MB,~3h)→ P2 龙虎榜/大宗/解禁/业绩预告/股本/指数/行业(~300MB,~1h)。
## 部署架构(自愈链,2026-07-19)
- **15min baostock**:schtask `sanguo-bs15min` + 自愈.bat(ping-sleep 30min重试)。2026-07-19 12:00 baostock全球故障(Mac+VPS同挂10002007),自愈中,恢复即续 from marker 2847。
- **baostock静态**:schtask `sanguo-bs-static` + 自愈.bat(**wait15**等15min "ALL DONE" → 自动接力 → ping-sleep自愈)。脚本`baostock_static_download.py`已部署(basic/adjust_factor/dividend,rs.fields动态取字段)。
- **akshare静态**:schtask `sanguo-bs-akshare`(待建)+ 自愈.bat。脚本构建中。**不同源,可与baostock并行**。
- 监控:cron 779cbb71 每30min probe_all + 异常自修 + 完成报告。caffeinate防睡眠。
- **.bat sleep 用 `ping -n N 127.0.0.1`**(timeout.exe在SYSTEM schtask下失效,见 memory schtasks-system-bat-gotchas)。
+46
View File
@@ -0,0 +1,46 @@
# 静态数据 3 个真缺口 — 补充设计(2026-07-19 记录,待当前下载完再动手)
> 当前下载(15min Mac补全 + akshare static + baostock static)跑完后再继续本设计。先记录存底。
## 数据现状实测(VPS quant_trading.db + data/ 目录,非推理)
- **DB dbbardata**: 15m(2025-07~2026-07,1年,xt_tacitdata源)/5m(1年)/**d日线(2010~2026,16年,5205 symbols,OHLCV+amount)**。dbbardata schema 有 turnover(=成交额amount),**无换手率/涨跌幅列**。
- **parquet data/raw + data/qfq**: 各 59816,O H L C V 6列,16年(xtdata建,build_daily_from_xtdata)。
- **data/static/**: akshare balance 跑着(4300+ parquet)。
- **实测缺口**:
1. 日线**缺换手率+涨跌幅**(amount已在DB 16年)
2. **ETF不在daily universe**(5205 symbols大概率纯股票,518880等海外ETF缺)
3. **指数成分历史(含被踢)完全无**(全项目无脚本,akshare只当前快照)
## 3 缺口设计(派生方案,避开数据混乱+接口限流)
### 缺口1:日线换手/涨跌 → 派生,不新下载
- **pct_chg** = (close今 - close昨)/close昨,**从 qfq close 算**(16年,避免除权跳空)。
- **换手率** = volume / 流通股本。volume在DB;**流通股本在 akshare valuation(stock_value_em,排队P1,8.5年)**。
- amount:DB已有(16年)。
- **不开 daily_extra 新目录**,读取层派生 或 DB加列 → 不加剧"四套口径分裂" + **零新东财负载**
### 缺口2:ETF日线 → 加进现有 xtdata universe
- ETF清单:海外 518880(黄金)/513100(纳指)/513030(德国)/164824(石油)/159866(有色) + 主 510300/510500/159915等,maintain成config。
- 加进 `build_daily_from_xtdata` 的 universe → 走现有 xtdata 本地管线(miniQMT),**不碰东财,无限流**。
### 缺口3:指数成分历史(含被踢) → csindex 抓取,先 spike
- 范围:hs300(000300)/zz500(000905)/zz50(000016)/中小综指(399101)/创业板指(399006)。
- **先派 agent spike 调研源**:akshare 有无历史成分API(index_stock_cons_weight_csindex带日期?fund_portfolio_hold_em?)、csindex.cn 历史成分xlsx URL规律+反爬、深证399101/399006 巨潮/szse 源。
- **B档(务实,先行)**:抓全部历史调仓成分→并集(曾经入选集),消灭幸存者偏差。output `data/index_const_hist/<indexcode>.parquet`
- A档(精确,后做):时间序列(指数,生效日,成分,加/剔)。
- csindex 独立源,串行单线程抓,**限流风险低**。
## 风险结论(为何用派生方案)
- **原设计(akshare daily_extra)** 有双风险:① 数据混乱——日线口径第四处(DB/parquet raw/parquet qfq/daily_extra),回测不知读哪;② 接口限流——daily_fields_akshare 并发 akshare_static = 第二股东财流量→东财封(同 baostock 黑名单原理)。
- **派生方案**:缺口1 两风险全消(不下载/不开新目录);ETF走xtdata无限流;csindex独立源串行低风险。
- 唯一仍调外部API:缺口3(csindex)+ 已排队的 akshare static 本身——保持串行+限速,不新增并发。
## 决策与顺序(下载完后)
1. **派生换手/涨跌**(读取层工具 或 DB加列)——缺口1
2. **ETF 入 xtdata daily universe**——缺口2
3. **csindex spike 调研** → 定 B档抓取脚本——缺口3
4. (远期)A档精确成分时间序列
## 关联
- 主计划:`docs/static_data_cache_plan.md`
- 现状memory:`baostock-15min-vps-deploy-plan` / `db-primary-parquet-fallback` / `data-download-architecture`
@@ -0,0 +1,221 @@
# P0 数据补全实现计划(历史成份股 + ETF 全市场 + 退市 K 线)
> **For agentic workers:** 用 superpowers:subagent-driven-development 或 executing-plans 执行。Steps 用 `[ ]` 跟踪。
**Goal:** 补齐治幸存者偏差 + 策略核心缺口三类数据,落到 VPS 本地。
**Architecture:** 各源采集脚本 → staging parquet → 验证探针 → 合并主库;baostock 单登录守 48000/天;dbbardata 不动。
**Tech Stack:** python3.10 / akshare / baostock / xtquant(xtdata)/ pandas / pyarrow / sqlite3
---
## Global Constraints(所有 task 隐含)
- **baostock 单进程单登录**,不并发(防黑名单,日 ≤48000 query)
- **直连不走代理**:`$env:http_proxy=''; $env:https_proxy=''; $env:all_proxy=''`
- **dbbardata 不破坏**:只 INSERT OR REPLACE `daily_baostock_full` / 新表,不动 dbbardata 既有行
- **优先 baostock + miniQMT(xtdata)**
- **staging → 验证探针 → 合并主库**(用户铁律,不直接写主库)
- Windows VPS 49.232.102.198,`C:\Python310\python.exe -X utf8`,schtasks `/ru SYSTEM`
- 输出根:`C:\sanguo_vnpy_v2\data\`
---
## File Structure
| 文件 | 责任 |
|---|---|
| `scripts/data_platform/index_const_hist_download.py`(新) | 历史成份股采集(akshare 国证 + 新浪 + baostock 补时点) |
| `scripts/data_platform/build_daily_from_xtdata.py`(改 :40) | ETF universe 扩展(一次性全量) |
| `scripts/data_platform/daily_update_xtdata.py`(改 :114) | ETF 每日增量 universe |
| `scripts/data_platform/baostock_delisted_download.py`(新) | 退市股列表 + K 线采集 |
| `scripts/data_platform/import_delisted_to_db.py`(新) | 退市 K 线灌 `daily_baostock_full` |
| 各 `*_wrapper.ps1` + schtask | 部署 |
---
## Task 1: 历史成份股采集(治幸存者偏差)
**Files:** Create `scripts/data_platform/index_const_hist_download.py`;Output `data/index_const_hist/{code}.parquet`
**Interfaces:**
- Consumes: akshare `index_detail_hist_cni(symbol)` + `index_detail_hist_adjust_cni(symbol)`(国证源);新浪 `vII_HistoryComponent`(pandas.read_html, gb2312);baostock `query_hs300/zz500/sz50_stocks(date)`
- Produces: `data/index_const_hist/{code}.parquet`(列:`updateDate/index_code/code/code_name/adjust_type`);并集 = 曾经入选集
**指数清单:**
- 深证/国证(akshare 国证源):399001 / 399006 / 399101 / 399005 / 399330
- 中证(新浪):000852(中证1000)/ 932000(中证2000)/ 000300(交叉校验)/ 000016(上证50)
- baostock 已有(300/500/50 在 `bs_index_constituent`):Task1 补时点序列到同 schema
- [ ] **1.1 探针:akshare 国证源 hist 版**
```python
import akshare as ak
df = ak.index_detail_hist_cni(symbol="399101") # 历史样本(日期/样本代码/权重)
print(df.columns.tolist(), len(df), df.head(3))
adj = ak.index_detail_hist_adjust_cni(symbol="399101") # 调样记录(调整类型 OLD/+/-)
print(adj.columns.tolist(), len(adj))
```
预期:hist 有日期+样本+权重;adjust 有调整类型。**陷阱:必须 hist 版**(`index_detail_cni` 非 hist 版 2025-11-25 起只近期);`ak.index_stock_hist` 已下线别用。
- [ ] **1.2 探针:新浪中证历史成份**
```python
import pandas as pd
url = "http://vip.stock.finance.sina.com.cn/corp/go.php/vII_HistoryComponent/indexid/000852.phtml"
df = pd.read_html(url, encoding="gb2312")[0]
print(df.columns.tolist(), len(df), df.head(3))
```
预期:品种代码/品种名称/纳入日期/剔除日期(空=至今在列),含 *ST/退市股。
- [ ] **1.3 实现 `index_const_hist_download.py`**:三路采集 → 统一 schema(`updateDate/index_code/code/code_name/adjust_type`)→ 写 `data/index_const_hist/{code}.parquet`。串行 `time.sleep(0.8)`(akshare/新浪防封),单进程。环境变量 `BS_INDEX_HIST_OUT_DIR` 覆盖默认 Mac 路径(同 Day1 wrapper 模式)。
- [ ] **1.4 验证探针**:每指数 parquet 行数 + 抽样 3 行;**幸存者偏差校验** = 并集 `distinct code` 数 > 当前成份股数(证明含被踢股,例如 399101 并集 > 958 当前)。
- [ ] **1.5 wrapper + schtask**:`index_const_hist_wrapper.ps1`(设 OUT_DIR + utf8 + unset proxy + log);schtask `sanguo-index-hist` `/sc monthly /mo 2`(半年度调样后,6/12 月)`/ru SYSTEM`
- [ ] **1.6 commit**:`git add scripts/data_platform/index_const_hist_download.py scripts/data_platform/index_const_hist_wrapper.ps1 && git commit -m "feat(data): 历史成份股采集(治幸存者偏差,国证+新浪+baostock)"`
---
## Task 2: ETF 全市场日线
**Files:** Modify `scripts/data_platform/build_daily_from_xtdata.py:40` + `daily_update_xtdata.py:114`
**Interfaces:**
- Consumes: xtdata `get_stock_list_in_sector('沪深A股'/'沪深ETF'/'沪深基金')` + `get_market_data_ex(dividend_type='front')`
- Produces: 全市场 ETF(~1000 只)日线**前复权**,落 parquet/dbbardata(复用现有 xtdata 管线)
- [ ] **2.1 探针:ETF universe + 1 只 K 线**
```python
from xtquant import xtdata as xd
etf = xd.get_stock_list_in_sector('沪深ETF') or []
fund = xd.get_stock_list_in_sector('沪深基金') or []
a = xd.get_stock_list_in_sector('沪深A股') or []
u = list(set(a + etf + fund))
print(f"A={len(a)} ETF={len(etf)} fund={len(fund)} union={len(u)}")
r = xd.get_market_data_ex([], ['510300.SH'], period='1d',
start_time='20240101', end_time='20260721', dividend_type='front')
df = r.get('510300.SH')
print('510300 bars:', 0 if df is None else len(df), '| tail close:', None if df is None else df['close'].iloc[-1])
```
预期:ETF ~1000,union > A 股数;510300 前复权日线有值,close 非 NaN。
- [ ] **2.2 改 universe**:`build_daily_from_xtdata.py:40``daily_update_xtdata.py:114`
```python
u = xd.get_stock_list_in_sector("沪深A股") or []
```
改为
```python
u = list(set(
(xd.get_stock_list_in_sector("沪深A股") or []) +
(xd.get_stock_list_in_sector("沪深ETF") or []) +
(xd.get_stock_list_in_sector("沪深基金") or [])
))
```
保留 `dividend_type='front'`(前复权,§13 默认)。
- [ ] **2.3 全量下载 ETF**:跑改后的 `build_daily_from_xtdata.py`(走现有 xtdata 管线,**无限流**)→ parquet。
- [ ] **2.4 验证**:ETF 数 + 抽样(510300/513050/159919)+ 前复权 close 非 NaN + 日期范围。
- [ ] **2.5 schtask**:复用 `sanguo-daily-update`(universe 扩展后自动含 ETF,无需新 schtask)。
- [ ] **2.6 commit**:`git commit -m "feat(data): ETF 全市场日线(xtdata universe 扩展+前复权)"`
---
## Task 3: 退市股 K 线(反幸存者偏差核心)
**Files:** Create `scripts/data_platform/baostock_delisted_download.py` + `import_delisted_to_db.py`;Output → `daily_baostock_full`
**Interfaces:**
- Consumes: baostock `query_all_stock(day)` + `query_stock_basic(code)`(status + 退市日期)+ `query_history_k_data_plus(code, fields, adjustflag=3)`
- Produces: 退市股 K 线 INSERT OR REPLACE `daily_baostock_full`(18 列,复用 `parse_baostock_code`)
**范围:** 近 5 年退市(退市日期 ≥ 2021;守 48000/天;退市股分天跑)
- [ ] **3.1 探针:退市股列表字段**
```python
import baostock as bs, pandas as pd
bs.login()
rs = bs.query_all_stock(day="2026-07-18")
rows = []
while (rs.error_code == '0') & rs.next():
rows.append(rs.get_row_data())
df = pd.DataFrame(rows, columns=rs.fields)
print('query_all_stock fields:', rs.fields, '| rows:', len(df))
rs2 = bs.query_stock_basic(code="sh.600000")
b = []
while (rs2.error_code == '0') & rs2.next():
b.append(rs2.get_row_data())
print('query_stock_basic fields:', rs2.fields, '| sample:', b[0] if b else None)
bs.logout()
```
预期:`query_stock_basic``type`(1股)/`status`(1上市 0退市)/`outDate`(退市日期)。筛 `status=0 & outDate>='2021-01-01'`
- [ ] **3.2 实现 `baostock_delisted_download.py`**:
- 遍历全 code(或 `query_all_stock` 多日并集)→ `query_stock_basic``status=0 & outDate>='2021-01-01'` → 退市股列表
- 逐只 `query_history_k_data_plus(code, start_date='1990-01-01', end_date=outDate, fields=18字段, adjustflag=3)` → staging `data/delisted_kline/{code}.parquet`
- 单进程单登录,`time.sleep` 守预算,marker 断点续传(复用 Day1 模板),DAILY_LIMIT 计数器
- [ ] **3.3 `import_delisted_to_db.py`**:staging → INSERT OR REPLACE `daily_baostock_full`(复用 `parse_baostock_code` sh.600000→600000+SH + `executemany`,WAL + busy_timeout=60000,同 `import_baostock_to_db.py`)。**dbbardata 不碰**。
- [ ] **3.4 验证探针**:退市股数 + 抽样(某退市股 K 线行数 + max(date) ≤ 退市日)+ `daily_baostock_full` 行数增量 + distinct symbol 增量。
- [ ] **3.5 wrapper + schtask**:`baostock_delisted_wrapper.ps1`;schtask `sanguo-delisted` `/sc monthly /ru SYSTEM`(月度,守 48000,错开 day2b 02:00 + bs-daily-increment 17:00)。
- [ ] **3.6 commit**:`git commit -m "feat(data): 退市股 K 线采集(baostock,反幸存者偏差)"`
---
## Task 4: baostock 日增量 → daily_baostock_full(#7 daily_update_static)
> **串行约束**:本 task 与 Task3 都用 baostock 长会话,**必须串行**(Task3 probe → Task3 执行 → Task4),不可并发(防黑名单)。
**Files:** Create `scripts/data_platform/daily_update_static.py` + `daily_update_static_wrapper.ps1`
**背景:** 现有 `daily_update_xtdata.py` 只产 parquet 不灌 `daily_baostock_full`(已知 gap,memory `db-primary-parquet-fallback` 记录)。本 task 补 baostock 日线的**每日增量灌库**。
**Interfaces:**
- Consumes: baostock `query_stock_basic`(全 A,type=1 含退市,复用 `baostock_daily_fullmarket_download.py:fetch_all_stocks`)+ `query_history_k_data_plus`(LOOKBACK 窗口,adjustflag=3 raw,18 字段同 `BS_FIELDS`)
- Produces: staging `data/daily_baostock_increment/{YYYYMMDD}/{code}.{exc}_daily.parquet`(审计)→ 同进程 INSERT OR REPLACE `daily_baostock_full`(复用 `parse_baostock_code`+executemany+WAL+busy_timeout,同 `import_baostock_to_db.py`)
**设计(LOOKBACK 窗口 + 幂等,不同于全量 marker 模式):**
- **不用 marker 断点续传**(全量才需要;增量每日全量重拉最近 N 天)
- `LOOKBACK_DAYS=7`(覆盖周末/节假日;baostock 日终更新,17:00 跑时当日 bar 已就绪)
- 每只 1 query → 5537 query/run ≪ 48000/天 ✅(留足余量给 day2b/Task3)
- `sleep 0.4s × 5537 ≈ 37min`(17:00 schtask 可接受)
- `QUERY_COUNT` 计数器 + `DAILY_LIMIT=40000` 防御(复用全量脚本模式)
- **一脚本贯通**:download LOOKBACK → staging parquet(审计)→ in-memory df → executemany INSERT OR REPLACE(幂等,重复跑同一天安全,`drop_duplicates keep last` 不需要因 PK+OR REPLACE 天然去重)
**Steps:**
- [ ] **4.1 探针(可选,Day1 已实证 query_history_k_data_plus 可用)**:ssh VPS 跑 1 只近 7 天确认接口 + 当日 bar 就绪
- [ ] **4.2 写 `daily_update_static.py`**:自包含,结构
- `unset proxy` + `socket.setdefaulttimeout(30)`(同全量脚本,baostock 坑)
- `_login_once`/`_relogin`/`fetch_all_stocks`/`fetch_one_daily`/`parse_baostock_code` 复用(可 import 或复制;优先 from `baostock_daily_fullmarket_download import ...`,注意 `QUERY_COUNT` global 需在同进程)
- `LOOKBACK` 窗口:`start=today-7, end=today`
- 主循环:逐只 `fetch_one_daily` → staging parquet → 累积 df → 每 100 只 `executemany INSERT OR REPLACE`(WAL+busy_timeout=60000)
- `QUERY_COUNT`/`DAILY_LIMIT`/断路器/定期重登 复用
- 结束 verify:抽样 3 只 `max(date) ≈ today`、当日新增行数
- 环境变量 `BS_INCREMENT_OUT_DIR`/`DB_PATH` 覆盖默认(同 Day1 wrapper 模式适配 Win)
- [ ] **4.3 小样本**:`--limit 10` 跑 10 只,确认 staging 有行 + DB 抽样 max(date)≈today
- [ ] **4.4 全量跑**:5537 只,守预算
- [ ] **4.5 wrapper + schtask**:`daily_update_static_wrapper.ps1`(unset proxy+utf8+OUT_DIR+log);schtask `sanguo-bs-daily-increment` `/sc daily /st 17:00 /ru SYSTEM`(错开 daily-update 16:30 + day2b 02:00 + Task3 月度)
- [ ] **4.6 commit**:`git commit -m "feat(data): baostock 日增量灌库 daily_update_static(#7 gap 补)"`
---
## Self-Review
- **Spec 覆盖**:Task1→spec §4 成份股行 + §8 P0.1;Task2→§4 ETF 行 + §8 P0.2;Task3→§4 退市行 + §8 P0.3 ✅
- **Placeholder 扫描**:无 TBD/TODO;采集脚本给接口+探针+schema,实现者按骨架写完整(采集脚本完整代码由执行 agent 基于 接口/schema/陷阱 产出)✅
- **类型一致**:`index_const_hist` schema 各源统一;`daily_baostock_full` 18 列复用 `import_baostock_to_db.py``parse_baostock_code`+executemany ✅
- **陷阱纳入**:`ak.index_stock_hist` 下线(1.1 标注)/ csindex SPA 无历史(用国证+新浪)/ 新浪 gb2312(1.2)/ hist 版必须(1.1)✅
---
## Execution Handoff
计划存 `docs/superpowers/plans/2026-07-21-data-fusion-p0.md`。执行方式:
1. **Subagent-Driven**(推荐):每 Task 派 fresh agent + task 间 review
2. **Inline**:本 session 批量执行 + checkpoint
@@ -0,0 +1,243 @@
# A 股多数据源融合层设计
> 日期:2026-07-21 | 基于 brainstorming + 4 源全能力调查(akshare / baostock / miniQMT / csindex)
> 状态:设计草案,待用户评审 → writing-plans
---
## 1. 背景与痛点
多数据源(akshare / baostock / xtdata / miniQMT)各不全,整合时格式有偏差:
- **symbol 格式**:`600519` vs `sh.600000` vs `600519.SH`
- **exchange 命名**:`SSE` vs `SH`
- **复权口径**:raw vs qfq
- **volume 单位**:xtdata÷100 vs 原值
- **价格源间漂移**:同股同日不同源 close 微差
### 用户约束(明确)
- ❌ 不要查询时网络源切换(源变化/限流不可控)
- ✅ VPS 本地一份稳定数据,日常只读本地
- ✅ 网络源只用于「采集时拼凑完整本地」
- ✅ 使用层无感(本地缺才网络兜底,罕见)
- ✅ 优先 miniQMT + baostock
---
## 2. 设计原则(三层)
| 层 | 职责 | 原则 |
|---|---|---|
| **采集层** | 多网络源 → 拼凑完整本地 | 各源 adapter + 定时 schtask;源不可控隔离在采集(失败重试,不影响使用层) |
| **数据层** | 整理(重叠定权威,特定保留) | **不强合物理表**(vnpy 回归风险);每类定权威源(优先 baostock+miniqmt) |
| **使用层** | `LocalUnifiedProvider` 逻辑融合 | 读权威表 + 归一化 + 本地缺网络兜底;策略无感 |
**核心**:网络源不稳的风险只影响采集层(定时跑、可重试),使用层永远读本地 —— 风险隔离。
---
## 3. 数据源全能力盘点(综合调查)
### 3.1 已下(稳定源)
| 表/源 | 内容 | 范围 | 增量 |
|---|---|---|---|
| `dbbardata` | 日线+5m/15m(xtdata/akshare/baostock) | 日线2010+/分钟2020+ | ✅ 16:30 |
| `daily_baostock_full` | 日线 18 字段(pe/pb/turn/pctChg) | 19902026 | 待 #7 |
| `bs_index_constituent` | 成份股 300/500/50(含退市) | 2006+ | 待增量 |
| `bs_adjust_factor` | 复权因子 | 全史 | 待增量 |
| akshare 静态表 | 三表/估值/北向/龙虎榜/融资融券/股本/解禁/业绩预告 | - | 部分增量 |
| miniQMT xtdata | 日线+5m/15m 全周期(实时 T+0,零漂移) | 16年 | 按需 |
| miniQMT PershareIndex | ROE/毛利率/EPS | 季频 | 按需 |
| parquet | data/raw、daily_baostock、minute_5/15、static/index_const、qfq+raw ETF(5只) | - | 部分 |
### 3.2 重叠(5 处)
1. 日线 OHLCV:dbbardata ∩ daily_baostock_full ∩ parquet raw(三处)
2. 估值 PE/PB:akshare valuation ∩ daily_baostock_full(peTTM/pbMRQ)
3. 15min:dbbardata ∩ parquet minute_15
4. 基本面:akshare 三表 ∩ miniQMT PershareIndex ∩ baostock 季频
5. 成份股:bs_index_constituent ∩ parquet index_const
### 3.3 新发现缺口(本次调查)
- **ETF 全市场日线**(现仅 5 只,策略资产类别缺口)
- **深证/中证历史成份股**(治幸存者偏差,当前仅最新快照 = latent bug)
- **退市股 K 线**(baostock 有,从未提取;反幸存者偏差核心)
- 申万行业 SW1/2/3 + 历史变动
- 龙虎榜 / 合约信息(涨跌停/ST)/ 可转债 / 研报一致预期 / 股东户数 / 除权明细
---
## 4. 权威源地图(数据层整理)
| 数据类 | 权威源 | 物理存储 | 重叠处理 / 备注 |
|---|---|---|---|
| 日线 OHLCV(个股,历史) | **baostock** | `daily_baostock_full` | dbbardata 日线保留(vnpy 回测硬依赖);parquet raw 冗余 |
| 日线(盘中实时) | **miniQMT xtdata** | xtdata API | 独有(T+0 实时) |
| **日线 ETF(全市场)** | **miniQMT xtdata** | parquet/dbbardata | universe 加 `沪深ETF∪沪深基金`,`dividend_type='front'` 自动复权 |
| 估值 PE/PB/turn | **baostock** | `daily_baostock_full` | akshare valuation 兜底/校验 |
| 基本面指标 ROE/毛利率 | **miniQMT PershareIndex** | miniQMT API | akshare 三表补原始报表 |
| 基本面三表(原始) | akshare | akshare 表 | miniQMT Balance/Income/CashFlow + baostock 季频交叉 |
| 15min | **baostock** | `dbbardata` | parquet minute_15 冗余可清 |
| 成份股 300/500/50(含退市) | **baostock** | `bs_index_constituent` | `query_*_stocks(date)` 任意时点 |
| **成份股 深证/国证(399xxx)** | **akshare(国证源)** | parquet | `index_detail_hist_cni` + `index_detail_hist_adjust_cni` |
| **成份股 中证1000/2000(000852/932000)** | **新浪** | parquet | `vII_HistoryComponent`(gb2312,含退市) |
| **退市股 K 线** | **baostock** | `daily_baostock_full` | `query_all_stock` status=0 + `query_stock_basic` 退市日期 + 逐只 K |
| 复权因子 | **baostock** | `bs_adjust_factor` | 独有 |
| 申万行业 SW1/2/3 | **miniQMT xtdata** | parquet | `get_sector_list` + `get_stock_list_in_sector`;akshare 补历史变动 |
| 龙虎榜 | **miniQMT xtdata** | parquet | `get_longhubang`;akshare 兜底 |
| 合约信息(涨跌停/ST/上市日) | **miniQMT xtdata** | parquet | `get_instrument_detail` 全 A 一入库 |
| 可转债 | akshare | parquet | `bond_zh_hs_cov_min` + `bond_cb_adj_logs_jsl`(转股价) |
| 研报/一致预期 EPS | akshare | parquet | `stock_research_info_em` |
| 股东户数 | miniQMT/akshare | parquet | `Holdernum` 表 / `stock_zh_a_gdhs_detail` |
| 除权明细 | miniQMT xtdata | parquet | `get_divid_factors` |
| 龙虎榜/北向/融资融券/解禁 | akshare | akshare 表 | 独有(保留) |
---
## 5. 归一化规则
| 维度 | 统一标准 | 源映射 |
|---|---|---|
| symbol | `600519.SH`(数字+交易所后缀) | baostock `sh.600000``600000.SH`;dbbardata 纯数字+exchange 字段 |
| exchange | `SH`/`SZ` | dbbardata `SSE`/`SZSE``SH`/`SZ` |
| 日期 | ISO `2026-07-21` | 各源统一 |
| 复权 | raw 存储 + `factor` 字段(QLib:`factor=adj/raw`) | 查询时按需 qfq(`$close/$factor`);治 raw/qfq 冲突 |
| volume | 原值(股) | xtdata ÷100 还原 |
| 停牌 | OHLCV 全 NaN | QLib 约定 |
| 溯源 | `source` 字段 | 每行标来源 + 主源/补丁标记 |
---
## 6. 使用层:`LocalUnifiedProvider`(逻辑融合)
**接口**:
```python
get_daily(symbol, start, end, adjust='raw') # 个股日线
get_etf_daily(symbol, ...) # ETF 日线
get_fundamentals(symbol, fields, date) # 财务指标/三表
get_constituent(index, date) # 成份股(含历史,治幸存者偏差)
get_industry(symbol, date) # 申万行业(含历史变动)
get_longhubang(symbol, start, end) # 龙虎榜
get_instrument_detail(symbol) # 涨跌停/ST
get_delisted_kline(...) # 退市股 K 线
```
**职责**:
- 按数据类路由到权威表(§4 地图)
- 归一化(§5 规则):源格式 → 统一 vt_symbol/exchange/复权/单位
- 本地缺 → `network_fetcher` 透明兜底(罕见,如新股未及采集)→ 写本地 → 返回
- `source` 字段溯源;可选多源交叉校验
- 使用层 API 不变,不知数据来自哪个源
**实现**:扩展现有 `sanguo_portfolio/providers/``DataProvider` 接口(BaostockProvider/LocalParquetProvider 已有)。
---
## 7. 增量 schtask 清单
| schtask | 数据 | 源 | 时间 |
|---|---|---|---|
| `sanguo-daily-update`(已有) | 日线+分钟→dbbardata | xtdata | 16:30 |
| `sanguo-bs-daily-increment`(#7 待建) | 日线→daily_baostock_full | baostock | 17:00 |
| `sanguo-etf-daily-increment`(新) | ETF 全市场日线 | xtdata(universe 沪深ETF) | 17:30 |
| `sanguo-akshare-static-increment`(新) | 估值/龙虎榜/三表/北向 | akshare | 18:00 |
| `sanguo-index-hist`(新,半年度) | 历史成份股调样 | akshare 国证 + 新浪 | 调样后(6/12 月) |
| `sanguo-delisted`(新,月度) | 退市股列表+K 线 | baostock | 月初 |
**query 预算守 48000/天/IP**(baostock 硬限):各 baostock schtask 错开 + 日计数器。
---
## 8. 分阶段实现
### P0 — 治幸存者偏差 + 策略核心缺口
1. **历史成份股**:深证/国证(`index_detail_hist_cni`)+ 中证1000/2000(新浪 `vII_HistoryComponent`)+ 300/500/50(baostock 已有)
2. **ETF 全市场日线**:xtdata universe 改 `沪深A股∪沪深ETF∪沪深基金` + `dividend_type='front'`(改 `build_daily_from_xtdata.py:40` + `daily_update_xtdata.py:114`)
3. **退市股 K 线**:baostock `query_all_stock` 筛 status=0 + `query_stock_basic` 退市日期 + 逐只 K → `daily_baostock_full`
### P1 — 策略增强
4. 申万行业 SW1/2/3(xtdata `get_sector_list`)+ 历史变动
5. 龙虎榜(xtdata `get_longhubang`)
6. 合约信息涨跌停/ST(xtdata `get_instrument_detail`)全 A 入库
7. 可转债(akshare `bond_zh_hs_cov_min` + 转股价调整)
8. 研报/一致预期 EPS(akshare `stock_research_info_em`)
### P2 — 按需
9. 股东户数 / 除权明细 / 业绩快报 / 大宗交易 / 宏观 / 期货
### 融合层(贯穿)
10. 归一化库(vt_symbol/exchange/factor/volume 映射)
11. `LocalUnifiedProvider`(读权威表 + 归一化 + 网络兜底)
12. 完整度监控报表(每类覆盖率/缺口,源退化早发现)
---
## 9. 陷阱清单(实证)
- `ak.index_stock_hist` **已下线**(akshare 1.10.37,2024 初)—— 别抄 2022-23 旧博客
- `ak.index_stock_cons` 的"纳入日期"字段有迷惑性 —— 只是当前 300 只各自最初纳入日,不含被剔除,治不了幸存者偏差
- csindex.com.cn 是 Vue SPA —— `requests.get` 拿空壳,官网只当前 Excel 无历史
- 新浪 `fund_etf_hist_sina` **不复权** —— 不适合回测;ETF 复权走 xtdata `dividend_type='front'`
- 东财 `fund_etf_hist_em` **封 IP** —— 单线程限速或避用
- baostock `query_all_stock` 不列退市日期 —— 配合 `query_stock_basic`
- 申万历史板块有变更 —— 回测用当时分类
- `ak.index_detail_cni`(非 hist 版)2025-11-25 起只近期 —— 必须用 hist 版
---
## 10. YAGNI(不做)
- ❌ 不强合物理表(冲突解决/历史一致性/vnpy 回归风险,代价大)
- ❌ 不引 QLib/OpenBB 框架(几百 MB,只摘模式:factor/source/归一)
- ❌ CS 截面归一(中期按需,先解决不全+格式)
- ❌ 实时 tick/盘口(非日终策略才需)
---
## 11. 风险与对策
| 风险 | 对策 |
|---|---|
| vnpy 回测硬读 dbbardata | 不动 dbbardata,provider 层 SSE↔SH 映射 |
| baostock query 预算 48000/天 | 增量 schtask 错开 + 日计数器(#7+day2b 同天 44296<48000) |
| 东财封 IP | 避用东财,优先 baostock/xtdata/新浪/国证 |
| 接口下线(如 index_stock_hist) | 调查实证,不抄旧文 |
| 数据源漂移/幽灵尖峰 | source 溯源 + 涨跌停/量异常校验 |
| 历史成份股缺口致回测幸存者偏差 | P0 优先补(深证+中证1000/2000+退市) |
---
## 12. 实现路径(writing-plans 拆)
- **Phase 1(P0 数据补全)**:历史成份股 + ETF 全市场 + 退市 K 线(3 个采集脚本 + 灌库)
- **Phase 2(融合层)**:归一化库 + `LocalUnifiedProvider`(读现有+新表)
- **Phase 3(P1 增强)**:板块/龙虎榜/合约/可转债/研报
- **Phase 4(运维)**:增量 schtask 全套 + 完整度监控
---
## 13. 开放问题与默认决策(自行决策,你可推翻)
| # | 问题 | 默认(我定) | 备选 | 理由 |
|---|---|---|---|---|
| 1 | exchange 统一格式 | **SH/SZ**(provider 映射 dbbardata SSE→SH) | 保留 SSE/SZSE | baostock/xtdata 都用 SH/SZ,主流;vnpy SSE 在 provider 层映射 |
| 2 | P0 三项优先级 | **全做**(历史成份股 + ETF + 退市 K 线) | 先 ETF(策略即用) | 三项都治幸存者偏差/核心缺口,并行不冲突 |
| 3 | ETF 复权方式 | **xtdata `dividend_type='front'`**(前复权) | raw + factor(精确还原) | 前复权够策略用;raw+factor 中期按需 |
| 4 | 退市股 K 线范围 | **近 5 年退市**(守 baostock 48000/天预算) | 全退市(几千只,慢) | 近 5 年覆盖绝大多数回测;全量可后补 |
| 5 | `LocalUnifiedProvider` 接口 | §6 签名(get_daily/get_etf/get_fundamentals/get_constituent/get_industry/...) | 精简 | 覆盖全天候 + CTA 需求 |
| 6 | 增量 schtask 时间 | 17:00(baostock 日线)/ 17:30(ETF)/ 18:00(akshare 静态) | 调整 | 错开 sanguo-daily-update 16:30 + day2b 02:00 |
| 7 | 物理表 | **不新建统一表**,provider 读现有(dbbardata/daily_baostock_full/各 parquet) | 建 daily_unified | 避免 vnpy 回归 + 数据迁移(§10 YAGNI) |
**默认推进路径**:按以上默认 → spec 定稿 → 转 writing-plans(P0 拆 3 个采集脚本实现计划)。你审 spec 时可推翻任一项,我改。
---
## 参考(调查来源)
- xtdata 官方:https://dict.thinktrader.net/nativeApi/xtdata.html
- akshare 指数:https://akshare.akfamily.xyz/data/index/index.html
- akshare 基金:https://akshare.akfamily.xyz/data/fund/fund_public.html
- akshare 债券:https://akshare.akfamily.xyz/data/bond/bond.html
- baostock API:https://www.baostock.com/mainContent?file=pythonAPI.md
- 国证指数网(深证历史):http://www.cnindex.com.cn/module/index-detail.html?indexCode=399001
- 新浪历史成份:http://vip.stock.finance.sina.com.cn/corp/go.php/vII_HistoryComponent/indexid/000852.phtml
- QLib 数据层:https://qlib.readthedocs.io/en/latest/component/data.html
- 现有缺口设计:`docs/static_data_gaps_design.md`
@@ -0,0 +1,983 @@
#!/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"
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
def write_parquet_and_marker(
df: pd.DataFrame,
parquet_path: Path,
) -> bool:
"""写 parquet + marker。返 True 成功 / False 失败。"""
try:
parquet_path.parent.mkdir(parents=True, exist_ok=True)
df.to_parquet(parquet_path, index=False)
marker_path_for(parquet_path).write_text(
datetime.datetime.now().isoformat()
)
return True
except Exception as e:
logger.error("写入 %s 失败: %s", parquet_path, e)
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) ========================
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."""
return _df_or_empty(call_ak_with_retry(
ak.stock_gdfx_free_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,
) -> 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'}。
"""
parquet_path = parquet_path_per_unit(data_type, unit_id)
marker_path = marker_path_for(parquet_path)
if not force and marker_path.exists():
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]
else:
todo = [(uid, fn) for uid, fn in units if uid not in done_set]
logger.info(
"[%s] 待处理 %d (已完成 %d, 总 %d)",
data_type, len(todo), len(done_set), len(units),
)
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)
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]]] = []
for code, exc in todo_codes:
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))
logger.info(
"[top_holders] %d× %d 期 = %d units",
len(todo_codes), 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")
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()
@@ -0,0 +1,576 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""BaoStock 15 分钟线全市场历史下载脚本 (Windows VPS, 只产 parquet)
目标:
在 Windows Server 2022 VPS (49.232.102.198) 上用 baostock 全量拉取 A 股
(含退市) 15min K 线历史 (2020-01-01 起, 5.5 年深度, 远超 xtdata 12 个月),
输出 import-ready parquet, 后续用 import_vnpy_minute_fast.py 灌 DB。
与已有 backfill_15min_baostock.py 区别:
- 跑在 Windows (VPS), 不是 NAS
- 只产 parquet, **绝不碰 DB** (下载与灌库解耦, 用户铁律)
- 文件名/列格式匹配 import_vnpy_minute_fast.py 期望, 直接复用 importer
- 不用 SIGALRM (Windows 没有), 改用 socket.setdefaulttimeout(30)
硬约束 (踩过的坑):
1. 单进程单登录, 严禁并发 (baostock 并发会拉黑封 IP 6-24h)
2. 直连不走代理 (脚本开头 unset proxy)
3. 每只 sleep 0.4s 限速
4. baostock login 受 socket.setdefaulttimeout(30) 保护
connect 卡死会抛 socket.timeout, 捕获后走 _relogin 重试
(Windows 无 SIGALRM, 这是跨平台等价方案)
用法:
python baostock_15min_fullmarket_download.py # 全市场 2020-01-01~今天
python baostock_15min_fullmarket_download.py --limit 10 # 测试: 前 10 只
python baostock_15min_fullmarket_download.py --codes 600519,000001 # 指定
python baostock_15min_fullmarket_download.py --force # 强制重下, 忽略 marker
python baostock_15min_fullmarket_download.py --shard 0 --total 4 # 分片
退出码: 0=完成, 1=致命错误 (登录失败/列表拉取失败), 2=断路器触发 (可重试)
"""
import argparse
import datetime
import json
import logging
import os
import socket
import sys
import time
from pathlib import Path
from typing import 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。
# baostock 的 socket 连接会继承这个超时, connect 卡死时抛 socket.timeout,
# 捕获后走 _relogin 重试。Windows 没有 SIGALRM, 这是等价方案。
socket.setdefaulttimeout(30)
# stdout 行缓冲 (SSH-detached / pipe 重定向时也能看到实时进度)
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, ValueError):
pass # 不是 TextIOWrapper 或已关闭
import baostock as bs # noqa: E402
import pandas as pd # noqa: E402
# ======================== 配置 (环境变量可覆盖) ========================
DEFAULT_OUT_DIR = r"C:\sanguo_vnpy_v2\data\minute_kline\15min_baostock"
OUT_DIR = Path(os.environ.get("BS_15MIN_OUT_DIR", DEFAULT_OUT_DIR))
DEFAULT_LOG_DIR = OUT_DIR / "logs"
_log_dir_env = os.environ.get("BS_15MIN_LOG_DIR")
LOG_DIR = Path(_log_dir_env) if _log_dir_env else DEFAULT_LOG_DIR
START_DATE_DEFAULT = "2020-01-01" # baostock 15min 实证可回溯 2020-01-02 (5.5 年)
BS_INTERVAL = 0.4 # 每只间隔秒 (防封 IP)
BS_MAX_RETRIES = 3 # 单只下载重试次数 (含强制重登)
PROGRESS_LOG_EVERY = 500 # 每 N 只打一次进度日志
RELOGIN_EVERY = 400 # 每 N 只定期重登保持连接
CIRCUIT_BREAKER = 30 # 连续失败 N 只 → 断路退出
# baostock 查询字段 (不要 code, date+time 用于合成 datetime)
BS_FIELDS = "date,time,open,high,low,close,volume,amount"
# parquet 输出列 (匹配 import_vnpy_minute_fast.py 期望); 空数据返回此 schema 的空 df
OUT_COLS = ["datetime", "open", "high", "low", "close", "volume", "turnover"]
# ======================== 日志 ========================
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_15min_fullmarket_{ts}.log"
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
root = logging.getLogger()
root.setLevel(logging.INFO)
# 清掉已有 handler, 防 re-init 重复
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:
"""无前缀时按代码开头猜交易所 (与 backfill 一致)。
6/68/51 开头 → SH, 其他 → SZ。仅用于 --codes 指定无前缀代码。
"""
if code.startswith(("6", "68", "51")):
return "SH"
return "SZ"
# ======================== 股票列表 (baostock query_stock_basic) ========================
def fetch_all_stocks() -> List[Tuple[str, str]]:
"""从 baostock 拉全市场 A 股列表 (含退市, 避免生存偏差)。
过滤: type=='1' (股票); **不过滤 status** (保留退市股, 避免生存偏差)。
返回: [(code, exchange), ...], 例 [('600519', 'SH'), ('000001', 'SZ')]
baostock 0.9.3 实测字段顺序: code, code_name, ipoDate, outDate, type, status
(无 code_english_name; 与 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 = 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 ""
# status 不过滤 (保留退市, 避免生存偏差)
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, 这里捕获。
返回 True=登录成功。
"""
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()
# ======================== 数据下载 ========================
def fetch_one_15min(
bs_code: str, start_date: str, end_date: str,
) -> Optional[pd.DataFrame]:
"""从 baostock 下载 15min 不复权数据, 转 importer-ready 格式。
返回 df 列 (匹配 import_vnpy_minute_fast.py 期望):
datetime (str YYYY-MM-DD HH:MM:SS), open, high, low, close, volume, turnover
volume 单位: **股** (baostock 原始值, 绝不 ×100, 与 xtdata 关键区别)
失败/空数据返回 None。
"""
rs = bs.query_history_k_data_plus(
bs_code,
BS_FIELDS,
start_date=start_date,
end_date=end_date,
frequency="15",
adjustflag="3", # 不复权 raw
)
if rs.error_code != "0":
# 真查询错误 (baostock 端报错) — 抛异常让上层 retry/relogin。
# 区别于"合法空数据": 空数据 error_code=="0" 但 0 行。
raise RuntimeError(
f"baostock {bs_code} 查询错误: {rs.error_code} {rs.error_msg}"
)
rows = []
while rs.next():
rows.append(rs.get_row_data())
if not rows:
# 合法空数据 (退市/区间外/新股未交易) — 返回空 df, 上层计 "empty" 中性, 不重试。
return pd.DataFrame(columns=OUT_COLS)
df = pd.DataFrame(
rows,
columns=["date", "time", "open", "high", "low", "close", "volume", "amount"],
)
# datetime 合成 (照抄样本脚本 baostock_15min_sample_to_db.py 已验证逻辑):
# baostock time = 17 位 YYYYMMDDHHMMSSSSS, 15min bar 秒恒 00
df["datetime"] = (
df["date"]
+ " "
+ df["time"].str[8:10]
+ ":"
+ df["time"].str[10:12]
+ ":00"
)
# 数值转换 (baostock 返回字符串)
for col in ("open", "high", "low", "close", "volume", "amount"):
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df.rename(columns={"amount": "turnover"})
# 数据自检 (照抄样本脚本): dropna OHLC + 过滤非法 OHLC
df = df.dropna(subset=["open", "high", "low", "close"])
bad_ohlc = (df["high"] < df[["open", "close"]].max(axis=1)) | \
(df["low"] > df[["open", "close"]].min(axis=1))
if bad_ohlc.any():
df = df[~bad_ohlc]
if df.empty:
# 自检后全过滤掉 (脏数据) — 也算合法空, 不重试。
return pd.DataFrame(columns=OUT_COLS)
# importer 期望列: datetime, open, high, low, close, volume (+ turnover 可选)
df = df[OUT_COLS]
return df.sort_values("datetime").reset_index(drop=True)
# ======================== marker / parquet 路径 ========================
def parquet_path_for(code: str, exchange: str) -> Path:
"""600519 + SH → OUT_DIR / 600519.SH_15m.parquet (匹配 importer 正则)。"""
return OUT_DIR / f"{code}.{exchange}_15m.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() -> set:
"""扫 OUT_DIR 所有 marker 构造已完成集合 (真相源)。
marker 文件名格式: .600519.SH_15m.baostock
只有 marker 存在 = 该票成功完成; failed 票无 marker, 下次复跑会重试。
JSON 进度文件不在这里读 (可选, 仅观察用)。
"""
done = set()
if not OUT_DIR.exists():
return done
suffix = ".baostock"
for marker in OUT_DIR.glob(f".*{suffix}"):
name = marker.name
if not name.startswith(".") or not name.endswith(suffix):
continue
stem = name[1:-len(suffix)] # e.g. 600519.SH_15m
# 解析: <code>.<exc>_15m
try:
head, period = stem.rsplit("_", 1)
if period != "15m":
continue
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_one(
code: str,
exchange: str,
start_date: str,
end_date: str,
force: bool,
) -> Tuple[str, int]:
"""下载单只 → 写 parquet + marker。
返回 (status, rows): status ∈ {'ok', 'skipped', 'failed'}。
"""
parquet_path = parquet_path_for(code, exchange)
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_one_15min 仅在真错误时抛异常 (走 retry/relogin);
# 空数据返回空 df (合法, 不重试), 成功 break。
df: Optional[pd.DataFrame] = None
for attempt in range(BS_MAX_RETRIES):
try:
df = fetch_one_15min(bs_code, start_date, end_date)
break # fetch 成功 (df 可能空 = 合法无数据)
except (socket.timeout, TimeoutError, OSError) as e:
logger.warning(
"%s socket 异常重试 %d/%d: %s — 强制重登",
code, attempt + 1, BS_MAX_RETRIES, e,
)
if not _relogin():
logger.error("重登失败, 放弃 %s", code)
df = None
break
except Exception as e:
logger.warning(
"%s 下载异常重试 %d/%d: %s — 强制重登",
code, attempt + 1, BS_MAX_RETRIES, e,
)
if not _relogin():
logger.error("重登失败, 放弃 %s", code)
df = None
break
if df is None:
return "failed", 0 # 多次重试仍报错 (真失败, 计断路器)
if df.empty:
return "empty", 0 # 合法无数据 (退市/区间外), 中性, 不计失败不重试
# 写 parquet + marker (只有成功写完且自检通过才写 marker)
try:
OUT_DIR.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 parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="BaoStock 15min 全市场历史下载 (Windows VPS, 只产 parquet)",
)
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="指定代码逗号分隔, 如 600519,000001 (测试用)")
p.add_argument("--limit", type=int, default=0, help="限制处理数量 (测试用)")
p.add_argument("--force", action="store_true", help="强制重下, 忽略 marker")
p.add_argument("--shard", type=int, default=None, help="分片号 0-indexed (并行下载用)")
p.add_argument("--total", type=int, default=1, help="总分片数 (与 --shard 配合)")
return p.parse_args()
def resolve_codes(args: argparse.Namespace) -> List[Tuple[str, str]]:
"""根据 args 解析待处理的 (code, exchange) 列表。"""
if args.codes:
# 指定代码: 无前缀, 按代码开头猜交易所 (与 backfill 风格一致)
return [
(c.strip(), guess_exchange_by_code(c.strip()))
for c in args.codes.split(",")
if c.strip()
]
return fetch_all_stocks()
def main() -> None:
args = parse_args()
start_date = normalize_date(args.start)
end_date = normalize_date(args.end)
logger.info("=" * 60)
logger.info("BaoStock 15min 全市场历史下载 (Windows VPS)")
logger.info(" 输出目录: %s", OUT_DIR)
logger.info(" 日志文件: %s", LOG_FILE)
logger.info(" 日期范围: %s ~ %s", start_date, end_date)
logger.info(" frequency=15, adjustflag=3 (不复权 raw)")
logger.info(" socket.setdefaulttimeout(30) (SIGALRM 替代)")
logger.info(" 当前时间: %s", datetime.datetime.now().isoformat())
if args.shard is not None:
logger.info(" 分片: shard=%s/%s", args.shard, args.total)
logger.info("=" * 60)
# 登录
if not _login_once():
logger.error("[FATAL] baostock 登录失败 (疑似冷却), 退出")
sys.exit(1)
logger.info("baostock 登录成功")
# 股票列表
try:
all_codes = resolve_codes(args)
except Exception as e:
logger.error("[FATAL] 获取股票列表失败: %s", e)
try:
bs.logout()
except Exception:
pass
sys.exit(1)
logger.info("全市场 A 股 (含退市): %d", len(all_codes))
# limit
if args.limit > 0:
all_codes = all_codes[:args.limit]
logger.info("limit=%d 截断", args.limit)
# 分片
if args.shard is not None and args.total > 1:
all_codes = [
s for i, s in enumerate(all_codes)
if i % args.total == args.shard
]
logger.info("shard=%s/%s 过滤后: %d", args.shard, args.total, len(all_codes))
# 断点续传 (marker 为真相源)
done_set = load_done_set()
if args.force:
todo = all_codes
else:
todo = [(c, e) for c, e in all_codes if (c, e) not in done_set]
logger.info("待处理: %d (已完成 %d)", 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("定期重登 @ %d/%d", i, len(todo))
if not _relogin():
logger.warning("定期重登失败, 继续 (单次失败不致命)")
try:
status, rows = download_one(code, exc, start_date, end_date, args.force)
except Exception as e:
status, rows = "failed", 0
logger.debug("download_one %s.%s 异常: %s", 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 中性: 不重置也不递增 (不证明 baostock 可用, 也不算失败)
# 断路器: 连续 N 只全 failed → baostock 疑似不可达, 保存进度主动退出
if consec_fail >= CIRCUIT_BREAKER:
logger.error(
"[FATAL] 断路器触发: 连续 %d 只失败, baostock 疑似不可达, "
"退出 (done_set 不含 failed 票, 复跑会重试)",
consec_fail,
)
circuit_triggered = True
break
# 进度日志
if (i + 1) % PROGRESS_LOG_EVERY == 0:
elapsed = time.time() - t_start
logger.info(
"进度 %d/%d ok=%d skipped=%d empty=%d failed=%d rows=%d (%.0f秒)",
i + 1, len(todo), stats["ok"], stats["skipped"],
stats["empty"], stats["failed"], stats["rows"], elapsed,
)
# 限速
if i < len(todo) - 1:
time.sleep(BS_INTERVAL)
# 登出
try:
bs.logout()
except Exception:
pass
elapsed = time.time() - t_start
logger.info("=" * 60)
if circuit_triggered:
logger.info("[RESULT] 断路器触发中止, 耗时 %.1f", elapsed)
else:
logger.info("[RESULT] 完成, 耗时 %.1f", elapsed)
logger.info("统计: %s", json.dumps(stats, ensure_ascii=False))
sys.exit(2 if circuit_triggered else 0)
if __name__ == "__main__":
main()
@@ -0,0 +1,551 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""BaoStock 15min 样本验证脚本 (独立运行,不走现有 backfill/import 链路)
目的:
在上全市场 15min baostock 灌库前,先用 10 只样本股走通完整链路:
baostock 下载 → pandas 转换 → sqlite 灌库 → 读回验证 → 自检断言
通过则证明 baostock 数据形态、字段映射、DB schema 全部兼容,可放心上全市场。
硬约束(踩过的坑):
1. 必须用 venv311 (已装 baostock 0.9.30 / pyarrow 25.0 / sqlite3)
2. baostock 单进程单登录,严禁并发,每只 sleep 0.4s(并发会拉黑封IP)
3. 直连不走代理(脚本开头 unset proxy 环境变量)
4. bs.login() 套 SIGALRM 30s 超时,防永久挂起
5. 只灌 /tmp/quant_trading_sample.db 本地测试库,绝不碰 NAS/项目主库
surgical 原则:
不修改 backfill_15min_baostock.py(全市场下载)/ import_vnpy_minute_fast.py(全市场灌库)。
本脚本只做样本验证,每次运行前重建测试库。
用法:
/Users/chufeng/.openclaw/sanguo_projects/sanguo_vnpy_v2/venv311/bin/python \
scripts/data_platform/baostock_15min_sample_to_db.py
退出码: 0=成功 1=致命错误(baostock login 失/断路触发)
"""
import datetime
import os
import signal
import sqlite3
import sys
import time
from typing import Optional, Tuple
# ======================== 硬约束: unset proxy ========================
# 必须在 import baostock 之前清理(baostock 底层 urllib 会读 proxy 环境变量)
for _k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
os.environ.pop(_k, None)
import baostock as bs # noqa: E402
import pandas as pd # noqa: E402
# ======================== 配置 ========================
VENV_PY = "/Users/chufeng/.openclaw/sanguo_projects/sanguo_vnpy_v2/venv311/bin/python"
SAMPLE_DB = "/tmp/quant_trading_sample.db"
SAMPLE_CODES = [
"600519", # 贵州茅台
"600000", # 浦发银行
"000001", # 平安银行
"000858", # 五粮液
"688981", # 中芯国际
"300750", # 宁德时代
"601318", # 中国平安
"002594", # 比亚迪
"600036", # 招商银行
"000651", # 格力电器
]
START_DATE = "2020-01-01" # 验证 baostock 5.5 年深度
END_DATE = datetime.date.today().strftime("%Y-%m-%d")
FREQUENCY = "15"
ADJUST_FLAG = "3" # 3=不复权(raw 真实价)
BS_INTERVAL = 0.4 # 每只间隔秒(防封 IP)
BS_LOGIN_TIMEOUT = 30 # bs.login() SIGALRM 超时
CIRCUIT_THRESHOLD = 3 # 连续失败 N 只 → 断路退出(样本验证保守值)
# baostock 字段(注意 adjustflag 即使在 fields 里也返回,但我们不用它)
BS_FIELDS = "date,time,code,open,high,low,close,volume,amount,adjustflag"
# ======================== 建表 SQL (vnpy 标准 schema) ========================
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS dbbardata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT, exchange TEXT, datetime TEXT, interval TEXT,
volume REAL, turnover REAL DEFAULT 0, open_interest REAL DEFAULT 0,
open_price REAL, high_price REAL, low_price REAL, close_price REAL,
UNIQUE(symbol, exchange, datetime, interval)
)
"""
INDEX_SQL = (
"CREATE INDEX IF NOT EXISTS idx_dbbardata_symbol_interval "
"ON dbbardata(symbol, interval)"
)
INSERT_SQL = (
"INSERT OR REPLACE INTO dbbardata "
"(symbol,exchange,datetime,interval,volume,turnover,open_interest,"
"open_price,high_price,low_price,close_price) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)"
)
# ======================== baostock 登录(带 SIGALRM 超时) ========================
class _LoginTimeout(Exception):
"""bs.login() SIGALRM 超时异常"""
def _login_timeout_handler(signum, frame):
raise _LoginTimeout("bs.login() 超时")
def login_with_timeout(timeout: int = BS_LOGIN_TIMEOUT) -> bool:
"""bs.login() 套 SIGALRM 超时,防永久挂起。返回 True=登录成功。"""
old_handler = signal.signal(signal.SIGALRM, _login_timeout_handler)
signal.alarm(timeout)
try:
lg = bs.login()
ok = lg.error_code == "0"
if not ok:
print(f"[FATAL] bs.login() 失败: code={lg.error_code} msg={lg.error_msg}",
flush=True)
return ok
except _LoginTimeout:
print(f"[FATAL] bs.login() 超时 {timeout}s (baostock 疑似冷却中)", flush=True)
return False
except Exception as e:
print(f"[FATAL] bs.login() 异常: {e}", flush=True)
return False
finally:
signal.alarm(0)
signal.signal(signal.SIGALRM, old_handler)
# ======================== 代码 → (baostock_code, exchange) ========================
def code_to_baostock(code: str) -> Tuple[str, str]:
"""6位代码 → (baostock格式 sh.XXXXXX/sz.XXXXXX, vnpy exchange SSE/SZSE)。
规则: 6/68/51 开头 → sh / SSE, 其他 → sz / SZSE
"""
if code.startswith(("6", "68", "51")):
return f"sh.{code}", "SSE"
else:
return f"sz.{code}", "SZSE"
# ======================== 下载单只 ========================
def fetch_one(code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
"""从 baostock 下载 15min 不复权数据,做字段转换。
返回处理后的 DataFrame,列:
symbol, exchange, datetime, open_price, high_price, low_price,
close_price, volume, turnover
失败/空数据返回 None。
"""
bs_code, exchange = code_to_baostock(code)
rs = bs.query_history_k_data_plus(
bs_code,
BS_FIELDS,
start_date=start_date,
end_date=end_date,
frequency=FREQUENCY,
adjustflag=ADJUST_FLAG,
)
if rs.error_code != "0":
print(f" [baostock] {code} ({bs_code}) 查询错误: "
f"code={rs.error_code} msg={rs.error_msg}", flush=True)
return None
rows = []
while rs.next():
rows.append(rs.get_row_data())
if not rows:
print(f" [baostock] {code} ({bs_code}) 返回 0 行(新股/停牌?)", flush=True)
return None
df = pd.DataFrame(rows, columns=[
"date", "time", "code", "open", "high", "low",
"close", "volume", "amount", "adjustflag",
])
# datetime 合成: baostock time 是 17 位 YYYYMMDDHHMMSSSSS
# date 是 YYYY-MM-DD, 取 time 的 HH:MM 部分, 秒恒 00
df["datetime"] = (
df["date"]
+ " "
+ df["time"].str[8:10]
+ ":"
+ df["time"].str[10:12]
+ ":00"
)
# 数值转换(baostock 返回字符串)
for col in ("open", "high", "low", "close"):
df[col] = pd.to_numeric(df[col], errors="coerce")
df["volume"] = pd.to_numeric(df["volume"], errors="coerce")
df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
# 重命名 → vnpy schema
df = df.rename(columns={
"open": "open_price",
"high": "high_price",
"low": "low_price",
"close": "close_price",
"amount": "turnover",
})
df["symbol"] = code
df["exchange"] = exchange
# 字段顺序对齐
df = df[[
"symbol", "exchange", "datetime",
"open_price", "high_price", "low_price", "close_price",
"volume", "turnover",
]]
# 删除 NaN OHLCV(baostock 偶有空值,无意义)
df = df.dropna(
subset=["open_price", "high_price", "low_price", "close_price", "volume"]
)
return df if not df.empty else None
# ======================== 自检断言 ========================
def self_check(df: pd.DataFrame, code: str) -> Tuple[bool, str]:
"""对下载并转换后的 df 做数据质量自检。
断言:
A. high >= low
B. low <= close <= high
C. volume > 0
D. datetime 格式 YYYY-MM-DD HH:MM:SS(19 字符)
返回 (all_pass, 报告字符串)。
"""
n = len(df)
if n == 0:
return False, "empty dataframe"
# A. high >= low
bad_hl = df[df["high_price"] < df["low_price"]]
a_pass = len(bad_hl) == 0
# B. low <= close <= high
bad_close = df[(df["close_price"] < df["low_price"]) |
(df["close_price"] > df["high_price"])]
b_pass = len(bad_close) == 0
# C. volume > 0
bad_vol = df[df["volume"] <= 0]
c_pass = len(bad_vol) == 0
# D. datetime 格式 (YYYY-MM-DD HH:MM:SS = 19 字符)
dt = df["datetime"].astype(str)
bad_fmt = dt[(dt.str.len() != 19) | (~dt.str.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$"))]
d_pass = len(bad_fmt) == 0
all_pass = a_pass and b_pass and c_pass and d_pass
report = (
f"high>=low={'PASS' if a_pass else 'FAIL(%d)' % len(bad_hl)} | "
f"low<=close<=high={'PASS' if b_pass else 'FAIL(%d)' % len(bad_close)} | "
f"volume>0={'PASS' if c_pass else 'FAIL(%d)' % len(bad_vol)} | "
f"dt_format={'PASS' if d_pass else 'FAIL(%d)' % len(bad_fmt)}"
)
return all_pass, report
# ======================== 灌库 ========================
def init_db(db_path: str) -> sqlite3.Connection:
"""(重)建测试库 + dbbardata 表。每次重跑前 drop 旧表。"""
conn = sqlite3.connect(db_path)
c = conn.cursor()
# 幂等: 若表存在先 drop(测试库不做增量,每次 fresh start)
c.execute("DROP TABLE IF EXISTS dbbardata")
c.execute(CREATE_TABLE_SQL)
c.execute(INDEX_SQL)
conn.commit()
return conn
def insert_df(conn: sqlite3.Connection, df: pd.DataFrame) -> int:
"""批量 INSERT OR REPLACE 灌入 15min bars。返回灌入行数。"""
values = df[[
"symbol", "exchange", "datetime",
"open_price", "high_price", "low_price", "close_price",
"volume", "turnover",
]].values.tolist()
# 在对应位置插入 interval='15m' 和 open_interest=0.0
# SQL 字段顺序: symbol,exchange,datetime,interval,volume,turnover,open_interest,
# open_price,high_price,low_price,close_price
rows = [
(v[0], v[1], v[2], "15m", float(v[7]), float(v[8]), 0.0,
float(v[3]), float(v[4]), float(v[5]), float(v[6]))
for v in values
]
conn.executemany(INSERT_SQL, rows)
conn.commit()
return len(rows)
def read_back_one(conn: sqlite3.Connection, symbol: str, exchange: str) -> pd.DataFrame:
"""从 DB 读回单只, 验证落库完整性。"""
sql = (
"SELECT datetime, open_price, high_price, low_price, close_price, "
"volume, turnover FROM dbbardata "
"WHERE symbol=? AND exchange=? AND interval='15m' "
"ORDER BY datetime"
)
return pd.read_sql(sql, conn, params=(symbol, exchange))
# ======================== 主流程 ========================
def main():
print("=" * 72, flush=True)
print("BaoStock 15min 样本验证脚本", flush=True)
print("=" * 72, flush=True)
print(f"Python: {sys.executable}", flush=True)
print(f"测试库: {SAMPLE_DB}", flush=True)
print(f"样本股 ({len(SAMPLE_CODES)}): {SAMPLE_CODES}", flush=True)
print(f"日期范围: {START_DATE} ~ {END_DATE}", flush=True)
print(f"frequency={FREQUENCY} adjustflag={ADJUST_FLAG} (不复权 raw)", flush=True)
print(f"baostock 间隔: {BS_INTERVAL}s/只 (单进程串行,防封IP)", flush=True)
print(f"proxy 环境: 已 unset", flush=True)
print(f"当前时间: {datetime.datetime.now().isoformat()}", flush=True)
print("-" * 72, flush=True)
# 1. 登录 baostock (带超时)
print("[1/4] 登录 baostock ...", flush=True)
if not login_with_timeout():
print("[FATAL] baostock 登录失败, 退出 (避免硬撑被拉黑)", flush=True)
sys.exit(1)
print(" ✅ baostock 登录成功", flush=True)
# 2. 初始化测试库
print(f"[2/4] 初始化测试库 {SAMPLE_DB} ...", flush=True)
if os.path.exists(SAMPLE_DB):
os.remove(SAMPLE_DB)
print(f" 已删除旧库", flush=True)
conn = init_db(SAMPLE_DB)
print(f" ✅ 新建 dbbardata 表", flush=True)
# 3. 逐只下载 → 灌库
print(f"[3/4] 开始逐只下载 (串行, 间隔 {BS_INTERVAL}s) ...", flush=True)
print("-" * 72, flush=True)
per_stock_results = [] # list of dict
total_rows = 0
success_count = 0
consec_fail = 0
circuit_triggered = False
t_start = time.time()
for idx, code in enumerate(SAMPLE_CODES, 1):
bs_code, exchange = code_to_baostock(code)
print(f"[{idx}/{len(SAMPLE_CODES)}] {code} ({bs_code} / {exchange})",
flush=True)
try:
df = fetch_one(code, START_DATE, END_DATE)
except Exception as e:
print(f" ❌ 下载异常: {e}", flush=True)
df = None
if df is None or df.empty:
print(f" ❌ 无数据,跳过灌库", flush=True)
per_stock_results.append({
"code": code, "bs_code": bs_code, "exchange": exchange,
"status": "FAILED", "rows": 0,
"dt_min": "-", "dt_max": "-",
"first_ohlcv": "-", "last_ohlcv": "-",
"self_check": "-",
})
consec_fail += 1
if consec_fail >= CIRCUIT_THRESHOLD:
print(f"\n[FATAL] 连续 {consec_fail} 只失败, 触发断路器, 停止后续 "
f"(baostock 疑似不可达, 避免硬撑被拉黑)", flush=True)
circuit_triggered = True
break
if idx < len(SAMPLE_CODES):
time.sleep(BS_INTERVAL)
continue
# 自检
ok, check_report = self_check(df, code)
status_flag = "PASS" if ok else "WARN"
# 灌库
try:
n_inserted = insert_df(conn, df)
except Exception as e:
print(f" ❌ 灌库异常: {e}", flush=True)
per_stock_results.append({
"code": code, "bs_code": bs_code, "exchange": exchange,
"status": "DB_FAIL", "rows": 0,
"dt_min": "-", "dt_max": "-",
"first_ohlcv": "-", "last_ohlcv": "-",
"self_check": check_report,
})
consec_fail += 1
if consec_fail >= CIRCUIT_THRESHOLD:
print(f"\n[FATAL] 连续 {consec_fail} 只失败, 触发断路器", flush=True)
circuit_triggered = True
break
if idx < len(SAMPLE_CODES):
time.sleep(BS_INTERVAL)
continue
consec_fail = 0 # 成功重置
total_rows += n_inserted
success_count += 1
# 详细打印
dt_min = df["datetime"].min()
dt_max = df["datetime"].max()
first = df.iloc[0]
last = df.iloc[-1]
first_str = (
f"dt={first['datetime']} O={first['open_price']:.2f} "
f"H={first['high_price']:.2f} L={first['low_price']:.2f} "
f"C={first['close_price']:.2f} V={int(first['volume'])} "
f"amt={first['turnover']:.0f}"
)
last_str = (
f"dt={last['datetime']} O={last['open_price']:.2f} "
f"H={last['high_price']:.2f} L={last['low_price']:.2f} "
f"C={last['close_price']:.2f} V={int(last['volume'])} "
f"amt={last['turnover']:.0f}"
)
# 读回验证
readback = read_back_one(conn, code, exchange)
readback_ok = len(readback) == n_inserted
print(f" ✅ 下载 {len(df)} 行 | 时间 {dt_min} ~ {dt_max}", flush=True)
print(f" 首行: {first_str}", flush=True)
print(f" 尾行: {last_str}", flush=True)
print(f" 自检 [{status_flag}]: {check_report}", flush=True)
print(f" 灌库 {n_inserted} 行 | 读回 {len(readback)}"
f"({'✅ 一致' if readback_ok else '❌ 不一致'})", flush=True)
per_stock_results.append({
"code": code, "bs_code": bs_code, "exchange": exchange,
"status": "OK", "rows": n_inserted,
"dt_min": dt_min, "dt_max": dt_max,
"first_ohlcv": first_str, "last_ohlcv": last_str,
"self_check": check_report,
"readback_rows": len(readback),
"readback_ok": readback_ok,
})
# 间隔(最后一只不用 sleep)
if idx < len(SAMPLE_CODES):
time.sleep(BS_INTERVAL)
elapsed = time.time() - t_start
# 4. 登出 baostock
try:
bs.logout()
print("\n[4/4] baostock 已登出", flush=True)
except Exception as e:
print(f"\n[4/4] baostock 登出异常(忽略): {e}", flush=True)
conn.close()
# ======================== 汇总报告 ========================
print("\n" + "=" * 72, flush=True)
print("汇总报告", flush=True)
print("=" * 72, flush=True)
if not per_stock_results:
print("[FATAL] 所有样本均失败, 无数据可汇总", flush=True)
sys.exit(1)
# 每只结果表
print(f"\n{'code':<8}{'status':<10}{'rows':>10} {'dt_min':<20}{'dt_max':<20}",
flush=True)
print("-" * 72, flush=True)
for r in per_stock_results:
print(f"{r['code']:<8}{r['status']:<10}{r['rows']:>10} "
f"{str(r['dt_min']):<20}{str(r['dt_max']):<20}", flush=True)
# 汇总指标
all_mins = [r["dt_min"] for r in per_stock_results if r["status"] == "OK"]
all_maxs = [r["dt_max"] for r in per_stock_results if r["status"] == "OK"]
earliest = min(all_mins) if all_mins else "-"
latest = max(all_maxs) if all_maxs else "-"
success_rate = success_count / len(SAMPLE_CODES) * 100
print(f"\n总行数 (灌入 dbbardata): {total_rows}", flush=True)
print(f"成功率: {success_count}/{len(SAMPLE_CODES)} = {success_rate:.1f}%",
flush=True)
print(f"最早日期: {earliest} (期望接近 {START_DATE} 验证 baostock 深度)",
flush=True)
print(f"最晚日期: {latest}", flush=True)
print(f"总耗时: {elapsed:.1f}s ({elapsed / 60:.1f}min)", flush=True)
# 茅台 600519 抽样(任务要求)
moutai = next((r for r in per_stock_results if r["code"] == "600519"), None)
if moutai and moutai["status"] == "OK":
print(f"\n茅台 600519 抽样 OHLCV (原始 baostock 值):", flush=True)
print(f" 首行: {moutai['first_ohlcv']}", flush=True)
print(f" 尾行: {moutai['last_ohlcv']}", flush=True)
print(f" 自检: {moutai['self_check']}", flush=True)
# 全局自检总结
print(f"\n自检总结 (所有 OK 的样本):", flush=True)
all_check_pass = True
for r in per_stock_results:
if r["status"] != "OK":
continue
flag = "PASS" if "PASS" in r["self_check"] and "FAIL" not in r["self_check"] else "WARN"
if "FAIL" in r["self_check"]:
all_check_pass = False
print(f" {r['code']:<8} [{flag}] {r['self_check']}", flush=True)
print(f"\n全局自检: {'✅ ALL PASS' if all_check_pass else '❌ 有 FAIL 项, 请检查'}",
flush=True)
# 深度验证 (是否到 2020-01-01)
print(f"\n深度验证 (最早日期是否接近 {START_DATE}):", flush=True)
if all_mins:
earliest_code = per_stock_results[
next(i for i, r in enumerate(per_stock_results) if r["dt_min"] == earliest)
]["code"] if earliest in all_mins else "?"
# 严格匹配最早 dt_min 对应的 code
candidates = [r for r in per_stock_results if r["status"] == "OK"]
if candidates:
earliest_row = min(candidates, key=lambda r: r["dt_min"])
print(f" 最早样本: {earliest_row['code']} ({earliest_row['bs_code']}) "
f"起始 {earliest_row['dt_min']}", flush=True)
if earliest.startswith("2020-"):
print(f" ✅ 深度达到 2020 年 (baostock 5.5 年历史可用)", flush=True)
elif earliest.startswith("2021") or earliest.startswith("2022"):
print(f" ⚠️ 深度晚于 2020-01-01 (可能新股上市晚, 看具体样本)",
flush=True)
else:
print(f" ❌ 深度严重不足, 检查 baostock 配置", flush=True)
# 读回一致性验证总结
readback_all_ok = all(
r.get("readback_ok", False) for r in per_stock_results
if r["status"] == "OK"
)
print(f"\n读回一致性: {'✅ 所有样本 灌入行数 == 读回行数' if readback_all_ok else '❌ 有样本读回不一致'}",
flush=True)
print("\n" + "=" * 72, flush=True)
if circuit_triggered:
print(f"[RESULT] 断路器触发, 部分样本未处理 - 请检查 baostock 状态后重试",
flush=True)
sys.exit(1)
elif success_count == len(SAMPLE_CODES) and all_check_pass and readback_all_ok:
print(f"[RESULT] ✅✅✅ 全部 10 只样本链路验证通过, 可以上全市场", flush=True)
sys.exit(0)
else:
print(f"[RESULT] ⚠️ 部分异常, 请审阅上面的详细报告", flush=True)
sys.exit(0 if success_count == len(SAMPLE_CODES) else 1)
if __name__ == "__main__":
main()
+496
View File
@@ -0,0 +1,496 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""BaoStock 成分股历史下载脚本 (只产 parquet)
目标:
在 Mac 本机用 baostock 拉取 HS300/ZZ500/SZ50 历史成分股快照,
按历史日期循环 (每周一一个快照, 从 2006-01 至今)。
输出 parquet 到 staging 目录。
硬约束 (踩过的坑):
1. 单进程单登录, 严禁并发 (baostock 并发会拉黑封 IP 6-24h)
2. 直连不走代理 (脚本开头 unset proxy)
3. 每次 query 后 sleep 0.4s 限速
4. staging parquet, 绝不直接写主库
用法:
python baostock_constituent_download.py # 全指数 2006-01~至今
python baostock_constituent_download.py --limit 5 # 测试: 前 5 个快照
python baostock_constituent_download.py --indices hs300 # 只跑指定指数
python baostock_constituent_download.py --start 2020-01-01 # 指定起始日期
退出码: 0=完成, 1=致命错误, 2=断路器触发 (可重试)
"""
import argparse
import datetime
import json
import logging
import os
import socket
import sys
import time
from datetime import date, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# ======================== 硬约束: unset proxy + socket timeout ========================
for _k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
os.environ.pop(_k, None)
socket.setdefaulttimeout(30)
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 = "/Users/chufeng/.openclaw/sanguo_projects/sanguo_vnpy_v2/data/constituent_baostock"
OUT_DIR = Path(os.environ.get("BS_CONSTITUENT_OUT_DIR", DEFAULT_OUT_DIR))
DEFAULT_LOG_DIR = OUT_DIR / "logs"
_log_dir_env = os.environ.get("BS_CONSTITUENT_LOG_DIR")
LOG_DIR = Path(_log_dir_env) if _log_dir_env else DEFAULT_LOG_DIR
START_DATE_DEFAULT = "2006-01-01" # 成分股历史起始
END_DATE_DEFAULT = "" # 默认今天
BS_INTERVAL = 0.4 # 每次间隔秒 (防封 IP)
BS_MAX_RETRIES = 3 # 单次下载重试次数
PROGRESS_LOG_EVERY = 50 # 每 N 个快照打一次进度日志
RELOGIN_EVERY = 100 # 每 N 个快照定期重登
CIRCUIT_BREAKER = 10 # 连续失败 N 次 → 断路退出
# 指数配置 (baostock code, 显示名)
INDICES_CONFIG = {
"hs300": {"code": "000300", "name": "HS300"},
"zz500": {"code": "000905", "name": "ZZ500"},
"sz50": {"code": "000016", "name": "SZ50"},
}
# ======================== 日志 ========================
def setup_logging() -> Tuple[logging.Logger, Path]:
"""配置 root logger: 同时写 stdout + 文件。"""
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_constituent_{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}")
def get_monday_dates(start_date: str, end_date: str) -> List[str]:
"""生成每周一日期列表 (包含 start_date 所在周, 含 end_date)。
返回: YYYY-MM-DD 格式日期列表。
"""
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
# 找到 start_date 后的第一个周一
d = start
while d.weekday() != 0: # Monday=0
d += timedelta(days=1)
dates = []
while d <= end:
dates.append(d.strftime("%Y-%m-%d"))
d += timedelta(days=7) # 下一周
return dates
def baostock_code_to_parts(bs_code: str) -> Tuple[str, str]:
"""sh.600519 → (600519, 'SH'); sz.000001 → (000001, 'SZ')。"""
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}")
# ======================== baostock 登录 ========================
def _login_once() -> bool:
"""bs.login() — 已被 socket.setdefaulttimeout(30) 保护。"""
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()
# ======================== 数据下载 ========================
def fetch_constituent_stocks(index_name: str, date_str: str) -> pd.DataFrame:
"""拉取指定指数在指定日期的成分股。
返回 df 列: code (baostock格式, 如 sh.600519)
空数据返回空 df。
"""
index_info = INDICES_CONFIG.get(index_name)
if not index_info:
raise ValueError(f"未知指数: {index_name}")
index_code = index_info["code"]
if index_name == "hs300":
rs = bs.query_hs300_stocks(date=date_str)
elif index_name == "zz500":
rs = bs.query_zz500_stocks(date=date_str)
elif index_name == "sz50":
rs = bs.query_sz50_stocks(date=date_str)
else:
raise ValueError(f"未实现的指数: {index_name}")
if rs.error_code != "0":
raise RuntimeError(
f"query_{index_name}_stocks({date_str}) 错误: {rs.error_code} {rs.error_msg}"
)
if rs.error_code != "0":
raise RuntimeError(
f"query_{index_name}_stocks({date_str}) 错误: {rs.error_code} {rs.error_msg}"
)
# 获取字段列表
fields = list(rs.fields)
rows = []
while rs.next():
rows.append(rs.get_row_data())
if not rows:
# 合法空数据 (指数不存在于该日期)
return pd.DataFrame(columns=fields)
# 使用 baostock 实际返回的字段名
return pd.DataFrame(rows, columns=fields)
# ======================== 路径 / marker ========================
def parquet_path_for(index_name: str, date_str: str) -> Path:
"""hs300 + 2020-01-06 → OUT_DIR / hs300_2020-01-06.parquet"""
return OUT_DIR / f"{index_name}_{date_str}.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(indices: List[str]) -> Dict[str, set]:
"""扫 OUT_DIR 所有 marker 构造已完成集合。
返回: {index_name: set(dates)} 字典。
"""
done: Dict[str, set] = {idx: set() for idx in indices}
if not OUT_DIR.exists():
return done
suffix = ".baostock"
for marker in OUT_DIR.glob(f".*{suffix}"):
name = marker.name
if not name.startswith(".") or not name.endswith(suffix):
continue
stem = name[1:-len(suffix)] # e.g. hs300_2020-01-06
# 解析: <index_name>_<date>
try:
parts = stem.split("_", 1)
if len(parts) != 2:
continue
index_name, date_str = parts
if index_name in done:
done[index_name].add(date_str)
except ValueError:
continue
return done
# ======================== 单次下载 ========================
def download_one_snapshot(
index_name: str,
date_str: str,
force: bool,
) -> Tuple[str, int]:
"""下载单个成分股快照 → 写 parquet + marker。
返回 (status, rows): status ∈ {'ok', 'skipped', 'failed', 'empty'}。
"""
parquet_path = parquet_path_for(index_name, date_str)
marker_path = marker_path_for(parquet_path)
if not force and marker_path.exists():
return "skipped", 0
# retry 循环
df: Optional[pd.DataFrame] = None
for attempt in range(BS_MAX_RETRIES):
try:
df = fetch_constituent_stocks(index_name, date_str)
break
except (socket.timeout, TimeoutError, OSError) as e:
logger.warning(
"%s %s socket 异常重试 %d/%d: %s — 强制重登",
index_name, date_str, attempt + 1, BS_MAX_RETRIES, e,
)
if not _relogin():
logger.error("重登失败, 放弃 %s %s", index_name, date_str)
df = None
break
except Exception as e:
logger.warning(
"%s %s 下载异常重试 %d/%d: %s — 强制重登",
index_name, date_str, attempt + 1, BS_MAX_RETRIES, e,
)
if not _relogin():
logger.error("重登失败, 放弃 %s %s", index_name, date_str)
df = None
break
if df is None:
return "failed", 0
if df.empty:
# 合法空数据 (指数不存在于该日期) — 也写 marker
try:
OUT_DIR.mkdir(parents=True, exist_ok=True)
df.to_parquet(parquet_path, index=False)
marker_path.write_text(datetime.datetime.now().isoformat())
return "empty", 0
except Exception as e:
logger.error("写入 %s 失败: %s", parquet_path, e)
return "failed", 0
# 写 parquet + marker
try:
OUT_DIR.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 parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="BaoStock 成分股历史下载 (只产 parquet)",
)
p.add_argument(
"--start", default=START_DATE_DEFAULT,
help=f"起始日期 YYYYMMDD 或 YYYY-MM-DD, 默认 {START_DATE_DEFAULT}",
)
p.add_argument("--end", default=END_DATE_DEFAULT, help="结束日期, 默认今天")
p.add_argument(
"--indices",
default="hs300,zz500,sz50",
help="逗号分隔指数名称, 默认 hs300,zz500,sz50",
)
p.add_argument("--limit", type=int, default=0, help="限制处理快照数 (测试用)")
p.add_argument("--force", action="store_true", help="强制重下, 忽略 marker")
return p.parse_args()
def resolve_indices(s: str) -> List[str]:
"""解析 --indices 字符串 → 去重保序的指数列表。"""
parts = [p.strip() for p in s.split(",") if p.strip()]
valid = set(INDICES_CONFIG.keys())
bad = [p for p in parts if p not in valid]
if bad:
raise SystemExit(f"未知 --indices: {bad}, 可选 {list(valid)}")
seen: set = set()
out: List[str] = []
for p in parts:
if p not in seen:
out.append(p)
seen.add(p)
return out
def main() -> None:
args = parse_args()
start_date = normalize_date(args.start)
end_date = normalize_date(args.end)
indices = resolve_indices(args.indices)
logger.info("=" * 60)
logger.info("BaoStock 成分股历史下载")
logger.info(" 输出目录: %s", OUT_DIR)
logger.info(" 日志文件: %s", LOG_FILE)
logger.info(" 日期范围: %s ~ %s", start_date, end_date)
logger.info(" 指数: %s", indices)
logger.info(" 频率: 每周一快照")
logger.info(" socket.setdefaulttimeout(30)")
logger.info(" 当前时间: %s", datetime.datetime.now().isoformat())
logger.info("=" * 60)
# 登录
if not _login_once():
logger.error("[FATAL] baostock 登录失败 (疑似冷却), 退出")
sys.exit(1)
logger.info("baostock 登录成功")
# 生成日期列表 (每周一)
monday_dates = get_monday_dates(start_date, end_date)
logger.info("快照日期列表: %d 个周一 (%s ~ %s)", len(monday_dates),
monday_dates[0] if monday_dates else "N/A",
monday_dates[-1] if monday_dates else "N/A")
# limit
if args.limit > 0:
monday_dates = monday_dates[:args.limit]
logger.info("limit=%d 截断", args.limit)
# 总快照数
total_snapshots = len(monday_dates) * len(indices)
logger.info("总快照数: %d (日期%d × 指数%d)", total_snapshots,
len(monday_dates), len(indices))
# 断点续传
done_set = load_done_set(indices)
if args.force:
todo_snapshots = [(idx, d) for idx in indices for d in monday_dates]
else:
todo_snapshots = [
(idx, d) for idx in indices for d in monday_dates
if d not in done_set.get(idx, set())
]
logger.info("待处理: %d (已完成 %d)", len(todo_snapshots),
total_snapshots - len(todo_snapshots))
# 主循环
stats = {"ok": 0, "skipped": 0, "empty": 0, "failed": 0, "rows": 0}
consec_fail = 0
circuit_triggered = False
t_start = time.time()
for i, (index_name, date_str) in enumerate(todo_snapshots):
# 定期重登
if i > 0 and i % RELOGIN_EVERY == 0:
logger.info("定期重登 @ %d/%d", i, len(todo_snapshots))
if not _relogin():
logger.warning("定期重登失败, 继续")
try:
status, rows = download_one_snapshot(index_name, date_str, args.force)
except Exception as e:
status, rows = "failed", 0
logger.debug("download_one_snapshot %s %s 异常: %s", index_name, date_str, e)
stats[status] = stats.get(status, 0) + 1
if status == "ok":
stats["rows"] += rows
consec_fail = 0
elif status == "failed":
consec_fail += 1
# 断路器
if consec_fail >= CIRCUIT_BREAKER:
logger.error(
"[FATAL] 断路器触发: 连续 %d 次失败, baostock 疑似不可达",
consec_fail,
)
circuit_triggered = True
break
# 进度日志
if (i + 1) % PROGRESS_LOG_EVERY == 0:
elapsed = time.time() - t_start
logger.info(
"进度 %d/%d ok=%d skipped=%d empty=%d failed=%d rows=%d (%.0f秒)",
i + 1, len(todo_snapshots), stats["ok"], stats["skipped"],
stats["empty"], stats["failed"], stats["rows"], elapsed,
)
# 限速
if i < len(todo_snapshots) - 1:
time.sleep(BS_INTERVAL)
# 登出
try:
bs.logout()
except Exception:
pass
elapsed = time.time() - t_start
logger.info("=" * 60)
if circuit_triggered:
logger.info("[RESULT] 断路器触发中止, 耗时 %.1f", elapsed)
else:
logger.info("[RESULT] 完成, 耗时 %.1f", elapsed)
logger.info("统计: %s", json.dumps(stats, ensure_ascii=False))
sys.exit(2 if circuit_triggered else 0)
if __name__ == "__main__":
main()
+582
View File
@@ -0,0 +1,582 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""BaoStock 日K线全市场全字段下载脚本 (只产 parquet)
目标:
在 Mac 本机用 baostock 全量拉取 A 股 (含退市) 日K线全字段历史,
输出 parquet 到 staging 目录, 后续验证+合并主库 (不直接写 DB).
与已有脚本复用:
- baostock_15min_fullmarket_download.py 模板: 单进程单登录/marker 断点续传/query 计数器
- baostock_static_download.py: 复权因子+除权除息已覆盖, 不重复
核心价值字段 (补 DB 日线缺口):
turn(换手率), pctChg(涨跌幅), peTTM(市盈率TTM), pbMRQ(市净率),
psTTM(市销率TTM), pcfNcfTTM(市现率TTM), isST(ST标记)
硬约束 (踩过的坑):
1. 单进程单登录, 严禁并发 (baostock 并发会拉黑封 IP 6-24h)
2. 直连不走代理 (脚本开头 unset proxy)
3. 每只 sleep 0.4s 限速
4. baostock 每日 ≤ 48000 query (硬上限, 超额即拉黑)
5. staging parquet, 绝不直接写主库 (quant_trading.db)
用法:
python baostock_daily_fullmarket_download.py # 全市场 1990-12-19~今天
python baostock_daily_fullmarket_download.py --limit 10 # 测试: 前 10 只
python baostock_daily_fullmarket_download.py --codes 600519,000001 # 指定
python baostock_daily_fullmarket_download.py --force # 强制重下, 忽略 marker
python baostock_daily_fullmarket_download.py --shard 0 --total 4 # 分片
退出码: 0=完成, 1=致命错误 (登录失败/列表拉取失败), 2=断路器触发 (可重试)
"""
import argparse
import datetime
import json
import logging
import os
import socket
import sys
import time
from pathlib import Path
from typing import 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。
# baostock 的 socket 连接会继承这个超时, connect 卡死时抛 socket.timeout,
# 捕获后走 _relogin 重试。
socket.setdefaulttimeout(30)
# stdout 行缓冲 (SSH-detached / pipe 重定向时也能看到实时进度)
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, ValueError):
pass # 不是 TextIOWrapper 或已关闭
import baostock as bs # noqa: E402
import pandas as pd # noqa: E402
# ======================== 配置 (环境变量可覆盖) ========================
DEFAULT_OUT_DIR = "/Users/chufeng/.openclaw/sanguo_projects/sanguo_vnpy_v2/data/daily_baostock"
OUT_DIR = Path(os.environ.get("BS_DAILY_OUT_DIR", DEFAULT_OUT_DIR))
DEFAULT_LOG_DIR = OUT_DIR / "logs"
_log_dir_env = os.environ.get("BS_DAILY_LOG_DIR")
LOG_DIR = Path(_log_dir_env) if _log_dir_env else DEFAULT_LOG_DIR
START_DATE_DEFAULT = "1990-12-19" # A 股最早交易日
BS_INTERVAL = 0.4 # 每只间隔秒 (防封 IP)
BS_MAX_RETRIES = 3 # 单只下载重试次数 (含强制重登)
PROGRESS_LOG_EVERY = 500 # 每 N 只打一次进度日志
RELOGIN_EVERY = 400 # 每 N 只定期重登保持连接
CIRCUIT_BREAKER = 30 # 连续失败 N 只 → 断路退出
# baostock 查询全字段 (日K线, 不复权 raw)
BS_FIELDS = "date,code,open,high,low,close,preclose,volume,amount,adjustflag,turn,tradestatus,pctChg,peTTM,psTTM,pcfNcfTTM,pbMRQ,isST"
# parquet 输出列 (保持 baostock 原始字段名)
OUT_COLS = BS_FIELDS.split(",")
# query 计数器 (防超 48000/天硬限)
QUERY_COUNT = 0
DAILY_LIMIT = 40000 # 接近 40000 自动停 (留余量)
# ======================== 日志 ========================
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_daily_fullmarket_{ts}.log"
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
root = logging.getLogger()
root.setLevel(logging.INFO)
# 清掉已有 handler, 防 re-init 重复
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:
"""无前缀时按代码开头猜交易所 (与 15min 模板一致)。
6/68/51 开头 → SH, 其他 → SZ。仅用于 --codes 指定无前缀代码。
"""
if code.startswith(("6", "68", "51")):
return "SH"
return "SZ"
# ======================== 股票列表 (baostock query_stock_basic) ========================
def fetch_all_stocks() -> List[Tuple[str, str]]:
"""从 baostock 拉全市场 A 股列表 (含退市, 避免生存偏差)。
过滤: type=='1' (股票); **不过滤 status** (保留退市股, 避免生存偏差)。
返回: [(code, exchange), ...], 例 [('600519', 'SH'), ('000001', 'SZ')]
"""
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 ""
# status 不过滤 (保留退市, 避免生存偏差)
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, 这里捕获。
返回 True=登录成功。
"""
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()
# ======================== 数据下载 ========================
def fetch_one_daily(
bs_code: str, start_date: str, end_date: str,
) -> Optional[pd.DataFrame]:
"""从 baostock 下载日K线全字段不复权数据。
返回 df 列 (保持 baostock 原始字段名):
date, code, open, high, low, close, preclose, volume, amount,
adjustflag, turn, tradestatus, pctChg, peTTM, psTTM, pcfNcfTTM, pbMRQ, isST
失败/空数据返回 None。
"""
global QUERY_COUNT
QUERY_COUNT += 1
rs = bs.query_history_k_data_plus(
bs_code,
BS_FIELDS,
start_date=start_date,
end_date=end_date,
frequency="d", # 日K线
adjustflag="3", # 不复权 raw
)
if rs.error_code != "0":
# 真查询错误 (baostock 端报错) — 抛异常让上层 retry/relogin。
raise RuntimeError(
f"baostock {bs_code} 查询错误: {rs.error_code} {rs.error_msg}"
)
rows = []
while rs.next():
rows.append(rs.get_row_data())
if not rows:
# 合法空数据 (退市/区间外/新股未交易) — 返回空 df, 上层计 "empty" 中性, 不重试。
return pd.DataFrame(columns=OUT_COLS)
df = pd.DataFrame(rows, columns=OUT_COLS)
# 数值转换 (baostock 返回字符串)
numeric_cols = ["open", "high", "low", "close", "preclose", "volume", "amount",
"turn", "pctChg", "peTTM", "psTTM", "pcfNcfTTM", "pbMRQ"]
for col in numeric_cols:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
# 数据自检: dropna OHLC + 过滤非法 OHLC
df = df.dropna(subset=["open", "high", "low", "close"])
bad_ohlc = (df["high"] < df[["open", "close"]].max(axis=1)) | \
(df["low"] > df[["open", "close"]].min(axis=1))
if bad_ohlc.any():
df = df[~bad_ohlc]
if df.empty:
# 自检后全过滤掉 (脏数据) — 也算合法空, 不重试。
return pd.DataFrame(columns=OUT_COLS)
return df.sort_values("date").reset_index(drop=True)
# ======================== marker / parquet 路径 ========================
def parquet_path_for(code: str, exchange: str) -> Path:
"""600519 + SH → OUT_DIR / 600519.SH_daily.parquet。"""
return OUT_DIR / f"{code}.{exchange}_daily.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() -> set:
"""扫 OUT_DIR 所有 marker 构造已完成集合 (真相源)。
marker 文件名格式: .600519.SH_daily.baostock
只有 marker 存在 = 该票成功完成; failed 票无 marker, 下次复跑会重试。
"""
done = set()
if not OUT_DIR.exists():
return done
suffix = ".baostock"
for marker in OUT_DIR.glob(f".*{suffix}"):
name = marker.name
if not name.startswith(".") or not name.endswith(suffix):
continue
stem = name[1:-len(suffix)] # e.g. 600519.SH_daily
# 解析: <code>.<exc>_daily
try:
head, period = stem.rsplit("_", 1)
if period != "daily":
continue
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_one(
code: str,
exchange: str,
start_date: str,
end_date: str,
force: bool,
) -> Tuple[str, int]:
"""下载单只 → 写 parquet + marker。
返回 (status, rows): status ∈ {'ok', 'skipped', 'failed', 'empty'}。
"""
global QUERY_COUNT
# query 计数器检查 (接近 40000 自动停)
if QUERY_COUNT >= DAILY_LIMIT:
logger.warning("query 计数器已达 %d, 接近 48000 上限, graceful stop", QUERY_COUNT)
return "limit_reached", 0
parquet_path = parquet_path_for(code, exchange)
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_one_daily 仅在真错误时抛异常 (走 retry/relogin);
# 空数据返回空 df (合法, 不重试), 成功 break。
df: Optional[pd.DataFrame] = None
for attempt in range(BS_MAX_RETRIES):
try:
df = fetch_one_daily(bs_code, start_date, end_date)
break # fetch 成功 (df 可能空 = 合法无数据)
except (socket.timeout, TimeoutError, OSError) as e:
logger.warning(
"%s socket 异常重试 %d/%d: %s — 强制重登",
code, attempt + 1, BS_MAX_RETRIES, e,
)
if not _relogin():
logger.error("重登失败, 放弃 %s", code)
df = None
break
except Exception as e:
logger.warning(
"%s 下载异常重试 %d/%d: %s — 强制重登",
code, attempt + 1, BS_MAX_RETRIES, e,
)
if not _relogin():
logger.error("重登失败, 放弃 %s", code)
df = None
break
if df is None:
return "failed", 0 # 多次重试仍报错 (真失败, 计断路器)
if df.empty:
return "empty", 0 # 合法无数据 (退市/区间外), 中性, 不计失败不重试
# 写 parquet + marker (只有成功写完且自检通过才写 marker)
try:
OUT_DIR.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 parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="BaoStock 日K线全市场全字段下载 (只产 parquet)",
)
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="指定代码逗号分隔, 如 600519,000001 (测试用)")
p.add_argument("--limit", type=int, default=0, help="限制处理数量 (测试用)")
p.add_argument("--force", action="store_true", help="强制重下, 忽略 marker")
p.add_argument("--shard", type=int, default=None, help="分片号 0-indexed (并行下载用)")
p.add_argument("--total", type=int, default=1, help="总分片数 (与 --shard 配合)")
return p.parse_args()
def resolve_codes(args: argparse.Namespace) -> List[Tuple[str, str]]:
"""根据 args 解析待处理的 (code, exchange) 列表。"""
if args.codes:
# 指定代码: 无前缀, 按代码开头猜交易所 (与 15min 风格一致)
return [
(c.strip(), guess_exchange_by_code(c.strip()))
for c in args.codes.split(",")
if c.strip()
]
return fetch_all_stocks()
def main() -> None:
global QUERY_COUNT
args = parse_args()
start_date = normalize_date(args.start)
end_date = normalize_date(args.end)
logger.info("=" * 60)
logger.info("BaoStock 日K线全市场全字段下载")
logger.info(" 输出目录: %s", OUT_DIR)
logger.info(" 日志文件: %s", LOG_FILE)
logger.info(" 日期范围: %s ~ %s", start_date, end_date)
logger.info(" frequency=d, adjustflag=3 (不复权 raw)")
logger.info(" 全字段: turn/pctChg/peTTM/pbMRQ/psTTM/pcfNcfTTM/isST")
logger.info(" socket.setdefaulttimeout(30) (SIGALRM 替代)")
logger.info(" query 计数器: %d/%d (接近上限自动停)", QUERY_COUNT, DAILY_LIMIT)
logger.info(" 当前时间: %s", datetime.datetime.now().isoformat())
if args.shard is not None:
logger.info(" 分片: shard=%s/%s", args.shard, args.total)
logger.info("=" * 60)
# 登录
if not _login_once():
logger.error("[FATAL] baostock 登录失败 (疑似冷却), 退出")
sys.exit(1)
logger.info("baostock 登录成功")
# 股票列表
try:
all_codes = resolve_codes(args)
except Exception as e:
logger.error("[FATAL] 获取股票列表失败: %s", e)
try:
bs.logout()
except Exception:
pass
sys.exit(1)
logger.info("全市场 A 股 (含退市): %d", len(all_codes))
# limit
if args.limit > 0:
all_codes = all_codes[:args.limit]
logger.info("limit=%d 截断", args.limit)
# 分片
if args.shard is not None and args.total > 1:
all_codes = [
s for i, s in enumerate(all_codes)
if i % args.total == args.shard
]
logger.info("shard=%s/%s 过滤后: %d", args.shard, args.total, len(all_codes))
# 断点续传 (marker 为真相源)
done_set = load_done_set()
if args.force:
todo = all_codes
else:
todo = [(c, e) for c, e in all_codes if (c, e) not in done_set]
logger.info("待处理: %d (已完成 %d)", len(todo), len(done_set))
# 主循环
stats = {"ok": 0, "skipped": 0, "empty": 0, "failed": 0, "limit_reached": 0, "rows": 0}
consec_fail = 0
circuit_triggered = False
limit_reached = False
t_start = time.time()
for i, (code, exc) in enumerate(todo):
# 定期重登保持连接
if i > 0 and i % RELOGIN_EVERY == 0:
logger.info("定期重登 @ %d/%d (query=%d)", i, len(todo), QUERY_COUNT)
if not _relogin():
logger.warning("定期重登失败, 继续 (单次失败不致命)")
try:
status, rows = download_one(code, exc, start_date, end_date, args.force)
except Exception as e:
status, rows = "failed", 0
logger.debug("download_one %s.%s 异常: %s", 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
elif status == "limit_reached":
limit_reached = True
break
# skipped / empty 中性: 不重置也不递增 (不证明 baostock 可用, 也不算失败)
# 断路器: 连续 N 只全 failed → baostock 疑似不可达, 保存进度主动退出
if consec_fail >= CIRCUIT_BREAKER:
logger.error(
"[FATAL] 断路器触发: 连续 %d 只失败, baostock 疑似不可达, "
"退出 (done_set 不含 failed 票, 复跑会重试)",
consec_fail,
)
circuit_triggered = True
break
# 进度日志
if (i + 1) % PROGRESS_LOG_EVERY == 0:
elapsed = time.time() - t_start
logger.info(
"进度 %d/%d ok=%d skipped=%d empty=%d failed=%d rows=%d query=%d (%.0f秒)",
i + 1, len(todo), stats["ok"], stats["skipped"],
stats["empty"], stats["failed"], stats["rows"], QUERY_COUNT, elapsed,
)
# 限速
if i < len(todo) - 1:
time.sleep(BS_INTERVAL)
# 登出
try:
bs.logout()
except Exception:
pass
elapsed = time.time() - t_start
logger.info("=" * 60)
if circuit_triggered:
logger.info("[RESULT] 断路器触发中止, 耗时 %.1f", elapsed)
elif limit_reached:
logger.info("[RESULT] query 计数器触达上限, graceful stop, 耗时 %.1f", elapsed)
else:
logger.info("[RESULT] 完成, 耗时 %.1f", elapsed)
logger.info("统计: %s", json.dumps(stats, ensure_ascii=False))
logger.info("query 总计: %d", QUERY_COUNT)
sys.exit(2 if circuit_triggered else 0)
if __name__ == "__main__":
main()
@@ -0,0 +1,35 @@
# baostock Day1 全量下载 wrapper(成份股 → 日K线 串行,单进程单登录)
# 环境变量覆盖脚本默认 Mac 路径 → Windows 输出路径
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$py = 'C:\Python310\python.exe'
$dir = 'C:\sanguo_vnpy_v2\scripts\data_platform'
$env:BS_DAILY_OUT_DIR = 'C:\sanguo_vnpy_v2\data\daily_baostock'
$env:BS_CONSTITUENT_OUT_DIR = 'C:\sanguo_vnpy_v2\data\constituent_baostock'
$env:PYTHONIOENCODING = 'utf-8'
$wrapper_log = 'C:\sanguo_vnpy_v2\data\baostock_day1_wrapper.log'
$const_log = 'C:\sanguo_vnpy_v2\data\constituent_baostock_download.log'
$daily_log = 'C:\sanguo_vnpy_v2\data\daily_baostock_download.log'
function Log-W($m) {
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $m" | Out-File -FilePath $wrapper_log -Append -Encoding UTF8
}
Log-W "=== WRAPPER START ==="
Log-W "BS_DAILY_OUT_DIR=$($env:BS_DAILY_OUT_DIR)"
Log-W "DiskFree_GB_start=$((Get-PSDrive C).Free / 1GB)"
# PHASE1 成份股历史(快,~20min~1200 query)— 同时验证 VPS 环境(login/网络/parquet写)
Log-W "PHASE1 constituent START"
& $py -X utf8 "$dir\baostock_constituent_download.py" *>&1 | Tee-Object -FilePath $const_log
$const_exit = $LASTEXITCODE
Log-W "PHASE1 constituent DONE exit=$const_exit"
# PHASE2 日K线全市场全字段(慢,~15h,~5537 query)— 主线,过夜跑
Log-W "PHASE2 daily_kline START"
& $py -X utf8 "$dir\baostock_daily_fullmarket_download.py" *>&1 | Tee-Object -FilePath $daily_log
$daily_exit = $LASTEXITCODE
Log-W "PHASE2 daily_kline DONE exit=$daily_exit"
Log-W "DiskFree_GB_end=$((Get-PSDrive C).Free / 1GB)"
Log-W "=== WRAPPER EXIT const_exit=$const_exit daily_exit=$daily_exit ==="
@@ -0,0 +1,24 @@
# baostock Day2a: 复权因子 + 基本资料(5538 query, ~1.5h
# dividend 单独 Day2b 明天跑(38759 query, 7年默认, 单独一天 fresh 预算)
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$py = 'C:\Python310\python.exe'
$dir = 'C:\sanguo_vnpy_v2\scripts\data_platform'
$env:BS_STATIC_OUT_DIR = 'C:\sanguo_vnpy_v2\data\static'
$env:PYTHONIOENCODING = 'utf-8'
$wrapper_log = 'C:\sanguo_vnpy_v2\data\baostock_day2a_wrapper.log'
$dl_log = 'C:\sanguo_vnpy_v2\data\baostock_day2a_download.log'
function Log-W($m) {
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $m" | Out-File -FilePath $wrapper_log -Append -Encoding UTF8
}
Log-W "=== DAY2a START (adjust_factor + basic) ==="
Log-W "BS_STATIC_OUT_DIR=$($env:BS_STATIC_OUT_DIR)"
Log-W "DiskFree_GB_start=$((Get-PSDrive C).Free / 1GB)"
& $py -X utf8 "$dir\baostock_static_download.py" --types basic,adjust_factor *>&1 | Tee-Object -FilePath $dl_log
$exit_code = $LASTEXITCODE
Log-W "DAY2a DONE exit=$exit_code"
Log-W "DiskFree_GB_end=$((Get-PSDrive C).Free / 1GB)"
Log-W "=== DAY2a WRAPPER EXIT ==="
@@ -0,0 +1,23 @@
# baostock Day2b: dividend 除权除息(DIVIDEND_YEAR_RANGE=2020-2026 默认7年, 38759 query
# 定时 07-22 02:00 跑(fresh 48000 预算, 与 Day1/Day2a 隔天)
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$py = 'C:\Python310\python.exe'
$dir = 'C:\sanguo_vnpy_v2\scripts\data_platform'
$env:BS_STATIC_OUT_DIR = 'C:\sanguo_vnpy_v2\data\static'
$env:PYTHONIOENCODING = 'utf-8'
$wrapper_log = 'C:\sanguo_vnpy_v2\data\baostock_day2b_wrapper.log'
$dl_log = 'C:\sanguo_vnpy_v2\data\baostock_day2b_download.log'
function Log-W($m) {
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $m" | Out-File -FilePath $wrapper_log -Append -Encoding UTF8
}
Log-W "=== DAY2b START (dividend 2020-2026) ==="
Log-W "DiskFree_GB_start=$((Get-PSDrive C).Free / 1GB)"
& $py -X utf8 "$dir\baostock_static_download.py" --types dividend *>&1 | Tee-Object -FilePath $dl_log
$exit_code = $LASTEXITCODE
Log-W "DAY2b DONE exit=$exit_code"
Log-W "DiskFree_GB_end=$((Get-PSDrive C).Free / 1GB)"
Log-W "=== DAY2b WRAPPER EXIT ==="
@@ -0,0 +1,443 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""P0 Task3: 退市股 K 线采集 (反幸存者偏差核心)
只在 Mac 跑 (VPS baostock IP 已黑名单 10001011)。Mac staging → rsync VPS → VPS 灌库。
Phase 1 (1 query): bs.query_stock_basic() 无参拉全市场 → 内存过滤
type=='1' & status=='0' & outDate>='2021-01-01'
Phase 2 (per-stock 1 query): 逐只 query_history_k_data_plus 18 字段 → parquet staging
硬约束 (探针实证):
1. 单进程单登录, 严禁并发 (baostock 并发会拉黑封 IP 6-24h)
2. 直连不走代理 (脚本开头 unset proxy)
3. 每只 sleep 0.3s, DAILY_LIMIT=9000 (安全日预算 <10000, 非 48000)
4. 退市最后一日 (outDate) 行的 preclose/volume/amount/turn/tradestatus/pctChg 是空字符串非 0
→ pd.to_numeric(errors='coerce') 自动转 NaN → parquet None, 不能 float()
5. 持久目录 data_cache/staging/delisted_202607 (防重启丢, /tmp 会清)
6. 断点续传 (marker 为真相源), 复跑只补未完成的
退出码: 0=完成, 1=致命错误 (登录失败/列表拉取失败), 2=断路器触发
"""
import datetime
import io
import json
import logging
import os
import socket
import sys
import time
from pathlib import Path
from typing import List, Optional, Tuple
# ======================== 硬约束: unset proxy + socket timeout ========================
for _k in ("http_proxy", "https_proxy", "all_proxy",
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"):
os.environ.pop(_k, None)
socket.setdefaulttimeout(30)
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, ValueError):
pass
import baostock as bs # noqa: E402
import pandas as pd # noqa: E402
# ======================== 配置 ========================
REPO_ROOT = Path(__file__).resolve().parents[2]
OUT_DIR = REPO_ROOT / "data_cache" / "staging" / "delisted_202607"
LOG_DIR = OUT_DIR / "logs"
START_DATE_FLOOR = "1990-12-19" # A 股最早交易日 (plan 规定, 对齐 daily_fullmarket_download)
END_DATE = "2026-07-18" # baostock 探针当天可用末日
DELIST_SINCE = "2021-01-01" # 近 5 年阈值
BS_INTERVAL = 0.3
BS_MAX_RETRIES = 3
PROGRESS_LOG_EVERY = 20
RELOGIN_EVERY = 100
CIRCUIT_BREAKER = 10
BS_FIELDS = ("date,code,open,high,low,close,preclose,volume,amount,"
"adjustflag,turn,tradestatus,pctChg,peTTM,psTTM,pcfNcfTTM,pbMRQ,isST")
OUT_COLS = BS_FIELDS.split(",")
QUERY_COUNT = 0
DAILY_LIMIT = 9000 # 安全预算 <10000, 不是 48000
# ======================== 日志 ========================
def setup_logging() -> logging.Logger:
LOG_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
log_file = LOG_DIR / f"delisted_{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__)
logger = setup_logging()
# ======================== baostock 登录 ========================
def _login_once() -> bool:
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 (疑似冷却/黑名单)", e)
return False
except Exception as e:
logger.error("bs.login() 异常: %s", e)
return False
def _relogin() -> bool:
try:
bs.logout()
except Exception:
pass
if _login_once():
return True
time.sleep(2)
try:
bs.logout()
except Exception:
pass
return _login_once()
# ======================== Phase 1: 扫全市场找近 5 年退市股 ========================
def scan_delisted() -> Tuple[List[dict], List[dict]]:
"""query_stock_basic() 无参拉全市场, 内存过滤近 5 年退市股。
返回:
delisted_list: 退市股 [{code, code_name, ipoDate, outDate, status, type}, ...]
all_basic_stats: 全市场统计 (debug)
"""
global QUERY_COUNT
QUERY_COUNT += 1
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)
all_rows: List[dict] = []
while rs.next():
r = rs.get_row_data()
all_rows.append({
"code": r[idx["code"]],
"code_name": r[idx["code_name"]] if "code_name" in idx else "",
"ipoDate": r[idx["ipoDate"]] if "ipoDate" in idx else "",
"outDate": r[idx["outDate"]] if "outDate" in idx else "",
"type": r[idx["type"]] if "type" in idx else "",
"status": r[idx["status"]] if "status" in idx else "",
})
logger.info("query_stock_basic 返回总行数: %d", len(all_rows))
# 统计
n_type1 = sum(1 for r in all_rows if r["type"] == "1")
n_type1_status0 = sum(1 for r in all_rows if r["type"] == "1" and r["status"] == "0")
n_type1_status0_recent = sum(
1 for r in all_rows
if r["type"] == "1"
and r["status"] == "0"
and r["outDate"]
and r["outDate"] >= DELIST_SINCE
)
all_basic_stats = {
"total_rows": len(all_rows),
"type_1_count": n_type1,
"type_1_status_0": n_type1_status0,
"type_1_status_0_recent5y": n_type1_status0_recent,
}
logger.info("全市场统计: %s", json.dumps(all_basic_stats, ensure_ascii=False))
# 过滤: type==1 (股票) & status==0 (退市) & outDate >= DELIST_SINCE
delisted = [
r for r in all_rows
if r["type"] == "1"
and r["status"] == "0"
and r["outDate"]
and r["outDate"] >= DELIST_SINCE
]
delisted.sort(key=lambda r: r["outDate"])
logger.info("近 5 年退市股: %d 只 (阈值 %s)", len(delisted), DELIST_SINCE)
return delisted, all_basic_stats
# ======================== Phase 2: 逐只 K 线下载 ========================
def fetch_one_kline(bs_code: str, start_date: str, end_date: str) -> Optional[pd.DataFrame]:
"""单只 query_history_k_data_plus。空数据返回空 df (合法, 不重试)。"""
global QUERY_COUNT
QUERY_COUNT += 1
rs = bs.query_history_k_data_plus(
bs_code, BS_FIELDS,
start_date=start_date, end_date=end_date,
frequency="d", adjustflag="3",
)
if rs.error_code != "0":
raise RuntimeError(
f"baostock {bs_code} 查询错误: {rs.error_code} {rs.error_msg}"
)
rows = []
while rs.next():
rows.append(rs.get_row_data())
if not rows:
return pd.DataFrame(columns=OUT_COLS)
df = pd.DataFrame(rows, columns=OUT_COLS)
# 退市当日空字符串 → NaN (关键! 探针实证 sh.600432 末日 volume/amount/turn 为空)
numeric_cols = ["open", "high", "low", "close", "preclose", "volume", "amount",
"turn", "tradestatus", "pctChg", "peTTM", "psTTM", "pcfNcfTTM", "pbMRQ"]
for col in numeric_cols:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
# isST 整数
if "isST" in df.columns:
df["isST"] = pd.to_numeric(df["isST"], errors="coerce").astype("Int64")
# adjustflag 保留字符串
# 数据自检: dropna OHLC
df = df.dropna(subset=["open", "high", "low", "close"])
bad_ohlc = (df["high"] < df[["open", "close"]].max(axis=1)) | \
(df["low"] > df[["open", "close"]].min(axis=1))
if bad_ohlc.any():
df = df[~bad_ohlc]
if df.empty:
return pd.DataFrame(columns=OUT_COLS)
return df.sort_values("date").reset_index(drop=True)
def parse_bs_code(bs_code: str) -> Tuple[str, str]:
"""sh.600074 → ('600074', 'SH')"""
prefix, num = bs_code.split(".", 1)
exc = "SH" if prefix == "sh" else "SZ"
return num, exc
def parquet_path(bs_code: str) -> Path:
num, exc = parse_bs_code(bs_code)
return OUT_DIR / f"{num}.{exc}_delisted.parquet"
def marker_path(parq: Path) -> Path:
return parq.parent / f".{parq.stem}.done"
def load_done_set() -> set:
done = set()
if not OUT_DIR.exists():
return done
for marker in OUT_DIR.glob(".*_delisted.done"):
stem = marker.name[1:-len(".done")] # 600074.SH_delisted
try:
head, period = stem.rsplit("_", 1)
if period != "delisted":
continue
code, exc = head.split(".", 1)
if len(code) == 6 and code.isdigit() and exc in ("SH", "SZ"):
done.add(f"{code}.{exc}")
except ValueError:
continue
return done
def download_one(rec: dict) -> Tuple[str, int]:
"""下载单只退市股 → staging parquet + marker。返回 (status, rows)。"""
global QUERY_COUNT
if QUERY_COUNT >= DAILY_LIMIT:
logger.warning("query 计数器 %d ≥ DAILY_LIMIT %d, graceful stop",
QUERY_COUNT, DAILY_LIMIT)
return "limit_reached", 0
bs_code = rec["code"]
parq = parquet_path(bs_code)
mk = marker_path(parq)
if mk.exists():
return "skipped", 0
# start_date = max(ipoDate, '2000-01-01')
ipo = rec.get("ipoDate", "") or ""
start = max(ipo, START_DATE_FLOOR) if ipo else START_DATE_FLOOR
df: Optional[pd.DataFrame] = None
for attempt in range(BS_MAX_RETRIES):
try:
df = fetch_one_kline(bs_code, start, END_DATE)
break
except (socket.timeout, TimeoutError, OSError) as e:
logger.warning("%s socket 异常重试 %d/%d: %s", bs_code, attempt + 1, BS_MAX_RETRIES, e)
if not _relogin():
logger.error("重登失败, 放弃 %s", bs_code)
df = None
break
except Exception as e:
logger.warning("%s 下载异常重试 %d/%d: %s", bs_code, attempt + 1, BS_MAX_RETRIES, e)
if not _relogin():
logger.error("重登失败, 放弃 %s", bs_code)
df = None
break
if df is None:
return "failed", 0
if df.empty:
# 合法空 (baostock 无该股该区间数据), 写空 marker 防复跑, 但不计入 ok
try:
mk.write_text(datetime.datetime.now().isoformat() + "\tempty")
except Exception:
pass
return "empty", 0
try:
OUT_DIR.mkdir(parents=True, exist_ok=True)
df.to_parquet(parq, index=False)
mk.write_text(datetime.datetime.now().isoformat())
return "ok", len(df)
except Exception as e:
logger.error("写入 %s 失败: %s", parq, e)
return "failed", 0
# ======================== 主流程 ========================
def main() -> None:
t0 = time.time()
logger.info("=" * 60)
logger.info("P0 Task3 退市股 K 线采集 (Mac baostock, 反幸存者偏差)")
logger.info(" 输出: %s", OUT_DIR)
logger.info(" end_date=%s delist_since=%s", END_DATE, DELIST_SINCE)
logger.info(" socket.setdefaulttimeout(30), BS_INTERVAL=%ss", BS_INTERVAL)
logger.info(" DAILY_LIMIT=%d (安全预算 <10000)", DAILY_LIMIT)
logger.info("=" * 60)
if not _login_once():
logger.error("[FATAL] baostock 登录失败 (疑似 Mac IP 黑名单), 退出")
sys.exit(1)
logger.info("baostock 登录成功 (Mac IP 未被封)")
# Phase 1: 扫描
try:
delisted, stats = scan_delisted()
except Exception as e:
logger.error("[FATAL] Phase 1 扫描失败: %s", e)
try:
bs.logout()
except Exception:
pass
sys.exit(1)
list_file = OUT_DIR / "delisted_list.json"
OUT_DIR.mkdir(parents=True, exist_ok=True)
with open(list_file, "w", encoding="utf-8") as f:
json.dump({
"generated_at": datetime.datetime.now().isoformat(),
"filter": {"type": "1", "status": "0", "outDate_gte": DELIST_SINCE},
"stats": stats,
"count": len(delisted),
"items": delisted,
}, f, ensure_ascii=False, indent=2)
logger.info("退市清单写入 %s", list_file)
if not delisted:
logger.info("无近 5 年退市股, 直接退出")
try:
bs.logout()
except Exception:
pass
sys.exit(0)
# Phase 2: 逐只下载
done_set = load_done_set()
todo = [r for r in delisted if parse_bs_code(r["code"])[0] + "." + parse_bs_code(r["code"])[1] not in done_set]
logger.info("Phase 2 待下载: %d (已完成 %d)", len(todo), len(done_set))
stats_dl = {"ok": 0, "skipped": 0, "empty": 0, "failed": 0, "limit_reached": 0, "rows": 0}
consec_fail = 0
circuit = False
limit_reached = False
for i, rec in enumerate(todo):
if i > 0 and i % RELOGIN_EVERY == 0:
logger.info("定期重登 @ %d/%d (query=%d)", i, len(todo), QUERY_COUNT)
if not _relogin():
logger.warning("定期重登失败, 继续")
try:
status, rows = download_one(rec)
except Exception as e:
status, rows = "failed", 0
logger.debug("download_one %s 异常: %s", rec["code"], e)
stats_dl[status] = stats_dl.get(status, 0) + 1
if status == "ok":
stats_dl["rows"] += rows
consec_fail = 0
elif status == "failed":
consec_fail += 1
elif status == "limit_reached":
limit_reached = True
break
if consec_fail >= CIRCUIT_BREAKER:
logger.error("[FATAL] 断路器触发: 连续 %d 只失败, 主动退出", consec_fail)
circuit = True
break
if (i + 1) % PROGRESS_LOG_EVERY == 0:
elapsed = time.time() - t0
logger.info(
"进度 %d/%d ok=%d empty=%d failed=%d skipped=%d rows=%d query=%d (%.0fs)",
i + 1, len(todo), stats_dl["ok"], stats_dl["empty"],
stats_dl["failed"], stats_dl["skipped"], stats_dl["rows"], QUERY_COUNT, elapsed,
)
if i < len(todo) - 1:
time.sleep(BS_INTERVAL)
try:
bs.logout()
except Exception:
pass
elapsed = time.time() - t0
logger.info("=" * 60)
if circuit:
logger.info("[RESULT] 断路器触发中止, 耗时 %.1fs", elapsed)
elif limit_reached:
logger.info("[RESULT] query 上限 graceful stop, 耗时 %.1fs", elapsed)
else:
logger.info("[RESULT] 完成, 耗时 %.1fs", elapsed)
logger.info("下载统计: %s", json.dumps(stats_dl, ensure_ascii=False))
logger.info("query 总计: %d / DAILY_LIMIT %d", QUERY_COUNT, DAILY_LIMIT)
logger.info("=" * 60)
sys.exit(2 if circuit else 0)
if __name__ == "__main__":
main()
@@ -0,0 +1,795 @@
#!/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()
@@ -37,7 +37,11 @@ def prefix_of(sym):
log("start")
u = xd.get_stock_list_in_sector("沪深A股") or []
u = list(set(
(xd.get_stock_list_in_sector("沪深A股") or []) +
(xd.get_stock_list_in_sector("沪深ETF") or []) +
(xd.get_stock_list_in_sector("沪深基金") or [])
))
log(f"universe={len(u)} sample={u[:3]}")
# 1. 下载一次(raw 日线),分批 paced(别猛打券商后端)
@@ -0,0 +1,333 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""baostock 日增量 → daily_baostock_full (VPS 直跑, schtask 17:00)
设计 (用户决策 A: VPS 直跑增量, 守 48000/天):
- 跑前 login 探针: 黑名单(10001011)则 graceful skip (exit 2), 不硬跑
- LOOKBACK_DAYS=7 覆盖周末/节假日 (baostock 日终更新, 17:00 跑当日 bar 已就绪)
- query_stock_basic() 无参 1 拉全 A (含退市, 与 Day1 同口径) → 每股 1 query
- 每股 query_history_k_data_plus(LOOKBACK 窗口) → staging parquet (审计) → executemany INSERT OR REPLACE daily_baostock_full
- 幂等: PK(symbol,exchange,date) + INSERT OR REPLACE, 重复跑安全
- QUERY_COUNT 计数器 + DAILY_LIMIT 防线 (实际 ~5538/天 = 11.5%, 远低于 48000)
- 单进程单登录, sleep 0.3s 限速, socket timeout 30s
预算 (实测): 1(list) + 5537(每股) = ~5538 query/天, 占 48000 的 11.5%, 安全。
耗时: 5537 × (sleep 0.3 + query ~0.3) ≈ 55min, 17:00 跑 18:00 前完。
复用模式:
- baostock_daily_fullmarket_download.py: login/relogin/socket timeout/QUERY_COUNT/fetch_all_stocks
- import_delisted_to_db.py / import_baostock_to_db.py: parse_baostock_code/executemany/WAL
用法 (VPS):
C:\\Python310\\python.exe -X utf8 daily_update_static.py # 正式跑
python daily_update_static.py --limit 10 # 小样本测
python daily_update_static.py --dry-run # 只下不灌
环境变量: LOOKBACK_DAYS (默认7), BS_INCREMENT_OUT_DIR (staging, 默认 data/daily_baostock_increment/{YYYYMMDD})
退出码: 0=完成; 1=致命错误; 2=黑名单 graceful skip; 3=query 超限 graceful stop
"""
import argparse
import datetime
import json
import logging
import os
import socket
import sys
import time
from pathlib import Path
from typing import List, Optional, Tuple
# ======================== 硬约束: unset proxy + socket timeout ========================
# 必须在 import baostock 之前清理 (baostock 底层 urllib 读 proxy env)
for _k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"):
os.environ.pop(_k, None)
socket.setdefaulttimeout(30)
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, ValueError):
pass
import baostock as bs # noqa: E402
import pandas as pd # noqa: E402
# ======================== 配置 (VPS 路径) ========================
BASE = Path(r"C:\sanguo_vnpy_v2")
DB_PATH = BASE / "data" / "quant_trading.db"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "7"))
_date_tag = datetime.date.today().strftime("%Y%m%d")
STAGING = Path(os.environ.get(
"BS_INCREMENT_OUT_DIR",
str(BASE / "data" / "daily_baostock_increment" / _date_tag),
))
# baostock 18 字段 (与 daily_baostock_full 同口径, adjustflag=3 raw)
BS_FIELDS = ("date,code,open,high,low,close,preclose,volume,amount,adjustflag,"
"turn,tradestatus,pctChg,peTTM,psTTM,pcfNcfTTM,pbMRQ,isST")
OUT_COLS = BS_FIELDS.split(",")
NUMERIC_COLS = ["open", "high", "low", "close", "preclose", "volume", "amount",
"turn", "pctChg", "peTTM", "psTTM", "pcfNcfTTM", "pbMRQ"]
BS_INTERVAL = 0.3
BS_MAX_RETRIES = 3
DAILY_LIMIT = 40000 # 防线 (实际 ~5538); 接近则 graceful stop
QUERY_COUNT = 0
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
# ======================== 登录 ========================
def login_once() -> bool:
"""login 探针。黑名单/超时返回 False (调用方 graceful skip)。"""
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", e)
return False
except Exception as e:
logger.error("bs.login() 异常: %s", e)
return False
def relogin() -> bool:
try:
bs.logout()
except Exception:
pass
if login_once():
return True
time.sleep(2)
try:
bs.logout()
except Exception:
pass
return login_once()
# ======================== 股票列表 (query_stock_basic 无参, 含退市) ========================
def fetch_all_stocks() -> List[Tuple[str, str]]:
"""query_stock_basic() 无参 → 全 A (type=1, 含退市 status=0), 与 Day1 同口径。
返回 [(code, exchange), ...], 例 [('600519', 'SH'), ('000001', 'SZ')]。"""
global QUERY_COUNT
QUERY_COUNT += 1
rs = bs.query_stock_basic()
if rs.error_code != "0":
raise RuntimeError(f"query_stock_basic 失败: {rs.error_code} {rs.error_msg}")
fields = list(rs.fields)
idx = {n: i for i, n in enumerate(fields)}
out = []
while rs.next():
r = rs.get_row_data()
type_ = r[idx["type"]] if "type" in idx else ""
if type_ != "1": # 只要股票
continue
bs_code = r[idx["code"]] if "code" in idx else ""
if "." not in bs_code:
continue
prefix, num = bs_code.split(".", 1)
if prefix not in ("sh", "sz") or len(num) != 6 or not num.isdigit():
continue
out.append((num, "SH" if prefix == "sh" else "SZ"))
return out
# ======================== 单股增量下载 ========================
def fetch_one_increment(bs_code: str, start: str, end: str) -> Optional[pd.DataFrame]:
"""query_history_k_data_plus LOOKBACK 窗口。失败抛异常 (上层 retry); 空返回空 df。"""
global QUERY_COUNT
QUERY_COUNT += 1
rs = bs.query_history_k_data_plus(
bs_code, BS_FIELDS, start_date=start, end_date=end,
frequency="d", adjustflag="3",
)
if rs.error_code != "0":
raise RuntimeError(f"{bs_code} 查询错误: {rs.error_code} {rs.error_msg}")
rows = []
while rs.next():
rows.append(rs.get_row_data())
if not rows:
return pd.DataFrame(columns=OUT_COLS)
df = pd.DataFrame(rows, columns=OUT_COLS)
for col in NUMERIC_COLS:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce") # 空字符串→NaN→None
df = df.dropna(subset=["open", "high", "low", "close"])
return df if not df.empty else pd.DataFrame(columns=OUT_COLS)
def parse_baostock_code(code: str) -> Tuple[str, str]:
"""sh.600519 → ('600519', 'SH')。"""
prefix, symbol = code.split(".", 1)
return symbol, prefix.upper()
# ======================== 灌库 ========================
def upsert_to_db(conn, df: pd.DataFrame) -> int:
"""df → INSERT OR REPLACE daily_baostock_full (19 列)。返回写入行数。"""
if df.empty:
return 0
work = df.copy()
work[["symbol", "exchange"]] = work["code"].apply(
lambda x: pd.Series(parse_baostock_code(x))
)
cols = ["symbol", "exchange", "date", "open", "high", "low", "close",
"preclose", "volume", "amount", "adjustflag", "turn", "tradestatus",
"pctChg", "peTTM", "psTTM", "pcfNcfTTM", "pbMRQ", "isST"]
work = work[cols]
work["adjustflag"] = work["adjustflag"].astype(str).replace({"nan": None, "None": None})
# NaN → None (sqlite 写 NULL)
work = work.where(pd.notnull(work), None)
conn.executemany(
"INSERT OR REPLACE INTO daily_baostock_full VALUES "
"(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
work.itertuples(index=False, name=None),
)
return len(work)
# ======================== 主流程 ========================
def parse_args():
p = argparse.ArgumentParser(description="baostock 日增量 → daily_baostock_full (VPS 直跑)")
p.add_argument("--limit", type=int, default=0, help="限制处理股数 (测试)")
p.add_argument("--dry-run", action="store_true", help="只下不灌库")
return p.parse_args()
def main():
global QUERY_COUNT
args = parse_args()
today = datetime.date.today()
end_date = today.strftime("%Y-%m-%d")
start_date = (today - datetime.timedelta(days=LOOKBACK_DAYS)).strftime("%Y-%m-%d")
logger.info("=" * 60)
logger.info("baostock 日增量 → daily_baostock_full (VPS 直跑)")
logger.info(" DB: %s", DB_PATH)
logger.info(" 窗口: %s ~ %s (LOOKBACK=%d)", start_date, end_date, LOOKBACK_DAYS)
logger.info(" staging: %s", STAGING)
logger.info(" dry_run=%s limit=%s", args.dry_run, args.limit or "")
logger.info("=" * 60)
# 1. login 探针 — 黑名单 graceful skip
if not login_once():
logger.error("[SKIP] baostock 登录失败 (疑似黑名单/冷却), graceful exit 2, 不硬跑")
sys.exit(2)
logger.info("baostock 登录成功 (IP 未被封)")
# 2. 全 A 列表
try:
all_codes = fetch_all_stocks()
except Exception as e:
logger.error("[FATAL] 取股票列表失败: %s", e)
try:
bs.logout()
except Exception:
pass
sys.exit(1)
logger.info("全 A (含退市): %d", len(all_codes))
if args.limit > 0:
all_codes = all_codes[: args.limit]
logger.info("limit=%d 截断", args.limit)
# 3. 连 DB (WAL + busy_timeout)
import sqlite3
conn = sqlite3.connect(DB_PATH, timeout=60)
conn.execute("PRAGMA busy_timeout = 60000")
conn.execute("PRAGMA journal_mode = WAL")
rows_before = conn.execute("SELECT COUNT(*) FROM daily_baostock_full").fetchone()[0]
logger.info("灌库前 baseline rows=%d", rows_before)
# 4. 主循环
STAGING.mkdir(parents=True, exist_ok=True)
stats = {"ok": 0, "empty": 0, "failed": 0, "rows_db": 0, "rows_staged": 0}
limit_reached = False
sample_latest = []
t0 = time.time()
if not args.dry_run:
conn.execute("BEGIN")
try:
for i, (code, exc) in enumerate(all_codes):
if QUERY_COUNT >= DAILY_LIMIT:
logger.warning("query 计数 %d 达防线 %d, graceful stop", QUERY_COUNT, DAILY_LIMIT)
limit_reached = True
break
bs_code = f"{'sh' if exc == 'SH' else 'sz'}.{code}"
df = None
for attempt in range(BS_MAX_RETRIES):
try:
df = fetch_one_increment(bs_code, start_date, end_date)
break
except (socket.timeout, TimeoutError, OSError, Exception) as e:
logger.warning("%s 重试 %d/%d: %s", code, attempt + 1, BS_MAX_RETRIES, e)
if not relogin():
df = None
break
if df is None:
stats["failed"] += 1
elif df.empty:
stats["empty"] += 1 # 退市/停牌/区间外, 合法空
else:
# staging (审计)
try:
df.to_parquet(STAGING / f"{code}.{exc}_incr.parquet", index=False)
stats["rows_staged"] += len(df)
except Exception as e:
logger.debug("staging 写失败 %s: %s", code, e)
# 灌库
if not args.dry_run:
n = upsert_to_db(conn, df)
stats["rows_db"] += n
stats["ok"] += 1
if len(sample_latest) < 5:
sample_latest.append((code, exc, len(df), df["date"].max()))
if (i + 1) % 500 == 0:
logger.info("进度 %d/%d ok=%d empty=%d failed=%d query=%d (%.0fs)",
i + 1, len(all_codes), stats["ok"], stats["empty"],
stats["failed"], QUERY_COUNT, time.time() - t0)
if i < len(all_codes) - 1:
time.sleep(BS_INTERVAL)
if args.dry_run:
logger.info("[DRY-RUN] 不灌库")
else:
conn.execute("COMMIT")
except Exception as e:
if not args.dry_run:
conn.execute("ROLLBACK")
logger.error("[FATAL] 事务回滚: %s", e)
try:
bs.logout()
except Exception:
pass
sys.exit(1)
# 5. 统计
rows_after = conn.execute("SELECT COUNT(*) FROM daily_baostock_full").fetchone()[0]
conn.close()
try:
bs.logout()
except Exception:
pass
logger.info("=" * 60)
logger.info("[RESULT] dry_run=%s 耗时 %.0f", args.dry_run, time.time() - t0)
logger.info(" ok=%d empty=%d failed=%d limit_reached=%s",
stats["ok"], stats["empty"], stats["failed"], limit_reached)
logger.info(" staging 行=%d 灌库行=%d query 总计=%d",
stats["rows_staged"], stats["rows_db"], QUERY_COUNT)
logger.info(" daily_baostock_full: %d%d (+%d)", rows_before, rows_after, rows_after - rows_before)
logger.info(" 样本 latest: %s", sample_latest)
logger.info("=" * 60)
sys.exit(3 if limit_reached else 0)
if __name__ == "__main__":
main()
@@ -0,0 +1,12 @@
# daily_update_static_wrapper.ps1 — baostock 日增量 schtask wrapper (VPS SYSTEM)
# schtask: sanguo-bs-daily-increment /sc daily /st 17:00 /ru SYSTEM /sd 2026/07/23
$env:http_proxy = ''
$env:https_proxy = ''
$env:all_proxy = ''
Set-Location C:\sanguo_vnpy_v2
$ts = Get-Date -Format 'yyyyMMdd_HHmmss'
$logDir = 'C:\sanguo_vnpy_v2\data\daily_baostock_increment'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$log = Join-Path $logDir "log_$ts.txt"
C:\Python310\python.exe -X utf8 C:\sanguo_vnpy_v2\scripts\data_platform\daily_update_static.py *>> $log
exit $LASTEXITCODE
+5 -1
View File
@@ -111,7 +111,11 @@ def merge_write(code, dividend_type, kind):
def main():
end, start = today_str(), start_str()
log(f"start LOOKBACK={LOOKBACK} window={start}~{end}")
u = xd.get_stock_list_in_sector("沪深A股") or []
u = list(set(
(xd.get_stock_list_in_sector("沪深A股") or []) +
(xd.get_stock_list_in_sector("沪深ETF") or []) +
(xd.get_stock_list_in_sector("沪深基金") or [])
))
if not u:
log("FATAL: empty universeminiQMT 未连?)")
os._exit(2)
@@ -0,0 +1,387 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
导入baostock数据到quant_trading.db新表
保留dbbardata表不变,创建3个新表:
1. daily_baostock_full - 日K线数据
2. bs_index_constituent - 指数成份股快照
3. bs_adjust_factor - 复权因子
VPS路径:
- DB: C:\sanguo_vnpy_v2\data\quant_trading.db
- 日K: data\daily_baostock\*.parquet (5537 files)
- 成分: data\constituent_baostock\*.parquet (3219 files)
- 复权: data\static\adjust_factor\*.parquet (5537 files)
"""
import sqlite3
import pandas as pd
from pathlib import Path
import logging
from datetime import datetime
import sys
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler('import_baostock.log', encoding='utf-8')
]
)
logger = logging.getLogger(__name__)
# VPS路径配置
BASE_DIR = Path(r'C:\sanguo_vnpy_v2')
DB_PATH = BASE_DIR / 'data' / 'quant_trading.db'
DAILY_DIR = BASE_DIR / 'data' / 'daily_baostock'
CONSTITUENT_DIR = BASE_DIR / 'data' / 'constituent_baostock'
ADJUST_DIR = BASE_DIR / 'data' / 'static' / 'adjust_factor'
def parse_baostock_code(code: str) -> tuple:
"""
解析baostock代码格式: sh.600000 / sz.000001
返回: (symbol, exchange)
例: ('600000', 'SH')
"""
if '.' not in code:
return code, 'SZ' # 默认深圳
prefix, symbol = code.split('.')
exchange = prefix.upper()
return symbol, exchange
def create_tables(conn):
"""创建3个新表"""
cursor = conn.cursor()
# 1. daily_baostock_full
cursor.execute('''
CREATE TABLE IF NOT EXISTS daily_baostock_full (
symbol TEXT NOT NULL,
exchange TEXT NOT NULL,
date TEXT NOT NULL,
open REAL,
high REAL,
low REAL,
close REAL,
preclose REAL,
volume REAL,
amount REAL,
adjustflag TEXT,
turn REAL,
tradestatus REAL,
pctChg REAL,
peTTM REAL,
psTTM REAL,
pcfNcfTTM REAL,
pbMRQ REAL,
isST REAL,
PRIMARY KEY (symbol, exchange, date)
)
''')
# 2. bs_index_constituent
cursor.execute('''
CREATE TABLE IF NOT EXISTS bs_index_constituent (
updateDate TEXT NOT NULL,
index_code TEXT NOT NULL,
code TEXT NOT NULL,
code_name TEXT,
PRIMARY KEY (updateDate, index_code, code)
)
''')
# 3. bs_adjust_factor
cursor.execute('''
CREATE TABLE IF NOT EXISTS bs_adjust_factor (
code TEXT NOT NULL,
dividOperateDate TEXT NOT NULL,
foreAdjustFactor REAL,
backAdjustFactor REAL,
adjustFactor REAL,
PRIMARY KEY (code, dividOperateDate)
)
''')
# 创建索引加速查询
cursor.execute('CREATE INDEX IF NOT EXISTS idx_daily_symbol_date ON daily_baostock_full(symbol, exchange, date)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_constituent_index_date ON bs_index_constituent(index_code, updateDate)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_adjust_code ON bs_adjust_factor(code)')
conn.commit()
logger.info("✅ 表创建完成")
def import_daily_data(conn, chunk_size=100):
"""导入日K线数据"""
logger.info("📊 开始导入日K线数据...")
cursor = conn.cursor()
# 获取所有parquet文件
parquet_files = list(DAILY_DIR.glob('*.parquet'))
total_files = len(parquet_files)
logger.info(f"找到 {total_files} 个日K线文件")
processed = 0
total_rows = 0
for file_path in parquet_files:
try:
# 读取parquet
df = pd.read_parquet(file_path)
# 转换代码格式
df[['symbol', 'exchange']] = df['code'].apply(
lambda x: pd.Series(parse_baostock_code(x))
)
# 重命名列匹配数据库
df = df.rename(columns={'pctChg': 'pctChg', 'isST': 'isST', 'peTTM': 'peTTM',
'psTTM': 'psTTM', 'pcfNcfTTM': 'pcfNcfTTM', 'pbMRQ': 'pbMRQ'})
# 选择需要的列(确保顺序与表结构一致)
columns = ['symbol', 'exchange', 'date', 'open', 'high', 'low', 'close',
'preclose', 'volume', 'amount', 'adjustflag', 'turn', 'tradestatus',
'pctChg', 'peTTM', 'psTTM', 'pcfNcfTTM', 'pbMRQ', 'isST']
df = df[columns]
# 批量 executemany(替代逐行 iterrows;占位符严格 19 个 = 表 19 列,修原 20 占位符 bug)
df['adjustflag'] = df['adjustflag'].astype(str)
cursor.executemany(
'INSERT OR REPLACE INTO daily_baostock_full VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
df.itertuples(index=False, name=None),
)
total_rows += len(df)
processed += 1
# 每100个文件提交一次
if processed % chunk_size == 0:
conn.commit()
logger.info(f"进度: {processed}/{total_files} ({processed/total_files*100:.1f}%) - {total_rows} rows")
except Exception as e:
logger.error(f"处理文件 {file_path.name} 失败: {e}")
continue
conn.commit()
logger.info(f"✅ 日K线数据导入完成: {total_rows} rows from {processed} files")
return total_rows
def import_constituent_data(conn, chunk_size=100):
"""导入指数成分股数据"""
logger.info("📊 开始导入指数成分股数据...")
cursor = conn.cursor()
# 获取所有parquet文件
parquet_files = list(CONSTITUENT_DIR.glob('*.parquet'))
total_files = len(parquet_files)
logger.info(f"找到 {total_files} 个成分股文件")
processed = 0
total_rows = 0
for file_path in parquet_files:
try:
# 从文件名提取index_code
# 例: hs300_2006-01-02.parquet -> hs300
index_code = file_path.stem.split('_')[0]
# 读取parquet
df = pd.read_parquet(file_path)
df['index_code'] = index_code
# 选择需要的列
df = df[['updateDate', 'index_code', 'code', 'code_name']]
# 插入数据库
for _, row in df.iterrows():
cursor.execute('''
INSERT OR REPLACE INTO bs_index_constituent VALUES (?, ?, ?, ?)
''', tuple(row))
total_rows += len(df)
processed += 1
# 每100个文件提交一次
if processed % chunk_size == 0:
conn.commit()
logger.info(f"进度: {processed}/{total_files} ({processed/total_files*100:.1f}%) - {total_rows} rows")
except Exception as e:
logger.error(f"处理文件 {file_path.name} 失败: {e}")
continue
conn.commit()
logger.info(f"✅ 成分股数据导入完成: {total_rows} rows from {processed} files")
return total_rows
def import_adjust_factor_data(conn, chunk_size=100):
"""导入复权因子数据"""
logger.info("📊 开始导入复权因子数据...")
cursor = conn.cursor()
# 获取所有parquet文件
parquet_files = list(ADJUST_DIR.glob('*.parquet'))
total_files = len(parquet_files)
logger.info(f"找到 {total_files} 个复权因子文件")
processed = 0
total_rows = 0
for file_path in parquet_files:
try:
# 读取parquet
df = pd.read_parquet(file_path)
# 选择需要的列
df = df[['code', 'dividOperateDate', 'foreAdjustFactor', 'backAdjustFactor', 'adjustFactor']]
# 插入数据库
for _, row in df.iterrows():
cursor.execute('''
INSERT OR REPLACE INTO bs_adjust_factor VALUES (?, ?, ?, ?, ?)
''', tuple(row))
total_rows += len(df)
processed += 1
# 每100个文件提交一次
if processed % chunk_size == 0:
conn.commit()
logger.info(f"进度: {processed}/{total_files} ({processed/total_files*100:.1f}%) - {total_rows} rows")
except Exception as e:
logger.error(f"处理文件 {file_path.name} 失败: {e}")
continue
conn.commit()
logger.info(f"✅ 复权因子数据导入完成: {total_rows} rows from {processed} files")
return total_rows
def verify_import(conn):
"""验证导入结果"""
logger.info("🔍 开始验证导入结果...")
cursor = conn.cursor()
# 统计各表行数
cursor.execute('SELECT COUNT(*) FROM daily_baostock_full')
daily_count = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM bs_index_constituent')
constituent_count = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM bs_adjust_factor')
adjust_count = cursor.fetchone()[0]
# 抽样检查日K线数据
cursor.execute('''
SELECT symbol, date, close, turn, peTTM
FROM daily_baostock_full
WHERE symbol='000001' AND exchange='SZ'
ORDER BY date DESC LIMIT 5
''')
sample_daily = cursor.fetchall()
# 检查日期范围
cursor.execute('SELECT MIN(date), MAX(date) FROM daily_baostock_full')
date_range = cursor.fetchone()
# 统计唯一symbol数量
cursor.execute('SELECT COUNT(DISTINCT symbol) FROM daily_baostock_full')
symbol_count = cursor.fetchone()[0]
logger.info(f"📊 验证结果:")
logger.info(f" daily_baostock_full: {daily_count:,} rows, {symbol_count} symbols")
logger.info(f" 日期范围: {date_range[0]} ~ {date_range[1]}")
logger.info(f" bs_index_constituent: {constituent_count:,} rows")
logger.info(f" bs_adjust_factor: {adjust_count:,} rows")
if sample_daily:
logger.info(f" 抽样 000001.SZ 最新5行:")
for row in sample_daily:
logger.info(f" {row[0]} {row[1]}: close={row[2]:.2f}, turn={row[3]:.2f}, peTTM={row[4]:.2f}")
return {
'daily_count': daily_count,
'constituent_count': constituent_count,
'adjust_count': adjust_count,
'symbol_count': symbol_count,
'date_range': date_range,
'sample_daily': sample_daily
}
def main():
"""主函数"""
logger.info("=" * 50)
logger.info("开始导入baostock数据到quant_trading.db")
logger.info(f"DB路径: {DB_PATH}")
logger.info(f"日K目录: {DAILY_DIR}")
logger.info(f"成分目录: {CONSTITUENT_DIR}")
logger.info(f"复权目录: {ADJUST_DIR}")
logger.info("=" * 50)
# 检查目录是否存在
if not DB_PATH.exists():
logger.error(f"❌ DB不存在: {DB_PATH}")
return
for dir_path in [DAILY_DIR, CONSTITUENT_DIR, ADJUST_DIR]:
if not dir_path.exists():
logger.error(f"❌ 目录不存在: {dir_path}")
return
# 连接数据库(WAL + busy_timeout防写竞争)
conn = sqlite3.connect(DB_PATH, timeout=60)
conn.execute('PRAGMA busy_timeout = 60000') # 60秒
conn.execute('PRAGMA journal_mode = WAL') # WAL模式
try:
# 记录开始时间
start_time = datetime.now()
# 1. 创建表
logger.info("🔨 创建表结构...")
create_tables(conn)
# 2. 导入日K线数据
daily_count = import_daily_data(conn)
# 3. 导入成分股数据
constituent_count = import_constituent_data(conn)
# 4. 导入复权因子数据
adjust_count = import_adjust_factor_data(conn)
# 5. 验证结果
results = verify_import(conn)
# 计算耗时
elapsed = (datetime.now() - start_time).total_seconds()
logger.info("=" * 50)
logger.info(f"✅ 导入完成! 总耗时: {elapsed/60:.1f} 分钟")
logger.info("=" * 50)
# 输出最终报告
print("\n" + "=" * 50)
print("📊 导入完成报告")
print("=" * 50)
print(f"daily_baostock_full: {results['daily_count']:,} rows / {results['symbol_count']} symbols / 日期范围 {results['date_range'][0]} ~ {results['date_range'][1]}")
print(f"bs_index_constituent: {results['constituent_count']:,} rows")
print(f"bs_adjust_factor: {results['adjust_count']:,} rows")
print("=" * 50)
return results
except Exception as e:
logger.error(f"❌ 导入失败: {e}")
import traceback
logger.error(traceback.format_exc())
return None
finally:
conn.close()
if __name__ == '__main__':
main()
@@ -0,0 +1,14 @@
# baostock 灌库一次性 wrapperschtask /ru SYSTEM 跑,独立 ssh 会话)
# 修 import_baostock_to_db.py 后重跑(幂等 INSERT OR REPLACE,空表重灌)
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
Set-Location C:\sanguo_vnpy_v2
$env:PYTHONIOENCODING = 'utf-8'
$env:VNPY_DB_PATH = 'C:\sanguo_vnpy_v2\data\quant_trading.db'
$log = 'C:\sanguo_vnpy_v2\data\import_bs_wrapper.log'
"=== IMPORT_BS START $(Get-Date -Format o) ===" | Out-File -FilePath $log -Encoding utf8
"DiskFree_GB_start=$((Get-PSDrive C).Free / 1GB)" | Out-File -FilePath $log -Encoding utf8 -Append
& C:\Python310\python.exe -X utf8 C:\sanguo_vnpy_v2\scripts\data_platform\import_baostock_to_db.py *>&1 | Tee-Object -FilePath $log -Append
$exit_code = $LASTEXITCODE
"=== IMPORT_BS DONE $(Get-Date -Format o) exit=$exit_code ===" | Out-File -FilePath $log -Encoding utf8 -Append
"DiskFree_GB_end=$((Get-PSDrive C).Free / 1GB)" | Out-File -FilePath $log -Encoding utf8 -Append
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""导入退市股 K 线到 VPS quant_trading.db 的 daily_baostock_full 表 (INSERT OR REPLACE)
在 VPS 上跑 (ssh VPS). 不用 baostock.
目标:
C:\\sanguo_vnpy_v2\\data\\delisted_kline\\*.parquet (rsync Mac→VPS 上来的)
→ daily_baostock_full (19 列, PK(symbol, exchange, date))
INSERT OR REPLACE, 不碰 dbbardata 表.
空字符串字段已在 Mac 下载阶段 pd.to_numeric(errors='coerce') 转 NaN, parquet 存 None;
这里 pd.read_parquet → sqlite executemany 会写 NULL, 不会触发类型错误.
用法:
python import_delisted_to_db.py # 全量灌
python import_delisted_to_db.py --dry-run # 只校验不入库
"""
import argparse
import logging
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
import pandas as pd
# Windows VPS 路径
BASE_DIR = Path(r"C:\sanguo_vnpy_v2")
DB_PATH = BASE_DIR / "data" / "quant_trading.db"
DELISTED_DIR = BASE_DIR / "data" / "delisted_kline"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
def parse_baostock_code(code: str) -> tuple:
"""sh.600074 → ('600074', 'SH')"""
if "." not in code:
return code, "SZ"
prefix, symbol = code.split(".", 1)
return symbol, prefix.upper()
def table_exists(conn: sqlite3.Connection, name: str) -> bool:
cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (name,)
)
return cur.fetchone() is not None
def get_row_count(conn: sqlite3.Connection, table: str, where: str = "") -> int:
sql = f"SELECT COUNT(*) FROM {table}"
if where:
sql += f" WHERE {where}"
return conn.execute(sql).fetchone()[0]
def import_one_parquet(conn: sqlite3.Connection, parq: Path) -> tuple:
"""返回 (rows_inserted, symbol, exchange, max_date)"""
df = pd.read_parquet(parq)
if df.empty:
return 0, "", "", ""
# 拆 baostock code → (symbol, exchange)
df[["symbol", "exchange"]] = df["code"].apply(
lambda x: pd.Series(parse_baostock_code(x))
)
columns = ["symbol", "exchange", "date", "open", "high", "low", "close",
"preclose", "volume", "amount", "adjustflag", "turn", "tradestatus",
"pctChg", "peTTM", "psTTM", "pcfNcfTTM", "pbMRQ", "isST"]
df = df[columns]
# adjustflag 保留字符串, NaN→None 由 pandas → sqlite 自动转
df["adjustflag"] = df["adjustflag"].astype(str).replace({"nan": None, "None": None})
conn.executemany(
"INSERT OR REPLACE INTO daily_baostock_full VALUES "
"(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
df.itertuples(index=False, name=None),
)
return len(df), df["symbol"].iloc[0], df["exchange"].iloc[0], df["date"].max()
def main():
p = argparse.ArgumentParser()
p.add_argument("--dry-run", action="store_true", help="只校验不入库")
p.add_argument("--dir", default=str(DELISTED_DIR), help="退市 K 线 parquet 目录")
args = p.parse_args()
src_dir = Path(args.dir)
logger.info("=" * 60)
logger.info("退市股 K 线灌库 → daily_baostock_full (INSERT OR REPLACE)")
logger.info(" DB: %s", DB_PATH)
logger.info(" 目录: %s", src_dir)
logger.info(" dry_run=%s", args.dry_run)
logger.info("=" * 60)
if not DB_PATH.exists():
logger.error("[FATAL] DB 不存在: %s", DB_PATH)
sys.exit(1)
if not src_dir.exists():
logger.error("[FATAL] 目录不存在: %s", src_dir)
sys.exit(1)
parquets = sorted(src_dir.glob("*_delisted.parquet"))
logger.info("找到 %d 个 parquet 文件", len(parquets))
if not parquets:
logger.error("[FATAL] 无 parquet, 检查 rsync 是否完成")
sys.exit(1)
conn = sqlite3.connect(DB_PATH, timeout=60)
conn.execute("PRAGMA busy_timeout = 60000")
conn.execute("PRAGMA journal_mode = WAL")
if not table_exists(conn, "daily_baostock_full"):
logger.error("[FATAL] daily_baostock_full 表不存在 (先跑 import_baostock_to_db.py)")
sys.exit(1)
# 灌前 baseline
rows_before = get_row_count(conn, "daily_baostock_full")
symbols_before = get_row_count(
conn, "daily_baostock_full", "1 GROUP BY symbol, exchange"
)
logger.info("灌库前 baseline: rows=%d, distinct(symbol,exchange)=%d",
rows_before, symbols_before)
total_rows = 0
processed = 0
failed = 0
sample_inserted = []
conn.execute("BEGIN")
try:
for i, parq in enumerate(parquets):
try:
rows, symbol, exc, maxd = import_one_parquet(conn, parq)
total_rows += rows
processed += 1
if i < 5:
sample_inserted.append((symbol, exc, rows, maxd))
if (i + 1) % 20 == 0:
logger.info("进度 %d/%d rows=%d (latest: %s.%s max_date=%s)",
i + 1, len(parquets), total_rows, symbol, exc, maxd)
except Exception as e:
failed += 1
logger.error("处理 %s 失败: %s", parq.name, e)
continue
if args.dry_run:
conn.execute("ROLLBACK")
logger.info("[DRY-RUN] 回滚, 不入库")
else:
conn.execute("COMMIT")
except Exception as e:
conn.execute("ROLLBACK")
logger.error("[FATAL] 事务回滚: %s", e)
sys.exit(2)
finally:
conn.close()
# 灌后统计 (重新连)
conn2 = sqlite3.connect(DB_PATH, timeout=60)
rows_after = get_row_count(conn2, "daily_baostock_full")
symbols_after = get_row_count(
conn2, "daily_baostock_full", "1 GROUP BY symbol, exchange"
)
conn2.close()
logger.info("=" * 60)
logger.info("[RESULT] 灌库完成 dry_run=%s", args.dry_run)
logger.info(" 处理 parquet: %d (failed=%d)", processed, failed)
logger.info(" 插入/替换行: %d", total_rows)
logger.info(" daily_baostock_full: %d%d (+%d rows)",
rows_before, rows_after, rows_after - rows_before)
logger.info(" distinct(symbol,exchange): %d%d (+%d)",
symbols_before, symbols_after, symbols_after - symbols_before)
logger.info(" 样本 (前 5): %s", sample_inserted)
logger.info("=" * 60)
if __name__ == "__main__":
main()
@@ -208,7 +208,8 @@ def main():
print(f'FATAL: DB not found: {DB_PATH}', flush=True)
sys.exit(1)
conn = sqlite3.connect(DB_PATH)
conn = sqlite3.connect(DB_PATH, timeout=60)
conn.execute("PRAGMA busy_timeout=60000") # wait vs concurrent web writer (WAL single-writer)
t_start = time.time()
grand_rows = 0
@@ -0,0 +1,627 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""历史成份股采集 (治幸存者偏差) - P0 Task1
数据源 (探针 2026-07-21 实证):
A. 深证/国证 adjust : ak.index_detail_hist_adjust_cni(sym)
399001/399006/399330/399005
返回 10 期半年度调样 (2022-06~2026-12)
: 开始日期/结束日期/样本代码/样本简称/所属行业/调整类型(OLD/+/-/备选)
陷阱: 必须 _adjust_cni (hist_cni 只返回最新一期, 陷阱)
B. 中证1000/2000快照: ak.index_stock_cons_csindex(sym)
000852/932000 当前快照 (历史不可补 = 已知 gap)
C. 新浪交叉校验(可选): 000300/000016/000905 table[3] ([0]陷阱)
baostock 已有 300/500/50 主源, 新浪仅交叉校验
输出 (OUT_DIR/{code}_*.parquet):
{code}_adjust.parquet - 原始调整记录 (统一 schema)
{code}_union.parquet - 曾经入选集 (OLD + -, 治幸存者偏差)
{code}_current.parquet - 最新一期成份股 (OLD +)
{code}_snapshot.parquet - csindex 当前快照 ( 000852/932000)
{code}_sina.parquet - 新浪交叉校验 (可选)
硬约束:
1. 单进程串行, sleep 1.0~1.5s (akshare/新浪防封)
2. 直连不走代理 (unset proxy)
3. 不碰 baostock (Task3 退市探针在用, 并发会封 IP)
4. 环境变量 INDEX_HIST_OUT_DIR 覆盖默认 Mac 路径
用法:
python index_const_hist_download.py # 全量 A+B+C
python index_const_hist_download.py --parts A # 只 A 深证/国证
python index_const_hist_download.py --parts A B # A + B
python index_const_hist_download.py --skip-sina # 跳过 C 新浪
退出码: 0=完成, 1=致命错误
"""
import argparse
import logging
import os
import socket
import sys
import time
from pathlib import Path
from typing import List, Optional, Tuple
# ======================== 硬约束: unset proxy + socket timeout ========================
for _k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"):
os.environ.pop(_k, None)
socket.setdefaulttimeout(30)
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, ValueError):
pass
import pandas as pd # noqa: E402
# ======================== 配置 ========================
DEFAULT_OUT_DIR = "/Users/chufeng/.openclaw/sanguo_projects/sanguo_vnpy_v2/data/index_const_hist"
OUT_DIR = Path(os.environ.get("INDEX_HIST_OUT_DIR", DEFAULT_OUT_DIR))
LOG_DIR = OUT_DIR / "logs"
# A. 深证/国证 adjust 指数 (cni 来源, 探针实证可返回 10 期半年度调样)
CNI_ADJUST_INDICES = [
("399001", "深证成指"),
("399006", "创业板指"),
("399330", "深证100"),
("399005", "中小板指"),
]
# B. 中证1000/2000 csindex 当前快照 (历史不可补 = 已知 gap)
CSINDEX_SNAPSHOT_INDICES = [
("000852", "中证1000"),
("932000", "中证2000"),
]
# C. 新浪交叉校验 (baostock 主源已有 300/500/50, 此处仅校验)
SINA_CROSS_INDICES = [
("000300", "沪深300"),
("000905", "中证500"),
("000016", "上证50"),
]
# 限速 (秒)
SLEEP_AKSHARE = 1.2 # akshare 每次调用间隔
SLEEP_SINA_PAGE = 1.5 # 新浪翻页间隔
SLEEP_BETWEEN_PARTS = 2.0 # 阶段间额外间隔
# 新浪翻页上限 (探针: 7-9 页)
SINA_MAX_PAGES = 12
# ======================== 日志 ========================
def setup_logging() -> Tuple[logging.Logger, Path]:
"""配置 root logger: 同时写 stdout + 文件。"""
OUT_DIR.mkdir(parents=True, exist_ok=True)
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_file = LOG_DIR / f"index_const_hist_{time.strftime('%Y%m%d_%H%M%S')}.log"
logger = logging.getLogger("index_const_hist")
logger.setLevel(logging.INFO)
logger.handlers.clear()
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S")
fh = logging.FileHandler(log_file, encoding="utf-8")
fh.setFormatter(fmt)
logger.addHandler(fh)
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(fmt)
logger.addHandler(sh)
logger.info(f"OUT_DIR = {OUT_DIR}")
logger.info(f"log_file = {log_file}")
return logger, log_file
# ======================== A. 深证/国证 adjust ========================
def _normalize_akshare_code(raw: str) -> str:
"""akshare 返回的样本代码可能是 '000001''sz000001' / 'sh600000' 形式, 归一化成 6 位代码."""
if not isinstance(raw, str):
return ""
s = raw.strip()
# 去 sh/sz/bj 前缀
for prefix in ("sh", "sz", "bj", "SH", "SZ", "BJ"):
if s.startswith(prefix):
s = s[len(prefix):]
break
return s.strip()
def download_cni_adjust(index_code: str, index_name: str, logger: logging.Logger) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame], Optional[pd.DataFrame]]:
"""A. ak.index_detail_hist_adjust_cni 拉取 + 重建.
Returns:
(adjust_df, union_df, current_df)
adjust_df - 原始调整记录统一 schema
union_df - 曾经入选集 (OLD + -), 治幸存者偏差
current_df - 最新一期成份股 (OLD +)
"""
import akshare as ak
logger.info(f"[A] {index_code} {index_name} ak.index_detail_hist_adjust_cni ...")
try:
raw = ak.index_detail_hist_adjust_cni(symbol=index_code)
except Exception as e:
logger.error(f"[A] {index_code} ak.Index_detail_hist_adjust_cni failed: {e}")
return None, None, None
if raw is None or len(raw) == 0:
logger.warning(f"[A] {index_code} empty result")
return None, None, None
logger.info(f"[A] {index_code} raw rows={len(raw)}, cols={list(raw.columns)}")
# 统一 schema (探针实证列名: 开始日期/结束日期/样本代码/样本简称/所属行业/调整类型)
col_map = {
"开始日期": "updateDate",
"结束日期": "endDate",
"样本代码": "code",
"样本简称": "code_name",
"所属行业": "industry",
"调整类型": "adjust_type",
}
df = raw.rename(columns=col_map).copy()
# 归一化代码
df["code"] = df["code"].apply(_normalize_akshare_code)
df["index_code"] = index_code
df["index_name"] = index_name
# 幸存者偏差治理: 重建
# 当期成份股 = OLD + (old 留存 + 新调入)
# 曾经入选 = OLD ∪ + ∪ - (含被踢)
def _codes_for_types(types: set) -> set:
mask = df["adjust_type"].isin(types)
return set(df.loc[mask, "code"].tolist())
old_codes = _codes_for_types({"OLD"})
plus_codes = _codes_for_types({"+", "新增", "调入"})
minus_codes = _codes_for_types({"-", "删除", "调出"})
current_codes = old_codes | plus_codes
ever_codes = old_codes | plus_codes | minus_codes
logger.info(
f"[A] {index_code} adjust breakdown: OLD={len(old_codes)} "
f"+={len(plus_codes)} -={len(minus_codes)}"
)
logger.info(
f"[A] {index_code} UNION (ever in)={len(ever_codes)}, "
f"CURRENT (OLD +)={len(current_codes)}"
)
# union df: 去重成份股 + 标注是否在当前
union_records = []
all_codes_with_name = (
df[df["adjust_type"].isin({"OLD", "+", "-", "新增", "调入", "删除", "调出"})]
.drop_duplicates(subset=["code"])
[["code", "code_name", "industry"]]
)
for _, row in all_codes_with_name.iterrows():
code = row["code"]
union_records.append({
"code": code,
"code_name": row.get("code_name", ""),
"industry": row.get("industry", ""),
"index_code": index_code,
"index_name": index_name,
"in_current": code in current_codes,
"was_removed": code in minus_codes and code not in current_codes,
})
union_df = pd.DataFrame(union_records)
# current df: 最新一期 (endDate 最大那期的 OLD +)
current_df = df[df["adjust_type"].isin({"OLD", "+", "新增", "调入"})][
["updateDate", "endDate", "code", "code_name", "industry", "index_code"]
].copy()
return df[["updateDate", "endDate", "code", "code_name", "industry", "adjust_type", "index_code", "index_name"]], union_df, current_df
# ======================== B. 中证1000/2000 快照 ========================
def _fetch_csindex_snapshot_with_timeout(index_code: str, timeout: float = 15.0):
"""用线程 + future.result(timeout) 包装 csindex 接口 (接口常超时挂死).
关键坑: ThreadPoolExecutor with 块退出时 shutdown(wait=True) 会卡住等 worker,
timeout 就失效了. 这里手动 shutdown(wait=False) worker 在后台自生自灭.
"""
import akshare as ak
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutTimeout
ex = ThreadPoolExecutor(max_workers=1)
fut = ex.submit(ak.index_stock_cons_csindex, symbol=index_code)
try:
result = fut.result(timeout=timeout)
ex.shutdown(wait=False)
return result, None
except FutTimeout:
ex.shutdown(wait=False) # 不等 worker, 主线程立即返回
return None, "timeout"
except Exception as e:
ex.shutdown(wait=False)
return None, str(e)
def download_csindex_snapshot(index_code: str, index_name: str, logger: logging.Logger) -> Optional[pd.DataFrame]:
"""B. ak.index_stock_cons_csindex 当前快照 (带 15s 超时保护, 3 次重试, 全失败标 gap)."""
raw, err = None, None
for attempt in range(1, 4): # 最多 3 次重试, csindex 接口时好时坏
logger.info(f"[B] {index_code} {index_name} ak.index_stock_cons_csindex (attempt {attempt}/3, timeout=15s) ...")
raw, err = _fetch_csindex_snapshot_with_timeout(index_code, timeout=15.0)
if not err and raw is not None and len(raw) > 0:
break
logger.warning(f"[B] {index_code} attempt {attempt} fail: {err}")
if attempt < 3:
time.sleep(5.0)
if err:
logger.error(f"[B] {index_code} ak.index_stock_cons_csindex FAIL after 3 attempts: {err} → 标 placeholder gap")
# 写 placeholder parquet 标 historical=false + unavailable=true
return pd.DataFrame([{
"updateDate": time.strftime("%Y-%m-%d"),
"code": "",
"code_name": "",
"index_code": index_code,
"index_name": index_name,
"historical": False,
"unavailable": True,
"note": f"csindex snapshot fetch {err} after 3 attempts, known gap",
}])
if raw is None or len(raw) == 0:
logger.warning(f"[B] {index_code} empty snapshot → placeholder gap")
return pd.DataFrame([{
"updateDate": time.strftime("%Y-%m-%d"),
"code": "",
"code_name": "",
"index_code": index_code,
"index_name": index_name,
"historical": False,
"unavailable": True,
"note": "csindex snapshot empty, known gap",
}])
logger.info(f"[B] {index_code} raw rows={len(raw)}, cols={list(raw.columns)}")
# 统一 schema: 尽量保留原始列 + 加 index_code/historical=False
df = raw.copy()
# 归一代码列 (csindex 实际列名: "成分券代码" - 注意是"分"不是"份"!)
code_col = None
for c in ("成分券代码", "成份券代码", "成分券代码 ", "代码", "code", "symbol"):
if c in df.columns:
code_col = c
break
if code_col:
df["code"] = df[code_col].apply(_normalize_akshare_code)
else:
logger.warning(f"[B] {index_code} no code column matched in {list(raw.columns)}")
name_col = None
for c in ("成分券名称", "成份券名称", "成分券简称", "成份券简称", "名称", "code_name", "name"):
if c in df.columns:
name_col = c
break
if name_col:
df["code_name"] = df[name_col]
df["index_code"] = index_code
df["index_name"] = index_name
if "historical" not in df.columns:
df["historical"] = False # 标注: 仅当前快照, 无历史
df["updateDate"] = time.strftime("%Y-%m-%d") # 采集日期
keep_cols = ["updateDate", "code", "code_name", "index_code", "index_name", "historical"]
if "unavailable" in df.columns:
keep_cols.append("unavailable")
if "note" in df.columns:
keep_cols.append("note")
keep_cols = [c for c in keep_cols if c in df.columns]
return df[keep_cols].reset_index(drop=True)
# ======================== C. 新浪交叉校验 ========================
def _fetch_sina_component_table(index_code: str, logger: logging.Logger) -> Optional[pd.DataFrame]:
"""新浪 vII_HistoryComponent 翻页拉取, 返回合并后的成份股历史.
新浪真表是 table[3] ([0] 陷阱). 翻页 ?pageNum=N.
"""
import requests
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://vip.stock.finance.sina.com.cn/",
}
all_rows = []
prev_codes_signature = None # 假分页检测: 翻页前后内容签名
for page in range(1, SINA_MAX_PAGES + 1):
# 新浪 URL: 历史成份股, 参数 sym (如 sh000300) + pageNum
prefix = "sh" if index_code.startswith(("000", "9")) else "sz"
url = (
f"https://vip.stock.finance.sina.com.cn/corp/go.php/vII_HistoryComponent/"
f"{prefix}{index_code}.phtml"
)
params = {"pageNum": page}
try:
resp = requests.get(url, headers=headers, params=params, timeout=15)
resp.encoding = "gb2312"
except Exception as e:
logger.warning(f"[C] {index_code} sina page {page} fetch fail: {e}")
break
if resp.status_code != 200:
logger.warning(f"[C] {index_code} sina page {page} status={resp.status_code}")
break
try:
from io import StringIO
tables = pd.read_html(StringIO(resp.text))
except Exception as e:
logger.warning(f"[C] {index_code} sina page {page} read_html fail: {e}")
break
# 陷阱: 真表是 table[3] 非 [0]
if len(tables) <= 3:
logger.info(f"[C] {index_code} sina page {page} tables={len(tables)}, stop")
break
tbl = tables[3]
if tbl is None or len(tbl) == 0:
logger.info(f"[C] {index_code} sina page {page} empty table[3], stop")
break
logger.info(f"[C] {index_code} sina page {page} rows={len(tbl)}, cols={list(tbl.columns)}")
# 假分页检测: 新浪 ?pageNum 是假分页, 每页同内容. 用前 4 列签名对比
sig = tuple(tbl.iloc[:5, :4].astype(str).values.flatten())
if prev_codes_signature is not None and sig == prev_codes_signature:
logger.info(f"[C] {index_code} sina page {page} same as prev (假分页 detected), stop")
break
prev_codes_signature = sig
all_rows.append(tbl)
time.sleep(SLEEP_SINA_PAGE)
# 少于 30 行视为末页
if len(tbl) < 30:
break
if not all_rows:
return None
big = pd.concat(all_rows, ignore_index=True)
return big
def download_sina_cross(index_code: str, index_name: str, logger: logging.Logger) -> Optional[pd.DataFrame]:
"""C. 新浪历史成份股 (交叉校验 baostock 300/500/50).
新浪 schema (table[3]): 多级 header, 第一行数据是 真正列名:
品种代码 / 品种名称 / 纳入日期 / 剔除日期
后续每只股票一行 (含被剔除的, 治幸存者偏差).
"""
logger.info(f"[C] {index_code} {index_name} sina vII_HistoryComponent ...")
raw = _fetch_sina_component_table(index_code, logger)
if raw is None or len(raw) == 0:
logger.warning(f"[C] {index_code} sina empty")
return None
# 陷阱: table[3] 第一行是 "品种代码/品种名称/纳入日期/剔除日期" 字符串 (非数据)
# 列名是 "历史成分/历史成分.1/..." 需要重命名
df = raw.copy()
# 找到包含 "品种代码" 的行作为 header 行, 丢弃它, 用前 4 列做数据
header_mask = df.apply(lambda r: r.astype(str).str.contains("品种代码").any(), axis=1)
if header_mask.any():
df = df[~header_mask].reset_index(drop=True)
# 取前 4 列 (品种代码/品种名称/纳入日期/剔除日期), 重命名
if df.shape[1] >= 4:
df = df.iloc[:, :4]
df.columns = ["code", "code_name", "in_date", "out_date"]
# 清洗 (剔除空行/非字符串)
df = df[df["code"].notna() & (df["code"].astype(str).str.strip() != "")].copy()
df["code"] = df["code"].apply(_normalize_akshare_code)
# 保险 dedup (即使假分页检测漏网, 这里兜底)
before_dedup = len(df)
df = df.drop_duplicates(subset=["code", "in_date", "out_date"]).reset_index(drop=True)
if len(df) < before_dedup:
logger.info(f"[C] {index_code} dedup {before_dedup} -> {len(df)}")
df["index_code"] = index_code
df["index_name"] = index_name
df["source"] = "sina"
# 治幸存者偏差证据: 有 out_date (非空) = 被剔除股票
df["was_removed"] = df["out_date"].notna() & (df["out_date"].astype(str).str.strip() != "")
n_total = len(df)
n_removed = int(df["was_removed"].sum())
n_current = n_total - n_removed
logger.info(
f"[C] {index_code} sina total={n_total} current(no out_date)={n_current} removed(out_date 非空)={n_removed}"
)
return df.reset_index(drop=True)
# ======================== 保存 ========================
def save_parquet(df: pd.DataFrame, path: Path, logger: logging.Logger) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
df.to_parquet(path, index=False)
logger.info(f"saved {path} rows={len(df)} size={path.stat().st_size}B")
# ======================== 主流程 ========================
def run(orders: List[str], skip_sina: bool, logger: logging.Logger) -> int:
"""串行跑 A/B/C."""
summary_rows = []
# ----- A. 深证/国证 adjust -----
if "A" in orders:
logger.info("=" * 60)
logger.info("PART A: CNI adjust (深证/国证) START")
logger.info("=" * 60)
for idx_code, idx_name in CNI_ADJUST_INDICES:
try:
adj, union, current = download_cni_adjust(idx_code, idx_name, logger)
except Exception as e:
logger.exception(f"[A] {idx_code} unexpected error: {e}")
adj, union, current = None, None, None
if adj is not None:
save_parquet(adj, OUT_DIR / f"{idx_code}_adjust.parquet", logger)
if union is not None:
save_parquet(union, OUT_DIR / f"{idx_code}_union.parquet", logger)
if current is not None:
save_parquet(current, OUT_DIR / f"{idx_code}_current.parquet", logger)
# 统计 (幸存者偏差证据)
n_union = len(union) if union is not None else 0
n_current = len(current.drop_duplicates(subset=["code"])) if current is not None else 0
n_removed = int((union["was_removed"].sum()) if (union is not None and "was_removed" in union.columns) else 0)
summary_rows.append({
"part": "A",
"index_code": idx_code,
"index_name": idx_name,
"rows_adjust": len(adj) if adj is not None else 0,
"distinct_code_ever": n_union,
"current_codes": n_current,
"removed_codes": n_removed,
"note": "historical=true (adjust 10 期)",
})
time.sleep(SLEEP_AKSHARE)
time.sleep(SLEEP_BETWEEN_PARTS)
# ----- B. 中证1000/2000 快照 -----
if "B" in orders:
logger.info("=" * 60)
logger.info("PART B: CSINDEX snapshot (中证1000/2000) START")
logger.info("=" * 60)
for idx_code, idx_name in CSINDEX_SNAPSHOT_INDICES:
try:
snap = download_csindex_snapshot(idx_code, idx_name, logger)
except Exception as e:
logger.exception(f"[B] {idx_code} unexpected error: {e}")
snap = None
if snap is not None:
save_parquet(snap, OUT_DIR / f"{idx_code}_snapshot.parquet", logger)
# B 部分 summary: 如有 code 列算 distinct, 否则用总行数
if snap is not None and "code" in snap.columns and (snap["code"].astype(str).str.strip() != "").any():
n_codes = int(snap["code"].replace("", pd.NA).dropna().nunique())
unavail = False
elif snap is not None and "unavailable" in snap.columns:
n_codes = 0
unavail = bool(snap["unavailable"].iloc[0])
else:
n_codes = len(snap) if snap is not None else 0
unavail = False
note_b = "historical=false (snapshot only, 历史不可补 = 已知 gap)"
if unavail:
note_b = "UNAVAILABLE: csindex timeout after 3 attempts, placeholder gap"
summary_rows.append({
"part": "B",
"index_code": idx_code,
"index_name": idx_name,
"rows_adjust": len(snap) if snap is not None else 0,
"distinct_code_ever": n_codes,
"current_codes": n_codes,
"removed_codes": 0,
"note": note_b,
})
time.sleep(SLEEP_AKSHARE)
time.sleep(SLEEP_BETWEEN_PARTS)
# ----- C. 新浪交叉校验 -----
if "C" in orders and not skip_sina:
logger.info("=" * 60)
logger.info("PART C: SINA cross-check (300/500/50) START")
logger.info("=" * 60)
for idx_code, idx_name in SINA_CROSS_INDICES:
try:
sina_df = download_sina_cross(idx_code, idx_name, logger)
except Exception as e:
logger.exception(f"[C] {idx_code} unexpected error: {e}")
sina_df = None
if sina_df is not None:
save_parquet(sina_df, OUT_DIR / f"{idx_code}_sina.parquet", logger)
if sina_df is not None and "was_removed" in sina_df.columns:
n_total = len(sina_df)
n_removed = int(sina_df["was_removed"].sum())
n_current = n_total - n_removed
note = "cross-check vs baostock (主源)"
else:
n_total = len(sina_df) if sina_df is not None else 0
n_removed = 0
n_current = 0
note = "sina empty or fetch fail"
summary_rows.append({
"part": "C",
"index_code": idx_code,
"index_name": idx_name,
"rows_adjust": n_total,
"distinct_code_ever": n_total,
"current_codes": n_current,
"removed_codes": n_removed,
"note": note,
})
time.sleep(SLEEP_AKSHARE)
# ----- summary -----
if summary_rows:
summary = pd.DataFrame(summary_rows)
save_parquet(summary, OUT_DIR / "_summary.parquet", logger)
logger.info("=" * 60)
logger.info("SUMMARY:")
logger.info("\n" + summary.to_string(index=False))
logger.info("=" * 60)
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="历史成份股采集 (治幸存者偏差)")
parser.add_argument(
"--parts",
nargs="+",
default=["A", "B", "C"],
choices=["A", "B", "C"],
help="执行哪些部分 (A=深证/国证 adjust, B=csindex 快照, C=新浪交叉)",
)
parser.add_argument("--skip-sina", action="store_true", help="跳过 C 新浪 (baostock 已有主源)")
args = parser.parse_args()
logger, _ = setup_logging()
logger.info(f"args: parts={args.parts}, skip_sina={args.skip_sina}")
logger.info(f"CNI_ADJUST_INDICES={[c[0] for c in CNI_ADJUST_INDICES]}")
logger.info(f"CSINDEX_SNAPSHOT_INDICES={[c[0] for c in CSINDEX_SNAPSHOT_INDICES]}")
logger.info(f"SINA_CROSS_INDICES={[c[0] for c in SINA_CROSS_INDICES]}")
try:
return run(args.parts, args.skip_sina, logger)
except KeyboardInterrupt:
logger.warning("interrupted by user")
return 1
except Exception as e:
logger.exception(f"FATAL: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,31 @@
# 历史成份股采集 wrapper (治幸存者偏差, P0 Task1)
# 环境变量覆盖脚本默认 Mac 路径 → Windows VPS 输出路径
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$py = 'C:\Python310\python.exe'
$dir = 'C:\sanguo_vnpy_v2\scripts\data_platform'
$env:INDEX_HIST_OUT_DIR = 'C:\sanguo_vnpy_v2\data\index_const_hist'
$env:PYTHONIOENCODING = 'utf-8'
# unset proxy (直连, 避免数据源封 IP)
$env:http_proxy = $null
$env:https_proxy = $null
$env:all_proxy = $null
$wrapper_log = 'C:\sanguo_vnpy_v2\data\index_const_hist_wrapper.log'
$script_log = 'C:\sanguo_vnpy_v2\data\index_const_hist_download.log'
function Log-W($m) {
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $m" | Out-File -FilePath $wrapper_log -Append -Encoding UTF8
}
Log-W "=== WRAPPER START ==="
Log-W "INDEX_HIST_OUT_DIR=$($env:INDEX_HIST_OUT_DIR)"
Log-W "DiskFree_GB_start=$((Get-PSDrive C).Free / 1GB)"
# 单进程串行跑 A (深证/国证 adjust) + B (中证1000/2000 快照) + C (新浪交叉校验)
Log-W "SCRIPT START"
& $py -X utf8 "$dir\index_const_hist_download.py" --parts A B C *>&1 | Tee-Object -FilePath $script_log
$script_exit = $LASTEXITCODE
Log-W "SCRIPT DONE exit=$script_exit"
Log-W "DiskFree_GB_end=$((Get-PSDrive C).Free / 1GB)"
Log-W "=== WRAPPER EXIT script_exit=$script_exit ==="
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""merge staging → data, 只复制 data 里不存在的文件(非破坏)。
A data 里已有 跳过保留(不被 staging 7/15 覆盖);ETF/基金 data 没有 复制全历史
用法:
DRY-RUN(默认): set MERGE_DRY_RUN=1 && python merge_staging_to_data.py
ACTUAL: set MERGE_DRY_RUN=0 && python merge_staging_to_data.py
"""
import os
import shutil
import sys
STAGING = r"C:\sanguo_vnpy_v2\data\_staging_xtdata"
DATA = r"C:\sanguo_vnpy_v2\data"
def main():
dry = os.environ.get("MERGE_DRY_RUN", "1") == "1"
total = skip = copy = fail = 0
new_by_kind = {"raw": 0, "qfq": 0}
for kind in ("raw", "qfq"):
s_root = os.path.join(STAGING, kind)
d_root = os.path.join(DATA, kind)
if not os.path.isdir(s_root):
continue
for year in os.listdir(s_root):
s_yr = os.path.join(s_root, year)
if not os.path.isdir(s_yr):
continue
d_yr = os.path.join(d_root, year)
for fname in os.listdir(s_yr):
if not fname.endswith("_daily.parquet"):
continue
total += 1
s_file = os.path.join(s_yr, fname)
d_file = os.path.join(d_yr, fname)
if os.path.exists(d_file):
skip += 1
continue
if dry:
copy += 1
new_by_kind[kind] += 1
continue
try:
os.makedirs(d_yr, exist_ok=True)
shutil.copy2(s_file, d_file)
copy += 1
new_by_kind[kind] += 1
except Exception as e: # noqa: BLE001
fail += 1
if fail <= 5:
print(f"copy err {s_file}: {e}", flush=True)
mode = "DRY-RUN" if dry else "ACTUAL"
print(f"[{mode}] total_staging={total} to_copy={copy} already_in_data(skip)={skip} fail={fail}", flush=True)
print(f"[{mode}] new raw={new_by_kind['raw']} new qfq={new_by_kind['qfq']}", flush=True)
sys.stdout.flush()
if __name__ == "__main__":
main()
@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""
P0 Task3 退市股 K 线 baostock 接口探针(只读)
硬约束:
- 直连不走代理(baostock 服务端在境内)
- 单进程单登录串行(防黑名单)
- 只读探针:不灌库不下全量
"""
import sys
import os
import io
# Windows 控制台 utf-8 + 强制 flush
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", write_through=True)
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace", write_through=True)
# 清代理(baostock 直连境内,走代理必挂)
for k in ("http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"):
os.environ.pop(k, None)
import baostock as bs
import pandas as pd
import time
def section(title):
print("\n" + "=" * 60)
print(f"### {title}")
print("=" * 60, flush=True)
def dump_rs(rs, label, max_rows=5):
"""把 ResultData 读完并打印字段 + 前 N 行"""
rows = []
while (rs.error_code == '0') & rs.next():
rows.append(rs.get_row_data())
df = pd.DataFrame(rows, columns=rs.fields) if rows else pd.DataFrame(columns=rs.fields)
print(f"[{label}] error={rs.error_code} {rs.error_msg} | fields={rs.fields} | rows={len(df)}")
if len(df) > 0:
print(f"[{label}] head({max_rows}):")
print(df.head(max_rows).to_string())
return df
# ============ 1. 登录 ============
section("LOGIN")
t0 = time.time()
lg = bs.login()
print(f"login: error_code={lg.error_code} error_msg={lg.error_msg} elapsed={time.time()-t0:.2f}s", flush=True)
if lg.error_code != '0':
print("LOGIN_FAILED_ABORT")
sys.exit(1)
# ============ 2. query_all_stock(某日全市场列表) ============
section("query_all_stock day=2026-07-18")
rs = bs.query_all_stock(day="2026-07-18")
df_all = dump_rs(rs, "query_all_stock(2026-07-18)", max_rows=5)
# 看字段含不含 status / 退市日
if len(df_all) > 0:
print("\n字段分析:")
print(f" columns = {list(df_all.columns)}")
print(f" has_status = {'status' in df_all.columns}")
print(f" has_outDate = {'outDate' in df_all.columns}")
# code 前缀分布
if 'code' in df_all.columns:
df_all['prefix'] = df_all['code'].str.split('.').str[0]
print(f" code 前缀分布:\n{df_all['prefix'].value_counts().to_string()}")
# tradeStatus 分布(若有)
if 'tradeStatus' in df_all.columns:
print(f" tradeStatus 分布:\n{df_all['tradeStatus'].value_counts().to_string()}")
# ============ 3. query_all_stock 不同日期对比(取旧日,看是否还能查到已退市股) ============
section("query_all_stock day=2021-01-04(旧日,看是否含现已退市)")
rs_old = bs.query_all_stock(day="2021-01-04")
df_old = dump_rs(rs_old, "query_all_stock(2021-01-04)", max_rows=3)
# 对比两日 code 差集(2021 在但 2026 不在 = 期间退市的候选)
if len(df_all) > 0 and len(df_old) > 0 and 'code' in df_all.columns:
s_new = set(df_all['code'])
s_old = set(df_old['code'])
only_old = sorted(s_old - s_new)
only_new = sorted(s_new - s_old)
print(f"\n2021 有 / 2026 无(候选退市): {len(only_old)} 只 | 前 10 示例: {only_old[:10]}")
print(f"2026 有 / 2021 无(期间上市): {len(only_new)} 只 | 前 10 示例: {only_new[:10]}")
# 存下来供后面 K 线验证用
candidate_delisted = only_old[:10] # 前 10 只候选退市股
else:
candidate_delisted = []
# ============ 4. query_stock_basic(单只基本信息 —— 找 status + outDate 字段) ============
section("query_stock_basic 字段探查(活跃股 + 已知退市股)")
# 先打活跃股(确认字段集)
active_codes = ["sh.600000", "sz.000001", "sz.000002"]
# 已知退市股候选(内存里的 + 常见历史退市)
# sh.600074 退市保千 / sh.600432 退市吉恩 / sz.002450 *ST康得新 / sz.000033 新都退 / sh.600632 *ST 神城
# 另外从 only_old 候选里挑几只(2021 有 2026 无 = 确认退市)
delisted_candidates = ["sh.600074", "sh.600432", "sz.002450", "sz.000033", "sh.600632"]
# 如果上面候选 only_old 里有,优先用(那些是 baostock 自己承认 2021 存在过的)
probe_codes = active_codes + delisted_candidates
if candidate_delisted:
probe_codes = probe_codes + candidate_delisted[:5]
found_delisted_samples = []
for code in probe_codes:
rs2 = bs.query_stock_basic(code=code)
b = []
while (rs2.error_code == '0') & rs2.next():
b.append(rs2.get_row_data())
df2 = pd.DataFrame(b, columns=rs2.fields) if b else pd.DataFrame(columns=rs2.fields)
print(f"\n--- query_stock_basic({code}) error={rs2.error_code} {rs2.error_msg}")
print(f" fields = {rs2.fields}")
if len(df2) > 0:
print(f" data = {df2.to_dict('records')}")
# 记录退市样本(outDate 非空 或 status=0)
rec = df2.iloc[0].to_dict()
status_val = str(rec.get('status', ''))
outdate_val = str(rec.get('outDate', ''))
if status_val == '0' or (outdate_val and outdate_val not in ('', 'nan', 'None')):
found_delisted_samples.append((code, rec))
else:
print(" (empty)")
time.sleep(0.3) # 温柔一点
print(f"\n找到的退市样本: {len(found_delisted_samples)}")
for code, rec in found_delisted_samples:
print(f" {code}: status={rec.get('status')} outDate={rec.get('outDate')} type={rec.get('type')}")
# ============ 5. 退市股 K 线验证 ============
section("query_history_k_data_plus 退市股 K 线")
fields = "date,code,open,high,low,close,preclose,volume,amount,adjustflag,turn,tradestatus,pctChg,peTTM,pbMRQ,psTTM,pcfNcfTTM,isST"
# 优先用真实退市股(status=0),否则用候选
kline_targets = []
for code, rec in found_delisted_samples:
kline_targets.append((code, rec.get('outDate', '')))
# 如果没找到 status=0 的退市股,直接用候选列表
if not kline_targets:
for code in delisted_candidates + candidate_delisted[:3]:
kline_targets.append((code, ''))
# 去重
seen = set()
kline_targets_uniq = []
for code, outdate in kline_targets:
if code not in seen:
seen.add(code)
kline_targets_uniq.append((code, outdate))
print(f"K 线验证标的({len(kline_targets_uniq)}): {kline_targets_uniq}")
for code, outdate in kline_targets_uniq[:6]:
print(f"\n--- K 线 {code} (outDate={outdate}) ---")
rs4 = bs.query_history_k_data_plus(
code, fields,
start_date='2020-01-01', end_date='2026-07-18',
frequency="d", adjustflag="3"
)
k = []
while (rs4.error_code == '0') & rs4.next():
k.append(rs4.get_row_data())
df4 = pd.DataFrame(k, columns=rs4.fields) if k else pd.DataFrame(columns=rs4.fields)
print(f" error={rs4.error_code} {rs4.error_msg} | rows={len(df4)}")
if len(df4) > 0:
# 字符串转日期比大小
try:
df4['date_dt'] = pd.to_datetime(df4['date'], errors='coerce')
maxd = df4['date_dt'].max()
mind = df4['date_dt'].min()
print(f" date range: {mind.date()} ~ {maxd.date()}")
print(f" tail(3):")
print(df4.drop(columns=['date_dt']).tail(3).to_string())
except Exception as e:
print(f" date parse err: {e}")
print(df4.tail(3).to_string())
time.sleep(0.3)
# ============ 6. 近 5 年退市股数量估计(小抽样,不扫全量) ============
section("近 5 年退市股数量估计(基于 query_all_stock 差集 + 候选 basic)")
if len(df_all) > 0 and len(df_old) > 0:
print(f"2021-01-04 全市场: {len(df_old)}")
print(f"2026-07-18 全市场: {len(df_all)}")
print(f"2021 在 2026 不在的差集(候选期间退市/暂停/更名): {len(candidate_delisted)} 只(实际 {len(set(df_old['code']) - set(df_all['code']))} 只)")
print(f"注:这差集是『上限』——含真退市 + 更名/合并/暂停 + 历史数据缺日等。精确退市数要逐只 query_stock_basic 看 status=0 + outDate。")
# ============ 7. logout ============
section("LOGOUT")
bs.logout()
print("DONE_PROBE")
@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""P0 Task3 补充探针:早期退市股 K 线完整性 + 全市场 status=0 数量抽样估计"""
import sys, os, io
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", write_through=True)
for k in ("http_proxy","https_proxy","all_proxy","HTTP_PROXY","HTTPS_PROXY","ALL_PROXY"):
os.environ.pop(k, None)
import baostock as bs
import pandas as pd
import time
lg = bs.login()
print(f"login: {lg.error_code} {lg.error_msg}")
if lg.error_code != '0':
print("ABORT"); sys.exit(1)
# === 1. 早期退市股用更早 start_date 验证 K 线完整性 ===
print("\n=== A. 早期退市股 K 线(start_date=2010-01-01) ===")
fields = "date,code,open,high,low,close,preclose,volume,amount,adjustflag,turn,tradestatus,pctChg,peTTM,pbMRQ,psTTM,pcfNcfTTM,isST"
early_delisted = [
("sh.600432", "2018-07-13"),
("sz.000033", "2017-07-07"),
("sh.600632", "2004-11-18"),
]
for code, outdate in early_delisted:
rs = bs.query_history_k_data_plus(
code, fields,
start_date='2010-01-01', end_date='2026-07-18',
frequency="d", adjustflag="3"
)
k = []
while (rs.error_code == '0') & rs.next():
k.append(rs.get_row_data())
df = pd.DataFrame(k, columns=rs.fields) if k else pd.DataFrame(columns=rs.fields)
if len(df) > 0:
df['date_dt'] = pd.to_datetime(df['date'], errors='coerce')
mind = df['date_dt'].min(); maxd = df['date_dt'].max()
print(f" {code} outDate={outdate}: rows={len(df)} range={mind.date()}~{maxd.date()} | maxdate==outDate? {str(maxd.date())==outdate}")
print(f" tail(2):")
print(df.drop(columns=['date_dt']).tail(2).to_string())
else:
print(f" {code} outDate={outdate}: rows=0 err={rs.error_msg}")
time.sleep(0.3)
# === 2. 全市场 status=0 退市股数量抽样估计 ===
# 从 2021-01-04 全市场 code 列表 + 扩展号段扫描 status=0
print("\n=== B. 全市场退市股数量估计 ===")
# 先用 query_all_stock 拿基准列表(2021-01-04)
rs0 = bs.query_all_stock(day="2021-01-04")
base_codes = []
while (rs0.error_code == '0') & rs0.next():
base_codes.append(rs0.get_row_data()[0])
print(f" 2021-01-04 all_stock code 数: {len(base_codes)}")
# 对这些 code 全部查 basic,统计 status=0 + outDate 分布
# 但 4687 只全扫会耗费 ~4687 query,Mac 单登录串行 ~30 分钟
# 改为抽样:每 10 只取 1 只,共约 470 只
sample_codes = base_codes[::10]
print(f" 抽样步长=10, 抽样数: {len(sample_codes)}")
status0_cnt = 0
status0_samples = []
ipo_cnt = 0
for i, code in enumerate(sample_codes):
rs2 = bs.query_stock_basic(code=code)
b = []
while (rs2.error_code == '0') & rs2.next():
b.append(rs2.get_row_data())
if b:
df2 = pd.DataFrame(b, columns=rs2.fields)
rec = df2.iloc[0].to_dict()
if str(rec.get('status','')) == '0':
status0_cnt += 1
status0_samples.append(rec)
elif str(rec.get('status','')) == '1':
ipo_cnt += 1
if (i+1) % 50 == 0:
print(f" progress {i+1}/{len(sample_codes)} | status0={status0_cnt} status1={ipo_cnt}", flush=True)
time.sleep(0.05) # 50ms 间隔 = 20 qps,温柔
print(f"\n 抽样结果: {len(sample_codes)} 只中 status=0 {status0_cnt} 只, status=1 {ipo_cnt}")
print(f" 退市率(抽样): {status0_cnt/len(sample_codes)*100:.2f}%")
print(f" 外推全市场(基于 2021-01-04 的 {len(base_codes)} 只): {int(status0_cnt/len(sample_codes)*len(base_codes))} 只 status=0")
print(f" 注:2021-01-04 当日已退市的不会出现在列表里,所以这是『2021-01-04 还在交易,但之后退市』的估计")
print(f" status=0 样本前 10:")
for rec in status0_samples[:10]:
print(f" {rec.get('code')} {rec.get('code_name')} ipoDate={rec.get('ipoDate')} outDate={rec.get('outDate')}")
bs.logout()
print("DONE_PROBE_EXT")
@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
"""最小探针:早期退市股 K 线完整性(3 query,瞬时)"""
import sys, os, io
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", write_through=True)
for k in ("http_proxy","https_proxy","all_proxy","HTTP_PROXY","HTTPS_PROXY","ALL_PROXY"):
os.environ.pop(k, None)
import baostock as bs
import pandas as pd
lg = bs.login()
print(f"login: {lg.error_code} {lg.error_msg}", flush=True)
if lg.error_code != '0':
sys.exit(1)
fields = "date,code,open,high,low,close,volume,amount,turn,pctChg,peTTM,pbMRQ,isST"
early = [
("sh.600432", "2018-07-13"),
("sz.000033", "2017-07-07"),
("sh.600632", "2004-11-18"),
]
for code, outdate in early:
rs = bs.query_history_k_data_plus(
code, fields,
start_date='2010-01-01', end_date='2026-07-18',
frequency="d", adjustflag="3"
)
k = []
while (rs.error_code == '0') & rs.next():
k.append(rs.get_row_data())
df = pd.DataFrame(k, columns=rs.fields) if k else pd.DataFrame(columns=rs.fields)
if len(df) > 0:
print(f"{code} outDate={outdate}: rows={len(df)} range={df['date'].min()}~{df['date'].max()}", flush=True)
print(f" maxdate==outDate? {df['date'].max()==outdate} | tail:", flush=True)
print(df.tail(2).to_string(), flush=True)
else:
print(f"{code} outDate={outdate}: rows=0 err={rs.error_msg}", flush=True)
bs.logout()
print("DONE", flush=True)
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ETF universe + 前复权日线探针(P0 Task2.1)。
VPS : C:\\Python310\\python.exe -X utf8 probe_etf.py
"""
import sys
from xtquant import xtdata as xd
def main():
etf = xd.get_stock_list_in_sector("沪深ETF") or []
fund = xd.get_stock_list_in_sector("沪深基金") or []
a = xd.get_stock_list_in_sector("沪深A股") or []
u = list(set(a + etf + fund))
print(f"A={len(a)} ETF={len(etf)} fund={len(fund)} union={len(u)}")
print(f"sample ETF: {etf[:5]}")
print(f"sample fund: {fund[:5]}")
# 抽样: 510300.SH(沪深300ETF) / 513050.SH(中概互联网ETF) / 159919.SZ(300ETF)
samples = ["510300.SH", "513050.SH", "159919.SZ"]
r = xd.get_market_data_ex([], samples, period="1d",
start_time="20240101", end_time="20260721",
dividend_type="front")
for sym in samples:
df = r.get(sym) if r else None
bars = 0 if df is None else len(df)
tail_close = None if df is None or not len(df) else float(df["close"].iloc[-1])
head_date = None if df is None or not len(df) else str(df.index[0])[:8]
tail_date = None if df is None or not len(df) else str(df.index[-1])[:8]
nan_close = None if df is None else bool(df["close"].isnull().any())
print(f"{sym}: bars={bars} date=[{head_date}~{tail_date}] tail_close={tail_close} nan_close={nan_close}")
sys.stdout.flush()
if __name__ == "__main__":
main()
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ETF 探针 v2: 先 download 再读,对比 dividend_type,确认 ETF 在 sector 中。"""
import sys
from xtquant import xtdata as xd
def main():
etf = xd.get_stock_list_in_sector("沪深ETF") or []
fund = xd.get_stock_list_in_sector("沪深基金") or []
a = xd.get_stock_list_in_sector("沪深A股") or []
u = list(set(a + etf + fund))
print(f"A={len(a)} ETF={len(etf)} fund={len(fund)} union={len(u)}")
# 1) 检查目标 samples 是否在 universe
for sym in ("510300.SH", "513050.SH", "159919.SZ"):
print(f" {sym} in A={sym in a} in ETF={sym in etf} in fund={sym in fund}")
# 2) 重叠分析: ETF 与 fund 是否相同
overlap = set(etf) & set(fund)
only_etf = set(etf) - set(fund)
only_fund = set(fund) - set(etf)
print(f"overlap(ETF&fund)={len(overlap)} only_etf={len(only_etf)} only_fund={len(only_fund)}")
if only_etf:
print(f" only_etf sample: {list(only_etf)[:5]}")
if only_fund:
print(f" only_fund sample: {list(only_fund)[:5]}")
# 3) 先 download 再读
samples = ["510300.SH", "513050.SH", "159919.SZ"]
print(f"\n=== download_history_data(1d, 20240101~20260721) ===")
for sym in samples:
try:
n = xd.download_history_data(sym, "1d", "20240101", "20260721")
print(f" {sym} download returned: {n}")
except Exception as e:
print(f" {sym} download err: {e}")
# 4) 读两种 dividend_type
for dt in ("front", "none"):
print(f"\n=== get_market_data_ex dividend_type={dt} ===")
r = xd.get_market_data_ex([], samples, period="1d",
start_time="20240101", end_time="20260721",
dividend_type=dt)
for sym in samples:
df = r.get(sym) if r else None
bars = 0 if df is None else len(df)
tail_close = None if df is None or not len(df) else float(df["close"].iloc[-1])
head_date = None if df is None or not len(df) else str(df.index[0])[:8]
tail_date = None if df is None or not len(df) else str(df.index[-1])[:8]
print(f" {sym}: bars={bars} date=[{head_date}~{tail_date}] tail_close={tail_close}")
sys.stdout.flush()
if __name__ == "__main__":
main()
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""小样本验证脚本: 测试日K线和成分股下载 (各 10 只/1 指数)
验证项:
1. 日K线 10 : parquet 生成 + 字段齐全 + 行数合理 + 日期范围对
2. 成分股 1 指数 1 : parquet 生成 + 成分股数量合理
3. query 计数器工作
用法:
python test_baostock_daily_constituent_sample.py
"""
import subprocess
import sys
from pathlib import Path
# 颜色输出
GREEN = "\033[92m"
RED = "\033[91m"
RESET = "\033[0m"
def run_test(name: str, cmd: list, expected_checks: dict):
"""运行测试脚本并验证结果"""
print(f"\n{'=' * 60}")
print(f"测试: {name}")
print(f"命令: {' '.join(cmd)}")
print(f"{'=' * 60}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode not in [0, 2]: # 0=完成, 2=断路器(可接受)
print(f"{RED}❌ 失败: returncode={result.returncode}{RESET}")
print("STDOUT:", result.stdout[-500:] if len(result.stdout) > 500 else result.stdout)
print("STDERR:", result.stderr[-500:] if len(result.stderr) > 500 else result.stderr)
return False
print(f"{GREEN}✅ 完成: returncode={result.returncode}{RESET}")
# 检查输出
output = result.stdout + result.stderr
for check_key, check_pattern in expected_checks.items():
if check_pattern in output:
print(f"{GREEN}{check_key}: 找到'{check_pattern}'{RESET}")
else:
print(f"{RED}{check_key}: 未找到'{check_pattern}'{RESET}")
return False
return True
def check_parquet_files(out_dir: Path, pattern: str, min_count: int):
"""检查 parquet 文件生成"""
print(f"\n检查 parquet 文件: {out_dir}/{pattern}")
parquet_files = list(out_dir.rglob(pattern))
if len(parquet_files) >= min_count:
print(f"{GREEN}✅ 找到 {len(parquet_files)} 个 parquet 文件 (≥{min_count}){RESET}")
# 显示前几个文件
for f in parquet_files[:3]:
print(f" - {f.name}")
return True
else:
print(f"{RED}❌ 只找到 {len(parquet_files)} 个 parquet 文件 (<{min_count}){RESET}")
return False
def check_parquet_fields(file_path: Path, required_fields: list):
"""检查 parquet 字段齐全"""
import pandas as pd
print(f"\n检查字段: {file_path}")
try:
df = pd.read_parquet(file_path)
missing_fields = [f for f in required_fields if f not in df.columns]
if missing_fields:
print(f"{RED}❌ 缺失字段: {missing_fields}{RESET}")
print(f"实际字段: {list(df.columns)}")
return False
else:
print(f"{GREEN}✅ 字段齐全: {len(required_fields)} 个必选字段都在{RESET}")
print(f"字段列表: {list(df.columns)}")
return True
except Exception as e:
print(f"{RED}❌ 读 parquet 失败: {e}{RESET}")
return False
def check_parquet_rowcount(file_path: Path, min_rows: int, max_rows: int):
"""检查 parquet 行数合理"""
import pandas as pd
print(f"\n检查行数: {file_path}")
try:
df = pd.read_parquet(file_path)
row_count = len(df)
if min_rows <= row_count <= max_rows:
print(f"{GREEN}✅ 行数合理: {row_count} 行 (期望 {min_rows}-{max_rows}){RESET}")
return True
else:
print(f"{RED}❌ 行数异常: {row_count} 行 (期望 {min_rows}-{max_rows}){RESET}")
return False
except Exception as e:
print(f"{RED}❌ 读 parquet 失败: {e}{RESET}")
return False
def main():
"""主测试流程"""
print(f"\n{'=' * 60}")
print("BaoStock 脚本小样本验证")
print(f"{'=' * 60}")
tests_passed = 0
tests_failed = 0
# ======================= 测试 1: 日K线 10 只 =======================
print(f"\n{'#' * 60}")
print("# 测试 1: 日K线下载 (10 只股票)")
print(f"{'#' * 60}")
daily_cmd = [
sys.executable,
"scripts/data_platform/baostock_daily_fullmarket_download.py",
"--limit", "10",
"--start", "2020-01-01",
"--end", "2020-12-31"
]
daily_checks = {
"登录成功": "baostock 登录成功",
"全市场A股": "全市场 A 股",
"处理10只": "limit=10 截断",
"query计数": "query 总计:",
}
if run_test("日K线下载", daily_cmd, daily_checks):
tests_passed += 1
# 检查 parquet 文件
daily_out_dir = Path("data/daily_baostock")
if check_parquet_files(daily_out_dir, "*.parquet", 10):
tests_passed += 1
# 检查第一个文件的字段和行数
first_parquet = list(daily_out_dir.glob("*.parquet"))[0]
daily_fields = "date,code,open,high,low,close,preclose,volume,amount,adjustflag,turn,tradestatus,pctChg,peTTM,psTTM,pcfNcfTTM,pbMRQ,isST".split(",")
if check_parquet_fields(first_parquet, daily_fields):
tests_passed += 1
# 2020-01-01~2020-12-31 约 244 个交易日
if check_parquet_rowcount(first_parquet, 200, 300):
tests_passed += 1
else:
tests_failed += 1
else:
tests_failed += 1
else:
tests_failed += 1
else:
tests_failed += 1
# ======================= 测试 2: 成分股 1 指数 1 年 =======================
print(f"\n{'#' * 60}")
print("# 测试 2: 成分股下载 (1 指数 1 年)")
print(f"{'#' * 60}")
constituent_cmd = [
sys.executable,
"scripts/data_platform/baostock_constituent_download.py",
"--indices", "hs300",
"--start", "2020-01-01",
"--end", "2020-12-31"
]
constituent_checks = {
"登录成功": "baostock 登录成功",
"快照日期": "快照日期列表:",
"每周一": "每周一快照",
}
if run_test("成分股下载", constituent_cmd, constituent_checks):
tests_passed += 1
# 检查 parquet 文件
constituent_out_dir = Path("data/constituent_baostock")
if check_parquet_files(constituent_out_dir, "hs300_*.parquet", 50): # 2020年约52个周一
tests_passed += 1
else:
tests_failed += 1
else:
tests_failed += 1
# ======================= 测试结果汇总 =======================
print(f"\n{'=' * 60}")
print("测试结果汇总")
print(f"{'=' * 60}")
print(f"通过: {tests_passed}")
print(f"失败: {tests_failed}")
if tests_failed == 0:
print(f"\n{GREEN}✅ 所有测试通过!{RESET}\n")
return 0
else:
print(f"\n{RED}❌ 有 {tests_failed} 个测试失败{RESET}\n")
return 1
if __name__ == "__main__":
sys.exit(main())
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""mootdx 分钟K线历史深度测试 —— 周一开盘后跑(非交易时段通达信全服务器返空)
目的:对比 miniQMT 模拟端 1m/5m/15m 统一只给 ~12 个月(2025-07-16 ),
看通达信公共行情服务器能给多深的分钟历史
用法:
venv311/bin/python scripts/data_platform/test_mootdx_depth.py
结果写 scripts/data_platform/_mootdx_depth_result.txt 并打印
注意:
- 频率值表(mootdx 0.11.7 实测): 0=5m 1=15m 2=30m 3=60m 4= 8=1分钟 9=日线
- bars 返回不复权原始价;offset 硬上限 800,更深历史靠 start 分页
- 非交易时段(收盘后/周末)通达信服务器 quotes+bars 全频率返空,连日线都不给
"""
import socket
import sys
import datetime
from mootdx.quotes import Quotes
_TDX_SERVERS = [
('119.97.185.59', 7709), ('124.70.133.119', 7709), ('116.205.183.150', 7709),
('123.60.73.44', 7709), ('116.205.163.254', 7709), ('121.36.225.169', 7709),
('123.60.70.228', 7709), ('124.71.9.153', 7709), ('110.41.147.114', 7709),
('124.71.187.122', 7709),
]
def _probe(ip, port, timeout=2.0):
try:
with socket.create_connection((ip, port), timeout=timeout):
return True
except Exception:
return False
def _n(x):
"""统一求长度, 规避空 DataFrame 的 bool 歧义坑"""
if x is None:
return 0
try:
return len(x)
except Exception:
return 0
def find_server(symbol='600519'):
"""遍历服务器, 返回第一个能返回日线 bars 的(交易日内才有)"""
for ip, port in _TDX_SERVERS:
if not _probe(ip, port):
continue
try:
c = Quotes.factory(market='std', server=(ip, port))
d = c.bars(symbol=symbol, frequency=9, offset=5)
if _n(d) > 0:
return ip, c
except Exception:
pass
return None, None
def test_depth(c, symbol, freq, name, max_pages=200):
"""start 分页翻到底, 找最早/最新 datetime. 200页: 1m≈20月/5m≈8年/15m≈24年"""
start = 0
total = 0
pages = 0
earliest = None
latest = None
while pages < max_pages:
try:
df = c.bars(symbol=symbol, frequency=freq, offset=800, start=start)
except Exception:
break
n = _n(df)
if n == 0:
break
total += n
try:
ft = str(df.iloc[0]['datetime'])
lt = str(df.iloc[-1]['datetime'])
if earliest is None or ft < earliest:
earliest = ft
if latest is None or lt > latest:
latest = lt
except Exception:
pass
if n < 800:
break
start += n
pages += 1
return name, total, earliest, latest, pages
def main():
out = ['mootdx 深度测试 @ %s' % datetime.datetime.now()]
ip, c = find_server()
if c is None:
out.append('!!! 没有服务器返回日线数据 —— 非交易时段(周末/收盘后)通达信全服务器返空')
out.append('!!! 请周一 09:30 开盘后重跑此脚本')
msg = '\n'.join(out)
print(msg)
with open('_mootdx_depth_result.txt', 'w') as f:
f.write(msg)
sys.exit(1)
out.append('server: %s' % ip)
out.append('')
for sym in ['600519']: # 茅台(2001上市, 老股, 测深度上限最佳)
out.append('=== %s (茅台) ===' % sym)
for fr, nm in [(8, '1分钟'), (0, '5分钟'), (1, '15分钟')]:
name, total, earliest, latest, pages = test_depth(c, sym, fr, nm)
out.append(' %-6s: %7d 根 | 最早=%s | 最新=%s | 翻%d'
% (name, total, earliest, latest, pages))
out.append('')
out.append('对比: miniQMT 模拟端 1m/5m/15m 统一 ~12 个月(2025-07-16 起)')
msg = '\n'.join(out)
print(msg)
with open('_mootdx_depth_result.txt', 'w') as f:
f.write(msg)
if __name__ == '__main__':
main()
@@ -0,0 +1,18 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""验证退市股是否早就在 daily_baostock_full(Task3 import +0 的根因确认)。"""
import sqlite3
DB = r"C:\sanguo_vnpy_v2\data\quant_trading.db"
c = sqlite3.connect(DB, timeout=60)
# 总况
total = c.execute("SELECT COUNT(*), COUNT(DISTINCT symbol||exchange) FROM daily_baostock_full").fetchone()
print(f"TOTAL: rows={total[0]} distinct(sym+exc)={total[1]}")
# 抽样退市股(Task3 下过的)
for sym, exc in [("000005", "SZ"), ("000023", "SZ"), ("600811", "SH"), ("600074", "SH"), ("000004", "SZ")]:
r = c.execute(
"SELECT COUNT(*), MIN(date), MAX(date) FROM daily_baostock_full WHERE symbol=? AND exchange=?",
(sym, exc),
).fetchone()
print(f" {sym}.{exc}: rows={r[0]} date {r[1]} ~ {r[2]}")
c.close()
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""验证主库 data(非 staging) 的 ETF + A股 max_date。
检查: sh510300/sh513050/sz159919 max_date==2026-07-21;sh600519 max_date==2026-07-21(未被 staging 覆盖)
"""
import os
import sys
import pandas as pd
DATA = r"C:\sanguo_vnpy_v2\data"
def check(sym_prefix, kind="qfq"):
"""逐年合并所有年份的 parquet。"""
root = os.path.join(DATA, kind)
if not os.path.isdir(root):
return {"err": f"no {kind} dir"}
all_dfs = []
for yr in sorted(os.listdir(root)):
if not yr.isdigit():
continue
p = os.path.join(root, yr, f"{sym_prefix}_daily.parquet")
if os.path.exists(p):
all_dfs.append(pd.read_parquet(p))
if not all_dfs:
return {"err": "not found"}
df = pd.concat(all_dfs).sort_values("date")
return {
"rows": len(df),
"min_date": str(df["date"].min().date()),
"max_date": str(df["date"].max().date()),
"tail_close": float(df["close"].iloc[-1]),
"nan_close": bool(df["close"].isnull().any()),
}
def main():
print("=== ETF 抽样 (qfq) ===")
for sym in ("sh510300", "sh513050", "sz159919"):
print(f" {sym}: {check(sym, 'qfq')}")
print("\n=== A 股对照 (qfq, 验证未被 staging 覆盖) ===")
print(f" sh600519: {check('sh600519', 'qfq')}")
print(f"\n=== raw 同抽样(完整性对照) ===")
print(f" sh510300 raw: {check('sh510300', 'raw')}")
print(f" sh600519 raw: {check('sh600519', 'raw')}")
sys.stdout.flush()
if __name__ == "__main__":
main()
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""验证 ETF staging parquet (P0 Task2.4)。
检查: 股票/ETF/fund 计数, 抽样 close NaN, 日期范围, 行数
VPS : C:\\Python310\\python.exe -X utf8 verify_etf_staging.py
"""
import os
import glob
import sys
import pandas as pd
STAGING = r"C:\sanguo_vnpy_v2\data\_staging_xtdata"
def count_symbols(kind):
"""kind='qfq' or 'raw' — count distinct symbol files across years."""
pattern = os.path.join(STAGING, kind, "*", "*_daily.parquet")
files = glob.glob(pattern)
syms = set(os.path.basename(f).replace("_daily.parquet", "") for f in files)
return len(syms), files
def sample_check(sym_prefix, kind="qfq"):
"""读最新年份 parquet 看尾部 close."""
# 逐年合并所有年份的 parquet
years = sorted(d for d in os.listdir(os.path.join(STAGING, kind))
if d.isdigit()) if os.path.exists(os.path.join(STAGING, kind)) else []
if not years:
return None
all_dfs = []
for yr in years:
p = os.path.join(STAGING, kind, yr, f"{sym_prefix}_daily.parquet")
if os.path.exists(p):
df = pd.read_parquet(p)
all_dfs.append(df)
if not all_dfs:
return None
df = pd.concat(all_dfs).sort_values("date")
return {
"rows": len(df),
"min_date": str(df["date"].min().date()),
"max_date": str(df["date"].max().date()),
"tail_close": float(df["close"].iloc[-1]),
"nan_close": bool(df["close"].isnull().any()),
}
def main():
for kind in ("raw", "qfq"):
n, files = count_symbols(kind)
print(f"=== {kind}: {n} distinct symbols, {len(files)} year-files ===")
if not n:
continue
# 分类: sh* sz* 前缀
sh = sum(1 for f in files if os.path.basename(f).startswith("sh"))
sz = sum(1 for f in files if os.path.basename(f).startswith("sz"))
print(f" sh-prefix: {sh} sz-prefix: {sz}")
# 总行数估算(抽样第一个年份目录)
years = sorted(set(os.path.dirname(f).split(os.sep)[-1] for f in files))
print(f" years: {years[0]}~{years[-1]} ({len(years)} years)")
print("\n=== 抽样 ETF (qfq) ===")
for sym_prefix in ("sh510300", "sh513050", "sz159919"):
info = sample_check(sym_prefix, "qfq")
if info is None:
print(f" {sym_prefix}: NOT FOUND")
else:
print(f" {sym_prefix}: {info}")
print("\n=== 抽样 A 股对照 (sh600519 茅台 qfq) ===")
info = sample_check("sh600519", "qfq")
print(f" sh600519: {info}")
sys.stdout.flush()
if __name__ == "__main__":
main()