|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const API_KEYS = {
|
|
|
CRYPTOCOMPARE: 'e79c8e6d4c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f',
|
|
|
CMC: 'b54bcf4d-1bca-4e8e-9a24-22ff2c3d462c',
|
|
|
CMC_BACKUP: '04cf4b5b-9868-465c-8ba0-9f2e78c92eb1',
|
|
|
ETHERSCAN: 'SZHYFZK2RR8H9TIMJBVW54V4H81K2Z2KR2',
|
|
|
BSCSCAN: 'K62RKHGXTDCG53RU4MCG6XABIMJKTN19IT',
|
|
|
TRONSCAN: '7ae72726-bffe-4e74-9c33-97b761eeea21'
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const OHLCV_SOURCES = [
|
|
|
|
|
|
|
|
|
|
|
|
{
|
|
|
id: 'binance',
|
|
|
name: 'Binance Public API',
|
|
|
baseUrl: 'https://api.binance.com',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 1,
|
|
|
maxLimit: 1000,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': '1m', '5m': '5m', '15m': '15m', '30m': '30m',
|
|
|
'1h': '1h', '4h': '4h', '1d': '1d', '1w': '1w', '1M': '1M'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const interval = OHLCV_SOURCES[0].timeframeMap[timeframe] || '1d';
|
|
|
return `/api/v3/klines?symbol=${symbol.toUpperCase()}USDT&interval=${interval}&limit=${limit}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
return data.map(item => ({
|
|
|
timestamp: item[0],
|
|
|
open: parseFloat(item[1]),
|
|
|
high: parseFloat(item[2]),
|
|
|
low: parseFloat(item[3]),
|
|
|
close: parseFloat(item[4]),
|
|
|
volume: parseFloat(item[5])
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'coingecko_ohlc',
|
|
|
name: 'CoinGecko OHLC',
|
|
|
baseUrl: 'https://api.coingecko.com/api/v3',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 2,
|
|
|
maxLimit: 365,
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const days = limit > 90 ? 365 : limit > 30 ? 90 : limit > 7 ? 30 : 7;
|
|
|
return `/coins/${symbol.toLowerCase()}/ohlc?vs_currency=usd&days=${days}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
return data.map(item => ({
|
|
|
timestamp: item[0],
|
|
|
open: item[1],
|
|
|
high: item[2],
|
|
|
low: item[3],
|
|
|
close: item[4],
|
|
|
volume: null
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'coinpaprika',
|
|
|
name: 'CoinPaprika Historical',
|
|
|
baseUrl: 'https://api.coinpaprika.com/v1',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 3,
|
|
|
maxLimit: 366,
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const now = new Date();
|
|
|
const start = new Date(now.getTime() - (limit * 24 * 60 * 60 * 1000));
|
|
|
return `/coins/${symbol.toLowerCase()}-${symbol.toLowerCase()}/ohlcv/historical?start=${start.toISOString().split('T')[0]}&end=${now.toISOString().split('T')[0]}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
return data.map(item => ({
|
|
|
timestamp: new Date(item.time_open).getTime(),
|
|
|
open: item.open,
|
|
|
high: item.high,
|
|
|
low: item.low,
|
|
|
close: item.close,
|
|
|
volume: item.volume
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'coincap_history',
|
|
|
name: 'CoinCap History',
|
|
|
baseUrl: 'https://api.coincap.io/v2',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 4,
|
|
|
maxLimit: 2000,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': 'm1', '5m': 'm5', '15m': 'm15', '30m': 'm30',
|
|
|
'1h': 'h1', '4h': 'h6', '1d': 'd1'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const interval = OHLCV_SOURCES.find(s => s.id === 'coincap_history').timeframeMap[timeframe] || 'd1';
|
|
|
const end = Date.now();
|
|
|
const start = end - (limit * this.getIntervalMs(timeframe));
|
|
|
return `/assets/${symbol.toLowerCase()}/history?interval=${interval}&start=${start}&end=${end}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
if (!data.data) return [];
|
|
|
return data.data.map(item => ({
|
|
|
timestamp: item.time,
|
|
|
open: parseFloat(item.priceUsd),
|
|
|
high: parseFloat(item.priceUsd),
|
|
|
low: parseFloat(item.priceUsd),
|
|
|
close: parseFloat(item.priceUsd),
|
|
|
volume: null
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'kraken',
|
|
|
name: 'Kraken Public OHLC',
|
|
|
baseUrl: 'https://api.kraken.com/0/public',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 5,
|
|
|
maxLimit: 720,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': '1', '5m': '5', '15m': '15', '30m': '30',
|
|
|
'1h': '60', '4h': '240', '1d': '1440', '1w': '10080'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const interval = OHLCV_SOURCES.find(s => s.id === 'kraken').timeframeMap[timeframe] || '1440';
|
|
|
const pair = `${symbol.toUpperCase()}USD`;
|
|
|
return `/OHLC?pair=${pair}&interval=${interval}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
if (!data.result) return [];
|
|
|
const pair = Object.keys(data.result).find(k => k !== 'last');
|
|
|
if (!pair) return [];
|
|
|
|
|
|
return data.result[pair].map(item => ({
|
|
|
timestamp: item[0] * 1000,
|
|
|
open: parseFloat(item[1]),
|
|
|
high: parseFloat(item[2]),
|
|
|
low: parseFloat(item[3]),
|
|
|
close: parseFloat(item[4]),
|
|
|
volume: parseFloat(item[6])
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{
|
|
|
id: 'cryptocompare_minute',
|
|
|
name: 'CryptoCompare Minute',
|
|
|
baseUrl: 'https://min-api.cryptocompare.com/data/v2',
|
|
|
needsProxy: false,
|
|
|
needsAuth: true,
|
|
|
priority: 6,
|
|
|
maxLimit: 2000,
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const endpoint = timeframe.includes('m') ? 'histominute' :
|
|
|
timeframe.includes('h') ? 'histohour' : 'histoday';
|
|
|
return `/${endpoint}?fsym=${symbol.toUpperCase()}&tsym=USD&limit=${limit}&api_key=${API_KEYS.CRYPTOCOMPARE}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
if (!data.Data || !data.Data.Data) return [];
|
|
|
return data.Data.Data.map(item => ({
|
|
|
timestamp: item.time * 1000,
|
|
|
open: item.open,
|
|
|
high: item.high,
|
|
|
low: item.low,
|
|
|
close: item.close,
|
|
|
volume: item.volumefrom
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'cryptocompare_hour',
|
|
|
name: 'CryptoCompare Hour',
|
|
|
baseUrl: 'https://min-api.cryptocompare.com/data/v2',
|
|
|
needsProxy: false,
|
|
|
needsAuth: true,
|
|
|
priority: 7,
|
|
|
maxLimit: 2000,
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
return `/histohour?fsym=${symbol.toUpperCase()}&tsym=USD&limit=${limit}&api_key=${API_KEYS.CRYPTOCOMPARE}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
if (!data.Data || !data.Data.Data) return [];
|
|
|
return data.Data.Data.map(item => ({
|
|
|
timestamp: item.time * 1000,
|
|
|
open: item.open,
|
|
|
high: item.high,
|
|
|
low: item.low,
|
|
|
close: item.close,
|
|
|
volume: item.volumefrom
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'cryptocompare_day',
|
|
|
name: 'CryptoCompare Day',
|
|
|
baseUrl: 'https://min-api.cryptocompare.com/data/v2',
|
|
|
needsProxy: false,
|
|
|
needsAuth: true,
|
|
|
priority: 8,
|
|
|
maxLimit: 2000,
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
return `/histoday?fsym=${symbol.toUpperCase()}&tsym=USD&limit=${limit}&api_key=${API_KEYS.CRYPTOCOMPARE}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
if (!data.Data || !data.Data.Data) return [];
|
|
|
return data.Data.Data.map(item => ({
|
|
|
timestamp: item.time * 1000,
|
|
|
open: item.open,
|
|
|
high: item.high,
|
|
|
low: item.low,
|
|
|
close: item.close,
|
|
|
volume: item.volumefrom
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{
|
|
|
id: 'bitfinex',
|
|
|
name: 'Bitfinex Candles',
|
|
|
baseUrl: 'https://api-pub.bitfinex.com/v2',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 9,
|
|
|
maxLimit: 10000,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': '1m', '5m': '5m', '15m': '15m', '30m': '30m',
|
|
|
'1h': '1h', '4h': '4h', '1d': '1D', '1w': '7D', '1M': '1M'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const tf = OHLCV_SOURCES.find(s => s.id === 'bitfinex').timeframeMap[timeframe] || '1D';
|
|
|
const now = Date.now();
|
|
|
const start = now - (limit * this.getIntervalMs(timeframe));
|
|
|
return `/candles/trade:${tf}:t${symbol.toUpperCase()}USD/hist?limit=${limit}&start=${start}&end=${now}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
return data.map(item => ({
|
|
|
timestamp: item[0],
|
|
|
open: item[1],
|
|
|
high: item[3],
|
|
|
low: item[4],
|
|
|
close: item[2],
|
|
|
volume: item[5]
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'coinbase',
|
|
|
name: 'Coinbase Pro Candles',
|
|
|
baseUrl: 'https://api.exchange.coinbase.com',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 10,
|
|
|
maxLimit: 300,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': '60', '5m': '300', '15m': '900',
|
|
|
'1h': '3600', '4h': '14400', '1d': '86400'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const granularity = OHLCV_SOURCES.find(s => s.id === 'coinbase').timeframeMap[timeframe] || '86400';
|
|
|
const end = Math.floor(Date.now() / 1000);
|
|
|
const start = end - (limit * parseInt(granularity));
|
|
|
return `/products/${symbol.toUpperCase()}-USD/candles?granularity=${granularity}&start=${start}&end=${end}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
return data.map(item => ({
|
|
|
timestamp: item[0] * 1000,
|
|
|
low: item[1],
|
|
|
high: item[2],
|
|
|
open: item[3],
|
|
|
close: item[4],
|
|
|
volume: item[5]
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'gemini',
|
|
|
name: 'Gemini Candles',
|
|
|
baseUrl: 'https://api.gemini.com/v2',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 11,
|
|
|
maxLimit: 500,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': '1m', '5m': '5m', '15m': '15m', '30m': '30m',
|
|
|
'1h': '1hr', '4h': '6hr', '1d': '1day'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const tf = OHLCV_SOURCES.find(s => s.id === 'gemini').timeframeMap[timeframe] || '1day';
|
|
|
return `/candles/${symbol.toLowerCase()}usd/${tf}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
return data.map(item => ({
|
|
|
timestamp: item[0],
|
|
|
open: item[1],
|
|
|
high: item[2],
|
|
|
low: item[3],
|
|
|
close: item[4],
|
|
|
volume: item[5]
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'okx',
|
|
|
name: 'OKX Market Data',
|
|
|
baseUrl: 'https://www.okx.com/api/v5/market',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 12,
|
|
|
maxLimit: 300,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': '1m', '5m': '5m', '15m': '15m', '30m': '30m',
|
|
|
'1h': '1H', '4h': '4H', '1d': '1D', '1w': '1W'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const bar = OHLCV_SOURCES.find(s => s.id === 'okx').timeframeMap[timeframe] || '1D';
|
|
|
return `/candles?instId=${symbol.toUpperCase()}-USDT&bar=${bar}&limit=${limit}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
if (!data.data) return [];
|
|
|
return data.data.map(item => ({
|
|
|
timestamp: parseInt(item[0]),
|
|
|
open: parseFloat(item[1]),
|
|
|
high: parseFloat(item[2]),
|
|
|
low: parseFloat(item[3]),
|
|
|
close: parseFloat(item[4]),
|
|
|
volume: parseFloat(item[5])
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'kucoin',
|
|
|
name: 'KuCoin Market Data',
|
|
|
baseUrl: 'https://api.kucoin.com/api/v1',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 13,
|
|
|
maxLimit: 1500,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': '1min', '5m': '5min', '15m': '15min', '30m': '30min',
|
|
|
'1h': '1hour', '4h': '4hour', '1d': '1day', '1w': '1week'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const type = OHLCV_SOURCES.find(s => s.id === 'kucoin').timeframeMap[timeframe] || '1day';
|
|
|
const end = Math.floor(Date.now() / 1000);
|
|
|
const start = end - (limit * this.getIntervalSeconds(timeframe));
|
|
|
return `/market/candles?type=${type}&symbol=${symbol.toUpperCase()}-USDT&startAt=${start}&endAt=${end}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
if (!data.data) return [];
|
|
|
return data.data.map(item => ({
|
|
|
timestamp: parseInt(item[0]) * 1000,
|
|
|
open: parseFloat(item[1]),
|
|
|
close: parseFloat(item[2]),
|
|
|
high: parseFloat(item[3]),
|
|
|
low: parseFloat(item[4]),
|
|
|
volume: parseFloat(item[5])
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'bybit',
|
|
|
name: 'Bybit Market Data',
|
|
|
baseUrl: 'https://api.bybit.com/v5/market',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 14,
|
|
|
maxLimit: 200,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': '1', '5m': '5', '15m': '15', '30m': '30',
|
|
|
'1h': '60', '4h': '240', '1d': 'D', '1w': 'W', '1M': 'M'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const interval = OHLCV_SOURCES.find(s => s.id === 'bybit').timeframeMap[timeframe] || 'D';
|
|
|
return `/kline?category=spot&symbol=${symbol.toUpperCase()}USDT&interval=${interval}&limit=${limit}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
if (!data.result || !data.result.list) return [];
|
|
|
return data.result.list.map(item => ({
|
|
|
timestamp: parseInt(item[0]),
|
|
|
open: parseFloat(item[1]),
|
|
|
high: parseFloat(item[2]),
|
|
|
low: parseFloat(item[3]),
|
|
|
close: parseFloat(item[4]),
|
|
|
volume: parseFloat(item[5])
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'gate_io',
|
|
|
name: 'Gate.io Market Data',
|
|
|
baseUrl: 'https://api.gateio.ws/api/v4',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 15,
|
|
|
maxLimit: 1000,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': '1m', '5m': '5m', '15m': '15m', '30m': '30m',
|
|
|
'1h': '1h', '4h': '4h', '1d': '1d', '1w': '7d'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const interval = OHLCV_SOURCES.find(s => s.id === 'gate_io').timeframeMap[timeframe] || '1d';
|
|
|
return `/spot/candlesticks?currency_pair=${symbol.toUpperCase()}_USDT&interval=${interval}&limit=${limit}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
return data.map(item => ({
|
|
|
timestamp: parseInt(item[0]) * 1000,
|
|
|
open: parseFloat(item[5]),
|
|
|
high: parseFloat(item[3]),
|
|
|
low: parseFloat(item[4]),
|
|
|
close: parseFloat(item[2]),
|
|
|
volume: parseFloat(item[1])
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{
|
|
|
id: 'bitstamp',
|
|
|
name: 'Bitstamp OHLC',
|
|
|
baseUrl: 'https://www.bitstamp.net/api/v2',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 16,
|
|
|
maxLimit: 1000,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': '60', '5m': '300', '15m': '900', '30m': '1800',
|
|
|
'1h': '3600', '4h': '14400', '1d': '86400'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const step = OHLCV_SOURCES.find(s => s.id === 'bitstamp').timeframeMap[timeframe] || '86400';
|
|
|
return `/ohlc/${symbol.toLowerCase()}usd/?step=${step}&limit=${limit}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
if (!data.data || !data.data.ohlc) return [];
|
|
|
return data.data.ohlc.map(item => ({
|
|
|
timestamp: parseInt(item.timestamp) * 1000,
|
|
|
open: parseFloat(item.open),
|
|
|
high: parseFloat(item.high),
|
|
|
low: parseFloat(item.low),
|
|
|
close: parseFloat(item.close),
|
|
|
volume: parseFloat(item.volume)
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'mexc',
|
|
|
name: 'MEXC Market Data',
|
|
|
baseUrl: 'https://api.mexc.com/api/v3',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 17,
|
|
|
maxLimit: 1000,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': '1m', '5m': '5m', '15m': '15m', '30m': '30m',
|
|
|
'1h': '1h', '4h': '4h', '1d': '1d', '1w': '1w', '1M': '1M'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const interval = OHLCV_SOURCES.find(s => s.id === 'mexc').timeframeMap[timeframe] || '1d';
|
|
|
return `/klines?symbol=${symbol.toUpperCase()}USDT&interval=${interval}&limit=${limit}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
return data.map(item => ({
|
|
|
timestamp: item[0],
|
|
|
open: parseFloat(item[1]),
|
|
|
high: parseFloat(item[2]),
|
|
|
low: parseFloat(item[3]),
|
|
|
close: parseFloat(item[4]),
|
|
|
volume: parseFloat(item[5])
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'huobi',
|
|
|
name: 'Huobi Market Data',
|
|
|
baseUrl: 'https://api.huobi.pro/market',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 18,
|
|
|
maxLimit: 2000,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': '1min', '5m': '5min', '15m': '15min', '30m': '30min',
|
|
|
'1h': '60min', '4h': '4hour', '1d': '1day', '1w': '1week', '1M': '1mon'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const period = OHLCV_SOURCES.find(s => s.id === 'huobi').timeframeMap[timeframe] || '1day';
|
|
|
return `/history/kline?symbol=${symbol.toLowerCase()}usdt&period=${period}&size=${limit}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
if (!data.data) return [];
|
|
|
return data.data.map(item => ({
|
|
|
timestamp: item.id * 1000,
|
|
|
open: item.open,
|
|
|
high: item.high,
|
|
|
low: item.low,
|
|
|
close: item.close,
|
|
|
volume: item.vol
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'defillama',
|
|
|
name: 'DefiLlama Charts',
|
|
|
baseUrl: 'https://coins.llama.fi',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 19,
|
|
|
maxLimit: 365,
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const span = limit * this.getIntervalSeconds(timeframe);
|
|
|
const start = Math.floor(Date.now() / 1000) - span;
|
|
|
return `/chart/coingecko:${symbol.toLowerCase()}?start=${start}&span=${limit}&period=1d`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
if (!data.coins) return [];
|
|
|
const coinKey = Object.keys(data.coins)[0];
|
|
|
if (!coinKey || !data.coins[coinKey].prices) return [];
|
|
|
|
|
|
return data.coins[coinKey].prices.map(item => ({
|
|
|
timestamp: item.timestamp * 1000,
|
|
|
open: item.price,
|
|
|
high: item.price,
|
|
|
low: item.price,
|
|
|
close: item.price,
|
|
|
volume: null
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'bitget',
|
|
|
name: 'Bitget Market Data',
|
|
|
baseUrl: 'https://api.bitget.com/api/spot/v1',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 20,
|
|
|
maxLimit: 1000,
|
|
|
|
|
|
timeframeMap: {
|
|
|
'1m': '1m', '5m': '5m', '15m': '15m', '30m': '30m',
|
|
|
'1h': '1h', '4h': '4h', '1d': '1day', '1w': '1week'
|
|
|
},
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const period = OHLCV_SOURCES.find(s => s.id === 'bitget').timeframeMap[timeframe] || '1day';
|
|
|
const end = Date.now();
|
|
|
const start = end - (limit * this.getIntervalMs(timeframe));
|
|
|
return `/market/candles?symbol=${symbol.toUpperCase()}USDT_SPBL&period=${period}&after=${start}&before=${end}&limit=${limit}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
if (!data.data) return [];
|
|
|
return data.data.map(item => ({
|
|
|
timestamp: parseInt(item[0]),
|
|
|
open: parseFloat(item[1]),
|
|
|
high: parseFloat(item[2]),
|
|
|
low: parseFloat(item[3]),
|
|
|
close: parseFloat(item[4]),
|
|
|
volume: parseFloat(item[5])
|
|
|
}));
|
|
|
}
|
|
|
},
|
|
|
|
|
|
{
|
|
|
id: 'messari',
|
|
|
name: 'Messari Timeseries',
|
|
|
baseUrl: 'https://data.messari.io/api/v1',
|
|
|
needsProxy: false,
|
|
|
needsAuth: false,
|
|
|
priority: 21,
|
|
|
maxLimit: 2000,
|
|
|
|
|
|
buildUrl: (symbol, timeframe, limit) => {
|
|
|
const interval = timeframe.includes('h') ? '1h' : '1d';
|
|
|
const start = new Date(Date.now() - (limit * this.getIntervalMs(timeframe))).toISOString();
|
|
|
const end = new Date().toISOString();
|
|
|
return `/assets/${symbol.toLowerCase()}/metrics/price/time-series?start=${start}&end=${end}&interval=${interval}`;
|
|
|
},
|
|
|
|
|
|
parseResponse: (data) => {
|
|
|
if (!data.data || !data.data.values) return [];
|
|
|
return data.data.values.map(item => ({
|
|
|
timestamp: item[0],
|
|
|
open: item[1],
|
|
|
high: item[1],
|
|
|
low: item[1],
|
|
|
close: item[1],
|
|
|
volume: null
|
|
|
}));
|
|
|
}
|
|
|
}
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getIntervalMs(timeframe) {
|
|
|
const map = {
|
|
|
'1m': 60 * 1000,
|
|
|
'5m': 5 * 60 * 1000,
|
|
|
'15m': 15 * 60 * 1000,
|
|
|
'30m': 30 * 60 * 1000,
|
|
|
'1h': 60 * 60 * 1000,
|
|
|
'4h': 4 * 60 * 60 * 1000,
|
|
|
'1d': 24 * 60 * 60 * 1000,
|
|
|
'1w': 7 * 24 * 60 * 60 * 1000,
|
|
|
'1M': 30 * 24 * 60 * 60 * 1000
|
|
|
};
|
|
|
return map[timeframe] || map['1d'];
|
|
|
}
|
|
|
|
|
|
function getIntervalSeconds(timeframe) {
|
|
|
return Math.floor(getIntervalMs(timeframe) / 1000);
|
|
|
}
|
|
|
|
|
|
async function fetchWithTimeout(url, options = {}, timeout = 15000) {
|
|
|
const controller = new AbortController();
|
|
|
const id = setTimeout(() => controller.abort(), timeout);
|
|
|
|
|
|
try {
|
|
|
const response = await fetch(url, {
|
|
|
...options,
|
|
|
signal: controller.signal
|
|
|
});
|
|
|
clearTimeout(id);
|
|
|
return response;
|
|
|
} catch (error) {
|
|
|
clearTimeout(id);
|
|
|
throw error;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OHLCVClient {
|
|
|
constructor() {
|
|
|
this.cache = new Map();
|
|
|
this.cacheTimeout = 60000;
|
|
|
this.requestLog = [];
|
|
|
this.sources = OHLCV_SOURCES.sort((a, b) => a.priority - b.priority);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async getOHLCV(symbol, timeframe = '1d', limit = 100) {
|
|
|
const cacheKey = `ohlcv_${symbol}_${timeframe}_${limit}`;
|
|
|
|
|
|
|
|
|
const cached = this.getCached(cacheKey);
|
|
|
if (cached) {
|
|
|
console.log(`π¦ Using cached OHLCV data for ${symbol} ${timeframe}`);
|
|
|
return cached;
|
|
|
}
|
|
|
|
|
|
console.log(`π Fetching OHLCV: ${symbol} ${timeframe} (${limit} candles)`);
|
|
|
console.log(`π Trying ${this.sources.length} sources...`);
|
|
|
|
|
|
|
|
|
for (const source of this.sources) {
|
|
|
try {
|
|
|
console.log(`π [${source.priority}/${this.sources.length}] Trying ${source.name}...`);
|
|
|
|
|
|
|
|
|
const endpoint = source.buildUrl(symbol, timeframe, Math.min(limit, source.maxLimit));
|
|
|
const url = `${source.baseUrl}${endpoint}`;
|
|
|
|
|
|
|
|
|
const response = await fetchWithTimeout(url, {}, 15000);
|
|
|
|
|
|
if (!response.ok) {
|
|
|
throw new Error(`HTTP ${response.status}`);
|
|
|
}
|
|
|
|
|
|
const rawData = await response.json();
|
|
|
|
|
|
|
|
|
const ohlcv = source.parseResponse(rawData);
|
|
|
|
|
|
|
|
|
if (!ohlcv || ohlcv.length === 0) {
|
|
|
throw new Error('Empty dataset');
|
|
|
}
|
|
|
|
|
|
|
|
|
ohlcv.sort((a, b) => a.timestamp - b.timestamp);
|
|
|
|
|
|
|
|
|
const result = ohlcv.slice(-limit);
|
|
|
|
|
|
|
|
|
this.setCache(cacheKey, result);
|
|
|
this.logRequest(source.name, true, result.length);
|
|
|
|
|
|
console.log(`β
SUCCESS: ${source.name} returned ${result.length} candles`);
|
|
|
console.log(` Date Range: ${new Date(result[0].timestamp).toLocaleDateString()} β ${new Date(result[result.length - 1].timestamp).toLocaleDateString()}`);
|
|
|
|
|
|
return result;
|
|
|
|
|
|
} catch (error) {
|
|
|
console.warn(`β ${source.name} failed:`, error.message);
|
|
|
this.logRequest(source.name, false, error.message);
|
|
|
continue;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
throw new Error(`All ${this.sources.length} OHLCV sources failed for ${symbol} ${timeframe}`);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async getFromSource(sourceId, symbol, timeframe = '1d', limit = 100) {
|
|
|
const source = this.sources.find(s => s.id === sourceId);
|
|
|
if (!source) {
|
|
|
throw new Error(`Source '${sourceId}' not found`);
|
|
|
}
|
|
|
|
|
|
console.log(`π― Direct request to ${source.name}...`);
|
|
|
|
|
|
const endpoint = source.buildUrl(symbol, timeframe, Math.min(limit, source.maxLimit));
|
|
|
const url = `${source.baseUrl}${endpoint}`;
|
|
|
|
|
|
const response = await fetchWithTimeout(url);
|
|
|
if (!response.ok) {
|
|
|
throw new Error(`HTTP ${response.status}`);
|
|
|
}
|
|
|
|
|
|
const rawData = await response.json();
|
|
|
const ohlcv = source.parseResponse(rawData);
|
|
|
|
|
|
console.log(`β
${source.name}: ${ohlcv.length} candles`);
|
|
|
return ohlcv;
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async getMultiSource(symbol, timeframe = '1d', limit = 100, sourceCount = 3) {
|
|
|
console.log(`π Fetching from ${sourceCount} sources in parallel...`);
|
|
|
|
|
|
const promises = this.sources.slice(0, sourceCount).map(async (source) => {
|
|
|
try {
|
|
|
const endpoint = source.buildUrl(symbol, timeframe, Math.min(limit, source.maxLimit));
|
|
|
const url = `${source.baseUrl}${endpoint}`;
|
|
|
const response = await fetchWithTimeout(url, {}, 10000);
|
|
|
|
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
|
|
|
|
const rawData = await response.json();
|
|
|
const ohlcv = source.parseResponse(rawData);
|
|
|
|
|
|
return {
|
|
|
source: source.name,
|
|
|
sourceId: source.id,
|
|
|
data: ohlcv.slice(-limit),
|
|
|
success: true
|
|
|
};
|
|
|
} catch (error) {
|
|
|
return {
|
|
|
source: source.name,
|
|
|
sourceId: source.id,
|
|
|
error: error.message,
|
|
|
success: false
|
|
|
};
|
|
|
}
|
|
|
});
|
|
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
|
|
|
|
const successful = results
|
|
|
.filter(r => r.status === 'fulfilled' && r.value.success)
|
|
|
.map(r => r.value);
|
|
|
|
|
|
const failed = results
|
|
|
.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && !r.value.success))
|
|
|
.map(r => r.status === 'fulfilled' ? r.value : { source: 'unknown', error: r.reason?.message });
|
|
|
|
|
|
console.log(`β
Successful: ${successful.length}/${sourceCount}`);
|
|
|
console.log(`β Failed: ${failed.length}/${sourceCount}`);
|
|
|
|
|
|
return {
|
|
|
successful,
|
|
|
failed,
|
|
|
total: sourceCount
|
|
|
};
|
|
|
}
|
|
|
|
|
|
|
|
|
getCached(key) {
|
|
|
const cached = this.cache.get(key);
|
|
|
if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
|
|
|
return cached.data;
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
setCache(key, data) {
|
|
|
this.cache.set(key, {
|
|
|
data,
|
|
|
timestamp: Date.now()
|
|
|
});
|
|
|
}
|
|
|
|
|
|
clearCache() {
|
|
|
this.cache.clear();
|
|
|
console.log('β
OHLCV cache cleared');
|
|
|
}
|
|
|
|
|
|
|
|
|
logRequest(source, success, detail) {
|
|
|
this.requestLog.push({
|
|
|
source,
|
|
|
success,
|
|
|
detail,
|
|
|
timestamp: new Date().toISOString()
|
|
|
});
|
|
|
|
|
|
if (this.requestLog.length > 200) {
|
|
|
this.requestLog.shift();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getStats() {
|
|
|
const total = this.requestLog.length;
|
|
|
const successful = this.requestLog.filter(r => r.success).length;
|
|
|
const failed = total - successful;
|
|
|
const successRate = total > 0 ? ((successful / total) * 100).toFixed(1) : 0;
|
|
|
|
|
|
|
|
|
const bySource = {};
|
|
|
this.requestLog.forEach(req => {
|
|
|
if (!bySource[req.source]) {
|
|
|
bySource[req.source] = { success: 0, failed: 0 };
|
|
|
}
|
|
|
if (req.success) {
|
|
|
bySource[req.source].success++;
|
|
|
} else {
|
|
|
bySource[req.source].failed++;
|
|
|
}
|
|
|
});
|
|
|
|
|
|
return {
|
|
|
total,
|
|
|
successful,
|
|
|
failed,
|
|
|
successRate: `${successRate}%`,
|
|
|
cacheSize: this.cache.size,
|
|
|
sourceStats: bySource,
|
|
|
recentRequests: this.requestLog.slice(-20),
|
|
|
availableSources: this.sources.length
|
|
|
};
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
listSources() {
|
|
|
return this.sources.map(s => ({
|
|
|
id: s.id,
|
|
|
name: s.name,
|
|
|
priority: s.priority,
|
|
|
maxLimit: s.maxLimit,
|
|
|
needsAuth: s.needsAuth || false,
|
|
|
needsProxy: s.needsProxy || false
|
|
|
}));
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async testAllSources(symbol, timeframe = '1d', limit = 10) {
|
|
|
console.log(`π§ͺ Testing all ${this.sources.length} sources for ${symbol} ${timeframe}...`);
|
|
|
console.log('β'.repeat(60));
|
|
|
|
|
|
const results = [];
|
|
|
|
|
|
for (const source of this.sources) {
|
|
|
try {
|
|
|
const startTime = Date.now();
|
|
|
const data = await this.getFromSource(source.id, symbol, timeframe, limit);
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
|
|
results.push({
|
|
|
source: source.name,
|
|
|
status: 'SUCCESS',
|
|
|
candles: data.length,
|
|
|
duration: `${duration}ms`,
|
|
|
priority: source.priority
|
|
|
});
|
|
|
|
|
|
console.log(`β
[${source.priority}] ${source.name}: ${data.length} candles (${duration}ms)`);
|
|
|
|
|
|
} catch (error) {
|
|
|
results.push({
|
|
|
source: source.name,
|
|
|
status: 'FAILED',
|
|
|
error: error.message,
|
|
|
priority: source.priority
|
|
|
});
|
|
|
|
|
|
console.log(`β [${source.priority}] ${source.name}: ${error.message}`);
|
|
|
}
|
|
|
|
|
|
|
|
|
await new Promise(r => setTimeout(r, 200));
|
|
|
}
|
|
|
|
|
|
console.log('β'.repeat(60));
|
|
|
const successCount = results.filter(r => r.status === 'SUCCESS').length;
|
|
|
console.log(`π Results: ${successCount}/${results.length} sources working`);
|
|
|
|
|
|
return results;
|
|
|
}
|
|
|
|
|
|
|
|
|
getIntervalMs(timeframe) {
|
|
|
return getIntervalMs(timeframe);
|
|
|
}
|
|
|
|
|
|
getIntervalSeconds(timeframe) {
|
|
|
return getIntervalSeconds(timeframe);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const ohlcvClient = new OHLCVClient();
|
|
|
export default ohlcvClient;
|
|
|
|
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
window.ohlcvClient = ohlcvClient;
|
|
|
}
|
|
|
|
|
|
|