Skip to content
Open
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://api.fxmacrodata.com/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
96 changes: 92 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,93 @@ 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"),
}
headers: Dict[str, str] = {}
if FXMacroDataConfig.API_KEY:
headers["X-API-Key"] = FXMacroDataConfig.API_KEY

url = f"{FXMacroDataConfig.BASE_URL}/forex/{normalized[:3].lower()}/{normalized[3:].lower()}"
# The API returns at most 100 rows per request, newest first; page
# with offset until enough bars are collected or has_more is false.
rows: List[Dict[str, Any]] = []
offset = 0
for _ in range(100):
page_params = dict(params, limit=100, offset=offset)
try:
# Do not follow redirects, so the X-API-Key header is never
# forwarded to another host.
response = requests.get(
url,
params=page_params,
headers=headers,
timeout=FXMacroDataConfig.TIMEOUT,
allow_redirects=False,
)
if 300 <= response.status_code < 400:
logger.debug("FXMacroData forex kline request redirected for %s; not followed", symbol)
return []
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 []

page = data.get("data") if isinstance(data, dict) else None
if not isinstance(page, list):
logger.debug("FXMacroData forex kline response missing data list for %s", symbol)
return []
rows.extend(page)
pagination = data.get("pagination") or {}
if not page or len(rows) >= limit or not pagination.get("has_more"):
break
next_offset = pagination.get("next_offset")
if next_offset is None:
next_offset = offset + len(page)
if next_offset <= offset:
break
offset = next_offset

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
116 changes: 116 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,116 @@
from app.data_sources.forex import ForexDataSource


def test_fxmacrodata_daily_kline_fetch(monkeypatch):
captured = {}

class FakeResponse:
status_code = 200

@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, headers, timeout, **kwargs):
captured["url"] = url
captured["params"] = params
captured["headers"] = headers
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://api.fxmacrodata.com/v1/forex/eur/usd",
"params": {
"start_date": "2024-01-22",
"end_date": "2024-02-01",
"limit": 100,
"offset": 0,
},
"headers": {"X-API-Key": "test-key"},
"timeout": 12,
}


def test_fxmacrodata_daily_kline_follows_pagination(monkeypatch):
pages = {
0: {
"data": [{"date": "2024-01-03", "val": 1.3}, {"date": "2024-01-02", "val": 1.2}],
"pagination": {"has_more": True, "next_offset": 2},
},
2: {
"data": [{"date": "2024-01-01", "val": 1.1}],
"pagination": {"has_more": False, "next_offset": None},
},
}
offsets = []

class FakeResponse:
status_code = 200

def __init__(self, payload):
self.payload = payload

def raise_for_status(self):
return None

def json(self):
return self.payload

def fake_get(url, params, headers, timeout, **kwargs):
offsets.append(params["offset"])
return FakeResponse(pages[params["offset"]])

monkeypatch.setattr("app.data_sources.forex.requests.get", fake_get)
rows = ForexDataSource()._get_kline_fxmacrodata("EURUSD", "1D", 5, before_time=1704326400)

assert offsets == [0, 2]
assert [row["close"] for row in rows] == [1.1, 1.2, 1.3]


def test_fxmacrodata_skips_intraday_timeframes():
source = ForexDataSource()
assert source._get_kline_fxmacrodata("EURUSD", "1m", 5) == []


def test_fxmacrodata_does_not_follow_redirects(monkeypatch):
captured = {}

class FakeResponse:
status_code = 302

@staticmethod
def raise_for_status():
return None

@staticmethod
def json():
return {"data": [{"date": "2024-01-03", "val": 1.0920}]}

def fake_get(url, params, headers, timeout, **kwargs):
captured.update(kwargs)
return FakeResponse()

monkeypatch.setenv("FXMACRODATA_API_KEY", "test-key")
monkeypatch.setattr("app.data_sources.forex.requests.get", fake_get)

assert ForexDataSource()._get_kline_fxmacrodata("EURUSD", "1D", 5) == []
assert captured["allow_redirects"] is False