一、现状审视:我们已经拥有什么
在进入架构设计之前,先梳理项目的现有资产。FinDataHub 已经构建了相当完善的基础设施,理解现状是设计未来的前提。
1.1 数据采集基础设施
DONE TuShare Fetcher
完整的 Python 模块化采集器,包含 API 封装、连接池管理、幂等 Upsert、数据快照、同步报告、校验器、Rust 加速核心。
DONE AKShare Fetcher
对标 tushare_fetcher 架构的独立采集器,覆盖 A 股商品、资金流向、财务指标、ETF 数据等。
DONE MySQL 存储
统一的 MySQL 数据库 + InnoDB 引擎 + UTF8MB4 编码,含 _table_meta 元数据表和完整 Schema。
DONE Launchd 定时任务
macOS launchd 集成,支持日/周/月三级同步频率,覆盖 TuShare 和 AKShare 两个数据源。
1.2 现有校验能力
两个 fetcher 各自内建了 DataValidator,校验维度包括:
| 校验维度 | TuShare Validator | AKShare Validator |
|---|---|---|
| 行数校验 | ✅ 表行数 vs 元数据记录 | ✅ 基础行数检查 |
| 日期连续性 | ✅ 交易日历驱动(13 张表) | ✅ 基础交易日判断(5 张表) |
| 代码覆盖率 | ✅ 期望股票 vs 实际股票(10 张表) | ✅ 基本覆盖率检查(4 张表) |
| 空表检测 | ✅ | ✅ |
| 数据快照对比 | ✅ DataSnapshot 行数趋势 | ❌ 未实现 |
| 同步报告 | ✅ JSON 结构化报告 | ❌ 简化为控制台输出 |
1.3 现有数据预处理 DONE
findatahub_core 统一管理。支持 3 种策略画像(high_freq / trend / ml_model)按需切换清洗参数。
1.4 现有数据调用方式 DONE
数据使用层已实现四层递进接入架构:
- Python SDK:
findatahub-client提供 11 个方法(daily/weekly/monthly/fina_indicator/search_stock/export 等) - RESTful API:FastAPI 服务,14 个端点(数据查询 + 质量分 + 健康监控)
- Skills 层:
skill.md结构化描述 5 表 schema,AI 智能体可自主查询 - MCP 服务:7 个 MCP 工具,支持自然语言直达数据(stdio 协议)
二、问题定义:三个核心问题
将所有需求归结起来,FinDataHub 要解决的不过是三个问题:
怎么拉取数据?
从 TuShare、AKShare 等多个数据源安全、高效、幂等地采集金融数据,并确保数据源的准确性和权威性。
数据如何落地存入 MySQL?
数据经过清洗、校验、标准化后,以结构化形态存入 MySQL,确保数据干净、有效、正确,为上层应用提供可靠的数据基础。
怎么方便地使用数据?
构建多层次数据访问框架(SDK → REST API → Skills → MCP),让不同技术水平的用户都能以最适合的方式消费数据。
TuShare 在其 AI 适配指南[1]中提出了三层接入架构(SDK 层 → Skills 层 → MCP 层),覆盖了从"AI 辅助编程取数"到"AI 自主取数"的完整演进路径。这正是 FinDataHub 数据调用框架的核心参考模型。
三、整体架构:三层解耦设计
基于以上分析,FinDataHub 的数据管道采用三层解耦架构,每一层有明确的职责边界和接口契约:
flowchart TB
subgraph L1["🔵 第一层:数据采集层 Data Collection"]
direction LR
TS[TuShare API]
AK[AKShare API]
DB1[(MySQL)]
VAL[DataValidator]
REP[SyncReport]
TS -->|pro API| F1[DataFetcher]
AK -->|free API| F2[DataFetcher]
F1 --> DB1
F2 --> DB1
DB1 --> VAL
VAL --> REP
end
subgraph L2["🟣 第二层:数据预处理层 Data Preprocessing"]
direction LR
CLEAN[数据清洗]
OUT[异常值检测]
STAT[统计量分析]
CORR[相关性分析]
PCA[主成分分析]
NORM[归一化]
DB2[(MySQL)]
CLEAN --> OUT --> STAT --> CORR --> PCA --> NORM
NORM --> DB2
end
subgraph L3["🟢 第三层:数据使用层 Data Access"]
direction LR
SDK[Python SDK]
REST[RESTful HTTP API]
SKILL[Skills 层]
MCP[MCP 服务]
end
L1 -->|raw data| L2
L2 -->|clean data| L3
style L1 fill:#eff6ff,stroke:#2563eb,stroke-width:2px
style L2 fill:#f5f3ff,stroke:#7c3aed,stroke-width:2px
style L3 fill:#f0fdf4,stroke:#059669,stroke-width:2px
3.1 层间接口契约
每一层通过明确定义的接口与相邻层通信:
| 层间边界 | 接口方式 | 数据形态 |
|---|---|---|
| L1 → L2 | MySQL 表读取(raw schema) | 原始数据(含噪声、缺失、异常) |
| L2 → L3 | MySQL 视图 / 物化表(clean schema) | 清洗后数据(标准化、去噪、完整) |
| L3 → 外部 | SDK / REST / MCP | 按需返回的数据集 |
3.2 Medallion 架构:三数据库分层
上述三层在物理上映射为三个 MySQL 数据库,形成业界标准的 Medallion 架构(Bronze → Silver → Gold):
| 层级 | 数据库 | 数据源 | 表数 | 谁来写 | 谁来读 | 数据状态 |
|---|---|---|---|---|---|---|
| 🥉 Bronze | tushare_data | tushare_fetcher | 80+ | DataFetcher | Preprocessing Pipeline | 只追加,永不变 |
| 🥉 Bronze | akshare_data | akshare_fetcher | 9 | DataFetcher | Preprocessing Pipeline | 只追加,永不变 |
| 🥈 Silver | fin_data_clean | 预处理 Pipeline | 按需 | PreprocessingPipeline | SDK / REST API / MCP | 清洗后,可重建 |
四、第一层:数据采集层
4.1 数据源
FinDataHub 采用双数据源互补策略:
| 数据源 | 类型 | 覆盖 | 特点 | 引用 |
|---|---|---|---|---|
| TuShare Pro | API 接口 | A股行情、财务、指数、基金、期货、债券、宏观 | 标准化程度高,自有数据治理体系,需注册 Token,部分接口需积分 | [2] |
| AKShare | 开源抓取库 | A股、港股、美股、期货、期权、宏观、另类数据 | MIT 开源(v1.18.77),接口丰富,覆盖更广,基于东方财富/新浪等多源抓取,稳定性不如 API | [3] |
TuShare Pro 作为主数据源(标准化程度高,"几乎零清洗"),AKShare 作为互补数据源(覆盖更广的另类和宏观数据)和交叉验证源(双源对比校验)。这一策略在知乎的量化数据获取指南中得到了印证[4],该文也推荐组合使用多数据源(yfinance + TuShare + AKShare)。
4.2 现状优势
当前的数据采集层设计已经相当成熟,核心组件包括:
| 组件 | 文件 | 职责 |
|---|---|---|
| API 层 | api.py | TuShare pro API 封装,含重试、限速 |
| 数据库层 | db.py | MySQL 连接池 + 幂等 Upsert + SQL 注入防护 |
| 缓存层 | cache.py | 交易日历缓存,避免重复查询 |
| 抓取器 | fetcher.py | 三模式分发(增量/全量/指定日期)+ 表路由 |
| 校验器 | validator.py | 行数/日期连续性/代码覆盖率 |
| 报告器 | report.py | JSON 结构化同步报告 |
| 快照器 | snapshot.py | 行数趋势监控,异常下降检测 |
| 元数据 | tables_registry.json | 表注册表:唯一键、日期列、类别、权限 |
4.3 增量更新策略
当前 fetcher.py 已实现基于日期列的智能增量更新:
sequenceDiagram
participant S as Scheduler
participant F as DataFetcher
participant C as SyncCache
participant A as TuShare API
participant D as MySQL
S->>F: 触发同步
F->>C: 查询上次同步日期
C-->>F: last_date = 2026-08-05
F->>F: 计算增量起始 = last_date + 1d
F->>A: 请求数据 (start=20260806)
A-->>F: DataFrame
F->>D: UPSERT (幂等写入)
F->>C: 更新 last_date
F->>D: 更新 _table_meta
4.4 数据采集层的质量保障
参考 TuShare 的数据质量理念[1],数据采集层的核心保障包括:
- 非爬取数据源:TuShare 和 AKShare 都是经过社区采集、整理、入库和质量控制后的标准化数据,非原始爬虫数据
- 字段规范化:所有接口遵循统一的字段命名规则(
ts_code、trade_date等)和数据类型定义 - 幂等写入:基于唯一键的
INSERT ... ON DUPLICATE KEY UPDATE,重复同步不会产生脏数据 - SQL 注入防护:
safe_identifier()白名单校验所有动态表名/列名 - 同步后校验:每次同步后自动执行完整性校验并生成报告
4.5 需要增强的部分 DONE
| 增强项 | 当前状态 | 结果 |
|---|---|---|
| 数据源交叉验证 | DONE | cross_validator.py — OHLCV 8 字段双源对比,差异写入 _data_issues。匹配率 99.9%(历史)、96.1%(最新日)。 |
| 采集失败自动重试 | DONE | retry.py — 指数退避(1s→2s→4s→8s→16s)+ 熔断 + _retry_queue 失败队列 |
| 采集日志结构化 | DONE | 所有模块统一使用 structlog 结构化日志 |
| 数据源健康监控 | DONE | health.py — 自动记录 API 耗时/成功率/限流,_api_health_metrics 表 + REST API 端点 |
| AKShare 数据源修复 | DONE | 修复成交额异常(637 万行)、科创板 vol 单位、涨跌幅重算、pre_close 时效(25 万行)、北交所新浪源重拉(333 只) |
五、第二层:数据预处理层
5.1 预处理 Pipeline 设计
flowchart LR
RAW[(Raw Tables)] --> STEP1
subgraph PP["数据预处理 Pipeline"]
STEP1[🔍 数据清洗
类型修正/去重/格式统一]
STEP2[📏 缺失值处理
插值/填充/标记]
STEP3[⚠️ 异常值检测
3σ/IQR/MAD]
STEP4[📊 统计量分析
mean/std/skew/kurtosis]
STEP5[🔗 相关性分析
Pearson/Spearman矩阵]
STEP6[📐 主成分分析
PCA降维]
STEP7[📏 归一化
MinMax/Z-score/Robust]
end
STEP1 --> STEP2 --> STEP3 --> STEP4 --> STEP5 --> STEP6 --> STEP7
STEP4 -.->|统计报告| REPORT[📋 Stats Report]
STEP5 -.->|相关性矩阵| CORR_RPT[📋 Corr Matrix]
STEP7 --> CLEAN[(Clean Tables)]
style PP fill:#f5f3ff,stroke:#7c3aed,stroke-width:2px
5.2 各步骤详细设计
步骤 1:数据清洗
| 操作 | 说明 | 示例 |
|---|---|---|
| 类型修正 | 将字符串日期转为 DATE,数字列转为 DOUBLE/DECIMAL | '20260807' → DATE |
| 去重 | 基于唯一键去除完全重复行 | DROP DUPLICATE ROWS |
| 列名标准化 | 统一为英文小写蛇形命名(snake_case) | '成交额' → 'amount' |
| 空值标记 | 将空字符串 ''、'--'、'N/A' 统一为 SQL NULL | '' → NULL |
| 单位统一 | 将不同单位的值统一(万元→元,手→股) | amount * 10000 |
步骤 2:缺失值处理
| 策略 | 适用场景 | 实现方式 |
|---|---|---|
| 前向填充 (ffill) | 行情数据(停牌日) | 用最近一个有效值填充 |
| 线性插值 | 连续型指标 | 时间序列线性插值 |
| 均值/中位数填充 | 截面数据 | 同行业/同市值分组内填充 |
| 标记列 | 所有缺失处理 | 增加 _is_imputed 标记列 |
| 删除 | 缺失率 > 50% | 标记该字段不可靠 |
_is_imputed 列中标记为 TRUE,确保下游使用者能区分原始数据和填充数据。这是金融数据可靠性的底线。
步骤 3:异常值检测
金融数据的异常值有三种典型来源,需要区别处理:
| 异常类型 | 检测方法 | 处理策略 | 示例 |
|---|---|---|---|
| 录入错误 | 绝对值阈值(如价格 < 0) | 标记为错误,回源修正 | 收盘价 = -100 |
| 统计异常 | 3σ / IQR / MAD | 标记但不删除,人工复核 | 单日涨幅 50% |
| 真实事件 | 关联新闻公告事件日期 | 保留原始值,追加事件标签 | 重大资产重组后的数据跳跃 |
步骤 4:统计量分析
对每张核心表自动生成统计报告,包含:
- 基本统计量:count, mean, std, min, 25%, 50%, 75%, max
- 分布特征:skewness(偏度), kurtosis(峰度)
- 缺失率:每列的缺失比例
- 唯一值数量:判断是否为类别变量
步骤 5:相关性分析
生成 Pearson 和 Spearman 相关性矩阵,用于:
- 识别高度相关的特征对(冗余特征检测)
- 发现潜在的因子共线性问题
- 为后续降维和特征选择提供依据
步骤 6-7:PCA + 归一化
这两个步骤是可选模块,仅在用户需要进行机器学习建模时激活:
- PCA:对高维财务指标进行降维,保留 95% 方差的主成分
- 归一化:支持 MinMaxScaler(值域压缩到 [0,1])、StandardScaler(Z-score 标准化)、RobustScaler(基于中位数和 IQR,抗异常值)
5.3 预处理层模块结构
Services/preprocessing/
├── config/
│ └── pipeline.yaml # Pipeline 配置(启用/禁用各步骤)
├── src/preprocessing/
│ ├── __init__.py
│ ├── pipeline.py # PreprocessingPipeline 主控器
│ ├── cleaner.py # 步骤1:数据清洗
│ ├── imputer.py # 步骤2:缺失值处理
│ ├── outlier.py # 步骤3:异常值检测
│ ├── stats.py # 步骤4:统计量分析
│ ├── correlation.py # 步骤5:相关性分析
│ ├── pca.py # 步骤6:主成分分析
│ ├── normalizer.py # 步骤7:归一化
│ └── report.py # 预处理报告生成器
└── pyproject.toml
5.4 Pipeline 配置示例
# pipeline.yaml
pipeline:
tables:
- stock_daily
- stock_weekly
- stock_monthly
steps:
cleaning:
enabled: true
drop_duplicates: true
standardize_columns: true
imputation:
enabled: true
strategy: ffill # ffill / linear / mean / median
add_flag_column: true
outlier_detection:
enabled: true
method: iqr # iqr / zscore / mad
threshold: 3.0
statistics:
enabled: true
output: reports/stats_report.json
correlation:
enabled: true
method: pearson # pearson / spearman
output: reports/corr_matrix.json
pca:
enabled: false # 按需启用
n_components: 0.95
normalization:
enabled: false # 按需启用
method: zscore # minmax / zscore / robust
output:
target_schema: fin_data_clean # 清洗后数据的数据库名
create_views: true # 同时创建 SQL VIEW
5.5 质量门控:如何判断数据合格
答案是一个四级门控链(Quality Gate Chain)。每一级有明确的阻断策略——不是所有问题都会阻止数据入库,但所有问题都会被记录。
flowchart TB
RAW[(Raw Tables)] --> G1
subgraph Gates["四级质量门控"]
G1["🚦 Gate 1: 采集门控
非空检查 · 行数阈值 · API返回码"]
G2["🚦 Gate 2: 清洗门控
OHLC关系 · 列名标准 · 日期格式"]
G3["🚦 Gate 3: 入库门控
唯一键 · 日期连续性 · Schema验证"]
G4["🚦 Gate 4: 后验门控
跨源交叉验证 · 质量评分"]
end
G1 -->|PASS| G2
G2 -->|PASS| G3
G3 -->|PASS| CLEAN[(fin_data_clean)]
CLEAN --> G4
G1 -.->|FAIL → 重试3次| ALERT[告警 + 人工介入]
G2 -.->|WARN → 标记| ISSUES[写入 _data_issues 表]
G3 -.->|FAIL → 退回| RETRY[重试队列]
G4 -.->|INFO → 记录| METRICS[写入 _quality_metrics]
style Gates fill:#fef3c7,stroke:#d97706,stroke-width:2px
每一级的判定标准
| Gate | 检查项 | 判定逻辑 | 阻断策略 |
|---|---|---|---|
| G1 | DataFrame 非空 | df is not None and len(df) > 0 | BLOCK — 阻断写入 |
| 行数 > 最小阈值 | 股票数 ≥ 预期数量的 50% | BLOCK — 可能是 API 限速 | |
| API 返回码正常 | 无 error_code 或超时异常 | BLOCK — 重试 3次后告警 | |
| G2 | OHLC 基本关系 | high ≥ max(open, close) AND low ≤ min(open, close) | WARN — 标记异常行 |
| 价格非负 | open > 0 AND close > 0 | BLOCK — 回源修正 | |
| 涨跌幅合理性 | 非ST:-10% ≤ pct_chg ≤ 10%;科创/创业:-20% ≤ pct_chg ≤ 20% | WARN — 可能是真实涨停 | |
| 成交量非负 | vol ≥ 0(停牌可为0) | WARN | |
| G3 | 唯一键无冲突 | (ts_code, trade_date) 无重复 | BLOCK — 去重后写入 |
| 日期连续性 | 相邻交易日间隔 ≤ 7 天 | WARN — 记录缺失区间 | |
| 质量总分 ≥ 0.6 | 综合评分(见下文) | BLOCK — 低于阈值退回重试 | |
| G4 | 双源交叉验证 | TuShare vs AKShare 同日同股差异 < 2% | INFO — 记录差异报告 |
| 质量趋势 | 连续 3 批次质量下降 > 10% | WARN — 可能是数据源问题 |
数据质量评分公式
每张表在通过 G3 之前,会被赋予一个0-1 的量化质量分数,综合六个维度:
| 维度 | 权重 | 计算方法 | 含义 |
|---|---|---|---|
完整性completeness | 20% | 关键字段(OHLCV)非空行数 / 总行数 | 数据填得全不全 |
准确性accuracy | 25% | (一致性分 + 有效性分) / 2 | 数据反映真实世界 |
时效性timeliness | 15% | 1 - min(延迟天数/14, 1),14天内满分 | 数据多新鲜 |
一致性consistency | 15% | 跨源验证一致率 = 1 - 差异记录数/总数 | 两个源说得一样不一样 |
唯一性uniqueness | 10% | 1 - 重复(ts_code, trade_date)数/总行数 | 有没有重复记录 |
有效性validity | 15% | 通过 OHLC 关系校验的行数/总行数 | 数据符合业务规则 |
5.6 双源交叉验证
这是之前报告中标记为 TODO 的高优先级项,也是 L1→L2 之间最重要的质量防线。
验证策略
| 策略 | 说明 |
|---|---|
| 对齐键 | TuShare 000001.SZ ↔ AKShare 000001,通过代码映射表统一 |
| 对比字段 | open, high, low, close, vol(OHLCV 五要素) |
| 容差 | 价格字段 0.5%(如果差异 < 0.01元则忽略),成交量 2% |
| 覆盖范围 | 最近 5 个交易日全量对比,历史数据抽样对比 |
| 差异分类 | 数据源差异 / 复权差异 / 时间戳差异 / 未知差异 |
| 优先级 | TuShare 为准(标准化程度更高),AKShare 为交叉验证 |
验证结果处理
- 差异 < 0.5%:两源一致,直接入库,标记
source='merged' - 差异 0.5%-2%:记录到
_cross_validation_log表,以 TuShare 为准入库 - 差异 > 2%:标记
is_validated=FALSE,写入_data_issues表等待人工复核
5.7 入库决策流程图
flowchart TD
START([采集完成]) --> G1_CHECK{G1: 采集门控}
G1_CHECK -->|FAIL| RETRY[重试机制
最多3次
指数退避]
RETRY -->|仍失败| ALERT[🚨 告警通知
人工介入]
RETRY -->|成功| G1_CHECK
G1_CHECK -->|PASS| G2_CHECK{G2: 清洗门控}
G2_CHECK -->|OHLC异常| TAG_WARN[标记 _is_flagged
写入 _data_issues]
G2_CHECK -->|价格负值| TAG_BLOCK[标记 _is_error
该行不入 clean 库]
G2_CHECK -->|格式错误| FIX[自动修正
日期/列名/类型]
G2_CHECK -->|通过| G3_CHECK{G3: 入库门控}
TAG_WARN --> G3_CHECK
FIX --> G3_CHECK
G3_CHECK -->|唯一键冲突| DEDUP[去重
保留最新]
G3_CHECK -->|质量分 ≥ 0.6| WRITE[✅ 写入 fin_data_clean
标记 is_validated=TRUE]
G3_CHECK -->|质量分 < 0.6| REJECT[❌ 退回收容表
_rejected_records]
DEDUP --> G3_CHECK
WRITE --> G4_CHECK{G4: 后验门控}
G4_CHECK --> CROSS[双源交叉验证]
CROSS --> SCORE[计算质量分]
SCORE --> TREND[更新质量趋势]
TREND --> DONE([完成])
5.8 清洗策略画像(Strategy Profile)
为什么停牌填充在不同策略下效果不同
| 日期 | 实际收盘价 | 停牌? | ffill 后 | drop 后 |
|---|---|---|---|---|
| 周一 | 10.00 | 否 | 10.00 | 10.00 |
| 周二 | 10.50 | 否 | 10.50 | 10.50 |
| 周三 | -- | 停牌 | 10.50(假数据) | 不存在 |
| 周四 | -- | 停牌 | 10.50(假数据) | 不存在 |
| 周五 | 10.20 | 否 | 10.20 | 10.20 |
ffill 后周三→周五的收益率 = (10.20 - 10.50) / 10.50 = -2.86%。但实际上这三天股票根本没有交易,这个收益率是计算幻觉。对于持仓 1-3 天的高频策略,基于假数据做出的交易决策可能导致真实亏损。而对于持仓数月的趋势策略,3 天假数据在 200 天序列中影响微乎其微。
策略画像定义
# pipeline_profiles.yaml — 不同策略画像使用不同清洗参数
profiles:
high_freq: # 高频/短线策略
imputation:
strategy: drop # 不填充,删除停牌日
outlier:
method: mad # MAD 更鲁棒,保留极端波动
threshold: 4.0 # 更宽松的阈值
normalization:
enabled: false # 不做归一化,保留原始价格
trend: # 趋势/中长线策略
imputation:
strategy: ffill # 前向填充,保证序列对齐
add_flag_column: true
outlier:
method: iqr
threshold: 2.5
normalization:
enabled: false
ml_model: # 机器学习建模
imputation:
strategy: linear # 线性插值,更平滑
outlier:
method: mad
threshold: 3.0
pca:
enabled: true # 启用 PCA 降维
n_components: 0.95
normalization:
enabled: true # 必须归一化
method: robust # RobustScaler,抗异常值
调用时指定画像即可:
# 不同策略拿到不同清洗程度的同一份数据
df_hf = pipeline.run("stock_daily", profile="high_freq")
df_tr = pipeline.run("stock_daily", profile="trend")
df_ml = pipeline.run("stock_daily", profile="ml_model")
stock_daily_hf、stock_daily_trend)。
5.9 pct_change 收益率的归属
知乎文章中把 pct_change 放在清洗阶段,但不建议放进 L2 预处理层。原因:
| 放在 L2(预处理层) | 放在 L3(使用层 / SDK)✅ 推荐 |
|---|---|
| 收益率是派生数据,不是清洗动作 | 回测时按需计算,不占存储 |
| 如果改了填充策略,收益率要重新计算——但 clean 库里已经存了一份旧的了 | 原始 OHLCV 不变,pct_change 只是 SQL 视图或 SDK 计算 |
fin_data_clean 的职责是"干净的基础数据" | SDK 加一行 df['return'] = df['close'].pct_change() 成本为零 |
pct_change 本质上是查询视图,不是数据转换。它不属于 Bronze → Silver 的精炼过程,而是 Silver → Gold 的消费行为。放在 SDK 层最合适:
# findatahub-client SDK 内置
client.daily("000001.SZ", start="20260101", with_returns=True)
# 返回的 DataFrame 自动包含 pct_chg, return_1d, return_5d 等衍生列
六、第三层:数据使用层
参考 TuShare 的 AI 适配三层架构[1],FinDataHub 的数据使用层同样设计了三层递进的接入方式:
flowchart TB
subgraph Users["用户群体"]
DEV[Python 开发者
量化工程师]
AI_DEV[AI Coding 用户
Cursor/Claude用户]
AI_NATIVE[AI 原生用户
零代码用户]
end
subgraph L3["🟢 第三层:数据使用层"]
SDK[Python SDK
findatahub-client]
API[RESTful HTTP API
FastAPI]
SKILL[Skills 层
findatahub-skill]
MCP[MCP 服务
Model Context Protocol]
end
DB[(Clean MySQL)]
DB --> SDK
DB --> API
API --> SKILL
API --> MCP
DEV -->|pip install| SDK
DEV -->|HTTP requests| API
AI_DEV -->|AI 辅助调用| SKILL
AI_NATIVE -->|自然语言| MCP
style L3 fill:#f0fdf4,stroke:#059669,stroke-width:2px
6.1 SDK 层:Python SDK + RESTful HTTP
Python SDK 设计
# pip install findatahub-client
from findatahub import Client
client = Client(
host="localhost",
port=3306,
user="findata",
password="***",
database="fin_data_clean"
)
# 获取日线数据
df = client.daily(
ts_code="000001.SZ",
start_date="20260101",
end_date="20260808",
fields=["open", "high", "low", "close", "vol", "amount"]
)
# 获取财务指标
df = client.fina_indicator(
ts_code="000001.SZ",
start_date="20250101",
fields=["roe", "roa", "eps", "bps"]
)
# 高级查询:按行业获取估值数据
df = client.query("""
SELECT b.ts_code, b.name, f.pe, f.pb, f.roe
FROM stock_basic b
JOIN fina_indicator f ON b.ts_code = f.ts_code
WHERE b.industry = '银行' AND f.end_date = '20260630'
""")
RESTful HTTP API 设计
# 启动 API 服务
findatahub serve --port 8000
# --- Endpoints ---
GET /api/v1/health # 健康检查
GET /api/v1/tables # 列出可用数据表
GET /api/v1/{table}/schema # 获取表结构
GET /api/v1/{table}/data # 查询数据
?ts_code=000001.SZ
&start_date=20260101
&end_date=20260808
&fields=open,high,low,close
&limit=1000
POST /api/v1/query # 高级 SQL 查询(参数化)
6.2 Skills 层:AI 智能体"读懂"数据
参考 TuShare 的 tushare-data Skill 设计[1]:
- 接口文档结构化:将所有数据表的字段说明、参数定义、调用示例转化为 AI 可理解的结构化描述
- 调用逻辑内置:AI 无需用户告知具体表名和字段名,只需理解自然语言意图即可自主匹配
- 错误处理指引:包含常见错误场景和处理策略,降低 AI 调用失败率
实际效果:用户说"帮我提取平安银行过去 5 年的 ROE 和归母净利润",AI 智能体可自主完成:识别对应表 (fina_indicator, income) → 匹配字段 (roe, n_income_attr_p) → 构造查询参数 → 执行查询 → 格式化输出。
6.3 MCP 层:AI 原生数据通道
MCP 服务的核心能力:
| 能力 | 实现 |
|---|---|
| 自然语言直达数据 | 用户对话即可获取金融数据,无需了解表名、字段名 |
| 上下文感知 | AI 根据对话上下文自动判断查询标的、时间范围、指标类型 |
| Token 优化 | 内置数据压缩和字段筛选机制,避免大量原始数据消耗 AI 上下文窗口 |
| 多平台兼容 | 支持 Cursor、Claude Code、Cline、Trae、OpenClaw 等主流 AI 开发工具 |
FinDataHub MCP 服务的 Tool 定义示例:
// MCP Tools
{
"tools": [
{
"name": "get_daily_data",
"description": "获取A股日线行情数据。支持按股票代码、日期范围查询。",
"parameters": {
"ts_code": "股票代码,如 000001.SZ",
"start_date": "起始日期 YYYYMMDD",
"end_date": "结束日期 YYYYMMDD",
"fields": ["open", "high", "low", "close", "vol", "amount"]
}
},
{
"name": "get_financial_indicator",
"description": "获取财务指标数据,包括ROE、ROA、EPS、PE、PB等。",
"parameters": {
"ts_code": "股票代码",
"start_date": "起始报告期 YYYYMMDD",
"end_date": "结束报告期 YYYYMMDD",
"indicators": ["roe", "roa", "eps", "pe", "pb"]
}
},
{
"name": "search_stock",
"description": "搜索股票,按名称、行业、地区筛选。",
"parameters": {
"keyword": "搜索关键词",
"industry": "行业(可选)",
"market": "市场(可选):主板/创业板/科创板"
}
}
]
}
6.4 三层对比总结
| 维度 | SDK 层 | REST API | Skills 层 | MCP 层 |
|---|---|---|---|---|
| 目标用户 | Python 开发者 | 任何语言开发者 | AI Coding 用户 | 零代码用户 |
| 技术门槛 | 中 | 中低 | 低 | 极低 |
| 调用方式 | client.daily(...) | GET /api/v1/... | 自然语言 + AI 翻译 | 纯自然语言对话 |
| AI 集成度 | AI 辅助编程 | AI 可调用 | AI 自主动态调用 | AI 原生协议 |
| 适用场景 | 量化策略开发 | 微服务/Web 应用 | AI IDE 内研究 | 对话式数据分析 |
七、模块化重构与流水线编排
7.1 现状问题:模块重复与 ad-hoc 脚本
对所有 Python 脚本的分析揭示出以下系统性问题:
| 问题类别 | 数量 | 典型示例 |
|---|---|---|
| fetcher 间重复模块 | 4 个完整类 | DataValidator、ReportGenerator、SyncCache、TradeDateCache 在 tushare_fetcher 和 akshare_fetcher 中各有一份 100% 重复的实现 |
| DatabaseManager 部分重复 | 2 处 | tushare_fetcher 和 akshare_fetcher 的 db.py 有 70% 相同逻辑(连接池、upsert、safe_identifier) |
| 快讯写入逻辑三重实现 | 3 处 | newsflash_collect.py、backfill.py、backfill_history.py 各自维护 SOURCE_TABLE 和 insert_news() |
| Ad-hoc 硬编码脚本 | 7 个 | fix_chinese_columns.py、fix_stock_daily.py、refill_balance_v4.py 等硬编码了日期范围、列名映射 |
| 脚本初始化模板重复 | 16 个 | 所有 akshare_fetcher/scripts/sync_*.py 重复相同 4 行初始化代码 |
| alpha-common 未被使用 | 4 个模块 | logging、config、tasks、formatting 已存在但未被任何 fetcher 导入 |
7.2 解决方案:findatahub-core 统一公共核心
将上述所有重复模块收敛到单一公共包,各 fetcher 和脚本通过 pip install 引用,不再各自维护一份。
Services/findatahub-core/ # 🆕 统一公共核心包
├── config/
│ ├── pipeline.yaml # Pipeline 默认配置
│ └── profiles.yaml # 策略画像配置
├── src/findatahub_core/
│ ├── __init__.py # 公共 API 导出
│ ├── context.py # PipelineContext + RerouteRequest
│ ├── pipeline.py # DAG 编排器(核心)
│ ├── gate.py # 四级质量门控
│ ├── cleaner.py # 数据清洗(待迁移)
│ ├── imputer.py # 缺失值填充(待迁移)
│ ├── outlier.py # 异常值检测(待迁移)
│ ├── validator.py # 统一 DataValidator(合并 tushare + akshare 两版)
│ ├── reporter.py # 统一 ReportGenerator(合并两版)
│ ├── cache.py # 统一 SyncCache(合并两版)
│ ├── cross_validator.py # 双源交叉验证
│ ├── db.py # 统一 DatabaseManager(合并两版)
│ ├── config.py # 统一配置加载(合并三套)
│ ├── logging.py # 统一结构化日志
│ └── schema.py # Schema 注册表 + 迁移
├── flows/
│ └── stock_daily_flow.py # 端到端流水线定义
└── pyproject.toml
7.3 模块单一职责契约
每个模块通过 PipelineContext 作为唯一数据载体,签名统一为 (PipelineContext) → PipelineContext:
| 模块 | 允许读取 | 允许写入 | 禁止 |
|---|---|---|---|
| Fetch | context.source, context.table_name | context.df | 不修改 df 内容 |
| Gate | context.df, context.quality_score | context.gate_results, context.is_blocked, context.reroute | 不修改 context.df |
| Clean | context.df, context.source | context.df(格式修正) | 不做校验 |
| Impute | context.df | context.df 中的 NaN 值 + _is_*_imputed 标记列 | 不检测异常值 |
| Outlier | context.df | _is_*_outlier 标记列 | 不修改原始值 |
| Store | context.df, context.table_name, context.quality_score | fin_data_clean 数据库 | 不判断数据是否合格 |
7.4 Pipeline 编排器:DAG + 并行 + 反馈回路
Pipeline 编排器是整个框架的核心组件。它将各个 Step 组织为有向无环图(DAG),自动处理并行执行和依赖管理。
flowchart TB
subgraph Parallel["⚡ 并行分支"]
F1[fetch_tushare]
F2[fetch_akshare]
end
F1 --> G1[gate1_collection]
F2 --> G1
G1 --> CL[clean] --> G2[gate2_cleaning] --> IM[impute] --> G3[gate3_ingestion] --> ST[store] --> G4[gate4_post]
G3 -.->|"🔄 发现 Gate2 漏检
Reroute 回传"| G2
style Parallel fill:#eff6ff,stroke:#2563eb,stroke-width:1.5px
style G3 fill:#fffbeb,stroke:#f59e0b,stroke-width:1.5px
核心特性
DAG 执行
每个 Step 声明 depends_on,Pipeline 自动推导执行顺序。无依赖的 Step 并行执行。
反馈回路
下游 Gate 发现上游漏检 → 通过 RerouteRequest 将 context 回传上游。目标 Step 重跑后,其所有下游级联重跑。
Reroute 上限
每个 Step 可设 max_reroute(默认 3),超过后 Pipeline 阻断并告警,防止死循环。
失败重试
每个 Step 支持 retry_count + 指数退避(1s, 2s, 4s...),与 reroute 机制独立。
使用示例
from findatahub_core import Pipeline, Step, PipelineContext
from findatahub_core.pipeline import make_fetch_step, make_gate_step, make_clean_step
# 构建 DAG
pipeline = Pipeline("stock_daily_sync")
pipeline.add_step(Step("fetch_tushare", make_fetch_step(tushare_fetcher), depends_on=[]))
pipeline.add_step(Step("fetch_akshare", make_fetch_step(akshare_fetcher), depends_on=[]))
pipeline.add_step(Step("gate1", make_gate_step("gate1_collection"), depends_on=["fetch_tushare"]))
pipeline.add_step(Step("clean", make_clean_step(cleaner), depends_on=["gate1"]))
pipeline.add_step(Step("gate2", make_gate_step("gate2_cleaning"), depends_on=["clean"], max_reroute=3))
# ... 后续步骤 ...
# 执行
ctx = PipelineContext(batch_id="...", source="tushare", table_name="stock_daily")
result = pipeline.run(ctx) # 自动 DAG 执行,支持并行 + reroute
7.5 未来扩展点
| 扩展方向 | 插入位置 | 涉及模块 |
|---|---|---|
| 增量更新优化 | L1 采集层 | fetcher.py — 支持 since_last_sync 模式 |
| 阿里云 MySQL 同步 | L1→云端 | 新增 cloud_sync 模块 |
| Redis 缓存层 | L3 使用层 | 热门查询结果缓存 |
| 数据版本管理 | L2 预处理层 | 每次预处理生成带版本号的结果集 |
| 数据血缘追踪 | 跨层 | 记录"源→采集→清洗"完整链路 |
| 多数据库支持 | L1+L3 | MySQL → PostgreSQL / DuckDB |
八、实施路线图
8.1 分阶段推进
阶段一:夯实采集层 DONE
✅ 数据源交叉验证 — 双源对比 99.9% 匹配率
✅ AKShare 数据源问题修复 — 成交额/科创板 vol/涨跌幅/pre_close/北交所
✅ 结构化日志 + 指数退避重试 + 熔断 + 失败队列
✅ 数据源健康监控 — 耗时/成功率 + REST API 端点
阶段二:构建预处理层 DONE
✅ 7 步 Pipeline:cleaner → imputer → outlier → stats → correlation → PCA → normalizer
✅ 4 级质量门控 — 采集/清洗/入库/后验
✅ 质量追踪辅助表 — _data_issues/_quality_metrics/_rejected_records
✅ 策略画像 — high_freq/trend/ml_model 三种 YAML 配置
✅ 双源交叉验证器 — 差异自动写入 _data_issues
阶段三:构建使用层基础 DONE
✅ Python SDK — 11 个方法(daily/weekly/monthly/fina_indicator/search_stock/export 等)
✅ RESTful API — 14 个端点(FastAPI)
✅ 数据导出 — JSON/CSV/Parquet
阶段四:AI 原生接入 DONE
✅ Skills 层 — skill.md 结构化描述 5 表 schema
✅ MCP 服务 — 7 个 MCP 工具(stdio 协议)
✅ 自然语言直达数据 — 支持搜索/日线/财务指标/质量分/统计概览
阶段五:云端扩展 未来
阿里云 MySQL 同步、Redis 缓存、数据版本管理、数据血缘追踪等高级特性。
8.2 关键里程碑与验收标准
| 阶段 | 状态 | 里程碑 | 实际验收结果 |
|---|---|---|---|
| P1 | DONE | 数据质量自动检测 | 双源交叉验证 99.9% 匹配率(历史),差异自动写入 _data_issues(324 条);AKShare 4 大问题修复(成交额/科创板 vol/pct_chg/pre_close);北交所新浪源重拉(333 只) |
| P2 | DONE | 预处理报告生成 | 7 步 Pipeline 全部实现;4 级质量门控;stats.py 自动生成描述性统计 + 分布特征 + 缺失率报告;correlation.py 生成 Pearson/Spearman 矩阵 + 冗余特征检测;质量分 0.85 |
| P3 | DONE | SDK 首批接口可用 | from findatahub import Client + 11 个方法(daily/weekly/monthly/fina_indicator/search_stock/export/stats/quality 等)+ REST API 14 个端点(含健康监控) |
| P4 | DONE | MCP 服务可对话查数据 | 7 个 MCP 工具(get_daily_data/weekly/monthly/fina_indicator/search_stock/get_data_quality/get_stats);skill.md 结构化 5 表 schema;Token 优化(限制 50 行) |
| P5 | TODO | 云端数据同步 | 远期规划:阿里云 MySQL 同步 / Redis 缓存 / 数据血缘 |
九、架构规范
以下规范定义了 FinDataHub 数据管道的开发法则。所有代码变更必须遵循。
9.1 依赖规则(铁律)
依赖方向:采集层 → 核心层 → 使用层。禁止反向引用。
🚫 禁止硬编码密码(从 os.environ 读取)
🚫 禁止 f-string 拼接 SQL 值(使用参数化查询)
🚫 禁止裸 except Exception(指定具体异常类型)
9.2 组件契约
| 组件 | 职责 | 接口 |
|---|---|---|
| Fetcher | 拉取数据 → 写入 raw 库。不做清洗 | fetch(table) → DataFrame + upsert(df, table) |
| Cleaner | 类型修正/去重/列名标准化。纯函数 | clean(df, source) → DataFrame |
| Gate | 质量检查。不修改 DataFrame | gate.check(df) → GateResult |
| SDK | 只读查询 fin_data_clean | client.daily("000001.SZ") → DataFrame |
9.3 可扩展性模式
| 扩展点 | 方法 | 不改动现有代码 |
|---|---|---|
| 新数据源 | 创建 fetcher 包 + Pipeline.add_step | ✅ 不改 core |
| 新预处理步骤 | 创建模块 + Pipeline.add_step | ✅ 不改现有 Step |
| 新策略画像 | pipeline_profiles.yaml 新增 block | ✅ 不改任何代码 |
| 数据库扩展 | 垂直拆分 / 读写分离 / Redis 缓存 | ✅ SDK 透明接入 |
9.4 代码复检清单
- ✅ core 层不 import fetcher
- ✅ pyproject.toml 声明所有跨包依赖
- ✅ 无裸 except Exception
- ✅ SQL 标识符经 safe_identifier 白名单校验
- ✅ 无硬编码密码
- ✅ 生产代码使用 structlog
- ✅ 异常值/缺失值用标记列,不删除原始数据
- ✅ 使用 INSERT ON DUPLICATE KEY UPDATE(幂等写入)
9.5 测试策略
| 测试层 | 覆盖 | 状态 |
|---|---|---|
| 单元测试 | 每个 core 模块的纯函数 | DONE |
| 集成测试 | Pipeline DAG 端到端 | DONE |
| 系统测试 | 交叉验证 6 日期全量对比(匹配率 96-100%) | DONE |
| API 测试 | 14 个 REST 端点 | DONE |
| MCP 测试 | 7 个 MCP 工具 stdio 协议 | DONE |
十、附录A:预处理核心代码
以下是实现预处理层最关键模块的参考代码骨架。每段代码对应前文设计中的一个具体组件。
9.1 数据清洗器
# cleaner.py — 数据清洗器
import pandas as pd
import numpy as np
class DataCleaner:
"""数据清洗器:类型修正、去重、列名标准化、空值标记、单位统一."""
# TuShare ↔ AKShare 列名映射表
COLUMN_MAP = {
'成交额': 'amount', '成交量': 'vol', '收盘价': 'close',
'开盘价': 'open', '最高价': 'high', '最低价': 'low',
'涨跌幅': 'pct_chg', '股票代码': 'ts_code', '日期': 'trade_date',
}
# 空值表示形式
NULL_PATTERNS = ['', '--', 'N/A', 'null', 'None', 'nan']
def clean(self, df: pd.DataFrame, source: str = 'tushare') -> pd.DataFrame:
"""执行完整清洗流程,返回清洗后的 DataFrame."""
df = self._fix_types(df, source)
df = self._drop_duplicates(df)
df = self._standardize_columns(df)
df = self._mark_nulls(df)
df = self._unify_units(df, source)
return df
def _fix_types(self, df, source):
"""日期转 DATE,数字转 numeric."""
if 'trade_date' in df.columns:
df['trade_date'] = pd.to_datetime(df['trade_date']).dt.strftime('%Y%m%d')
numeric_cols = ['open', 'high', 'low', 'close', 'vol', 'amount', 'pct_chg']
for col in numeric_cols:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors='coerce')
return df
def _drop_duplicates(self, df):
"""基于 (ts_code, trade_date) 去重."""
if 'ts_code' in df.columns and 'trade_date' in df.columns:
return df.drop_duplicates(subset=['ts_code', 'trade_date'], keep='last')
return df
def _standardize_columns(self, df):
"""中文列名 → 英文列名."""
df = df.rename(columns=self.COLUMN_MAP)
df.columns = df.columns.str.strip().str.lower().str.replace(r'[^a-z0-9_]', '_', regex=True)
return df
def _mark_nulls(self, df):
"""空字符串/占位符 → NaN."""
for pat in self.NULL_PATTERNS:
df = df.replace(pat, np.nan)
return df
def _unify_units(self, df, source):
"""单位统一:AKShare amount 可能是元,TuShare 是千元,统一为元."""
if source == 'tushare' and 'amount' in df.columns:
df['amount'] = df['amount'] * 1000 # 千元 → 元
return df
9.2 缺失值处理器
# imputer.py — 缺失值处理器
import pandas as pd
import numpy as np
class DataImputer:
"""缺失值处理:前向填充、线性插值、均值填充,所有填充均标记 _is_imputed."""
def impute(self, df: pd.DataFrame, strategy: str = 'ffill') -> pd.DataFrame:
"""对数值列执行缺失值处理."""
df = df.copy()
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
# 排除标记列,避免递归标记
numeric_cols = [c for c in numeric_cols if not c.startswith('_is_')]
for col in numeric_cols:
null_mask = df[col].isna()
if null_mask.sum() == 0:
continue
# 初始化标记列
flag_col = f'_is_{col}_imputed'
if flag_col not in df.columns:
df[flag_col] = False
if strategy == 'ffill':
df[col] = df[col].ffill()
elif strategy == 'linear':
df[col] = df[col].interpolate(method='linear')
elif strategy == 'median':
median_val = df[col].median()
df[col] = df[col].fillna(median_val)
# 标记哪些行被填充了
df.loc[null_mask, flag_col] = True
return df
9.3 异常值检测器
# outlier.py — 异常值检测器
import pandas as pd
import numpy as np
class OutlierDetector:
"""异常值检测:IQR / MAD / 绝对阈值,区分录入错误 vs 统计异常 vs 真实事件."""
def detect(self, df: pd.DataFrame, method: str = 'iqr',
threshold: float = 3.0) -> pd.DataFrame:
"""检测异常值,返回带 _is_outlier 标记的 DataFrame."""
df = df.copy()
price_cols = ['open', 'high', 'low', 'close', 'vol']
for col in price_cols:
if col not in df.columns:
continue
flag_col = f'_is_{col}_outlier'
df[flag_col] = False
# Level 1: 绝对阈值(录入错误)
if col in ['open', 'high', 'low', 'close']:
df.loc[df[col] <= 0, flag_col] = True # 价格不能为负
# Level 2: 统计异常(IQR 或 MAD)
if method == 'iqr':
Q1 = df[col].quantile(0.25)
Q3 = df[col].quantile(0.75)
IQR = Q3 - Q1
lower, upper = Q1 - threshold * IQR, Q3 + threshold * IQR
stat_outliers = (df[col] < lower) | (df[col] > upper)
elif method == 'mad':
median = df[col].median()
mad = np.median(np.abs(df[col] - median))
mad_norm = mad * 1.4826
lower, upper = median - threshold * mad_norm, median + threshold * mad_norm
stat_outliers = (df[col] < lower) | (df[col] > upper)
df.loc[stat_outliers, flag_col] = True
# Level 3: 涨跌停豁免(真实事件,不标记)
if 'pct_chg' in df.columns:
df.loc[df['pct_chg'].abs() > 9.5, f'_is_close_outlier'] = False
return df
9.4 质量门控器
# quality_gate.py — 质量门控器(最核心组件)
from dataclasses import dataclass, field
from typing import Optional
import pandas as pd
import numpy as np
@dataclass
class GateResult:
"""单级门控结果."""
gate_name: str
passed: bool = True
checks: list[dict] = field(default_factory=list)
class QualityGate:
"""四级质量门控链式执行器."""
def __init__(self, batch_name: str):
self.batch_name = batch_name
self.results: list[GateResult] = []
def gate1_collection(self, df, expected_count: int) -> GateResult:
"""Gate 1: 采集门控."""
result = GateResult(gate_name="Gate1-采集门控")
# 非空
if df is None or len(df) == 0:
result.checks.append({"check": "non_empty", "passed": False, "severity": "block"})
result.passed = False
else:
result.checks.append({"check": "non_empty", "passed": True, "severity": "block"})
# 行数阈值
if len(df) < expected_count * 0.5:
result.checks.append({"check": "min_rows", "passed": False, "severity": "block",
"detail": f"got {len(df)}, expected >= {int(expected_count*0.5)}"})
result.passed = False
else:
result.checks.append({"check": "min_rows", "passed": True, "severity": "block"})
self.results.append(result)
return result
def gate2_cleaning(self, df) -> GateResult:
"""Gate 2: 清洗门控 — OHLC 关系、价格非负、涨跌幅."""
result = GateResult(gate_name="Gate2-清洗门控")
# OHLC 关系
if all(c in df.columns for c in ['open', 'high', 'low', 'close']):
ohlc_ok = (
(df['high'] >= df[['open', 'close']].max(axis=1)) &
(df['low'] <= df[['open', 'close']].min(axis=1))
)
fail_count = (~ohlc_ok).sum()
result.checks.append({
"check": "ohlc_relation", "passed": fail_count == 0,
"severity": "warn", "detail": f"{fail_count} rows violate OHLC rule"
})
# 价格非负
for col in ['open', 'close']:
if col in df.columns:
neg_count = (df[col] <= 0).sum()
result.checks.append({
"check": f"{col}_non_negative", "passed": neg_count == 0,
"severity": "block" if neg_count > 0 else "info",
"detail": f"{neg_count} rows have {col} <= 0"
})
if neg_count > 0:
result.passed = False
self.results.append(result)
return result
def gate3_ingestion(self, df, quality_score: float) -> GateResult:
"""Gate 3: 入库门控 — 唯一键、质量分."""
result = GateResult(gate_name="Gate3-入库门控")
# 唯一键
dup_count = df.duplicated(subset=['ts_code', 'trade_date']).sum()
result.checks.append({
"check": "unique_key", "passed": dup_count == 0,
"severity": "block", "detail": f"{dup_count} duplicate rows"
})
# 质量分
result.checks.append({
"check": "quality_score", "passed": quality_score >= 0.60,
"severity": "block", "detail": f"score={quality_score:.2f}, threshold=0.60"
})
if quality_score < 0.60:
result.passed = False
self.results.append(result)
return result
def is_blocked(self) -> bool:
"""是否有阻断级错误."""
for r in self.results:
for c in r.checks:
if c['severity'] == 'block' and not c['passed']:
return True
return False
def can_write(self) -> bool:
"""是否允许写入 clean 库."""
return not self.is_blocked()
9.5 Pipeline 主控器
# pipeline.py — 预处理 Pipeline 主控器
from cleaner import DataCleaner
from imputer import DataImputer
from outlier import OutlierDetector
from quality_gate import QualityGate
class PreprocessingPipeline:
"""预处理 Pipeline — 串联所有步骤,驱动质量门控."""
def __init__(self, config: dict, db_raw, db_clean):
self.config = config
self.db_raw = db_raw # tushare_data / akshare_data
self.db_clean = db_clean # fin_data_clean
self.cleaner = DataCleaner()
self.imputer = DataImputer()
self.outlier = OutlierDetector()
def run(self, table_name: str, source: str = 'tushare'):
"""对一张表执行完整预处理流程."""
# Step 0: 从 raw 库读取
df = self.db_raw.read_table(table_name)
# Gate 1: 采集门控
gate = QualityGate(f"{table_name}_{source}")
g1 = gate.gate1_collection(df, expected_count=5000)
if gate.is_blocked():
self._alert(f"Gate 1 FAILED for {table_name}")
return None
# Step 1: 数据清洗
df = self.cleaner.clean(df, source)
# Gate 2: 清洗门控
g2 = gate.gate2_cleaning(df)
# Step 2: 缺失值处理
strategy = self.config.get('imputation', {}).get('strategy', 'ffill')
df = self.imputer.impute(df, strategy)
# Step 3: 异常值检测
method = self.config.get('outlier_detection', {}).get('method', 'iqr')
threshold = self.config.get('outlier_detection', {}).get('threshold', 3.0)
df = self.outlier.detect(df, method, threshold)
# Gate 3: 入库门控
quality_score = self._compute_quality_score(df)
g3 = gate.gate3_ingestion(df, quality_score)
if gate.is_blocked():
self.db_clean.write_rejected(table_name, df)
self._alert(f"Gate 3 FAILED for {table_name}, score={quality_score:.2f}")
return None
# Step 4: 写入 clean 库
self.db_clean.upsert(table_name, df)
self.db_clean.update_quality_metrics(table_name, quality_score)
return {"table": table_name, "rows": len(df), "score": quality_score, "gate_results": gate.results}
def _compute_quality_score(self, df) -> float:
"""计算数据质量综合分."""
scores = {}
key_cols = ['open', 'high', 'low', 'close', 'vol']
# 完整性
scores['completeness'] = df[key_cols].notna().all(axis=1).mean()
# 有效性
if all(c in df.columns for c in ['high', 'low', 'open', 'close']):
valid = (df['high'] >= df['low']) & (df['high'] >= df[['open','close']].max(axis=1))
scores['validity'] = valid.mean()
else:
scores['validity'] = 1.0
scores['accuracy'] = scores['validity'] # 简化
scores['uniqueness'] = 1 - df.duplicated(subset=['ts_code', 'trade_date']).sum() / len(df)
# 加权
weights = {'completeness': 0.25, 'accuracy': 0.35, 'validity': 0.25, 'uniqueness': 0.15}
return sum(scores[k] * weights[k] for k in weights)
def _alert(self, message: str):
"""发送告警(日志 + 通知)."""
import structlog
logger = structlog.get_logger(__name__)
logger.error("quality_gate_blocked", message=message)
9.6 质量追踪辅助表
-- 在 fin_data_clean 库中创建辅助表
CREATE TABLE IF NOT EXISTS `_data_issues` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`table_name` VARCHAR(64) NOT NULL,
`ts_code` VARCHAR(16),
`trade_date` VARCHAR(10),
`issue_type` VARCHAR(32) NOT NULL COMMENT 'missing/outlier/ohlc_violation/cross_validation',
`severity` VARCHAR(16) NOT NULL COMMENT 'block/warn/info',
`detail` JSON COMMENT '详细信息',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY `idx_table` (`table_name`),
KEY `idx_type` (`issue_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `_quality_metrics` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`table_name` VARCHAR(64) NOT NULL,
`batch_time` DATETIME NOT NULL,
`completeness` DOUBLE,
`accuracy` DOUBLE,
`timeliness` DOUBLE,
`consistency` DOUBLE,
`uniqueness` DOUBLE,
`validity` DOUBLE,
`total_score` DOUBLE NOT NULL,
`row_count` INT,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY `idx_table_time` (`table_name`, `batch_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `_rejected_records` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`table_name` VARCHAR(64) NOT NULL,
`ts_code` VARCHAR(16),
`trade_date` VARCHAR(10),
`reject_reason` VARCHAR(128),
`original_row` JSON COMMENT '被拒绝的原始行数据',
`rejected_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY `idx_table` (`table_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;