diff --git a/.gitignore b/.gitignore index 3996e81..71115b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# 项目配置(含密码等敏感信息) +config.yaml + # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ @@ -174,3 +177,4 @@ cython_debug/ # PyPI configuration file .pypirc +.idea/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ce1d3ea --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/ashare-data.iml b/.idea/ashare-data.iml new file mode 100644 index 0000000..ed39dea --- /dev/null +++ b/.idea/ashare-data.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/claudeCodeTabState.xml b/.idea/claudeCodeTabState.xml new file mode 100644 index 0000000..5ca02c5 --- /dev/null +++ b/.idea/claudeCodeTabState.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..449e696 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e5a82cf --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 33a33af..3c666bf 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,263 @@ # ashare-data -保存A股数据 \ No newline at end of file +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模块按需导入,未使用的模块不会加载 diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..ff7c092 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,13 @@ +mysql: + host: "localhost" + port: 3306 + user: "root" + password: "your_password" + database: "ashare" + charset: "utf8mb4" + +fetch: + # 请求间隔(秒),避免被限流 + delay: 0.5 + # 失败重试次数 + retry: 3 diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..0b8c9c5 --- /dev/null +++ b/config.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ff20c9d --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aa4aee9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +akshare +pymysql +sqlalchemy>=2.0 +pyyaml +pandas diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..119cc5d --- /dev/null +++ b/src/config.py @@ -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}) diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..d178b3d --- /dev/null +++ b/src/db.py @@ -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批量upsert:INSERT 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() diff --git a/src/fetchers/__init__.py b/src/fetchers/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/src/fetchers/daily.py b/src/fetchers/daily.py new file mode 100644 index 0000000..b845b34 --- /dev/null +++ b/src/fetchers/daily.py @@ -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) diff --git a/src/fetchers/dividend.py b/src/fetchers/dividend.py new file mode 100644 index 0000000..7b663d3 --- /dev/null +++ b/src/fetchers/dividend.py @@ -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 diff --git a/src/fetchers/dragon_tiger.py b/src/fetchers/dragon_tiger.py new file mode 100644 index 0000000..cc353c9 --- /dev/null +++ b/src/fetchers/dragon_tiger.py @@ -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 diff --git a/src/fetchers/financial.py b/src/fetchers/financial.py new file mode 100644 index 0000000..3875cdf --- /dev/null +++ b/src/fetchers/financial.py @@ -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 diff --git a/src/fetchers/intraday.py b/src/fetchers/intraday.py new file mode 100644 index 0000000..ec410fd --- /dev/null +++ b/src/fetchers/intraday.py @@ -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) diff --git a/src/fetchers/money_flow.py b/src/fetchers/money_flow.py new file mode 100644 index 0000000..5af289c --- /dev/null +++ b/src/fetchers/money_flow.py @@ -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 diff --git a/src/fetchers/stock_list.py b/src/fetchers/stock_list.py new file mode 100644 index 0000000..2338316 --- /dev/null +++ b/src/fetchers/stock_list.py @@ -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 的 NaT(Not 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] diff --git a/src/fetchers/trading_day.py b/src/fetchers/trading_day.py new file mode 100644 index 0000000..7f0d253 --- /dev/null +++ b/src/fetchers/trading_day.py @@ -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() diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..d6183d6 --- /dev/null +++ b/src/main.py @@ -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()