Skip to content

Commit a7104ac

Browse files
DragonL641EchoingFootsteps
authored andcommitted
feat: add Finnhub & AlphaVantage US market data source adapters (ZhuLinsen#1313)
* feat: add Finnhub and AlphaVantage API key config fields * feat: add FinnhubFetcher for US market OHLCV and realtime quotes * feat: add AlphaVantageFetcher for US market OHLCV and realtime quotes * feat: register FinnhubFetcher and AlphaVantageFetcher in DataFetcherManager * fix: address PR review feedback on US fetcher routing and AlphaVantage pct_chg - Fix AlphaVantage pct_chg calculation: sort by date ascending before computing percentage change to handle newest-first API response - Extend US daily routing source_order to include FinnhubFetcher and AlphaVantageFetcher in the failover chain - Add newest-first pct_chg regression test for AlphaVantage - Add US routing fallback order test - Update CHANGELOG.md with new feature and fix entries - Add docs/specs/ to .gitignore to prevent accidental commits Verified: 1956 tests passed, ci_gate.sh clean * fix: address third round PR review - routing gaps, index isolation, CHANGELOG cleanup - Isolate US index routing: YFinance-first, skip Finnhub/AlphaVantage for index codes - Add Finnhub/AlphaVantage to US realtime_quote and get_stock_name routing - Clean CHANGELOG.md: only our 3 Finnhub/AlphaVantage entries in [Unreleased]
1 parent 5192d6a commit a7104ac

10 files changed

Lines changed: 884 additions & 7 deletions

File tree

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ ANSPIRE_API_KEYS=
2424
TUSHARE_TOKEN=
2525
# TickFlow API Key(可选,用于 A 股大盘复盘指数增强;若套餐支持标的池查询,也可增强市场统计)
2626
# TICKFLOW_API_KEY=
27+
28+
# Finnhub(可选,美股数据源,免费 tier 60 calls/min)
29+
# 获取: https://finnhub.io/register
30+
# FINNHUB_API_KEY=
31+
32+
# AlphaVantage(可选,美股数据源,免费 tier 25 calls/day)
33+
# 获取: https://www.alphavantage.co/support/#api-key
34+
# ALPHAVANTAGE_API_KEY=
35+
2736
# Longbridge OpenAPI(可选,美股/港股量比、换手率、PE 等字段兜底)
2837
# 从 https://open.longbridge.com/ 获取
2938
# LONGBRIDGE_APP_KEY=

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,4 @@ verify_*.py
8484
static/
8585
/apps/dsa-desktop/dist/
8686
/apps/dsa-desktop/node_modules/
87+
docs/specs/

data_provider/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
from .baostock_fetcher import BaostockFetcher
3939
from .yfinance_fetcher import YfinanceFetcher
4040
from .longbridge_fetcher import LongbridgeFetcher
41+
from .finnhub_fetcher import FinnhubFetcher
42+
from .alphavantage_fetcher import AlphaVantageFetcher
4143
from .us_index_mapping import is_us_index_code, is_us_stock_code, get_us_index_yf_symbol, US_INDEX_MAPPING
4244

4345
__all__ = [
@@ -50,6 +52,8 @@
5052
'BaostockFetcher',
5153
'YfinanceFetcher',
5254
'LongbridgeFetcher',
55+
'FinnhubFetcher',
56+
'AlphaVantageFetcher',
5357
'is_us_index_code',
5458
'is_us_stock_code',
5559
'is_hk_stock_code',
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
AlphaVantageFetcher — US market data source (Priority 3)
4+
5+
Data source: AlphaVantage REST API
6+
Rate limit: 25 calls/day, 5 calls/min (free tier)
7+
Markets: US only
8+
"""
9+
10+
import logging
11+
import os
12+
from datetime import datetime
13+
from typing import Optional
14+
15+
import pandas as pd
16+
import requests
17+
18+
from .base import BaseFetcher, DataFetchError, STANDARD_COLUMNS
19+
from .realtime_types import UnifiedRealtimeQuote, RealtimeSource
20+
from .us_index_mapping import is_us_stock_code
21+
22+
logger = logging.getLogger(__name__)
23+
24+
_AV_BASE_URL = "https://www.alphavantage.co/query"
25+
26+
27+
class AlphaVantageFetcher(BaseFetcher):
28+
name = "AlphaVantageFetcher"
29+
priority = 3
30+
31+
def __init__(self):
32+
from src.config import get_config
33+
config = get_config()
34+
self._api_key = getattr(config, 'alphavantage_api_key', None) or os.getenv('ALPHAVANTAGE_API_KEY')
35+
if not self._api_key:
36+
logger.debug("[AlphaVantage] API key not configured, fetcher disabled")
37+
38+
def _is_us_stock(self, stock_code: str) -> bool:
39+
return is_us_stock_code(stock_code)
40+
41+
def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
42+
if not self._api_key:
43+
raise DataFetchError("[AlphaVantage] API key not configured")
44+
if not self._is_us_stock(stock_code):
45+
raise DataFetchError(f"[AlphaVantage] {stock_code} is not a US stock")
46+
47+
symbol = stock_code.strip().upper()
48+
params = {
49+
'function': 'TIME_SERIES_DAILY',
50+
'symbol': symbol,
51+
'outputsize': 'compact',
52+
'apikey': self._api_key,
53+
}
54+
55+
try:
56+
self.random_sleep(0.5, 1.5)
57+
resp = requests.get(_AV_BASE_URL, params=params, timeout=30)
58+
resp.raise_for_status()
59+
data = resp.json()
60+
except Exception as e:
61+
raise DataFetchError(f"[AlphaVantage] HTTP request failed for {symbol}: {e}") from e
62+
63+
if 'Note' in data:
64+
raise DataFetchError(f"[AlphaVantage] Rate limited: {data['Note']}")
65+
if 'Error Message' in data:
66+
raise DataFetchError(f"[AlphaVantage] API error for {symbol}: {data['Error Message']}")
67+
68+
ts_key = 'Time Series (Daily)'
69+
if ts_key not in data or not data[ts_key]:
70+
raise DataFetchError(f"[AlphaVantage] No time series data for {symbol}")
71+
72+
rows = []
73+
start = datetime.strptime(start_date, '%Y-%m-%d').date()
74+
end = datetime.strptime(end_date, '%Y-%m-%d').date()
75+
for date_str, values in data[ts_key].items():
76+
row_date = datetime.strptime(date_str, '%Y-%m-%d').date()
77+
if start <= row_date <= end:
78+
rows.append({
79+
'date': date_str,
80+
'1. open': float(values.get('1. open', 0)),
81+
'2. high': float(values.get('2. high', 0)),
82+
'3. low': float(values.get('3. low', 0)),
83+
'4. close': float(values.get('4. close', 0)),
84+
'5. volume': float(values.get('5. volume', 0)),
85+
})
86+
87+
if not rows:
88+
raise DataFetchError(f"[AlphaVantage] No data in date range for {symbol}")
89+
90+
df = pd.DataFrame(rows)
91+
df.index = pd.to_datetime(df['date'])
92+
return df.drop(columns=['date'])
93+
94+
def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
95+
if df.empty:
96+
return df
97+
98+
df = df.copy()
99+
df['date'] = pd.to_datetime(df.index).date
100+
df = df.rename(columns={
101+
'1. open': 'open', '2. high': 'high', '3. low': 'low',
102+
'4. close': 'close', '5. volume': 'volume',
103+
})
104+
# AlphaVantage returns newest-first; sort ascending before computing pct_chg
105+
df = df.sort_values('date', ascending=True).reset_index(drop=True)
106+
df['pct_chg'] = df['close'].pct_change() * 100
107+
df['pct_chg'] = df['pct_chg'].fillna(0).round(2)
108+
df['amount'] = df['volume'] * df['close']
109+
df['code'] = stock_code
110+
111+
keep = ['code'] + STANDARD_COLUMNS
112+
df = df[[col for col in keep if col in df.columns]]
113+
return df
114+
115+
def get_realtime_quote(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]:
116+
if not self._api_key or not self._is_us_stock(stock_code):
117+
return None
118+
119+
symbol = stock_code.strip().upper()
120+
try:
121+
self.random_sleep(0.5, 1.5)
122+
resp = requests.get(_AV_BASE_URL, params={
123+
'function': 'GLOBAL_QUOTE',
124+
'symbol': symbol,
125+
'apikey': self._api_key,
126+
}, timeout=15)
127+
resp.raise_for_status()
128+
data = resp.json()
129+
except Exception as e:
130+
logger.warning(f"[AlphaVantage] Realtime quote failed for {symbol}: {e}")
131+
return None
132+
133+
gq = data.get('Global Quote', {})
134+
price_str = gq.get('05. price')
135+
if not price_str:
136+
return None
137+
138+
price = float(price_str)
139+
prev_close = float(gq.get('08. previous close', 0))
140+
change_pct_str = gq.get('10. change percent', '0%').replace('%', '')
141+
change_pct = float(change_pct_str) if change_pct_str else None
142+
143+
return UnifiedRealtimeQuote(
144+
code=symbol,
145+
source=RealtimeSource.FALLBACK,
146+
price=price,
147+
change_pct=round(change_pct, 2) if change_pct is not None else None,
148+
change_amount=round(float(gq.get('09. change', 0)), 4),
149+
volume=int(float(gq.get('06. volume', 0))),
150+
amount=None,
151+
volume_ratio=None,
152+
turnover_rate=None,
153+
amplitude=None,
154+
open_price=float(gq.get('02. open', 0)),
155+
high=float(gq.get('03. high', 0)),
156+
low=float(gq.get('04. low', 0)),
157+
pre_close=prev_close,
158+
)
159+
160+
def get_stock_name(self, stock_code: str) -> Optional[str]:
161+
if not self._api_key or not self._is_us_stock(stock_code):
162+
return None
163+
164+
symbol = stock_code.strip().upper()
165+
try:
166+
resp = requests.get(_AV_BASE_URL, params={
167+
'function': 'SYMBOL_SEARCH',
168+
'keywords': symbol,
169+
'apikey': self._api_key,
170+
}, timeout=10)
171+
resp.raise_for_status()
172+
data = resp.json()
173+
except Exception as e:
174+
logger.debug(f"[AlphaVantage] Symbol search failed for {symbol}: {e}")
175+
return None
176+
177+
for match in data.get('bestMatches', []):
178+
if match.get('1. symbol') == symbol and match.get('2. name'):
179+
return match['2. name']
180+
return None

data_provider/base.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,8 @@ class DataFetcherManager:
523523
"BaostockFetcher": {"cn"},
524524
"YfinanceFetcher": {"cn", "hk", "us"},
525525
"LongbridgeFetcher": {"hk", "us"},
526+
"FinnhubFetcher": {"us"},
527+
"AlphaVantageFetcher": {"us"},
526528
}
527529

528530
def __init__(self, fetchers: Optional[List[BaseFetcher]] = None):
@@ -1032,6 +1034,20 @@ def _init_default_fetchers(self) -> None:
10321034
else:
10331035
logger.debug("[数据源初始化] 跳过未配置的 LongbridgeFetcher")
10341036

1037+
finnhub_api_key = (getattr(config, "finnhub_api_key", None) or "").strip()
1038+
if finnhub_api_key:
1039+
from .finnhub_fetcher import FinnhubFetcher
1040+
optional_fetchers.append(FinnhubFetcher())
1041+
else:
1042+
logger.debug("[数据源初始化] 跳过未配置的 FinnhubFetcher")
1043+
1044+
alphavantage_api_key = (getattr(config, "alphavantage_api_key", None) or "").strip()
1045+
if alphavantage_api_key:
1046+
from .alphavantage_fetcher import AlphaVantageFetcher
1047+
optional_fetchers.append(AlphaVantageFetcher())
1048+
else:
1049+
logger.debug("[数据源初始化] 跳过未配置的 AlphaVantageFetcher")
1050+
10351051
# 初始化数据源列表
10361052
self._ensure_concurrency_guards()
10371053
with self._fetchers_lock:
@@ -1116,14 +1132,18 @@ def get_daily_data(
11161132
logger.error(f"[数据源终止] {stock_code} 获取失败: {error_summary}")
11171133
raise DataFetchError(error_summary)
11181134

1119-
# 美股(含美股指数)使用 Longbridge/YFinance 特殊路由;港股走下方通用数据源循环
1135+
# 美股(含美股指数)使用专用路由;港股走下方通用数据源循环
1136+
# Failover chain: Finnhub(P2) -> AlphaVantage(P3) -> Yfinance(P4) -> Longbridge(P5)
1137+
# When Longbridge preferred: Longbridge -> Finnhub -> AlphaVantage -> Yfinance
11201138
if is_us:
11211139
prefer_lb = self._longbridge_preferred(capability="daily_data") and not is_us_index
1122-
source_order = (
1123-
["LongbridgeFetcher", "YfinanceFetcher"]
1124-
if prefer_lb
1125-
else ["YfinanceFetcher", "LongbridgeFetcher"]
1126-
)
1140+
if is_us_index:
1141+
# 指数始终 YFinance 首选(Longbridge 不提供指数K线)
1142+
source_order = ["YfinanceFetcher", "FinnhubFetcher"]
1143+
elif prefer_lb:
1144+
source_order = ["LongbridgeFetcher", "FinnhubFetcher", "AlphaVantageFetcher", "YfinanceFetcher"]
1145+
else:
1146+
source_order = ["FinnhubFetcher", "AlphaVantageFetcher", "YfinanceFetcher", "LongbridgeFetcher"]
11271147
market_label = "美股指数" if is_us_index else "美股"
11281148

11291149
for src_name in source_order:
@@ -1358,6 +1378,12 @@ def get_realtime_quote(self, stock_code: str, *, log_final_failure: bool = True)
13581378
primary_quote = self._supplement_quote(
13591379
stock_code, primary_quote, secondary_src, **secondary_kw,
13601380
)
1381+
# 美股个股(非指数)尝试从 Finnhub/AlphaVantage 补充缺失字段
1382+
if is_us and not is_us_index and primary_quote is not None:
1383+
for extra_src in ["FinnhubFetcher", "AlphaVantageFetcher"]:
1384+
primary_quote = self._supplement_quote(
1385+
stock_code, primary_quote, extra_src,
1386+
)
13611387
if primary_quote is not None:
13621388
return primary_quote
13631389
if log_final_failure:
@@ -1640,7 +1666,7 @@ def get_stock_name(self, stock_code: str, allow_realtime: bool = True) -> Option
16401666
# 3. 依次尝试各个数据源
16411667
from .akshare_fetcher import _is_us_code
16421668
is_us = _is_us_code(stock_code)
1643-
_US_CAPABLE_FETCHERS = {"YfinanceFetcher", "LongbridgeFetcher"}
1669+
_US_CAPABLE_FETCHERS = {"YfinanceFetcher", "LongbridgeFetcher", "FinnhubFetcher", "AlphaVantageFetcher"}
16441670
for fetcher in self._get_fetchers_snapshot():
16451671
if not hasattr(fetcher, 'get_stock_name'):
16461672
continue

0 commit comments

Comments
 (0)