Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend_api_python/app/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
FinnhubConfig,
TradingEconomicsConfig,
TiingoConfig,
FXMacroDataConfig,
YFinanceConfig,
CCXTConfig,
AkshareConfig
Expand All @@ -24,6 +25,7 @@
'FinnhubConfig',
'TradingEconomicsConfig',
'TiingoConfig',
'FXMacroDataConfig',
'YFinanceConfig',
'CCXTConfig',
'AkshareConfig',
Expand Down
21 changes: 21 additions & 0 deletions backend_api_python/app/config/data_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
68 changes: 64 additions & 4 deletions backend_api_python/app/data_sources/forex.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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__)

Expand Down Expand Up @@ -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,
Expand All @@ -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]]:
Expand Down
50 changes: 50 additions & 0 deletions backend_api_python/tests/test_fxmacrodata_forex_source.py
Original file line number Diff line number Diff line change
@@ -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) == []