[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:
parent
bf726a595c
commit
ed2e1472f4
471
src/modules/assistant/components/SignalExecutionPanel.tsx
Normal file
471
src/modules/assistant/components/SignalExecutionPanel.tsx
Normal 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;
|
||||
@ -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';
|
||||
|
||||
22
src/modules/assistant/hooks/index.ts
Normal file
22
src/modules/assistant/hooks/index.ts
Normal 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';
|
||||
233
src/modules/assistant/hooks/useChatAssistant.ts
Normal file
233
src/modules/assistant/hooks/useChatAssistant.ts
Normal 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;
|
||||
345
src/modules/assistant/hooks/useStreamingChat.ts
Normal file
345
src/modules/assistant/hooks/useStreamingChat.ts
Normal 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;
|
||||
44
src/modules/assistant/utils/index.ts
Normal file
44
src/modules/assistant/utils/index.ts
Normal 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';
|
||||
390
src/modules/assistant/utils/messageFormatters.ts
Normal file
390
src/modules/assistant/utils/messageFormatters.ts
Normal 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,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user