# ET-TRD-005: Especificación Técnica - Frontend Components **Version:** 1.0.0 **Fecha:** 2025-12-05 **Estado:** Pendiente **Épica:** [OQI-003](../_MAP.md) **Requerimiento:** RF-TRD-005 --- ## Resumen Esta especificación detalla la implementación técnica de los componentes React para el módulo de trading, incluyendo TradingPage, ChartComponent (Lightweight Charts), OrderPanel, PositionsPanel, WatchlistPanel y gestión de estado con Zustand. --- ## Arquitectura ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ TRADING PAGE │ │ ┌──────────────────────────────────────────────────────────────────┐ │ │ │ Layout │ │ │ │ ┌────────────────┐ ┌─────────────────────────────────────┐ │ │ │ │ │ │ │ │ │ │ │ │ │ Watchlist │ │ Chart Component │ │ │ │ │ │ Panel │ │ (Lightweight Charts v4) │ │ │ │ │ │ │ │ │ │ │ │ │ │ - Symbols │ │ - Candlesticks │ │ │ │ │ │ - Search │ │ - Indicators │ │ │ │ │ │ - Alerts │ │ - Drawings │ │ │ │ │ │ │ │ - Timeframes │ │ │ │ │ └────────────────┘ └─────────────────────────────────────┘ │ │ │ │ │ │ │ │ ┌────────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ │ │ Order Panel │ │ Positions │ │ Order Book │ │ │ │ │ │ │ │ Panel │ │ Panel │ │ │ │ │ │ - Buy/Sell │ │ │ │ │ │ │ │ │ │ - Order Type │ │ - Open │ │ - Bids/Asks │ │ │ │ │ │ - Amount │ │ - History │ │ - Depth │ │ │ │ │ │ - SL/TP │ │ - PnL │ │ │ │ │ │ │ └────────────────┘ └──────────────┘ └──────────────────┘ │ │ │ └──────────────────────────────────────────────────────────────────┘ │ └────────────────────────────────┬────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ ZUSTAND STORES │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ tradingStore │ │ orderStore │ │ chartStore │ │ │ │ │ │ │ │ │ │ │ │ - symbol │ │ - orders │ │ - interval │ │ │ │ - ticker │ │ - positions │ │ - indicators │ │ │ │ - orderbook │ │ - balances │ │ - drawings │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ SERVICES & HOOKS │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ useWebSocket │ │ useMarketData│ │ useOrders │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ ``` --- ## Stores - Zustand ### tradingStore **Ubicación:** `apps/frontend/src/modules/trading/stores/trading.store.ts` ```typescript import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; export interface Ticker { symbol: string; price: number; priceChange: number; priceChangePercent: number; high: number; low: number; volume: number; } export interface OrderBook { lastUpdateId: number; bids: [number, number][]; asks: [number, number][]; } interface TradingState { // Selected symbol selectedSymbol: string; setSelectedSymbol: (symbol: string) => void; // Market data ticker: Ticker | null; setTicker: (ticker: Ticker) => void; orderBook: OrderBook | null; setOrderBook: (orderBook: OrderBook) => void; // Klines/Candles klines: any[]; setKlines: (klines: any[]) => void; appendKline: (kline: any) => void; // Watchlists watchlists: any[]; setWatchlists: (watchlists: any[]) => void; selectedWatchlistId: string | null; setSelectedWatchlistId: (id: string | null) => void; // Loading states isLoadingTicker: boolean; isLoadingKlines: boolean; isLoadingOrderBook: boolean; // Actions reset: () => void; } export const useTradingStore = create()( devtools( persist( (set, get) => ({ // Initial state selectedSymbol: 'BTCUSDT', ticker: null, orderBook: null, klines: [], watchlists: [], selectedWatchlistId: null, isLoadingTicker: false, isLoadingKlines: false, isLoadingOrderBook: false, // Actions setSelectedSymbol: (symbol) => set({ selectedSymbol: symbol }), setTicker: (ticker) => set({ ticker, isLoadingTicker: false }), setOrderBook: (orderBook) => set({ orderBook, isLoadingOrderBook: false }), setKlines: (klines) => set({ klines, isLoadingKlines: false }), appendKline: (kline) => { const { klines } = get(); const lastKline = klines[klines.length - 1]; // Update last kline if same timestamp, otherwise append if (lastKline && lastKline.openTime === kline.openTime) { set({ klines: [...klines.slice(0, -1), kline], }); } else { set({ klines: [...klines, kline], }); } }, setWatchlists: (watchlists) => set({ watchlists }), setSelectedWatchlistId: (id) => set({ selectedWatchlistId: id }), reset: () => set({ ticker: null, orderBook: null, klines: [], isLoadingTicker: false, isLoadingKlines: false, isLoadingOrderBook: false, }), }), { name: 'trading-storage', partialize: (state) => ({ selectedSymbol: state.selectedSymbol, selectedWatchlistId: state.selectedWatchlistId, }), } ) ) ); ``` ### orderStore **Ubicación:** `apps/frontend/src/modules/trading/stores/order.store.ts` ```typescript import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; export interface PaperBalance { asset: string; total: number; available: number; locked: number; } export interface PaperOrder { id: string; symbol: string; side: 'buy' | 'sell'; type: 'market' | 'limit' | 'stop_loss' | 'stop_limit'; status: 'pending' | 'open' | 'filled' | 'cancelled'; quantity: number; filledQuantity: number; price?: number; averageFillPrice?: number; placedAt: Date; } export interface PaperPosition { id: string; symbol: string; side: 'long' | 'short'; status: 'open' | 'closed'; currentQuantity: number; averageEntryPrice: number; unrealizedPnl: number; realizedPnl: number; totalPnl: number; pnlPercentage: number; openedAt: Date; } interface OrderState { // Balances balances: PaperBalance[]; setBalances: (balances: PaperBalance[]) => void; getBalance: (asset: string) => PaperBalance | undefined; // Orders orders: PaperOrder[]; setOrders: (orders: PaperOrder[]) => void; addOrder: (order: PaperOrder) => void; updateOrder: (orderId: string, updates: Partial) => void; removeOrder: (orderId: string) => void; // Positions positions: PaperPosition[]; setPositions: (positions: PaperPosition[]) => void; updatePosition: (positionId: string, updates: Partial) => void; // Order form state orderSide: 'buy' | 'sell'; setOrderSide: (side: 'buy' | 'sell') => void; orderType: 'market' | 'limit' | 'stop_loss'; setOrderType: (type: 'market' | 'limit' | 'stop_loss') => void; orderQuantity: string; setOrderQuantity: (quantity: string) => void; orderPrice: string; setOrderPrice: (price: string) => void; stopLoss: string; setStopLoss: (price: string) => void; takeProfit: string; setTakeProfit: (price: string) => void; // Actions resetOrderForm: () => void; } export const useOrderStore = create()( devtools((set, get) => ({ // Initial state balances: [], orders: [], positions: [], orderSide: 'buy', orderType: 'market', orderQuantity: '', orderPrice: '', stopLoss: '', takeProfit: '', // Actions setBalances: (balances) => set({ balances }), getBalance: (asset) => { return get().balances.find((b) => b.asset === asset); }, setOrders: (orders) => set({ orders }), addOrder: (order) => set((state) => ({ orders: [order, ...state.orders], })), updateOrder: (orderId, updates) => set((state) => ({ orders: state.orders.map((o) => o.id === orderId ? { ...o, ...updates } : o ), })), removeOrder: (orderId) => set((state) => ({ orders: state.orders.filter((o) => o.id !== orderId), })), setPositions: (positions) => set({ positions }), updatePosition: (positionId, updates) => set((state) => ({ positions: state.positions.map((p) => p.id === positionId ? { ...p, ...updates } : p ), })), setOrderSide: (side) => set({ orderSide: side }), setOrderType: (type) => set({ orderType: type }), setOrderQuantity: (quantity) => set({ orderQuantity: quantity }), setOrderPrice: (price) => set({ orderPrice: price }), setStopLoss: (price) => set({ stopLoss: price }), setTakeProfit: (price) => set({ takeProfit: price }), resetOrderForm: () => set({ orderQuantity: '', orderPrice: '', stopLoss: '', takeProfit: '', }), })) ); ``` ### chartStore **Ubicación:** `apps/frontend/src/modules/trading/stores/chart.store.ts` ```typescript import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; export type TimeInterval = '1m' | '5m' | '15m' | '1h' | '4h' | '1d'; export interface ChartIndicator { id: string; type: 'SMA' | 'EMA' | 'RSI' | 'MACD' | 'BB'; params: Record; visible: boolean; color?: string; } interface ChartState { // Timeframe interval: TimeInterval; setInterval: (interval: TimeInterval) => void; // Chart type chartType: 'candlestick' | 'line' | 'area'; setChartType: (type: 'candlestick' | 'line' | 'area') => void; // Indicators indicators: ChartIndicator[]; addIndicator: (indicator: ChartIndicator) => void; removeIndicator: (id: string) => void; updateIndicator: (id: string, updates: Partial) => void; toggleIndicator: (id: string) => void; // Drawings drawingsEnabled: boolean; setDrawingsEnabled: (enabled: boolean) => void; // Settings showVolume: boolean; toggleVolume: () => void; showGrid: boolean; toggleGrid: () => void; theme: 'light' | 'dark'; setTheme: (theme: 'light' | 'dark') => void; } export const useChartStore = create()( devtools( persist( (set, get) => ({ // Initial state interval: '1h', chartType: 'candlestick', indicators: [], drawingsEnabled: false, showVolume: true, showGrid: true, theme: 'dark', // Actions setInterval: (interval) => set({ interval }), setChartType: (chartType) => set({ chartType }), addIndicator: (indicator) => set((state) => ({ indicators: [...state.indicators, indicator], })), removeIndicator: (id) => set((state) => ({ indicators: state.indicators.filter((i) => i.id !== id), })), updateIndicator: (id, updates) => set((state) => ({ indicators: state.indicators.map((i) => i.id === id ? { ...i, ...updates } : i ), })), toggleIndicator: (id) => set((state) => ({ indicators: state.indicators.map((i) => i.id === id ? { ...i, visible: !i.visible } : i ), })), setDrawingsEnabled: (enabled) => set({ drawingsEnabled: enabled }), toggleVolume: () => set((state) => ({ showVolume: !state.showVolume })), toggleGrid: () => set((state) => ({ showGrid: !state.showGrid })), setTheme: (theme) => set({ theme }), }), { name: 'chart-storage', } ) ) ); ``` --- ## Componentes Principales ### TradingPage **Ubicación:** `apps/frontend/src/modules/trading/pages/TradingPage.tsx` ```typescript import React, { useEffect } from 'react'; import { WatchlistPanel } from '../components/WatchlistPanel'; import { ChartComponent } from '../components/ChartComponent'; import { OrderPanel } from '../components/OrderPanel'; import { PositionsPanel } from '../components/PositionsPanel'; import { OrderBookPanel } from '../components/OrderBookPanel'; import { useTradingStore } from '../stores/trading.store'; import { useWebSocket } from '../hooks/useWebSocket'; import { useMarketData } from '../hooks/useMarketData'; export const TradingPage: React.FC = () => { const { selectedSymbol } = useTradingStore(); const { subscribe, unsubscribe } = useWebSocket(); const { fetchKlines, fetchTicker } = useMarketData(); useEffect(() => { // Fetch initial data fetchKlines(selectedSymbol); fetchTicker(selectedSymbol); // Subscribe to real-time updates subscribe(`kline:${selectedSymbol}:1h`); subscribe(`ticker:${selectedSymbol}`); return () => { unsubscribe(`kline:${selectedSymbol}:1h`); unsubscribe(`ticker:${selectedSymbol}`); }; }, [selectedSymbol]); return (
{/* Header */}

Paper Trading

{/* Main content */}
{/* Left sidebar - Watchlist */} {/* Center - Chart */}
{/* Bottom panels */}
); }; ``` ### ChartComponent **Ubicación:** `apps/frontend/src/modules/trading/components/ChartComponent.tsx` ```typescript import React, { useEffect, useRef } from 'react'; import { createChart, IChartApi, ISeriesApi } from 'lightweight-charts'; import { useTradingStore } from '../stores/trading.store'; import { useChartStore } from '../stores/chart.store'; export const ChartComponent: React.FC = () => { const chartContainerRef = useRef(null); const chartRef = useRef(null); const candlestickSeriesRef = useRef | null>(null); const { klines, selectedSymbol, ticker } = useTradingStore(); const { interval, theme, showVolume, showGrid } = useChartStore(); // Initialize chart useEffect(() => { if (!chartContainerRef.current) return; const chart = createChart(chartContainerRef.current, { width: chartContainerRef.current.clientWidth, height: chartContainerRef.current.clientHeight, layout: { background: { color: theme === 'dark' ? '#1a1a1a' : '#ffffff' }, textColor: theme === 'dark' ? '#d1d5db' : '#1f2937', }, grid: { vertLines: { visible: showGrid, color: '#2a2a2a' }, horzLines: { visible: showGrid, color: '#2a2a2a' }, }, crosshair: { mode: 1, }, rightPriceScale: { borderColor: '#2a2a2a', }, timeScale: { borderColor: '#2a2a2a', timeVisible: true, secondsVisible: false, }, }); const candlestickSeries = chart.addCandlestickSeries({ upColor: '#22c55e', downColor: '#ef4444', borderUpColor: '#22c55e', borderDownColor: '#ef4444', wickUpColor: '#22c55e', wickDownColor: '#ef4444', }); chartRef.current = chart; candlestickSeriesRef.current = candlestickSeries; // Handle resize const handleResize = () => { if (chartContainerRef.current) { chart.applyOptions({ width: chartContainerRef.current.clientWidth, height: chartContainerRef.current.clientHeight, }); } }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); chart.remove(); }; }, [theme, showGrid]); // Update data useEffect(() => { if (!candlestickSeriesRef.current || !klines.length) return; const formattedData = klines.map((k) => ({ time: Math.floor(k.openTime / 1000), open: parseFloat(k.open), high: parseFloat(k.high), low: parseFloat(k.low), close: parseFloat(k.close), })); candlestickSeriesRef.current.setData(formattedData); }, [klines]); // Update last price line useEffect(() => { if (!chartRef.current || !ticker) return; chartRef.current.applyOptions({ watermark: { visible: true, fontSize: 24, horzAlign: 'left', vertAlign: 'top', color: ticker.priceChange >= 0 ? '#22c55e' : '#ef4444', text: `${selectedSymbol} ${ticker.price.toFixed(2)}`, }, }); }, [ticker, selectedSymbol]); return (
{/* Chart toolbar */}
{/* Chart container */}
); }; // Sub-components const TimeframeSelector: React.FC = () => { const { interval, setInterval } = useChartStore(); const intervals = ['1m', '5m', '15m', '1h', '4h', '1d'] as const; return (
{intervals.map((int) => ( ))}
); }; const IndicatorSelector: React.FC = () => { const { indicators, addIndicator } = useChartStore(); const availableIndicators = [ { type: 'SMA', label: 'SMA(20)' }, { type: 'EMA', label: 'EMA(20)' }, { type: 'RSI', label: 'RSI(14)' }, { type: 'MACD', label: 'MACD' }, { type: 'BB', label: 'Bollinger Bands' }, ]; return ( ); }; const ChartTypeSelector: React.FC = () => { const { chartType, setChartType } = useChartStore(); return ( ); }; ``` ### OrderPanel **Ubicación:** `apps/frontend/src/modules/trading/components/OrderPanel.tsx` ```typescript import React, { useState } from 'react'; import { useOrderStore } from '../stores/order.store'; import { useTradingStore } from '../stores/trading.store'; import { api } from '../services/api'; import { toast } from 'react-hot-toast'; export const OrderPanel: React.FC = () => { const { selectedSymbol, ticker } = useTradingStore(); const { orderSide, setOrderSide, orderType, setOrderType, orderQuantity, setOrderQuantity, orderPrice, setOrderPrice, stopLoss, setStopLoss, takeProfit, setTakeProfit, resetOrderForm, getBalance, } = useOrderStore(); const [isSubmitting, setIsSubmitting] = useState(false); const balance = getBalance('USDT'); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsSubmitting(true); try { const orderData = { symbol: selectedSymbol, side: orderSide, type: orderType, quantity: parseFloat(orderQuantity), price: orderType === 'limit' ? parseFloat(orderPrice) : undefined, stopLoss: stopLoss ? parseFloat(stopLoss) : undefined, takeProfit: takeProfit ? parseFloat(takeProfit) : undefined, }; await api.post('/paper/orders', orderData); toast.success(`${orderSide.toUpperCase()} order placed successfully`); resetOrderForm(); } catch (error: any) { toast.error(error.response?.data?.error?.message || 'Failed to place order'); } finally { setIsSubmitting(false); } }; const estimatedCost = orderType === 'market' ? parseFloat(orderQuantity || '0') * (ticker?.price || 0) : parseFloat(orderQuantity || '0') * parseFloat(orderPrice || '0'); return (

Place Order

{/* Buy/Sell Tabs */}
{/* Order Type */}
{/* Price (for limit orders) */} {orderType === 'limit' && (
setOrderPrice(e.target.value)} className="w-full bg-gray-700 text-white px-3 py-2 rounded" placeholder={ticker?.price.toFixed(2)} required />
)} {/* Quantity */}
setOrderQuantity(e.target.value)} className="w-full bg-gray-700 text-white px-3 py-2 rounded" placeholder="0.00" required />
{/* Stop Loss */}
setStopLoss(e.target.value)} className="w-full bg-gray-700 text-white px-3 py-2 rounded" placeholder="0.00" />
{/* Take Profit */}
setTakeProfit(e.target.value)} className="w-full bg-gray-700 text-white px-3 py-2 rounded" placeholder="0.00" />
{/* Summary */}
Available: {balance?.available.toFixed(2)} USDT
Estimated Cost: {estimatedCost.toFixed(2)} USDT
{/* Submit */}
); }; ``` ### PositionsPanel **Ubicación:** `apps/frontend/src/modules/trading/components/PositionsPanel.tsx` ```typescript import React, { useEffect } from 'react'; import { useOrderStore } from '../stores/order.store'; import { api } from '../services/api'; import { toast } from 'react-hot-toast'; export const PositionsPanel: React.FC = () => { const { positions, setPositions } = useOrderStore(); useEffect(() => { fetchPositions(); }, []); const fetchPositions = async () => { try { const { data } = await api.get('/paper/positions', { params: { status: 'open' }, }); setPositions(data.data); } catch (error) { console.error('Failed to fetch positions:', error); } }; const handleClosePosition = async (positionId: string) => { try { await api.post(`/paper/positions/${positionId}/close`); toast.success('Position closed'); fetchPositions(); } catch (error: any) { toast.error('Failed to close position'); } }; return (

Open Positions

{positions.length === 0 ? (

No open positions

) : (
{positions.map((position) => (
{position.symbol}
{position.side.toUpperCase()} {position.currentQuantity}
= 0 ? 'text-green-500' : 'text-red-500' }`} > {position.totalPnl >= 0 ? '+' : ''} {position.totalPnl.toFixed(2)} USDT
({position.pnlPercentage.toFixed(2)}%)
Entry:{' '} {position.averageEntryPrice.toFixed(2)}
Value:{' '} {( position.currentQuantity * position.averageEntryPrice ).toFixed(2)}
))}
)}
); }; ``` --- ## Hooks Personalizados ### useMarketData ```typescript // hooks/useMarketData.ts import { useTradingStore } from '../stores/trading.store'; import { api } from '../services/api'; export function useMarketData() { const { setKlines, setTicker, setOrderBook } = useTradingStore(); const fetchKlines = async (symbol: string, interval: string = '1h') => { try { const { data } = await api.get('/market/klines', { params: { symbol, interval, limit: 500 }, }); setKlines(data.data); } catch (error) { console.error('Failed to fetch klines:', error); } }; const fetchTicker = async (symbol: string) => { try { const { data } = await api.get(`/market/ticker/${symbol}`); setTicker(data.data); } catch (error) { console.error('Failed to fetch ticker:', error); } }; const fetchOrderBook = async (symbol: string) => { try { const { data } = await api.get(`/market/orderbook/${symbol}`); setOrderBook(data.data); } catch (error) { console.error('Failed to fetch order book:', error); } }; return { fetchKlines, fetchTicker, fetchOrderBook }; } ``` --- ## Dependencias ```json { "dependencies": { "react": "^18.2.0", "zustand": "^4.4.7", "lightweight-charts": "^4.1.0", "axios": "^1.6.0", "react-hot-toast": "^2.4.1", "tailwindcss": "^3.4.0" } } ``` --- ## Referencias - [Lightweight Charts Documentation](https://tradingview.github.io/lightweight-charts/) - [Zustand Documentation](https://docs.pmnd.rs/zustand) - [React 18 Documentation](https://react.dev/)