Metadata-Version: 2.4
Name: stock-gateway
Version: 0.1.0
Summary: A-share data aggregation SDK with explicit gateway APIs.
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: pandas>=1.3.0
Requires-Dist: requests>=2.31.0
Requires-Dist: thsdk>=1.7.18
Requires-Dist: mootdx>=0.11.7
Requires-Dist: lxml>=6.1.1

# StockDataGateway 设计文档

## 背景

当前项目已经整理了多类 A 股数据源：

- `mootdx` / 通达信 SDK：K线、实时行情、盘口、分笔成交、财务快照、F10。
- `thsdk`：K线、分时、盘口、Tick、大单、竞价、板块、指数、问财；多市场行情暂列低优先级。
- 腾讯财经：实时行情、PE/PB、市值、换手率、涨跌停、指数/ETF。
- 东方财富：个股新闻、资金流、龙虎榜、解禁、融资融券、大宗交易、股东户数、分红、行业排名、研报。
- 新浪：实时行情、财报三表、图表图片。
- 百度股市通：K线带均线、个股概念/行业/地域归属。
- 巨潮：公告全文检索。
- 选股宝、金十、韭研公社、timor.tech 等辅助数据源。

这些数据源的字段、代码格式、稳定性、限流规则都不同。目标是封装一个统一网关，让上层业务调用明确方法，而不是关心底层 provider。

运行依赖由 `pyproject.toml` 声明，默认 SDK 安装包含 `requests`、`pandas`、`thsdk`、`mootdx`、`lxml` 等 provider 运行所需包；如果某个真实 provider 在本机不可用，测试仍应通过 fake/mock provider 覆盖网关和 schema 行为。

## 设计目标

1. 对外 API 必须显式，例如 `get_kline()`、`get_quote()`、`get_stock_news()`，不使用 `gateway.get(capability="...")` 这种动态入口。
2. 每个方法内部可以有多个 provider，并按策略自动降级。
3. 自动降级只允许发生在语义等价或兼容的数据源之间。
4. 每次降级必须记录 `fallback_chain`，不能静默换源。
5. 所有返回结果使用统一 `GatewayResult` 包裹，包含数据、provider、schema、warnings、耗时和错误。
6. 常用数据统一字段 schema，保留 `raw` 或 provider 原始结果以便调试。
7. 东方财富等有风控的数据源统一限流，不允许并发打接口。
8. 可以分期实现，首期覆盖高频行情，后续逐步接入资金、公告、财务、研报、板块和问财。

## 非目标

- 首版不做动态插件市场。
- 首版不做跨进程缓存服务。
- 首版不做异步任务队列。
- 首版不把所有数据强行合并成一个大 schema。
- 首版不把不等价数据做静默替代，例如“个股新闻”不自动降级到“全市场快讯”。
- 不接入 `iwencai OpenAPI`，不要求 API Key 或 `X-Claw-*` 头；问财能力统一通过 `thsdk` 的 `wencai_nlp` / `wencai_base` 模式承载。
- 多市场行情能力暂缓，不作为近期阶段的默认建设目标。

## 对外 API

### 行情与交易数据

```python
class StockDataGateway:
    def get_kline(
        self,
        code: str,
        interval: str = "day",
        count: int | None = None,
        start: str | None = None,
        end: str | None = None,
        adjust: str | None = None,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_quote(
        self,
        code: str | list[str],
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_depth(
        self,
        code: str,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_intraday(
        self,
        code: str,
        date: str | None = None,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_transaction(
        self,
        code: str,
        date: str | None = None,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_tick(
        self,
        code: str,
        mode: str = "level1",
        date: str | None = None,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_big_order(
        self,
        code: str,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_call_auction(
        self,
        code: str,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def scan_call_auction_anomaly(
        self,
        market: str = "USZA",
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...
```

### 资金、筹码与交易异动

```python
    def get_fund_flow(
        self,
        code: str,
        period: str = "minute",
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_fund_flow_120d(
        self,
        code: str,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_margin(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_block_trade(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_holder_num(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_dividend(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...

    def get_lockup_expiry(
        self,
        code: str,
        forward_days: int = 90,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_dragon_tiger(
        self,
        code: str,
        trade_date: str | None = None,
        look_back: int = 30,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_daily_dragon_tiger(
        self,
        trade_date: str | None = None,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...
```

### 指数、板块与题材

```python
    def get_index_quote(self, index_code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_index_kline(self, index_code: str, interval: str = "day", provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_index_list(self, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...

    def get_sector_list(
        self,
        sector_type: str = "industry",
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_sector_quote(self, sector_code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_sector_constituents(self, sector_code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_industry_rank(self, top_n: int = 20, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_concept_blocks(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_hot_reason(self, date: str | None = None, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
```

### 新闻、公告与资讯

```python
    def get_stock_news(
        self,
        code: str,
        limit: int = 20,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_global_news(
        self,
        limit: int = 50,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_announcements(
        self,
        code: str,
        limit: int = 30,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_f10(
        self,
        code: str,
        section: str = "公司概况",
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_latest_notice_summary(
        self,
        code: str,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...
```

### 财务、估值与研报

```python
    def get_finance_snapshot(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_stock_info(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...

    def get_financial_statement(
        self,
        code: str,
        report_type: str = "income",
        limit: int = 8,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def get_eps_forecast(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_reports(self, code: str, max_pages: int = 2, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def download_report(
        self,
        record: dict,
        target_dir: str | None = None,
        filename: str | None = None,
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...
    def get_valuation(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
```

### 问财与自然语言

```python
    def query_wencai(self, query: str, provider: str = "auto") -> GatewayResult: ...
    def query_wencai_base(self, query: str, provider: str = "auto") -> GatewayResult: ...
    def screen_stocks(self, query: str, provider: str = "auto") -> GatewayResult: ...
    def compare_stocks(self, codes: list[str], interval: str = "day", count: int = 30, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def normalize_trend(self, codes: list[str], interval: str = "day", count: int = 30, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def correlation_matrix(self, codes: list[str], interval: str = "day", count: int = 30, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
```

### 多市场

```python
    def get_hk_quote(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_us_quote(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_uk_quote(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_forex_quote(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_future_quote(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_bond_quote(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_fund_quote(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def get_option_data(self, code: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def list_market(self, market: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
```

### 图片与辅助

```python
    def get_chart_image(
        self,
        code: str,
        chart_type: str = "daily",
        provider: str = "auto",
        fallback: bool = True,
    ) -> GatewayResult: ...

    def search_symbol(self, keyword: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
    def normalize_symbol(self, code: str) -> dict: ...
    def is_trading_day(self, date: str, provider: str = "auto", fallback: bool = True) -> GatewayResult: ...
```

## 统一返回模型

```python
from dataclasses import dataclass
from typing import Any

@dataclass
class GatewayResult:
    ok: bool
    data: Any
    provider: str | None
    fallback_chain: list[dict]
    warnings: list[str]
    schema: list[str]
    symbol: str | None = None
    normalized_symbol: dict | None = None
    latency_ms: float | None = None
    error: str | None = None
    raw: Any | None = None
```

`fallback_chain` 记录每个 provider 的调用情况：

```python
[
    {
        "provider": "thsdk",
        "status": "failed",
        "error_type": "timeout",
        "error": "connect timeout",
        "latency_ms": 3000,
    },
    {
        "provider": "mootdx",
        "status": "success",
        "latency_ms": 126,
    },
]
```

## SymbolResolver

`SymbolResolver` 负责把用户输入归一化为不同 provider 所需格式。

输入支持：

| 输入 | 归一化 |
|---|---|
| `300498` | 纯 6 位代码 |
| `SZ300498` / `sz300498` | 纯 6 位代码 |
| `300498.SZ` | 纯 6 位代码 |
| `USZA300498` | THSCODE |
| `sz300498` | 腾讯/新浪格式 |

输出示例：

```python
{
    "raw": "300498",
    "code": "300498",
    "market": "sz",
    "ths": "USZA300498",
    "tencent": "sz300498",
    "sina": "sz300498",
    "eastmoney_secid": "0.300498",
    "mootdx": "300498",
}
```

指数、港股、美股、外汇、期货等使用独立转换规则。

## Provider 设计

每个 provider 是一个适配器，只实现自己支持的方法。

```python
class BaseProvider:
    name: str

    def health(self) -> dict: ...
```

### ThsdkProvider

支持：

- `get_kline`
- `get_quote`
- `get_depth`
- `get_intraday`
- `get_tick`
- `get_big_order`
- `get_call_auction`
- `scan_call_auction_anomaly`
- `get_index_quote`
- `get_index_kline`
- `get_index_list`
- `get_sector_list`
- `get_sector_quote`
- `get_sector_constituents`
- `get_eps_forecast` 的动态问财 fallback
- `query_wencai`
- `query_wencai_base`
- `get_global_news`
- 多市场 quote/list 方法暂缓接入

### MootdxProvider

支持：

- `get_kline`
- `get_quote`
- `get_depth`
- `get_intraday`
- `get_transaction`
- `get_finance_snapshot`
- `get_f10`
- `get_latest_notice_summary`
- `get_index_kline`

### TencentProvider

支持：

- `get_quote`
- `get_index_quote`
- `get_stock_info` 的部分字段
- `get_valuation`

### EastmoneyProvider

支持：

- `get_stock_news`
- `get_global_news`
- `get_fund_flow`
- `get_fund_flow_120d`
- `get_margin`
- `get_block_trade`
- `get_holder_num`
- `get_dividend`
- `get_lockup_expiry`
- `get_dragon_tiger`
- `get_daily_dragon_tiger`
- `get_industry_rank`
- `get_stock_info`
- `get_reports`
- `get_eps_forecast`
- `download_report`
- `get_valuation`

所有 Eastmoney 请求必须经过限流器。

### SinaProvider

支持：

- `get_quote`
- `get_financial_statement`
- `get_chart_image`

### BaiduProvider

支持：

- `get_kline`
- `get_concept_blocks`
- `get_sector_constituents` 的部分 fallback

### SohuProvider

支持：

- `get_kline` 的历史日线 fallback

### CninfoProvider

支持：

- `get_announcements`

### JiuyangongsheProvider

支持：

- `get_stock_news` fallback

### XuanGuBaoProvider

支持：

- `get_global_news` fallback

### Jin10Provider

支持：

- `get_global_news` fallback，WebSocket 形态，首版可先不实现实时长连接。

### TimorProvider

支持：

- `is_trading_day`

## 降级策略

降级策略按方法写死，不对外暴露动态 capability。

### 行情与 K线

| 方法 | 默认 provider 顺序 | 等价性 | 说明 |
|---|---|---|---|
| `get_kline(interval="1m")` | `thsdk -> mootdx` | `compatible_with_warning` | mootdx 可能合并 09:30 bar |
| `get_kline(interval="5m")` | `thsdk -> mootdx` | `compatible_with_warning` | 分钟边界可能略不同 |
| `get_kline(interval="day")` | `thsdk -> mootdx -> baidu -> sohu` | `equivalent/compatible` | 复权方式需标记 |
| `get_kline(interval="week")` | `thsdk -> mootdx` | `equivalent` |  |
| `get_kline(interval="month")` | `thsdk -> mootdx` | `equivalent` |  |
| `get_quote` | `tencent -> thsdk -> mootdx -> sina` | `compatible_with_warning` | 各源估值字段覆盖不同 |
| `get_depth` | `thsdk -> mootdx` | `compatible_with_warning` | mootdx 普通五档，THS 可更细 |
| `get_intraday` | `thsdk -> mootdx` | `compatible_with_warning` | 历史覆盖范围不同 |
| `get_transaction` | `mootdx` | `not_supported` | 暂无自动 fallback |
| `get_tick` | `thsdk` | `not_supported` | 暂无自动 fallback |
| `get_big_order` | `thsdk` | `not_supported` | 不等同于资金流 |
| `get_call_auction` | `thsdk` | `not_supported` |  |
| `scan_call_auction_anomaly` | `thsdk` | `not_supported` |  |

### 新闻、公告、资金

| 方法 | 默认 provider 顺序 | 等价性 | 说明 |
|---|---|---|---|
| `get_stock_news` | `eastmoney -> jiuyangongshe` | `compatible_with_warning` | 不降级到全市场快讯 |
| `get_global_news` | `thsdk -> eastmoney_global -> xuan_gu_bao -> jin10` | `compatible_with_warning` | 来源差异大 |
| `get_announcements` | `cninfo -> mootdx_f10` | `compatible_with_warning` | F10 是摘要，不是全文 |
| `get_fund_flow` | `eastmoney` | `not_supported` | THS 大单不等价；东财 provider 内部可从 `push2` 同源切到 `push2delay`，仍复用 limiter |
| `get_fund_flow_120d` | `eastmoney` | `not_supported` | 东财 provider 内部可从 `push2his daykline` 切到 `push2delay kline(klt=101)`，仍复用 limiter |

### 筹码与事件

| 方法 | 默认 provider 顺序 | 等价性 | 说明 |
|---|---|---|---|
| `get_margin` | `eastmoney` | `not_supported` |  |
| `get_block_trade` | `eastmoney` | `not_supported` |  |
| `get_holder_num` | `eastmoney` | `not_supported` |  |
| `get_dividend` | `eastmoney` | `not_supported` |  |
| `get_lockup_expiry` | `eastmoney` | `not_supported` |  |
| `get_dragon_tiger` | `eastmoney -> thsdk_wencai` | `compatible_with_warning` | thsdk 问财字段动态 |
| `get_daily_dragon_tiger` | `eastmoney -> thsdk_wencai` | `compatible_with_warning` |  |

### 指数、板块、题材、财务、研报

| 方法 | 默认 provider 顺序 | 等价性 | 说明 |
|---|---|---|---|
| `get_index_quote` | `thsdk` | `not_supported` | 指数使用 `market_data_index` |
| `get_index_kline` | `thsdk` | `not_supported` | 指数代码使用 `USHI` / `USZI` 前缀 |
| `get_index_list` | `thsdk` | `not_supported` |  |
| `get_sector_list` | `thsdk -> eastmoney` | `compatible_with_warning` | 东财 fallback 仅覆盖板块列表基础字段 |
| `get_sector_quote` | `thsdk` | `not_supported` |  |
| `get_sector_constituents` | `thsdk` | `not_supported` | 板块成分股优先 THS |
| `get_industry_rank` | `eastmoney` | `not_supported` | 东财适合排名，必须限流；provider 内部可切 `push2delay` 同源备用域 |
| `get_concept_blocks` | `baidu -> thsdk_wencai` | `compatible_with_warning` | 百度优先；百度 403/不可用时 thsdk 问财返回动态字段和 raw，必须 warning |
| `get_hot_reason` | `ths_hot -> thsdk_wencai` | `compatible_with_warning` | thsdk 问财 fallback 字段动态 |
| `get_finance_snapshot` | `mootdx -> eastmoney` | `compatible_with_warning` | 字段不同 |
| `get_stock_info` | `eastmoney -> tencent` | `compatible_with_warning` | 腾讯缺上市日期等字段 |
| `get_financial_statement` | `sina` | `not_supported` | 新浪三表按真实报表表格展平为 `period/item/value/raw`，不混入财务快照 |
| `get_eps_forecast` | `eastmoney -> thsdk_wencai` | `compatible_with_warning` | 东财研报 EPS 字段优先；thsdk 问财 fallback 字段动态，必须 warning；无机构覆盖返回空，不算异常 |
| `get_reports` | `eastmoney` | `not_supported` |  |
| `download_report` | `eastmoney` | `not_supported` | 默认只返回 PDF bytes；传 `target_dir` 才落盘，避免提交下载产物 |
| `get_valuation` | `tencent -> eastmoney` | `compatible_with_warning` | 腾讯 quote 估值字段优先；东财 push2 补充 |
| `query_wencai` / `query_wencai_base` | `thsdk` | `not_supported` | 只走 `thsdk.wencai_nlp` / `thsdk.wencai_base`，不需要 API Key；`query_wencai_base` 在 thsdk base 查询失败时可退回 NLP 动态结果 |
| `screen_stocks` | `thsdk` | `not_supported` | 业务包装，保留原始问财返回 |
| `compare_stocks` / `normalize_trend` / `correlation_matrix` | 复用 `get_quote` / `get_kline` | `compatible_with_warning` | 按单只显式方法循环拉取，记录子调用 fallback_chain |
| `search_reports` | 不进入当前路线 | `not_supported` | 不接入 iwencai OpenAPI；主题检索优先通过 `query_wencai` 动态探索 |

## 错误处理

触发 fallback：

- 网络超时。
- TCP 连接失败。
- HTTP 403 / 429。
- provider 返回明确错误。
- 返回字段缺失。
- 数据校验失败。
- 权限不足。

不一定触发 fallback：

- 非交易时间返回空分笔。
- 没有机构覆盖导致 EPS 预期为空。
- 非交易日没有当日龙虎榜。
- 查询条件本身无结果。

错误分类：

```python
class GatewayError(Exception): ...
class ProviderUnavailable(GatewayError): ...
class ProviderTimeout(GatewayError): ...
class ProviderRateLimited(GatewayError): ...
class ProviderPermissionDenied(GatewayError): ...
class EmptyData(GatewayError): ...
class SchemaValidationError(GatewayError): ...
class UnsupportedProvider(GatewayError): ...
```

## 限流与健康状态

### RateLimiter

东方财富默认：

- 串行。
- 最小间隔 1 秒。
- 加随机抖动。
- 批量任务可配置为 1.5 到 2 秒。

其他 HTTP provider：

- 默认轻量限流。
- 支持 provider 级别配置。

### ProviderHealth

记录：

- 最近成功时间。
- 最近失败时间。
- 连续失败次数。
- 平均耗时。
- 是否处于冷却期。
- 最近错误类型。

如果 provider 触发 403 / 429，进入冷却期，自动跳过一段时间。

## 标准 Schema

### K线

```python
KLINE_SCHEMA = [
    "time",
    "open",
    "high",
    "low",
    "close",
    "volume",
    "amount",
]
```

### 实时行情

```python
QUOTE_SCHEMA = [
    "symbol",
    "name",
    "price",
    "prev_close",
    "open",
    "high",
    "low",
    "change",
    "change_pct",
    "volume",
    "amount",
    "turnover_rate",
    "volume_ratio",
    "pe_ttm",
    "pb",
    "market_cap",
    "float_market_cap",
    "limit_up",
    "limit_down",
]
```

### 盘口

```python
DEPTH_SCHEMA = [
    "symbol",
    "bid1_price", "bid1_volume",
    "bid2_price", "bid2_volume",
    "bid3_price", "bid3_volume",
    "bid4_price", "bid4_volume",
    "bid5_price", "bid5_volume",
    "ask1_price", "ask1_volume",
    "ask2_price", "ask2_volume",
    "ask3_price", "ask3_volume",
    "ask4_price", "ask4_volume",
    "ask5_price", "ask5_volume",
]
```

### 个股新闻

```python
STOCK_NEWS_SCHEMA = [
    "time",
    "source",
    "title",
    "summary",
    "url",
    "symbols",
    "relevance",
]
```

### 资金流

```python
FUND_FLOW_SCHEMA = [
    "time",
    "main_net",
    "super_net",
    "large_net",
    "mid_net",
    "small_net",
]
```

### 公告

```python
ANNOUNCEMENT_SCHEMA = [
    "date",
    "title",
    "type",
    "url",
]
```

### 资金面、筹码与事件

```python
MARGIN_SCHEMA = [
    "date",
    "rzye",
    "rzmre",
    "rzche",
    "rqye",
    "rqmcl",
    "rqchl",
    "rzrqye",
]

BLOCK_TRADE_SCHEMA = [
    "date",
    "price",
    "close",
    "premium_pct",
    "vol",
    "amount",
    "buyer",
    "seller",
]

HOLDER_NUM_SCHEMA = [
    "date",
    "holder_num",
    "change_num",
    "change_ratio",
    "avg_shares",
]

DIVIDEND_SCHEMA = [
    "date",
    "bonus_rmb",
    "transfer_ratio",
    "bonus_ratio",
    "plan",
]

LOCKUP_EXPIRY_SCHEMA = [
    "history",
    "upcoming",
]

DRAGON_TIGER_SCHEMA = [
    "records",
    "seats",
    "institution",
]

DAILY_DRAGON_TIGER_SCHEMA = [
    "date",
    "total_records",
    "stocks",
    "note",
]
```

金额字段统一保留为元，不在 SDK provider 层换算成万元或亿元；展示层可自行换算。

## 目录结构

```text
stock_gateway/
  __init__.py
  gateway.py
  models.py
  resolver.py
  errors.py
  fallback.py
  health.py
  rate_limit.py

  providers/
    __init__.py
    base.py
    thsdk_provider.py
    mootdx_provider.py
    tencent_provider.py
    eastmoney_provider.py
    sina_provider.py
    baidu_provider.py
    sohu_provider.py
    cninfo_provider.py
    jiuyangongshe_provider.py
    xuan_gu_bao_provider.py
    jin10_provider.py
    timor_provider.py

  normalizers/
    __init__.py
    kline.py
    quote.py
    depth.py
    intraday.py
    news.py
    announcement.py
    fund_flow.py
    finance.py
    sector.py

  policies/
    __init__.py
    kline_policy.py
    quote_policy.py
    news_policy.py
    finance_policy.py

  tests/
    test_gateway_kline.py
    test_gateway_quote.py
    test_fallback.py
    test_normalizers.py
    test_stock_news_policy.py
```

## 分期实现计划

### Phase 0：基础框架

目标：建立可扩展骨架。

实现：

- `GatewayResult`
- `SymbolResolver`
- `GatewayError` 错误体系
- `FallbackRunner`
- `RateLimiter`
- `ProviderHealth`
- `BaseProvider`
- 基础测试框架

验收：

- 可以实例化 `StockDataGateway`。
- 可以注册 provider。
- 可以模拟 provider 失败并进入 fallback。

### Phase 1：高频行情 MVP

目标：支撑日常看盘和分析。

实现：

- `get_kline`
- `get_quote`
- `get_depth`
- `get_intraday`
- `get_transaction`
- `get_chart_image`

Provider：

- `ThsdkProvider`
- `MootdxProvider`
- `TencentProvider`
- `SinaProvider`
- `BaiduProvider`
- `SohuProvider`

验收：

- `gateway.get_kline("300498", interval="1m")` 可返回标准 K线 schema。
- thsdk 异常时可降级到 mootdx。
- `gateway.get_quote("300498")` 优先腾讯，失败后降级。
- 1m K线降级到 mootdx 时包含 09:30 差异 warning。

### Phase 2：新闻、公告、资金

目标：覆盖个股研究最常用信息面。

实现：

- `get_stock_news`
- `get_global_news`
- `get_announcements`
- `get_fund_flow`
- `get_fund_flow_120d`
- `get_finance_snapshot`
- `get_stock_info`

当前实现状态（2026-06-04）：

- 已实现 `get_stock_news`、`get_global_news`、`get_announcements`、`get_fund_flow`、`get_fund_flow_120d`、`get_finance_snapshot`、`get_stock_info`。
- 已额外接入 `get_reports` 的东财研报列表基础能力，满足个股研究入口；PDF 下载仍留在 Phase 5，iwencai OpenAPI 不进入当前路线。
- 东财 provider 使用统一限流入口，所有东财 HTTP 请求先经过 limiter。
- 个股新闻默认链路只在 `eastmoney -> jiuyangongshe` 内降级，不降级到 `get_global_news`。
- 公告默认链路为 `cninfo -> mootdx`；mootdx 仅作为 F10 摘要 fallback，gateway 必须返回 warning。
- `get_finance_snapshot` 默认 `mootdx -> eastmoney`；东财 fallback 只提供基本面兼容字段，gateway 必须返回 warning。

Provider：

- `EastmoneyProvider`
- `CninfoProvider`
- `MootdxProvider`
- `JiuyangongsheProvider`

验收：

- 东财请求走限流器。
- 个股新闻不默认降级到全市场快讯。
- 公告全文优先巨潮，F10 fallback 必须带 warning。

### Phase 3：资金面、筹码、事件

目标：补齐 `a-stock-data` 独有事件数据。

实现：

- `get_margin`
- `get_block_trade`
- `get_holder_num`
- `get_dividend`
- `get_lockup_expiry`
- `get_dragon_tiger`
- `get_daily_dragon_tiger`

当前实现状态（2026-06-04）：

- 已实现 `get_margin`、`get_block_trade`、`get_holder_num`、`get_dividend`、`get_lockup_expiry`、`get_dragon_tiger`、`get_daily_dragon_tiger`。
- 东财 datacenter 请求统一经 `EastmoneyProvider._get()`，复用 provider limiter。
- `get_dragon_tiger` 与 `get_daily_dragon_tiger` 默认链路为 `eastmoney -> thsdk`；thsdk 仅作为 wencai 动态字段 fallback，gateway 必须返回 warning。
- `get_daily_dragon_tiger` 在非交易日或盘后未更新时返回空列表和 note，不把无数据当作异常。
- 金额字段统一保留为元，展示层按需换算万元/亿元。

Provider：

- `EastmoneyProvider`
- `ThsdkProvider` 的 wencai fallback。

验收：

- 金额字段统一为元。
- 展示层可自行换算万元/亿元。
- 非交易日无龙虎榜不算异常。

### Phase 4：板块、指数、题材

目标：支持行业轮动、概念研究和成分股分析。

实现：

- `get_index_quote`
- `get_index_kline`
- `get_index_list`
- `get_sector_list`
- `get_sector_quote`
- `get_sector_constituents`
- `get_industry_rank`
- `get_concept_blocks`
- `get_hot_reason`

Provider：

- `ThsdkProvider`
- `EastmoneyProvider`
- `BaiduProvider`
- `ThsHotProvider`

当前实现状态（2026-06-05）：

- 已实现 `get_index_quote`、`get_index_kline`、`get_index_list`、`get_sector_list`、`get_sector_quote`、`get_sector_constituents`、`get_industry_rank`、`get_concept_blocks`、`get_hot_reason`。
- `get_index_quote` / `get_index_kline` 会把常见指数代码映射到 thsdk 真实内码，例如 `000001 -> USHI1A0001`、`000300 -> USHI1B0300`、`399001 -> USZI399001`。
- `get_sector_constituents` 默认只走 `thsdk`，避免把“个股归属”静默替代为“不完整成分股”。
- `get_industry_rank` 默认只走 `eastmoney`，东财 push2 请求统一经 `EastmoneyProvider._get()`，复用 provider limiter；当前网络下 `push2` 断连时可切换同源 `push2delay`。
- `get_concept_blocks` 默认链路为 `baidu -> thsdk`；百度返回 403 或不可用时，thsdk 问财仅作为动态字段 fallback，返回行业、概念、地域、`concept_tags` 和 raw，gateway 必须返回 warning。
- `get_hot_reason` 默认链路为 `ths_hot -> thsdk`；`ths_hot` 有独立串行 limiter，`thsdk` 仅作为 wencai 动态字段 fallback，gateway 必须返回 warning。

验收：

- 板块成分股优先 THS。
- 行业排名优先东财。
- 个股概念归属优先百度；百度不可用时允许 thsdk 问财动态 fallback，并显式 warning。

### Phase 5：研报、估值、财报

目标：支持基本面研究。

实现：

- `get_reports`
- `download_report`
- `get_eps_forecast`
- `get_financial_statement`
- `get_valuation`

Provider：

- `EastmoneyProvider`
- `ThsdkProvider`
- `SinaProvider`
- `TencentProvider`

当前实现状态（2026-06-05）：

- 已实现 `download_report`、`get_eps_forecast`、`get_financial_statement`、`get_valuation`，并补充 `get_reports` 的 EPS 预测字段。
- `get_reports`、`download_report`、东财估值补充都复用 `EastmoneyProvider._get()`，因此继续经过 provider limiter。
- `download_report` 默认不写文件，只返回 PDF bytes；只有调用者显式传 `target_dir` 时才落盘。
- `get_eps_forecast` 默认链路为 `eastmoney -> thsdk`；东财研报无 EPS 字段或无机构覆盖时可返回空列表，不视为异常。thsdk 仅作为问财动态字段 fallback，gateway 必须返回 warning。
- `get_financial_statement` 默认只走 `sina`，三表返回 `report_type/rows/raw`，并将新浪横向期间表展平成 `period/item/value/raw` 行，不与 `get_finance_snapshot` 混合。
- `get_valuation` 默认链路为 `tencent -> eastmoney`，返回 PE/PB/市值/换手等 quote 派生估值字段并保留 raw。

验收：

- 不引入 iwencai OpenAPI，不读取 API Key，不实现依赖 `X-Claw-*` 头的 provider。
- 主题研报探索如需自然语言入口，走 `query_wencai` 返回动态字段和 raw，不作为强 schema 研报搜索接口。
- 一致预期没有机构覆盖时返回空结果，不算异常。
- 三表字段不强行和财务快照混合。

### Phase 6：问财与组合分析

目标：提供无 API Key 的自然语言和批量分析入口。

实现：

- `query_wencai`
- `query_wencai_base`
- `screen_stocks`
- `compare_stocks`
- `normalize_trend`
- `correlation_matrix`

Provider：

- `ThsdkProvider`

当前实现状态（2026-06-05）：

- 已实现 `query_wencai`、`query_wencai_base`、`screen_stocks`、`compare_stocks`、`normalize_trend`、`correlation_matrix`。
- `query_wencai` 调用 `thsdk.wencai_nlp`；`query_wencai_base` 优先调用 `thsdk.wencai_base`，如 thsdk base 模式返回失败，可在同一 provider 内退回 `wencai_nlp` 动态字段；不接入 iwencai OpenAPI，不读取 API Key，不构造 `X-Claw-*` 头。
- `ThsdkProvider` 对问财调用做串行限速，避免连续 `wencai_nlp` / `wencai_base` 触发 thsdk 官方 250ms 间隔限制。
- 问财返回使用动态 schema 标记，并保留 raw；`screen_stocks` 仅包装自然语言选股结果，不把动态字段强行固定。
- `compare_stocks`、`normalize_trend`、`correlation_matrix` 基于已有 `get_quote` / `get_kline` 显式方法逐只调用，聚合子调用的 `fallback_chain` 和 warnings，避免把沪深市场混入 THS 单次批量请求。

验收：

- 问财只走 `thsdk.wencai_nlp` / `thsdk.wencai_base`，不接入 iwencai OpenAPI。
- 问财字段动态，不能硬编码 schema。
- `screen_stocks` 是业务包装，保留原始问财返回。
- thsdk 不可用时返回明确 provider 不可用错误，不要求用户配置 API Key。

### 交易日历

当前实现状态（2026-06-05）：

- 已实现 `is_trading_day(date, provider="auto")`，默认 provider 为 `timor`。
- `TimorProvider` 调用 `https://timor.tech/api/holiday/info/{date}` 判断中国节假日，返回 `date/is_trading_day/type/name/week/holiday/raw`。
- `is_trading_day` 只把普通周一至周五工作日判为 A 股交易日；周末、法定节假日和调休上班日均不直接视为 A 股开市日，避免把中国工作日口径误当交易所日历。
- 真实数据源 demo 已加入 `is_trading_day`，使用 `--date` 参数控制查询日期。

验收：

- timor 返回失败或被风控时，gateway 返回明确 provider 失败，不用本地 weekday 规则冒充交易所日历。
- 返回保留 `raw`，便于核对节假日 API 原始口径。

### Phase 7：多市场（低优先级，暂缓）

目标：把 thsdk 多市场能力纳入；当前优先级低于 A 股指数、板块、题材、财务和问财能力。

实现：

- `get_hk_quote`
- `get_us_quote`
- `get_uk_quote`
- `get_forex_quote`
- `get_future_quote`
- `get_bond_quote`
- `get_fund_quote`
- `get_option_data`
- `list_market`

Provider：

- `ThsdkProvider`

验收：

- 多市场统一返回行情 schema。
- 保留 `raw` 字段。
- 没有 fallback 的能力明确标记 provider 唯一。

## 示例

```python
gateway = StockDataGateway()

kline = gateway.get_kline("300498", interval="1m", count=260)
quote = gateway.get_quote("300498")
depth = gateway.get_depth("300498")
news = gateway.get_stock_news("300498", limit=10)
flow = gateway.get_fund_flow("300498", period="minute")
anns = gateway.get_announcements("300498", limit=20)
```

指定 provider：

```python
kline = gateway.get_kline("300498", interval="1m", provider="thsdk", fallback=False)
quote = gateway.get_quote("300498", provider="tencent")
```

查看降级链：

```python
result = gateway.get_kline("300498", interval="1m")
print(result.provider)
print(result.warnings)
print(result.fallback_chain)
```

## 测试策略

### 单元测试

- `SymbolResolver` 各种代码格式转换。
- normalizer 字段转换。
- fallback runner 成功/失败路径。
- rate limiter 是否生效。

### 集成测试

- `get_kline("300498", interval="1m")`
- `get_quote("300498")`
- `get_depth("300498")`
- `get_stock_news("300498")`
- `get_announcements("300498")`

### 降级测试

- 模拟 thsdk 超时，确认降级到 mootdx。
- 模拟东财 429，确认 provider 冷却。
- 模拟个股新闻东财失败，确认降级到韭研公社，不降级到全市场快讯。
- 模拟巨潮失败，确认 F10 fallback 有 warning。

### 数据校验

- K线字段必须包含标准 schema。
- `time` 必须可解析。
- `open/high/low/close` 必须为数值。
- `volume/amount` 必须为数值。
- quote 至少包含 `symbol/name/price`。

## 风险与约束

1. thsdk 权限可能限制某些专业数据。
2. mootdx 在海外网络可能超时。
3. 东方财富接口有风控，必须限流。
4. 问财返回字段动态，不适合强 schema。
5. 不同 provider 的复权、分钟边界、成交量单位可能不同，必须通过 warnings 暴露。
6. 图表图片是资源接口，不等同于结构化 K线。
7. 全市场快讯不能冒充个股新闻。

## 开放问题

1. 是否需要默认缓存？首版建议不做持久缓存，只保留 provider health。
2. 是否需要异步版本？首版建议同步接口，后续可加 `AsyncStockDataGateway`。
3. 是否需要 CLI？首版不需要。
4. 是否需要把所有 provider 原始数据落盘？首版仅返回 `raw`，不自动落盘。
