--- id: "ET-TRD-001" title: "Market Data Integration" type: "Specification" status: "Done" rf_parent: "RF-TRD-001" epic: "OQI-003" version: "1.0" created_date: "2025-12-05" updated_date: "2026-01-04" --- # ET-TRD-001: Especificación Técnica - Market Data Integration **Version:** 1.0.0 **Fecha:** 2025-12-05 **Estado:** Pendiente **Épica:** [OQI-003](../_MAP.md) **Requerimiento:** RF-TRD-001 --- ## Resumen Esta especificación detalla la implementación técnica del sistema de obtención y gestión de datos de mercado en tiempo real desde Binance API, incluyendo caching, rate limiting y optimización de consultas. --- ## Arquitectura ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ FRONTEND │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ │ TradingPage.tsx │───▶│ ChartComponent │───▶│ tradingStore │ │ │ │ │ │ .tsx │ │ (Zustand) │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ └────────────────────────────────┬────────────────────────────────────────┘ │ │ HTTPS/WSS ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ BACKEND │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ │ market.routes │───▶│ binance.service │───▶│ cache.service │ │ │ │ .ts │ │ .ts │ │ .ts (Redis) │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │ │ │ │ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ PostgreSQL (trading schema) │ │ │ │ ┌──────────────┐ ┌────────────────┐ ┌──────────────────┐ │ │ │ │ │ market_data │ │ rate_limits │ │ api_requests │ │ │ │ │ └──────────────┘ └────────────────┘ └──────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ BINANCE API │ │ ┌─────────────────────┐ ┌────────────────────────────────────────┐ │ │ │ REST API │ │ WebSocket API │ │ │ │ /api/v3/klines │ │ wss://stream.binance.com:9443/ws │ │ │ │ /api/v3/ticker/24hr │ │ @kline_ │ │ │ │ /api/v3/depth │ │ @ticker │ │ │ └─────────────────────┘ └────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ ``` --- ## Componentes ### 1. Binance Service (`binance.service.ts`) **Ubicación:** `apps/backend/src/modules/trading/services/binance.service.ts` ```typescript import axios, { AxiosInstance } from 'axios'; import { createHmac } from 'crypto'; export interface Kline { openTime: number; open: string; high: string; low: string; close: string; volume: string; closeTime: number; quoteVolume: string; trades: number; takerBuyBaseVolume: string; takerBuyQuoteVolume: string; } export interface Ticker24hr { symbol: string; priceChange: string; priceChangePercent: string; weightedAvgPrice: string; lastPrice: string; lastQty: string; bidPrice: string; bidQty: string; askPrice: string; askQty: string; openPrice: string; highPrice: string; lowPrice: string; volume: string; quoteVolume: string; openTime: number; closeTime: number; firstId: number; lastId: number; count: number; } export interface OrderBook { lastUpdateId: number; bids: [string, string][]; // [price, quantity] asks: [string, string][]; } export class BinanceService { private client: AxiosInstance; private apiKey: string; private apiSecret: string; private baseURL = 'https://api.binance.com'; constructor() { this.apiKey = process.env.BINANCE_API_KEY || ''; this.apiSecret = process.env.BINANCE_API_SECRET || ''; this.client = axios.create({ baseURL: this.baseURL, timeout: 10000, headers: { 'X-MBX-APIKEY': this.apiKey, }, }); // Request interceptor para rate limiting this.client.interceptors.request.use( async (config) => { await this.checkRateLimit(); return config; } ); } // Obtener klines/candles históricos async getKlines(params: { symbol: string; interval: KlineInterval; startTime?: number; endTime?: number; limit?: number; // Max 1000 }): Promise { const response = await this.client.get('/api/v3/klines', { params }); return response.data.map(this.parseKline); } // Obtener ticker 24hr async getTicker24hr(symbol: string): Promise { const response = await this.client.get('/api/v3/ticker/24hr', { params: { symbol }, }); return response.data; } // Obtener múltiples tickers async getAllTickers(): Promise { const response = await this.client.get('/api/v3/ticker/24hr'); return response.data; } // Obtener order book async getOrderBook(symbol: string, limit: number = 100): Promise { const response = await this.client.get('/api/v3/depth', { params: { symbol, limit }, }); return response.data; } // Obtener precio actual async getCurrentPrice(symbol: string): Promise { const response = await this.client.get('/api/v3/ticker/price', { params: { symbol }, }); return response.data.price; } // Obtener información de símbolos disponibles async getExchangeInfo(symbol?: string): Promise { const params = symbol ? { symbol } : {}; const response = await this.client.get('/api/v3/exchangeInfo', { params }); return response.data; } // Verificar conectividad async ping(): Promise { try { await this.client.get('/api/v3/ping'); return true; } catch { return false; } } // Obtener tiempo del servidor async getServerTime(): Promise { const response = await this.client.get('/api/v3/time'); return response.data.serverTime; } private parseKline(data: any[]): Kline { return { openTime: data[0], open: data[1], high: data[2], low: data[3], close: data[4], volume: data[5], closeTime: data[6], quoteVolume: data[7], trades: data[8], takerBuyBaseVolume: data[9], takerBuyQuoteVolume: data[10], }; } private async checkRateLimit(): Promise { // Implementar rate limiting según límites de Binance // 1200 requests per minute con weight } } export type KlineInterval = | '1s' | '1m' | '3m' | '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '8h' | '12h' | '1d' | '3d' | '1w' | '1M'; ``` ### 2. Cache Service (`cache.service.ts`) **Ubicación:** `apps/backend/src/modules/trading/services/cache.service.ts` ```typescript import Redis from 'ioredis'; export class MarketDataCacheService { private redis: Redis; constructor() { this.redis = new Redis({ host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379'), password: process.env.REDIS_PASSWORD, db: 1, // Usar DB 1 para market data }); } // Cache para klines async cacheKlines( symbol: string, interval: string, klines: Kline[], ttl: number = 60 ): Promise { const key = `klines:${symbol}:${interval}`; await this.redis.setex(key, ttl, JSON.stringify(klines)); } async getCachedKlines( symbol: string, interval: string ): Promise { const key = `klines:${symbol}:${interval}`; const data = await this.redis.get(key); return data ? JSON.parse(data) : null; } // Cache para ticker async cacheTicker(symbol: string, ticker: Ticker24hr, ttl: number = 5): Promise { const key = `ticker:${symbol}`; await this.redis.setex(key, ttl, JSON.stringify(ticker)); } async getCachedTicker(symbol: string): Promise { const key = `ticker:${symbol}`; const data = await this.redis.get(key); return data ? JSON.parse(data) : null; } // Cache para order book async cacheOrderBook( symbol: string, orderBook: OrderBook, ttl: number = 2 ): Promise { const key = `orderbook:${symbol}`; await this.redis.setex(key, ttl, JSON.stringify(orderBook)); } async getCachedOrderBook(symbol: string): Promise { const key = `orderbook:${symbol}`; const data = await this.redis.get(key); return data ? JSON.parse(data) : null; } // Cache para lista de símbolos async cacheSymbols(symbols: any[], ttl: number = 3600): Promise { await this.redis.setex('symbols:all', ttl, JSON.stringify(symbols)); } async getCachedSymbols(): Promise { const data = await this.redis.get('symbols:all'); return data ? JSON.parse(data) : null; } // Invalidar cache async invalidateKlines(symbol: string, interval?: string): Promise { if (interval) { await this.redis.del(`klines:${symbol}:${interval}`); } else { const keys = await this.redis.keys(`klines:${symbol}:*`); if (keys.length > 0) { await this.redis.del(...keys); } } } async invalidateTicker(symbol: string): Promise { await this.redis.del(`ticker:${symbol}`); } } ``` ### 3. Market Data Routes **Ubicación:** `apps/backend/src/modules/trading/market.routes.ts` ```typescript import { Router } from 'express'; import { MarketDataController } from './controllers/market-data.controller'; import { authenticate } from '@/middleware/auth'; import { validateRequest } from '@/middleware/validation'; import { getKlinesSchema, getTickerSchema } from './validation/market.schemas'; const router = Router(); const controller = new MarketDataController(); // Obtener klines/candles router.get( '/klines', authenticate, validateRequest(getKlinesSchema), controller.getKlines ); // Obtener ticker 24hr router.get( '/ticker/:symbol', authenticate, controller.getTicker ); // Obtener todos los tickers router.get( '/tickers', authenticate, controller.getAllTickers ); // Obtener order book router.get( '/orderbook/:symbol', authenticate, controller.getOrderBook ); // Obtener precio actual router.get( '/price/:symbol', authenticate, controller.getCurrentPrice ); // Obtener símbolos disponibles router.get( '/symbols', authenticate, controller.getSymbols ); // Health check router.get( '/health', controller.healthCheck ); export default router; ``` --- ## Configuración ### Variables de Entorno ```bash # .env # Binance API BINANCE_API_KEY=your_api_key_here BINANCE_API_SECRET=your_api_secret_here BINANCE_BASE_URL=https://api.binance.com BINANCE_WS_URL=wss://stream.binance.com:9443/ws # Redis Cache REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DB_MARKET_DATA=1 # Rate Limiting BINANCE_MAX_REQUESTS_PER_MINUTE=1200 BINANCE_MAX_WEIGHT_PER_MINUTE=6000 ``` ### Rate Limiting Configuration ```typescript // config/rate-limit.config.ts export const binanceRateLimits = { // Request limits requests: { perMinute: 1200, perSecond: 20, }, // Weight limits weight: { perMinute: 6000, }, // Endpoint weights weights: { '/api/v3/klines': 1, '/api/v3/ticker/24hr': 1, // single symbol '/api/v3/ticker/24hr_all': 40, // all symbols '/api/v3/depth': (limit: number) => { if (limit <= 100) return 1; if (limit <= 500) return 5; if (limit <= 1000) return 10; return 50; }, }, }; ``` --- ## Esquema de Base de Datos ```sql -- Schema trading CREATE SCHEMA IF NOT EXISTS trading; -- Tabla para cachear datos históricos (opcional) CREATE TABLE trading.market_data ( id BIGSERIAL PRIMARY KEY, symbol VARCHAR(20) NOT NULL, interval VARCHAR(10) NOT NULL, open_time BIGINT NOT NULL, open DECIMAL(20, 8) NOT NULL, high DECIMAL(20, 8) NOT NULL, low DECIMAL(20, 8) NOT NULL, close DECIMAL(20, 8) NOT NULL, volume DECIMAL(20, 8) NOT NULL, close_time BIGINT NOT NULL, quote_volume DECIMAL(20, 8), trades INTEGER, taker_buy_base_volume DECIMAL(20, 8), taker_buy_quote_volume DECIMAL(20, 8), created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(symbol, interval, open_time) ); -- Índices CREATE INDEX idx_market_data_symbol ON trading.market_data(symbol); CREATE INDEX idx_market_data_interval ON trading.market_data(interval); CREATE INDEX idx_market_data_time ON trading.market_data(open_time DESC); CREATE INDEX idx_market_data_composite ON trading.market_data(symbol, interval, open_time DESC); -- Tabla para tracking de rate limits CREATE TABLE trading.rate_limits ( id SERIAL PRIMARY KEY, endpoint VARCHAR(100) NOT NULL, window_start TIMESTAMPTZ NOT NULL, request_count INTEGER DEFAULT 0, weight_used INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(endpoint, window_start) ); -- Tabla para log de requests (debugging) CREATE TABLE trading.api_requests ( id BIGSERIAL PRIMARY KEY, user_id UUID REFERENCES public.users(id), endpoint VARCHAR(200) NOT NULL, method VARCHAR(10) NOT NULL, params JSONB, response_time INTEGER, -- ms status_code INTEGER, error TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_api_requests_user ON trading.api_requests(user_id); CREATE INDEX idx_api_requests_created ON trading.api_requests(created_at DESC); ``` --- ## Implementación del Controller ```typescript // controllers/market-data.controller.ts import { Request, Response } from 'express'; import { BinanceService } from '../services/binance.service'; import { MarketDataCacheService } from '../services/cache.service'; export class MarketDataController { private binanceService: BinanceService; private cacheService: MarketDataCacheService; constructor() { this.binanceService = new BinanceService(); this.cacheService = new MarketDataCacheService(); } getKlines = async (req: Request, res: Response) => { const { symbol, interval, startTime, endTime, limit } = req.query; try { // Verificar cache const cached = await this.cacheService.getCachedKlines( symbol as string, interval as string ); if (cached) { return res.json({ data: cached, cached: true, }); } // Obtener de Binance const klines = await this.binanceService.getKlines({ symbol: symbol as string, interval: interval as KlineInterval, startTime: startTime ? parseInt(startTime as string) : undefined, endTime: endTime ? parseInt(endTime as string) : undefined, limit: limit ? parseInt(limit as string) : 500, }); // Cachear resultado await this.cacheService.cacheKlines( symbol as string, interval as string, klines, this.getCacheTTL(interval as string) ); res.json({ data: klines, cached: false, }); } catch (error) { res.status(500).json({ error: error.message }); } }; getTicker = async (req: Request, res: Response) => { const { symbol } = req.params; try { // Verificar cache const cached = await this.cacheService.getCachedTicker(symbol); if (cached) { return res.json({ data: cached, cached: true }); } // Obtener de Binance const ticker = await this.binanceService.getTicker24hr(symbol); // Cachear (TTL corto para datos en tiempo real) await this.cacheService.cacheTicker(symbol, ticker, 5); res.json({ data: ticker, cached: false }); } catch (error) { res.status(500).json({ error: error.message }); } }; getAllTickers = async (req: Request, res: Response) => { try { const tickers = await this.binanceService.getAllTickers(); res.json({ data: tickers }); } catch (error) { res.status(500).json({ error: error.message }); } }; getOrderBook = async (req: Request, res: Response) => { const { symbol } = req.params; const { limit = 100 } = req.query; try { const cached = await this.cacheService.getCachedOrderBook(symbol); if (cached) { return res.json({ data: cached, cached: true }); } const orderBook = await this.binanceService.getOrderBook( symbol, parseInt(limit as string) ); await this.cacheService.cacheOrderBook(symbol, orderBook, 2); res.json({ data: orderBook, cached: false }); } catch (error) { res.status(500).json({ error: error.message }); } }; getCurrentPrice = async (req: Request, res: Response) => { const { symbol } = req.params; try { const price = await this.binanceService.getCurrentPrice(symbol); res.json({ symbol, price }); } catch (error) { res.status(500).json({ error: error.message }); } }; getSymbols = async (req: Request, res: Response) => { try { const cached = await this.cacheService.getCachedSymbols(); if (cached) { return res.json({ data: cached, cached: true }); } const info = await this.binanceService.getExchangeInfo(); const symbols = info.symbols.filter(s => s.status === 'TRADING'); await this.cacheService.cacheSymbols(symbols, 3600); res.json({ data: symbols, cached: false }); } catch (error) { res.status(500).json({ error: error.message }); } }; healthCheck = async (req: Request, res: Response) => { try { const isHealthy = await this.binanceService.ping(); res.json({ status: isHealthy ? 'healthy' : 'unhealthy' }); } catch (error) { res.status(503).json({ status: 'unhealthy', error: error.message }); } }; private getCacheTTL(interval: string): number { const ttls = { '1s': 1, '1m': 5, '5m': 30, '15m': 60, '1h': 300, '1d': 3600, }; return ttls[interval] || 60; } } ``` --- ## Seguridad ### API Key Management ```typescript // services/api-key.service.ts import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; export class ApiKeyService { private algorithm = 'aes-256-gcm'; private key: Buffer; constructor() { this.key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex'); } encryptApiKey(apiKey: string): string { const iv = randomBytes(16); const cipher = createCipheriv(this.algorithm, this.key, iv); let encrypted = cipher.update(apiKey, 'utf8', 'hex'); encrypted += cipher.final('hex'); const authTag = cipher.getAuthTag(); return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; } decryptApiKey(encrypted: string): string { const [ivHex, authTagHex, encryptedData] = encrypted.split(':'); const iv = Buffer.from(ivHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex'); const decipher = createDecipheriv(this.algorithm, this.key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(encryptedData, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } } ``` ### Rate Limiting Middleware ```typescript // middleware/rate-limit.middleware.ts import { Request, Response, NextFunction } from 'express'; import Redis from 'ioredis'; const redis = new Redis(); export async function rateLimitMiddleware( req: Request, res: Response, next: NextFunction ) { const userId = req.user?.id; const key = `rate_limit:${userId}:${Date.now() / 60000 | 0}`; const current = await redis.incr(key); if (current === 1) { await redis.expire(key, 60); } const limit = 100; // requests per minute per user if (current > limit) { return res.status(429).json({ error: 'Rate limit exceeded', retryAfter: 60, }); } res.setHeader('X-RateLimit-Limit', limit.toString()); res.setHeader('X-RateLimit-Remaining', (limit - current).toString()); next(); } ``` --- ## Testing ### Unit Tests ```typescript describe('BinanceService', () => { let service: BinanceService; beforeEach(() => { service = new BinanceService(); }); describe('getKlines', () => { it('should fetch klines data', async () => { const params = { symbol: 'BTCUSDT', interval: '1h' as KlineInterval, limit: 100, }; const klines = await service.getKlines(params); expect(klines).toBeInstanceOf(Array); expect(klines.length).toBeLessThanOrEqual(100); expect(klines[0]).toHaveProperty('open'); expect(klines[0]).toHaveProperty('close'); }); }); describe('getTicker24hr', () => { it('should fetch 24hr ticker data', async () => { const ticker = await service.getTicker24hr('BTCUSDT'); expect(ticker).toHaveProperty('symbol', 'BTCUSDT'); expect(ticker).toHaveProperty('lastPrice'); expect(ticker).toHaveProperty('priceChangePercent'); }); }); }); describe('MarketDataCacheService', () => { let cacheService: MarketDataCacheService; beforeEach(() => { cacheService = new MarketDataCacheService(); }); afterEach(async () => { await cacheService.invalidateKlines('BTCUSDT'); }); it('should cache and retrieve klines', async () => { const mockKlines = [ { openTime: 1234567890, open: '50000', high: '51000', low: '49000', close: '50500', volume: '100', closeTime: 1234567899, }, ]; await cacheService.cacheKlines('BTCUSDT', '1h', mockKlines, 60); const cached = await cacheService.getCachedKlines('BTCUSDT', '1h'); expect(cached).toEqual(mockKlines); }); }); ``` --- ## Dependencias ```json { "dependencies": { "axios": "^1.6.0", "ioredis": "^5.3.2", "ws": "^8.16.0" }, "devDependencies": { "@types/ws": "^8.5.10" } } ``` --- ## Referencias - [Binance API Documentation](https://binance-docs.github.io/apidocs/spot/en/) - [Binance Rate Limits](https://binance-docs.github.io/apidocs/spot/en/#limits) - [Redis Caching Best Practices](https://redis.io/docs/manual/patterns/)