mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
feat: add Finnhub & AlphaVantage US market data source adapters (#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]
This commit is contained in:
@@ -24,6 +24,15 @@ ANSPIRE_API_KEYS=
|
||||
TUSHARE_TOKEN=
|
||||
# TickFlow API Key(可选,用于 A 股大盘复盘指数增强;若套餐支持标的池查询,也可增强市场统计)
|
||||
# TICKFLOW_API_KEY=
|
||||
|
||||
# Finnhub(可选,美股数据源,免费 tier 60 calls/min)
|
||||
# 获取: https://finnhub.io/register
|
||||
# FINNHUB_API_KEY=
|
||||
|
||||
# AlphaVantage(可选,美股数据源,免费 tier 25 calls/day)
|
||||
# 获取: https://www.alphavantage.co/support/#api-key
|
||||
# ALPHAVANTAGE_API_KEY=
|
||||
|
||||
# Longbridge OpenAPI(可选,美股/港股量比、换手率、PE 等字段兜底)
|
||||
# 从 https://open.longbridge.com/ 获取
|
||||
# LONGBRIDGE_APP_KEY=
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -84,3 +84,4 @@ verify_*.py
|
||||
static/
|
||||
/apps/dsa-desktop/dist/
|
||||
/apps/dsa-desktop/node_modules/
|
||||
docs/specs/
|
||||
|
||||
@@ -38,6 +38,8 @@ from .pytdx_fetcher import PytdxFetcher
|
||||
from .baostock_fetcher import BaostockFetcher
|
||||
from .yfinance_fetcher import YfinanceFetcher
|
||||
from .longbridge_fetcher import LongbridgeFetcher
|
||||
from .finnhub_fetcher import FinnhubFetcher
|
||||
from .alphavantage_fetcher import AlphaVantageFetcher
|
||||
from .us_index_mapping import is_us_index_code, is_us_stock_code, get_us_index_yf_symbol, US_INDEX_MAPPING
|
||||
|
||||
__all__ = [
|
||||
@@ -50,6 +52,8 @@ __all__ = [
|
||||
'BaostockFetcher',
|
||||
'YfinanceFetcher',
|
||||
'LongbridgeFetcher',
|
||||
'FinnhubFetcher',
|
||||
'AlphaVantageFetcher',
|
||||
'is_us_index_code',
|
||||
'is_us_stock_code',
|
||||
'is_hk_stock_code',
|
||||
|
||||
180
data_provider/alphavantage_fetcher.py
Normal file
180
data_provider/alphavantage_fetcher.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AlphaVantageFetcher — US market data source (Priority 3)
|
||||
|
||||
Data source: AlphaVantage REST API
|
||||
Rate limit: 25 calls/day, 5 calls/min (free tier)
|
||||
Markets: US only
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
from .base import BaseFetcher, DataFetchError, STANDARD_COLUMNS
|
||||
from .realtime_types import UnifiedRealtimeQuote, RealtimeSource
|
||||
from .us_index_mapping import is_us_stock_code
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AV_BASE_URL = "https://www.alphavantage.co/query"
|
||||
|
||||
|
||||
class AlphaVantageFetcher(BaseFetcher):
|
||||
name = "AlphaVantageFetcher"
|
||||
priority = 3
|
||||
|
||||
def __init__(self):
|
||||
from src.config import get_config
|
||||
config = get_config()
|
||||
self._api_key = getattr(config, 'alphavantage_api_key', None) or os.getenv('ALPHAVANTAGE_API_KEY')
|
||||
if not self._api_key:
|
||||
logger.debug("[AlphaVantage] API key not configured, fetcher disabled")
|
||||
|
||||
def _is_us_stock(self, stock_code: str) -> bool:
|
||||
return is_us_stock_code(stock_code)
|
||||
|
||||
def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
|
||||
if not self._api_key:
|
||||
raise DataFetchError("[AlphaVantage] API key not configured")
|
||||
if not self._is_us_stock(stock_code):
|
||||
raise DataFetchError(f"[AlphaVantage] {stock_code} is not a US stock")
|
||||
|
||||
symbol = stock_code.strip().upper()
|
||||
params = {
|
||||
'function': 'TIME_SERIES_DAILY',
|
||||
'symbol': symbol,
|
||||
'outputsize': 'compact',
|
||||
'apikey': self._api_key,
|
||||
}
|
||||
|
||||
try:
|
||||
self.random_sleep(0.5, 1.5)
|
||||
resp = requests.get(_AV_BASE_URL, params=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
raise DataFetchError(f"[AlphaVantage] HTTP request failed for {symbol}: {e}") from e
|
||||
|
||||
if 'Note' in data:
|
||||
raise DataFetchError(f"[AlphaVantage] Rate limited: {data['Note']}")
|
||||
if 'Error Message' in data:
|
||||
raise DataFetchError(f"[AlphaVantage] API error for {symbol}: {data['Error Message']}")
|
||||
|
||||
ts_key = 'Time Series (Daily)'
|
||||
if ts_key not in data or not data[ts_key]:
|
||||
raise DataFetchError(f"[AlphaVantage] No time series data for {symbol}")
|
||||
|
||||
rows = []
|
||||
start = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
for date_str, values in data[ts_key].items():
|
||||
row_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
if start <= row_date <= end:
|
||||
rows.append({
|
||||
'date': date_str,
|
||||
'1. open': float(values.get('1. open', 0)),
|
||||
'2. high': float(values.get('2. high', 0)),
|
||||
'3. low': float(values.get('3. low', 0)),
|
||||
'4. close': float(values.get('4. close', 0)),
|
||||
'5. volume': float(values.get('5. volume', 0)),
|
||||
})
|
||||
|
||||
if not rows:
|
||||
raise DataFetchError(f"[AlphaVantage] No data in date range for {symbol}")
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
df.index = pd.to_datetime(df['date'])
|
||||
return df.drop(columns=['date'])
|
||||
|
||||
def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
df = df.copy()
|
||||
df['date'] = pd.to_datetime(df.index).date
|
||||
df = df.rename(columns={
|
||||
'1. open': 'open', '2. high': 'high', '3. low': 'low',
|
||||
'4. close': 'close', '5. volume': 'volume',
|
||||
})
|
||||
# AlphaVantage returns newest-first; sort ascending before computing pct_chg
|
||||
df = df.sort_values('date', ascending=True).reset_index(drop=True)
|
||||
df['pct_chg'] = df['close'].pct_change() * 100
|
||||
df['pct_chg'] = df['pct_chg'].fillna(0).round(2)
|
||||
df['amount'] = df['volume'] * df['close']
|
||||
df['code'] = stock_code
|
||||
|
||||
keep = ['code'] + STANDARD_COLUMNS
|
||||
df = df[[col for col in keep if col in df.columns]]
|
||||
return df
|
||||
|
||||
def get_realtime_quote(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]:
|
||||
if not self._api_key or not self._is_us_stock(stock_code):
|
||||
return None
|
||||
|
||||
symbol = stock_code.strip().upper()
|
||||
try:
|
||||
self.random_sleep(0.5, 1.5)
|
||||
resp = requests.get(_AV_BASE_URL, params={
|
||||
'function': 'GLOBAL_QUOTE',
|
||||
'symbol': symbol,
|
||||
'apikey': self._api_key,
|
||||
}, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"[AlphaVantage] Realtime quote failed for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
gq = data.get('Global Quote', {})
|
||||
price_str = gq.get('05. price')
|
||||
if not price_str:
|
||||
return None
|
||||
|
||||
price = float(price_str)
|
||||
prev_close = float(gq.get('08. previous close', 0))
|
||||
change_pct_str = gq.get('10. change percent', '0%').replace('%', '')
|
||||
change_pct = float(change_pct_str) if change_pct_str else None
|
||||
|
||||
return UnifiedRealtimeQuote(
|
||||
code=symbol,
|
||||
source=RealtimeSource.FALLBACK,
|
||||
price=price,
|
||||
change_pct=round(change_pct, 2) if change_pct is not None else None,
|
||||
change_amount=round(float(gq.get('09. change', 0)), 4),
|
||||
volume=int(float(gq.get('06. volume', 0))),
|
||||
amount=None,
|
||||
volume_ratio=None,
|
||||
turnover_rate=None,
|
||||
amplitude=None,
|
||||
open_price=float(gq.get('02. open', 0)),
|
||||
high=float(gq.get('03. high', 0)),
|
||||
low=float(gq.get('04. low', 0)),
|
||||
pre_close=prev_close,
|
||||
)
|
||||
|
||||
def get_stock_name(self, stock_code: str) -> Optional[str]:
|
||||
if not self._api_key or not self._is_us_stock(stock_code):
|
||||
return None
|
||||
|
||||
symbol = stock_code.strip().upper()
|
||||
try:
|
||||
resp = requests.get(_AV_BASE_URL, params={
|
||||
'function': 'SYMBOL_SEARCH',
|
||||
'keywords': symbol,
|
||||
'apikey': self._api_key,
|
||||
}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.debug(f"[AlphaVantage] Symbol search failed for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
for match in data.get('bestMatches', []):
|
||||
if match.get('1. symbol') == symbol and match.get('2. name'):
|
||||
return match['2. name']
|
||||
return None
|
||||
@@ -523,6 +523,8 @@ class DataFetcherManager:
|
||||
"BaostockFetcher": {"cn"},
|
||||
"YfinanceFetcher": {"cn", "hk", "us"},
|
||||
"LongbridgeFetcher": {"hk", "us"},
|
||||
"FinnhubFetcher": {"us"},
|
||||
"AlphaVantageFetcher": {"us"},
|
||||
}
|
||||
|
||||
def __init__(self, fetchers: Optional[List[BaseFetcher]] = None):
|
||||
@@ -1032,6 +1034,20 @@ class DataFetcherManager:
|
||||
else:
|
||||
logger.debug("[数据源初始化] 跳过未配置的 LongbridgeFetcher")
|
||||
|
||||
finnhub_api_key = (getattr(config, "finnhub_api_key", None) or "").strip()
|
||||
if finnhub_api_key:
|
||||
from .finnhub_fetcher import FinnhubFetcher
|
||||
optional_fetchers.append(FinnhubFetcher())
|
||||
else:
|
||||
logger.debug("[数据源初始化] 跳过未配置的 FinnhubFetcher")
|
||||
|
||||
alphavantage_api_key = (getattr(config, "alphavantage_api_key", None) or "").strip()
|
||||
if alphavantage_api_key:
|
||||
from .alphavantage_fetcher import AlphaVantageFetcher
|
||||
optional_fetchers.append(AlphaVantageFetcher())
|
||||
else:
|
||||
logger.debug("[数据源初始化] 跳过未配置的 AlphaVantageFetcher")
|
||||
|
||||
# 初始化数据源列表
|
||||
self._ensure_concurrency_guards()
|
||||
with self._fetchers_lock:
|
||||
@@ -1116,14 +1132,18 @@ class DataFetcherManager:
|
||||
logger.error(f"[数据源终止] {stock_code} 获取失败: {error_summary}")
|
||||
raise DataFetchError(error_summary)
|
||||
|
||||
# 美股(含美股指数)使用 Longbridge/YFinance 特殊路由;港股走下方通用数据源循环
|
||||
# 美股(含美股指数)使用专用路由;港股走下方通用数据源循环
|
||||
# Failover chain: Finnhub(P2) -> AlphaVantage(P3) -> Yfinance(P4) -> Longbridge(P5)
|
||||
# When Longbridge preferred: Longbridge -> Finnhub -> AlphaVantage -> Yfinance
|
||||
if is_us:
|
||||
prefer_lb = self._longbridge_preferred(capability="daily_data") and not is_us_index
|
||||
source_order = (
|
||||
["LongbridgeFetcher", "YfinanceFetcher"]
|
||||
if prefer_lb
|
||||
else ["YfinanceFetcher", "LongbridgeFetcher"]
|
||||
)
|
||||
if is_us_index:
|
||||
# 指数始终 YFinance 首选(Longbridge 不提供指数K线)
|
||||
source_order = ["YfinanceFetcher", "FinnhubFetcher"]
|
||||
elif prefer_lb:
|
||||
source_order = ["LongbridgeFetcher", "FinnhubFetcher", "AlphaVantageFetcher", "YfinanceFetcher"]
|
||||
else:
|
||||
source_order = ["FinnhubFetcher", "AlphaVantageFetcher", "YfinanceFetcher", "LongbridgeFetcher"]
|
||||
market_label = "美股指数" if is_us_index else "美股"
|
||||
|
||||
for src_name in source_order:
|
||||
@@ -1358,6 +1378,12 @@ class DataFetcherManager:
|
||||
primary_quote = self._supplement_quote(
|
||||
stock_code, primary_quote, secondary_src, **secondary_kw,
|
||||
)
|
||||
# 美股个股(非指数)尝试从 Finnhub/AlphaVantage 补充缺失字段
|
||||
if is_us and not is_us_index and primary_quote is not None:
|
||||
for extra_src in ["FinnhubFetcher", "AlphaVantageFetcher"]:
|
||||
primary_quote = self._supplement_quote(
|
||||
stock_code, primary_quote, extra_src,
|
||||
)
|
||||
if primary_quote is not None:
|
||||
return primary_quote
|
||||
if log_final_failure:
|
||||
@@ -1640,7 +1666,7 @@ class DataFetcherManager:
|
||||
# 3. 依次尝试各个数据源
|
||||
from .akshare_fetcher import _is_us_code
|
||||
is_us = _is_us_code(stock_code)
|
||||
_US_CAPABLE_FETCHERS = {"YfinanceFetcher", "LongbridgeFetcher"}
|
||||
_US_CAPABLE_FETCHERS = {"YfinanceFetcher", "LongbridgeFetcher", "FinnhubFetcher", "AlphaVantageFetcher"}
|
||||
for fetcher in self._get_fetchers_snapshot():
|
||||
if not hasattr(fetcher, 'get_stock_name'):
|
||||
continue
|
||||
|
||||
169
data_provider/finnhub_fetcher.py
Normal file
169
data_provider/finnhub_fetcher.py
Normal file
@@ -0,0 +1,169 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
FinnhubFetcher — US market data source (Priority 2)
|
||||
|
||||
Data source: Finnhub.io REST API
|
||||
Rate limit: 60 calls/min (free tier)
|
||||
Markets: US only
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
from .base import BaseFetcher, DataFetchError, STANDARD_COLUMNS
|
||||
from .realtime_types import UnifiedRealtimeQuote, RealtimeSource
|
||||
from .us_index_mapping import is_us_stock_code
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FINNHUB_BASE_URL = "https://finnhub.io/api/v1"
|
||||
|
||||
|
||||
class FinnhubFetcher(BaseFetcher):
|
||||
name = "FinnhubFetcher"
|
||||
priority = 2
|
||||
|
||||
def __init__(self):
|
||||
from src.config import get_config
|
||||
config = get_config()
|
||||
self._api_key = getattr(config, 'finnhub_api_key', None) or os.getenv('FINNHUB_API_KEY')
|
||||
if not self._api_key:
|
||||
logger.debug("[Finnhub] API key not configured, fetcher disabled")
|
||||
|
||||
def _is_us_stock(self, stock_code: str) -> bool:
|
||||
return is_us_stock_code(stock_code)
|
||||
|
||||
def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
|
||||
if not self._api_key:
|
||||
raise DataFetchError("[Finnhub] API key not configured")
|
||||
if not self._is_us_stock(stock_code):
|
||||
raise DataFetchError(f"[Finnhub] {stock_code} is not a US stock")
|
||||
|
||||
symbol = stock_code.strip().upper()
|
||||
start_ts = int(datetime.strptime(start_date, '%Y-%m-%d').timestamp())
|
||||
end_ts = int(datetime.strptime(end_date, '%Y-%m-%d').timestamp())
|
||||
|
||||
url = f"{_FINNHUB_BASE_URL}/stock/candle"
|
||||
params = {
|
||||
'symbol': symbol,
|
||||
'resolution': 'D',
|
||||
'from': start_ts,
|
||||
'to': end_ts,
|
||||
'token': self._api_key,
|
||||
}
|
||||
|
||||
try:
|
||||
self.random_sleep(0.3, 0.8)
|
||||
resp = requests.get(url, params=params, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
raise DataFetchError(f"[Finnhub] HTTP request failed for {symbol}: {e}") from e
|
||||
|
||||
if data.get('s') != 'ok' or not data.get('c'):
|
||||
raise DataFetchError(f"[Finnhub] No data returned for {symbol}")
|
||||
|
||||
return pd.DataFrame({
|
||||
'c': data['c'],
|
||||
'h': data['h'],
|
||||
'l': data['l'],
|
||||
'o': data['o'],
|
||||
't': data['t'],
|
||||
'v': data['v'],
|
||||
})
|
||||
|
||||
def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
df = df.copy()
|
||||
df['date'] = pd.to_datetime(df['t'], unit='s').dt.date
|
||||
df = df.rename(columns={
|
||||
'o': 'open', 'h': 'high', 'l': 'low',
|
||||
'c': 'close', 'v': 'volume',
|
||||
})
|
||||
df['pct_chg'] = df['close'].pct_change() * 100
|
||||
df['pct_chg'] = df['pct_chg'].fillna(0).round(2)
|
||||
df['amount'] = df['volume'] * df['close']
|
||||
df['code'] = stock_code
|
||||
|
||||
keep = ['code'] + STANDARD_COLUMNS
|
||||
df = df[[col for col in keep if col in df.columns]]
|
||||
return df
|
||||
|
||||
def get_realtime_quote(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]:
|
||||
if not self._api_key or not self._is_us_stock(stock_code):
|
||||
return None
|
||||
|
||||
symbol = stock_code.strip().upper()
|
||||
try:
|
||||
self.random_sleep(0.3, 0.8)
|
||||
resp = requests.get(
|
||||
f"{_FINNHUB_BASE_URL}/quote",
|
||||
params={'symbol': symbol, 'token': self._api_key},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"[Finnhub] Realtime quote failed for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
price = data.get('c')
|
||||
if not price:
|
||||
return None
|
||||
|
||||
prev_close = data.get('pc', 0)
|
||||
change_pct = data.get('dp')
|
||||
change_amount = data.get('d')
|
||||
high = data.get('h')
|
||||
low = data.get('l')
|
||||
open_price = data.get('o')
|
||||
|
||||
amplitude = None
|
||||
if high and low and prev_close and prev_close > 0:
|
||||
amplitude = round((high - low) / prev_close * 100, 2)
|
||||
|
||||
return UnifiedRealtimeQuote(
|
||||
code=symbol,
|
||||
source=RealtimeSource.FALLBACK,
|
||||
price=price,
|
||||
change_pct=round(change_pct, 2) if change_pct is not None else None,
|
||||
change_amount=round(change_amount, 4) if change_amount is not None else None,
|
||||
volume=data.get('v'),
|
||||
amount=None,
|
||||
volume_ratio=None,
|
||||
turnover_rate=None,
|
||||
amplitude=amplitude,
|
||||
open_price=open_price,
|
||||
high=high,
|
||||
low=low,
|
||||
pre_close=prev_close,
|
||||
)
|
||||
|
||||
def get_stock_name(self, stock_code: str) -> Optional[str]:
|
||||
if not self._api_key or not self._is_us_stock(stock_code):
|
||||
return None
|
||||
|
||||
symbol = stock_code.strip().upper()
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{_FINNHUB_BASE_URL}/search",
|
||||
params={'q': symbol, 'token': self._api_key},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.debug(f"[Finnhub] Symbol search failed for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
for item in data.get('result', []):
|
||||
if item.get('symbol') == symbol and item.get('description'):
|
||||
return item['description']
|
||||
return None
|
||||
@@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [改进] 告警中心 P2 新增后台评估 worker,schedule 模式可同时评估持久化 active rules 与 legacy JSON 规则,并记录 `triggered` / `skipped` / `degraded` / `failed` 最小评估历史。
|
||||
- [修复] 统一 Windows 桌面安装包与自动更新元数据文件名,避免 Release 中出现重复安装包并阻断 `latest.yml` 指向不存在附件。
|
||||
- [修复] 桌面端启动 WebUI 时为入口页增加 no-cache 响应头和版本化 cache-busting URL,避免安装新版后 Electron 继续复用旧 WebUI 缓存。
|
||||
- [新功能] 新增 Finnhub / AlphaVantage 美股数据源适配器,扩展美股日线 failover 链至 Finnhub(P2) -> AlphaVantage(P3) -> Yfinance(P4) -> Longbridge(P5)。
|
||||
- [修复] AlphaVantage 适配器在 newest-first 原始数据下 pct_chg 计算错误:改为先按日期升序排序再计算涨跌幅。
|
||||
- [修复] 美股日线路由未包含 Finnhub / AlphaVantage:扩展 `get_daily_data()` 美股分支的 source_order 以覆盖新增数据源。
|
||||
|
||||
## [3.17.1] - 2026-05-16
|
||||
|
||||
|
||||
@@ -531,6 +531,8 @@ class Config:
|
||||
# === 数据源 API Token ===
|
||||
tushare_token: Optional[str] = None
|
||||
tickflow_api_key: Optional[str] = None
|
||||
finnhub_api_key: Optional[str] = None
|
||||
alphavantage_api_key: Optional[str] = None
|
||||
longbridge_app_key: Optional[str] = None
|
||||
longbridge_app_secret: Optional[str] = None
|
||||
longbridge_access_token: Optional[str] = None
|
||||
@@ -1289,6 +1291,8 @@ class Config:
|
||||
feishu_folder_token=os.getenv('FEISHU_FOLDER_TOKEN'),
|
||||
tushare_token=os.getenv('TUSHARE_TOKEN'),
|
||||
tickflow_api_key=os.getenv('TICKFLOW_API_KEY'),
|
||||
finnhub_api_key=os.getenv('FINNHUB_API_KEY') or None,
|
||||
alphavantage_api_key=os.getenv('ALPHAVANTAGE_API_KEY') or None,
|
||||
longbridge_app_key=os.getenv('LONGBRIDGE_APP_KEY') or None,
|
||||
longbridge_app_secret=os.getenv('LONGBRIDGE_APP_SECRET') or None,
|
||||
longbridge_access_token=os.getenv('LONGBRIDGE_ACCESS_TOKEN') or None,
|
||||
|
||||
222
tests/test_alphavantage_fetcher.py
Normal file
222
tests/test_alphavantage_fetcher.py
Normal file
@@ -0,0 +1,222 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AlphaVantageFetcher offline unit tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
|
||||
def _make_mock_response(json_data, status_code=200):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = json_data
|
||||
resp.raise_for_status.return_value = None
|
||||
return resp
|
||||
|
||||
|
||||
class TestAlphaVantageFetcherNormalize(unittest.TestCase):
|
||||
"""Test _normalize_data with raw AlphaVantage TIME_SERIES_DAILY response."""
|
||||
|
||||
def setUp(self):
|
||||
from data_provider.alphavantage_fetcher import AlphaVantageFetcher
|
||||
self.fetcher = AlphaVantageFetcher()
|
||||
|
||||
def test_normalize_daily_data(self):
|
||||
import pandas as pd
|
||||
raw = pd.DataFrame({
|
||||
'1. open': [149.5, 151.0],
|
||||
'2. high': [151.0, 153.0],
|
||||
'3. low': [149.0, 150.0],
|
||||
'4. close': [150.0, 152.0],
|
||||
'5. volume': [1000000, 1200000],
|
||||
}, index=pd.to_datetime(['2024-06-10', '2024-06-11']))
|
||||
result = self.fetcher._normalize_data(raw, 'AAPL')
|
||||
self.assertIn('date', result.columns)
|
||||
self.assertIn('close', result.columns)
|
||||
self.assertAlmostEqual(result.iloc[0]['close'], 150.0)
|
||||
self.assertEqual(result.iloc[0]['code'], 'AAPL')
|
||||
|
||||
def test_normalize_calculates_pct_chg(self):
|
||||
import pandas as pd
|
||||
raw = pd.DataFrame({
|
||||
'1. open': [149.5, 152.0],
|
||||
'2. high': [151.0, 154.0],
|
||||
'3. low': [149.0, 152.0],
|
||||
'4. close': [150.0, 153.0],
|
||||
'5. volume': [1000000, 1200000],
|
||||
}, index=pd.to_datetime(['2024-06-10', '2024-06-11']))
|
||||
result = self.fetcher._normalize_data(raw, 'AAPL')
|
||||
self.assertAlmostEqual(result.iloc[1]['pct_chg'], 2.0)
|
||||
|
||||
def test_normalize_empty_df(self):
|
||||
import pandas as pd
|
||||
raw = pd.DataFrame()
|
||||
result = self.fetcher._normalize_data(raw, 'AAPL')
|
||||
self.assertTrue(result.empty)
|
||||
|
||||
|
||||
class TestAlphaVantageFetcherFetchRaw(unittest.TestCase):
|
||||
"""Test _fetch_raw_data with mocked HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
from data_provider.alphavantage_fetcher import AlphaVantageFetcher
|
||||
self.fetcher = AlphaVantageFetcher()
|
||||
self.fetcher._api_key = "test_key"
|
||||
|
||||
@patch('data_provider.alphavantage_fetcher.requests.get')
|
||||
def test_fetch_raw_success(self, mock_get):
|
||||
mock_get.return_value = _make_mock_response({
|
||||
'Time Series (Daily)': {
|
||||
'2024-06-11': {
|
||||
'1. open': '151.0', '2. high': '153.0',
|
||||
'3. low': '150.0', '4. close': '152.0',
|
||||
'5. volume': '1200000',
|
||||
},
|
||||
'2024-06-10': {
|
||||
'1. open': '149.5', '2. high': '151.0',
|
||||
'3. low': '149.0', '4. close': '150.0',
|
||||
'5. volume': '1000000',
|
||||
},
|
||||
},
|
||||
})
|
||||
df = self.fetcher._fetch_raw_data('AAPL', '2024-06-10', '2024-06-11')
|
||||
self.assertFalse(df.empty)
|
||||
self.assertIn('4. close', df.columns)
|
||||
|
||||
@patch('data_provider.alphavantage_fetcher.requests.get')
|
||||
def test_fetch_raw_rate_limit(self, mock_get):
|
||||
from data_provider.base import DataFetchError
|
||||
mock_get.return_value = _make_mock_response({
|
||||
'Note': 'Thank you for using Alpha Vantage! Our standard API call frequency is 25 calls per day.',
|
||||
})
|
||||
with self.assertRaises(DataFetchError):
|
||||
self.fetcher._fetch_raw_data('AAPL', '2024-06-10', '2024-06-11')
|
||||
|
||||
@patch('data_provider.alphavantage_fetcher.requests.get')
|
||||
def test_fetch_raw_error_response(self, mock_get):
|
||||
from data_provider.base import DataFetchError
|
||||
mock_get.return_value = _make_mock_response({
|
||||
'Error Message': 'Invalid API call.',
|
||||
})
|
||||
with self.assertRaises(DataFetchError):
|
||||
self.fetcher._fetch_raw_data('INVALID', '2024-06-10', '2024-06-11')
|
||||
|
||||
@patch('data_provider.alphavantage_fetcher.requests.get')
|
||||
def test_fetch_raw_http_error(self, mock_get):
|
||||
from data_provider.base import DataFetchError
|
||||
mock_get.side_effect = Exception("connection timeout")
|
||||
with self.assertRaises(DataFetchError):
|
||||
self.fetcher._fetch_raw_data('AAPL', '2024-06-10', '2024-06-11')
|
||||
|
||||
|
||||
class TestAlphaVantageFetcherRealtimeQuote(unittest.TestCase):
|
||||
"""Test get_realtime_quote with mocked HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
from data_provider.alphavantage_fetcher import AlphaVantageFetcher
|
||||
self.fetcher = AlphaVantageFetcher()
|
||||
self.fetcher._api_key = "test_key"
|
||||
|
||||
@patch('data_provider.alphavantage_fetcher.requests.get')
|
||||
def test_realtime_quote_us_stock(self, mock_get):
|
||||
mock_get.return_value = _make_mock_response({
|
||||
'Global Quote': {
|
||||
'01. symbol': 'AAPL',
|
||||
'02. open': '149.0',
|
||||
'03. high': '151.0',
|
||||
'04. low': '148.0',
|
||||
'05. price': '150.0',
|
||||
'06. volume': '5000000',
|
||||
'08. previous close': '148.0',
|
||||
'09. change': '2.0',
|
||||
'10. change percent': '1.3514%',
|
||||
},
|
||||
})
|
||||
quote = self.fetcher.get_realtime_quote('AAPL')
|
||||
self.assertIsNotNone(quote)
|
||||
self.assertEqual(quote.code, 'AAPL')
|
||||
self.assertAlmostEqual(quote.price, 150.0)
|
||||
|
||||
def test_realtime_quote_non_us_stock(self):
|
||||
quote = self.fetcher.get_realtime_quote('600519')
|
||||
self.assertIsNone(quote)
|
||||
|
||||
|
||||
class TestAlphaVantageFetcherStockName(unittest.TestCase):
|
||||
"""Test get_stock_name with mocked HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
from data_provider.alphavantage_fetcher import AlphaVantageFetcher
|
||||
self.fetcher = AlphaVantageFetcher()
|
||||
self.fetcher._api_key = "test_key"
|
||||
|
||||
@patch('data_provider.alphavantage_fetcher.requests.get')
|
||||
def test_get_stock_name_found(self, mock_get):
|
||||
mock_get.return_value = _make_mock_response({
|
||||
'bestMatches': [
|
||||
{'1. symbol': 'AAPL', '2. name': 'Apple Inc', '3. type': 'Equity', '4. region': 'United States'},
|
||||
],
|
||||
})
|
||||
name = self.fetcher.get_stock_name('AAPL')
|
||||
self.assertEqual(name, 'Apple Inc')
|
||||
|
||||
@patch('data_provider.alphavantage_fetcher.requests.get')
|
||||
def test_get_stock_name_empty(self, mock_get):
|
||||
mock_get.return_value = _make_mock_response({'bestMatches': []})
|
||||
name = self.fetcher.get_stock_name('NOTEXIST')
|
||||
self.assertIsNone(name)
|
||||
|
||||
|
||||
class TestAlphaVantageFetcherInit(unittest.TestCase):
|
||||
"""Test constructor / key handling."""
|
||||
|
||||
@patch('src.config.get_config')
|
||||
def test_init_with_key(self, mock_config):
|
||||
mock_config.return_value = MagicMock(alphavantage_api_key='AVTEST123')
|
||||
from data_provider.alphavantage_fetcher import AlphaVantageFetcher
|
||||
f = AlphaVantageFetcher()
|
||||
self.assertEqual(f._api_key, 'AVTEST123')
|
||||
|
||||
@patch.dict(os.environ, {}, clear=False)
|
||||
@patch('src.config.get_config')
|
||||
def test_init_without_key(self, mock_config):
|
||||
os.environ.pop('ALPHAVANTAGE_API_KEY', None)
|
||||
mock_config.return_value = MagicMock(alphavantage_api_key=None)
|
||||
from data_provider.alphavantage_fetcher import AlphaVantageFetcher
|
||||
f = AlphaVantageFetcher()
|
||||
self.assertIsNone(f._api_key)
|
||||
|
||||
|
||||
class TestAlphaVantageFetcherNewestFirst(unittest.TestCase):
|
||||
"""Verify pct_chg is correct when API returns newest-first data."""
|
||||
|
||||
def setUp(self):
|
||||
from data_provider.alphavantage_fetcher import AlphaVantageFetcher
|
||||
self.fetcher = AlphaVantageFetcher()
|
||||
|
||||
def test_pct_chg_correct_newest_first(self):
|
||||
"""AlphaVantage returns newest date first; pct_chg must still be correct."""
|
||||
import pandas as pd
|
||||
# Simulate newest-first raw data: 2024-06-12 (close=156) before 2024-06-10 (close=150)
|
||||
raw = pd.DataFrame({
|
||||
'1. open': [155.0, 152.0, 149.5],
|
||||
'2. high': [157.0, 154.0, 151.0],
|
||||
'3. low': [154.0, 151.0, 149.0],
|
||||
'4. close': [156.0, 153.0, 150.0],
|
||||
'5. volume': [1100000, 1200000, 1000000],
|
||||
}, index=pd.to_datetime(['2024-06-12', '2024-06-11', '2024-06-10']))
|
||||
result = self.fetcher._normalize_data(raw, 'AAPL')
|
||||
|
||||
# After sorting ascending: row0=2024-06-10(150), row1=2024-06-11(153), row2=2024-06-12(156)
|
||||
self.assertAlmostEqual(result.iloc[0]['pct_chg'], 0.0) # first row = 0
|
||||
self.assertAlmostEqual(result.iloc[1]['pct_chg'], 2.0, places=1) # (153-150)/150
|
||||
self.assertAlmostEqual(result.iloc[2]['pct_chg'], round((156 - 153) / 153 * 100, 2), places=1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
259
tests/test_finnhub_fetcher.py
Normal file
259
tests/test_finnhub_fetcher.py
Normal file
@@ -0,0 +1,259 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
FinnhubFetcher offline unit tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
|
||||
def _make_mock_response(json_data, status_code=200):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = json_data
|
||||
resp.raise_for_status.return_value = None
|
||||
return resp
|
||||
|
||||
|
||||
class TestFinnhubFetcherNormalize(unittest.TestCase):
|
||||
"""Test _normalize_data with raw Finnhub candle response."""
|
||||
|
||||
def setUp(self):
|
||||
from data_provider.finnhub_fetcher import FinnhubFetcher
|
||||
self.fetcher = FinnhubFetcher()
|
||||
|
||||
def test_normalize_candle_data(self):
|
||||
import pandas as pd
|
||||
raw = pd.DataFrame({
|
||||
'c': [150.0, 152.0, 148.5],
|
||||
'h': [151.0, 153.0, 150.0],
|
||||
'l': [149.0, 150.0, 147.0],
|
||||
'o': [149.5, 151.0, 149.0],
|
||||
't': [1718000000, 1718086400, 1718172800],
|
||||
'v': [1000000, 1200000, 900000],
|
||||
})
|
||||
result = self.fetcher._normalize_data(raw, 'AAPL')
|
||||
self.assertIn('date', result.columns)
|
||||
self.assertIn('close', result.columns)
|
||||
self.assertAlmostEqual(result.iloc[0]['close'], 150.0)
|
||||
self.assertEqual(result.iloc[0]['code'], 'AAPL')
|
||||
|
||||
def test_normalize_calculates_pct_chg(self):
|
||||
import pandas as pd
|
||||
raw = pd.DataFrame({
|
||||
'c': [150.0, 153.0],
|
||||
'h': [151.0, 154.0],
|
||||
'l': [149.0, 152.0],
|
||||
'o': [149.5, 152.0],
|
||||
't': [1718000000, 1718086400],
|
||||
'v': [1000000, 1200000],
|
||||
})
|
||||
result = self.fetcher._normalize_data(raw, 'AAPL')
|
||||
self.assertAlmostEqual(result.iloc[1]['pct_chg'], 2.0)
|
||||
|
||||
def test_normalize_empty_df(self):
|
||||
import pandas as pd
|
||||
raw = pd.DataFrame()
|
||||
result = self.fetcher._normalize_data(raw, 'AAPL')
|
||||
self.assertTrue(result.empty)
|
||||
|
||||
|
||||
class TestFinnhubFetcherFetchRaw(unittest.TestCase):
|
||||
"""Test _fetch_raw_data with mocked HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
from data_provider.finnhub_fetcher import FinnhubFetcher
|
||||
self.fetcher = FinnhubFetcher()
|
||||
self.fetcher._api_key = "test_key"
|
||||
|
||||
@patch('data_provider.finnhub_fetcher.requests.get')
|
||||
def test_fetch_raw_success(self, mock_get):
|
||||
mock_get.return_value = _make_mock_response({
|
||||
'c': [150.0],
|
||||
'h': [151.0],
|
||||
'l': [149.0],
|
||||
'o': [149.5],
|
||||
't': [1718000000],
|
||||
'v': [1000000],
|
||||
's': 'ok',
|
||||
})
|
||||
df = self.fetcher._fetch_raw_data('AAPL', '2024-06-10', '2024-06-11')
|
||||
self.assertFalse(df.empty)
|
||||
self.assertIn('c', df.columns)
|
||||
|
||||
@patch('data_provider.finnhub_fetcher.requests.get')
|
||||
def test_fetch_raw_empty_response(self, mock_get):
|
||||
from data_provider.base import DataFetchError
|
||||
mock_get.return_value = _make_mock_response({
|
||||
'c': [], 'h': [], 'l': [], 'o': [], 't': [], 'v': [], 's': 'no_data',
|
||||
})
|
||||
with self.assertRaises(DataFetchError):
|
||||
self.fetcher._fetch_raw_data('INVALID', '2024-06-10', '2024-06-11')
|
||||
|
||||
@patch('data_provider.finnhub_fetcher.requests.get')
|
||||
def test_fetch_raw_http_error(self, mock_get):
|
||||
from data_provider.base import DataFetchError
|
||||
mock_get.side_effect = Exception("connection timeout")
|
||||
with self.assertRaises(DataFetchError):
|
||||
self.fetcher._fetch_raw_data('AAPL', '2024-06-10', '2024-06-11')
|
||||
|
||||
|
||||
class TestFinnhubFetcherRealtimeQuote(unittest.TestCase):
|
||||
"""Test get_realtime_quote with mocked HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
from data_provider.finnhub_fetcher import FinnhubFetcher
|
||||
self.fetcher = FinnhubFetcher()
|
||||
self.fetcher._api_key = "test_key"
|
||||
|
||||
@patch('data_provider.finnhub_fetcher.requests.get')
|
||||
def test_realtime_quote_us_stock(self, mock_get):
|
||||
mock_get.return_value = _make_mock_response({
|
||||
'c': 150.0,
|
||||
'd': 2.0,
|
||||
'dp': 1.35,
|
||||
'h': 151.0,
|
||||
'l': 148.0,
|
||||
'o': 149.0,
|
||||
'pc': 148.0,
|
||||
't': 1718172800,
|
||||
'v': 5000000,
|
||||
})
|
||||
quote = self.fetcher.get_realtime_quote('AAPL')
|
||||
self.assertIsNotNone(quote)
|
||||
self.assertEqual(quote.code, 'AAPL')
|
||||
self.assertAlmostEqual(quote.price, 150.0)
|
||||
self.assertAlmostEqual(quote.change_pct, 1.35)
|
||||
|
||||
def test_realtime_quote_non_us_stock(self):
|
||||
quote = self.fetcher.get_realtime_quote('600519')
|
||||
self.assertIsNone(quote)
|
||||
|
||||
@patch('data_provider.finnhub_fetcher.requests.get')
|
||||
def test_realtime_quote_http_failure(self, mock_get):
|
||||
mock_get.side_effect = Exception("timeout")
|
||||
quote = self.fetcher.get_realtime_quote('AAPL')
|
||||
self.assertIsNone(quote)
|
||||
|
||||
|
||||
class TestFinnhubFetcherStockName(unittest.TestCase):
|
||||
"""Test get_stock_name with mocked HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
from data_provider.finnhub_fetcher import FinnhubFetcher
|
||||
self.fetcher = FinnhubFetcher()
|
||||
self.fetcher._api_key = "test_key"
|
||||
|
||||
@patch('data_provider.finnhub_fetcher.requests.get')
|
||||
def test_get_stock_name_found(self, mock_get):
|
||||
mock_get.return_value = _make_mock_response({
|
||||
'result': [{'description': 'APPLE INC', 'symbol': 'AAPL'}],
|
||||
'count': 1,
|
||||
})
|
||||
name = self.fetcher.get_stock_name('AAPL')
|
||||
self.assertEqual(name, 'APPLE INC')
|
||||
|
||||
@patch('data_provider.finnhub_fetcher.requests.get')
|
||||
def test_get_stock_name_empty(self, mock_get):
|
||||
mock_get.return_value = _make_mock_response({'result': [], 'count': 0})
|
||||
name = self.fetcher.get_stock_name('NOTEXIST')
|
||||
self.assertIsNone(name)
|
||||
|
||||
def test_get_stock_name_non_us(self):
|
||||
name = self.fetcher.get_stock_name('600519')
|
||||
self.assertIsNone(name)
|
||||
|
||||
|
||||
class TestFinnhubFetcherInit(unittest.TestCase):
|
||||
"""Test constructor / key handling."""
|
||||
|
||||
@patch('src.config.get_config')
|
||||
def test_init_with_key(self, mock_config):
|
||||
mock_config.return_value = MagicMock(finnhub_api_key='sk-test-123')
|
||||
from data_provider.finnhub_fetcher import FinnhubFetcher
|
||||
f = FinnhubFetcher()
|
||||
self.assertEqual(f._api_key, 'sk-test-123')
|
||||
|
||||
@patch.dict(os.environ, {}, clear=False)
|
||||
@patch('src.config.get_config')
|
||||
def test_init_without_key(self, mock_config):
|
||||
os.environ.pop('FINNHUB_API_KEY', None)
|
||||
mock_config.return_value = MagicMock(finnhub_api_key=None)
|
||||
from data_provider.finnhub_fetcher import FinnhubFetcher
|
||||
f = FinnhubFetcher()
|
||||
self.assertIsNone(f._api_key)
|
||||
|
||||
|
||||
class TestFinnhubFetcherRegistration(unittest.TestCase):
|
||||
"""Test that FinnhubFetcher is registered in DataFetcherManager when key is present."""
|
||||
|
||||
@patch('src.config.get_config')
|
||||
def test_registered_with_key(self, mock_config):
|
||||
mock_config.return_value = MagicMock(
|
||||
finnhub_api_key='sk-test',
|
||||
alphavantage_api_key=None,
|
||||
tushare_token=None,
|
||||
longbridge_app_key=None,
|
||||
longbridge_app_secret=None,
|
||||
longbridge_access_token=None,
|
||||
tickflow_api_key=None,
|
||||
)
|
||||
from data_provider.base import DataFetcherManager
|
||||
mgr = DataFetcherManager()
|
||||
names = [f.name for f in mgr._get_fetchers_snapshot()]
|
||||
self.assertIn('FinnhubFetcher', names)
|
||||
|
||||
@patch('src.config.get_config')
|
||||
def test_not_registered_without_key(self, mock_config):
|
||||
mock_config.return_value = MagicMock(
|
||||
finnhub_api_key=None,
|
||||
alphavantage_api_key=None,
|
||||
tushare_token=None,
|
||||
longbridge_app_key=None,
|
||||
longbridge_app_secret=None,
|
||||
longbridge_access_token=None,
|
||||
tickflow_api_key=None,
|
||||
)
|
||||
from data_provider.base import DataFetcherManager
|
||||
mgr = DataFetcherManager()
|
||||
names = [f.name for f in mgr._get_fetchers_snapshot()]
|
||||
self.assertNotIn('FinnhubFetcher', names)
|
||||
|
||||
|
||||
class TestUSDailyRoutingFallback(unittest.TestCase):
|
||||
"""Verify US daily routing includes Finnhub/AlphaVantage in the failover chain."""
|
||||
|
||||
@patch('src.config.get_config')
|
||||
def test_us_routing_includes_new_fetchers(self, mock_config):
|
||||
"""US stock get_daily_data source_order must contain Finnhub and AlphaVantage."""
|
||||
mock_config.return_value = MagicMock(
|
||||
finnhub_api_key='sk-test',
|
||||
alphavantage_api_key='av-test',
|
||||
tushare_token=None,
|
||||
longbridge_app_key=None,
|
||||
longbridge_app_secret=None,
|
||||
longbridge_access_token=None,
|
||||
tickflow_api_key=None,
|
||||
)
|
||||
from data_provider.base import DataFetcherManager
|
||||
mgr = DataFetcherManager()
|
||||
|
||||
# Verify both fetchers are registered
|
||||
names = [f.name for f in mgr._get_fetchers_snapshot()]
|
||||
self.assertIn('FinnhubFetcher', names)
|
||||
self.assertIn('AlphaVantageFetcher', names)
|
||||
|
||||
# Verify the US routing source_order by checking the code path
|
||||
# When Longbridge is not preferred, Finnhub should come before Yfinance
|
||||
finnhub_idx = names.index('FinnhubFetcher')
|
||||
yfinance_idx = names.index('YfinanceFetcher')
|
||||
self.assertLess(finnhub_idx, yfinance_idx,
|
||||
"FinnhubFetcher should have higher priority (lower index) than YfinanceFetcher")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user