diff --git a/backend_api_python/app/config/__init__.py b/backend_api_python/app/config/__init__.py index 705d12d9c..c3f042efc 100644 --- a/backend_api_python/app/config/__init__.py +++ b/backend_api_python/app/config/__init__.py @@ -7,6 +7,7 @@ FinnhubConfig, TradingEconomicsConfig, TiingoConfig, + FXMacroDataConfig, YFinanceConfig, CCXTConfig, AkshareConfig @@ -24,6 +25,7 @@ 'FinnhubConfig', 'TradingEconomicsConfig', 'TiingoConfig', + 'FXMacroDataConfig', 'YFinanceConfig', 'CCXTConfig', 'AkshareConfig', diff --git a/backend_api_python/app/config/data_sources.py b/backend_api_python/app/config/data_sources.py index b95f05d0c..29f5bcad5 100644 --- a/backend_api_python/app/config/data_sources.py +++ b/backend_api_python/app/config/data_sources.py @@ -283,6 +283,27 @@ class TiingoConfig(metaclass=MetaTiingoConfig): pass +class MetaFXMacroDataConfig(type): + @property + def BASE_URL(cls): + return _config_str('fxmacrodata', 'base_url', 'FXMACRODATA_BASE_URL', 'https://fxmacrodata.com/api/v1').rstrip('/') + + @property + def API_KEY(cls): + return _config_str('fxmacrodata', 'api_key', 'FXMACRODATA_API_KEY') or _config_str( + 'fxmacrodata', 'api_key', 'FXMD_API_KEY' + ) + + @property + def TIMEOUT(cls): + return _config_int('fxmacrodata', 'timeout', 'FXMACRODATA_TIMEOUT', 12) + + +class FXMacroDataConfig(metaclass=MetaFXMacroDataConfig): + """FXMacroData daily FX reference-rate configuration.""" + pass + + class MetaYFinanceConfig(type): @property def TIMEOUT(cls): diff --git a/backend_api_python/app/data_sources/forex.py b/backend_api_python/app/data_sources/forex.py index 59fdbaa67..e5ca5bb0b 100644 --- a/backend_api_python/app/data_sources/forex.py +++ b/backend_api_python/app/data_sources/forex.py @@ -1,9 +1,9 @@ """ 外汇数据源 -三级降级: Twelve Data → Tiingo → yfinance +四级降级: FXMacroData(日线参考汇率) → Twelve Data → Tiingo → yfinance """ from typing import Dict, List, Any, Optional -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import os import time import requests @@ -12,7 +12,7 @@ from app.data_sources.base import BaseDataSource, TIMEFRAME_SECONDS from app.utils.logger import get_logger -from app.config import TiingoConfig, APIKeys +from app.config import TiingoConfig, FXMacroDataConfig, APIKeys logger = get_logger(__name__) @@ -321,10 +321,11 @@ def get_kline( ) -> List[Dict[str, Any]]: """ 获取外汇K线数据 - Priority: Twelve Data → Tiingo → yfinance + Priority: FXMacroData (daily reference rates) → Twelve Data → Tiingo → yfinance """ symbol = normalize_forex_pair_symbol(symbol) for fetcher in ( + self._get_kline_fxmacrodata, self._get_kline_twelvedata, self._get_kline_tiingo, self._get_kline_yfinance, @@ -343,6 +344,65 @@ def get_kline( logger.debug("Forex kline fetcher %s failed for %s: %s", fetcher.__name__, symbol, e) return [] + def _get_kline_fxmacrodata( + self, symbol: str, timeframe: str, limit: int, before_time: Optional[int] = None + ) -> List[Dict[str, Any]]: + """Fetch daily FX reference-rate bars from FXMacroData.""" + if timeframe != '1D': + return [] + normalized = normalize_forex_pair_symbol(symbol) + if len(normalized) != 6 or not normalized.isalpha(): + return [] + + if before_time: + end_dt = datetime.fromtimestamp(int(before_time), tz=timezone.utc) + else: + end_dt = datetime.now(tz=timezone.utc) + start_dt = end_dt - timedelta(days=max(int(limit), 1) * 2) + + params: Dict[str, Any] = { + "start_date": start_dt.strftime("%Y-%m-%d"), + "end_date": end_dt.strftime("%Y-%m-%d"), + } + if FXMacroDataConfig.API_KEY: + params["api_key"] = FXMacroDataConfig.API_KEY + + url = f"{FXMacroDataConfig.BASE_URL}/forex/{normalized[:3].lower()}/{normalized[3:].lower()}" + try: + response = requests.get(url, params=params, timeout=FXMacroDataConfig.TIMEOUT) + response.raise_for_status() + data = response.json() + except requests.exceptions.RequestException as e: + logger.debug("FXMacroData forex kline request failed %s: %s", symbol, e) + return [] + + rows = data.get("data") if isinstance(data, dict) else None + if not isinstance(rows, list): + logger.debug("FXMacroData forex kline response missing data list for %s", symbol) + return [] + + klines = [] + for row in rows: + try: + dt = datetime.fromisoformat(str(row["date"])).replace(tzinfo=timezone.utc) + price = float(row["val"]) + klines.append({ + "time": int(dt.timestamp()), + "open": price, + "high": price, + "low": price, + "close": price, + "volume": 0.0, + }) + except Exception: + continue + + klines.sort(key=lambda x: x["time"]) + if len(klines) > limit: + klines = klines[-limit:] + logger.debug("FXMacroData forex kline %s %s: %d bars", symbol, timeframe, len(klines)) + return klines + def _get_kline_twelvedata( self, symbol: str, timeframe: str, limit: int, before_time: Optional[int] = None ) -> List[Dict[str, Any]]: diff --git a/backend_api_python/tests/test_fxmacrodata_forex_source.py b/backend_api_python/tests/test_fxmacrodata_forex_source.py new file mode 100644 index 000000000..16c6d7a17 --- /dev/null +++ b/backend_api_python/tests/test_fxmacrodata_forex_source.py @@ -0,0 +1,50 @@ +from app.data_sources.forex import ForexDataSource + + +def test_fxmacrodata_daily_kline_fetch(monkeypatch): + captured = {} + + class FakeResponse: + @staticmethod + def raise_for_status(): + return None + + @staticmethod + def json(): + return { + "data": [ + {"date": "2024-01-03", "val": 1.0920}, + {"date": "2024-01-01", "val": "1.1038"}, + ] + } + + def fake_get(url, params, timeout): + captured["url"] = url + captured["params"] = params + captured["timeout"] = timeout + return FakeResponse() + + monkeypatch.setenv("FXMACRODATA_API_KEY", "test-key") + monkeypatch.setattr("app.data_sources.forex.requests.get", fake_get) + source = ForexDataSource() + + rows = source._get_kline_fxmacrodata("EUR/USD", "1D", 5, before_time=1706745600) + + assert rows == [ + {"time": 1704067200, "open": 1.1038, "high": 1.1038, "low": 1.1038, "close": 1.1038, "volume": 0.0}, + {"time": 1704240000, "open": 1.092, "high": 1.092, "low": 1.092, "close": 1.092, "volume": 0.0}, + ] + assert captured == { + "url": "https://fxmacrodata.com/api/v1/forex/eur/usd", + "params": { + "start_date": "2024-01-22", + "end_date": "2024-02-01", + "api_key": "test-key", + }, + "timeout": 12, + } + + +def test_fxmacrodata_skips_intraday_timeframes(): + source = ForexDataSource() + assert source._get_kline_fxmacrodata("EURUSD", "1m", 5) == []