108 lines
3.5 KiB
TypeScript
108 lines
3.5 KiB
TypeScript
import type { Candle } from '../connectors/types.js';
|
|
|
|
function normalizeCryptoProductId(symbol: string): string {
|
|
const upper = String(symbol || '').trim().toUpperCase();
|
|
if (!upper) return upper;
|
|
if (upper.includes('/')) return upper.replace('/', '-');
|
|
if (upper.endsWith('USDT')) return `${upper.slice(0, -4)}-USDT`;
|
|
if (upper.endsWith('USD')) return `${upper.slice(0, -3)}-USD`;
|
|
return upper;
|
|
}
|
|
|
|
function toCoinbaseGranularity(timeframe: string): number {
|
|
const normalized = String(timeframe || '').trim().toLowerCase();
|
|
switch (normalized) {
|
|
case '1m':
|
|
case '1min':
|
|
return 60;
|
|
case '5m':
|
|
case '5min':
|
|
return 300;
|
|
case '15m':
|
|
case '15min':
|
|
return 900;
|
|
case '1h':
|
|
case '1hour':
|
|
return 3600;
|
|
case '4h':
|
|
case '4hour':
|
|
return 3600;
|
|
case '1d':
|
|
case '1day':
|
|
return 86400;
|
|
case '1w':
|
|
case '1week':
|
|
return 86400;
|
|
case '1month':
|
|
return 86400;
|
|
default:
|
|
return 3600;
|
|
}
|
|
}
|
|
|
|
function aggregateBars(candles: Candle[], factor: number): Candle[] {
|
|
const aggregated: Candle[] = [];
|
|
for (let i = 0; i < candles.length; i += factor) {
|
|
const chunk = candles.slice(i, i + factor);
|
|
if (!chunk.length) continue;
|
|
aggregated.push({
|
|
timestamp: chunk[0].timestamp,
|
|
open: chunk[0].open,
|
|
high: Math.max(...chunk.map((c) => c.high)),
|
|
low: Math.min(...chunk.map((c) => c.low)),
|
|
close: chunk[chunk.length - 1].close,
|
|
volume: chunk.reduce((sum, c) => sum + c.volume, 0),
|
|
});
|
|
}
|
|
return aggregated;
|
|
}
|
|
|
|
export async function fetchCoinbaseCryptoCandles(params: {
|
|
symbol: string;
|
|
timeframe: string;
|
|
start?: string;
|
|
end?: string;
|
|
limit?: number;
|
|
}): Promise<Candle[]> {
|
|
const productId = normalizeCryptoProductId(params.symbol);
|
|
const granularity = toCoinbaseGranularity(params.timeframe);
|
|
const query = new URLSearchParams({
|
|
granularity: String(granularity),
|
|
});
|
|
if (params.start) query.set('start', params.start);
|
|
if (params.end) query.set('end', params.end);
|
|
|
|
const response = await fetch(`https://api.exchange.coinbase.com/products/${encodeURIComponent(productId)}/candles?${query.toString()}`);
|
|
if (!response.ok) {
|
|
const txt = await response.text().catch(() => '');
|
|
throw new Error(`Coinbase candles fetch failed: ${response.status} ${txt || response.statusText}`);
|
|
}
|
|
|
|
const payload = await response.json() as any[];
|
|
const candles: Candle[] = Array.isArray(payload)
|
|
? payload.map((row: any) => ({
|
|
timestamp: Number(row[0]) * 1000,
|
|
low: Number(row[1]),
|
|
high: Number(row[2]),
|
|
open: Number(row[3]),
|
|
close: Number(row[4]),
|
|
volume: Number(row[5] || 0),
|
|
}))
|
|
: [];
|
|
|
|
const sorted = candles
|
|
.filter((c) => Number.isFinite(c.timestamp) && Number.isFinite(c.open) && Number.isFinite(c.high) && Number.isFinite(c.low) && Number.isFinite(c.close))
|
|
.sort((a, b) => a.timestamp - b.timestamp);
|
|
|
|
const normalizedTimeframe = String(params.timeframe || '').trim().toLowerCase();
|
|
const aggregated = normalizedTimeframe === '4h' || normalizedTimeframe === '4hour'
|
|
? aggregateBars(sorted, 4)
|
|
: sorted;
|
|
|
|
if (params.limit && params.limit > 0 && aggregated.length > params.limit) {
|
|
return aggregated.slice(-params.limit);
|
|
}
|
|
|
|
return aggregated;
|
|
}
|