Changes include: - Updated architecture documentation - Enhanced module definitions (OQI-001 to OQI-008) - ML integration documentation updates - Trading strategies documentation - Orchestration and inventory updates - Docker configuration updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1112 lines
34 KiB
Markdown
1112 lines
34 KiB
Markdown
---
|
|
id: "ET-TRD-005"
|
|
title: "Especificación Técnica - Frontend Components"
|
|
type: "Technical Specification"
|
|
status: "Done"
|
|
priority: "Alta"
|
|
epic: "OQI-003"
|
|
project: "trading-platform"
|
|
version: "1.0.0"
|
|
created_date: "2025-12-05"
|
|
updated_date: "2026-01-04"
|
|
---
|
|
|
|
# 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<TradingState>()(
|
|
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<PaperOrder>) => void;
|
|
removeOrder: (orderId: string) => void;
|
|
|
|
// Positions
|
|
positions: PaperPosition[];
|
|
setPositions: (positions: PaperPosition[]) => void;
|
|
updatePosition: (positionId: string, updates: Partial<PaperPosition>) => 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<OrderState>()(
|
|
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<string, any>;
|
|
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<ChartIndicator>) => 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<ChartState>()(
|
|
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 (
|
|
<div className="h-screen flex flex-col bg-gray-900">
|
|
{/* Header */}
|
|
<header className="h-16 border-b border-gray-800 flex items-center px-4">
|
|
<h1 className="text-xl font-bold text-white">Paper Trading</h1>
|
|
</header>
|
|
|
|
{/* Main content */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* Left sidebar - Watchlist */}
|
|
<aside className="w-64 border-r border-gray-800 overflow-y-auto">
|
|
<WatchlistPanel />
|
|
</aside>
|
|
|
|
{/* Center - Chart */}
|
|
<main className="flex-1 flex flex-col">
|
|
<div className="flex-1">
|
|
<ChartComponent />
|
|
</div>
|
|
|
|
{/* Bottom panels */}
|
|
<div className="h-80 border-t border-gray-800 grid grid-cols-3 gap-4 p-4">
|
|
<OrderPanel />
|
|
<PositionsPanel />
|
|
<OrderBookPanel />
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 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<HTMLDivElement>(null);
|
|
const chartRef = useRef<IChartApi | null>(null);
|
|
const candlestickSeriesRef = useRef<ISeriesApi<'Candlestick'> | 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 (
|
|
<div className="relative w-full h-full">
|
|
{/* Chart toolbar */}
|
|
<div className="absolute top-0 left-0 right-0 z-10 bg-gray-800 border-b border-gray-700 px-4 py-2 flex items-center gap-4">
|
|
<TimeframeSelector />
|
|
<IndicatorSelector />
|
|
<ChartTypeSelector />
|
|
</div>
|
|
|
|
{/* Chart container */}
|
|
<div ref={chartContainerRef} className="w-full h-full pt-12" />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Sub-components
|
|
const TimeframeSelector: React.FC = () => {
|
|
const { interval, setInterval } = useChartStore();
|
|
|
|
const intervals = ['1m', '5m', '15m', '1h', '4h', '1d'] as const;
|
|
|
|
return (
|
|
<div className="flex gap-1">
|
|
{intervals.map((int) => (
|
|
<button
|
|
key={int}
|
|
onClick={() => setInterval(int)}
|
|
className={`px-3 py-1 text-sm rounded ${
|
|
interval === int
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
|
}`}
|
|
>
|
|
{int}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<select
|
|
className="bg-gray-700 text-white px-3 py-1 rounded text-sm"
|
|
onChange={(e) => {
|
|
if (e.target.value) {
|
|
addIndicator({
|
|
id: `${e.target.value}-${Date.now()}`,
|
|
type: e.target.value as any,
|
|
params: {},
|
|
visible: true,
|
|
});
|
|
e.target.value = '';
|
|
}
|
|
}}
|
|
>
|
|
<option value="">Add Indicator</option>
|
|
{availableIndicators.map((ind) => (
|
|
<option key={ind.type} value={ind.type}>
|
|
{ind.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
);
|
|
};
|
|
|
|
const ChartTypeSelector: React.FC = () => {
|
|
const { chartType, setChartType } = useChartStore();
|
|
|
|
return (
|
|
<select
|
|
value={chartType}
|
|
onChange={(e) => setChartType(e.target.value as any)}
|
|
className="bg-gray-700 text-white px-3 py-1 rounded text-sm"
|
|
>
|
|
<option value="candlestick">Candlestick</option>
|
|
<option value="line">Line</option>
|
|
<option value="area">Area</option>
|
|
</select>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 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 (
|
|
<div className="bg-gray-800 rounded-lg p-4">
|
|
<h3 className="text-lg font-semibold text-white mb-4">Place Order</h3>
|
|
|
|
{/* Buy/Sell Tabs */}
|
|
<div className="flex gap-2 mb-4">
|
|
<button
|
|
onClick={() => setOrderSide('buy')}
|
|
className={`flex-1 py-2 rounded ${
|
|
orderSide === 'buy'
|
|
? 'bg-green-600 text-white'
|
|
: 'bg-gray-700 text-gray-400'
|
|
}`}
|
|
>
|
|
Buy
|
|
</button>
|
|
<button
|
|
onClick={() => setOrderSide('sell')}
|
|
className={`flex-1 py-2 rounded ${
|
|
orderSide === 'sell'
|
|
? 'bg-red-600 text-white'
|
|
: 'bg-gray-700 text-gray-400'
|
|
}`}
|
|
>
|
|
Sell
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* Order Type */}
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">Order Type</label>
|
|
<select
|
|
value={orderType}
|
|
onChange={(e) => setOrderType(e.target.value as any)}
|
|
className="w-full bg-gray-700 text-white px-3 py-2 rounded"
|
|
>
|
|
<option value="market">Market</option>
|
|
<option value="limit">Limit</option>
|
|
<option value="stop_loss">Stop Loss</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Price (for limit orders) */}
|
|
{orderType === 'limit' && (
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">
|
|
Price (USDT)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={orderPrice}
|
|
onChange={(e) => setOrderPrice(e.target.value)}
|
|
className="w-full bg-gray-700 text-white px-3 py-2 rounded"
|
|
placeholder={ticker?.price.toFixed(2)}
|
|
required
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quantity */}
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">Quantity</label>
|
|
<input
|
|
type="number"
|
|
step="0.00000001"
|
|
value={orderQuantity}
|
|
onChange={(e) => setOrderQuantity(e.target.value)}
|
|
className="w-full bg-gray-700 text-white px-3 py-2 rounded"
|
|
placeholder="0.00"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Stop Loss */}
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">
|
|
Stop Loss (Optional)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={stopLoss}
|
|
onChange={(e) => setStopLoss(e.target.value)}
|
|
className="w-full bg-gray-700 text-white px-3 py-2 rounded"
|
|
placeholder="0.00"
|
|
/>
|
|
</div>
|
|
|
|
{/* Take Profit */}
|
|
<div>
|
|
<label className="block text-sm text-gray-400 mb-1">
|
|
Take Profit (Optional)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={takeProfit}
|
|
onChange={(e) => setTakeProfit(e.target.value)}
|
|
className="w-full bg-gray-700 text-white px-3 py-2 rounded"
|
|
placeholder="0.00"
|
|
/>
|
|
</div>
|
|
|
|
{/* Summary */}
|
|
<div className="bg-gray-700 rounded p-3 space-y-1 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-400">Available:</span>
|
|
<span className="text-white">{balance?.available.toFixed(2)} USDT</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-400">Estimated Cost:</span>
|
|
<span className="text-white">{estimatedCost.toFixed(2)} USDT</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting || !orderQuantity}
|
|
className={`w-full py-3 rounded font-semibold ${
|
|
orderSide === 'buy'
|
|
? 'bg-green-600 hover:bg-green-700'
|
|
: 'bg-red-600 hover:bg-red-700'
|
|
} text-white disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
>
|
|
{isSubmitting
|
|
? 'Placing Order...'
|
|
: `${orderSide === 'buy' ? 'Buy' : 'Sell'} ${selectedSymbol}`}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 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 (
|
|
<div className="bg-gray-800 rounded-lg p-4">
|
|
<h3 className="text-lg font-semibold text-white mb-4">Open Positions</h3>
|
|
|
|
{positions.length === 0 ? (
|
|
<p className="text-gray-400 text-sm text-center py-8">
|
|
No open positions
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{positions.map((position) => (
|
|
<div
|
|
key={position.id}
|
|
className="bg-gray-700 rounded p-3 space-y-2"
|
|
>
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<div className="font-semibold text-white">
|
|
{position.symbol}
|
|
</div>
|
|
<div className="text-xs text-gray-400">
|
|
{position.side.toUpperCase()} {position.currentQuantity}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`font-semibold ${
|
|
position.totalPnl >= 0 ? 'text-green-500' : 'text-red-500'
|
|
}`}
|
|
>
|
|
{position.totalPnl >= 0 ? '+' : ''}
|
|
{position.totalPnl.toFixed(2)} USDT
|
|
<div className="text-xs">
|
|
({position.pnlPercentage.toFixed(2)}%)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
<div>
|
|
<span className="text-gray-400">Entry:</span>{' '}
|
|
<span className="text-white">
|
|
{position.averageEntryPrice.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-400">Value:</span>{' '}
|
|
<span className="text-white">
|
|
{(
|
|
position.currentQuantity * position.averageEntryPrice
|
|
).toFixed(2)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => handleClosePosition(position.id)}
|
|
className="w-full bg-red-600 hover:bg-red-700 text-white text-sm py-1 rounded"
|
|
>
|
|
Close Position
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 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/)
|