fix: Forex data source improvements and Tiingo subscription hints
This commit is contained in:
@@ -20,17 +20,18 @@ class ForexDataSource(BaseDataSource):
|
|||||||
name = "Forex/Tiingo"
|
name = "Forex/Tiingo"
|
||||||
|
|
||||||
# Tiingo resampleFreq 映射
|
# 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 = {
|
TIMEFRAME_MAP = {
|
||||||
'1m': '1min',
|
'1m': '1min', # 需要付费订阅
|
||||||
'5m': '5min',
|
'5m': '5min',
|
||||||
'15m': '15min',
|
'15m': '15min',
|
||||||
'30m': '30min',
|
'30m': '30min',
|
||||||
'1H': '1hour',
|
'1H': '1hour',
|
||||||
'4H': '4hour',
|
'4H': '4hour',
|
||||||
'1D': '1day',
|
'1D': '1day',
|
||||||
'1W': '1week',
|
'1W': None, # Tiingo 不支持,需要聚合
|
||||||
'1M': '1month'
|
'1M': None # Tiingo 不支持,需要聚合
|
||||||
}
|
}
|
||||||
|
|
||||||
# 外汇对映射 (Tiingo 使用标准 ticker,如 eurusd, audusd)
|
# 外汇对映射 (Tiingo 使用标准 ticker,如 eurusd, audusd)
|
||||||
@@ -89,10 +90,31 @@ class ForexDataSource(BaseDataSource):
|
|||||||
|
|
||||||
# 2. 解析 Resolution (resampleFreq)
|
# 2. 解析 Resolution (resampleFreq)
|
||||||
resample_freq = self.TIMEFRAME_MAP.get(timeframe)
|
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:
|
if not resample_freq:
|
||||||
logger.warning(f"Tiingo does not support timeframe: {timeframe}")
|
logger.warning(f"Tiingo does not support timeframe: {timeframe}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# 1分钟数据需要付费订阅提示
|
||||||
|
if timeframe == '1m':
|
||||||
|
logger.info(f"Note: Tiingo 1-minute forex data requires a paid subscription")
|
||||||
|
|
||||||
# 3. 计算时间范围
|
# 3. 计算时间范围
|
||||||
if before_time:
|
if before_time:
|
||||||
end_dt = datetime.fromtimestamp(before_time)
|
end_dt = datetime.fromtimestamp(before_time)
|
||||||
@@ -100,9 +122,19 @@ class ForexDataSource(BaseDataSource):
|
|||||||
end_dt = datetime.now()
|
end_dt = datetime.now()
|
||||||
|
|
||||||
# 根据周期和数量计算开始时间
|
# 根据周期和数量计算开始时间
|
||||||
tf_seconds = self._get_timeframe_seconds(timeframe)
|
# 注意:聚合模式下使用日线秒数计算
|
||||||
# 多取一些缓冲时间
|
if aggregate_to_weekly or aggregate_to_monthly:
|
||||||
start_dt = end_dt - timedelta(seconds=limit * tf_seconds * 2)
|
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 支持该格式)
|
# 格式化日期为 YYYY-MM-DD (Tiingo 支持该格式)
|
||||||
start_date_str = start_dt.strftime('%Y-%m-%d')
|
start_date_str = start_dt.strftime('%Y-%m-%d')
|
||||||
@@ -175,9 +207,17 @@ class ForexDataSource(BaseDataSource):
|
|||||||
# 按时间排序
|
# 按时间排序
|
||||||
klines.sort(key=lambda x: x['time'])
|
klines.sort(key=lambda x: x['time'])
|
||||||
|
|
||||||
# 过滤
|
# 如果需要聚合到周线或月线
|
||||||
if len(klines) > limit:
|
if aggregate_to_weekly:
|
||||||
klines = klines[-limit:]
|
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 外汇数据")
|
# logger.info(f"获取到 {len(klines)} 条 Tiingo 外汇数据")
|
||||||
return klines
|
return klines
|
||||||
@@ -188,3 +228,86 @@ class ForexDataSource(BaseDataSource):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to process Tiingo data: {e}")
|
logger.error(f"Failed to process Tiingo data: {e}")
|
||||||
return []
|
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
|
||||||
|
|||||||
@@ -60,10 +60,17 @@ def get_kline():
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not klines:
|
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({
|
return jsonify({
|
||||||
'code': 0,
|
'code': 0,
|
||||||
'msg': 'No data found',
|
'msg': msg,
|
||||||
'data': []
|
'data': [],
|
||||||
|
'hint': 'tiingo_subscription' if (market == 'Forex' and timeframe == '1m') else None
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ CONFIG_SCHEMA = {
|
|||||||
'required': False,
|
'required': False,
|
||||||
'link': 'https://www.tiingo.com/account/api/token',
|
'link': 'https://www.tiingo.com/account/api/token',
|
||||||
'link_text': 'settings.link.getToken',
|
'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',
|
'key': 'TIINGO_TIMEOUT',
|
||||||
|
|||||||
@@ -527,6 +527,7 @@ const locale = {
|
|||||||
'dashboard.indicator.error.pleaseLogin': 'Please login first',
|
'dashboard.indicator.error.pleaseLogin': 'Please login first',
|
||||||
'dashboard.indicator.error.loadDataFailed': 'Failed to load data',
|
'dashboard.indicator.error.loadDataFailed': 'Failed to load data',
|
||||||
'dashboard.indicator.error.loadDataFailedDesc': 'Please check network connection',
|
'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.pythonEngineFailed': 'Python engine failed to load, indicator functionality may be unavailable',
|
||||||
'dashboard.indicator.error.chartInitFailed': 'Chart initialization failed',
|
'dashboard.indicator.error.chartInitFailed': 'Chart initialization failed',
|
||||||
'dashboard.indicator.warning.enterCode': 'Please enter indicator code first',
|
'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
|
// 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.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_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 {
|
export default {
|
||||||
|
|||||||
@@ -527,6 +527,7 @@ const locale = {
|
|||||||
'dashboard.indicator.error.pleaseLogin': '请先登录',
|
'dashboard.indicator.error.pleaseLogin': '请先登录',
|
||||||
'dashboard.indicator.error.loadDataFailed': '数据加载失败',
|
'dashboard.indicator.error.loadDataFailed': '数据加载失败',
|
||||||
'dashboard.indicator.error.loadDataFailedDesc': '请检查网络连接',
|
'dashboard.indicator.error.loadDataFailedDesc': '请检查网络连接',
|
||||||
|
'dashboard.indicator.error.tiingoSubscription': '外汇1分钟数据需要 Tiingo 付费订阅,请使用其他时间周期或升级订阅',
|
||||||
'dashboard.indicator.error.pythonEngineFailed': 'Python 引擎加载失败,指标功能可能无法使用',
|
'dashboard.indicator.error.pythonEngineFailed': 'Python 引擎加载失败,指标功能可能无法使用',
|
||||||
'dashboard.indicator.error.chartInitFailed': '图表初始化失败',
|
'dashboard.indicator.error.chartInitFailed': '图表初始化失败',
|
||||||
'dashboard.indicator.warning.enterCode': '请先输入指标代码',
|
'dashboard.indicator.warning.enterCode': '请先输入指标代码',
|
||||||
@@ -1814,7 +1815,7 @@ const locale = {
|
|||||||
'settings.desc.FINNHUB_API_KEY': 'Finnhub API密钥,用于美股数据(有免费额度)',
|
'settings.desc.FINNHUB_API_KEY': 'Finnhub API密钥,用于美股数据(有免费额度)',
|
||||||
'settings.desc.FINNHUB_TIMEOUT': 'Finnhub API请求超时时间',
|
'settings.desc.FINNHUB_TIMEOUT': 'Finnhub API请求超时时间',
|
||||||
'settings.desc.FINNHUB_RATE_LIMIT': '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.TIINGO_TIMEOUT': 'Tiingo API请求超时时间',
|
||||||
'settings.desc.AKSHARE_TIMEOUT': 'Akshare API超时时间,用于A股数据',
|
'settings.desc.AKSHARE_TIMEOUT': 'Akshare API超时时间,用于A股数据',
|
||||||
'settings.desc.YFINANCE_TIMEOUT': 'Yahoo Finance API超时时间',
|
'settings.desc.YFINANCE_TIMEOUT': 'Yahoo Finance API超时时间',
|
||||||
|
|||||||
@@ -527,6 +527,7 @@ const locale = {
|
|||||||
'dashboard.indicator.error.pleaseLogin': '請先登錄',
|
'dashboard.indicator.error.pleaseLogin': '請先登錄',
|
||||||
'dashboard.indicator.error.loadDataFailed': '數據加載失敗',
|
'dashboard.indicator.error.loadDataFailed': '數據加載失敗',
|
||||||
'dashboard.indicator.error.loadDataFailedDesc': '請檢查網絡連接',
|
'dashboard.indicator.error.loadDataFailedDesc': '請檢查網絡連接',
|
||||||
|
'dashboard.indicator.error.tiingoSubscription': '外匯1分鐘數據需要 Tiingo 付費訂閱,請使用其他時間週期或升級訂閱',
|
||||||
'dashboard.indicator.error.pythonEngineFailed': 'Python 引擎加載失敗,指標功能可能無法使用',
|
'dashboard.indicator.error.pythonEngineFailed': 'Python 引擎加載失敗,指標功能可能無法使用',
|
||||||
'dashboard.indicator.error.chartInitFailed': '圖表初始化失敗',
|
'dashboard.indicator.error.chartInitFailed': '圖表初始化失敗',
|
||||||
'dashboard.indicator.warning.enterCode': '請先輸入指標代碼',
|
'dashboard.indicator.warning.enterCode': '請先輸入指標代碼',
|
||||||
@@ -1815,7 +1816,7 @@ const locale = {
|
|||||||
'settings.desc.FINNHUB_API_KEY': 'Finnhub API密鑰,用於美股數據(有免費額度)',
|
'settings.desc.FINNHUB_API_KEY': 'Finnhub API密鑰,用於美股數據(有免費額度)',
|
||||||
'settings.desc.FINNHUB_TIMEOUT': 'Finnhub API請求超時時間',
|
'settings.desc.FINNHUB_TIMEOUT': 'Finnhub API請求超時時間',
|
||||||
'settings.desc.FINNHUB_RATE_LIMIT': '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.TIINGO_TIMEOUT': 'Tiingo API請求超時時間',
|
||||||
'settings.desc.AKSHARE_TIMEOUT': 'Akshare API超時時間,用於A股數據',
|
'settings.desc.AKSHARE_TIMEOUT': 'Akshare API超時時間,用於A股數據',
|
||||||
'settings.desc.YFINANCE_TIMEOUT': 'Yahoo Finance API超時時間',
|
'settings.desc.YFINANCE_TIMEOUT': 'Yahoo Finance API超時時間',
|
||||||
|
|||||||
@@ -1504,7 +1504,12 @@ registerOverlay({
|
|||||||
if (response.code === 1 && response.data && Array.isArray(response.data)) {
|
if (response.code === 1 && response.data && Array.isArray(response.data)) {
|
||||||
formattedData = formatKlineData(response.data)
|
formattedData = formatKlineData(response.data)
|
||||||
} else {
|
} 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) {
|
} catch (apiErr) {
|
||||||
throw apiErr
|
throw apiErr
|
||||||
|
|||||||
Reference in New Issue
Block a user