This commit is contained in:
曾志威
2026-05-10 00:35:59 +08:00
parent deb030d1df
commit 217b12c205
24 changed files with 1916 additions and 1 deletions
+4
View File
@@ -1,3 +1,6 @@
# 项目配置(含密码等敏感信息)
config.yaml
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -174,3 +177,4 @@ cython_debug/
# PyPI configuration file
.pypirc
.idea/
+10
View File
@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ClaudeCodeTabState">
<option name="tabSessions">
<map>
<entry key="0">
<value>
<TabSessionState>
<option name="provider" value="claude" />
<option name="sessionId" value="fff69755-9aa0-4879-bbe1-5e61efee9bea" />
<option name="cwd" value="$PROJECT_DIR$" />
<option name="model" value="claude-opus-4-7[1m]" />
<option name="permissionMode" value="bypassPermissions" />
<option name="reasoningEffort" value="high" />
</TabSessionState>
</value>
</entry>
</map>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ashare-data.iml" filepath="$PROJECT_DIR$/.idea/ashare-data.iml" />
</modules>
</component>
</project>
+261 -1
View File
@@ -1,3 +1,263 @@
# ashare-data
保存A股数据
A股数据抓取工具,使用 [AKShare](https://github.com/akfamily/akshare) 获取数据,保存到 MySQL 数据库。
## 功能概览
| 数据类型 | 说明 | 数据来源 |
|---------|------|---------|
| 股票列表 | A股全部股票代码和名称 | 东方财富 |
| 日线行情 | 开盘价、收盘价、最高价、最低价、成交量、成交额、涨跌幅等(前复权) | 东方财富 |
| 财务报表 | 利润表、资产负债表、现金流量表 | 新浪财经 |
| 资金流向 | 主力/超大/大/中/小单净流入及占比 | 东方财富 |
| 龙虎榜 | 上榜股票、买入卖出金额、上榜原因 | 东方财富 |
| 分红送转 | 每10股送转、派息、股息率等 | 巨潮资讯 |
## 快速开始
### 1. 环境要求
- Python >= 3.10
- MySQL >= 5.7(建议 8.0+
### 2. 安装依赖
```bash
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # Linux/Mac
pip install -r requirements.txt
```
### 3. 配置数据库
复制配置文件并修改 MySQL 连接信息:
```bash
cp config.example.yaml config.yaml
```
编辑 `config.yaml`,填写你的 MySQL 连接信息:
```yaml
mysql:
host: "localhost"
port: 3306
user: "root"
password: "your_password"
database: "ashare"
charset: "utf8mb4"
fetch:
delay: 0.5 # 请求间隔(秒),防止被限流
retry: 3 # 失败重试次数
```
确保 MySQL 中已创建对应数据库:
```sql
CREATE DATABASE ashare CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
### 4. 运行
```bash
# 先抓取股票列表(其他模块依赖此数据)
python -m src.main --stock-list
# 抓取最近30天的日线行情
python -m src.main --daily
# 指定日期范围抓取日线
python -m src.main --daily --start-date 20260430 --end-date 20260508
# 抓取财务报表(全部股票)
python -m src.main --financial
# 抓取单只股票的财务数据
python -m src.main --financial --symbol 000001
# 抓取资金流向
python -m src.main --money-flow
# 抓取最近30天的龙虎榜
python -m src.main --dragon-tiger
# 指定日期范围抓取龙虎榜
python -m src.main --dragon-tiger --start-date 20260423 --end-date 20250509
# 抓取分红送转
python -m src.main --dividend
# 全量抓取所有数据
python -m src.main --all
```
### 5. 命令行参数说明
```
usage: main.py [-h] [--stock-list] [--daily] [--financial] [--money-flow]
[--dragon-tiger] [--dividend] [--all]
[--start-date START_DATE] [--end-date END_DATE]
[--symbol SYMBOL]
选项:
--stock-list 抓取A股股票列表
--daily 抓取日线行情
--financial 抓取财务报表(利润表、资产负债表、现金流量表)
--money-flow 抓取个股资金流向
--dragon-tiger 抓取龙虎榜数据
--dividend 抓取分红送转数据
--all 全量抓取以上所有数据
日期过滤(仅对日线行情和龙虎榜生效):
--start-date 开始日期,格式 YYYYMMDD,默认30天前
--end-date 结束日期,格式 YYYYMMDD,默认今天
股票过滤(仅对财务报表和分红送转生效):
--symbol 指定单只股票代码,如 000001,默认全部股票
```
## 数据库表结构
### stock_info — 股票基本信息
| 字段 | 类型 | 说明 |
|------|------|------|
| code | VARCHAR(10) PK | 股票代码 |
| name | VARCHAR(50) | 股票名称 |
### stock_daily — 日线行情(前复权)
| 字段 | 类型 | 说明 |
|------|------|------|
| code | VARCHAR(10) | 股票代码 |
| date | DATE | 交易日期 |
| open | FLOAT | 开盘价 |
| close | FLOAT | 收盘价 |
| high | FLOAT | 最高价 |
| low | FLOAT | 最低价 |
| volume | FLOAT | 成交量 |
| turnover | FLOAT | 成交额 |
| amplitude | FLOAT | 振幅% |
| pct_change | FLOAT | 涨跌幅% |
| change | FLOAT | 涨跌额 |
| turnover_rate | FLOAT | 换手率% |
联合主键:`(code, date)`
### stock_financial_income — 利润表
| 字段 | 类型 | 说明 |
|------|------|------|
| code | VARCHAR(10) | 股票代码 |
| report_date | VARCHAR(20) | 报告期 |
| data | TEXT | JSON格式利润表数据 |
联合主键:`(code, report_date)`
### stock_financial_balance — 资产负债表
| 字段 | 类型 | 说明 |
|------|------|------|
| code | VARCHAR(10) | 股票代码 |
| report_date | VARCHAR(20) | 报告期 |
| data | TEXT | JSON格式资产负债表数据 |
联合主键:`(code, report_date)`
### stock_financial_cashflow — 现金流量表
| 字段 | 类型 | 说明 |
|------|------|------|
| code | VARCHAR(10) | 股票代码 |
| report_date | VARCHAR(20) | 报告期 |
| data | TEXT | JSON格式现金流量表数据 |
联合主键:`(code, report_date)`
### stock_money_flow — 个股资金流向
| 字段 | 类型 | 说明 |
|------|------|------|
| code | VARCHAR(10) | 股票代码 |
| date | DATE | 日期 |
| close | FLOAT | 收盘价 |
| pct_change | FLOAT | 涨跌幅% |
| main_net_inflow | FLOAT | 主力净流入-净额 |
| main_net_pct | FLOAT | 主力净流入-净占比 |
| huge_net_inflow | FLOAT | 超大盘净流入-净额 |
| huge_net_pct | FLOAT | 超大盘净流入-净占比 |
| big_net_inflow | FLOAT | 大盘净流入-净额 |
| big_net_pct | FLOAT | 大盘净流入-净占比 |
| mid_net_inflow | FLOAT | 中盘净流入-净额 |
| mid_net_pct | FLOAT | 中盘净流入-净占比 |
| small_net_inflow | FLOAT | 小盘净流入-净额 |
| small_net_pct | FLOAT | 小盘净流入-净占比 |
联合主键:`(code, date)`
### stock_dragon_tiger — 龙虎榜
| 字段 | 类型 | 说明 |
|------|------|------|
| code | VARCHAR(10) | 股票代码 |
| name | VARCHAR(50) | 股票名称 |
| date | DATE | 上榜日期 |
| close | FLOAT | 收盘价 |
| pct_change | FLOAT | 涨跌幅% |
| reason | VARCHAR(200) | 上榜原因 |
| buy_amount | FLOAT | 买入额 |
| sell_amount | FLOAT | 卖出额 |
| net_amount | FLOAT | 净额 |
联合主键:`(code, date)`
### stock_dividend — 分红送转
| 字段 | 类型 | 说明 |
|------|------|------|
| code | VARCHAR(10) | 股票代码 |
| name | VARCHAR(50) | 股票名称 |
| report_date | VARCHAR(20) | 报告期 |
| dividend_date | DATE | 除权除息日 |
| bonus_ratio | FLOAT | 每10股送转比例 |
| cash_div | FLOAT | 每10股派息 |
| convert_ratio | FLOAT | 每10股转增比例 |
| ex_right_date | DATE | 除权日 |
| dividend_yield | FLOAT | 股息率% |
联合主键:`(code, report_date)`
## 项目结构
```
ashare-data/
├── config.example.yaml # 配置文件模板
├── config.yaml # 实际配置(含密码,已加入.gitignore)
├── pyproject.toml # 项目元数据
├── requirements.txt # Python依赖
├── src/
│ ├── __init__.py
│ ├── config.py # 配置读取模块
│ ├── db.py # 数据库模型与连接管理
│ ├── main.py # 命令行入口
│ └── fetchers/
│ ├── __init__.py
│ ├── stock_list.py # 股票列表抓取
│ ├── daily.py # 日线行情抓取
│ ├── financial.py # 财务报表抓取
│ ├── money_flow.py # 资金流向抓取
│ ├── dragon_tiger.py # 龙虎榜抓取
│ └── dividend.py # 分红送转抓取
└── README.md
```
## 设计说明
- **去重写入**:所有表使用 `INSERT ON DUPLICATE KEY UPDATE`(upsert),重复执行不会产生重复数据
- **自动重试**:网络请求失败自动重试(默认3次),单只股票失败不影响整体
- **限速保护**:请求间自动延迟(默认0.5秒),防止被数据源限流
- **增量更新**:日线行情和龙虎榜支持通过 `--start-date` / `--end-date` 指定日期范围
- **懒加载导入**:各fetcher模块按需导入,未使用的模块不会加载
+13
View File
@@ -0,0 +1,13 @@
mysql:
host: "localhost"
port: 3306
user: "root"
password: "your_password"
database: "ashare"
charset: "utf8mb4"
fetch:
# 请求间隔(秒),避免被限流
delay: 0.5
# 失败重试次数
retry: 3
+14
View File
@@ -0,0 +1,14 @@
# MySQL 数据库连接配置
mysql:
host: "db.freeicu.top" # 数据库地址
port: 32000 # 端口号
user: "root" # 用户名
password: "ttx2011" # 密码
database: "ashare" # 数据库名(需提前创建)
charset: "utf8mb4" # 字符集,支持中文
# 数据抓取配置
fetch:
delay: 0.1 # 每次请求间隔(秒)
retry: 2 # 失败重试次数
workers: 5 # 并发线程数;3个数据源(BaoStock+新浪+腾讯),建议 5-8
+15
View File
@@ -0,0 +1,15 @@
[project]
name = "ashare-data"
version = "0.1.0"
description = "A股数据抓取,保存到MySQL数据库"
requires-python = ">=3.10"
dependencies = [
"akshare",
"pymysql",
"sqlalchemy>=2.0",
"pyyaml",
"pandas",
]
[project.scripts]
ashare = "src.main:main"
+5
View File
@@ -0,0 +1,5 @@
akshare
pymysql
sqlalchemy>=2.0
pyyaml
pandas
View File
+47
View File
@@ -0,0 +1,47 @@
"""全局配置加载模块,读取 config.yaml 提供 MySQL 连接和抓取参数"""
import os
import yaml
from pathlib import Path
_CONFIG = None
def load_config(config_path: str | None = None) -> dict:
global _CONFIG
if _CONFIG is not None:
return _CONFIG
# 支持通过环境变量 ASHARE_CONFIG 指定配置文件路径
if config_path is None:
config_path = os.environ.get("ASHARE_CONFIG", "config.yaml")
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(
f"配置文件不存在: {path}\n"
f"请复制 config.example.yaml 为 config.yaml 并填写配置"
)
with open(path, "r", encoding="utf-8") as f:
_CONFIG = yaml.safe_load(f)
return _CONFIG
def get_mysql_url(config: dict | None = None) -> str:
"""构建 SQLAlchemy 连接 URL,使用 pymysql 驱动"""
if config is None:
config = load_config()
m = config["mysql"]
return (
f"mysql+pymysql://{m['user']}:{m['password']}"
f"@{m['host']}:{m['port']}/{m['database']}"
f"?charset={m.get('charset', 'utf8mb4')}"
)
def get_fetch_config(config: dict | None = None) -> dict:
"""返回抓取相关配置(delay/retry/workers),缺失时使用默认值"""
if config is None:
config = load_config()
return config.get("fetch", {"delay": 0.5, "retry": 3})
+271
View File
@@ -0,0 +1,271 @@
"""数据库模型定义与连接管理
表结构概览:
- stock_info: 股票基本信息(含上市日期,用于跳过未上市股票)
- stock_daily: 日线行情(多源抓取:BaoStock/新浪/腾讯)
- stock_financial_income/balance/cashflow: 三大财务报表(JSON存储)
- stock_money_flow: 个股资金流向
- stock_dragon_tiger: 龙虎榜
- stock_dividend: 分红送转
- stock_intraday: 1分钟分时行情
- trading_day: 交易日历(用于判断数据完整性)
- stock_no_data: 无数据/停牌记录(避免重复抓取)
"""
from sqlalchemy import (
Column, String, Date, DateTime, Float, Integer, Text,
UniqueConstraint, Index, create_engine, MetaData, func, text,
)
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from sqlalchemy.dialects.mysql import insert as mysql_insert
from src.config import get_mysql_url
class Base(DeclarativeBase):
pass
# ── 股票基本信息 ──
class StockInfo(Base):
__tablename__ = "stock_info"
code = Column(String(10), primary_key=True, comment="股票代码")
name = Column(String(50), comment="股票名称")
# 上市日期用于在抓取历史数据时跳过当时尚未上市的股票
ipo_date = Column(Date, comment="上市日期")
# ── 日线行情 ──
class StockDaily(Base):
__tablename__ = "stock_daily"
__table_args__ = (
UniqueConstraint("code", "date", name="uq_daily_code_date"),
Index("ix_daily_date", "date"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(10), nullable=False, comment="股票代码")
# 多个数据源(BaoStock/新浪/腾讯)写入同一张表,通过 upsert 去重
date = Column(Date, nullable=False, comment="交易日期")
open = Column(Float, comment="开盘价")
close = Column(Float, comment="收盘价")
high = Column(Float, comment="最高价")
low = Column(Float, comment="最低价")
volume = Column(Float, comment="成交量")
turnover = Column(Float, comment="成交额")
amplitude = Column(Float, comment="振幅%")
pct_change = Column(Float, comment="涨跌幅%")
change = Column(Float, comment="涨跌额")
turnover_rate = Column(Float, comment="换手率%")
# ── 利润表 ──
class FinancialIncome(Base):
__tablename__ = "stock_financial_income"
__table_args__ = (
UniqueConstraint("code", "report_date", name="uq_income_code_date"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(10), nullable=False, comment="股票代码")
report_date = Column(String(20), nullable=False, comment="报告期")
data = Column(Text, comment="JSON格式利润表数据")
# ── 资产负债表 ──
class FinancialBalance(Base):
__tablename__ = "stock_financial_balance"
__table_args__ = (
UniqueConstraint("code", "report_date", name="uq_balance_code_date"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(10), nullable=False, comment="股票代码")
report_date = Column(String(20), nullable=False, comment="报告期")
data = Column(Text, comment="JSON格式资产负债表数据")
# ── 现金流量表 ──
class FinancialCashflow(Base):
__tablename__ = "stock_financial_cashflow"
__table_args__ = (
UniqueConstraint("code", "report_date", name="uq_cashflow_code_date"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(10), nullable=False, comment="股票代码")
report_date = Column(String(20), nullable=False, comment="报告期")
data = Column(Text, comment="JSON格式现金流量表数据")
# ── 资金流向 ──
class StockMoneyFlow(Base):
__tablename__ = "stock_money_flow"
__table_args__ = (
UniqueConstraint("code", "date", name="uq_moneyflow_code_date"),
Index("ix_moneyflow_date", "date"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(10), nullable=False, comment="股票代码")
date = Column(Date, nullable=False, comment="日期")
close = Column(Float, comment="收盘价")
pct_change = Column(Float, comment="涨跌幅%")
main_net_inflow = Column(Float, comment="主力净流入-净额")
main_net_pct = Column(Float, comment="主力净流入-净占比")
huge_net_inflow = Column(Float, comment="超大盘净流入-净额")
huge_net_pct = Column(Float, comment="超大盘净流入-净占比")
big_net_inflow = Column(Float, comment="大盘净流入-净额")
big_net_pct = Column(Float, comment="大盘净流入-净占比")
mid_net_inflow = Column(Float, comment="中盘净流入-净额")
mid_net_pct = Column(Float, comment="中盘净流入-净占比")
small_net_inflow = Column(Float, comment="小盘净流入-净额")
small_net_pct = Column(Float, comment="小盘净流入-净占比")
# ── 龙虎榜 ──
class StockDragonTiger(Base):
__tablename__ = "stock_dragon_tiger"
__table_args__ = (
UniqueConstraint("code", "date", name="uq_lhb_code_date"),
Index("ix_lhb_date", "date"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(10), nullable=False, comment="股票代码")
name = Column(String(50), comment="股票名称")
date = Column(Date, nullable=False, comment="上榜日期")
close = Column(Float, comment="收盘价")
pct_change = Column(Float, comment="涨跌幅%")
reason = Column(String(200), comment="上榜原因")
buy_amount = Column(Float, comment="买入额")
sell_amount = Column(Float, comment="卖出额")
net_amount = Column(Float, comment="净额")
# ── 分红送转 ──
class StockDividend(Base):
__tablename__ = "stock_dividend"
__table_args__ = (
UniqueConstraint("code", "report_date", name="uq_dividend_code_date"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(10), nullable=False, comment="股票代码")
name = Column(String(50), comment="股票名称")
report_date = Column(String(20), nullable=False, comment="报告期")
dividend_date = Column(Date, comment="除权除息日")
bonus_ratio = Column(Float, comment="每10股送转比例")
cash_div = Column(Float, comment="每10股派息")
convert_ratio = Column(Float, comment="每10股转增比例")
ex_right_date = Column(Date, comment="除权日")
dividend_yield = Column(Float, comment="股息率%")
# ── 交易日历 ──
class TradingDay(Base):
__tablename__ = "trading_day"
__table_args__ = (
UniqueConstraint("date", name="uq_trading_day_date"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
date = Column(Date, nullable=False, comment="交易日期")
# ── 无数据/停牌记录(按天粒度) ──
class StockNoData(Base):
__tablename__ = "stock_no_data"
__table_args__ = (
UniqueConstraint("code", "date", name="uq_nodata_code_date"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(10), nullable=False, comment="股票代码")
date = Column(Date, nullable=False, comment="停牌/无数据日期")
created_at = Column(DateTime, server_default=func.now(), comment="记录时间")
# ── 分时行情(1分钟线) ──
class StockIntraday(Base):
__tablename__ = "stock_intraday"
__table_args__ = (
UniqueConstraint("code", "datetime", name="uq_intraday_code_dt"),
Index("ix_intraday_date", "datetime"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(10), nullable=False, comment="股票代码")
datetime = Column(DateTime, nullable=False, comment="时间")
open = Column(Float, comment="开盘价")
high = Column(Float, comment="最高价")
low = Column(Float, comment="最低价")
close = Column(Float, comment="收盘价")
volume = Column(Float, comment="成交量")
amount = Column(Float, comment="成交额")
# ── 数据库连接管理 ──
_engine = None
_SessionFactory = None
def get_engine():
global _engine
if _engine is None:
_engine = create_engine(get_mysql_url(), pool_size=5, pool_recycle=3600)
return _engine
def get_session() -> Session:
global _SessionFactory
if _SessionFactory is None:
_SessionFactory = sessionmaker(bind=get_engine())
return _SessionFactory()
def init_db():
engine = get_engine()
# 自动迁移:旧版 stock_no_data 使用 date_range 列,新版改为 date 列
# 检测到旧表结构时先删除,由 create_all 重建
with engine.connect() as conn:
result = conn.execute(text("SHOW COLUMNS FROM stock_no_data LIKE 'date_range'"))
if result.fetchone():
conn.execute(text("DROP TABLE stock_no_data"))
conn.commit()
print(" stock_no_data 表结构已升级(date_range → date")
Base.metadata.create_all(engine)
print("数据库表初始化完成")
def batch_upsert(model_cls: type[Base], rows: list[dict], index_columns: list[str]):
"""MySQL批量upsertINSERT ON DUPLICATE KEY UPDATE
index_columns: 用于判断重复的唯一键列名(如 ["code", "date"]),
这些列在冲突时不更新,其余列使用新值覆盖。
"""
if not rows:
return
session = get_session()
try:
stmt = mysql_insert(model_cls).values(rows)
# 只更新输入数据中实际包含的列,排除唯一键列、自增主键和 server_default 列
input_keys = set(rows[0].keys())
update_dict = {
col.name: stmt.inserted[col.name]
for col in model_cls.__table__.columns
if col.name in input_keys
and col.name not in index_columns
and not col.primary_key
and not col.server_default
}
if update_dict:
stmt = stmt.on_duplicate_key_update(**update_dict)
session.execute(stmt)
session.commit()
except Exception as e:
session.rollback()
raise e
finally:
session.close()
View File
+398
View File
@@ -0,0 +1,398 @@
"""日线行情抓取模块 — 核心模块,采用三数据源轮询 + 自动降级架构
数据源优先级(按速度排序):
1. BaoStock: 速度极快(~0.04s/只),但不支持北交所(920xxx),线程不安全需加锁
2. 新浪: 支持全部交易所(含北交所 bj 前缀),返回 JSONP 需解析
3. 腾讯: 不支持北交所,返回标准 JSON
跳过策略(停牌天按天记录):
- 数据完整 = 行情记录数 + 已标记停牌天数 >= 交易日总数
- 未上市股票(ipo_date > 查询结束日期)
- 抓取成功后自动识别缺失交易日并标记为停牌
"""
import json
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
import requests
from src.config import get_fetch_config
from src.db import StockInfo, StockDaily, StockNoData, batch_upsert, get_session
from sqlalchemy import select, func
# 新浪日线接口:返回 JSONP 格式,需正则提取 JSON 数组
_SINA_URL = "https://quotes.sina.cn/cn/api/jsonp_v2.php/var=/CN_MarketDataService.getKLineData"
# 腾讯日线接口:返回标准 JSON,支持前复权
_TENCENT_URL = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://finance.sina.com.cn",
}
# 复用 HTTP Session 以利用连接池和 keep-alive,减少 TCP 握手开销
_sina_session = requests.Session()
_sina_session.headers.update(_HEADERS)
_tencent_session = requests.Session()
_tencent_session.headers.update(_HEADERS)
# BaoStock 全局连接:bs.login() 只需调用一次,但 query_history_k_data_plus 非线程安全
import baostock as bs
import threading
_bs_lock = threading.Lock()
_bs_logged_in = False
def _bs_ensure_login():
global _bs_logged_in
with _bs_lock:
if not _bs_logged_in:
bs.login()
_bs_logged_in = True
def _get_stock_codes() -> list[str]:
session = get_session()
try:
result = session.execute(select(StockInfo.code))
return [row[0] for row in result]
finally:
session.close()
def _get_not_listed(end_date: str) -> set[str]:
"""查询在end_date之后上市的股票(未上市,需跳过)
例如抓取 20260501~20260508 的数据时,5月10日上市的股票应被跳过
"""
session = get_session()
try:
result = session.execute(
select(StockInfo.code).where(StockInfo.ipo_date > end_date)
)
return {row[0] for row in result}
finally:
session.close()
def _get_complete_codes(start_date: str, end_date: str, trading_days: list[str]) -> set[str]:
"""数据完整的判断:行情记录数 + 已标记停牌天数 >= 交易日总数
某只股票在范围内停牌了2天、有行情8天、共10个交易日 → 8+2=10, 视为完整。
这样停牌股票不会被反复抓取,同时部分停牌也能正确处理。
"""
if not trading_days:
return set()
td_count = len(trading_days)
session = get_session()
try:
# 行情记录数
rec_rows = session.execute(
select(StockDaily.code, func.count(StockDaily.id))
.where(StockDaily.date >= start_date)
.where(StockDaily.date <= end_date)
.group_by(StockDaily.code)
)
rec_counts = {row[0]: row[1] for row in rec_rows}
# 停牌天数
susp_rows = session.execute(
select(StockNoData.code, func.count(StockNoData.id))
.where(StockNoData.date >= start_date)
.where(StockNoData.date <= end_date)
.group_by(StockNoData.code)
)
susp_counts = {row[0]: row[1] for row in susp_rows}
complete = set()
for code in set(rec_counts) | set(susp_counts):
if rec_counts.get(code, 0) + susp_counts.get(code, 0) >= td_count:
complete.add(code)
return complete
finally:
session.close()
def _record_nodata_days(code: str, days: list[str]):
"""记录个股的停牌/无数据日期(按天粒度)
抓取成功后,对比实际交易日列表,缺失的日期即为停牌天。
全部源无数据时,所有交易日都标记为停牌。
"""
if not days:
return
rows = [{"code": code, "date": d} for d in days]
batch_upsert(StockNoData, rows, ["code", "date"])
def _code_to_prefix(code: str) -> str:
"""转为新浪/腾讯接口的代码前缀格式(如 sh600000、sz000001"""
# 北交所920开头需用 bj 前缀(新浪特有),否则返回数据中缺少日期字段
if code.startswith("920"):
return f"bj{code}"
if code.startswith(("6", "9")):
return f"sh{code}"
return f"sz{code}"
def _code_to_baostock(code: str) -> str:
"""BaoStock格式: sh.600000 / sz.000001"""
if code.startswith(("6", "9")):
return f"sh.{code}"
return f"sz.{code}"
def _clean(val):
"""将空字符串、无效值转为 None
BaoStock 对无数据的字段返回空字符串 ''MySQL FLOAT 列不接受空字符串,
不做转换会触发 DataError。此函数统一处理所有数据源的空值情况。
"""
if val is None:
return None
if isinstance(val, str) and val.strip() == "":
return None
return val
def _fetch_sina(code: str, start_date: str, end_date: str, datalen: int) -> list[dict] | None:
"""新浪数据源:支持全部交易所(含北交所 bj 前缀)
接口返回 JSONP 格式 `var=(...)`, 需正则提取 JSON 数组。
datalen 参数控制返回的K线条数,新浪不支持精确日期范围过滤,
所以拿到数据后再在客户端按日期范围筛选。
"""
symbol = _code_to_prefix(code)
try:
r = _sina_session.get(
_SINA_URL,
params={"symbol": symbol, "scale": "240", "ma": "no", "datalen": str(datalen)},
timeout=10,
)
m = re.search(r"\((\[.*\])\)", r.text, re.DOTALL)
if not m:
return None
data = json.loads(m.group(1))
if not data:
return None
# 新浪接口返回的日期格式为 "2026-05-08",需转为统一格式做范围比较
sd = f"{start_date[:4]}-{start_date[4:6]}-{start_date[6:8]}"
ed = f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}"
rows = []
for item in data:
day = item.get("day", "")
if sd <= day <= ed:
rows.append({
"code": code, "date": day,
"open": _clean(item.get("open")), "high": _clean(item.get("high")),
"low": _clean(item.get("low")), "close": _clean(item.get("close")),
"volume": _clean(item.get("volume")), "turnover": None,
"amplitude": None, "pct_change": None, "change": None, "turnover_rate": None,
})
return rows if rows else None
except Exception:
return None
def _fetch_tencent(code: str, start_date: str, end_date: str) -> list[dict] | None:
"""腾讯数据源:不支持北交所(920xxx),返回标准 JSON
接口支持精确日期范围查询和前复权(qfq),数据路径为 data.{symbol}.qfqday
"""
# 腾讯接口无北交所数据,直接跳过避免无效请求
if code.startswith("920"):
return None
symbol = _code_to_prefix(code)
sd = f"{start_date[:4]}-{start_date[4:6]}-{start_date[6:8]}"
ed = f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}"
try:
r = _tencent_session.get(
_TENCENT_URL,
params={"param": f"{symbol},day,{sd},{ed},300,qfq"},
timeout=10,
)
d = r.json()
klines = d.get("data", {}).get(symbol, {}).get("qfqday")
if not klines:
return None
rows = []
for k in klines:
rows.append({
"code": code, "date": k[0],
"open": _clean(k[1]), "close": _clean(k[2]), "high": _clean(k[3]), "low": _clean(k[4]),
"volume": _clean(k[5]), "turnover": None,
"amplitude": None, "pct_change": None, "change": None, "turnover_rate": None,
})
return rows if rows else None
except Exception:
return None
def _fetch_baostock(code: str, start_date: str, end_date: str) -> list[dict] | None:
"""BaoStock 数据源:速度极快(~0.04s/只),但不支持北交所
BaoStock 的 query_history_k_data_plus 不是线程安全的,
必须在 _bs_lock 保护下串行调用,否则会出现数据错乱。
"""
if code.startswith("920"):
return None
symbol = _code_to_baostock(code)
# BaoStock日期格式: 2026-05-01
sd = f"{start_date[:4]}-{start_date[4:6]}-{start_date[6:8]}"
ed = f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}"
try:
with _bs_lock:
# adjustflag="2" 表示前复权,amount 为成交额
rs = bs.query_history_k_data_plus(
symbol, "date,open,high,low,close,volume,amount",
start_date=sd, end_date=ed, frequency="d", adjustflag="2",
)
rows = []
while (rs.error_code == "0") and rs.next():
r = rs.get_row_data()
rows.append({
"code": code, "date": r[0],
"open": _clean(r[1]), "high": _clean(r[2]), "low": _clean(r[3]), "close": _clean(r[4]),
"volume": _clean(r[5]), "turnover": _clean(r[6]),
"amplitude": None, "pct_change": None, "change": None, "turnover_rate": None,
})
return rows if rows else None
except Exception:
return None
# 数据源列表,按速度排序,轮询分配以分散请求压力
_SOURCES = ["baostock", "sina", "tencent"]
_FETCH_FNS = {
"baostock": lambda code, sd, ed, dl: _fetch_baostock(code, sd, ed),
"sina": _fetch_sina,
"tencent": lambda code, sd, ed, dl: _fetch_tencent(code, sd, ed),
}
def _fetch_one(code: str, start_date: str, end_date: str, source: str, datalen: int) -> dict:
"""单只股票抓取:主源失败时自动按顺序尝试其他数据源(降级策略)
例如主源为 baostock,失败后会依次尝试 sina、tencent
全部失败则标记为 fail 或 no_data。
"""
ordered = [source] + [s for s in _SOURCES if s != source]
tried = []
for src in ordered:
fn = _FETCH_FNS[src]
rows = fn(code, start_date, end_date, datalen)
tried.append(src)
if rows is not None:
label = src if src == source else f"{src}(fallback)"
return {"code": code, "status": "ok", "rows": rows, "source": label}
return {"code": code, "status": "fail", "source": "".join(tried),
"no_data": all(s in tried for s in _SOURCES)}
def fetch_daily(start_date: str | None = None, end_date: str | None = None):
cfg = get_fetch_config()
delay = cfg.get("delay", 0.1)
workers = cfg.get("workers", 5)
codes = _get_stock_codes()
if not codes:
print(" 无股票列表,请先运行 --stock-list", flush=True)
return
if end_date is None:
end_date = datetime.now().strftime("%Y%m%d")
if start_date is None:
start_date = (datetime.now() - timedelta(days=30)).strftime("%Y%m%d")
start_dt = datetime.strptime(start_date, "%Y%m%d")
end_dt = datetime.strptime(end_date, "%Y%m%d")
# 多取10天以防节假日偏移导致数据不足
datalen = (end_dt - start_dt).days + 10
# BaoStock 需要先 login() 才能查询,全局只需一次
_bs_ensure_login()
# 获取交易日历
from src.fetchers.trading_day import get_trading_days
trading_days = get_trading_days(start_date, end_date)
td_count = len(trading_days)
print(f" 交易日历: {start_date} ~ {end_date}{td_count} 个交易日", flush=True)
# 跳过过滤:数据完整(含已标记停牌天数)+ 未上市
complete = _get_complete_codes(start_date, end_date, trading_days)
not_listed = _get_not_listed(end_date)
skip_set = complete | not_listed
if complete:
print(f" {len(complete)} 只股票数据已完整(含停牌天),跳过...", flush=True)
if not_listed:
print(f" {len(not_listed)} 只股票未上市,跳过...", flush=True)
codes = [c for c in codes if c not in skip_set]
# 交易日集合,用于抓取后比对缺失日期
trading_days_set = set(trading_days)
total = len(codes)
if total == 0:
print(" 所有股票数据已完整,无需抓取", flush=True)
return
success = 0
fail = 0
nodata_count = 0
skipped = len(skip_set)
t_start = time.time()
# 轮询分配数据源:将股票均匀分配到3个源,分散请求压力
sources = [_SOURCES[i % 3] for i in range(total)]
print(f"正在抓取日线行情 {start_date} ~ {end_date},需抓取 {total} 只(跳过 {skipped} 只),"
f"{workers} 线程 × 3 源(BaoStock+新浪+腾讯)...", flush=True)
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {}
for idx, code in enumerate(codes):
f = pool.submit(_fetch_one, code, start_date, end_date, sources[idx], datalen)
futures[f] = code
done_count = 0
for f in as_completed(futures):
done_count += 1
result = f.result()
code = result["code"]
status = result["status"]
if status == "ok":
try:
batch_upsert(StockDaily, result["rows"], ["code", "date"])
success += 1
# 比对返回日期与交易日,缺失的标记为停牌天
returned_dates = {row["date"] for row in result["rows"]}
missing_days = [d for d in trading_days if d not in returned_dates]
if missing_days:
_record_nodata_days(code, missing_days)
except Exception as e:
print(f" {code} 写入失败: {e}", flush=True)
fail += 1
else:
if result.get("no_data"):
# 三个数据源全部返回空数据,所有交易日标记为停牌
_record_nodata_days(code, trading_days)
nodata_count += 1
else:
fail += 1
total_elapsed = time.time() - t_start
avg = total_elapsed / done_count
eta = avg * (total - done_count)
src = result.get("source", "")
print(f" [{done_count}/{total}] {code} [{src}] 成功:{success} 失败:{fail} "
f"已用时:{total_elapsed:.0f}s 预计剩余:{eta:.0f}s", flush=True)
time.sleep(delay)
total_time = time.time() - t_start
print(f" 日线行情抓取完成,成功:{success} 失败:{fail} 停牌:{nodata_count} 总耗时:{total_time:.1f}s", flush=True)
+120
View File
@@ -0,0 +1,120 @@
"""分红送转数据抓取模块 — 使用巨潮信息网(CNInfo)数据源
通过 AKShare 的 stock_dividend_cninfo 接口按股票代码逐个查询,
返回该股票历史所有分红记录(含送股、转增、派息等)。
"""
import time
from datetime import datetime
import akshare as ak
from src.config import get_fetch_config
from src.db import StockInfo, StockDividend, batch_upsert, get_session
from sqlalchemy import select
def _get_stock_codes() -> list[str]:
session = get_session()
try:
result = session.execute(select(StockInfo.code))
return [row[0] for row in result]
finally:
session.close()
def fetch_dividend(symbol: str | None = None):
"""抓取分红送转数据"""
cfg = get_fetch_config()
delay = cfg.get("delay", 1.0)
retry = cfg.get("retry", 5)
if symbol:
codes = [symbol]
else:
codes = _get_stock_codes()
if not codes:
print(" 无股票列表,请先运行 --stock-list", flush=True)
return
total = len(codes)
success = 0
fail = 0
consecutive_fail = 0
print(f"正在抓取分红送转数据,共 {total} 只股票...", flush=True)
for i, code in enumerate(codes):
df = None
for attempt in range(retry):
try:
df = ak.stock_dividend_cninfo(symbol=code)
consecutive_fail = 0
break
except Exception as e:
wait = 3 * (attempt + 1)
if attempt < retry - 1:
print(f" [{i+1}/{total}] {code}{attempt+1}次重试,等待{wait}秒...", flush=True)
time.sleep(wait)
else:
print(f" [{i+1}/{total}] {code} 抓取失败: {e}", flush=True)
fail += 1
consecutive_fail += 1
if consecutive_fail >= 5:
print(f" 连续{consecutive_fail}只失败,暂停60秒...", flush=True)
time.sleep(60)
consecutive_fail = 0
if df is None or df.empty:
time.sleep(delay)
continue
rows = []
for _, row in df.iterrows():
rows.append({
"code": code,
"name": str(row.get("名称", row.get("name", ""))),
"report_date": str(row.get("报告期", row.get("report_date", ""))),
"dividend_date": _safe_date(row, ["分红年度", "除权除息日", "dividend_date"]),
"bonus_ratio": _safe_float(row, ["送转比例", "每10股送转", "bonus_ratio"]),
"cash_div": _safe_float(row, ["每10股派息", "现金分红", "cash_div"]),
"convert_ratio": _safe_float(row, ["转增比例", "每10股转增", "convert_ratio"]),
"ex_right_date": _safe_date(row, ["除权日", "除权除息日", "ex_right_date"]),
"dividend_yield": _safe_float(row, ["股息率", "dividend_yield"]),
})
try:
batch_upsert(StockDividend, rows, ["code", "report_date"])
success += 1
except Exception as e:
print(f" [{i+1}/{total}] {code} 写入失败: {e}", flush=True)
fail += 1
if (i + 1) % 50 == 0:
print(f" [{i+1}/{total}] 进度... 成功:{success} 失败:{fail}", flush=True)
else:
print(f" [{i+1}/{total}] {code} OK", flush=True)
time.sleep(delay)
print(f" 分红送转抓取完成,成功:{success} 失败:{fail}", flush=True)
def _safe_float(row, keys: list[str]):
for key in keys:
val = row.get(key)
if val is not None:
try:
return float(val)
except (ValueError, TypeError):
return None
return None
def _safe_date(row, keys: list[str]):
"""从 DataFrame 行中按多个候选列名提取日期值"""
for key in keys:
val = row.get(key)
if val is not None and str(val).strip():
return str(val).strip()
return None
+69
View File
@@ -0,0 +1,69 @@
"""龙虎榜数据抓取模块 — 使用东方财富数据源
龙虎榜按日期范围查询,返回该期间内所有上榜股票的买卖详情。
数据量相对较小(每天几十到上百条),通常一次请求即可获取全部数据。
"""
import time
from datetime import datetime, timedelta
import akshare as ak
from src.config import get_fetch_config
from src.db import StockDragonTiger, batch_upsert
def fetch_dragon_tiger(start_date: str | None = None, end_date: str | None = None):
"""抓取龙虎榜数据
start_date/end_date: YYYYMMDD格式
"""
cfg = get_fetch_config()
retry = cfg.get("retry", 3)
if end_date is None:
end_date = datetime.now().strftime("%Y%m%d")
if start_date is None:
start_date = (datetime.now() - timedelta(days=30)).strftime("%Y%m%d")
print(f"正在抓取龙虎榜数据 {start_date} ~ {end_date}...", flush=True)
for attempt in range(retry):
try:
df = ak.stock_lhb_detail_em(start_date=start_date, end_date=end_date)
break
except Exception as e:
if attempt < retry - 1:
print(f" 抓取失败({attempt + 1}/{retry}): {e},等待重试...", flush=True)
time.sleep(2)
else:
raise
if df is None or df.empty:
print(" 无龙虎榜数据", flush=True)
return
rows = []
for _, row in df.iterrows():
rows.append({
"code": str(row.get("代码", row.get("code", ""))),
"name": row.get("名称", row.get("name", "")),
"date": row.get("上榜日", row.get("date", "")),
"close": _safe_float(row, ["收盘价", "close"]),
"pct_change": _safe_float(row, ["涨跌幅", "pct_change"]),
"reason": str(row.get("上榜原因", row.get("reason", ""))),
"buy_amount": _safe_float(row, ["买入额", "buy_amount"]),
"sell_amount": _safe_float(row, ["卖出额", "sell_amount"]),
"net_amount": _safe_float(row, ["净额", "net_amount"]),
})
batch_upsert(StockDragonTiger, rows, ["code", "date"])
print(f" 龙虎榜抓取完成,共 {len(rows)} 条记录", flush=True)
def _safe_float(row, keys: list[str]):
for key in keys:
val = row.get(key)
if val is not None:
try:
return float(val)
except (ValueError, TypeError):
return None
return None
+137
View File
@@ -0,0 +1,137 @@
"""财务报表抓取模块 — 利润表、资产负债表、现金流量表
使用 AKShare 的新浪财务数据接口,每个接口返回一个报告期对应的所有字段。
由于不同股票的字段名可能变化,采用 JSON 格式存储完整数据而非逐字段建列。
防限流策略:
- 指数退避重试(3s、6s、9s...)
- 连续5只股票失败时暂停60秒(可能触发了限流)
"""
import json
import time
from datetime import datetime
import akshare as ak
from src.config import get_fetch_config
from src.db import StockInfo, FinancialIncome, FinancialBalance, FinancialCashflow, batch_upsert, get_session
from sqlalchemy import select
def _get_stock_codes() -> list[str]:
session = get_session()
try:
result = session.execute(select(StockInfo.code))
return [row[0] for row in result]
finally:
session.close()
def fetch_financial(symbol: str | None = None):
"""抓取财务报表数据(利润表、资产负债表、现金流量表)"""
cfg = get_fetch_config()
delay = cfg.get("delay", 1.0)
retry = cfg.get("retry", 5)
if symbol:
codes = [symbol]
else:
codes = _get_stock_codes()
if not codes:
print(" 无股票列表,请先运行 --stock-list", flush=True)
return
total = len(codes)
success = 0
fail = 0
consecutive_fail = 0
print(f"正在抓取财务报表,共 {total} 只股票...", flush=True)
for i, code in enumerate(codes):
income_df = None
balance_df = None
cashflow_df = None
for attempt in range(retry):
try:
income_df = ak.stock_financial_report_sina(stock=code, symbol="利润表")
balance_df = ak.stock_financial_report_sina(stock=code, symbol="资产负债表")
cashflow_df = ak.stock_financial_report_sina(stock=code, symbol="现金流量表")
consecutive_fail = 0
break
except Exception as e:
wait = 3 * (attempt + 1)
if attempt < retry - 1:
print(f" [{i+1}/{total}] {code}{attempt+1}次重试,等待{wait}秒...", flush=True)
time.sleep(wait)
else:
print(f" [{i+1}/{total}] {code} 抓取失败: {e}", flush=True)
fail += 1
consecutive_fail += 1
# 连续多次失败说明可能被限流,暂停一段时间让限制重置
if consecutive_fail >= 5:
print(f" 连续{consecutive_fail}只失败,暂停60秒...", flush=True)
time.sleep(60)
consecutive_fail = 0
try:
if income_df is not None and not income_df.empty:
income_rows = _parse_financial_df(code, income_df)
batch_upsert(FinancialIncome, income_rows, ["code", "report_date"])
if balance_df is not None and not balance_df.empty:
balance_rows = _parse_financial_df(code, balance_df)
batch_upsert(FinancialBalance, balance_rows, ["code", "report_date"])
if cashflow_df is not None and not cashflow_df.empty:
cashflow_rows = _parse_financial_df(code, cashflow_df)
batch_upsert(FinancialCashflow, cashflow_rows, ["code", "report_date"])
success += 1
except Exception as e:
print(f" [{i+1}/{total}] {code} 写入失败: {e}", flush=True)
fail += 1
if (i + 1) % 50 == 0:
print(f" [{i+1}/{total}] 进度... 成功:{success} 失败:{fail}", flush=True)
else:
print(f" [{i+1}/{total}] {code} OK", flush=True)
time.sleep(delay)
print(f" 财务报表抓取完成,成功:{success} 失败:{fail}", flush=True)
def _parse_financial_df(code: str, df) -> list[dict]:
"""将财务报表 DataFrame 转为数据库行,所有字段以 JSON 格式存储
因为不同报表的字段名和数量差异很大,且可能随时间变化,
不适合逐字段建列。JSON 存储保留了原始数据的完整性。
"""
rows = []
report_date_col = None
for col in df.columns:
if "报告期" in col or "报告日" in col or "date" in col.lower():
report_date_col = col
break
if report_date_col is None and len(df.columns) > 0:
report_date_col = df.columns[0]
for _, row in df.iterrows():
report_date = str(row[report_date_col])
data_dict = {col: _safe_val(row[col]) for col in df.columns if col != report_date_col}
rows.append({
"code": code,
"report_date": report_date,
"data": json.dumps(data_dict, ensure_ascii=False),
})
return rows
def _safe_val(val):
"""安全转换值,处理 NaN(float NaN 不等于自身的特性)"""
if val is None or (isinstance(val, float) and val != val):
return None
return val
+137
View File
@@ -0,0 +1,137 @@
"""分时行情抓取模块(1分钟线)— 使用新浪数据源
注意:分时数据量非常大(每只股票每天约240条分钟记录),
目前暂未启用此模块的抓取任务。
"""
import time
from datetime import datetime
import akshare as ak
from src.config import get_fetch_config
from src.db import StockInfo, StockIntraday, batch_upsert, get_session
from sqlalchemy import select, func
def _get_stock_codes() -> list[str]:
session = get_session()
try:
result = session.execute(select(StockInfo.code))
return [row[0] for row in result]
finally:
session.close()
def _get_existing_codes() -> set[str]:
"""查询已有分时数据的股票代码(最近5个交易日有数据的视为已有)
分时数据时效性强,只保留近期数据即可,避免重复抓取。
"""
session = get_session()
try:
result = session.execute(
select(StockIntraday.code)
.group_by(StockIntraday.code)
.having(func.max(StockIntraday.datetime) >= func.date_sub(func.now(), interval=7 * 24 * 3600))
)
return {row[0] for row in result}
except Exception:
return set()
finally:
session.close()
def _code_to_sina(code: str) -> str:
if code.startswith(("6", "9")):
return f"sh{code}"
return f"sz{code}"
def fetch_intraday():
"""抓取1分钟分时行情(新浪接口,返回近5个交易日数据)"""
cfg = get_fetch_config()
delay = cfg.get("delay", 0.2)
retry = cfg.get("retry", 5)
codes = _get_stock_codes()
if not codes:
print(" 无股票列表,请先运行 --stock-list", flush=True)
return
existing = _get_existing_codes()
if existing:
print(f" 已有 {len(existing)} 只股票的分时数据,跳过...", flush=True)
codes = [c for c in codes if c not in existing]
total = len(codes)
if total == 0:
print(" 所有股票分时数据已存在,无需抓取", flush=True)
return
success = 0
fail = 0
consecutive_fail = 0
t_start = time.time()
print(f"正在抓取分时行情(1分钟线),需抓取 {total} 只(跳过 {len(existing)} 只)...", flush=True)
for i, code in enumerate(codes):
sina_code = _code_to_sina(code)
t0 = time.time()
df = None
for attempt in range(retry):
try:
df = ak.stock_zh_a_minute(symbol=sina_code, period="1")
consecutive_fail = 0
break
except Exception as e:
wait = 3 * (attempt + 1)
if attempt < retry - 1:
print(f" [{i+1}/{total}] {code}{attempt+1}次重试,等待{wait}秒...", flush=True)
time.sleep(wait)
else:
print(f" [{i+1}/{total}] {code} 抓取失败: {e}", flush=True)
fail += 1
consecutive_fail += 1
if consecutive_fail >= 5:
print(f" 连续{consecutive_fail}只失败,暂停60秒...", flush=True)
time.sleep(60)
consecutive_fail = 0
if df is None or df.empty:
time.sleep(delay)
continue
records = df.to_dict(orient="records")
rows = []
for rec in records:
rows.append({
"code": code,
"datetime": rec.get("day"),
"open": rec.get("open"),
"high": rec.get("high"),
"low": rec.get("low"),
"close": rec.get("close"),
"volume": rec.get("volume"),
"amount": rec.get("amount"),
})
try:
batch_upsert(StockIntraday, rows, ["code", "datetime"])
success += 1
except Exception as e:
print(f" [{i+1}/{total}] {code} 写入失败: {e}", flush=True)
fail += 1
total_elapsed = time.time() - t_start
avg = total_elapsed / (i + 1)
eta = avg * (total - i - 1)
print(f" [{i+1}/{total}] {code} 成功:{success} 失败:{fail} "
f"已用时:{total_elapsed:.0f}s 预计剩余:{eta:.0f}s", flush=True)
time.sleep(delay)
total_time = time.time() - t_start
print(f" 分时行情抓取完成,成功:{success} 失败:{fail} 总耗时:{total_time:.1f}s", flush=True)
+125
View File
@@ -0,0 +1,125 @@
"""个股资金流向抓取模块 — 使用东方财富数据源
通过 AKShare 的 stock_individual_fund_flow 接口获取每只股票的历史资金流向,
包含主力/超大/大/中/小单的净流入金额和占比。
字段名使用多候选匹配(如 ["收盘价", "close"]),
因为 AKShare 不同版本返回的列名可能为中文或英文。
"""
import time
from datetime import datetime
import akshare as ak
from src.config import get_fetch_config
from src.db import StockInfo, StockMoneyFlow, batch_upsert, get_session
from sqlalchemy import select
def _get_stock_codes() -> list[str]:
session = get_session()
try:
result = session.execute(select(StockInfo.code))
return [row[0] for row in result]
finally:
session.close()
def fetch_money_flow(market: str = "sh"):
"""抓取个股资金流向数据
market: "sh""sz"
"""
cfg = get_fetch_config()
delay = cfg.get("delay", 1.0)
retry = cfg.get("retry", 5)
codes = _get_stock_codes()
if not codes:
print(" 无股票列表,请先运行 --stock-list", flush=True)
return
total = len(codes)
success = 0
fail = 0
consecutive_fail = 0
print(f"正在抓取资金流向数据,共 {total} 只股票...", flush=True)
for i, code in enumerate(codes):
df = None
for attempt in range(retry):
try:
df = ak.stock_individual_fund_flow(stock=code, market=market)
consecutive_fail = 0
break
except Exception as e:
wait = 3 * (attempt + 1)
if attempt < retry - 1:
print(f" [{i+1}/{total}] {code}{attempt+1}次重试,等待{wait}秒...", flush=True)
time.sleep(wait)
else:
print(f" [{i+1}/{total}] {code} 抓取失败: {e}", flush=True)
fail += 1
consecutive_fail += 1
if consecutive_fail >= 5:
print(f" 连续{consecutive_fail}只失败,暂停60秒...", flush=True)
time.sleep(60)
consecutive_fail = 0
if df is None or df.empty:
time.sleep(delay)
continue
rows = []
for _, row in df.iterrows():
date_val = row.get("日期", row.get("date", ""))
try:
rows.append({
"code": code,
"date": date_val,
"close": _safe_float(row, ["收盘价", "close"]),
"pct_change": _safe_float(row, ["涨跌幅", "pct_change"]),
"main_net_inflow": _safe_float(row, ["主力净流入-净额", "main_net_inflow"]),
"main_net_pct": _safe_float(row, ["主力净流入-净占比", "main_net_pct"]),
"huge_net_inflow": _safe_float(row, ["超大盘净流入-净额", "huge_net_inflow"]),
"huge_net_pct": _safe_float(row, ["超大盘净流入-净占比", "huge_net_pct"]),
"big_net_inflow": _safe_float(row, ["大盘净流入-净额", "big_net_inflow"]),
"big_net_pct": _safe_float(row, ["大盘净流入-净占比", "big_net_pct"]),
"mid_net_inflow": _safe_float(row, ["中盘净流入-净额", "mid_net_inflow"]),
"mid_net_pct": _safe_float(row, ["中盘净流入-净占比", "mid_net_pct"]),
"small_net_inflow": _safe_float(row, ["小盘净流入-净额", "small_net_inflow"]),
"small_net_pct": _safe_float(row, ["小盘净流入-净占比", "small_net_pct"]),
})
except Exception:
continue
try:
batch_upsert(StockMoneyFlow, rows, ["code", "date"])
success += 1
except Exception as e:
print(f" [{i+1}/{total}] {code} 写入失败: {e}", flush=True)
fail += 1
if (i + 1) % 50 == 0:
print(f" [{i+1}/{total}] 进度... 成功:{success} 失败:{fail}", flush=True)
else:
print(f" [{i+1}/{total}] {code} OK", flush=True)
time.sleep(delay)
print(f" 资金流向抓取完成,成功:{success} 失败:{fail}", flush=True)
def _safe_float(row, keys: list[str]):
"""从 DataFrame 行中按多个候选列名提取浮点值
AKShare 接口返回的列名可能在中文/英文之间变化,
因此传入多个候选列名依次尝试。
"""
val = row.get(key)
if val is not None:
try:
return float(val)
except (ValueError, TypeError):
return None
return None
+102
View File
@@ -0,0 +1,102 @@
"""股票列表抓取模块 — 分别从沪深北三个交易所获取股票基本信息
AKShare 对三个交易所有独立的接口,返回字段名不一致:
- 沪市: 证券代码、证券简称、上市日期
- 深市: A股代码、A股简称、A股上市日期
- 北交所: 证券代码、证券简称、上市日期
需要分别处理字段映射,最后按 code 去重合并。
"""
import time
import akshare as ak
from src.config import get_fetch_config
from src.db import StockInfo, batch_upsert
def fetch_stock_list():
"""抓取A股股票列表(含上市日期)"""
cfg = get_fetch_config()
retry = cfg.get("retry", 3)
print("正在抓取A股股票列表(含上市日期)...", flush=True)
rows = []
# 沪市
print(" 抓取沪市...", flush=True)
for attempt in range(retry):
try:
df = ak.stock_info_sh_name_code(symbol="主板A股")
for _, r in df.iterrows():
rows.append({
"code": str(r["证券代码"]),
"name": r["证券简称"],
"ipo_date": _safe_date(r.get("上市日期")),
})
print(f" 沪市 {len(df)}", flush=True)
break
except Exception as e:
if attempt < retry - 1:
time.sleep(2)
else:
print(f" 沪市抓取失败: {e}", flush=True)
# 深市
print(" 抓取深市...", flush=True)
for attempt in range(retry):
try:
df = ak.stock_info_sz_name_code()
for _, r in df.iterrows():
rows.append({
"code": str(r["A股代码"]),
"name": r["A股简称"],
"ipo_date": _safe_date(r.get("A股上市日期")),
})
print(f" 深市 {len(df)}", flush=True)
break
except Exception as e:
if attempt < retry - 1:
time.sleep(2)
else:
print(f" 深市抓取失败: {e}", flush=True)
# 北交所
print(" 抓取北交所...", flush=True)
for attempt in range(retry):
try:
df = ak.stock_info_bj_name_code()
for _, r in df.iterrows():
rows.append({
"code": str(r["证券代码"]),
"name": r["证券简称"],
"ipo_date": _safe_date(r.get("上市日期")),
})
print(f" 北交所 {len(df)}", flush=True)
break
except Exception as e:
if attempt < retry - 1:
time.sleep(2)
else:
print(f" 北交所抓取失败: {e}", flush=True)
# 三个交易所可能有重叠代码(理论上不会),以 code 为主键去重
seen = set()
unique = []
for r in rows:
if r["code"] not in seen:
seen.add(r["code"])
unique.append(r)
batch_upsert(StockInfo, unique, ["code"])
print(f" 股票列表抓取完成,共 {len(unique)} 只股票", flush=True)
return unique
def _safe_date(val):
"""安全转换日期值,处理 pandas 的 NaTNot a Time)等特殊值"""
if val is None:
return None
s = str(val).strip()
if not s or s == "None" or s == "NaT":
return None
return s[:10]
+70
View File
@@ -0,0 +1,70 @@
"""交易日历模块 — 提供三级回退策略获取交易日列表
1. 本地 trading_day 表(最快,之前已缓存)
2. AKShare 新浪交易日历接口(从交易所获取官方日历)
3. 从 stock_daily 表已有数据推断(最后手段)
交易日历是日线数据完整性判断的关键依据:
股票在某个日期范围内的记录数必须等于交易日数才算完整。
"""
import time
import akshare as ak
from src.db import TradingDay, batch_upsert, get_session
from sqlalchemy import select, func
def get_trading_days(start_date: str, end_date: str) -> list[str]:
"""获取指定范围内的交易日列表,本地表优先,缺失则从数据源拉取"""
session = get_session()
try:
result = session.execute(
select(TradingDay.date)
.where(TradingDay.date >= start_date)
.where(TradingDay.date <= end_date)
.order_by(TradingDay.date)
)
days = [str(row[0]) for row in result]
if days:
return days
finally:
session.close()
# 本地无数据,从 AKShare 拉取交易所交易日历
print(f" 正在从交易所获取交易日历 {start_date} ~ {end_date}...", flush=True)
try:
df = ak.tool_trade_date_hist_sina()
# AKShare 返回的 trade_date 列是 datetime.date 对象,不能直接和字符串比较
# 必须先转为字符串再做范围过滤
sd = f"{start_date[:4]}-{start_date[4:6]}-{start_date[6:8]}"
ed = f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}"
df["date_str"] = df["trade_date"].astype(str)
mask = (df["date_str"] >= sd) & (df["date_str"] <= ed)
filtered = df.loc[mask]
rows = [{"date": row["date_str"]} for _, row in filtered.iterrows()]
if rows:
batch_upsert(TradingDay, rows, ["date"])
print(f" 交易日历已保存,{len(rows)} 个交易日", flush=True)
return [r["date"] for r in rows]
except Exception as e:
print(f" 获取交易日历失败: {e},将从已有行情数据推断", flush=True)
# 回退:从 stock_daily 推断
return _infer_from_daily(start_date, end_date)
def _infer_from_daily(start_date: str, end_date: str) -> list[str]:
"""从 stock_daily 表推断交易日"""
from src.db import StockDaily
session = get_session()
try:
result = session.execute(
select(func.distinct(StockDaily.date))
.where(StockDaily.date >= start_date)
.where(StockDaily.date <= end_date)
.order_by(StockDaily.date)
)
return [str(row[0]) for row in result]
finally:
session.close()
+75
View File
@@ -0,0 +1,75 @@
"""A股数据抓取工具主入口
用法示例:
python -m src.main --stock-list # 先抓取股票列表
python -m src.main --daily --start-date 20260501 --end-date 20260508
python -m src.main --all # 全量抓取
python -m src.main --financial --symbol 000001 # 单只股票财务数据
"""
import argparse
from datetime import datetime
from src.config import load_config
from src.db import init_db
def main():
parser = argparse.ArgumentParser(description="A股数据抓取工具")
parser.add_argument("--stock-list", action="store_true", help="抓取股票列表")
parser.add_argument("--daily", action="store_true", help="抓取日线行情")
parser.add_argument("--financial", action="store_true", help="抓取财务报表")
parser.add_argument("--money-flow", action="store_true", help="抓取资金流向")
parser.add_argument("--dragon-tiger", action="store_true", help="抓取龙虎榜")
parser.add_argument("--dividend", action="store_true", help="抓取分红送转")
parser.add_argument("--intraday", action="store_true", help="抓取分时行情(1分钟线)")
parser.add_argument("--all", action="store_true", help="全量抓取所有数据")
parser.add_argument("--start-date", type=str, help="开始日期 YYYYMMDD")
parser.add_argument("--end-date", type=str, help="结束日期 YYYYMMDD")
parser.add_argument("--symbol", type=str, help="指定单只股票代码")
args = parser.parse_args()
if not any([args.stock_list, args.daily, args.financial, args.money_flow,
args.dragon_tiger, args.dividend, args.intraday, args.all]):
parser.print_help()
return
# 加载配置并初始化数据库(自动建表)
load_config()
init_db()
# 按需延迟导入各抓取模块,避免加载全部依赖
if args.all or args.stock_list:
from src.fetchers.stock_list import fetch_stock_list
fetch_stock_list()
if args.all or args.daily:
from src.fetchers.daily import fetch_daily
fetch_daily(start_date=args.start_date, end_date=args.end_date)
if args.all or args.financial:
from src.fetchers.financial import fetch_financial
fetch_financial(symbol=args.symbol)
if args.all or args.money_flow:
from src.fetchers.money_flow import fetch_money_flow
fetch_money_flow()
if args.all or args.dragon_tiger:
from src.fetchers.dragon_tiger import fetch_dragon_tiger
fetch_dragon_tiger(start_date=args.start_date, end_date=args.end_date)
if args.all or args.dividend:
from src.fetchers.dividend import fetch_dividend
fetch_dividend(symbol=args.symbol)
if args.all or args.intraday:
from src.fetchers.intraday import fetch_intraday
fetch_intraday()
print("全部任务完成", flush=True)
if __name__ == "__main__":
main()