From ed2e1472f4d33f19271c262f14c0c3db2de00c1a Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 11:05:55 -0600 Subject: [PATCH] [OQI-007] feat: Add hooks, utils, and SignalExecutionPanel - useChatAssistant: Centralized chat logic with retry and streaming support - useStreamingChat: SSE streaming with token animation - messageFormatters: Signal parsing, price formatting, markdown processing - SignalExecutionPanel: Execute trading signals with risk validation Co-Authored-By: Claude Opus 4.5 --- .../components/SignalExecutionPanel.tsx | 471 ++++++++++++++++++ src/modules/assistant/components/index.ts | 4 + src/modules/assistant/hooks/index.ts | 22 + .../assistant/hooks/useChatAssistant.ts | 233 +++++++++ .../assistant/hooks/useStreamingChat.ts | 345 +++++++++++++ src/modules/assistant/utils/index.ts | 44 ++ .../assistant/utils/messageFormatters.ts | 390 +++++++++++++++ 7 files changed, 1509 insertions(+) create mode 100644 src/modules/assistant/components/SignalExecutionPanel.tsx create mode 100644 src/modules/assistant/hooks/index.ts create mode 100644 src/modules/assistant/hooks/useChatAssistant.ts create mode 100644 src/modules/assistant/hooks/useStreamingChat.ts create mode 100644 src/modules/assistant/utils/index.ts create mode 100644 src/modules/assistant/utils/messageFormatters.ts diff --git a/src/modules/assistant/components/SignalExecutionPanel.tsx b/src/modules/assistant/components/SignalExecutionPanel.tsx new file mode 100644 index 0000000..87d0f26 --- /dev/null +++ b/src/modules/assistant/components/SignalExecutionPanel.tsx @@ -0,0 +1,471 @@ +/** + * SignalExecutionPanel Component + * Execute and validate trading signals from assistant recommendations + */ + +import React, { useState, useMemo } from 'react'; +import { + TrendingUp, + TrendingDown, + Shield, + Target, + AlertTriangle, + CheckCircle, + XCircle, + Loader2, + Calculator, + Zap, + Info, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import type { TradingSignal } from './SignalCard'; + +interface SignalExecutionPanelProps { + signal: TradingSignal; + accountBalance?: number; + onExecute?: (params: ExecutionParams) => Promise; + onCancel?: () => void; +} + +export interface ExecutionParams { + symbol: string; + direction: 'BUY' | 'SELL'; + volume: number; + entry: number; + stopLoss?: number; + takeProfit?: number; + riskPercent: number; +} + +export interface ExecutionResult { + success: boolean; + ticket?: number; + error?: string; + executedPrice?: number; + executedVolume?: number; +} + +interface ValidationResult { + isValid: boolean; + warnings: string[]; + errors: string[]; + riskAssessment: 'low' | 'medium' | 'high' | 'extreme'; +} + +const SignalExecutionPanel: React.FC = ({ + signal, + accountBalance = 10000, + onExecute, + onCancel, +}) => { + const [volume, setVolume] = useState('0.01'); + const [riskPercent, setRiskPercent] = useState('1'); + const [useAutoSize, setUseAutoSize] = useState(true); + const [showDetails, setShowDetails] = useState(false); + const [isExecuting, setIsExecuting] = useState(false); + const [executionResult, setExecutionResult] = useState(null); + const [confirmStep, setConfirmStep] = useState(false); + + const isBuy = signal.direction === 'BUY' || signal.direction === 'LONG'; + + // Calculate position size based on risk + const calculatedVolume = useMemo(() => { + if (!signal.stopLoss || !useAutoSize) return parseFloat(volume) || 0.01; + + const risk = parseFloat(riskPercent) || 1; + const riskAmount = (accountBalance * risk) / 100; + const slPips = Math.abs(signal.entry - signal.stopLoss) * (signal.symbol.includes('JPY') ? 100 : 10000); + const pipValue = signal.symbol.includes('JPY') ? 1000 : 10; // Approximate per standard lot + + if (slPips === 0) return 0.01; + + const lots = riskAmount / (slPips * pipValue); + return Math.max(0.01, Math.min(parseFloat(lots.toFixed(2)), 10)); + }, [signal, accountBalance, riskPercent, useAutoSize, volume]); + + // Calculate risk/reward + const riskReward = useMemo(() => { + if (!signal.stopLoss || !signal.takeProfit) return null; + const risk = Math.abs(signal.entry - signal.stopLoss); + const reward = Math.abs(signal.takeProfit - signal.entry); + if (risk === 0) return null; + return reward / risk; + }, [signal]); + + // Calculate potential outcomes + const potentialLoss = useMemo(() => { + if (!signal.stopLoss) return null; + const pips = Math.abs(signal.entry - signal.stopLoss) * (signal.symbol.includes('JPY') ? 100 : 10000); + const pipValue = signal.symbol.includes('JPY') ? 1000 : 10; + return calculatedVolume * pips * pipValue; + }, [signal, calculatedVolume]); + + const potentialProfit = useMemo(() => { + if (!signal.takeProfit) return null; + const pips = Math.abs(signal.takeProfit - signal.entry) * (signal.symbol.includes('JPY') ? 100 : 10000); + const pipValue = signal.symbol.includes('JPY') ? 1000 : 10; + return calculatedVolume * pips * pipValue; + }, [signal, calculatedVolume]); + + // Validate the signal + const validation = useMemo((): ValidationResult => { + const warnings: string[] = []; + const errors: string[] = []; + + // Check for stop loss + if (!signal.stopLoss) { + warnings.push('No stop loss defined - unlimited risk'); + } + + // Check for take profit + if (!signal.takeProfit) { + warnings.push('No take profit defined'); + } + + // Check confidence + if (signal.confidence && signal.confidence < 60) { + warnings.push(`Low confidence signal (${signal.confidence}%)`); + } + + // Check risk/reward + if (riskReward && riskReward < 1) { + warnings.push(`Poor risk/reward ratio (1:${riskReward.toFixed(2)})`); + } + + // Check position size vs account + const riskAmount = potentialLoss || 0; + const riskPct = (riskAmount / accountBalance) * 100; + + if (riskPct > 5) { + errors.push(`Risk exceeds 5% of account (${riskPct.toFixed(1)}%)`); + } else if (riskPct > 2) { + warnings.push(`High risk position (${riskPct.toFixed(1)}% of account)`); + } + + // Determine risk assessment + let riskAssessment: ValidationResult['riskAssessment'] = 'low'; + if (errors.length > 0) riskAssessment = 'extreme'; + else if (warnings.length > 2) riskAssessment = 'high'; + else if (warnings.length > 0) riskAssessment = 'medium'; + + return { + isValid: errors.length === 0, + warnings, + errors, + riskAssessment, + }; + }, [signal, riskReward, potentialLoss, accountBalance]); + + const riskColors = { + low: 'text-green-400 bg-green-500/20', + medium: 'text-yellow-400 bg-yellow-500/20', + high: 'text-orange-400 bg-orange-500/20', + extreme: 'text-red-400 bg-red-500/20', + }; + + const handleExecute = async () => { + if (!onExecute || !validation.isValid) return; + + setIsExecuting(true); + setExecutionResult(null); + + try { + const result = await onExecute({ + symbol: signal.symbol, + direction: isBuy ? 'BUY' : 'SELL', + volume: calculatedVolume, + entry: signal.entry, + stopLoss: signal.stopLoss, + takeProfit: signal.takeProfit, + riskPercent: parseFloat(riskPercent), + }); + + setExecutionResult(result); + + if (result.success) { + setConfirmStep(false); + } + } catch (err) { + setExecutionResult({ + success: false, + error: err instanceof Error ? err.message : 'Execution failed', + }); + } finally { + setIsExecuting(false); + } + }; + + // Already executed successfully + if (executionResult?.success) { + return ( +
+ +

Order Executed

+

+ Ticket #{executionResult.ticket} +

+
+

{signal.symbol} {isBuy ? 'BUY' : 'SELL'} @ {executionResult.executedPrice?.toFixed(5)}

+

Volume: {executionResult.executedVolume} lots

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ {isBuy ? ( + + ) : ( + + )} +
+
+

Execute Signal

+

+ {signal.symbol} • {isBuy ? 'BUY' : 'SELL'} +

+
+
+
+ {validation.riskAssessment.charAt(0).toUpperCase() + validation.riskAssessment.slice(1)} Risk +
+
+ +
+ {/* Signal Details */} +
+
+

Entry

+

{signal.entry.toFixed(5)}

+
+
+

Stop Loss

+

+ {signal.stopLoss?.toFixed(5) || 'Not set'} +

+
+
+

Take Profit

+

+ {signal.takeProfit?.toFixed(5) || 'Not set'} +

+
+
+ + {/* Position Sizing */} +
+
+ + +
+ + {useAutoSize ? ( +
+
+ Risk: + setRiskPercent(e.target.value)} + className="w-20 px-2 py-1 bg-gray-900 border border-gray-700 rounded text-white text-sm focus:outline-none focus:border-blue-500" + /> + % of account +
+

+ Calculated: {calculatedVolume.toFixed(2)} lots +

+
+ ) : ( + setVolume(e.target.value)} + className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-blue-500" + /> + )} +
+ + {/* Potential Outcomes */} +
+
+
+ + Potential Loss +
+

+ ${potentialLoss?.toFixed(2) || '—'} +

+
+
+
+ + Potential Profit +
+

+ ${potentialProfit?.toFixed(2) || '—'} +

+
+
+ + {/* Risk/Reward & Confidence */} +
+
+ R:R Ratio: + = 2 ? 'text-green-400' : + riskReward && riskReward >= 1 ? 'text-yellow-400' : 'text-red-400' + }`}> + {riskReward ? `1:${riskReward.toFixed(2)}` : '—'} + +
+ {signal.confidence && ( +
+ Confidence: + = 70 ? 'text-green-400' : + signal.confidence >= 50 ? 'text-yellow-400' : 'text-red-400' + }`}> + {signal.confidence}% + +
+ )} +
+ + {/* Validation Warnings */} + {(validation.warnings.length > 0 || validation.errors.length > 0) && ( +
+ + + {showDetails && ( +
+ {validation.errors.map((error, i) => ( +
+ + {error} +
+ ))} + {validation.warnings.map((warning, i) => ( +
+ + {warning} +
+ ))} +
+ )} +
+ )} + + {/* Execution Error */} + {executionResult?.error && ( +
+ +
+

Execution Failed

+

{executionResult.error}

+
+
+ )} + + {/* Action Buttons */} + {!confirmStep ? ( +
+ {onCancel && ( + + )} + +
+ ) : ( +
+
+
+ + Confirm Execution +
+

+ {isBuy ? 'Buy' : 'Sell'} {calculatedVolume.toFixed(2)} lots of {signal.symbol} at market price +

+
+
+ + +
+
+ )} +
+
+ ); +}; + +export default SignalExecutionPanel; diff --git a/src/modules/assistant/components/index.ts b/src/modules/assistant/components/index.ts index 97bf7b1..da07ca3 100644 --- a/src/modules/assistant/components/index.ts +++ b/src/modules/assistant/components/index.ts @@ -32,3 +32,7 @@ export { TypingDots, PulseIndicator, ProcessingSteps } from './StreamingIndicato // Settings export { default as AssistantSettingsPanel } from './AssistantSettingsPanel'; export type { AssistantSettings } from './AssistantSettingsPanel'; + +// Signal Execution +export { default as SignalExecutionPanel } from './SignalExecutionPanel'; +export type { ExecutionParams, ExecutionResult } from './SignalExecutionPanel'; diff --git a/src/modules/assistant/hooks/index.ts b/src/modules/assistant/hooks/index.ts new file mode 100644 index 0000000..fadbcfa --- /dev/null +++ b/src/modules/assistant/hooks/index.ts @@ -0,0 +1,22 @@ +/** + * Assistant Hooks - Index Export + * Custom hooks for LLM assistant functionality + */ + +export { default as useChatAssistant } from './useChatAssistant'; +export type { + ChatAssistantOptions, + SendMessageOptions, + ChatAssistantState, + ChatAssistantActions, + UseChatAssistantReturn, +} from './useChatAssistant'; + +export { default as useStreamingChat } from './useStreamingChat'; +export type { + StreamStatus, + StreamChunk, + StreamState, + StreamOptions, + UseStreamingChatReturn, +} from './useStreamingChat'; diff --git a/src/modules/assistant/hooks/useChatAssistant.ts b/src/modules/assistant/hooks/useChatAssistant.ts new file mode 100644 index 0000000..9a0f428 --- /dev/null +++ b/src/modules/assistant/hooks/useChatAssistant.ts @@ -0,0 +1,233 @@ +/** + * useChatAssistant Hook + * Centralized chat logic for the LLM assistant + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useChatStore } from '../../../stores/chatStore'; +import type { Message } from '../components/ChatMessage'; +import type { ToolCall } from '../components/ToolCallCard'; +import { extractTradingSignals, extractMentionedTools } from '../utils/messageFormatters'; + +export interface ChatAssistantOptions { + sessionId?: string; + autoScroll?: boolean; + maxRetries?: number; + retryDelay?: number; +} + +export interface SendMessageOptions { + attachments?: File[]; + context?: Record; + tools?: string[]; +} + +export interface ChatAssistantState { + messages: Message[]; + isLoading: boolean; + isStreaming: boolean; + error: string | null; + currentSessionId: string | null; + pendingToolCalls: ToolCall[]; +} + +export interface ChatAssistantActions { + sendMessage: (content: string, options?: SendMessageOptions) => Promise; + regenerateLastResponse: () => Promise; + cancelGeneration: () => void; + clearError: () => void; + createNewSession: () => Promise; + loadSession: (sessionId: string) => Promise; + deleteMessage: (messageId: string) => void; + editMessage: (messageId: string, newContent: string) => Promise; +} + +export function useChatAssistant(options: ChatAssistantOptions = {}): ChatAssistantState & ChatAssistantActions { + const { + sessionId: initialSessionId, + autoScroll = true, + maxRetries = 3, + retryDelay = 1000, + } = options; + + // Store state + const { + sessions, + currentSessionId, + messages: storeMessages, + loading, + error: storeError, + createNewSession: storeCreateSession, + loadSession: storeLoadSession, + sendMessage: storeSendMessage, + deleteSession, + } = useChatStore(); + + // Local state + const [isStreaming, setIsStreaming] = useState(false); + const [localError, setLocalError] = useState(null); + const [pendingToolCalls, setPendingToolCalls] = useState([]); + + // Refs + const abortControllerRef = useRef(null); + const messagesEndRef = useRef(null); + const retryCountRef = useRef(0); + + // Initialize session + useEffect(() => { + if (initialSessionId && initialSessionId !== currentSessionId) { + storeLoadSession(initialSessionId); + } + }, [initialSessionId, currentSessionId, storeLoadSession]); + + // Auto-scroll on new messages + useEffect(() => { + if (autoScroll && messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [storeMessages, autoScroll]); + + // Transform store messages to include parsed data + const messages: Message[] = storeMessages.map((msg) => ({ + ...msg, + toolsUsed: msg.toolsUsed || extractMentionedTools(msg.content), + signals: extractTradingSignals(msg.content), + })); + + // Send message with retry logic + const sendMessage = useCallback(async ( + content: string, + sendOptions: SendMessageOptions = {} + ) => { + if (!content.trim()) return; + + setLocalError(null); + abortControllerRef.current = new AbortController(); + + try { + // Add user message optimistically + const userMessage: Message = { + id: `temp-${Date.now()}`, + role: 'user', + content, + timestamp: new Date().toISOString(), + }; + + // If tools are requested, set up pending tool calls + if (sendOptions.tools?.length) { + setPendingToolCalls( + sendOptions.tools.map((tool) => ({ + id: `pending-${tool}`, + name: tool, + arguments: {}, + status: 'pending', + })) + ); + } + + setIsStreaming(true); + + // Send to store/API + await storeSendMessage(content); + + // Clear pending tool calls on success + setPendingToolCalls([]); + retryCountRef.current = 0; + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to send message'; + + // Retry logic + if (retryCountRef.current < maxRetries && !abortControllerRef.current?.signal.aborted) { + retryCountRef.current++; + await new Promise((resolve) => setTimeout(resolve, retryDelay * retryCountRef.current)); + return sendMessage(content, sendOptions); + } + + setLocalError(errorMessage); + setPendingToolCalls([]); + } finally { + setIsStreaming(false); + abortControllerRef.current = null; + } + }, [storeSendMessage, maxRetries, retryDelay]); + + // Regenerate last assistant response + const regenerateLastResponse = useCallback(async () => { + const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user'); + if (!lastUserMessage) return; + + // Remove the last assistant message from local state + const lastAssistantIdx = messages.findLastIndex((m) => m.role === 'assistant'); + if (lastAssistantIdx >= 0) { + // In a real implementation, we'd call an API to regenerate + await sendMessage(lastUserMessage.content); + } + }, [messages, sendMessage]); + + // Cancel ongoing generation + const cancelGeneration = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + setIsStreaming(false); + setPendingToolCalls([]); + } + }, []); + + // Clear error + const clearError = useCallback(() => { + setLocalError(null); + }, []); + + // Create new session + const createNewSession = useCallback(async (): Promise => { + const session = await storeCreateSession(); + return session.id; + }, [storeCreateSession]); + + // Load existing session + const loadSession = useCallback(async (sessionId: string) => { + await storeLoadSession(sessionId); + }, [storeLoadSession]); + + // Delete a message (local only, would need API support) + const deleteMessage = useCallback((messageId: string) => { + // This would require store support + console.warn('deleteMessage not implemented in store'); + }, []); + + // Edit a message and regenerate + const editMessage = useCallback(async (messageId: string, newContent: string) => { + // Find the message and regenerate from that point + const messageIdx = messages.findIndex((m) => m.id === messageId); + if (messageIdx === -1) return; + + // In a real implementation, we'd truncate history and regenerate + await sendMessage(newContent); + }, [messages, sendMessage]); + + return { + // State + messages, + isLoading: loading, + isStreaming, + error: localError || storeError, + currentSessionId, + pendingToolCalls, + + // Actions + sendMessage, + regenerateLastResponse, + cancelGeneration, + clearError, + createNewSession, + loadSession, + deleteMessage, + editMessage, + }; +} + +// Export types +export type UseChatAssistantReturn = ReturnType; + +export default useChatAssistant; diff --git a/src/modules/assistant/hooks/useStreamingChat.ts b/src/modules/assistant/hooks/useStreamingChat.ts new file mode 100644 index 0000000..99a5c36 --- /dev/null +++ b/src/modules/assistant/hooks/useStreamingChat.ts @@ -0,0 +1,345 @@ +/** + * useStreamingChat Hook + * Handle real-time streaming responses with chunk buffering + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; + +export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'complete' | 'error' | 'cancelled'; + +export interface StreamChunk { + type: 'content' | 'tool_start' | 'tool_end' | 'thinking' | 'error' | 'done'; + content?: string; + toolName?: string; + toolResult?: unknown; + error?: string; +} + +export interface StreamState { + status: StreamStatus; + content: string; + chunks: StreamChunk[]; + currentTool: string | null; + progress: number; + error: string | null; + startTime: number | null; + endTime: number | null; +} + +export interface StreamOptions { + onChunk?: (chunk: StreamChunk) => void; + onComplete?: (content: string) => void; + onError?: (error: string) => void; + onToolStart?: (toolName: string) => void; + onToolEnd?: (toolName: string, result: unknown) => void; + animateTokens?: boolean; + tokenDelay?: number; +} + +const initialState: StreamState = { + status: 'idle', + content: '', + chunks: [], + currentTool: null, + progress: 0, + error: null, + startTime: null, + endTime: null, +}; + +export function useStreamingChat(options: StreamOptions = {}) { + const { + onChunk, + onComplete, + onError, + onToolStart, + onToolEnd, + animateTokens = true, + tokenDelay = 20, + } = options; + + const [state, setState] = useState(initialState); + + // Refs for cleanup and animation + const eventSourceRef = useRef(null); + const abortControllerRef = useRef(null); + const animationFrameRef = useRef(null); + const tokenQueueRef = useRef([]); + const isProcessingRef = useRef(false); + + // Cleanup function + const cleanup = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + tokenQueueRef.current = []; + isProcessingRef.current = false; + }, []); + + // Cleanup on unmount + useEffect(() => { + return cleanup; + }, [cleanup]); + + // Token animation processor + const processTokenQueue = useCallback(() => { + if (!isProcessingRef.current || tokenQueueRef.current.length === 0) { + isProcessingRef.current = false; + return; + } + + const token = tokenQueueRef.current.shift(); + if (token) { + setState((prev) => ({ + ...prev, + content: prev.content + token, + })); + } + + if (tokenQueueRef.current.length > 0) { + animationFrameRef.current = requestAnimationFrame(() => { + setTimeout(processTokenQueue, tokenDelay); + }); + } else { + isProcessingRef.current = false; + } + }, [tokenDelay]); + + // Add content with optional animation + const addContent = useCallback((text: string) => { + if (animateTokens) { + // Split into tokens (words or characters based on length) + const tokens = text.length > 50 ? text.match(/.{1,5}/g) || [text] : text.split(''); + tokenQueueRef.current.push(...tokens); + + if (!isProcessingRef.current) { + isProcessingRef.current = true; + processTokenQueue(); + } + } else { + setState((prev) => ({ + ...prev, + content: prev.content + text, + })); + } + }, [animateTokens, processTokenQueue]); + + // Process incoming chunk + const processChunk = useCallback((chunk: StreamChunk) => { + setState((prev) => ({ + ...prev, + chunks: [...prev.chunks, chunk], + })); + + onChunk?.(chunk); + + switch (chunk.type) { + case 'content': + if (chunk.content) { + addContent(chunk.content); + } + break; + + case 'tool_start': + setState((prev) => ({ ...prev, currentTool: chunk.toolName || null })); + if (chunk.toolName) { + onToolStart?.(chunk.toolName); + } + break; + + case 'tool_end': + setState((prev) => ({ ...prev, currentTool: null })); + if (chunk.toolName) { + onToolEnd?.(chunk.toolName, chunk.toolResult); + } + break; + + case 'thinking': + setState((prev) => ({ ...prev, status: 'streaming' })); + break; + + case 'error': + setState((prev) => ({ + ...prev, + status: 'error', + error: chunk.error || 'Unknown error', + })); + if (chunk.error) { + onError?.(chunk.error); + } + break; + + case 'done': + setState((prev) => ({ + ...prev, + status: 'complete', + endTime: Date.now(), + progress: 100, + })); + break; + } + }, [addContent, onChunk, onToolStart, onToolEnd, onError]); + + // Start SSE stream + const startStream = useCallback(async (url: string, body?: Record) => { + cleanup(); + + setState({ + ...initialState, + status: 'connecting', + startTime: Date.now(), + }); + + try { + // For POST requests with SSE, we need to use fetch + ReadableStream + if (body) { + abortControllerRef.current = new AbortController(); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + }, + body: JSON.stringify(body), + signal: abortControllerRef.current.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + if (!response.body) { + throw new Error('No response body'); + } + + setState((prev) => ({ ...prev, status: 'streaming' })); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + processChunk({ type: 'done' }); + break; + } + + buffer += decoder.decode(value, { stream: true }); + + // Process SSE format: data: {...}\n\n + const lines = buffer.split('\n\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + processChunk(data as StreamChunk); + } catch { + // If not JSON, treat as plain content + processChunk({ type: 'content', content: line.slice(6) }); + } + } + } + } + } else { + // For GET requests, use EventSource + eventSourceRef.current = new EventSource(url); + + eventSourceRef.current.onopen = () => { + setState((prev) => ({ ...prev, status: 'streaming' })); + }; + + eventSourceRef.current.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + processChunk(data as StreamChunk); + } catch { + processChunk({ type: 'content', content: event.data }); + } + }; + + eventSourceRef.current.onerror = () => { + setState((prev) => ({ + ...prev, + status: 'error', + error: 'Connection lost', + })); + onError?.('Connection lost'); + cleanup(); + }; + } + + } catch (err) { + if ((err as Error).name === 'AbortError') { + setState((prev) => ({ + ...prev, + status: 'cancelled', + endTime: Date.now(), + })); + } else { + const errorMsg = err instanceof Error ? err.message : 'Stream failed'; + setState((prev) => ({ + ...prev, + status: 'error', + error: errorMsg, + endTime: Date.now(), + })); + onError?.(errorMsg); + } + } + }, [cleanup, processChunk, onError]); + + // Stop streaming + const stopStream = useCallback(() => { + cleanup(); + setState((prev) => ({ + ...prev, + status: prev.status === 'streaming' ? 'cancelled' : prev.status, + endTime: Date.now(), + })); + }, [cleanup]); + + // Reset state + const reset = useCallback(() => { + cleanup(); + setState(initialState); + }, [cleanup]); + + // Calculate duration + const duration = state.startTime + ? (state.endTime || Date.now()) - state.startTime + : 0; + + return { + // State + ...state, + duration, + isActive: state.status === 'connecting' || state.status === 'streaming', + + // Actions + startStream, + stopStream, + reset, + + // Utilities + appendContent: addContent, + }; +} + +// Export types +export type UseStreamingChatReturn = ReturnType; + +export default useStreamingChat; diff --git a/src/modules/assistant/utils/index.ts b/src/modules/assistant/utils/index.ts new file mode 100644 index 0000000..1879bba --- /dev/null +++ b/src/modules/assistant/utils/index.ts @@ -0,0 +1,44 @@ +/** + * Assistant Utilities - Index Export + * Utility functions for message parsing and formatting + */ + +export { + // Price & Number Formatting + formatPrice, + formatPercentage, + formatPnL, + formatVolume, + formatPips, + formatCurrency, + + // Signal Parsing + extractTradingSignals, + extractPriceLevels, + + // Tool Call Parsing + parseToolCallReferences, + extractMentionedTools, + + // Markdown & Text Processing + parseMarkdownTable, + stripMarkdown, + extractCodeBlocks, + + // Time & Date Formatting + formatChatTime, + formatDuration, + + // Validation Helpers + containsTradingContent, + isValidPrice, + + // Default export + default as messageFormatters, +} from './messageFormatters'; + +export type { + PriceLevel, + ToolCallReference, + ParsedSignal, +} from './messageFormatters'; diff --git a/src/modules/assistant/utils/messageFormatters.ts b/src/modules/assistant/utils/messageFormatters.ts new file mode 100644 index 0000000..aea60d4 --- /dev/null +++ b/src/modules/assistant/utils/messageFormatters.ts @@ -0,0 +1,390 @@ +/** + * Message Formatters & Signal Parsers + * Utility functions for parsing assistant responses and formatting trading data + */ + +import type { TradingSignal } from '../components/SignalCard'; +import type { ToolCall } from '../components/ToolCallCard'; + +// Types +export interface PriceLevel { + price: number; + type: 'entry' | 'stop_loss' | 'take_profit' | 'support' | 'resistance' | 'unknown'; + label?: string; +} + +export interface ToolCallReference { + toolName: string; + status: 'pending' | 'running' | 'success' | 'error'; + position: { start: number; end: number }; +} + +export interface ParsedSignal { + symbol: string; + direction: 'BUY' | 'SELL' | 'HOLD'; + entry?: number; + stopLoss?: number; + takeProfit?: number; + confidence?: number; + timeframe?: string; + raw: string; +} + +// ============================================================ +// Price & Number Formatting +// ============================================================ + +/** + * Format price based on symbol type + */ +export function formatPrice(price: number, symbol: string): string { + const isJpy = symbol.toUpperCase().includes('JPY'); + const decimals = isJpy ? 3 : 5; + return price.toFixed(decimals); +} + +/** + * Format percentage with optional decimals + */ +export function formatPercentage(value: number, decimals = 2): string { + const sign = value >= 0 ? '+' : ''; + return `${sign}${value.toFixed(decimals)}%`; +} + +/** + * Format P&L with value and percentage + */ +export function formatPnL(pnl: number, pnlPct?: number): string { + const sign = pnl >= 0 ? '+' : ''; + const pnlStr = `${sign}$${Math.abs(pnl).toFixed(2)}`; + + if (pnlPct !== undefined) { + return `${pnlStr} (${formatPercentage(pnlPct)})`; + } + return pnlStr; +} + +/** + * Format volume/lots + */ +export function formatVolume(volume: number): string { + if (volume >= 1) return volume.toFixed(2); + return volume.toFixed(3); +} + +/** + * Format pips difference + */ +export function formatPips(pips: number): string { + const sign = pips >= 0 ? '+' : ''; + return `${sign}${pips.toFixed(1)} pips`; +} + +/** + * Format currency + */ +export function formatCurrency(value: number, currency = 'USD'): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + minimumFractionDigits: 2, + }).format(value); +} + +// ============================================================ +// Signal Parsing +// ============================================================ + +/** + * Extract trading signals from assistant response text + */ +export function extractTradingSignals(content: string): ParsedSignal[] { + const signals: ParsedSignal[] = []; + + // Pattern for explicit signal mentions + const signalPatterns = [ + // "BUY EURUSD at 1.0850" + /\b(BUY|SELL)\s+([A-Z]{6})\s+(?:at\s+)?(\d+\.?\d*)/gi, + // "Signal: LONG GBPUSD" + /signal[:\s]+?(LONG|SHORT|BUY|SELL)\s+([A-Z]{6})/gi, + // "Recommendation: Buy EUR/USD" + /recommend(?:ation)?[:\s]+?(BUY|SELL)\s+([A-Z]{3})\/?([A-Z]{3})/gi, + ]; + + for (const pattern of signalPatterns) { + let match; + while ((match = pattern.exec(content)) !== null) { + const direction = match[1].toUpperCase(); + const symbol = match[2] ? match[2].toUpperCase() : `${match[2]}${match[3]}`.toUpperCase(); + const entry = match[3] ? parseFloat(match[3]) : undefined; + + signals.push({ + symbol: symbol.replace('/', ''), + direction: direction === 'LONG' ? 'BUY' : direction === 'SHORT' ? 'SELL' : direction as 'BUY' | 'SELL', + entry, + raw: match[0], + }); + } + } + + // Extract SL/TP if present near signal + for (const signal of signals) { + const slMatch = content.match(new RegExp(`SL[:\\s]*?(\\d+\\.\\d+)`, 'i')); + const tpMatch = content.match(new RegExp(`TP[:\\s]*?(\\d+\\.\\d+)`, 'i')); + const confMatch = content.match(/confidence[:\s]*?(\d+)%?/i); + + if (slMatch) signal.stopLoss = parseFloat(slMatch[1]); + if (tpMatch) signal.takeProfit = parseFloat(tpMatch[1]); + if (confMatch) signal.confidence = parseInt(confMatch[1]); + } + + return signals; +} + +/** + * Extract price levels from text + */ +export function extractPriceLevels(content: string): PriceLevel[] { + const levels: PriceLevel[] = []; + + const patterns = [ + { regex: /entry[:\s]*?(\d+\.?\d*)/gi, type: 'entry' as const }, + { regex: /stop[- ]?loss[:\s]*?(\d+\.?\d*)/gi, type: 'stop_loss' as const }, + { regex: /take[- ]?profit[:\s]*?(\d+\.?\d*)/gi, type: 'take_profit' as const }, + { regex: /support[:\s]*?(\d+\.?\d*)/gi, type: 'support' as const }, + { regex: /resistance[:\s]*?(\d+\.?\d*)/gi, type: 'resistance' as const }, + { regex: /SL[:\s]*?(\d+\.?\d*)/gi, type: 'stop_loss' as const }, + { regex: /TP[:\s]*?(\d+\.?\d*)/gi, type: 'take_profit' as const }, + ]; + + for (const { regex, type } of patterns) { + let match; + while ((match = regex.exec(content)) !== null) { + levels.push({ + price: parseFloat(match[1]), + type, + }); + } + } + + return levels; +} + +// ============================================================ +// Tool Call Parsing +// ============================================================ + +/** + * Parse tool call references from message content + */ +export function parseToolCallReferences(content: string): ToolCallReference[] { + const references: ToolCallReference[] = []; + + // Pattern: [tool:get_price:running] or {tool:analyze_symbol:success} + const pattern = /[\[{]tool:(\w+):(\w+)[\]}]/gi; + + let match; + while ((match = pattern.exec(content)) !== null) { + references.push({ + toolName: match[1], + status: match[2] as ToolCallReference['status'], + position: { start: match.index, end: match.index + match[0].length }, + }); + } + + return references; +} + +/** + * Extract tool names mentioned in text + */ +export function extractMentionedTools(content: string): string[] { + const toolKeywords = [ + 'get_price', 'get_quote', 'analyze_symbol', 'get_signal', + 'create_order', 'close_position', 'get_portfolio', 'search_symbols', + 'backtest', 'calculate_risk', 'get_history', + ]; + + return toolKeywords.filter((tool) => + content.toLowerCase().includes(tool.replace('_', ' ')) || + content.toLowerCase().includes(tool) + ); +} + +// ============================================================ +// Markdown & Text Processing +// ============================================================ + +/** + * Parse markdown-style tables to structured data + */ +export function parseMarkdownTable(text: string): Record[] { + const lines = text.trim().split('\n'); + const data: Record[] = []; + + // Find table header + const headerLine = lines.findIndex((line) => line.includes('|')); + if (headerLine === -1) return data; + + const headers = lines[headerLine] + .split('|') + .map((h) => h.trim()) + .filter(Boolean); + + // Skip separator line + const dataStart = lines[headerLine + 1]?.includes('---') ? headerLine + 2 : headerLine + 1; + + for (let i = dataStart; i < lines.length; i++) { + if (!lines[i].includes('|')) continue; + + const values = lines[i] + .split('|') + .map((v) => v.trim()) + .filter(Boolean); + + const row: Record = {}; + headers.forEach((header, idx) => { + row[header] = values[idx] || ''; + }); + data.push(row); + } + + return data; +} + +/** + * Strip markdown formatting for plain text + */ +export function stripMarkdown(text: string): string { + return text + .replace(/\*\*(.*?)\*\*/g, '$1') // Bold + .replace(/\*(.*?)\*/g, '$1') // Italic + .replace(/`(.*?)`/g, '$1') // Inline code + .replace(/```[\s\S]*?```/g, '') // Code blocks + .replace(/\[(.*?)\]\(.*?\)/g, '$1') // Links + .replace(/#{1,6}\s/g, '') // Headers + .replace(/[>\-*+]\s/g, '') // List items + .trim(); +} + +/** + * Extract code blocks from markdown + */ +export function extractCodeBlocks(content: string): { language: string; code: string }[] { + const blocks: { language: string; code: string }[] = []; + const pattern = /```(\w*)\n([\s\S]*?)```/g; + + let match; + while ((match = pattern.exec(content)) !== null) { + blocks.push({ + language: match[1] || 'text', + code: match[2].trim(), + }); + } + + return blocks; +} + +// ============================================================ +// Time & Date Formatting +// ============================================================ + +/** + * Format timestamp for chat display + */ +export function formatChatTime(timestamp: string | Date): string { + const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp; + const now = new Date(); + const diff = now.getTime() - date.getTime(); + + // Less than a minute + if (diff < 60000) return 'Just now'; + + // Less than an hour + if (diff < 3600000) { + const mins = Math.floor(diff / 60000); + return `${mins}m ago`; + } + + // Same day + if (date.toDateString() === now.toDateString()) { + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + } + + // Yesterday + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + if (date.toDateString() === yesterday.toDateString()) { + return 'Yesterday'; + } + + // Within a week + if (diff < 7 * 24 * 3600000) { + return date.toLocaleDateString('en-US', { weekday: 'short' }); + } + + // Older + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +/** + * Format duration in ms to human readable + */ +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`; +} + +// ============================================================ +// Validation Helpers +// ============================================================ + +/** + * Check if text contains trading-related content + */ +export function containsTradingContent(content: string): boolean { + const tradingKeywords = [ + 'buy', 'sell', 'long', 'short', 'signal', 'entry', 'exit', + 'stop loss', 'take profit', 'sl', 'tp', 'pips', 'lots', + 'bullish', 'bearish', 'support', 'resistance', 'trend', + ]; + + const lowerContent = content.toLowerCase(); + return tradingKeywords.some((kw) => lowerContent.includes(kw)); +} + +/** + * Validate price format for symbol + */ +export function isValidPrice(price: number, symbol: string): boolean { + const isJpy = symbol.toUpperCase().includes('JPY'); + + if (isJpy) { + return price > 50 && price < 500; // JPY pairs typically 100-200 + } + + // Most forex pairs + return price > 0.5 && price < 3; +} + +// Export all formatters +export default { + formatPrice, + formatPercentage, + formatPnL, + formatVolume, + formatPips, + formatCurrency, + extractTradingSignals, + extractPriceLevels, + parseToolCallReferences, + extractMentionedTools, + parseMarkdownTable, + stripMarkdown, + extractCodeBlocks, + formatChatTime, + formatDuration, + containsTradingContent, + isValidPrice, +};