From 43523d479618741b48f819e2664e31b66e62fd3a Mon Sep 17 00:00:00 2001 From: TIANHE Date: Mon, 12 Jan 2026 04:28:42 +0800 Subject: [PATCH] fix: Forex data source improvements and Tiingo subscription hints --- backend_api_python/app/data_sources/forex.py | 143 ++++++++++++++++-- backend_api_python/app/routes/kline.py | 11 +- backend_api_python/app/routes/settings.py | 2 +- quantdinger_vue/src/locales/lang/en-US.js | 5 +- quantdinger_vue/src/locales/lang/zh-CN.js | 3 +- quantdinger_vue/src/locales/lang/zh-TW.js | 3 +- .../components/KlineChart.vue | 7 +- 7 files changed, 157 insertions(+), 17 deletions(-) diff --git a/backend_api_python/app/data_sources/forex.py b/backend_api_python/app/data_sources/forex.py index 18e8b89..3bcd4bb 100644 --- a/backend_api_python/app/data_sources/forex.py +++ b/backend_api_python/app/data_sources/forex.py @@ -20,17 +20,18 @@ class ForexDataSource(BaseDataSource): name = "Forex/Tiingo" # Tiingo resampleFreq 映射 - # Tiingo 支持: 1min, 5min, 15min, 30min, 1hour, 4hour, 1day 等 + # Tiingo 免费账户支持: 5min, 15min, 30min, 1hour, 4hour, 1day + # 注意: 1min 需要付费订阅, 1week/1month 不被 Tiingo FX API 支持 TIMEFRAME_MAP = { - '1m': '1min', + '1m': '1min', # 需要付费订阅 '5m': '5min', '15m': '15min', '30m': '30min', '1H': '1hour', '4H': '4hour', '1D': '1day', - '1W': '1week', - '1M': '1month' + '1W': None, # Tiingo 不支持,需要聚合 + '1M': None # Tiingo 不支持,需要聚合 } # 外汇对映射 (Tiingo 使用标准 ticker,如 eurusd, audusd) @@ -89,10 +90,31 @@ class ForexDataSource(BaseDataSource): # 2. 解析 Resolution (resampleFreq) resample_freq = self.TIMEFRAME_MAP.get(timeframe) + + # 特殊处理:1W/1M 需要用日线聚合 + aggregate_to_weekly = (timeframe == '1W') + aggregate_to_monthly = (timeframe == '1M') + original_limit = limit # 保存原始请求数量 + + if aggregate_to_weekly or aggregate_to_monthly: + # 用日线数据聚合 + resample_freq = '1day' + # 限制周线/月线的最大请求数量(Tiingo 免费 API 有数据量限制) + # 周线最多请求 100 周 = 700 天 ≈ 2年 + # 月线最多请求 36 月 = 1080 天 ≈ 3年 + max_limit = 100 if aggregate_to_weekly else 36 + original_limit = min(original_limit, max_limit) + # 需要更多日线数据来聚合(周线需要7天,月线需要30天) + limit = original_limit * (7 if aggregate_to_weekly else 30) + if not resample_freq: logger.warning(f"Tiingo does not support timeframe: {timeframe}") return [] + # 1分钟数据需要付费订阅提示 + if timeframe == '1m': + logger.info(f"Note: Tiingo 1-minute forex data requires a paid subscription") + # 3. 计算时间范围 if before_time: end_dt = datetime.fromtimestamp(before_time) @@ -100,9 +122,19 @@ class ForexDataSource(BaseDataSource): end_dt = datetime.now() # 根据周期和数量计算开始时间 - tf_seconds = self._get_timeframe_seconds(timeframe) - # 多取一些缓冲时间 - start_dt = end_dt - timedelta(seconds=limit * tf_seconds * 2) + # 注意:聚合模式下使用日线秒数计算 + if aggregate_to_weekly or aggregate_to_monthly: + tf_seconds = 86400 # 日线秒数 + else: + tf_seconds = self._get_timeframe_seconds(timeframe) + # 多取一些缓冲时间(1.5倍,外汇周末不交易) + start_dt = end_dt - timedelta(seconds=limit * tf_seconds * 1.5) + + # Tiingo 免费 API 最多支持约 5 年数据,限制最大时间范围 + max_days = 365 * 3 # 最多 3 年 + if (end_dt - start_dt).days > max_days: + start_dt = end_dt - timedelta(days=max_days) + logger.info(f"Tiingo: Limited date range to {max_days} days") # 格式化日期为 YYYY-MM-DD (Tiingo 支持该格式) start_date_str = start_dt.strftime('%Y-%m-%d') @@ -175,9 +207,17 @@ class ForexDataSource(BaseDataSource): # 按时间排序 klines.sort(key=lambda x: x['time']) - # 过滤 - if len(klines) > limit: - klines = klines[-limit:] + # 如果需要聚合到周线或月线 + if aggregate_to_weekly: + klines = self._aggregate_to_weekly(klines) + logger.debug(f"Aggregated {len(klines)} weekly candles from daily data") + elif aggregate_to_monthly: + klines = self._aggregate_to_monthly(klines) + logger.debug(f"Aggregated {len(klines)} monthly candles from daily data") + + # 过滤到原始请求数量 + if len(klines) > original_limit: + klines = klines[-original_limit:] # logger.info(f"获取到 {len(klines)} 条 Tiingo 外汇数据") return klines @@ -188,3 +228,86 @@ class ForexDataSource(BaseDataSource): except Exception as e: logger.error(f"Failed to process Tiingo data: {e}") return [] + + def _aggregate_to_weekly(self, daily_klines: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """将日线数据聚合为周线""" + if not daily_klines: + return [] + + weekly_klines = [] + current_week = None + week_data = None + + for kline in daily_klines: + dt = datetime.fromtimestamp(kline['time']) + # 获取该日期所在周的周一 + week_start = dt - timedelta(days=dt.weekday()) + week_key = week_start.strftime('%Y-%W') + + if week_key != current_week: + # 保存上一周的数据 + if week_data: + weekly_klines.append(week_data) + # 开始新的一周 + current_week = week_key + week_data = { + 'time': int(week_start.timestamp()), + 'open': kline['open'], + 'high': kline['high'], + 'low': kline['low'], + 'close': kline['close'], + 'volume': kline['volume'] + } + else: + # 更新本周数据 + week_data['high'] = max(week_data['high'], kline['high']) + week_data['low'] = min(week_data['low'], kline['low']) + week_data['close'] = kline['close'] + week_data['volume'] += kline['volume'] + + # 添加最后一周 + if week_data: + weekly_klines.append(week_data) + + return weekly_klines + + def _aggregate_to_monthly(self, daily_klines: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """将日线数据聚合为月线""" + if not daily_klines: + return [] + + monthly_klines = [] + current_month = None + month_data = None + + for kline in daily_klines: + dt = datetime.fromtimestamp(kline['time']) + month_key = dt.strftime('%Y-%m') + + if month_key != current_month: + # 保存上个月的数据 + if month_data: + monthly_klines.append(month_data) + # 开始新的一月 + current_month = month_key + month_start = dt.replace(day=1, hour=0, minute=0, second=0) + month_data = { + 'time': int(month_start.timestamp()), + 'open': kline['open'], + 'high': kline['high'], + 'low': kline['low'], + 'close': kline['close'], + 'volume': kline['volume'] + } + else: + # 更新本月数据 + month_data['high'] = max(month_data['high'], kline['high']) + month_data['low'] = min(month_data['low'], kline['low']) + month_data['close'] = kline['close'] + month_data['volume'] += kline['volume'] + + # 添加最后一月 + if month_data: + monthly_klines.append(month_data) + + return monthly_klines diff --git a/backend_api_python/app/routes/kline.py b/backend_api_python/app/routes/kline.py index ec6a56d..8838205 100644 --- a/backend_api_python/app/routes/kline.py +++ b/backend_api_python/app/routes/kline.py @@ -60,10 +60,17 @@ def get_kline(): ) if not klines: + # 针对特定情况给出更详细的提示 + msg = 'No data found' + if market == 'Forex' and timeframe == '1m': + msg = 'Forex 1-minute data requires Tiingo paid subscription' + elif market == 'Forex' and timeframe in ('1W', '1M'): + msg = 'No weekly/monthly data available for this period' return jsonify({ 'code': 0, - 'msg': 'No data found', - 'data': [] + 'msg': msg, + 'data': [], + 'hint': 'tiingo_subscription' if (market == 'Forex' and timeframe == '1m') else None }) return jsonify({ diff --git a/backend_api_python/app/routes/settings.py b/backend_api_python/app/routes/settings.py index d9999e2..84748ba 100644 --- a/backend_api_python/app/routes/settings.py +++ b/backend_api_python/app/routes/settings.py @@ -317,7 +317,7 @@ CONFIG_SCHEMA = { 'required': False, 'link': 'https://www.tiingo.com/account/api/token', 'link_text': 'settings.link.getToken', - 'description': 'Tiingo API key for US stock data (free tier available)' + 'description': 'Tiingo API key for Forex/Metals data (free tier does not support 1-minute data)' }, { 'key': 'TIINGO_TIMEOUT', diff --git a/quantdinger_vue/src/locales/lang/en-US.js b/quantdinger_vue/src/locales/lang/en-US.js index 6d6b29c..6b6311e 100644 --- a/quantdinger_vue/src/locales/lang/en-US.js +++ b/quantdinger_vue/src/locales/lang/en-US.js @@ -527,6 +527,7 @@ const locale = { 'dashboard.indicator.error.pleaseLogin': 'Please login first', 'dashboard.indicator.error.loadDataFailed': 'Failed to load data', 'dashboard.indicator.error.loadDataFailedDesc': 'Please check network connection', + 'dashboard.indicator.error.tiingoSubscription': 'Forex 1-minute data requires Tiingo paid subscription. Please use other timeframes or upgrade your subscription.', 'dashboard.indicator.error.pythonEngineFailed': 'Python engine failed to load, indicator functionality may be unavailable', 'dashboard.indicator.error.chartInitFailed': 'Chart initialization failed', 'dashboard.indicator.warning.enterCode': 'Please enter indicator code first', @@ -2046,7 +2047,9 @@ const locale = { // Note: These are optional since backend already provides English descriptions 'settings.desc.ORDER_MODE': 'maker: Limit order first (lower fees), market: Market order (instant fill)', 'settings.desc.MAKER_WAIT_SEC': 'Wait time for limit order fill before switching to market order', - 'settings.desc.MAKER_OFFSET_BPS': 'Price offset in basis points. Buy: price*(1-offset), Sell: price*(1+offset)' + 'settings.desc.MAKER_OFFSET_BPS': 'Price offset in basis points. Buy: price*(1-offset), Sell: price*(1+offset)', + 'settings.desc.TIINGO_API_KEY': 'Tiingo API key for Forex/Metals data (free tier does not support 1-minute data)', + 'settings.desc.TIINGO_TIMEOUT': 'Tiingo API request timeout' } export default { diff --git a/quantdinger_vue/src/locales/lang/zh-CN.js b/quantdinger_vue/src/locales/lang/zh-CN.js index 3b3472b..f5e658d 100644 --- a/quantdinger_vue/src/locales/lang/zh-CN.js +++ b/quantdinger_vue/src/locales/lang/zh-CN.js @@ -527,6 +527,7 @@ const locale = { 'dashboard.indicator.error.pleaseLogin': '请先登录', 'dashboard.indicator.error.loadDataFailed': '数据加载失败', 'dashboard.indicator.error.loadDataFailedDesc': '请检查网络连接', +'dashboard.indicator.error.tiingoSubscription': '外汇1分钟数据需要 Tiingo 付费订阅,请使用其他时间周期或升级订阅', 'dashboard.indicator.error.pythonEngineFailed': 'Python 引擎加载失败,指标功能可能无法使用', 'dashboard.indicator.error.chartInitFailed': '图表初始化失败', 'dashboard.indicator.warning.enterCode': '请先输入指标代码', @@ -1814,7 +1815,7 @@ const locale = { 'settings.desc.FINNHUB_API_KEY': 'Finnhub API密钥,用于美股数据(有免费额度)', 'settings.desc.FINNHUB_TIMEOUT': 'Finnhub API请求超时时间', 'settings.desc.FINNHUB_RATE_LIMIT': 'Finnhub API速率限制(每分钟请求数)', -'settings.desc.TIINGO_API_KEY': 'Tiingo API密钥,用于美股数据(有免费额度)', +'settings.desc.TIINGO_API_KEY': 'Tiingo API密钥,用于外汇/贵金属数据(免费版不支持1分钟数据)', 'settings.desc.TIINGO_TIMEOUT': 'Tiingo API请求超时时间', 'settings.desc.AKSHARE_TIMEOUT': 'Akshare API超时时间,用于A股数据', 'settings.desc.YFINANCE_TIMEOUT': 'Yahoo Finance API超时时间', diff --git a/quantdinger_vue/src/locales/lang/zh-TW.js b/quantdinger_vue/src/locales/lang/zh-TW.js index 9b5b113..6035088 100644 --- a/quantdinger_vue/src/locales/lang/zh-TW.js +++ b/quantdinger_vue/src/locales/lang/zh-TW.js @@ -527,6 +527,7 @@ const locale = { 'dashboard.indicator.error.pleaseLogin': '請先登錄', 'dashboard.indicator.error.loadDataFailed': '數據加載失敗', 'dashboard.indicator.error.loadDataFailedDesc': '請檢查網絡連接', + 'dashboard.indicator.error.tiingoSubscription': '外匯1分鐘數據需要 Tiingo 付費訂閱,請使用其他時間週期或升級訂閱', 'dashboard.indicator.error.pythonEngineFailed': 'Python 引擎加載失敗,指標功能可能無法使用', 'dashboard.indicator.error.chartInitFailed': '圖表初始化失敗', 'dashboard.indicator.warning.enterCode': '請先輸入指標代碼', @@ -1815,7 +1816,7 @@ const locale = { 'settings.desc.FINNHUB_API_KEY': 'Finnhub API密鑰,用於美股數據(有免費額度)', 'settings.desc.FINNHUB_TIMEOUT': 'Finnhub API請求超時時間', 'settings.desc.FINNHUB_RATE_LIMIT': 'Finnhub API速率限制(每分鐘請求數)', - 'settings.desc.TIINGO_API_KEY': 'Tiingo API密鑰,用於美股數據(有免費額度)', + 'settings.desc.TIINGO_API_KEY': 'Tiingo API密鑰,用於外匯/貴金屬數據(免費版不支持1分鐘數據)', 'settings.desc.TIINGO_TIMEOUT': 'Tiingo API請求超時時間', 'settings.desc.AKSHARE_TIMEOUT': 'Akshare API超時時間,用於A股數據', 'settings.desc.YFINANCE_TIMEOUT': 'Yahoo Finance API超時時間', diff --git a/quantdinger_vue/src/views/indicator-analysis/components/KlineChart.vue b/quantdinger_vue/src/views/indicator-analysis/components/KlineChart.vue index 714fd87..cdca237 100644 --- a/quantdinger_vue/src/views/indicator-analysis/components/KlineChart.vue +++ b/quantdinger_vue/src/views/indicator-analysis/components/KlineChart.vue @@ -1504,7 +1504,12 @@ registerOverlay({ if (response.code === 1 && response.data && Array.isArray(response.data)) { formattedData = formatKlineData(response.data) } else { - throw new Error(response.msg || '获取K线数据失败') + // 特殊处理 Tiingo 订阅限制提示 + let errMsg = response.msg || '获取K线数据失败' + if (response.hint === 'tiingo_subscription') { + errMsg = proxy.$t('dashboard.indicator.error.tiingoSubscription') || 'Forex 1-minute data requires Tiingo paid subscription' + } + throw new Error(errMsg) } } catch (apiErr) { throw apiErr