[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 11:05:55 -06:00
parent bf726a595c
commit ed2e1472f4
7 changed files with 1509 additions and 0 deletions

View File

@ -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<ExecutionResult>;
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<SignalExecutionPanelProps> = ({
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<ExecutionResult | null>(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 (
<div className="bg-gray-800 rounded-xl border border-green-500/30 p-6 text-center">
<CheckCircle className="w-12 h-12 text-green-400 mx-auto mb-3" />
<h4 className="font-medium text-white mb-1">Order Executed</h4>
<p className="text-sm text-gray-400 mb-2">
Ticket #{executionResult.ticket}
</p>
<div className="text-sm text-gray-300">
<p>{signal.symbol} {isBuy ? 'BUY' : 'SELL'} @ {executionResult.executedPrice?.toFixed(5)}</p>
<p>Volume: {executionResult.executedVolume} lots</p>
</div>
</div>
);
}
return (
<div className="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
{/* Header */}
<div className={`flex items-center justify-between p-4 border-b ${
isBuy ? 'bg-green-500/10 border-green-500/30' : 'bg-red-500/10 border-red-500/30'
}`}>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isBuy ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
{isBuy ? (
<TrendingUp className="w-5 h-5 text-green-400" />
) : (
<TrendingDown className="w-5 h-5 text-red-400" />
)}
</div>
<div>
<h3 className="font-medium text-white">Execute Signal</h3>
<p className="text-sm text-gray-400">
{signal.symbol} {isBuy ? 'BUY' : 'SELL'}
</p>
</div>
</div>
<div className={`px-3 py-1 rounded-full text-sm font-medium ${riskColors[validation.riskAssessment]}`}>
{validation.riskAssessment.charAt(0).toUpperCase() + validation.riskAssessment.slice(1)} Risk
</div>
</div>
<div className="p-4 space-y-4">
{/* Signal Details */}
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="text-center p-3 bg-gray-900/50 rounded-lg">
<p className="text-gray-500 text-xs mb-1">Entry</p>
<p className="text-white font-mono font-medium">{signal.entry.toFixed(5)}</p>
</div>
<div className="text-center p-3 bg-gray-900/50 rounded-lg">
<p className="text-gray-500 text-xs mb-1">Stop Loss</p>
<p className={`font-mono font-medium ${signal.stopLoss ? 'text-red-400' : 'text-gray-500'}`}>
{signal.stopLoss?.toFixed(5) || 'Not set'}
</p>
</div>
<div className="text-center p-3 bg-gray-900/50 rounded-lg">
<p className="text-gray-500 text-xs mb-1">Take Profit</p>
<p className={`font-mono font-medium ${signal.takeProfit ? 'text-green-400' : 'text-gray-500'}`}>
{signal.takeProfit?.toFixed(5) || 'Not set'}
</p>
</div>
</div>
{/* Position Sizing */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-gray-400">Position Size</label>
<button
onClick={() => setUseAutoSize(!useAutoSize)}
className={`flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors ${
useAutoSize
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-400 hover:text-white'
}`}
>
<Calculator className="w-3 h-3" />
Auto-size
</button>
</div>
{useAutoSize ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-400">Risk:</span>
<input
type="number"
step="0.5"
min="0.1"
max="5"
value={riskPercent}
onChange={(e) => 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"
/>
<span className="text-sm text-gray-400">% of account</span>
</div>
<p className="text-sm text-green-400">
Calculated: <span className="font-medium">{calculatedVolume.toFixed(2)}</span> lots
</p>
</div>
) : (
<input
type="number"
step="0.01"
min="0.01"
max="10"
value={volume}
onChange={(e) => 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"
/>
)}
</div>
{/* Potential Outcomes */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<div className="flex items-center gap-2 text-red-400 text-sm mb-1">
<Shield className="w-4 h-4" />
<span>Potential Loss</span>
</div>
<p className="text-lg font-bold text-red-400">
${potentialLoss?.toFixed(2) || '—'}
</p>
</div>
<div className="p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<div className="flex items-center gap-2 text-green-400 text-sm mb-1">
<Target className="w-4 h-4" />
<span>Potential Profit</span>
</div>
<p className="text-lg font-bold text-green-400">
${potentialProfit?.toFixed(2) || '—'}
</p>
</div>
</div>
{/* Risk/Reward & Confidence */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-400">R:R Ratio:</span>
<span className={`font-medium ${
riskReward && riskReward >= 2 ? 'text-green-400' :
riskReward && riskReward >= 1 ? 'text-yellow-400' : 'text-red-400'
}`}>
{riskReward ? `1:${riskReward.toFixed(2)}` : '—'}
</span>
</div>
{signal.confidence && (
<div className="flex items-center gap-2">
<span className="text-gray-400">Confidence:</span>
<span className={`font-medium ${
signal.confidence >= 70 ? 'text-green-400' :
signal.confidence >= 50 ? 'text-yellow-400' : 'text-red-400'
}`}>
{signal.confidence}%
</span>
</div>
)}
</div>
{/* Validation Warnings */}
{(validation.warnings.length > 0 || validation.errors.length > 0) && (
<div>
<button
onClick={() => setShowDetails(!showDetails)}
className="flex items-center gap-2 text-sm text-yellow-400 hover:text-yellow-300"
>
<AlertTriangle className="w-4 h-4" />
{validation.errors.length + validation.warnings.length} issue(s) found
{showDetails ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showDetails && (
<div className="mt-2 p-3 bg-gray-900/50 rounded-lg space-y-2">
{validation.errors.map((error, i) => (
<div key={i} className="flex items-start gap-2 text-sm text-red-400">
<XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{error}</span>
</div>
))}
{validation.warnings.map((warning, i) => (
<div key={i} className="flex items-start gap-2 text-sm text-yellow-400">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{warning}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Execution Error */}
{executionResult?.error && (
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<XCircle className="w-5 h-5 text-red-400 flex-shrink-0" />
<div>
<p className="font-medium text-red-400">Execution Failed</p>
<p className="text-sm text-gray-300">{executionResult.error}</p>
</div>
</div>
)}
{/* Action Buttons */}
{!confirmStep ? (
<div className="flex items-center gap-2">
{onCancel && (
<button
onClick={onCancel}
className="flex-1 py-2.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition-colors"
>
Cancel
</button>
)}
<button
onClick={() => setConfirmStep(true)}
disabled={!validation.isValid || !onExecute}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isBuy
? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-red-600 hover:bg-red-500 text-white'
}`}
>
<Zap className="w-4 h-4" />
Review Order
</button>
</div>
) : (
<div className="space-y-3">
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-center gap-2 text-yellow-400">
<Info className="w-4 h-4" />
<span className="font-medium">Confirm Execution</span>
</div>
<p className="text-sm text-gray-300 mt-1">
{isBuy ? 'Buy' : 'Sell'} {calculatedVolume.toFixed(2)} lots of {signal.symbol} at market price
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setConfirmStep(false)}
className="flex-1 py-2.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition-colors"
>
Back
</button>
<button
onClick={handleExecute}
disabled={isExecuting}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 ${
isBuy
? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-red-600 hover:bg-red-500 text-white'
}`}
>
{isExecuting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Executing...
</>
) : (
<>
<CheckCircle className="w-4 h-4" />
Confirm {isBuy ? 'Buy' : 'Sell'}
</>
)}
</button>
</div>
</div>
)}
</div>
</div>
);
};
export default SignalExecutionPanel;

View File

@ -32,3 +32,7 @@ export { TypingDots, PulseIndicator, ProcessingSteps } from './StreamingIndicato
// Settings // Settings
export { default as AssistantSettingsPanel } from './AssistantSettingsPanel'; export { default as AssistantSettingsPanel } from './AssistantSettingsPanel';
export type { AssistantSettings } from './AssistantSettingsPanel'; export type { AssistantSettings } from './AssistantSettingsPanel';
// Signal Execution
export { default as SignalExecutionPanel } from './SignalExecutionPanel';
export type { ExecutionParams, ExecutionResult } from './SignalExecutionPanel';

View File

@ -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';

View File

@ -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<string, unknown>;
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<void>;
regenerateLastResponse: () => Promise<void>;
cancelGeneration: () => void;
clearError: () => void;
createNewSession: () => Promise<string>;
loadSession: (sessionId: string) => Promise<void>;
deleteMessage: (messageId: string) => void;
editMessage: (messageId: string, newContent: string) => Promise<void>;
}
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<string | null>(null);
const [pendingToolCalls, setPendingToolCalls] = useState<ToolCall[]>([]);
// Refs
const abortControllerRef = useRef<AbortController | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(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<string> => {
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<typeof useChatAssistant>;
export default useChatAssistant;

View File

@ -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<StreamState>(initialState);
// Refs for cleanup and animation
const eventSourceRef = useRef<EventSource | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const animationFrameRef = useRef<number | null>(null);
const tokenQueueRef = useRef<string[]>([]);
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<string, unknown>) => {
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<typeof useStreamingChat>;
export default useStreamingChat;

View File

@ -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';

View File

@ -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<string, string>[] {
const lines = text.trim().split('\n');
const data: Record<string, string>[] = [];
// 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<string, string> = {};
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,
};