From 7ac32466bef36606e70a899537da14e5fbc08916 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 11:59:12 -0600 Subject: [PATCH] [OQI-003] feat: Add chart analysis and trading journal components - IndicatorConfigPanel: Advanced technical indicator configuration - ChartDrawingToolsPanel: Drawing tools for technical analysis - SymbolInfoPanel: Comprehensive symbol information sidebar - TradeJournalPanel: Trade review and journaling for paper trading Co-Authored-By: Claude Opus 4.5 --- .../components/ChartDrawingToolsPanel.tsx | 419 ++++++++++++ .../components/IndicatorConfigPanel.tsx | 506 +++++++++++++++ .../trading/components/SymbolInfoPanel.tsx | 433 +++++++++++++ .../trading/components/TradeJournalPanel.tsx | 609 ++++++++++++++++++ src/modules/trading/components/index.ts | 8 + 5 files changed, 1975 insertions(+) create mode 100644 src/modules/trading/components/ChartDrawingToolsPanel.tsx create mode 100644 src/modules/trading/components/IndicatorConfigPanel.tsx create mode 100644 src/modules/trading/components/SymbolInfoPanel.tsx create mode 100644 src/modules/trading/components/TradeJournalPanel.tsx diff --git a/src/modules/trading/components/ChartDrawingToolsPanel.tsx b/src/modules/trading/components/ChartDrawingToolsPanel.tsx new file mode 100644 index 0000000..0630490 --- /dev/null +++ b/src/modules/trading/components/ChartDrawingToolsPanel.tsx @@ -0,0 +1,419 @@ +/** + * ChartDrawingToolsPanel Component + * Drawing tools for technical analysis on charts + * OQI-003: Trading y Charts + */ + +import React, { useState, useMemo } from 'react'; +import { + Pencil, + TrendingUp, + Minus, + Square, + Circle, + Type, + Trash2, + RotateCcw, + Layers, + Eye, + EyeOff, + Lock, + Unlock, + Palette, + ChevronDown, + ChevronUp, + Copy, + ArrowUpRight, + Triangle, + Hash, +} from 'lucide-react'; + +export type DrawingTool = + | 'trendline' + | 'horizontal' + | 'vertical' + | 'rectangle' + | 'circle' + | 'fibonacci' + | 'text' + | 'arrow' + | 'channel' + | 'pitchfork'; + +export interface Drawing { + id: string; + tool: DrawingTool; + color: string; + lineWidth: number; + visible: boolean; + locked: boolean; + label?: string; + points: { x: number; y: number; price?: number; time?: string }[]; + createdAt: string; +} + +interface ChartDrawingToolsPanelProps { + drawings?: Drawing[]; + activeTool?: DrawingTool | null; + onToolSelect?: (tool: DrawingTool | null) => void; + onDrawingAdd?: (drawing: Drawing) => void; + onDrawingUpdate?: (drawing: Drawing) => void; + onDrawingRemove?: (drawingId: string) => void; + onDrawingsVisibilityToggle?: (visible: boolean) => void; + onClearAll?: () => void; + onUndo?: () => void; + compact?: boolean; +} + +const DRAWING_TOOLS: { tool: DrawingTool; label: string; icon: React.ReactNode; description: string }[] = [ + { tool: 'trendline', label: 'Trendline', icon: , description: 'Draw trend lines' }, + { tool: 'horizontal', label: 'Horizontal', icon: , description: 'Support/resistance levels' }, + { tool: 'vertical', label: 'Vertical', icon: , description: 'Mark time points' }, + { tool: 'rectangle', label: 'Rectangle', icon: , description: 'Price zones' }, + { tool: 'circle', label: 'Circle', icon: , description: 'Highlight areas' }, + { tool: 'fibonacci', label: 'Fibonacci', icon: , description: 'Fib retracement' }, + { tool: 'text', label: 'Text', icon: , description: 'Add annotations' }, + { tool: 'arrow', label: 'Arrow', icon: , description: 'Direction markers' }, + { tool: 'channel', label: 'Channel', icon: , description: 'Price channels' }, + { tool: 'pitchfork', label: 'Pitchfork', icon: , description: 'Andrews pitchfork' }, +]; + +const PRESET_COLORS = [ + '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', + '#EC4899', '#06B6D4', '#84CC16', '#F97316', '#FFFFFF', +]; + +const ChartDrawingToolsPanel: React.FC = ({ + drawings = [], + activeTool = null, + onToolSelect, + onDrawingAdd, + onDrawingUpdate, + onDrawingRemove, + onDrawingsVisibilityToggle, + onClearAll, + onUndo, + compact = false, +}) => { + const [selectedColor, setSelectedColor] = useState('#3B82F6'); + const [lineWidth, setLineWidth] = useState(2); + const [showDrawingsList, setShowDrawingsList] = useState(false); + const [expandedDrawingId, setExpandedDrawingId] = useState(null); + const [allVisible, setAllVisible] = useState(true); + + const visibleCount = useMemo(() => drawings.filter(d => d.visible).length, [drawings]); + + const handleToolClick = (tool: DrawingTool) => { + if (activeTool === tool) { + onToolSelect?.(null); + } else { + onToolSelect?.(tool); + } + }; + + const handleToggleVisibility = (drawing: Drawing) => { + onDrawingUpdate?.({ ...drawing, visible: !drawing.visible }); + }; + + const handleToggleLock = (drawing: Drawing) => { + onDrawingUpdate?.({ ...drawing, locked: !drawing.locked }); + }; + + const handleUpdateColor = (drawing: Drawing, color: string) => { + onDrawingUpdate?.({ ...drawing, color }); + }; + + const handleDuplicateDrawing = (drawing: Drawing) => { + const newDrawing: Drawing = { + ...drawing, + id: `draw_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + createdAt: new Date().toISOString(), + locked: false, + points: drawing.points.map(p => ({ ...p, y: p.y + 10 })), + }; + onDrawingAdd?.(newDrawing); + }; + + const handleToggleAllVisibility = () => { + const newVisible = !allVisible; + setAllVisible(newVisible); + onDrawingsVisibilityToggle?.(newVisible); + }; + + const getToolLabel = (tool: DrawingTool) => { + return DRAWING_TOOLS.find(t => t.tool === tool)?.label || tool; + }; + + const formatTime = (timestamp: string) => { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + }; + + return ( +
+ {/* Header */} +
+
+ +
+

Drawing Tools

+

{visibleCount} of {drawings.length} visible

+
+
+ +
+ + + +
+
+ + {/* Tool Grid */} +
+ {DRAWING_TOOLS.map(({ tool, label, icon, description }) => ( + + ))} +
+ + {/* Active Tool Options */} + {activeTool && ( +
+
+ + {getToolLabel(activeTool)} Selected + + +
+ +
+ {/* Color Picker */} +
+ +
+ {PRESET_COLORS.slice(0, 5).map((color) => ( +
+
+ + {/* Line Width */} +
+ +
+ {[1, 2, 3, 4].map((width) => ( + + ))} +
+
+
+ +

+ Click on the chart to start drawing +

+
+ )} + + {/* Drawings List */} +
+ + + {showDrawingsList && ( +
+ {drawings.length === 0 ? ( +
+

No drawings yet

+
+ ) : ( + drawings.map((drawing) => { + const toolDef = DRAWING_TOOLS.find(t => t.tool === drawing.tool); + const isExpanded = expandedDrawingId === drawing.id; + + return ( +
+
setExpandedDrawingId(isExpanded ? null : drawing.id)} + className="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-800/50" + > +
+
+
+ {toolDef?.icon} +
+
+ {toolDef?.label} + {drawing.label && ( + {drawing.label} + )} +
+
+ +
+ + + {isExpanded ? ( + + ) : ( + + )} +
+
+ + {isExpanded && ( +
+
+ Color: +
+ {PRESET_COLORS.slice(0, 5).map((color) => ( +
+
+ +
+ + Created: {formatTime(drawing.createdAt)} + +
+ + +
+
+
+ )} +
+ ); + }) + )} +
+ )} +
+ + {/* Quick Tips */} + {!compact && ( +
+
+

Tips:

+
    +
  • • Click and drag to draw
  • +
  • • Double-click to finish
  • +
  • • Hold Shift for straight lines
  • +
+
+
+ )} +
+ ); +}; + +export default ChartDrawingToolsPanel; diff --git a/src/modules/trading/components/IndicatorConfigPanel.tsx b/src/modules/trading/components/IndicatorConfigPanel.tsx new file mode 100644 index 0000000..8cef672 --- /dev/null +++ b/src/modules/trading/components/IndicatorConfigPanel.tsx @@ -0,0 +1,506 @@ +/** + * IndicatorConfigPanel Component + * Advanced technical indicator configuration and management + * OQI-003: Trading y Charts + */ + +import React, { useState, useMemo } from 'react'; +import { + Settings, + Plus, + Trash2, + Save, + RotateCcw, + ChevronDown, + ChevronUp, + TrendingUp, + Activity, + BarChart3, + Waves, + Target, + Eye, + EyeOff, + Palette, + Copy, +} from 'lucide-react'; + +export type IndicatorType = 'sma' | 'ema' | 'rsi' | 'macd' | 'bollinger' | 'atr' | 'stochastic' | 'vwap'; + +export interface IndicatorConfig { + id: string; + type: IndicatorType; + enabled: boolean; + params: Record; + color: string; + lineWidth: number; +} + +interface IndicatorPreset { + name: string; + indicators: Omit[]; +} + +interface IndicatorConfigPanelProps { + indicators?: IndicatorConfig[]; + onIndicatorChange?: (indicators: IndicatorConfig[]) => void; + onIndicatorAdd?: (indicator: IndicatorConfig) => void; + onIndicatorRemove?: (indicatorId: string) => void; + onPresetSave?: (preset: IndicatorPreset) => void; + onPresetLoad?: (preset: IndicatorPreset) => void; + savedPresets?: IndicatorPreset[]; + compact?: boolean; +} + +const INDICATOR_DEFINITIONS: Record; + paramLabels: Record; + color: string; +}> = { + sma: { + label: 'Simple Moving Average', + icon: , + defaultParams: { period: 20 }, + paramLabels: { period: 'Period' }, + color: '#3B82F6', + }, + ema: { + label: 'Exponential Moving Average', + icon: , + defaultParams: { period: 12 }, + paramLabels: { period: 'Period' }, + color: '#10B981', + }, + rsi: { + label: 'Relative Strength Index', + icon: , + defaultParams: { period: 14, overbought: 70, oversold: 30 }, + paramLabels: { period: 'Period', overbought: 'Overbought', oversold: 'Oversold' }, + color: '#8B5CF6', + }, + macd: { + label: 'MACD', + icon: , + defaultParams: { fastPeriod: 12, slowPeriod: 26, signalPeriod: 9 }, + paramLabels: { fastPeriod: 'Fast', slowPeriod: 'Slow', signalPeriod: 'Signal' }, + color: '#F59E0B', + }, + bollinger: { + label: 'Bollinger Bands', + icon: , + defaultParams: { period: 20, stdDev: 2 }, + paramLabels: { period: 'Period', stdDev: 'Std Dev' }, + color: '#EC4899', + }, + atr: { + label: 'Average True Range', + icon: , + defaultParams: { period: 14 }, + paramLabels: { period: 'Period' }, + color: '#EF4444', + }, + stochastic: { + label: 'Stochastic Oscillator', + icon: , + defaultParams: { kPeriod: 14, dPeriod: 3, smooth: 3 }, + paramLabels: { kPeriod: '%K Period', dPeriod: '%D Period', smooth: 'Smooth' }, + color: '#06B6D4', + }, + vwap: { + label: 'Volume Weighted Avg Price', + icon: , + defaultParams: { period: 1 }, + paramLabels: { period: 'Days' }, + color: '#84CC16', + }, +}; + +const PRESET_COLORS = [ + '#3B82F6', '#10B981', '#8B5CF6', '#F59E0B', '#EC4899', + '#EF4444', '#06B6D4', '#84CC16', '#F97316', '#6366F1', +]; + +const DEFAULT_PRESETS: IndicatorPreset[] = [ + { + name: 'Trend Following', + indicators: [ + { type: 'sma', enabled: true, params: { period: 50 }, color: '#3B82F6', lineWidth: 2 }, + { type: 'sma', enabled: true, params: { period: 200 }, color: '#EF4444', lineWidth: 2 }, + { type: 'macd', enabled: true, params: { fastPeriod: 12, slowPeriod: 26, signalPeriod: 9 }, color: '#F59E0B', lineWidth: 1 }, + ], + }, + { + name: 'Momentum', + indicators: [ + { type: 'rsi', enabled: true, params: { period: 14, overbought: 70, oversold: 30 }, color: '#8B5CF6', lineWidth: 1 }, + { type: 'stochastic', enabled: true, params: { kPeriod: 14, dPeriod: 3, smooth: 3 }, color: '#06B6D4', lineWidth: 1 }, + ], + }, + { + name: 'Volatility', + indicators: [ + { type: 'bollinger', enabled: true, params: { period: 20, stdDev: 2 }, color: '#EC4899', lineWidth: 1 }, + { type: 'atr', enabled: true, params: { period: 14 }, color: '#EF4444', lineWidth: 1 }, + ], + }, +]; + +const IndicatorConfigPanel: React.FC = ({ + indicators = [], + onIndicatorChange, + onIndicatorAdd, + onIndicatorRemove, + onPresetSave, + onPresetLoad, + savedPresets = DEFAULT_PRESETS, + compact = false, +}) => { + const [expandedId, setExpandedId] = useState(null); + const [showAddMenu, setShowAddMenu] = useState(false); + const [showPresets, setShowPresets] = useState(false); + const [presetName, setPresetName] = useState(''); + + const generateId = () => `ind_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + const handleAddIndicator = (type: IndicatorType) => { + const def = INDICATOR_DEFINITIONS[type]; + const newIndicator: IndicatorConfig = { + id: generateId(), + type, + enabled: true, + params: { ...def.defaultParams }, + color: def.color, + lineWidth: 2, + }; + onIndicatorAdd?.(newIndicator); + setShowAddMenu(false); + }; + + const handleToggleEnabled = (id: string) => { + const updated = indicators.map(ind => + ind.id === id ? { ...ind, enabled: !ind.enabled } : ind + ); + onIndicatorChange?.(updated); + }; + + const handleParamChange = (id: string, paramKey: string, value: number) => { + const updated = indicators.map(ind => + ind.id === id ? { ...ind, params: { ...ind.params, [paramKey]: value } } : ind + ); + onIndicatorChange?.(updated); + }; + + const handleColorChange = (id: string, color: string) => { + const updated = indicators.map(ind => + ind.id === id ? { ...ind, color } : ind + ); + onIndicatorChange?.(updated); + }; + + const handleLineWidthChange = (id: string, lineWidth: number) => { + const updated = indicators.map(ind => + ind.id === id ? { ...ind, lineWidth } : ind + ); + onIndicatorChange?.(updated); + }; + + const handleResetDefaults = (id: string) => { + const indicator = indicators.find(ind => ind.id === id); + if (!indicator) return; + + const def = INDICATOR_DEFINITIONS[indicator.type]; + const updated = indicators.map(ind => + ind.id === id ? { ...ind, params: { ...def.defaultParams }, color: def.color, lineWidth: 2 } : ind + ); + onIndicatorChange?.(updated); + }; + + const handleDuplicateIndicator = (indicator: IndicatorConfig) => { + const newIndicator: IndicatorConfig = { + ...indicator, + id: generateId(), + color: PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)], + }; + onIndicatorAdd?.(newIndicator); + }; + + const handleSavePreset = () => { + if (!presetName.trim()) return; + const preset: IndicatorPreset = { + name: presetName, + indicators: indicators.map(({ id, ...rest }) => rest), + }; + onPresetSave?.(preset); + setPresetName(''); + setShowPresets(false); + }; + + const handleLoadPreset = (preset: IndicatorPreset) => { + onPresetLoad?.(preset); + setShowPresets(false); + }; + + const enabledCount = useMemo(() => indicators.filter(i => i.enabled).length, [indicators]); + + return ( +
+ {/* Header */} +
+
+ +
+

Indicators

+

{enabledCount} active

+
+
+ +
+ {/* Presets Button */} +
+ + + {showPresets && ( +
+
+
Load Preset
+
+ {savedPresets.map((preset, idx) => ( + + ))} +
+
+
+
Save Current
+
+ setPresetName(e.target.value)} + placeholder="Preset name..." + className="flex-1 px-2 py-1.5 bg-gray-900 border border-gray-700 rounded text-xs text-white focus:outline-none focus:border-blue-500" + /> + +
+
+
+ )} +
+ + {/* Add Indicator Button */} +
+ + + {showAddMenu && ( +
+ {Object.entries(INDICATOR_DEFINITIONS).map(([type, def]) => ( + + ))} +
+ )} +
+
+
+ + {/* Indicators List */} + {indicators.length === 0 ? ( +
+ +

No indicators configured

+

Click "Add" to add technical indicators

+
+ ) : ( +
+ {indicators.map((indicator) => { + const def = INDICATOR_DEFINITIONS[indicator.type]; + const isExpanded = expandedId === indicator.id; + + return ( +
+ {/* Header Row */} +
setExpandedId(isExpanded ? null : indicator.id)} + className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-800/50" + > +
+
+
+ {def.icon} +
+
+ {def.label} +
+ {Object.entries(indicator.params) + .map(([k, v]) => `${def.paramLabels[k]}: ${v}`) + .join(' • ')} +
+
+
+ +
+ + {isExpanded ? ( + + ) : ( + + )} +
+
+ + {/* Expanded Config */} + {isExpanded && ( +
+ {/* Parameters */} +
+ {Object.entries(indicator.params).map(([paramKey, paramValue]) => ( +
+ + handleParamChange(indicator.id, paramKey, Number(e.target.value))} + className="w-full px-2 py-1.5 bg-gray-800 border border-gray-700 rounded text-white text-sm focus:outline-none focus:border-blue-500" + /> +
+ ))} +
+ + {/* Style Options */} +
+
+ +
+ {PRESET_COLORS.slice(0, 5).map((color) => ( +
+
+
+ +
+ {[1, 2, 3].map((width) => ( + + ))} +
+
+
+ + {/* Actions */} +
+
+ + +
+ +
+
+ )} +
+ ); + })} +
+ )} + + {/* Close menus on outside click */} + {(showAddMenu || showPresets) && ( +
{ + setShowAddMenu(false); + setShowPresets(false); + }} + /> + )} +
+ ); +}; + +export default IndicatorConfigPanel; diff --git a/src/modules/trading/components/SymbolInfoPanel.tsx b/src/modules/trading/components/SymbolInfoPanel.tsx new file mode 100644 index 0000000..d62d0ad --- /dev/null +++ b/src/modules/trading/components/SymbolInfoPanel.tsx @@ -0,0 +1,433 @@ +/** + * SymbolInfoPanel Component + * Comprehensive symbol information and details sidebar + * OQI-003: Trading y Charts + */ + +import React, { useState, useMemo } from 'react'; +import { + Info, + TrendingUp, + TrendingDown, + BarChart3, + Activity, + Clock, + DollarSign, + Percent, + Globe, + Star, + StarOff, + ExternalLink, + RefreshCw, + ChevronDown, + ChevronUp, + Zap, + AlertCircle, +} from 'lucide-react'; + +export interface SymbolStats { + price: number; + change24h: number; + changePercent24h: number; + high24h: number; + low24h: number; + open24h: number; + volume24h: number; + volumeQuote24h: number; + marketCap?: number; + circulatingSupply?: number; + totalSupply?: number; + allTimeHigh?: number; + allTimeLow?: number; + athDate?: string; + atlDate?: string; +} + +export interface SymbolInfo { + symbol: string; + name: string; + baseAsset: string; + quoteAsset: string; + exchange: string; + type: 'spot' | 'futures' | 'forex' | 'crypto'; + status: 'trading' | 'halt' | 'break'; + minNotional?: number; + tickSize?: number; + stepSize?: number; + maxLeverage?: number; + tradingHours?: string; + description?: string; +} + +interface RelatedSymbol { + symbol: string; + correlation: number; + change24h: number; +} + +interface SymbolInfoPanelProps { + symbol: string; + info?: SymbolInfo; + stats?: SymbolStats; + relatedSymbols?: RelatedSymbol[]; + isFavorite?: boolean; + onToggleFavorite?: (symbol: string) => void; + onSymbolSelect?: (symbol: string) => void; + onRefresh?: () => void; + isLoading?: boolean; + compact?: boolean; +} + +const SymbolInfoPanel: React.FC = ({ + symbol, + info, + stats, + relatedSymbols = [], + isFavorite = false, + onToggleFavorite, + onSymbolSelect, + onRefresh, + isLoading = false, + compact = false, +}) => { + const [expandedSection, setExpandedSection] = useState('stats'); + + const priceChangeColor = useMemo(() => { + if (!stats) return 'text-gray-400'; + return stats.change24h >= 0 ? 'text-green-400' : 'text-red-400'; + }, [stats]); + + const formatNumber = (num: number, decimals = 2) => { + if (num >= 1e9) return `${(num / 1e9).toFixed(decimals)}B`; + if (num >= 1e6) return `${(num / 1e6).toFixed(decimals)}M`; + if (num >= 1e3) return `${(num / 1e3).toFixed(decimals)}K`; + return num.toFixed(decimals); + }; + + const formatPrice = (price: number) => { + if (price >= 1000) return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + if (price >= 1) return price.toFixed(4); + return price.toFixed(8); + }; + + const formatPercent = (percent: number) => { + const sign = percent >= 0 ? '+' : ''; + return `${sign}${percent.toFixed(2)}%`; + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'trading': + return Trading; + case 'halt': + return Halted; + case 'break': + return Break; + default: + return null; + } + }; + + const toggleSection = (section: string) => { + setExpandedSection(expandedSection === section ? null : section); + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+
+

{symbol}

+ {info && getStatusBadge(info.status)} +
+ {info && ( +

{info.name}

+ )} +
+
+ +
+ + +
+
+ + {/* Price Display */} + {stats && ( +
+
+
+ ${formatPrice(stats.price)} +
+ {stats.change24h >= 0 ? ( + + ) : ( + + )} + + {stats.change24h >= 0 ? '+' : ''}{formatPrice(stats.change24h)} + + + ({formatPercent(stats.changePercent24h)}) + +
+
+
+
24h High: ${formatPrice(stats.high24h)}
+
24h Low: ${formatPrice(stats.low24h)}
+
+
+ + {/* Mini Stats Row */} +
+
+
Volume
+
${formatNumber(stats.volumeQuote24h)}
+
+ {stats.marketCap && ( +
+
Market Cap
+
${formatNumber(stats.marketCap)}
+
+ )} +
+
Open
+
${formatPrice(stats.open24h)}
+
+
+
+ )} + + {/* Expandable Sections */} +
+ {/* Stats Section */} + {stats && ( +
+ + + {expandedSection === 'stats' && ( +
+
+ 24h Volume ({info?.baseAsset}) + {formatNumber(stats.volume24h)} +
+ {stats.allTimeHigh && ( +
+ All-Time High +
+ ${formatPrice(stats.allTimeHigh)} + {stats.athDate && ( +
{stats.athDate}
+ )} +
+
+ )} + {stats.allTimeLow && ( +
+ All-Time Low +
+ ${formatPrice(stats.allTimeLow)} + {stats.atlDate && ( +
{stats.atlDate}
+ )} +
+
+ )} + {stats.circulatingSupply && ( +
+ Circulating Supply + {formatNumber(stats.circulatingSupply, 0)} +
+ )} +
+ )} +
+ )} + + {/* Info Section */} + {info && ( +
+ + + {expandedSection === 'info' && ( +
+
+ Exchange + {info.exchange} +
+
+ Type + {info.type} +
+
+ Base Asset + {info.baseAsset} +
+
+ Quote Asset + {info.quoteAsset} +
+ {info.tickSize && ( +
+ Tick Size + {info.tickSize} +
+ )} + {info.minNotional && ( +
+ Min Order + ${info.minNotional} +
+ )} + {info.maxLeverage && ( +
+ Max Leverage + {info.maxLeverage}x +
+ )} + {info.tradingHours && ( +
+ Trading Hours + {info.tradingHours} +
+ )} +
+ )} +
+ )} + + {/* Related Symbols */} + {relatedSymbols.length > 0 && ( +
+ + + {expandedSection === 'related' && ( +
+ {relatedSymbols.map((related) => ( + + ))} +
+ )} +
+ )} + + {/* Description */} + {info?.description && ( +
+ + + {expandedSection === 'desc' && ( +
+

{info.description}

+
+ )} +
+ )} +
+ + {/* Quick Actions */} + {!compact && ( +
+
+ + +
+
+ )} +
+ ); +}; + +export default SymbolInfoPanel; diff --git a/src/modules/trading/components/TradeJournalPanel.tsx b/src/modules/trading/components/TradeJournalPanel.tsx new file mode 100644 index 0000000..0829221 --- /dev/null +++ b/src/modules/trading/components/TradeJournalPanel.tsx @@ -0,0 +1,609 @@ +/** + * TradeJournalPanel Component + * Trade review, analysis, and journaling for paper trading + * OQI-003: Trading y Charts + */ + +import React, { useState, useMemo } from 'react'; +import { + BookOpen, + TrendingUp, + TrendingDown, + Calendar, + Tag, + MessageSquare, + Star, + Filter, + Search, + Download, + BarChart3, + Target, + Clock, + DollarSign, + Percent, + ChevronDown, + ChevronUp, + Edit3, + Save, + X, + Plus, + Award, + AlertTriangle, + Zap, +} from 'lucide-react'; + +export interface TradeEntry { + id: string; + ticket: number; + symbol: string; + type: 'BUY' | 'SELL'; + entryPrice: number; + exitPrice: number; + lots: number; + profit: number; + profitPercent: number; + entryTime: string; + exitTime: string; + duration: number; // minutes + stopLoss?: number; + takeProfit?: number; + // Journal fields + notes?: string; + rating?: 1 | 2 | 3 | 4 | 5; + tags?: string[]; + strategy?: string; + setup?: string; + emotions?: string; + lessonsLearned?: string; + screenshots?: string[]; +} + +interface TradeStats { + totalTrades: number; + winningTrades: number; + losingTrades: number; + winRate: number; + totalProfit: number; + averageWin: number; + averageLoss: number; + profitFactor: number; + largestWin: number; + largestLoss: number; + averageRR: number; + currentStreak: number; + longestWinStreak: number; + longestLoseStreak: number; + averageDuration: number; +} + +interface TradeJournalPanelProps { + trades?: TradeEntry[]; + onTradeUpdate?: (trade: TradeEntry) => void; + onExportJournal?: (format: 'csv' | 'pdf') => void; + compact?: boolean; +} + +const STRATEGY_TAGS = ['Trend Following', 'Reversal', 'Breakout', 'Scalping', 'Swing', 'Range']; +const SETUP_TAGS = ['Support/Resistance', 'Moving Average', 'RSI Divergence', 'MACD Cross', 'Fibonacci', 'Pattern']; +const EMOTION_TAGS = ['Confident', 'Fearful', 'Greedy', 'Patient', 'Impulsive', 'Disciplined']; + +const TradeJournalPanel: React.FC = ({ + trades = [], + onTradeUpdate, + onExportJournal, + compact = false, +}) => { + const [selectedTrade, setSelectedTrade] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [editedNotes, setEditedNotes] = useState(''); + const [editedRating, setEditedRating] = useState(0); + const [editedTags, setEditedTags] = useState([]); + const [editedStrategy, setEditedStrategy] = useState(''); + const [editedLessons, setEditedLessons] = useState(''); + const [filterSymbol, setFilterSymbol] = useState(''); + const [filterResult, setFilterResult] = useState<'all' | 'wins' | 'losses'>('all'); + const [showStats, setShowStats] = useState(true); + + // Calculate statistics + const stats: TradeStats = useMemo(() => { + if (trades.length === 0) { + return { + totalTrades: 0, + winningTrades: 0, + losingTrades: 0, + winRate: 0, + totalProfit: 0, + averageWin: 0, + averageLoss: 0, + profitFactor: 0, + largestWin: 0, + largestLoss: 0, + averageRR: 0, + currentStreak: 0, + longestWinStreak: 0, + longestLoseStreak: 0, + averageDuration: 0, + }; + } + + const wins = trades.filter(t => t.profit > 0); + const losses = trades.filter(t => t.profit < 0); + const totalWins = wins.reduce((sum, t) => sum + t.profit, 0); + const totalLosses = Math.abs(losses.reduce((sum, t) => sum + t.profit, 0)); + + // Calculate streaks + let currentStreak = 0; + let longestWinStreak = 0; + let longestLoseStreak = 0; + let tempWinStreak = 0; + let tempLoseStreak = 0; + + const sortedTrades = [...trades].sort((a, b) => + new Date(b.exitTime).getTime() - new Date(a.exitTime).getTime() + ); + + sortedTrades.forEach((trade, idx) => { + if (trade.profit > 0) { + tempWinStreak++; + tempLoseStreak = 0; + if (tempWinStreak > longestWinStreak) longestWinStreak = tempWinStreak; + if (idx === 0) currentStreak = tempWinStreak; + } else { + tempLoseStreak++; + tempWinStreak = 0; + if (tempLoseStreak > longestLoseStreak) longestLoseStreak = tempLoseStreak; + if (idx === 0) currentStreak = -tempLoseStreak; + } + }); + + return { + totalTrades: trades.length, + winningTrades: wins.length, + losingTrades: losses.length, + winRate: (wins.length / trades.length) * 100, + totalProfit: trades.reduce((sum, t) => sum + t.profit, 0), + averageWin: wins.length > 0 ? totalWins / wins.length : 0, + averageLoss: losses.length > 0 ? totalLosses / losses.length : 0, + profitFactor: totalLosses > 0 ? totalWins / totalLosses : totalWins, + largestWin: wins.length > 0 ? Math.max(...wins.map(t => t.profit)) : 0, + largestLoss: losses.length > 0 ? Math.min(...losses.map(t => t.profit)) : 0, + averageRR: losses.length > 0 && wins.length > 0 + ? (totalWins / wins.length) / (totalLosses / losses.length) : 0, + currentStreak, + longestWinStreak, + longestLoseStreak, + averageDuration: trades.reduce((sum, t) => sum + t.duration, 0) / trades.length, + }; + }, [trades]); + + // Filter trades + const filteredTrades = useMemo(() => { + return trades.filter(trade => { + if (filterSymbol && !trade.symbol.toLowerCase().includes(filterSymbol.toLowerCase())) { + return false; + } + if (filterResult === 'wins' && trade.profit <= 0) return false; + if (filterResult === 'losses' && trade.profit >= 0) return false; + return true; + }); + }, [trades, filterSymbol, filterResult]); + + const handleEditTrade = (trade: TradeEntry) => { + setSelectedTrade(trade); + setEditedNotes(trade.notes || ''); + setEditedRating(trade.rating || 0); + setEditedTags(trade.tags || []); + setEditedStrategy(trade.strategy || ''); + setEditedLessons(trade.lessonsLearned || ''); + setIsEditing(true); + }; + + const handleSaveTrade = () => { + if (!selectedTrade) return; + + const updatedTrade: TradeEntry = { + ...selectedTrade, + notes: editedNotes, + rating: editedRating as 1 | 2 | 3 | 4 | 5, + tags: editedTags, + strategy: editedStrategy, + lessonsLearned: editedLessons, + }; + + onTradeUpdate?.(updatedTrade); + setIsEditing(false); + setSelectedTrade(updatedTrade); + }; + + const handleToggleTag = (tag: string) => { + if (editedTags.includes(tag)) { + setEditedTags(editedTags.filter(t => t !== tag)); + } else { + setEditedTags([...editedTags, tag]); + } + }; + + const formatDuration = (minutes: number) => { + if (minutes < 60) return `${minutes}m`; + if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; + return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`; + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( +
+ {/* Header */} +
+
+ +
+

Trade Journal

+

{trades.length} trades recorded

+
+
+ +
+ +
+
+ + {/* Stats Summary */} + {showStats && ( +
+
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + ${stats.totalProfit.toFixed(2)} +
+
Total P&L
+
+
+
{stats.winRate.toFixed(1)}%
+
Win Rate
+
+
+
{stats.profitFactor.toFixed(2)}
+
Profit Factor
+
+
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + {stats.currentStreak >= 0 ? `+${stats.currentStreak}` : stats.currentStreak} +
+
Streak
+
+
+ )} + + {/* Extended Stats */} + {!compact && showStats && ( +
+
+ +
+
Best Trade
+
${stats.largestWin.toFixed(2)}
+
+
+
+ +
+
Worst Trade
+
${stats.largestLoss.toFixed(2)}
+
+
+
+ +
+
Avg Duration
+
{formatDuration(stats.averageDuration)}
+
+
+
+ )} + + {/* Filters */} +
+
+ + setFilterSymbol(e.target.value)} + placeholder="Filter by symbol..." + className="w-full pl-10 pr-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500" + /> +
+ +
+ + {/* Trades List */} + {filteredTrades.length === 0 ? ( +
+ +

No trades to journal

+

Complete some trades to start journaling

+
+ ) : ( +
+ {filteredTrades.map((trade) => ( +
setSelectedTrade(selectedTrade?.id === trade.id ? null : trade)} + className={`p-3 bg-gray-900/50 rounded-lg border cursor-pointer transition-all ${ + selectedTrade?.id === trade.id + ? 'border-blue-500/50 bg-blue-500/5' + : 'border-gray-700 hover:border-gray-600' + }`} + > +
+
+
+ {trade.type === 'BUY' ? ( + + ) : ( + + )} +
+
+
+ {trade.symbol} + {trade.lots} lots + {trade.rating && ( +
+ {Array.from({ length: trade.rating }).map((_, i) => ( + + ))} +
+ )} +
+
+ {formatDate(trade.exitTime)} • {formatDuration(trade.duration)} +
+
+
+
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + {trade.profit >= 0 ? '+' : ''}{trade.profit.toFixed(2)} +
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + {trade.profitPercent >= 0 ? '+' : ''}{trade.profitPercent.toFixed(2)}% +
+
+
+ + {/* Tags */} + {trade.tags && trade.tags.length > 0 && ( +
+ {trade.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + {/* Expanded Details */} + {selectedTrade?.id === trade.id && !isEditing && ( +
+
+
+ Entry: + {trade.entryPrice.toFixed(5)} +
+
+ Exit: + {trade.exitPrice.toFixed(5)} +
+ {trade.stopLoss && ( +
+ SL: + {trade.stopLoss.toFixed(5)} +
+ )} + {trade.takeProfit && ( +
+ TP: + {trade.takeProfit.toFixed(5)} +
+ )} +
+ + {trade.strategy && ( +
+ Strategy: + {trade.strategy} +
+ )} + + {trade.notes && ( +
+ Notes: +

{trade.notes}

+
+ )} + + {trade.lessonsLearned && ( +
+ Lessons: +

{trade.lessonsLearned}

+
+ )} + + +
+ )} +
+ ))} +
+ )} + + {/* Edit Modal */} + {isEditing && selectedTrade && ( +
+
+
+

Edit Journal Entry

+ +
+ +
+ {/* Trade Info */} +
+
+ {selectedTrade.type === 'BUY' ? ( + + ) : ( + + )} +
+
+
{selectedTrade.symbol}
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + {selectedTrade.profit >= 0 ? '+' : ''}{selectedTrade.profit.toFixed(2)} +
+
+
+ + {/* Rating */} +
+ +
+ {[1, 2, 3, 4, 5].map((rating) => ( + + ))} +
+
+ + {/* Strategy */} +
+ +
+ {STRATEGY_TAGS.map((tag) => ( + + ))} +
+
+ + {/* Tags */} +
+ +
+ {[...SETUP_TAGS, ...EMOTION_TAGS].map((tag) => ( + + ))} +
+
+ + {/* Notes */} +
+ +