This is page 2 of 6. Use http://codebase.md/sammcj/bybit-mcp?page={x} to view the full context. # Directory Structure ``` ├── .env.example ├── .gitignore ├── client │ ├── .env.example │ ├── .gitignore │ ├── package.json │ ├── pnpm-lock.yaml │ ├── README.md │ ├── src │ │ ├── cli.ts │ │ ├── client.ts │ │ ├── config.ts │ │ ├── env.ts │ │ ├── index.ts │ │ └── launch.ts │ └── tsconfig.json ├── DEV_PLAN.md ├── docs │ └── HTTP_SERVER.md ├── eslint.config.js ├── jest.config.js ├── LICENSE ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── README.md ├── specs │ ├── bybit │ │ ├── bybit-api-v5-openapi.yaml │ │ └── bybit-api-v5-postman-collection.json │ ├── mcp │ │ ├── mcp-schema.json │ │ └── mcp-schema.ts │ └── README.md ├── src │ ├── __tests__ │ │ ├── GetMLRSI.test.ts │ │ ├── integration.test.ts │ │ ├── test-setup.ts │ │ └── tools.test.ts │ ├── constants.ts │ ├── env.ts │ ├── httpServer.ts │ ├── index.ts │ ├── tools │ │ ├── BaseTool.ts │ │ ├── GetInstrumentInfo.ts │ │ ├── GetKline.ts │ │ ├── GetMarketInfo.ts │ │ ├── GetMarketStructure.ts │ │ ├── GetMLRSI.ts │ │ ├── GetOrderBlocks.ts │ │ ├── GetOrderbook.ts │ │ ├── GetOrderHistory.ts │ │ ├── GetPositions.ts │ │ ├── GetTicker.ts │ │ ├── GetTrades.ts │ │ └── GetWalletBalance.ts │ └── utils │ ├── knnAlgorithm.ts │ ├── mathUtils.ts │ ├── toolLoader.ts │ └── volumeAnalysis.ts ├── tsconfig.json └── webui ├── .dockerignore ├── .env.example ├── build-docker.sh ├── docker-compose.yml ├── docker-entrypoint.sh ├── docker-healthcheck.sh ├── DOCKER.md ├── Dockerfile ├── index.html ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── public │ ├── favicon.svg │ └── inter.woff2 ├── README.md ├── screenshot.png ├── src │ ├── assets │ │ └── fonts │ │ └── fonts.css │ ├── components │ │ ├── AgentDashboard.ts │ │ ├── chat │ │ │ ├── DataCard.ts │ │ │ └── MessageRenderer.ts │ │ ├── ChatApp.ts │ │ ├── DataVerificationPanel.ts │ │ ├── DebugConsole.ts │ │ └── ToolsManager.ts │ ├── main.ts │ ├── services │ │ ├── agentConfig.ts │ │ ├── agentMemory.ts │ │ ├── aiClient.ts │ │ ├── citationProcessor.ts │ │ ├── citationStore.ts │ │ ├── configService.ts │ │ ├── logService.ts │ │ ├── mcpClient.ts │ │ ├── multiStepAgent.ts │ │ ├── performanceOptimiser.ts │ │ └── systemPrompt.ts │ ├── styles │ │ ├── agent-dashboard.css │ │ ├── base.css │ │ ├── citations.css │ │ ├── components.css │ │ ├── data-cards.css │ │ ├── main.css │ │ ├── processing.css │ │ ├── variables.css │ │ └── verification-panel.css │ ├── types │ │ ├── agent.ts │ │ ├── ai.ts │ │ ├── citation.ts │ │ ├── mcp.ts │ │ └── workflow.ts │ └── utils │ ├── dataDetection.ts │ └── formatters.ts ├── tsconfig.json └── vite.config.ts ``` # Files -------------------------------------------------------------------------------- /webui/src/styles/agent-dashboard.css: -------------------------------------------------------------------------------- ```css /** * Agent Dashboard Styles */ /* Dashboard container */ .agent-dashboard { position: fixed; top: 0; left: 0; width: 350px; height: 100vh; background: var(--dashboard-bg, #ffffff); border-right: 1px solid var(--dashboard-border, #e0e0e0); box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); z-index: 1000; transform: translateX(-100%); transition: transform 0.3s ease; display: flex; flex-direction: column; overflow: hidden; } .agent-dashboard.visible { transform: translateX(0); } .agent-dashboard.hidden { transform: translateX(-100%); } /* Dashboard header */ .dashboard-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--dashboard-border, #e0e0e0); background: var(--dashboard-header-bg, #f8f9fa); flex-shrink: 0; } .dashboard-header h3 { margin: 0; font-size: 1.1em; font-weight: 600; color: var(--dashboard-title, #333); } .dashboard-controls { display: flex; align-items: center; gap: 8px; } .refresh-btn, .toggle-btn { background: var(--accent-color, #007acc); color: white; border: none; padding: 6px 10px; border-radius: 4px; cursor: pointer; transition: background 0.2s ease; font-size: 0.9em; } .refresh-btn:hover, .toggle-btn:hover { background: var(--accent-hover, #005a9e); } /* Dashboard content */ .dashboard-content { flex: 1; overflow-y: auto; padding: 16px 20px; } /* Dashboard sections */ .dashboard-section { margin-bottom: 24px; } .dashboard-section h4 { margin: 0 0 12px 0; font-size: 1em; font-weight: 600; color: var(--section-title, #333); border-bottom: 1px solid var(--section-border, #eee); padding-bottom: 6px; } /* Statistics grid */ .stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .stat-item { background: var(--stat-bg, #f8f9fa); border: 1px solid var(--stat-border, #e9ecef); border-radius: 6px; padding: 12px; text-align: center; } .stat-label { display: block; font-size: 0.8em; color: var(--text-muted, #666); margin-bottom: 4px; } .stat-value { display: block; font-size: 1.2em; font-weight: 600; color: var(--accent-color, #007acc); } /* Analysis list */ .analysis-list { display: flex; flex-direction: column; gap: 12px; max-height: 300px; overflow-y: auto; } .analysis-item { background: var(--analysis-bg, #fff); border: 1px solid var(--analysis-border, #e0e0e0); border-radius: 6px; padding: 12px; transition: all 0.2s ease; } .analysis-item:hover { border-color: var(--accent-color, #007acc); box-shadow: 0 2px 4px rgba(0, 122, 204, 0.1); } .analysis-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; flex-wrap: wrap; gap: 6px; } .analysis-symbol { background: var(--accent-color, #007acc); color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.8em; font-weight: 500; } .analysis-type { background: var(--type-bg, #e9ecef); color: var(--type-text, #495057); padding: 2px 6px; border-radius: 3px; font-size: 0.8em; text-transform: capitalize; } .analysis-time { font-size: 0.8em; color: var(--text-muted, #666); } .analysis-query { font-size: 0.85em; color: var(--text-primary, #333); margin-bottom: 6px; line-height: 1.3; } .analysis-metrics { display: flex; gap: 8px; flex-wrap: wrap; } .analysis-metrics .metric { font-size: 0.75em; color: var(--text-muted, #666); background: var(--metric-bg, #f1f3f4); padding: 2px 6px; border-radius: 3px; } /* Action buttons */ .action-buttons { display: flex; flex-direction: column; gap: 8px; } .action-btn { background: var(--button-bg, #fff); color: var(--button-text, #333); border: 1px solid var(--button-border, #ddd); padding: 8px 12px; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; font-size: 0.9em; } .action-btn:hover { background: var(--button-hover-bg, #f8f9fa); border-color: var(--accent-color, #007acc); } .action-btn:active { background: var(--button-active-bg, #e9ecef); } /* Empty state */ .empty-state { text-align: center; padding: 20px; color: var(--text-muted, #666); } .empty-state p { margin: 0; font-style: italic; font-size: 0.9em; } /* Toast notifications */ .dashboard-toast { position: fixed; top: 20px; left: 370px; /* Position next to dashboard */ padding: 12px 20px; border-radius: 6px; color: white; font-weight: 500; z-index: 3000; transform: translateX(-100%); transition: transform 0.3s ease; max-width: 300px; } .dashboard-toast.show { transform: translateX(0); } .dashboard-toast.toast-success { background: var(--success-color, #28a745); } .dashboard-toast.toast-error { background: var(--danger-color, #dc3545); } /* Dark theme */ [data-theme="dark"] .agent-dashboard { --dashboard-bg: #2d2d2d; --dashboard-border: #444; --dashboard-header-bg: #333; --dashboard-title: #fff; --section-title: #fff; --section-border: #444; --stat-bg: #333; --stat-border: #444; --text-muted: #aaa; --text-primary: #fff; --analysis-bg: #333; --analysis-border: #444; --type-bg: #444; --type-text: #ccc; --metric-bg: #404040; --button-bg: #444; --button-text: #fff; --button-border: #555; --button-hover-bg: #505050; --button-active-bg: #555; } /* Responsive design */ @media (max-width: 768px) { .agent-dashboard { width: 100vw; } .dashboard-toast { left: 20px; right: 20px; max-width: none; } .stats-grid { grid-template-columns: 1fr; } } /* Scrollbar styling */ .dashboard-content::-webkit-scrollbar, .analysis-list::-webkit-scrollbar { width: 6px; } .dashboard-content::-webkit-scrollbar-track, .analysis-list::-webkit-scrollbar-track { background: var(--scrollbar-track, #f1f1f1); } .dashboard-content::-webkit-scrollbar-thumb, .analysis-list::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb, #c1c1c1); border-radius: 3px; } .dashboard-content::-webkit-scrollbar-thumb:hover, .analysis-list::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover, #a8a8a8); } /* Animation for stats updates */ .stat-value { transition: color 0.3s ease; } .stat-value.updated { color: var(--success-color, #28a745); } /* Performance indicators */ .stat-item.performance-good .stat-value { color: var(--success-color, #28a745); } .stat-item.performance-warning .stat-value { color: var(--warning-color, #ffc107); } .stat-item.performance-poor .stat-value { color: var(--danger-color, #dc3545); } /* Loading state */ .dashboard-section.loading { opacity: 0.6; pointer-events: none; } .dashboard-section.loading::after { content: ''; position: absolute; top: 50%; left: 50%; width: 20px; height: 20px; margin: -10px 0 0 -10px; border: 2px solid var(--accent-color, #007acc); border-top: 2px solid transparent; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } ``` -------------------------------------------------------------------------------- /webui/src/types/workflow.ts: -------------------------------------------------------------------------------- ```typescript /** * Workflow event types and utilities for agent workflows */ // Base workflow event interface export interface BaseWorkflowEvent { id: string; timestamp: number; source: 'agent' | 'tool' | 'workflow' | 'user'; } // Market analysis workflow events export interface MarketAnalysisRequestEvent extends BaseWorkflowEvent { type: 'market_analysis_request'; data: { symbol: string; analysisType: 'quick' | 'standard' | 'comprehensive'; userQuery: string; preferences: { includeTechnical: boolean; includeStructure: boolean; includeRisk: boolean; }; }; } export interface TechnicalDataGatheredEvent extends BaseWorkflowEvent { type: 'technical_data_gathered'; data: { symbol: string; priceData: any; indicators: any; volume: any; confidence: number; }; } export interface StructureAnalysisCompleteEvent extends BaseWorkflowEvent { type: 'structure_analysis_complete'; data: { symbol: string; orderBlocks: any; marketStructure: any; liquidityZones: any; confidence: number; }; } export interface RiskAssessmentDoneEvent extends BaseWorkflowEvent { type: 'risk_assessment_done'; data: { symbol: string; riskLevel: 'low' | 'medium' | 'high'; positionSizing: any; stopLoss: number; takeProfit: number; confidence: number; }; } export interface FinalRecommendationEvent extends BaseWorkflowEvent { type: 'final_recommendation'; data: { symbol: string; action: 'buy' | 'sell' | 'hold' | 'wait'; confidence: number; reasoning: string; technicalAnalysis?: any; structureAnalysis?: any; riskAssessment?: any; timeframe: string; }; } // Agent communication events export interface AgentHandoffEvent extends BaseWorkflowEvent { type: 'agent_handoff'; data: { fromAgent: string; toAgent: string; context: any; reason: string; }; } export interface AgentCollaborationEvent extends BaseWorkflowEvent { type: 'agent_collaboration'; data: { participants: string[]; topic: string; consensus?: any; disagreements?: any; }; } // Tool execution events export interface ToolExecutionStartEvent extends BaseWorkflowEvent { type: 'tool_execution_start'; data: { toolName: string; parameters: Record<string, any>; expectedDuration?: number; agent: string; }; } export interface ToolExecutionCompleteEvent extends BaseWorkflowEvent { type: 'tool_execution_complete'; data: { toolName: string; parameters: Record<string, any>; result: any; duration: number; success: boolean; agent: string; }; } export interface ToolExecutionErrorEvent extends BaseWorkflowEvent { type: 'tool_execution_error'; data: { toolName: string; parameters: Record<string, any>; error: string; duration: number; agent: string; retryable: boolean; }; } // Workflow control events export interface WorkflowStartEvent extends BaseWorkflowEvent { type: 'workflow_start'; data: { workflowName: string; initialQuery: string; configuration: any; }; } export interface WorkflowStepEvent extends BaseWorkflowEvent { type: 'workflow_step'; data: { stepName: string; stepDescription: string; progress: number; totalSteps: number; currentAgent?: string; }; } export interface WorkflowCompleteEvent extends BaseWorkflowEvent { type: 'workflow_complete'; data: { workflowName: string; result: any; duration: number; stepsCompleted: number; success: boolean; }; } export interface WorkflowErrorEvent extends BaseWorkflowEvent { type: 'workflow_error'; data: { workflowName: string; error: string; step?: string; agent?: string; recoverable: boolean; }; } // Agent thinking and reasoning events export interface AgentReasoningEvent extends BaseWorkflowEvent { type: 'agent_reasoning'; data: { agent: string; thought: string; nextAction: string; confidence: number; context: any; }; } export interface AgentDecisionEvent extends BaseWorkflowEvent { type: 'agent_decision'; data: { agent: string; decision: string; reasoning: string; alternatives: string[]; confidence: number; }; } // Union type for all workflow events export type WorkflowEvent = | MarketAnalysisRequestEvent | TechnicalDataGatheredEvent | StructureAnalysisCompleteEvent | RiskAssessmentDoneEvent | FinalRecommendationEvent | AgentHandoffEvent | AgentCollaborationEvent | ToolExecutionStartEvent | ToolExecutionCompleteEvent | ToolExecutionErrorEvent | WorkflowStartEvent | WorkflowStepEvent | WorkflowCompleteEvent | WorkflowErrorEvent | AgentReasoningEvent | AgentDecisionEvent; // Event utilities export class WorkflowEventEmitter { private listeners: Map<string, ((event: WorkflowEvent) => void)[]> = new Map(); on(eventType: string, listener: (event: WorkflowEvent) => void): void { if (!this.listeners.has(eventType)) { this.listeners.set(eventType, []); } this.listeners.get(eventType)!.push(listener); } off(eventType: string, listener: (event: WorkflowEvent) => void): void { const listeners = this.listeners.get(eventType); if (listeners) { const index = listeners.indexOf(listener); if (index > -1) { listeners.splice(index, 1); } } } emit(event: WorkflowEvent): void { const listeners = this.listeners.get(event.type); if (listeners) { listeners.forEach(listener => listener(event)); } // Also emit to 'all' listeners const allListeners = this.listeners.get('all'); if (allListeners) { allListeners.forEach(listener => listener(event)); } } clear(): void { this.listeners.clear(); } } // Event factory functions export function createWorkflowEvent<T extends WorkflowEvent>( type: T['type'], data: T['data'], source: BaseWorkflowEvent['source'] = 'workflow' ): T { return { id: `${type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, timestamp: Date.now(), source, type, data } as T; } // Event type guards export function isToolEvent(event: WorkflowEvent): event is ToolExecutionStartEvent | ToolExecutionCompleteEvent | ToolExecutionErrorEvent { return event.type.startsWith('tool_execution'); } export function isAgentEvent(event: WorkflowEvent): event is AgentHandoffEvent | AgentCollaborationEvent | AgentReasoningEvent | AgentDecisionEvent { return event.type.startsWith('agent_'); } export function isWorkflowControlEvent(event: WorkflowEvent): event is WorkflowStartEvent | WorkflowStepEvent | WorkflowCompleteEvent | WorkflowErrorEvent { return event.type.startsWith('workflow_'); } export function isAnalysisEvent(event: WorkflowEvent): event is MarketAnalysisRequestEvent | TechnicalDataGatheredEvent | StructureAnalysisCompleteEvent | RiskAssessmentDoneEvent | FinalRecommendationEvent { return ['market_analysis_request', 'technical_data_gathered', 'structure_analysis_complete', 'risk_assessment_done', 'final_recommendation'].includes(event.type); } ``` -------------------------------------------------------------------------------- /webui/src/services/citationStore.ts: -------------------------------------------------------------------------------- ```typescript /** * Citation store for managing tool response data and references */ import type { CitationData, ExtractedMetric } from '@/types/citation'; export class CitationStore { private citations: Map<string, CitationData> = new Map(); private maxCitations = 100; // Limit to prevent memory issues private cleanupThreshold = 120; // Clean up citations older than 2 hours /** * Store tool response data with citation metadata */ storeCitation(data: CitationData): void { this.citations.set(data.referenceId, data); // Clean up old citations if we exceed the limit if (this.citations.size > this.maxCitations) { this.cleanupOldCitations(); } } /** * Retrieve citation data by reference ID */ getCitation(referenceId: string): CitationData | undefined { return this.citations.get(referenceId); } /** * Get all citations sorted by timestamp (newest first) */ getAllCitations(): CitationData[] { return Array.from(this.citations.values()) .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); } /** * Get recent citations (last N citations) */ getRecentCitations(limit: number = 10): CitationData[] { return this.getAllCitations().slice(0, limit); } /** * Extract key metrics from tool response data */ extractMetrics(toolName: string, rawData: any): ExtractedMetric[] { const metrics: ExtractedMetric[] = []; try { switch (toolName) { case 'get_ticker': if (rawData.lastPrice) { metrics.push({ type: 'price', label: 'Last Price', value: rawData.lastPrice, unit: 'USD', significance: 'high' }); } if (rawData.price24hPcnt) { metrics.push({ type: 'percentage', label: '24h Change', value: rawData.price24hPcnt, unit: '%', significance: 'high' }); } if (rawData.volume24h) { metrics.push({ type: 'volume', label: '24h Volume', value: rawData.volume24h, significance: 'medium' }); } break; case 'get_kline': if (rawData.data && Array.isArray(rawData.data) && rawData.data.length > 0) { const latestCandle = rawData.data[0]; if (latestCandle.close) { metrics.push({ type: 'price', label: 'Close Price', value: latestCandle.close, unit: 'USD', significance: 'high' }); } } break; case 'get_ml_rsi': if (rawData.data && Array.isArray(rawData.data) && rawData.data.length > 0) { const latestRsi = rawData.data[0]; if (latestRsi.mlRsi !== undefined) { metrics.push({ type: 'indicator', label: 'ML RSI', value: latestRsi.mlRsi.toFixed(2), significance: 'high' }); } if (latestRsi.trend) { metrics.push({ type: 'other', label: 'Trend', value: latestRsi.trend, significance: 'medium' }); } } break; case 'get_orderbook': if (rawData.bids && rawData.bids.length > 0) { metrics.push({ type: 'price', label: 'Best Bid', value: rawData.bids[0][0], unit: 'USD', significance: 'high' }); } if (rawData.asks && rawData.asks.length > 0) { metrics.push({ type: 'price', label: 'Best Ask', value: rawData.asks[0][0], unit: 'USD', significance: 'high' }); } break; default: // Generic extraction for unknown tools if (typeof rawData === 'object' && rawData !== null) { Object.entries(rawData).forEach(([key, value]) => { if (typeof value === 'string' || typeof value === 'number') { metrics.push({ type: 'other', label: key, value: value, significance: 'low' }); } }); } break; } } catch (error) { console.warn('Error extracting metrics:', error); } return metrics.slice(0, 5); // Limit to 5 key metrics } /** * Process tool response and store citation if it has reference metadata */ processToolResponse(toolResponse: any): void { console.log('🔍 Processing tool response for citations:', toolResponse); if (!toolResponse || typeof toolResponse !== 'object') { console.log('❌ Invalid tool response format'); return; } // MCP responses are wrapped in a content array, so we need to extract the actual data let actualData = toolResponse; // Check if response has content array (MCP format) if (toolResponse.content && Array.isArray(toolResponse.content) && toolResponse.content.length > 0) { console.log('🔍 Found MCP content array, extracting data...'); const contentItem = toolResponse.content[0]; if (contentItem.type === 'text' && contentItem.text) { try { actualData = JSON.parse(contentItem.text); console.log('🔍 Parsed content data:', actualData); } catch (e) { console.log('❌ Failed to parse content text as JSON'); return; } } } // Check if response has reference metadata if (actualData._referenceId && actualData._timestamp && actualData._toolName) { console.log('✅ Found reference metadata:', { referenceId: actualData._referenceId, toolName: actualData._toolName, timestamp: actualData._timestamp }); const extractedMetrics = this.extractMetrics(actualData._toolName, actualData); const citationData: CitationData = { referenceId: actualData._referenceId, timestamp: actualData._timestamp, toolName: actualData._toolName, endpoint: actualData._endpoint, rawData: actualData, extractedMetrics }; this.storeCitation(citationData); console.log('📋 Stored citation data for', actualData._referenceId); } else { console.log('❌ No reference metadata found in tool response'); console.log('🔍 Available keys in actualData:', Object.keys(actualData)); } } /** * Clean up citations older than the threshold */ private cleanupOldCitations(): void { const now = Date.now(); const thresholdMs = this.cleanupThreshold * 60 * 1000; // Convert minutes to milliseconds for (const [referenceId, citation] of this.citations.entries()) { const citationAge = now - new Date(citation.timestamp).getTime(); if (citationAge > thresholdMs) { this.citations.delete(referenceId); } } // Cleanup completed silently } /** * Clear all citations */ clear(): void { this.citations.clear(); } /** * Get citation count */ getCount(): number { return this.citations.size; } } // Singleton instance export const citationStore = new CitationStore(); ``` -------------------------------------------------------------------------------- /client/src/cli.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { Command } from 'commander' import chalk from 'chalk' import { BybitMcpClient, Message } from './client.js' import { Config } from './config.js' import { createInterface } from 'readline' const program = new Command() const config = new Config() let client: BybitMcpClient | null = null // Debug helper to log configuration function logDebugInfo() { if (config.get('debug')) { console.log(chalk.yellow('Debug Info:')) console.log('Ollama Host:', config.get('ollamaHost')) console.log('Default Model:', config.get('defaultModel')) console.log('Debug Mode:', config.get('debug')) } } program .name('bybit-mcp-client') .description('CLI for interacting with Ollama LLMs and bybit-mcp server') .version('0.1.0') .option('-i, --integrated', 'Run in integrated mode with built-in server') .option('-d, --debug', 'Enable debug logging') program .command('config') .description('Configure client settings') .option('-h, --ollama-host <url>', 'Set Ollama host URL') .option('-m, --default-model <model>', 'Set default Ollama model') .option('-d, --debug <boolean>', 'Enable/disable debug mode') .action((options: { ollamaHost?: string; defaultModel?: string; debug?: string }) => { if (options.ollamaHost) { config.set('ollamaHost', options.ollamaHost) console.log(chalk.green(`Ollama host set to: ${options.ollamaHost}`)) } if (options.defaultModel) { config.set('defaultModel', options.defaultModel) console.log(chalk.green(`Default model set to: ${options.defaultModel}`)) } if (options.debug !== undefined) { const debugEnabled = options.debug.toLowerCase() === 'true' config.set('debug', debugEnabled) console.log(chalk.green(`Debug mode ${debugEnabled ? 'enabled' : 'disabled'}`)) } logDebugInfo() }) program .command('models') .description('List available Ollama models') .action(async () => { try { logDebugInfo() client = new BybitMcpClient(config) const models = await client.listModels() console.log(chalk.cyan('Available models:')) models.forEach(model => console.log(` ${model}`)) } catch (error) { console.error(chalk.red('Error listing models:'), error) } finally { await client?.close() } }) program .command('tools') .description('List available bybit-mcp tools') .argument('[server-command]', 'Command to start the bybit-mcp server (not needed in integrated mode)') .action(async (serverCommand?: string) => { try { logDebugInfo() client = new BybitMcpClient(config) if (program.opts().integrated) { if (program.opts().debug) { console.log(chalk.yellow('Starting integrated server...')) } await client.startIntegratedServer() if (program.opts().debug) { console.log(chalk.green('Started integrated server')) } } else if (serverCommand) { await client.connectToServer(serverCommand) } else { throw new Error('Either use --integrated or provide a server command') } const tools = await client.listTools() console.log(chalk.cyan('Available tools:')) tools.forEach(tool => { console.log(chalk.bold(`\n${tool.name}`)) if (tool.description) console.log(` Description: ${tool.description}`) if (tool.inputSchema) console.log(` Input Schema: ${JSON.stringify(tool.inputSchema, null, 2)}`) }) } catch (error) { console.error(chalk.red('Error listing tools:'), error) } finally { await client?.close() } }) program .command('chat') .description('Chat with an Ollama model') .argument('[model]', 'Model to use (defaults to config setting)') .option('-s, --system <message>', 'System message to set context') .action(async (modelArg: string | undefined, options: { system?: string }) => { try { // Enable debug mode for chat to help diagnose issues config.set('debug', true) logDebugInfo() client = new BybitMcpClient(config) // Always start in integrated mode for chat if (program.opts().debug) { console.log(chalk.yellow('Starting integrated server for chat...')) } await client.startIntegratedServer() if (program.opts().debug) { console.log(chalk.green('Started integrated server')) } const model = modelArg || config.get('defaultModel') if (!model) { throw new Error('No model specified and no default model configured') } const messages: Message[] = [] if (options.system) { messages.push({ role: 'system', content: options.system }) } console.log(chalk.cyan(`Chatting with ${model} (Ctrl+C to exit)`)) console.log(chalk.yellow('Tools are available - ask about cryptocurrency data!')) // Start chat loop while (true) { const userInput = await question(chalk.green('You: ')) if (!userInput) continue messages.push({ role: 'user', content: userInput }) process.stdout.write(chalk.blue('Assistant: ')) await client.streamChat(model, messages, (token) => { process.stdout.write(token) }) process.stdout.write('\n') messages.push({ role: 'assistant', content: await client.chat(model, messages) }) } } catch (error) { console.error(chalk.red('Error in chat:'), error) if (program.opts().debug) { console.error('Full error:', error) } } finally { await client?.close() } }) program .command('tool') .description('Call a bybit-mcp tool') .argument('[server-command]', 'Command to start the bybit-mcp server (not needed in integrated mode)') .argument('<tool-name>', 'Name of the tool to call') .argument('[args...]', 'Tool arguments as key=value pairs') .action(async (serverCommand: string | undefined, toolName: string, args: string[]) => { try { logDebugInfo() client = new BybitMcpClient(config) if (program.opts().integrated) { if (program.opts().debug) { console.log(chalk.yellow('Starting integrated server...')) } await client.startIntegratedServer() if (program.opts().debug) { console.log(chalk.green('Started integrated server')) } } else if (serverCommand) { await client.connectToServer(serverCommand) } else { throw new Error('Either use --integrated or provide a server command') } // Parse arguments const toolArgs: Record<string, unknown> = {} args.forEach((arg: string) => { const [key, value] = arg.split('=') if (key && value) { // Try to parse as number or boolean if possible if (value === 'true') toolArgs[key] = true else if (value === 'false') toolArgs[key] = false else if (!isNaN(Number(value))) toolArgs[key] = Number(value) else toolArgs[key] = value } }) const result = await client.callTool(toolName, toolArgs) console.log(result) } catch (error) { console.error(chalk.red('Error calling tool:'), error) } finally { await client?.close() } }) // Helper function to read user input function question(query: string): Promise<string> { const readline = createInterface({ input: process.stdin, output: process.stdout }) return new Promise(resolve => readline.question(query, (answer: string) => { readline.close() resolve(answer) })) } // Set debug mode from command line option if (program.opts().debug) { config.set('debug', true) } program.parse() ``` -------------------------------------------------------------------------------- /webui/index.html: -------------------------------------------------------------------------------- ```html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Bybit MCP WebUI</title> <meta name="description" content="Modern web interface for Bybit MCP server with AI chat capabilities" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <!-- Preload critical CSS to prevent layout shift --> <link rel="preload" href="/src/styles/main.css" as="style" /> </head> <body> <div id="app"> <!-- Loading spinner --> <div id="loading" class="loading-container"> <div class="loading-spinner"></div> <p>Loading Bybit MCP WebUI...</p> </div> <!-- Main application container --> <div id="main-container" class="main-container hidden"> <!-- Header --> <header class="header"> <div class="header-content"> <div class="logo"> <h1>Bybit MCP</h1> <span class="version">v1.0.0</span> </div> <div class="header-controls"> <button id="agent-dashboard-btn" class="agent-dashboard-btn" aria-label="Agent Dashboard"> <span class="dashboard-icon">🤖</span> </button> <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme"> <span class="theme-icon">🌙</span> </button> <button id="settings-btn" class="settings-btn" aria-label="Settings"> <span class="settings-icon">⚙️</span> </button> </div> </div> </header> <!-- Main content area --> <main class="main-content"> <!-- Sidebar --> <aside class="sidebar"> <nav class="nav-menu"> <button class="nav-item active" data-view="chat"> <span class="nav-icon">💬</span> <span class="nav-label">AI Chat</span> </button> <button class="nav-item" data-view="tools"> <span class="nav-icon">🔧</span> <span class="nav-label">MCP Tools</span> </button> <button class="nav-item" data-view="dashboard"> <span class="nav-icon">🤖</span> <span class="nav-label">Agent Dashboard</span> </button> </nav> </aside> <!-- Content area --> <section class="content-area"> <!-- Chat View --> <div id="chat-view" class="view active"> <div class="chat-container"> <div class="chat-messages" id="chat-messages"> <div class="welcome-message"> <h2>Welcome to Bybit MCP AI Assistant</h2> <p>Ask me anything about cryptocurrency markets, trading data, or technical analysis!</p> <div class="example-queries"> <button class="example-query">What's the current BTC price?</button> <button class="example-query">Check for XRPUSDT order blocks in the 30 minute window</button> <button class="example-query">Review XRPUSDT over the past week, where is the price likely to go in the next day?</button> <button class="example-query">Analyse XRPUSDT with ML-RSI</button> <button class="example-query">Compare XRPUSDT RSI with ML-RSI</button> <button class="example-query">Show me the latest price candles for XRPUSDT</button> </div> </div> </div> <div class="chat-input-container"> <div class="chat-input-wrapper"> <textarea id="chat-input" class="chat-input" placeholder="Ask about markets, trading data, or technical analysis..." rows="1" ></textarea> <button id="send-btn" class="send-btn" disabled> <span class="send-icon">➤</span> </button> </div> <div class="input-status"> <span id="connection-status" class="connection-status">🔴 Disconnected</span> <span id="typing-indicator" class="typing-indicator hidden">AI is typing...</span> </div> </div> </div> </div> <!-- Tools View --> <div id="tools-view" class="view"> <div class="tools-container"> <h2>MCP Tools</h2> <div class="tools-grid" id="tools-grid"> <!-- Tools will be populated dynamically --> </div> </div> </div> <!-- Dashboard View --> <div id="dashboard-view" class="view"> <div class="dashboard-container"> <h2>Agent Dashboard</h2> <div id="dashboard-content-wrapper"> <!-- Agent dashboard will be embedded here --> </div> </div> </div> </section> </main> </div> <!-- Settings Modal --> <div id="settings-modal" class="modal hidden"> <div class="modal-content"> <div class="modal-header"> <h2>Settings</h2> <button id="close-settings" class="close-btn">×</button> </div> <div class="modal-body"> <div class="settings-section"> <h3>AI Configuration</h3> <label for="ai-endpoint">AI Endpoint:</label> <input type="url" id="ai-endpoint" placeholder="https://ollama.example.com" /> <label for="ai-model">Model:</label> <input type="text" id="ai-model" placeholder="qwen3-30b-a3b-ud-nothink-128k:q4_k_xl" /> </div> <div class="settings-section"> <h3>MCP Server</h3> <label for="mcp-endpoint">MCP Endpoint:</label> <input type="url" id="mcp-endpoint" placeholder="(auto-detect current domain)" /> </div> <div class="settings-section"> <h3>Agent Settings</h3> <label class="checkbox-label"> <input type="checkbox" id="agent-mode-enabled" /> <span>Enable Agent Mode</span> <small>Use multi-step reasoning agent instead of simple AI chat</small> </label> <label for="max-iterations">Max Iterations:</label> <input type="number" id="max-iterations" min="1" max="20" value="5" /> <label for="tool-timeout">Tool Timeout (ms):</label> <input type="number" id="tool-timeout" min="5000" max="120000" step="1000" value="30000" /> <label class="checkbox-label"> <input type="checkbox" id="show-workflow-steps" /> <span>Show Workflow Steps</span> <small>Display agent reasoning process</small> </label> <label class="checkbox-label"> <input type="checkbox" id="show-tool-calls" /> <span>Show Tool Calls</span> <small>Display tool execution details</small> </label> <label class="checkbox-label"> <input type="checkbox" id="enable-debug-mode" /> <span>Debug Mode</span> <small>Enable verbose logging</small> </label> </div> </div> <div class="modal-footer"> <button id="save-settings" class="save-btn">Save Settings</button> </div> </div> </div> <!-- Agent Dashboard --> <div id="agent-dashboard-container"></div> <!-- Data Verification Panel --> <div id="verification-panel-container"></div> </div> <script type="module" src="/src/main.ts"></script> </body> </html> ``` -------------------------------------------------------------------------------- /src/tools/GetOrderBlocks.ts: -------------------------------------------------------------------------------- ```typescript import { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js" import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { BaseToolImplementation } from "./BaseTool.js" import { KlineData } from "../utils/mathUtils.js" import { detectOrderBlocks, getActiveLevels, calculateOrderBlockStats, OrderBlock, VolumeAnalysisConfig } from "../utils/volumeAnalysis.js" import { GetKlineParamsV5, KlineIntervalV3 } from "bybit-api" // Zod schema for input validation const inputSchema = z.object({ symbol: z.string() .min(1, "Symbol is required") .regex(/^[A-Z0-9]+$/, "Symbol must contain only uppercase letters and numbers"), category: z.enum(["spot", "linear", "inverse"]), interval: z.enum(["1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M"]), volumePivotLength: z.number().min(1).max(20).optional().default(5), bullishBlocks: z.number().min(1).max(10).optional().default(3), bearishBlocks: z.number().min(1).max(10).optional().default(3), mitigationMethod: z.enum(["wick", "close"]).optional().default("wick"), limit: z.number().min(100).max(1000).optional().default(200) }) type ToolArguments = z.infer<typeof inputSchema> interface OrderBlockResponse { symbol: string; interval: string; bullishBlocks: Array<{ id: string; timestamp: number; top: number; bottom: number; average: number; volume: number; mitigated: boolean; mitigationTime?: number; }>; bearishBlocks: Array<{ id: string; timestamp: number; top: number; bottom: number; average: number; volume: number; mitigated: boolean; mitigationTime?: number; }>; currentSupport: number[]; currentResistance: number[]; metadata: { volumePivotLength: number; mitigationMethod: string; blocksDetected: number; activeBullishBlocks: number; activeBearishBlocks: number; averageVolume: number; calculationTime: number; }; } class GetOrderBlocks extends BaseToolImplementation { name = "get_order_blocks" toolDefinition: Tool = { name: this.name, description: "Detect institutional order accumulation zones based on volume analysis. Identifies bullish and bearish order blocks using volume peaks and tracks their mitigation status.", inputSchema: { type: "object", properties: { symbol: { type: "string", description: "Trading pair symbol (e.g., 'BTCUSDT')", pattern: "^[A-Z0-9]+$" }, category: { type: "string", description: "Category of the instrument", enum: ["spot", "linear", "inverse"] }, interval: { type: "string", description: "Kline interval", enum: ["1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M"] }, volumePivotLength: { type: "number", description: "Volume pivot detection period (default: 5)", minimum: 1, maximum: 20 }, bullishBlocks: { type: "number", description: "Number of bullish blocks to track (default: 3)", minimum: 1, maximum: 10 }, bearishBlocks: { type: "number", description: "Number of bearish blocks to track (default: 3)", minimum: 1, maximum: 10 }, mitigationMethod: { type: "string", description: "Mitigation detection method (default: wick)", enum: ["wick", "close"] }, limit: { type: "number", description: "Historical data points to analyse (default: 200)", minimum: 100, maximum: 1000 } }, required: ["symbol", "category", "interval"] } } async toolCall(request: z.infer<typeof CallToolRequestSchema>): Promise<CallToolResult> { const startTime = Date.now() try { this.logInfo("Starting get_order_blocks tool call") // Parse and validate input const validationResult = inputSchema.safeParse(request.params.arguments) if (!validationResult.success) { const errorDetails = validationResult.error.errors.map(err => ({ field: err.path.join('.'), message: err.message, code: err.code })) throw new Error(`Invalid input: ${JSON.stringify(errorDetails)}`) } const args = validationResult.data // Fetch kline data const klineData = await this.fetchKlineData(args) if (klineData.length < args.volumePivotLength * 2 + 10) { throw new Error(`Insufficient data. Need at least ${args.volumePivotLength * 2 + 10} data points, got ${klineData.length}`) } // Configure volume analysis const config: VolumeAnalysisConfig = { volumePivotLength: args.volumePivotLength, bullishBlocks: args.bullishBlocks, bearishBlocks: args.bearishBlocks, mitigationMethod: args.mitigationMethod } // Detect order blocks const { bullishBlocks, bearishBlocks } = detectOrderBlocks(klineData, config) // Get active support and resistance levels const { support, resistance } = getActiveLevels([...bullishBlocks, ...bearishBlocks]) // Calculate statistics const stats = calculateOrderBlockStats(bullishBlocks, bearishBlocks) const calculationTime = Date.now() - startTime const response: OrderBlockResponse = { symbol: args.symbol, interval: args.interval, bullishBlocks: bullishBlocks.map(block => ({ id: block.id, timestamp: block.timestamp, top: block.top, bottom: block.bottom, average: block.average, volume: block.volume, mitigated: block.mitigated, mitigationTime: block.mitigationTime })), bearishBlocks: bearishBlocks.map(block => ({ id: block.id, timestamp: block.timestamp, top: block.top, bottom: block.bottom, average: block.average, volume: block.volume, mitigated: block.mitigated, mitigationTime: block.mitigationTime })), currentSupport: support.slice(0, 5), // Top 5 support levels currentResistance: resistance.slice(0, 5), // Top 5 resistance levels metadata: { volumePivotLength: args.volumePivotLength, mitigationMethod: args.mitigationMethod, blocksDetected: stats.totalBlocks, activeBullishBlocks: stats.activeBullishBlocks, activeBearishBlocks: stats.activeBearishBlocks, averageVolume: stats.averageVolume, calculationTime } } this.logInfo(`Order block detection completed in ${calculationTime}ms. Found ${stats.totalBlocks} blocks (${stats.activeBullishBlocks} bullish, ${stats.activeBearishBlocks} bearish active)`) return this.formatResponse(response) } catch (error) { this.logInfo(`Order block detection failed: ${error instanceof Error ? error.message : String(error)}`) return this.handleError(error) } } private async fetchKlineData(args: ToolArguments): Promise<KlineData[]> { const params: GetKlineParamsV5 = { category: args.category, symbol: args.symbol, interval: args.interval as KlineIntervalV3, limit: args.limit } const response = await this.executeRequest(() => this.client.getKline(params)) if (!response.list || response.list.length === 0) { throw new Error("No kline data received from API") } // Convert API response to KlineData format return response.list.map(kline => ({ timestamp: parseInt(kline[0]), open: parseFloat(kline[1]), high: parseFloat(kline[2]), low: parseFloat(kline[3]), close: parseFloat(kline[4]), volume: parseFloat(kline[5]) })).reverse() // Reverse to get chronological order } } export default GetOrderBlocks ``` -------------------------------------------------------------------------------- /src/utils/mathUtils.ts: -------------------------------------------------------------------------------- ```typescript /** * Mathematical utility functions for technical analysis * Inspired by pinescript mathematical operations */ export interface KlineData { timestamp: number; open: number; high: number; low: number; close: number; volume: number; } /** * Calculate RSI (Relative Strength Index) */ export function calculateRSI(prices: number[], period: number = 14): number[] { if (prices.length < period + 1) { return [] } const rsiValues: number[] = [] const gains: number[] = [] const losses: number[] = [] // Calculate initial gains and losses for (let i = 1; i < prices.length; i++) { const change = prices[i] - prices[i - 1] gains.push(change > 0 ? change : 0) losses.push(change < 0 ? Math.abs(change) : 0) } // Calculate initial average gain and loss let avgGain = gains.slice(0, period).reduce((sum, gain) => sum + gain, 0) / period let avgLoss = losses.slice(0, period).reduce((sum, loss) => sum + loss, 0) / period // Calculate first RSI value const rs = avgGain / (avgLoss || 0.0001) // Avoid division by zero rsiValues.push(100 - (100 / (1 + rs))) // Calculate subsequent RSI values using smoothed averages for (let i = period; i < gains.length; i++) { avgGain = (avgGain * (period - 1) + gains[i]) / period avgLoss = (avgLoss * (period - 1) + losses[i]) / period const rs = avgGain / (avgLoss || 0.0001) rsiValues.push(100 - (100 / (1 + rs))) } return rsiValues } /** * Calculate momentum (rate of change) */ export function calculateMomentum(values: number[], period: number = 1): number[] { const momentum: number[] = [] for (let i = period; i < values.length; i++) { momentum.push(values[i] - values[i - period]) } return momentum } /** * Calculate volatility using standard deviation */ export function calculateVolatility(values: number[], period: number = 10): number[] { const volatility: number[] = [] for (let i = period - 1; i < values.length; i++) { const slice = values.slice(i - period + 1, i + 1) const mean = slice.reduce((sum, val) => sum + val, 0) / slice.length const variance = slice.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / slice.length volatility.push(Math.sqrt(variance)) } return volatility } /** * Calculate linear regression slope */ export function calculateSlope(values: number[], period: number = 5): number[] { const slopes: number[] = [] for (let i = period - 1; i < values.length; i++) { const slice = values.slice(i - period + 1, i + 1) const n = slice.length const x = Array.from({ length: n }, (_, idx) => idx) const sumX = x.reduce((sum, val) => sum + val, 0) const sumY = slice.reduce((sum, val) => sum + val, 0) const sumXY = x.reduce((sum, val, idx) => sum + val * slice[idx], 0) const sumXX = x.reduce((sum, val) => sum + val * val, 0) const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX) slopes.push(slope) } return slopes } /** * Min-max normalisation */ export function normalize(values: number[], period: number): number[] { const normalized: number[] = [] for (let i = period - 1; i < values.length; i++) { const slice = values.slice(i - period + 1, i + 1) const min = Math.min(...slice) const max = Math.max(...slice) const range = max - min if (range === 0) { normalized.push(0.5) // Middle value when no variation } else { normalized.push((values[i] - min) / range) } } return normalized } /** * Calculate Euclidean distance between two feature vectors */ export function euclideanDistance(vector1: number[], vector2: number[]): number { if (vector1.length !== vector2.length) { throw new Error("Vectors must have the same length") } const sumSquares = vector1.reduce((sum, val, idx) => { return sum + Math.pow(val - vector2[idx], 2) }, 0) return Math.sqrt(sumSquares) } /** * Simple Moving Average */ export function sma(values: number[], period: number): number[] { const smaValues: number[] = [] for (let i = period - 1; i < values.length; i++) { const slice = values.slice(i - period + 1, i + 1) const average = slice.reduce((sum, val) => sum + val, 0) / slice.length smaValues.push(average) } return smaValues } /** * Exponential Moving Average */ export function ema(values: number[], period: number): number[] { if (values.length === 0) return [] const emaValues: number[] = [] const multiplier = 2 / (period + 1) // First EMA value is the first price emaValues.push(values[0]) for (let i = 1; i < values.length; i++) { const emaValue = (values[i] * multiplier) + (emaValues[i - 1] * (1 - multiplier)) emaValues.push(emaValue) } return emaValues } /** * Kalman Filter implementation for smoothing */ export function kalmanFilter(values: number[], processNoise: number = 0.01, measurementNoise: number = 0.1): number[] { if (values.length === 0) return [] const filtered: number[] = [] let estimate = values[0] let errorEstimate = 1.0 filtered.push(estimate) for (let i = 1; i < values.length; i++) { // Prediction step const predictedEstimate = estimate const predictedError = errorEstimate + processNoise // Update step const kalmanGain = predictedError / (predictedError + measurementNoise) estimate = predictedEstimate + kalmanGain * (values[i] - predictedEstimate) errorEstimate = (1 - kalmanGain) * predictedError filtered.push(estimate) } return filtered } /** * ALMA (Arnaud Legoux Moving Average) implementation */ export function alma(values: number[], period: number, offset: number = 0.85, sigma: number = 6): number[] { if (values.length < period) return [] const almaValues: number[] = [] const m = Math.floor(offset * (period - 1)) const s = period / sigma for (let i = period - 1; i < values.length; i++) { let weightedSum = 0 let weightSum = 0 for (let j = 0; j < period; j++) { const weight = Math.exp(-Math.pow(j - m, 2) / (2 * Math.pow(s, 2))) weightedSum += values[i - period + 1 + j] * weight weightSum += weight } almaValues.push(weightedSum / weightSum) } return almaValues } /** * Double EMA implementation */ export function doubleEma(values: number[], period: number): number[] { const firstEma = ema(values, period) const secondEma = ema(firstEma, period) return firstEma.map((val, idx) => { if (idx < secondEma.length) { return 2 * val - secondEma[idx] } return val }).slice(period - 1) // Remove initial values that don't have corresponding second EMA } /** * Extract features for KNN analysis */ export interface FeatureVector { rsi: number; momentum?: number; volatility?: number; slope?: number; priceMomentum?: number; } export function extractFeatures( klineData: KlineData[], index: number, rsiValues: number[], featureCount: number, lookbackPeriod: number ): FeatureVector | null { if (index < lookbackPeriod || index >= rsiValues.length) { return null } const features: FeatureVector = { rsi: rsiValues[index] } if (featureCount >= 2) { const rsiMomentum = calculateMomentum(rsiValues.slice(0, index + 1), 3) if (rsiMomentum.length > 0) { features.momentum = rsiMomentum[rsiMomentum.length - 1] } } if (featureCount >= 3) { const rsiVolatility = calculateVolatility(rsiValues.slice(0, index + 1), 10) if (rsiVolatility.length > 0) { features.volatility = rsiVolatility[rsiVolatility.length - 1] } } if (featureCount >= 4) { const rsiSlope = calculateSlope(rsiValues.slice(0, index + 1), 5) if (rsiSlope.length > 0) { features.slope = rsiSlope[rsiSlope.length - 1] } } if (featureCount >= 5) { const closePrices = klineData.slice(0, index + 1).map(k => k.close) const priceMomentum = calculateMomentum(closePrices, 5) if (priceMomentum.length > 0) { features.priceMomentum = priceMomentum[priceMomentum.length - 1] } } return features } ``` -------------------------------------------------------------------------------- /src/utils/volumeAnalysis.ts: -------------------------------------------------------------------------------- ```typescript /** * Volume analysis utilities for Order Block Detection * Based on the pinescript order-block-detector implementation */ import { KlineData } from './mathUtils.js' export interface OrderBlock { id: string; timestamp: number; top: number; bottom: number; average: number; volume: number; mitigated: boolean; mitigationTime?: number; type: 'bullish' | 'bearish'; } export interface VolumeAnalysisConfig { volumePivotLength: number; bullishBlocks: number; bearishBlocks: number; mitigationMethod: 'wick' | 'close'; } /** * Detect volume pivots (peaks) in the data */ export function detectVolumePivots(klineData: KlineData[], pivotLength: number): number[] { const pivotIndices: number[] = [] for (let i = pivotLength; i < klineData.length - pivotLength; i++) { const currentVolume = klineData[i].volume let isPivot = true // Check if current volume is higher than surrounding volumes for (let j = i - pivotLength; j <= i + pivotLength; j++) { if (j !== i && klineData[j].volume >= currentVolume) { isPivot = false break } } if (isPivot) { pivotIndices.push(i) } } return pivotIndices } /** * Determine market structure (uptrend/downtrend) at a given point */ export function getMarketStructure(klineData: KlineData[], index: number, lookback: number): 'uptrend' | 'downtrend' { const startIndex = Math.max(0, index - lookback) const slice = klineData.slice(startIndex, index + 1) if (slice.length < 2) return 'uptrend' const highs = slice.map(k => k.high) const lows = slice.map(k => k.low) const currentHigh = highs[highs.length - 1] const currentLow = lows[lows.length - 1] const previousHigh = Math.max(...highs.slice(0, -1)) const previousLow = Math.min(...lows.slice(0, -1)) // Simple trend detection based on higher highs/lower lows if (currentHigh > previousHigh && currentLow > previousLow) { return 'uptrend' } else if (currentHigh < previousHigh && currentLow < previousLow) { return 'downtrend' } // Default to uptrend if unclear return 'uptrend' } /** * Create order block from volume pivot */ export function createOrderBlock( klineData: KlineData[], pivotIndex: number, pivotLength: number, type: 'bullish' | 'bearish' ): OrderBlock { const kline = klineData[pivotIndex] const { high, low, close, volume, timestamp } = kline let top: number, bottom: number if (type === 'bullish') { // Bullish order block: from low to median (hl2) bottom = low top = (high + low) / 2 } else { // Bearish order block: from median (hl2) to high bottom = (high + low) / 2 top = high } const average = (top + bottom) / 2 return { id: `${type}_${timestamp}_${pivotIndex}`, timestamp, top, bottom, average, volume, mitigated: false, type } } /** * Check if an order block has been mitigated */ export function checkMitigation( orderBlock: OrderBlock, klineData: KlineData[], currentIndex: number, method: 'wick' | 'close' ): boolean { if (orderBlock.mitigated) return true const currentKline = klineData[currentIndex] if (orderBlock.type === 'bullish') { // Bullish order block is mitigated when price goes below the bottom if (method === 'wick') { return currentKline.low < orderBlock.bottom } else { return currentKline.close < orderBlock.bottom } } else { // Bearish order block is mitigated when price goes above the top if (method === 'wick') { return currentKline.high > orderBlock.top } else { return currentKline.close > orderBlock.top } } } /** * Update order block mitigation status */ export function updateOrderBlockMitigation( orderBlocks: OrderBlock[], klineData: KlineData[], currentIndex: number, method: 'wick' | 'close' ): { mitigatedBullish: boolean; mitigatedBearish: boolean } { let mitigatedBullish = false let mitigatedBearish = false for (const block of orderBlocks) { if (!block.mitigated && checkMitigation(block, klineData, currentIndex, method)) { block.mitigated = true block.mitigationTime = klineData[currentIndex].timestamp if (block.type === 'bullish') { mitigatedBullish = true } else { mitigatedBearish = true } } } return { mitigatedBullish, mitigatedBearish } } /** * Remove mitigated order blocks from arrays */ export function removeMitigatedBlocks(orderBlocks: OrderBlock[]): OrderBlock[] { return orderBlocks.filter(block => !block.mitigated) } /** * Get active support and resistance levels from order blocks */ export function getActiveLevels(orderBlocks: OrderBlock[]): { support: number[]; resistance: number[]; } { const activeBlocks = orderBlocks.filter(block => !block.mitigated) const support = activeBlocks .filter(block => block.type === 'bullish') .map(block => block.average) .sort((a, b) => b - a) // Descending order const resistance = activeBlocks .filter(block => block.type === 'bearish') .map(block => block.average) .sort((a, b) => a - b) // Ascending order return { support, resistance } } /** * Detect order blocks from kline data */ export function detectOrderBlocks( klineData: KlineData[], config: VolumeAnalysisConfig ): { bullishBlocks: OrderBlock[]; bearishBlocks: OrderBlock[]; volumePivots: number[]; } { const volumePivots = detectVolumePivots(klineData, config.volumePivotLength) const bullishBlocks: OrderBlock[] = [] const bearishBlocks: OrderBlock[] = [] for (const pivotIndex of volumePivots) { // Determine market structure at pivot point const marketStructure = getMarketStructure(klineData, pivotIndex, config.volumePivotLength) if (marketStructure === 'uptrend') { // In uptrend, create bullish order block const block = createOrderBlock(klineData, pivotIndex, config.volumePivotLength, 'bullish') bullishBlocks.push(block) } else { // In downtrend, create bearish order block const block = createOrderBlock(klineData, pivotIndex, config.volumePivotLength, 'bearish') bearishBlocks.push(block) } } // Process mitigation for all blocks for (let i = 0; i < klineData.length; i++) { updateOrderBlockMitigation([...bullishBlocks, ...bearishBlocks], klineData, i, config.mitigationMethod) } // Keep only the most recent unmitigated blocks const activeBullishBlocks = removeMitigatedBlocks(bullishBlocks) .slice(-config.bullishBlocks) const activeBearishBlocks = removeMitigatedBlocks(bearishBlocks) .slice(-config.bearishBlocks) return { bullishBlocks: activeBullishBlocks, bearishBlocks: activeBearishBlocks, volumePivots } } /** * Calculate order block statistics */ export function calculateOrderBlockStats( bullishBlocks: OrderBlock[], bearishBlocks: OrderBlock[] ): { totalBlocks: number; activeBullishBlocks: number; activeBearishBlocks: number; mitigatedBlocks: number; averageVolume: number; } { const allBlocks = [...bullishBlocks, ...bearishBlocks] const activeBlocks = allBlocks.filter(block => !block.mitigated) const mitigatedBlocks = allBlocks.filter(block => block.mitigated) const activeBullishBlocks = bullishBlocks.filter(block => !block.mitigated).length const activeBearishBlocks = bearishBlocks.filter(block => !block.mitigated).length const averageVolume = allBlocks.length > 0 ? allBlocks.reduce((sum, block) => sum + block.volume, 0) / allBlocks.length : 0 return { totalBlocks: allBlocks.length, activeBullishBlocks, activeBearishBlocks, mitigatedBlocks: mitigatedBlocks.length, averageVolume } } /** * Find nearest order blocks to current price */ export function findNearestOrderBlocks( orderBlocks: OrderBlock[], currentPrice: number, maxDistance: number = 0.05 // 5% price distance ): OrderBlock[] { return orderBlocks .filter(block => !block.mitigated) .filter(block => { const distance = Math.abs(block.average - currentPrice) / currentPrice return distance <= maxDistance }) .sort((a, b) => { const distanceA = Math.abs(a.average - currentPrice) const distanceB = Math.abs(b.average - currentPrice) return distanceA - distanceB }) } ``` -------------------------------------------------------------------------------- /webui/src/styles/citations.css: -------------------------------------------------------------------------------- ```css /** * Citation system styles */ /* Citation reference styling */ .citation-ref { display: inline-block; background: var(--accent-color, #007acc); color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.8em; font-weight: 500; cursor: pointer; transition: all 0.2s ease; text-decoration: none; margin: 0 2px; vertical-align: baseline; } .citation-ref:hover { background: var(--accent-hover, #005a9e); transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); } .citation-ref:focus { outline: 2px solid var(--focus-color, #4a90e2); outline-offset: 2px; } .citation-ref.no-data { background: var(--warning-color, #ff9800); cursor: not-allowed; } .citation-ref.no-data:hover { background: var(--warning-hover, #f57c00); transform: none; } /* Citation tooltip */ .citation-tooltip-container { background: var(--tooltip-bg, #ffffff); color: var(--tooltip-text, #333333); border: 1px solid var(--tooltip-border, #e0e0e0); border-radius: 12px; padding: 16px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08); max-width: 320px; font-size: 0.9em; z-index: 1000; animation: tooltipFadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); backdrop-filter: blur(8px); position: relative; } /* Tooltip arrow */ .citation-tooltip-container::before { content: ''; position: absolute; top: -6px; left: 50%; transform: translateX(-50%); width: 12px; height: 12px; background: var(--tooltip-bg, #ffffff); border: 1px solid var(--tooltip-border, #e0e0e0); border-bottom: none; border-right: none; transform: translateX(-50%) rotate(45deg); z-index: -1; } .citation-tooltip { display: flex; flex-direction: column; gap: 8px; } .citation-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--tooltip-border, #f0f0f0); padding-bottom: 8px; margin-bottom: 8px; } .citation-id { font-weight: 600; color: var(--accent-color, #007acc); font-size: 0.9em; background: var(--accent-bg, #f0f8ff); padding: 2px 8px; border-radius: 6px; border: 1px solid var(--accent-border, #b3d9ff); } .citation-tool { font-size: 0.8em; color: var(--text-muted, #666666); font-weight: 500; background: var(--tool-bg, #f8f9fa); padding: 2px 6px; border-radius: 4px; } .citation-time { font-size: 0.8em; color: var(--text-muted, #666666); display: flex; align-items: center; gap: 4px; } .citation-time::before { content: "🕒"; font-size: 0.9em; } .citation-endpoint { font-size: 0.8em; color: var(--text-muted, #666666); font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; background: var(--code-bg, #f6f8fa); padding: 4px 6px; border-radius: 4px; border: 1px solid var(--code-border, #e1e4e8); margin-top: 4px; } .citation-metrics { margin-top: 12px; background: var(--metrics-bg, #f8f9fa); border-radius: 8px; padding: 12px; border: 1px solid var(--metrics-border, #e9ecef); } .citation-metrics h4 { margin: 0 0 8px 0; font-size: 0.85em; color: var(--text-primary, #333333); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; display: flex; align-items: center; gap: 4px; } .citation-metrics h4::before { content: "📊"; font-size: 1em; } .citation-metrics ul { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; } .citation-metrics li { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; font-size: 0.8em; background: var(--metric-item-bg, #ffffff); border-radius: 6px; border: 1px solid var(--metric-item-border, #e9ecef); } .citation-metrics .metric-high { color: var(--success-color, #22c55e); font-weight: 600; } .citation-metrics .metric-medium { color: var(--warning-color, #f59e0b); font-weight: 600; } .citation-metrics .metric-low { color: var(--text-muted, #6b7280); font-weight: 500; } .metric-label { font-weight: 500; color: var(--text-secondary, #4b5563); } .metric-value { font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; font-weight: 600; font-size: 0.9em; } .citation-actions { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--tooltip-border, #f0f0f0); display: flex; justify-content: center; } .btn-view-full { background: linear-gradient(135deg, var(--accent-color, #007acc) 0%, var(--accent-secondary, #0066cc) 100%); color: white; border: none; padding: 8px 16px; border-radius: 8px; font-size: 0.8em; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 2px 4px rgba(0, 122, 204, 0.2); display: flex; align-items: center; gap: 6px; } .btn-view-full::before { content: "👁️"; font-size: 0.9em; } .btn-view-full:hover { background: linear-gradient(135deg, var(--accent-hover, #005a9e) 0%, var(--accent-secondary-hover, #0052a3) 100%); transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0, 122, 204, 0.3); } .btn-view-full:active { transform: translateY(0); box-shadow: 0 2px 4px rgba(0, 122, 204, 0.2); } /* Citation modal */ .citation-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; z-index: 2000; animation: fadeIn 0.3s ease; } .citation-modal { background: var(--modal-bg, #fff); color: var(--modal-text, #333); border-radius: 12px; max-width: 80vw; max-height: 80vh; overflow: hidden; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); animation: slideIn 0.3s ease; } .citation-modal-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--modal-border, #eee); background: var(--modal-header-bg, #f8f9fa); } .citation-modal-header h3 { margin: 0; font-size: 1.2em; color: var(--modal-title, #333); } .citation-modal-close { background: none; border: none; font-size: 1.5em; cursor: pointer; color: var(--text-muted, #666); padding: 0; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: background 0.2s ease; } .citation-modal-close:hover { background: var(--hover-bg, #f0f0f0); } .citation-modal-content { padding: 20px; overflow-y: auto; max-height: calc(80vh - 80px); } .citation-info { margin-bottom: 20px; } .citation-info p { margin: 8px 0; font-size: 0.9em; } .citation-raw-data { margin-top: 20px; } .citation-raw-data h4 { margin: 0 0 12px 0; font-size: 1em; color: var(--modal-title, #333); } .citation-raw-data pre { background: var(--code-bg, #f5f5f5); border: 1px solid var(--code-border, #ddd); border-radius: 6px; padding: 12px; overflow-x: auto; font-size: 0.8em; line-height: 1.4; max-height: 300px; } .citation-raw-data code { color: var(--code-text, #333); font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; } /* Animations */ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideIn { from { opacity: 0; transform: translateY(-20px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes tooltipFadeIn { from { opacity: 0; transform: translateY(-8px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } /* Dark theme adjustments */ [data-theme="dark"] .citation-modal { --modal-bg: #2d2d2d; --modal-text: #e0e0e0; --modal-border: #444; --modal-header-bg: #333; --modal-title: #fff; --code-bg: #1e1e1e; --code-border: #444; --code-text: #e0e0e0; --hover-bg: #404040; } [data-theme="dark"] .citation-tooltip-container { --tooltip-bg: #1f2937; --tooltip-text: #f9fafb; --tooltip-border: #374151; --accent-bg: #1e3a8a; --accent-border: #3b82f6; --tool-bg: #374151; --code-bg: #111827; --code-border: #374151; --metrics-bg: #374151; --metrics-border: #4b5563; --metric-item-bg: #1f2937; --metric-item-border: #4b5563; --text-primary: #f9fafb; --text-secondary: #d1d5db; --text-muted: #9ca3af; } /* Responsive design */ @media (max-width: 768px) { .citation-modal { max-width: 95vw; max-height: 90vh; margin: 20px; } .citation-tooltip-container { max-width: 250px; font-size: 0.8em; } } ``` -------------------------------------------------------------------------------- /webui/src/services/systemPrompt.ts: -------------------------------------------------------------------------------- ```typescript /** * Centralised system prompt service for consistent AI agent behavior */ import { mcpClient } from './mcpClient'; import type { MCPTool } from '@/types/mcp'; export interface SystemPromptConfig { includeTimestamp?: boolean; includeTools?: boolean; includeMemoryContext?: boolean; customInstructions?: string; } export class SystemPromptService { private static instance: SystemPromptService; private cachedTools: MCPTool[] = []; private lastToolsUpdate: number = 0; private readonly TOOLS_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes private constructor() {} public static getInstance(): SystemPromptService { if (!SystemPromptService.instance) { SystemPromptService.instance = new SystemPromptService(); } return SystemPromptService.instance; } /** * Generate the complete system prompt with all components */ public async generateSystemPrompt(config: SystemPromptConfig = {}): Promise<string> { console.log('🎯 Generating system prompt with dynamic tools...'); const { includeTimestamp = true, includeTools = true, includeMemoryContext = false, // Reserved for future use customInstructions } = config; let prompt = this.getBasePrompt(); // Add current timestamp if (includeTimestamp) { prompt += `\n\nCurrent date and time: ${this.getCurrentTimestamp()}`; } // Add dynamic tools list if (includeTools) { const toolsSection = await this.generateToolsSection(); if (toolsSection) { prompt += `\n\n${toolsSection}`; } } // Add guidelines and instructions prompt += `\n\n${this.getGuidelines()}`; // Add custom instructions if provided if (customInstructions) { prompt += `\n\nAdditional Instructions:\n${customInstructions}`; } // Note: includeMemoryContext is reserved for future memory integration if (includeMemoryContext) { // Future: Add memory context here console.log('Memory context integration is planned for future releases'); } return prompt; } /** * Get the base system prompt without dynamic content */ private getBasePrompt(): string { return `You are an expert cryptocurrency trading assistant with access to real-time market data and advanced analysis tools through the Bybit MCP server. You specialise in: - Real-time market analysis and price monitoring - Technical analysis using advanced indicators (RSI, MACD, Order Blocks, ML-RSI) - Risk assessment and trading recommendations - Market structure analysis and trend identification - Portfolio analysis and position management Your responses should be: - Data-driven and based on current market conditions - Clear and actionable with proper risk warnings - Professional yet accessible to traders of all levels - Comprehensive when needed, concise when appropriate - Formatted in markdown for better readability`; } /** * Generate the tools section with dynamic tool discovery */ private async generateToolsSection(): Promise<string> { try { const tools = await this.getAvailableTools(); if (tools.length === 0) { return 'No tools are currently available.'; } let toolsSection = 'Available tools:\n\n'; for (const tool of tools) { toolsSection += `**${tool.name}**\n`; toolsSection += `${tool.description || 'No description available'}\n`; // Add parameter information if available if (tool.inputSchema?.properties) { const params = Object.entries(tool.inputSchema.properties); if (params.length > 0) { toolsSection += 'Parameters:\n'; for (const [paramName, paramDef] of params) { const isRequired = tool.inputSchema.required?.includes(paramName) || false; const description = (paramDef as any)?.description || 'No description'; toolsSection += `- ${paramName}${isRequired ? ' (required)' : ''}: ${description}\n`; } } } toolsSection += '\n'; } return toolsSection.trim(); } catch (error) { console.warn('Failed to generate tools section:', error); return 'Tools are currently unavailable due to connection issues.'; } } /** * Get available tools with caching */ private async getAvailableTools(): Promise<MCPTool[]> { const now = Date.now(); // Return cached tools if still fresh if (this.cachedTools.length > 0 && (now - this.lastToolsUpdate) < this.TOOLS_CACHE_DURATION) { return this.cachedTools; } try { // Fetch fresh tools from MCP client const tools = await mcpClient.listTools(); this.cachedTools = tools; this.lastToolsUpdate = now; return tools; } catch (error) { console.warn('Failed to fetch tools, using cached version:', error); return this.cachedTools; } } /** * Get current timestamp in consistent format */ private getCurrentTimestamp(): string { const now = new Date(); return now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0') + ' ' + String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0') + ':' + String(now.getSeconds()).padStart(2, '0') + ' UTC'; } /** * Get standard guidelines and instructions */ private getGuidelines(): string { return `Guidelines: 1. **Always use relevant tools** to gather current data before making recommendations 2. **Include reference IDs** - For all Bybit tool calls, always include the parameter "includeReferenceId": true to enable data verification 3. **Cite your sources** - When citing specific data from tool responses, include the reference ID in square brackets like [REF001] 4. **Provide clear insights** with proper risk warnings and confidence levels 5. **Explain your reasoning** and methodology behind analysis 6. **Consider multiple timeframes** when relevant for comprehensive analysis 7. **Use tools intelligently** - Don't call unnecessary tools, focus on what's needed for the user's question 8. **Be comprehensive when appropriate** but concise when a simple answer suffices 9. **Include confidence levels** in your analysis and recommendations 10. **Always include risk warnings** for trading recommendations and emphasize that this is not financial advice Remember: You are providing analysis and insights, not financial advice. Users should always do their own research, consider their risk tolerance, and consult with qualified financial advisors before making trading decisions.`; } /** * Generate a simplified system prompt for legacy compatibility */ public generateLegacySystemPrompt(): string { const timestamp = this.getCurrentTimestamp(); return `You are an AI assistant specialised in cryptocurrency trading and market analysis. You have access to the Bybit MCP server which provides real-time market data and advanced technical analysis tools. Current date and time: ${timestamp} Available tools include: - get_ticker: Get current price and 24h statistics for any trading pair - get_orderbook: View current buy/sell orders and market depth - get_kline: Retrieve historical price data (candlestick charts) - get_trades: See recent trade history and market activity - get_ml_rsi: Advanced ML-enhanced RSI indicator for trend analysis - get_order_blocks: Detect institutional order blocks and liquidity zones - get_market_structure: Comprehensive market structure analysis Guidelines: - Always use relevant tools to gather current data before making recommendations - Provide clear, actionable insights with proper risk warnings - Explain your reasoning and methodology - Include confidence levels in your analysis - Consider multiple timeframes when relevant - Use tools intelligently based on the user's question - don't call unnecessary tools - Provide comprehensive analysis when appropriate, but be concise when a simple answer suffices - IMPORTANT: For all Bybit tool calls, always include the parameter "includeReferenceId": true to enable data verification. When citing specific data from tool responses, include the reference ID in square brackets like [REF001]. Be helpful, accurate, and focused on providing valuable trading insights.`; } /** * Clear the tools cache to force refresh */ public clearToolsCache(): void { this.cachedTools = []; this.lastToolsUpdate = 0; } /** * Get tools cache status */ public getToolsCacheStatus(): { count: number; lastUpdate: number; isStale: boolean } { const now = Date.now(); const isStale = (now - this.lastToolsUpdate) > this.TOOLS_CACHE_DURATION; return { count: this.cachedTools.length, lastUpdate: this.lastToolsUpdate, isStale }; } } // Export singleton instance export const systemPromptService = SystemPromptService.getInstance(); ``` -------------------------------------------------------------------------------- /src/utils/knnAlgorithm.ts: -------------------------------------------------------------------------------- ```typescript /** * K-Nearest Neighbors algorithm implementation for ML-RSI * Based on the pinescript ML-RSI implementation */ import { euclideanDistance, normalize, FeatureVector, KlineData } from './mathUtils.js' export interface KNNNeighbor { index: number; distance: number; rsiValue: number; weight: number; } export interface KNNResult { enhancedRsi: number; knnDivergence: number; effectiveNeighbors: number; adaptiveOverbought: number; adaptiveOversold: number; confidence: number; } export interface KNNConfig { neighbors: number; lookbackPeriod: number; mlWeight: number; featureCount: number; } /** * Normalize feature vector for comparison */ function normalizeFeatureVector( features: FeatureVector, allFeatures: FeatureVector[], lookbackPeriod: number ): number[] { const normalized: number[] = [] // Extract all RSI values for normalization const rsiValues = allFeatures.map(f => f.rsi).filter(v => v !== undefined) const normalizedRsi = normalize(rsiValues, Math.min(lookbackPeriod, rsiValues.length)) normalized.push(normalizedRsi[normalizedRsi.length - 1] || 0.5) if (features.momentum !== undefined) { const momentumValues = allFeatures.map(f => f.momentum).filter(v => v !== undefined) as number[] if (momentumValues.length > 0) { const normalizedMomentum = normalize(momentumValues, Math.min(lookbackPeriod, momentumValues.length)) normalized.push(normalizedMomentum[normalizedMomentum.length - 1] || 0.5) } } if (features.volatility !== undefined) { const volatilityValues = allFeatures.map(f => f.volatility).filter(v => v !== undefined) as number[] if (volatilityValues.length > 0) { const normalizedVolatility = normalize(volatilityValues, Math.min(lookbackPeriod, volatilityValues.length)) normalized.push(normalizedVolatility[normalizedVolatility.length - 1] || 0.5) } } if (features.slope !== undefined) { const slopeValues = allFeatures.map(f => f.slope).filter(v => v !== undefined) as number[] if (slopeValues.length > 0) { const normalizedSlope = normalize(slopeValues, Math.min(lookbackPeriod, slopeValues.length)) normalized.push(normalizedSlope[normalizedSlope.length - 1] || 0.5) } } if (features.priceMomentum !== undefined) { const priceMomentumValues = allFeatures.map(f => f.priceMomentum).filter(v => v !== undefined) as number[] if (priceMomentumValues.length > 0) { const normalizedPriceMomentum = normalize(priceMomentumValues, Math.min(lookbackPeriod, priceMomentumValues.length)) normalized.push(normalizedPriceMomentum[normalizedPriceMomentum.length - 1] || 0.5) } } return normalized } /** * Find K nearest neighbors using feature similarity */ export function findKNearestNeighbors( currentFeatures: FeatureVector, historicalFeatures: FeatureVector[], rsiValues: number[], config: KNNConfig ): KNNNeighbor[] { if (historicalFeatures.length === 0 || rsiValues.length === 0) { return [] } const distances: { index: number; distance: number; rsiValue: number }[] = [] // Normalize current features const currentNormalized = normalizeFeatureVector(currentFeatures, historicalFeatures, config.lookbackPeriod) // Calculate distances to all historical points for (let i = 0; i < Math.min(historicalFeatures.length, config.lookbackPeriod); i++) { const historicalNormalized = normalizeFeatureVector(historicalFeatures[i], historicalFeatures, config.lookbackPeriod) if (currentNormalized.length === historicalNormalized.length) { const distance = euclideanDistance(currentNormalized, historicalNormalized) const rsiValue = rsiValues[i] if (!isNaN(distance) && !isNaN(rsiValue)) { distances.push({ index: i, distance, rsiValue }) } } } // Sort by distance (closest first) distances.sort((a, b) => a.distance - b.distance) // Take K nearest neighbors const kNearest = distances.slice(0, Math.min(config.neighbors, distances.length)) // Calculate weights (inverse distance weighting) const neighbors: KNNNeighbor[] = kNearest.map(neighbor => { const weight = neighbor.distance < 0.0001 ? 1.0 : 1.0 / neighbor.distance return { index: neighbor.index, distance: neighbor.distance, rsiValue: neighbor.rsiValue, weight } }) return neighbors } /** * Calculate adaptive thresholds based on historical RSI distribution */ function calculateAdaptiveThresholds( neighbors: KNNNeighbor[], klineData: KlineData[], defaultOverbought: number = 70, defaultOversold: number = 30 ): { overbought: number; oversold: number } { if (neighbors.length === 0) { return { overbought: defaultOverbought, oversold: defaultOversold } } const overboughtCandidates: number[] = [] const oversoldCandidates: number[] = [] // Analyze future returns for each neighbor to identify extreme zones for (const neighbor of neighbors) { const futureIndex = neighbor.index + 5 // Look 5 periods ahead if (futureIndex < klineData.length) { const currentPrice = klineData[neighbor.index].close const futurePrice = klineData[futureIndex].close const futureReturn = (futurePrice - currentPrice) / currentPrice // If significant positive return followed, this RSI level might be oversold if (futureReturn > 0.02) { // 2% positive return oversoldCandidates.push(neighbor.rsiValue) } // If significant negative return followed, this RSI level might be overbought if (futureReturn < -0.02) { // 2% negative return overboughtCandidates.push(neighbor.rsiValue) } } } // Calculate adaptive thresholds const overbought = overboughtCandidates.length > 0 ? overboughtCandidates.reduce((sum, val) => sum + val, 0) / overboughtCandidates.length : defaultOverbought const oversold = oversoldCandidates.length > 0 ? oversoldCandidates.reduce((sum, val) => sum + val, 0) / oversoldCandidates.length : defaultOversold return { overbought: Math.max(overbought, 60), // Ensure reasonable bounds oversold: Math.min(oversold, 40) } } /** * Apply KNN algorithm to enhance RSI */ export function applyKNNToRSI( currentRsi: number, currentFeatures: FeatureVector, historicalFeatures: FeatureVector[], rsiValues: number[], klineData: KlineData[], config: KNNConfig ): KNNResult { // Find nearest neighbors const neighbors = findKNearestNeighbors(currentFeatures, historicalFeatures, rsiValues, config) if (neighbors.length === 0) { return { enhancedRsi: currentRsi, knnDivergence: 0, effectiveNeighbors: 0, adaptiveOverbought: 70, adaptiveOversold: 30, confidence: 0 } } // Calculate weighted average RSI from neighbors const totalWeight = neighbors.reduce((sum, neighbor) => sum + neighbor.weight, 0) const weightedRsi = neighbors.reduce((sum, neighbor) => { return sum + (neighbor.rsiValue * neighbor.weight) }, 0) / totalWeight // Blend traditional RSI with ML-enhanced RSI const enhancedRsi = Math.max(0, Math.min(100, (1 - config.mlWeight) * currentRsi + config.mlWeight * weightedRsi )) // Calculate divergence (how different current RSI is from similar historical patterns) const avgDistance = neighbors.reduce((sum, neighbor) => sum + neighbor.distance, 0) / neighbors.length const knnDivergence = avgDistance * 100 // Scale for readability // Calculate adaptive thresholds const { overbought, oversold } = calculateAdaptiveThresholds(neighbors, klineData) // Calculate confidence based on neighbor similarity and count const maxDistance = Math.max(...neighbors.map(n => n.distance)) const avgSimilarity = maxDistance > 0 ? 1 - (avgDistance / maxDistance) : 1 const countFactor = Math.min(neighbors.length / config.neighbors, 1) const confidence = avgSimilarity * countFactor * 100 return { enhancedRsi, knnDivergence, effectiveNeighbors: neighbors.length, adaptiveOverbought: overbought, adaptiveOversold: oversold, confidence } } /** * Batch process multiple RSI values with KNN enhancement */ export function batchProcessKNN( rsiValues: number[], allFeatures: FeatureVector[], klineData: KlineData[], config: KNNConfig ): KNNResult[] { const results: KNNResult[] = [] for (let i = config.lookbackPeriod; i < rsiValues.length; i++) { const currentRsi = rsiValues[i] const currentFeatures = allFeatures[i] if (currentFeatures) { // Use historical features up to current point const historicalFeatures = allFeatures.slice(Math.max(0, i - config.lookbackPeriod), i) const historicalRsi = rsiValues.slice(Math.max(0, i - config.lookbackPeriod), i) const result = applyKNNToRSI( currentRsi, currentFeatures, historicalFeatures, historicalRsi, klineData, config ) results.push(result) } else { // Fallback for missing features results.push({ enhancedRsi: currentRsi, knnDivergence: 0, effectiveNeighbors: 0, adaptiveOverbought: 70, adaptiveOversold: 30, confidence: 0 }) } } return results } ``` -------------------------------------------------------------------------------- /src/__tests__/integration.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, beforeAll, it, expect } from '@jest/globals' import { config } from 'dotenv' import { join } from 'path' import { existsSync } from 'fs' import GetTicker from '../tools/GetTicker.js' import GetOrderbook from '../tools/GetOrderbook.js' import GetPositions from '../tools/GetPositions.js' import GetWalletBalance from '../tools/GetWalletBalance.js' import GetInstrumentInfo from '../tools/GetInstrumentInfo.js' import GetKline from '../tools/GetKline.js' import GetMarketInfo from '../tools/GetMarketInfo.js' import GetOrderHistory from '../tools/GetOrderHistory.js' import GetTrades from '../tools/GetTrades.js' import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" type ToolCallRequest = z.infer<typeof CallToolRequestSchema> // Load environment variables const envPath = join(process.cwd(), '.env') if (existsSync(envPath)) { config({ path: envPath }) } // Check if we're in development mode (no API credentials) const isDevMode = !process.env.BYBIT_API_KEY || !process.env.BYBIT_API_SECRET const useTestnet = process.env.BYBIT_USE_TESTNET === "true" if (isDevMode) { console.warn('Running in development mode with limited functionality') } describe('Bybit API Integration Tests', () => { // Common test symbols const testSymbols = { spot: 'BTCUSDT', linear: 'BTCUSDT', inverse: 'BTCUSD' } beforeAll(() => { if (isDevMode) { console.warn('Running integration tests in development mode (testnet)') } else { console.info(`Running integration tests against ${useTestnet ? 'testnet' : 'mainnet'}`) } }) describe('Market Data Endpoints', () => { describe('GetTicker', () => { it('should fetch ticker data for spot market', async () => { const getTicker = new GetTicker() const request: ToolCallRequest = { params: { name: 'get_ticker', arguments: { category: 'spot', symbol: testSymbols.spot } }, method: 'tools/call' as const } const result = await getTicker.toolCall(request) expect(result.content[0].type).toBe('text') const data = JSON.parse(result.content[0].text as string) expect(data).toHaveProperty('symbol', testSymbols.spot) expect(data).toHaveProperty('lastPrice') }) }) describe('GetOrderbook', () => { it('should fetch orderbook data for spot market', async () => { const getOrderbook = new GetOrderbook() const request: ToolCallRequest = { params: { name: 'get_orderbook', arguments: { category: 'spot', symbol: testSymbols.spot, limit: 5 } }, method: 'tools/call' as const } const result = await getOrderbook.toolCall(request) expect(result.content[0].type).toBe('text') const data = JSON.parse(result.content[0].text as string) expect(data).toHaveProperty('asks') expect(data).toHaveProperty('bids') }) }) describe('GetKline', () => { it('should fetch kline data for spot market', async () => { const getKline = new GetKline() const request: ToolCallRequest = { params: { name: 'get_kline', arguments: { category: 'spot', symbol: testSymbols.spot, interval: '1', limit: 5 } }, method: 'tools/call' as const } const result = await getKline.toolCall(request) expect(result.content[0].type).toBe('text') const data = JSON.parse(result.content[0].text as string) expect(Array.isArray(data)).toBe(true) expect(data.length).toBeGreaterThan(0) }) }) describe('GetTrades', () => { it('should fetch recent trades for spot market', async () => { const getTrades = new GetTrades() const request: ToolCallRequest = { params: { name: 'get_trades', arguments: { category: 'spot', symbol: testSymbols.spot, limit: 5 } }, method: 'tools/call' as const } const result = await getTrades.toolCall(request) expect(result.content[0].type).toBe('text') const data = JSON.parse(result.content[0].text as string) expect(Array.isArray(data)).toBe(true) expect(data.length).toBeGreaterThan(0) }) }) }); // Skip account-specific tests in development mode (isDevMode ? describe.skip : describe)('Account Data Endpoints', () => { describe('GetPositions', () => { it('should fetch positions for linear perpetual', async () => { const getPositions = new GetPositions() const request: ToolCallRequest = { params: { name: 'get_positions', arguments: { category: 'linear', symbol: testSymbols.linear } }, method: 'tools/call' as const } const result = await getPositions.toolCall(request) expect(result.content[0].type).toBe('text') const data = JSON.parse(result.content[0].text as string) expect(Array.isArray(data)).toBe(true) }) }) describe('GetWalletBalance', () => { it('should fetch wallet balance for unified account', async () => { const getWalletBalance = new GetWalletBalance() const request: ToolCallRequest = { params: { name: 'get_wallet_balance', arguments: { accountType: 'UNIFIED' } }, method: 'tools/call' as const } const result = await getWalletBalance.toolCall(request) expect(result.content[0].type).toBe('text') const data = JSON.parse(result.content[0].text as string) expect(data).toHaveProperty('totalEquity') expect(data).toHaveProperty('totalWalletBalance') }) }) describe('GetOrderHistory', () => { it('should fetch order history for spot market', async () => { const getOrderHistory = new GetOrderHistory() const request: ToolCallRequest = { params: { name: 'get_order_history', arguments: { category: 'spot', limit: 5 } }, method: 'tools/call' as const } const result = await getOrderHistory.toolCall(request) expect(result.content[0].type).toBe('text') const data = JSON.parse(result.content[0].text as string) expect(Array.isArray(data)).toBe(true) }) }) }) describe('Market Information Endpoints', () => { describe('GetInstrumentInfo', () => { it('should fetch instrument info for spot market', async () => { const getInstrumentInfo = new GetInstrumentInfo() const request: ToolCallRequest = { params: { name: 'get_instrument_info', arguments: { category: 'spot', symbol: testSymbols.spot } }, method: 'tools/call' as const } const result = await getInstrumentInfo.toolCall(request) expect(result.content[0].type).toBe('text') const data = JSON.parse(result.content[0].text as string) expect(data).toHaveProperty('symbol', testSymbols.spot) }) }) describe('GetMarketInfo', () => { it('should fetch market info for spot category', async () => { const getMarketInfo = new GetMarketInfo() const request: ToolCallRequest = { params: { name: 'get_market_info', arguments: { category: 'spot' } }, method: 'tools/call' as const } const result = await getMarketInfo.toolCall(request) expect(result.content[0].type).toBe('text') const data = JSON.parse(result.content[0].text as string) expect(Array.isArray(data)).toBe(true) expect(data.length).toBeGreaterThan(0) }) }) }) describe('Error Handling', () => { it('should handle invalid symbols gracefully', async () => { const getTicker = new GetTicker() const request: ToolCallRequest = { params: { name: 'get_ticker', arguments: { category: 'spot', symbol: 'INVALID-PAIR' } }, method: 'tools/call' as const } const result = await getTicker.toolCall(request) expect(result.content[0].type).toBe('text') expect(result.isError).toBe(true) const errorData = JSON.parse(result.content[0].text as string) expect(errorData.category).toBe('VALIDATION') }) it('should handle invalid categories gracefully', async () => { const getMarketInfo = new GetMarketInfo() const request: ToolCallRequest = { params: { name: 'get_market_info', arguments: { category: 'invalid-category' as any } }, method: 'tools/call' as const } const result = await getMarketInfo.toolCall(request) expect(result.content[0].type).toBe('text') expect(result.isError).toBe(true) const errorData = JSON.parse(result.content[0].text as string) expect(errorData.category).toBe('VALIDATION') }) }) }) ``` -------------------------------------------------------------------------------- /webui/src/utils/dataDetection.ts: -------------------------------------------------------------------------------- ```typescript /** * Data Detection Utilities * * Detects and classifies different types of data from MCP tool responses * to determine appropriate visualisation methods. */ export type DataType = 'kline' | 'rsi' | 'orderBlocks' | 'price' | 'volume' | 'unknown'; export interface DetectionResult { dataType: DataType; confidence: number; // 0-1 scale summary: string; visualisable: boolean; sampleData?: any; } /** * Main data detection function */ export function detectDataType(data: any): DetectionResult { if (!data) { return createResult('unknown', 0, 'No data provided', false); } // Try different detection methods in order of specificity const detectors = [ detectKlineData, detectRSIData, detectOrderBlocksData, detectPriceData, detectVolumeData ]; for (const detector of detectors) { const result = detector(data); if (result.confidence > 0.7) { return result; } } // Fallback to unknown return createResult('unknown', 0, 'Unrecognised data format', false); } /** * Detect OHLCV/Kline data */ function detectKlineData(data: any): DetectionResult { try { // Check if data has a nested 'data' array (common MCP response format) let klineArray = data; if (data && typeof data === 'object' && Array.isArray(data.data)) { klineArray = data.data; } // Check if it's an array of kline data if (Array.isArray(klineArray) && klineArray.length > 0) { const sample = klineArray[0]; // Check for common kline data structures const hasOHLCV = sample && ( // Array format: [timestamp, open, high, low, close, volume] (Array.isArray(sample) && sample.length >= 6) || // Object format with OHLCV properties (typeof sample === 'object' && hasNumericProperties(sample, ['open', 'high', 'low', 'close']) && (sample.timestamp || sample.time || sample.openTime)) ); if (hasOHLCV) { const count = klineArray.length; const timespan = getTimespan(klineArray); const symbol = data.symbol || 'Unknown'; return createResult( 'kline', 0.9, `${count} candles for ${symbol}${timespan ? ` (${timespan})` : ''}`, true, klineArray.slice(0, 3) // Sample first 3 items ); } } // Check for single kline object if (typeof data === 'object' && hasNumericProperties(data, ['open', 'high', 'low', 'close'])) { return createResult('kline', 0.8, 'Single candle data', true, data); } return createResult('kline', 0, '', false); } catch { return createResult('kline', 0, '', false); } } /** * Detect RSI or other indicator data */ function detectRSIData(data: any): DetectionResult { try { // Check for RSI-specific patterns if (Array.isArray(data) && data.length > 0) { const sample = data[0]; // RSI values are typically between 0-100 const hasRSIValues = sample && ( (typeof sample === 'object' && (sample.rsi !== undefined || sample.RSI !== undefined)) || (typeof sample === 'number' && sample >= 0 && sample <= 100) ); if (hasRSIValues) { const count = data.length; const avgValue = calculateAverageRSI(data); return createResult( 'rsi', 0.85, `${count} RSI values (avg: ${avgValue.toFixed(1)})`, true, data.slice(0, 5) ); } } // Check for single RSI value if (typeof data === 'number' && data >= 0 && data <= 100) { return createResult('rsi', 0.7, `RSI: ${data.toFixed(2)}`, true, data); } // Check for object with RSI property if (typeof data === 'object' && (data.rsi !== undefined || data.RSI !== undefined)) { const rsiValue = data.rsi || data.RSI; return createResult('rsi', 0.8, `RSI: ${rsiValue}`, true, data); } return createResult('rsi', 0, '', false); } catch { return createResult('rsi', 0, '', false); } } /** * Detect Order Blocks data */ function detectOrderBlocksData(data: any): DetectionResult { try { if (Array.isArray(data) && data.length > 0) { const sample = data[0]; // Look for order block characteristics const hasOrderBlockProps = sample && typeof sample === 'object' && ( (sample.type && (sample.type.includes('block') || sample.type.includes('order'))) || (hasNumericProperties(sample, ['high', 'low']) && sample.volume) || (sample.bullish !== undefined || sample.bearish !== undefined) ); if (hasOrderBlockProps) { const count = data.length; const types = getOrderBlockTypes(data); return createResult( 'orderBlocks', 0.85, `${count} blocks (${types})`, true, data.slice(0, 3) ); } } return createResult('orderBlocks', 0, '', false); } catch { return createResult('orderBlocks', 0, '', false); } } /** * Detect Price data */ function detectPriceData(data: any): DetectionResult { try { // Single price value if (typeof data === 'number' && data > 0) { return createResult('price', 0.6, `$${data.toFixed(4)}`, true, data); } // Price object if (typeof data === 'object' && data.price !== undefined) { return createResult('price', 0.8, `$${data.price}`, true, data); } // Array of prices if (Array.isArray(data) && data.length > 0 && typeof data[0] === 'number') { const count = data.length; const latest = data[data.length - 1]; return createResult('price', 0.7, `${count} prices (latest: $${latest})`, true, data.slice(-5)); } return createResult('price', 0, '', false); } catch { return createResult('price', 0, '', false); } } /** * Detect Volume data */ function detectVolumeData(data: any): DetectionResult { try { if (Array.isArray(data) && data.length > 0) { const sample = data[0]; // Volume array or objects with volume property const hasVolumeData = ( typeof sample === 'number' || (typeof sample === 'object' && sample.volume !== undefined) ); if (hasVolumeData) { const count = data.length; const totalVolume = calculateTotalVolume(data); return createResult( 'volume', 0.8, `${count} volume points (total: ${formatVolume(totalVolume)})`, true, data.slice(0, 5) ); } } return createResult('volume', 0, '', false); } catch { return createResult('volume', 0, '', false); } } /** * Helper function to create detection results */ function createResult( dataType: DataType, confidence: number, summary: string, visualisable: boolean, sampleData?: any ): DetectionResult { return { dataType, confidence, summary, visualisable, sampleData }; } /** * Check if object has numeric properties */ function hasNumericProperties(obj: any, props: string[]): boolean { return props.every(prop => obj[prop] !== undefined && (typeof obj[prop] === 'number' || !isNaN(parseFloat(obj[prop]))) ); } /** * Calculate timespan for kline data */ function getTimespan(data: any[]): string | null { try { if (data.length < 2) return null; const first = data[0]; const last = data[data.length - 1]; // Extract timestamps let firstTime, lastTime; if (Array.isArray(first)) { firstTime = first[0]; lastTime = last[0]; } else if (typeof first === 'object') { firstTime = first.timestamp || first.time || first.openTime; lastTime = last.timestamp || last.time || last.openTime; } if (firstTime && lastTime) { const diffHours = Math.abs(lastTime - firstTime) / (1000 * 60 * 60); if (diffHours < 24) return `${diffHours.toFixed(1)}h`; if (diffHours < 24 * 7) return `${(diffHours / 24).toFixed(1)}d`; return `${(diffHours / (24 * 7)).toFixed(1)}w`; } return null; } catch { return null; } } /** * Calculate average RSI value */ function calculateAverageRSI(data: any[]): number { try { const values = data.map(item => { if (typeof item === 'number') return item; if (typeof item === 'object') return item.rsi || item.RSI; return 0; }).filter(val => val > 0); return values.reduce((sum, val) => sum + val, 0) / values.length; } catch { return 0; } } /** * Get order block types summary */ function getOrderBlockTypes(data: any[]): string { try { const types = data.map(block => { if (block.bullish) return 'bullish'; if (block.bearish) return 'bearish'; if (block.type) return block.type; return 'unknown'; }); const bullish = types.filter(t => t === 'bullish').length; const bearish = types.filter(t => t === 'bearish').length; return `${bullish}B/${bearish}B`; } catch { return 'mixed'; } } /** * Calculate total volume */ function calculateTotalVolume(data: any[]): number { try { return data.reduce((total, item) => { const volume = typeof item === 'number' ? item : (item.volume || 0); return total + volume; }, 0); } catch { return 0; } } /** * Format volume for display */ function formatVolume(volume: number): string { if (volume >= 1e9) return `${(volume / 1e9).toFixed(1)}B`; if (volume >= 1e6) return `${(volume / 1e6).toFixed(1)}M`; if (volume >= 1e3) return `${(volume / 1e3).toFixed(1)}K`; return volume.toFixed(0); } ``` -------------------------------------------------------------------------------- /webui/src/styles/verification-panel.css: -------------------------------------------------------------------------------- ```css /** * Data Verification Panel Styles */ /* Panel container */ .verification-panel { position: fixed; top: 0; right: 0; width: 400px; height: 100vh; background: var(--panel-bg, #ffffff); border-left: 1px solid var(--panel-border, #e0e0e0); box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); z-index: 1000; transform: translateX(100%); transition: transform 0.3s ease; display: flex; flex-direction: column; } .verification-panel.visible { transform: translateX(0); } .verification-panel.hidden { transform: translateX(100%); } /* Panel header */ .panel-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--panel-border, #e0e0e0); background: var(--panel-header-bg, #f8f9fa); } .panel-header h3 { margin: 0; font-size: 1.1em; font-weight: 600; color: var(--panel-title, #333); } .panel-controls { display: flex; align-items: center; gap: 12px; } .filter-select { padding: 4px 8px; border: 1px solid var(--input-border, #ddd); border-radius: 4px; background: var(--input-bg, #fff); color: var(--input-text, #333); font-size: 0.9em; } .toggle-btn { background: var(--accent-color, #007acc); color: white; border: none; padding: 8px 12px; border-radius: 6px; cursor: pointer; transition: background 0.2s ease; font-size: 1em; } .toggle-btn:hover { background: var(--accent-hover, #005a9e); } /* Panel content */ .panel-content { flex: 1; overflow-y: auto; padding: 16px 20px; } /* Citations summary */ .citations-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; padding: 12px; background: var(--summary-bg, #f8f9fa); border-radius: 8px; border: 1px solid var(--summary-border, #e9ecef); } .summary-item { display: flex; flex-direction: column; align-items: center; text-align: center; } .summary-item .label { font-size: 0.8em; color: var(--text-muted, #666); margin-bottom: 4px; } .summary-item .value { font-size: 1.2em; font-weight: 600; color: var(--accent-color, #007acc); } /* Citations list */ .citations-list { display: flex; flex-direction: column; gap: 12px; } .empty-state { text-align: center; padding: 40px 20px; color: var(--text-muted, #666); } .empty-state p { margin: 0; font-style: italic; } /* Citation item */ .citation-item { background: var(--item-bg, #fff); border: 1px solid var(--item-border, #e0e0e0); border-radius: 8px; padding: 12px; transition: all 0.2s ease; } .citation-item:hover { border-color: var(--accent-color, #007acc); box-shadow: 0 2px 8px rgba(0, 122, 204, 0.1); } .citation-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; flex-wrap: wrap; gap: 8px; } .reference-id { background: var(--accent-color, #007acc); color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.8em; font-weight: 500; } .tool-name { background: var(--tool-bg, #e9ecef); color: var(--tool-text, #495057); padding: 2px 6px; border-radius: 4px; font-size: 0.8em; font-family: monospace; } .timestamp { font-size: 0.8em; color: var(--text-muted, #666); } /* Key metrics */ .key-metrics { margin: 8px 0; display: flex; flex-direction: column; gap: 4px; } .metric-item { display: flex; justify-content: space-between; align-items: center; padding: 4px 8px; border-radius: 4px; font-size: 0.85em; } .metric-item.metric-high { background: var(--metric-high-bg, #e8f5e8); border-left: 3px solid var(--success-color, #28a745); } .metric-item.metric-medium { background: var(--metric-medium-bg, #fff3cd); border-left: 3px solid var(--warning-color, #ffc107); } .metric-item.metric-low { background: var(--metric-low-bg, #f8f9fa); border-left: 3px solid var(--secondary-color, #6c757d); } .metric-label { font-weight: 500; color: var(--text-primary, #333); } .metric-value { font-weight: 600; color: var(--accent-color, #007acc); font-family: monospace; } /* Citation actions */ .citation-actions { display: flex; gap: 8px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--item-border, #e0e0e0); } .btn-view-details, .btn-copy-data { flex: 1; padding: 6px 12px; border: 1px solid var(--button-border, #ddd); border-radius: 4px; background: var(--button-bg, #fff); color: var(--button-text, #333); font-size: 0.8em; cursor: pointer; transition: all 0.2s ease; } .btn-view-details:hover { background: var(--accent-color, #007acc); color: white; border-color: var(--accent-color, #007acc); } .btn-copy-data:hover { background: var(--secondary-color, #6c757d); color: white; border-color: var(--secondary-color, #6c757d); } /* Verification modal */ .verification-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; z-index: 2000; animation: fadeIn 0.3s ease; } .verification-modal { background: var(--modal-bg, #fff); color: var(--modal-text, #333); border-radius: 12px; max-width: 90vw; max-height: 90vh; width: 800px; overflow: hidden; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); animation: slideIn 0.3s ease; display: flex; flex-direction: column; } .modal-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--modal-border, #eee); background: var(--modal-header-bg, #f8f9fa); } .modal-header h3 { margin: 0; font-size: 1.2em; color: var(--modal-title, #333); } .modal-close { background: none; border: none; font-size: 1.5em; cursor: pointer; color: var(--text-muted, #666); padding: 0; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: background 0.2s ease; } .modal-close:hover { background: var(--hover-bg, #f0f0f0); } .modal-content { flex: 1; overflow-y: auto; padding: 20px; } /* Citation metadata */ .citation-metadata { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-bottom: 20px; padding: 16px; background: var(--metadata-bg, #f8f9fa); border-radius: 8px; } .metadata-item { font-size: 0.9em; } .metadata-item strong { color: var(--text-primary, #333); } /* Extracted metrics */ .extracted-metrics { margin-bottom: 20px; } .extracted-metrics h4 { margin: 0 0 12px 0; color: var(--text-primary, #333); } .metrics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; } .metric-card { padding: 12px; border-radius: 8px; text-align: center; border: 1px solid var(--card-border, #e0e0e0); } .metric-card.metric-high { background: var(--metric-high-bg, #e8f5e8); border-color: var(--success-color, #28a745); } .metric-card.metric-medium { background: var(--metric-medium-bg, #fff3cd); border-color: var(--warning-color, #ffc107); } .metric-card.metric-low { background: var(--metric-low-bg, #f8f9fa); border-color: var(--secondary-color, #6c757d); } .metric-type { font-size: 0.7em; text-transform: uppercase; color: var(--text-muted, #666); margin-bottom: 4px; } .metric-card .metric-label { font-size: 0.8em; font-weight: 500; margin-bottom: 4px; } .metric-card .metric-value { font-size: 1.1em; font-weight: 600; color: var(--accent-color, #007acc); font-family: monospace; } /* Raw data section */ .raw-data-section { margin-top: 20px; } .section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .section-header h4 { margin: 0; color: var(--text-primary, #333); } .btn-copy-json { background: var(--secondary-color, #6c757d); color: white; border: none; padding: 6px 12px; border-radius: 4px; font-size: 0.8em; cursor: pointer; transition: background 0.2s ease; } .btn-copy-json:hover { background: var(--secondary-hover, #5a6268); } /* JSON viewer */ .json-viewer { background: var(--code-bg, #f8f9fa); border: 1px solid var(--code-border, #e9ecef); border-radius: 6px; padding: 16px; overflow-x: auto; max-height: 400px; overflow-y: auto; } .json-viewer code { font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.85em; line-height: 1.4; color: var(--code-text, #333); } /* Toast notifications */ .verification-toast { position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 6px; color: white; font-weight: 500; z-index: 3000; transform: translateX(100%); transition: transform 0.3s ease; } .verification-toast.show { transform: translateX(0); } .verification-toast.toast-success { background: var(--success-color, #28a745); } .verification-toast.toast-error { background: var(--danger-color, #dc3545); } /* Animations */ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideIn { from { transform: scale(0.9) translateY(-20px); opacity: 0; } to { transform: scale(1) translateY(0); opacity: 1; } } /* Dark theme */ [data-theme="dark"] .verification-panel { --panel-bg: #2d2d2d; --panel-border: #444; --panel-header-bg: #333; --panel-title: #fff; --summary-bg: #333; --summary-border: #444; --item-bg: #333; --item-border: #444; --tool-bg: #444; --tool-text: #ccc; --text-muted: #aaa; --text-primary: #fff; --button-bg: #444; --button-text: #fff; --button-border: #555; --modal-bg: #2d2d2d; --modal-text: #fff; --modal-border: #444; --modal-header-bg: #333; --modal-title: #fff; --metadata-bg: #333; --card-border: #444; --code-bg: #1e1e1e; --code-border: #444; --code-text: #e0e0e0; --hover-bg: #404040; --metric-high-bg: #1a3d1a; --metric-medium-bg: #3d3d1a; --metric-low-bg: #333; } /* Responsive design */ @media (max-width: 768px) { .verification-panel { width: 100vw; } .verification-modal { width: 95vw; margin: 20px; } .citations-summary { grid-template-columns: 1fr; } .metrics-grid { grid-template-columns: 1fr; } } ``` -------------------------------------------------------------------------------- /src/tools/GetMLRSI.ts: -------------------------------------------------------------------------------- ```typescript import { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js" import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { BaseToolImplementation } from "./BaseTool.js" import { calculateRSI, extractFeatures, kalmanFilter, alma, doubleEma, KlineData, FeatureVector } from "../utils/mathUtils.js" import { applyKNNToRSI, batchProcessKNN, KNNConfig, KNNResult } from "../utils/knnAlgorithm.js" import { GetKlineParamsV5, KlineIntervalV3 } from "bybit-api" // Zod schema for input validation const inputSchema = z.object({ symbol: z.string() .min(1, "Symbol is required") .regex(/^[A-Z0-9]+$/, "Symbol must contain only uppercase letters and numbers"), category: z.enum(["spot", "linear", "inverse"]), interval: z.enum(["1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M"]), rsiLength: z.number().min(2).max(50).optional().default(14), knnNeighbors: z.number().min(1).max(50).optional().default(5), knnLookback: z.number().min(20).max(500).optional().default(100), mlWeight: z.number().min(0).max(1).optional().default(0.4), featureCount: z.number().min(1).max(5).optional().default(3), smoothingMethod: z.enum(["none", "kalman", "alma", "double_ema"]).optional().default("none"), limit: z.number().min(50).max(1000).optional().default(200) }) type ToolArguments = z.infer<typeof inputSchema> interface MLRSIDataPoint { timestamp: number; standardRsi: number; mlRsi: number; adaptiveOverbought: number; adaptiveOversold: number; knnDivergence: number; effectiveNeighbors: number; trend: "bullish" | "bearish" | "neutral"; confidence: number; } interface MLRSIResponse { symbol: string; interval: string; data: MLRSIDataPoint[]; metadata: { mlEnabled: boolean; featuresUsed: string[]; smoothingApplied: string; calculationTime: number; rsiLength: number; knnConfig: KNNConfig; }; } class GetMLRSI extends BaseToolImplementation { name = "get_ml_rsi" toolDefinition: Tool = { name: this.name, description: "Get ML-enhanced RSI using K-Nearest Neighbors algorithm for pattern recognition. Provides adaptive overbought/oversold levels and enhanced RSI values based on historical pattern similarity.", inputSchema: { type: "object", properties: { symbol: { type: "string", description: "Trading pair symbol (e.g., 'BTCUSDT')", pattern: "^[A-Z0-9]+$" }, category: { type: "string", description: "Category of the instrument", enum: ["spot", "linear", "inverse"] }, interval: { type: "string", description: "Kline interval", enum: ["1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "W", "M"] }, rsiLength: { type: "number", description: "RSI calculation period (default: 14)", minimum: 2, maximum: 50 }, knnNeighbors: { type: "number", description: "Number of neighbors for KNN algorithm (default: 5)", minimum: 1, maximum: 50 }, knnLookback: { type: "number", description: "Historical period for pattern matching (default: 100)", minimum: 20, maximum: 500 }, mlWeight: { type: "number", description: "ML influence weight 0-1 (default: 0.4)", minimum: 0, maximum: 1 }, featureCount: { type: "number", description: "Number of features to use 1-5 (default: 3)", minimum: 1, maximum: 5 }, smoothingMethod: { type: "string", description: "Smoothing method to apply (default: none)", enum: ["none", "kalman", "alma", "double_ema"] }, limit: { type: "number", description: "Number of data points to return (default: 200)", minimum: 50, maximum: 1000 } }, required: ["symbol", "category", "interval"] } } async toolCall(request: z.infer<typeof CallToolRequestSchema>): Promise<CallToolResult> { const startTime = Date.now() try { this.logInfo("Starting get_ml_rsi tool call") // Parse and validate input const validationResult = inputSchema.safeParse(request.params.arguments) if (!validationResult.success) { const errorDetails = validationResult.error.errors.map(err => ({ field: err.path.join('.'), message: err.message, code: err.code })) throw new Error(`Invalid input: ${JSON.stringify(errorDetails)}`) } const args = validationResult.data // Fetch kline data const klineData = await this.fetchKlineData(args) if (klineData.length < args.rsiLength + args.knnLookback) { throw new Error(`Insufficient data. Need at least ${args.rsiLength + args.knnLookback} data points, got ${klineData.length}`) } // Calculate standard RSI const closePrices = klineData.map(k => k.close) const rsiValues = calculateRSI(closePrices, args.rsiLength) if (rsiValues.length === 0) { throw new Error("Failed to calculate RSI values") } // Extract features for all data points const allFeatures: FeatureVector[] = [] for (let i = 0; i < klineData.length; i++) { const features = extractFeatures(klineData, i, rsiValues, args.featureCount, args.knnLookback) allFeatures.push(features || { rsi: rsiValues[i] || 0 }) } // Configure KNN const knnConfig: KNNConfig = { neighbors: args.knnNeighbors, lookbackPeriod: args.knnLookback, mlWeight: args.mlWeight, featureCount: args.featureCount } // Apply KNN enhancement const knnResults = batchProcessKNN(rsiValues, allFeatures, klineData, knnConfig) // Apply smoothing if requested const smoothedResults = this.applySmoothingToResults(knnResults, rsiValues, args.smoothingMethod) // Format response data const responseData = this.formatMLRSIData(klineData, rsiValues, smoothedResults, args.limit) const calculationTime = Date.now() - startTime const response: MLRSIResponse = { symbol: args.symbol, interval: args.interval, data: responseData, metadata: { mlEnabled: true, featuresUsed: this.getFeatureNames(args.featureCount), smoothingApplied: args.smoothingMethod, calculationTime, rsiLength: args.rsiLength, knnConfig } } this.logInfo(`ML-RSI calculation completed in ${calculationTime}ms`) return this.formatResponse(response) } catch (error) { this.logInfo(`ML-RSI calculation failed: ${error instanceof Error ? error.message : String(error)}`) return this.handleError(error) } } private async fetchKlineData(args: ToolArguments): Promise<KlineData[]> { const params: GetKlineParamsV5 = { category: args.category, symbol: args.symbol, interval: args.interval as KlineIntervalV3, limit: args.limit } const response = await this.executeRequest(() => this.client.getKline(params)) if (!response.list || response.list.length === 0) { throw new Error("No kline data received from API") } // Convert API response to KlineData format return response.list.map(kline => ({ timestamp: parseInt(kline[0]), open: parseFloat(kline[1]), high: parseFloat(kline[2]), low: parseFloat(kline[3]), close: parseFloat(kline[4]), volume: parseFloat(kline[5]) })).reverse() // Reverse to get chronological order } private applySmoothingToResults( knnResults: KNNResult[], rsiValues: number[], method: string ): KNNResult[] { if (method === "none" || knnResults.length === 0) { return knnResults } const enhancedRsiValues = knnResults.map(r => r.enhancedRsi) let smoothedValues: number[] = [] switch (method) { case "kalman": smoothedValues = kalmanFilter(enhancedRsiValues, 0.01, 0.1) break case "alma": smoothedValues = alma(enhancedRsiValues, Math.min(14, enhancedRsiValues.length), 0.85, 6) break case "double_ema": smoothedValues = doubleEma(enhancedRsiValues, Math.min(10, enhancedRsiValues.length)) break default: smoothedValues = enhancedRsiValues } // Apply smoothed values back to results return knnResults.map((result, index) => ({ ...result, enhancedRsi: smoothedValues[index] || result.enhancedRsi })) } private formatMLRSIData( klineData: KlineData[], rsiValues: number[], knnResults: KNNResult[], limit: number ): MLRSIDataPoint[] { const data: MLRSIDataPoint[] = [] const startIndex = Math.max(0, klineData.length - limit) for (let i = startIndex; i < klineData.length; i++) { const knnIndex = i - (rsiValues.length - knnResults.length) const knnResult = knnIndex >= 0 ? knnResults[knnIndex] : null const rsiIndex = i - (klineData.length - rsiValues.length) const standardRsi = rsiIndex >= 0 ? rsiValues[rsiIndex] : 50 if (knnResult) { const trend = this.determineTrend(knnResult.enhancedRsi, knnResult.adaptiveOverbought, knnResult.adaptiveOversold) data.push({ timestamp: klineData[i].timestamp, standardRsi, mlRsi: knnResult.enhancedRsi, adaptiveOverbought: knnResult.adaptiveOverbought, adaptiveOversold: knnResult.adaptiveOversold, knnDivergence: knnResult.knnDivergence, effectiveNeighbors: knnResult.effectiveNeighbors, trend, confidence: knnResult.confidence }) } } return data } /** * Determine market trend based on RSI values with proper priority handling * Fixed: Removed overlapping conditions that could cause contradictory results */ private determineTrend(rsi: number, overbought: number, oversold: number): "bullish" | "bearish" | "neutral" { // Priority 1: Adaptive levels (ML-enhanced thresholds take precedence) if (rsi > overbought) return "bearish" if (rsi < oversold) return "bullish" // Priority 2: Standard RSI levels (only apply if not in adaptive zones) // Use a buffer zone around 50 to avoid too frequent switches if (rsi >= 55) return "bullish" if (rsi <= 45) return "bearish" // Priority 3: Neutral zone (45-55 range) return "neutral" } private getFeatureNames(featureCount: number): string[] { const features = ["rsi"] if (featureCount >= 2) features.push("momentum") if (featureCount >= 3) features.push("volatility") if (featureCount >= 4) features.push("slope") if (featureCount >= 5) features.push("price_momentum") return features } } export default GetMLRSI ``` -------------------------------------------------------------------------------- /src/__tests__/GetMLRSI.test.ts: -------------------------------------------------------------------------------- ```typescript import { jest, describe, beforeEach, it, expect } from '@jest/globals' import GetMLRSI from '../tools/GetMLRSI.js' import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { RestClientV5 } from "bybit-api" type ToolCallRequest = z.infer<typeof CallToolRequestSchema> // Create mock client methods const mockClient = { getKline: jest.fn(), } as any describe('GetMLRSI Tool', () => { let getMLRSI: GetMLRSI beforeEach(() => { jest.clearAllMocks() getMLRSI = new GetMLRSI(mockClient) }) describe('Tool Definition', () => { it('should have correct tool name', () => { expect(getMLRSI.name).toBe('get_ml_rsi') }) it('should have proper tool definition structure', () => { const toolDef = getMLRSI.toolDefinition expect(toolDef.name).toBe('get_ml_rsi') expect(toolDef.description).toContain('ML-enhanced RSI') expect(toolDef.description).toContain('K-Nearest Neighbors') expect(toolDef.inputSchema.type).toBe('object') expect(toolDef.inputSchema.required).toEqual(['symbol', 'category', 'interval']) }) it('should have all required input parameters', () => { const properties = getMLRSI.toolDefinition.inputSchema.properties expect(properties).toHaveProperty('symbol') expect(properties).toHaveProperty('category') expect(properties).toHaveProperty('interval') expect(properties).toHaveProperty('rsiLength') expect(properties).toHaveProperty('knnNeighbors') expect(properties).toHaveProperty('knnLookback') expect(properties).toHaveProperty('mlWeight') expect(properties).toHaveProperty('featureCount') expect(properties).toHaveProperty('smoothingMethod') expect(properties).toHaveProperty('limit') }) }) describe('Input Validation', () => { it('should reject invalid symbol format', async () => { const request: ToolCallRequest = { method: "tools/call", params: { name: "get_ml_rsi", arguments: { symbol: "btc-usdt", // Invalid format category: "spot", interval: "15" } } } const result = await getMLRSI.toolCall(request) expect(result.isError).toBe(true) expect(result.content[0].text).toContain('Invalid input') }) it('should reject invalid category', async () => { const request: ToolCallRequest = { method: "tools/call", params: { name: "get_ml_rsi", arguments: { symbol: "BTCUSDT", category: "invalid", // Invalid category interval: "15" } } } const result = await getMLRSI.toolCall(request) expect(result.isError).toBe(true) expect(result.content[0].text).toContain('Invalid input') }) it('should reject invalid RSI length', async () => { const request: ToolCallRequest = { method: "tools/call", params: { name: "get_ml_rsi", arguments: { symbol: "BTCUSDT", category: "spot", interval: "15", rsiLength: 1 // Too small } } } const result = await getMLRSI.toolCall(request) expect(result.isError).toBe(true) expect(result.content[0].text).toContain('Invalid input') }) it('should reject invalid ML weight', async () => { const request: ToolCallRequest = { method: "tools/call", params: { name: "get_ml_rsi", arguments: { symbol: "BTCUSDT", category: "spot", interval: "15", mlWeight: 1.5 // Too large } } } const result = await getMLRSI.toolCall(request) expect(result.isError).toBe(true) expect(result.content[0].text).toContain('Invalid input') }) it('should accept valid parameters with defaults', async () => { // Mock successful API response const mockKlineResponse = { retCode: 0, retMsg: 'OK', result: { list: Array.from({ length: 200 }, (_, i) => [ String(Date.now() + i * 900000), // timestamp "50000", // open "50100", // high "49900", // low "50050", // close "1000" // volume ]) }, time: Date.now(), }; (mockClient.getKline as jest.Mock).mockResolvedValueOnce(mockKlineResponse) const request: ToolCallRequest = { method: "tools/call", params: { name: "get_ml_rsi", arguments: { symbol: "BTCUSDT", category: "spot", interval: "15" } } } const result = await getMLRSI.toolCall(request) expect(result.isError).not.toBe(true) expect(mockClient.getKline).toHaveBeenCalled() }) }) describe('Feature Configuration', () => { it('should handle different feature counts', async () => { // Mock successful API response const mockKlineResponse = { retCode: 0, retMsg: 'OK', result: { list: Array.from({ length: 200 }, (_, i) => [ String(Date.now() + i * 900000), "50000", "50100", "49900", "50050", "1000" ]) }, time: Date.now(), }; (mockClient.getKline as jest.Mock).mockResolvedValueOnce(mockKlineResponse) const request: ToolCallRequest = { method: "tools/call", params: { name: "get_ml_rsi", arguments: { symbol: "BTCUSDT", category: "spot", interval: "15", featureCount: 5 // Maximum features } } } const result = await getMLRSI.toolCall(request) expect(result.isError).not.toBe(true) const response = JSON.parse(result.content[0].text as string) expect(response.metadata.featuresUsed).toEqual([ "rsi", "momentum", "volatility", "slope", "price_momentum" ]) }) it('should handle different smoothing methods', async () => { // Mock successful API response const mockKlineResponse = { retCode: 0, retMsg: 'OK', result: { list: Array.from({ length: 200 }, (_, i) => [ String(Date.now() + i * 900000), "50000", "50100", "49900", "50050", "1000" ]) }, time: Date.now(), }; (mockClient.getKline as jest.Mock).mockResolvedValueOnce(mockKlineResponse) const request: ToolCallRequest = { method: "tools/call", params: { name: "get_ml_rsi", arguments: { symbol: "BTCUSDT", category: "spot", interval: "15", smoothingMethod: "kalman" } } } const result = await getMLRSI.toolCall(request) expect(result.isError).not.toBe(true) const response = JSON.parse(result.content[0].text as string) expect(response.metadata.smoothingApplied).toBe("kalman") }) }) describe('Error Handling', () => { it('should handle insufficient data error', async () => { // Mock API response with insufficient data const mockKlineResponse = { retCode: 0, retMsg: 'OK', result: { list: Array.from({ length: 50 }, (_, i) => [ String(Date.now() + i * 900000), "50000", "50100", "49900", "50050", "1000" ]) }, time: Date.now(), }; (mockClient.getKline as jest.Mock).mockResolvedValueOnce(mockKlineResponse) const request: ToolCallRequest = { method: "tools/call", params: { name: "get_ml_rsi", arguments: { symbol: "BTCUSDT", category: "spot", interval: "15" } } } const result = await getMLRSI.toolCall(request) expect(result.isError).toBe(true) expect(result.content[0].text).toContain('Insufficient data') }) it('should handle API errors gracefully', async () => { // Mock API error - use non-retryable error code const mockErrorResponse = { retCode: 10001, retMsg: 'Parameter error', result: null, time: Date.now(), }; (mockClient.getKline as jest.Mock).mockResolvedValue(mockErrorResponse) const request: ToolCallRequest = { method: "tools/call", params: { name: "get_ml_rsi", arguments: { symbol: "BTCUSDT", category: "spot", interval: "15" } } } const result = await getMLRSI.toolCall(request) expect(result.isError).toBe(true) }) }) describe('Response Format', () => { it('should return properly formatted ML-RSI response', async () => { // Mock successful API response const mockKlineResponse = { retCode: 0, retMsg: 'OK', result: { list: Array.from({ length: 200 }, (_, i) => [ String(Date.now() + i * 900000), "50000", "50100", "49900", "50050", "1000" ]) }, time: Date.now(), }; (mockClient.getKline as jest.Mock).mockResolvedValueOnce(mockKlineResponse) const request: ToolCallRequest = { method: "tools/call", params: { name: "get_ml_rsi", arguments: { symbol: "BTCUSDT", category: "spot", interval: "15" } } } const result = await getMLRSI.toolCall(request) expect(result.isError).not.toBe(true) const response = JSON.parse(result.content[0].text as string) // Check response structure expect(response).toHaveProperty('symbol', 'BTCUSDT') expect(response).toHaveProperty('interval', '15') expect(response).toHaveProperty('data') expect(response).toHaveProperty('metadata') // Check metadata expect(response.metadata).toHaveProperty('mlEnabled', true) expect(response.metadata).toHaveProperty('featuresUsed') expect(response.metadata).toHaveProperty('smoothingApplied') expect(response.metadata).toHaveProperty('calculationTime') expect(response.metadata).toHaveProperty('rsiLength') expect(response.metadata).toHaveProperty('knnConfig') // Check data points structure if (response.data.length > 0) { const dataPoint = response.data[0] expect(dataPoint).toHaveProperty('timestamp') expect(dataPoint).toHaveProperty('standardRsi') expect(dataPoint).toHaveProperty('mlRsi') expect(dataPoint).toHaveProperty('adaptiveOverbought') expect(dataPoint).toHaveProperty('adaptiveOversold') expect(dataPoint).toHaveProperty('knnDivergence') expect(dataPoint).toHaveProperty('effectiveNeighbors') expect(dataPoint).toHaveProperty('trend') expect(dataPoint).toHaveProperty('confidence') } }) }) }) ``` -------------------------------------------------------------------------------- /webui/src/components/chat/MessageRenderer.ts: -------------------------------------------------------------------------------- ```typescript /** * Enhanced Message Renderer * * Extends the existing chat message rendering to detect and visualise * structured data from MCP tool responses using DataCards. */ import { DataCard, type DataCardConfig } from './DataCard'; import { detectDataType, type DetectionResult } from '../../utils/dataDetection'; import { citationProcessor } from '../../services/citationProcessor'; export interface MessageData { content: string; role: 'system' | 'user' | 'assistant' | 'tool'; timestamp?: number; toolCall?: { name: string; result: any; }; citations?: any[]; } export class MessageRenderer { private container: HTMLElement; private dataCards: DataCard[] = []; constructor(container: HTMLElement) { this.container = container; } /** * Render a message with enhanced data visualisation support */ public renderMessage(message: MessageData): HTMLElement { console.log('[MessageRenderer] renderMessage called with:', JSON.parse(JSON.stringify(message))); // DEV_PLAN 1.23 const messageElement = document.createElement('div'); messageElement.className = `message ${message.role}`; // Add timestamp if available if (message.timestamp) { messageElement.setAttribute('data-timestamp', message.timestamp.toString()); } // Create message content const contentElement = this.createContentElement(message); messageElement.appendChild(contentElement); // Check for visualisable data in tool responses if (message.toolCall && message.toolCall.result) { console.log('[MessageRenderer] Message has toolCall, attempting to create DataCard directly from toolCall.result'); // DEV_PLAN 1.23 const dataCardElement = this.createDataCardIfApplicable(message.toolCall.name, message.toolCall.result); if (dataCardElement) { messageElement.appendChild(dataCardElement); } } // Handle citations if present // DEV_PLAN 1.22 & 1.23: This is where logic for DataCards from citations should be. // Currently, it only creates simple citation links. if (message.citations && message.citations.length > 0) { console.log('[MessageRenderer] Message has citations:', JSON.parse(JSON.stringify(message.citations))); // DEV_PLAN 1.22 // TODO: Iterate through citations and attempt to create DataCards if they contain raw tool results. // For now, just render the basic citation links. const citationsElement = this.createCitationsElement(message.citations); messageElement.appendChild(citationsElement); // Placeholder for new logic: Attempt to create DataCards from citations message.citations.forEach(citation => { // Assuming citation object might have a 'rawData' or similar field holding the tool result // This structure needs to be confirmed by inspecting the logs. const toolResult = citation.rawData || citation.data || citation.result; // Guessing potential fields const toolName = citation.toolName || 'unknown_tool_from_citation'; // Guessing potential fields if (toolResult) { console.log(`[MessageRenderer] Attempting to create DataCard from citation: ${citation.id || 'unknown_id'}`, JSON.parse(JSON.stringify(toolResult))); const dataCardElement = this.createDataCardIfApplicable(toolName, toolResult); if (dataCardElement) { console.log(`[MessageRenderer] Successfully created DataCard from citation: ${citation.id || 'unknown_id'}`); // Decide where to append this. For now, append after main message content. // This might need a more sophisticated layout strategy. messageElement.appendChild(dataCardElement); } else { console.log(`[MessageRenderer] Did not create DataCard from citation: ${citation.id || 'unknown_id'} (either not visualisable or low confidence)`); } } else { console.log(`[MessageRenderer] Citation ${citation.id || 'unknown_id'} does not seem to contain tool result data for DataCard.`); } }); } else { console.log('[MessageRenderer] Message has no citations or toolCall.result for DataCard processing.'); } return messageElement; } /** * Create the main content element for the message */ private createContentElement(message: MessageData): HTMLElement { const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; // Add role indicator const roleSpan = document.createElement('span'); roleSpan.className = 'message-role'; roleSpan.textContent = this.getRoleDisplayName(message.role); contentDiv.appendChild(roleSpan); // Add message text const textDiv = document.createElement('div'); textDiv.className = 'message-text'; // Process content for markdown or special formatting textDiv.innerHTML = this.processMessageContent(message.content); contentDiv.appendChild(textDiv); // Add tool call info if present if (message.toolCall) { const toolInfoDiv = document.createElement('div'); toolInfoDiv.className = 'tool-call-info'; toolInfoDiv.innerHTML = ` <small class="tool-name">🔧 ${message.toolCall.name}</small> `; contentDiv.appendChild(toolInfoDiv); } return contentDiv; } /** * Create a DataCard if the tool result contains visualisable data */ private createDataCardIfApplicable(toolName: string, toolResult: any): HTMLElement | null { // Modified signature console.log('[MessageRenderer] createDataCardIfApplicable called with toolName:', toolName, 'and toolResult:', JSON.parse(JSON.stringify(toolResult))); // DEV_PLAN 1.25 try { // Detect if the result contains visualisable data const detection = detectDataType(toolResult); console.log('[MessageRenderer] Data detection result:', JSON.parse(JSON.stringify(detection))); // DEV_PLAN 1.24 if (!detection.visualisable || detection.confidence < 0.6) { console.log('[MessageRenderer] Data not visualisable or confidence too low. Visualisable:', detection.visualisable, 'Confidence:', detection.confidence); return null; } // Create container for the data card const cardContainer = document.createElement('div'); cardContainer.className = 'message-data-card'; // Configure the data card const cardConfig: DataCardConfig = { title: this.generateCardTitle(toolName, detection), // Use passed toolName summary: detection.summary, data: toolResult, // Use passed toolResult dataType: detection.dataType, expanded: true, // Start expanded to show charts immediately showChart: true }; console.log('[MessageRenderer] DataCard config:', JSON.parse(JSON.stringify(cardConfig))); // Create and store the data card const dataCard = new DataCard(cardContainer, cardConfig); this.dataCards.push(dataCard); console.log('[MessageRenderer] DataCard created successfully.'); // DEV_PLAN 1.25 return cardContainer; } catch (error) { console.error('Failed to create data card:', error); // Changed to console.error for better visibility // DEV_PLAN 1.25 return null; } } /** * Generate appropriate title for data card based on tool and data type */ private generateCardTitle(toolName: string, detection: DetectionResult): string { const toolDisplayNames: Record<string, string> = { 'get_kline_data': 'Price Chart Data', 'get_ml_rsi': 'ML-Enhanced RSI', 'get_order_blocks': 'Order Blocks Analysis', 'get_market_structure': 'Market Structure', 'get_ticker_info': 'Ticker Information' }; const baseTitle = toolDisplayNames[toolName] || toolName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); // Add data type context if it adds value const dataTypeLabels: Record<string, string> = { 'kline': 'Candlestick Data', 'rsi': 'RSI Analysis', 'orderBlocks': 'Order Blocks', 'price': 'Price Data', 'volume': 'Volume Analysis' }; const dataTypeLabel = dataTypeLabels[detection.dataType]; if (dataTypeLabel && !baseTitle.toLowerCase().includes(dataTypeLabel.toLowerCase())) { return `${baseTitle} - ${dataTypeLabel}`; } return baseTitle; } /** * Create citations element */ private createCitationsElement(citations: any[]): HTMLElement { const citationsDiv = document.createElement('div'); citationsDiv.className = 'message-citations'; citations.forEach((citation, index) => { const citationSpan = document.createElement('span'); citationSpan.className = 'citation'; citationSpan.textContent = `[${index + 1}]`; citationSpan.title = citation.source || citation.url || 'Citation'; citationsDiv.appendChild(citationSpan); }); return citationsDiv; } /** * Get display name for message role */ private getRoleDisplayName(role: string): string { switch (role) { case 'user': return 'You'; case 'assistant': return 'AI'; case 'tool': return 'Tool'; default: return role; } } /** * Process message content for basic formatting and citations */ private processMessageContent(content: string): string { // Process citations first to convert [REF001] patterns to interactive elements const processedMessage = citationProcessor.processMessage(content); let processed = processedMessage.processedContent; // Basic markdown-like processing // Bold text processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>'); // Italic text processed = processed.replace(/\*(.*?)\*/g, '<em>$1</em>'); // Code blocks processed = processed.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>'); // Inline code processed = processed.replace(/`(.*?)`/g, '<code>$1</code>'); // Line breaks processed = processed.replace(/\n/g, '<br>'); return processed; } /** * Clear all rendered content and data cards */ public clear(): void { // Destroy all data cards this.dataCards.forEach(card => card.destroy()); this.dataCards = []; // Clear container this.container.innerHTML = ''; } /** * Get all active data cards */ public getDataCards(): DataCard[] { return [...this.dataCards]; } /** * Expand all data cards */ public expandAllCards(): void { this.dataCards.forEach(card => card.expand()); } /** * Collapse all data cards */ public collapseAllCards(): void { this.dataCards.forEach(card => card.collapse()); } /** * Remove a specific message element */ public removeMessage(messageElement: HTMLElement): void { // Find and destroy associated data cards const cardContainers = messageElement.querySelectorAll('.message-data-card'); cardContainers.forEach(container => { const cardIndex = this.dataCards.findIndex(card => container.contains(card['container']) // Access private container property ); if (cardIndex >= 0) { this.dataCards[cardIndex].destroy(); this.dataCards.splice(cardIndex, 1); } }); // Remove the message element if (messageElement.parentNode) { messageElement.parentNode.removeChild(messageElement); } } /** * Update container reference */ public setContainer(container: HTMLElement): void { this.container = container; } } ``` -------------------------------------------------------------------------------- /src/__tests__/tools.test.ts: -------------------------------------------------------------------------------- ```typescript import { jest, describe, beforeEach, it, expect } from '@jest/globals' import GetTicker from '../tools/GetTicker.js' import GetOrderbook from '../tools/GetOrderbook.js' import GetPositions from '../tools/GetPositions.js' import GetWalletBalance from '../tools/GetWalletBalance.js' import GetInstrumentInfo from '../tools/GetInstrumentInfo.js' import GetKline from '../tools/GetKline.js' import GetOrderHistory from '../tools/GetOrderHistory.js' import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { RestClientV5 } from "bybit-api" type ToolCallRequest = z.infer<typeof CallToolRequestSchema> // Create mock client methods const mockClient = { getTickers: jest.fn(), getOrderbook: jest.fn(), getPositionInfo: jest.fn(), getWalletBalance: jest.fn(), getInstrumentsInfo: jest.fn(), getKline: jest.fn(), getHistoricOrders: jest.fn(), } as any describe('Bybit MCP Tools', () => { const mockSuccessResponse = { retCode: 0, retMsg: 'OK', result: { list: [], }, time: Date.now(), } const mockErrorResponse = { retCode: 10001, // Parameter error - won't trigger retries retMsg: 'Parameter error', result: null, time: Date.now(), } beforeEach(() => { jest.clearAllMocks() }) describe('GetTicker', () => { let getTicker: GetTicker beforeEach(() => { getTicker = new GetTicker(mockClient) }) it('should validate input parameters', async () => { const invalidRequest: ToolCallRequest = { params: { name: 'get_ticker', arguments: { symbol: '123!@#', // Invalid symbol }, }, method: 'tools/call' as const, } const result = await getTicker.toolCall(invalidRequest) expect(result.content[0].type).toBe('text') expect(result.isError).toBe(true) const errorData = JSON.parse(result.content[0].text as string) expect(errorData.category).toBe('VALIDATION') expect(errorData.message).toContain('Invalid input') }) it('should handle successful API response', async () => { const request: ToolCallRequest = { params: { name: 'get_ticker', arguments: { symbol: 'BTCUSDT', category: 'spot', }, }, method: 'tools/call' as const, }; const mockTickerResponse = { retCode: 0, retMsg: 'OK', result: { list: [{ symbol: 'BTCUSDT', lastPrice: '50000.00', price24hPcnt: '0.0250', highPrice24h: '51000.00', lowPrice24h: '49000.00', prevPrice24h: '48800.00', volume24h: '1000.50', turnover24h: '50000000.00', bid1Price: '49999.50', bid1Size: '0.1', ask1Price: '50000.50', ask1Size: '0.1' }] }, time: Date.now(), }; (mockClient.getTickers as jest.Mock).mockResolvedValueOnce(mockTickerResponse) const result = await getTicker.toolCall(request) expect(result.content[0].type).toBe('text') expect(JSON.parse(result.content[0].text as string)).toHaveProperty('symbol', 'BTCUSDT') }) it('should handle API errors', async () => { const request: ToolCallRequest = { params: { name: 'get_ticker', arguments: { symbol: 'BTCUSDT', }, }, method: 'tools/call' as const, }; // Mock the error response for all retry attempts to avoid infinite retry loop (mockClient.getTickers as jest.Mock).mockResolvedValue(mockErrorResponse) const result = await getTicker.toolCall(request) expect(result.content[0].type).toBe('text') expect(result.isError).toBe(true) const errorData = JSON.parse(result.content[0].text as string) expect(errorData.category).toBe('VALIDATION') expect(errorData.message).toContain('Parameter error') }) }) describe('GetOrderbook', () => { let getOrderbook: GetOrderbook beforeEach(() => { getOrderbook = new GetOrderbook(mockClient) }) it('should validate input parameters', async () => { const invalidRequest: ToolCallRequest = { params: { name: 'get_orderbook', arguments: { symbol: '', // Empty symbol }, }, method: 'tools/call' as const, } const result = await getOrderbook.toolCall(invalidRequest) expect(result.content[0].type).toBe('text') expect(result.isError).toBe(true) const errorData = JSON.parse(result.content[0].text as string) expect(errorData.category).toBe('VALIDATION') }) it('should handle successful API response', async () => { const request: ToolCallRequest = { params: { name: 'get_orderbook', arguments: { symbol: 'BTCUSDT', category: 'spot', }, }, method: 'tools/call' as const, }; const mockOrderbookResponse = { retCode: 0, retMsg: 'OK', result: { s: 'BTCUSDT', b: [['49999.50', '0.1'], ['49999.00', '0.2']], a: [['50000.50', '0.1'], ['50001.00', '0.2']], ts: Date.now(), u: 12345 }, time: Date.now(), }; (mockClient.getOrderbook as jest.Mock).mockResolvedValueOnce(mockOrderbookResponse) const result = await getOrderbook.toolCall(request) expect(result.content[0].type).toBe('text') }) }) describe('GetPositions', () => { let getPositions: GetPositions beforeEach(() => { getPositions = new GetPositions(mockClient) }) it('should validate input parameters', async () => { const invalidRequest: ToolCallRequest = { params: { name: 'get_positions', arguments: { category: 'invalid', // Invalid category }, }, method: 'tools/call' as const, } const result = await getPositions.toolCall(invalidRequest) expect(result.content[0].type).toBe('text') expect(result.isError).toBe(true) const errorData = JSON.parse(result.content[0].text as string) expect(errorData.category).toBe('VALIDATION') }) it('should handle successful API response', async () => { const request: ToolCallRequest = { params: { name: 'get_positions', arguments: { category: 'linear', }, }, method: 'tools/call' as const, }; (mockClient.getPositionInfo as jest.Mock).mockResolvedValueOnce(mockSuccessResponse) const result = await getPositions.toolCall(request) expect(result.content[0].type).toBe('text') }) }) describe('GetWalletBalance', () => { let getWalletBalance: GetWalletBalance beforeEach(() => { getWalletBalance = new GetWalletBalance(mockClient) }) it('should validate input parameters', async () => { const invalidRequest: ToolCallRequest = { params: { name: 'get_wallet_balance', arguments: { accountType: 'invalid', // Invalid account type }, }, method: 'tools/call' as const, } const result = await getWalletBalance.toolCall(invalidRequest) expect(result.content[0].type).toBe('text') expect(result.isError).toBe(true) const errorData = JSON.parse(result.content[0].text as string) expect(errorData.category).toBe('AUTHENTICATION') // Auth check happens before validation }) it('should handle successful API response', async () => { const request: ToolCallRequest = { params: { name: 'get_wallet_balance', arguments: { accountType: 'UNIFIED', }, }, method: 'tools/call' as const, }; (mockClient.getWalletBalance as jest.Mock).mockResolvedValueOnce(mockSuccessResponse) const result = await getWalletBalance.toolCall(request) expect(result.content[0].type).toBe('text') }) }) describe('Rate Limiting', () => { let getTicker: GetTicker beforeEach(() => { getTicker = new GetTicker(mockClient) }) it('should handle rate limiting', async () => { const request: ToolCallRequest = { params: { name: 'get_ticker', arguments: { symbol: 'BTCUSDT', }, }, method: 'tools/call' as const, } // Mock successful responses for all requests const mockTickerResponse = { retCode: 0, retMsg: 'OK', result: { list: [{ symbol: 'BTCUSDT', lastPrice: '50000.00', price24hPcnt: '0.0250', highPrice24h: '51000.00', lowPrice24h: '49000.00', prevPrice24h: '48800.00', volume24h: '1000.50', turnover24h: '50000000.00', bid1Price: '49999.50', bid1Size: '0.1', ask1Price: '50000.50', ask1Size: '0.1' }] }, time: Date.now(), }; (mockClient.getTickers as jest.Mock).mockResolvedValue(mockTickerResponse) // Mock multiple rapid requests const promises = Array(15).fill(null).map(() => getTicker.toolCall(request)) const results = await Promise.all(promises) // Verify that some requests were rate limited or successful const errors = results.filter(r => r.isError === true) const successes = results.filter(r => r.isError !== true) // At least some should succeed, and rate limiting should be handled gracefully expect(successes.length).toBeGreaterThan(0) }) }) // Add similar test blocks for remaining tools describe('GetInstrumentInfo', () => { let getInstrumentInfo: GetInstrumentInfo beforeEach(() => { getInstrumentInfo = new GetInstrumentInfo(mockClient) }) it('should handle successful API response', async () => { const request: ToolCallRequest = { params: { name: 'get_instrument_info', arguments: { category: 'spot', symbol: 'BTCUSDT', }, }, method: 'tools/call' as const, }; (mockClient.getInstrumentsInfo as jest.Mock).mockResolvedValueOnce(mockSuccessResponse) const result = await getInstrumentInfo.toolCall(request) expect(result.content[0].type).toBe('text') }) }) describe('GetKline', () => { let getKline: GetKline beforeEach(() => { getKline = new GetKline(mockClient) }) it('should handle successful API response', async () => { const request: ToolCallRequest = { params: { name: 'get_kline', arguments: { category: 'spot', symbol: 'BTCUSDT', interval: '1', }, }, method: 'tools/call' as const, }; (mockClient.getKline as jest.Mock).mockResolvedValueOnce(mockSuccessResponse) const result = await getKline.toolCall(request) expect(result.content[0].type).toBe('text') }) }) describe('GetOrderHistory', () => { let getOrderHistory: GetOrderHistory beforeEach(() => { getOrderHistory = new GetOrderHistory(mockClient) }) it('should handle successful API response', async () => { const request: ToolCallRequest = { params: { name: 'get_order_history', arguments: { category: 'spot', }, }, method: 'tools/call' as const, }; (mockClient.getHistoricOrders as jest.Mock).mockResolvedValueOnce(mockSuccessResponse) const result = await getOrderHistory.toolCall(request) expect(result.content[0].type).toBe('text') }) }) }) ``` -------------------------------------------------------------------------------- /client/src/client.ts: -------------------------------------------------------------------------------- ```typescript import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { Ollama } from 'ollama' import { Config } from './config.js' import type { Tool, TextContent, ImageContent, CallToolResult, } from '@modelcontextprotocol/sdk/types.js' import { fileURLToPath } from 'url' import { dirname, join } from 'path' import { spawn, type ChildProcess } from 'child_process' import { existsSync } from 'fs' // Define a simpler Message type for Ollama compatibility export interface Message { role: 'system' | 'user' | 'assistant' content: string } export interface ServerProcess { process: ChildProcess kill: () => void } class RequestQueue { private queue: (() => Promise<any>)[] = [] private processing: boolean = false async enqueue<T>(request: () => Promise<T>): Promise<T> { return new Promise((resolve, reject) => { this.queue.push(async () => { try { const result = await this.executeWithRetry(request) resolve(result) } catch (error) { reject(error) } }) this.processQueue() }) } private async executeWithRetry(request: () => Promise<any>, retries = 3, delay = 1000): Promise<any> { for (let attempt = 1; attempt <= retries; attempt++) { try { return await request() } catch (error) { if (attempt === retries) throw error if (error instanceof Error && error.message.includes('model not found')) throw error await new Promise(resolve => setTimeout(resolve, delay * attempt)) } } } private async processQueue() { if (this.processing || this.queue.length === 0) return this.processing = true while (this.queue.length > 0) { const request = this.queue.shift() if (request) { try { await request() } catch (error) { console.error('Error processing request:', error) } } } this.processing = false } } export class BybitMcpClient { private mcpClient: Client private ollama: Ollama private config: Config private serverProcess: ServerProcess | null = null private availableTools: Tool[] = [] private modelValidated: boolean = false private requestQueue: RequestQueue constructor(config: Config) { this.config = config this.requestQueue = new RequestQueue() this.mcpClient = new Client({ name: 'bybit-mcp-client', version: '0.1.0' }, { capabilities: { roots: { listChanged: true }, sampling: {} } }) const ollamaHost = config.get('ollamaHost') if (!ollamaHost) { throw new Error('OLLAMA_HOST is not configured') } this.ollama = new Ollama({ host: ollamaHost }) } private async validateModel(): Promise<void> { if (this.modelValidated) { return } const model = this.config.get('defaultModel') try { const response = await this.requestQueue.enqueue(() => this.ollama.list()) const modelExists = response.models.some(m => m.name === model) if (!modelExists) { const ollamaHost = this.config.get('ollamaHost') throw new Error( `Model "${model}" not found on Ollama server at ${ollamaHost}.\n` + `Available models: ${response.models.map(m => m.name).join(', ')}\n\n` + `To pull the required model, run:\n` + `curl -X POST ${ollamaHost}/api/pull -d '{"name": "${model}"}'` ) } this.modelValidated = true } catch (error) { throw new Error(`Failed to validate model: ${error}`) } } private getServerPath(): string { // When running as part of bybit-mcp repository const repoServerPath = join(process.cwd(), '..', 'build', 'index.js') // When installed as a package const packageServerPath = join( dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'build', 'index.js' ) // Check which path exists and is executable try { if (existsSync(repoServerPath)) { return repoServerPath } if (existsSync(packageServerPath)) { return packageServerPath } } catch (error) { console.error('Error finding server path:', error) } throw new Error('Could not find bybit-mcp server. Please ensure it is installed correctly.') } async startIntegratedServer(): Promise<void> { if (this.serverProcess) { throw new Error('Server is already running') } const serverPath = this.getServerPath() const serverProcess = spawn('node', [serverPath], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, DEVELOPMENT_MODE: 'true', NODE_ENV: 'production' } }) // Handle server output serverProcess.stdout?.on('data', (data: Buffer) => { if (this.config.get('debug')) { console.log('[Server]:', data.toString()) } }) serverProcess.stderr?.on('data', (data: Buffer) => { if (this.config.get('debug')) { console.error('[Server Error]:', data.toString()) } }) // Handle server exit serverProcess.on('exit', (code: number | null) => { if (code !== 0 && this.config.get('debug')) { console.error(`Server exited with code ${code}`) } this.serverProcess = null }) this.serverProcess = { process: serverProcess, kill: () => { serverProcess.kill() this.serverProcess = null } } // Connect to the server const transport = new StdioClientTransport({ command: 'node', args: [serverPath] }) await this.mcpClient.connect(transport) // Cache available tools const response = await this.mcpClient.listTools() this.availableTools = response.tools // Validate model availability once at startup await this.validateModel() } async connectToServer(command: string): Promise<void> { const transport = new StdioClientTransport({ command, args: [] }) await this.mcpClient.connect(transport) // Cache available tools const response = await this.mcpClient.listTools() this.availableTools = response.tools // Validate model availability once at startup await this.validateModel() } async listTools(): Promise<Tool[]> { return this.availableTools } async callTool(toolName: string, args: Record<string, unknown>): Promise<string> { const response = await this.mcpClient.callTool({ name: toolName, arguments: args }) as CallToolResult if (!response.content?.[0]) { throw new Error('Invalid response from tool') } const content = response.content[0] if (content.type !== 'text') { throw new Error(`Unexpected content type: ${content.type}`) } if (response.isError) { throw new Error(content.text) } return content.text } private generateSystemPrompt(userSystemMessage?: string): string { let systemPrompt = `You are a helpful assistant with access to real-time cryptocurrency data through the bybit-mcp server. You have access to the following tools: ${this.availableTools.map(tool => { const schema = tool.inputSchema as { properties?: Record<string, any>, required?: string[] } const required = schema.required || [] const properties = Object.entries(schema.properties || {}).map(([name, prop]) => { const isRequired = required.includes(name) const annotations = (prop as any).annotations || {} return ` ${name}${isRequired ? ' (required)' : ''}: ${prop.description || 'No description'} ${annotations.priority === 1 ? '(high priority)' : ''}` }).join('\n') return `${tool.name}: Description: ${tool.description || 'No description provided'} Parameters: ${properties} ` }).join('\n')} When a user asks about cryptocurrency data, you MUST use these tools to provide real-time information. For example: - Use get_ticker to get current price information - Use get_orderbook to see current buy/sell orders - Use get_kline to view price history - Use get_trades to see recent trades To use a tool, format your response like this: <tool>get_ticker</tool> <arguments> { "category": "spot", "symbol": "BTCUSDT" } </arguments> ` if (userSystemMessage) { systemPrompt += `\n\nAdditional Context: ${userSystemMessage}` } return systemPrompt } private async handleToolUsage(response: string): Promise<string | null> { const toolMatch = response.match(/<tool>(.*?)<\/tool>/s) const argsMatch = response.match(/<arguments>(.*?)<\/arguments>/s) if (toolMatch && argsMatch) { const toolName = toolMatch[1].trim() try { const args = JSON.parse(argsMatch[1].trim()) const result = await this.callTool(toolName, args) return result } catch (error) { console.error(`Error executing tool ${toolName}:`, error) return `Error executing tool ${toolName}: ${error instanceof Error ? error.message : String(error)}` } } return null } async chat(model: string, messages: Message[]): Promise<string> { // Create a copy of messages to avoid modifying the input const messagesCopy = [...messages] // If there's no system message, add one with tool information if (!messagesCopy.some(m => m.role === 'system')) { messagesCopy.unshift({ role: 'system', content: this.generateSystemPrompt() }) } const response = await this.requestQueue.enqueue(() => this.ollama.chat({ model, messages: messagesCopy, stream: false }).then(response => response.message.content) ) // Check if the response contains a tool usage request const toolResult = await this.handleToolUsage(response) if (toolResult) { // Add the tool result to the conversation and get a new response messagesCopy.push({ role: 'assistant', content: response }) messagesCopy.push({ role: 'system', content: `Tool result: ${toolResult}` }) return this.requestQueue.enqueue(() => this.ollama.chat({ model, messages: messagesCopy, stream: false }).then(response => response.message.content) ) } return response } async streamChat( model: string, messages: Message[], onToken: (token: string) => void ): Promise<void> { // Create a copy of messages to avoid modifying the input const messagesCopy = [...messages] // If there's no system message, add one with tool information if (!messagesCopy.some(m => m.role === 'system')) { messagesCopy.unshift({ role: 'system', content: this.generateSystemPrompt() }) } let fullResponse = '' await this.requestQueue.enqueue(async () => { for await (const chunk of await this.ollama.chat({ model, messages: messagesCopy, stream: true })) { if (chunk.message?.content) { fullResponse += chunk.message.content onToken(chunk.message.content) } } }) // Check if the response contains a tool usage request const toolResult = await this.handleToolUsage(fullResponse) if (toolResult) { // Add the tool result to the conversation and get a new response messagesCopy.push({ role: 'assistant', content: fullResponse }) messagesCopy.push({ role: 'system', content: `Tool result: ${toolResult}` }) onToken('\n\nTool result: ' + toolResult + '\n\nProcessing result...\n\n') await this.requestQueue.enqueue(async () => { for await (const chunk of await this.ollama.chat({ model, messages: messagesCopy, stream: true })) { if (chunk.message?.content) { onToken(chunk.message.content) } } }) } } async listModels(): Promise<string[]> { const response = await this.requestQueue.enqueue(() => this.ollama.list()) return response.models.map(model => model.name) } async close(): Promise<void> { if (this.serverProcess) { this.serverProcess.kill() } await this.mcpClient.close() } isIntegrated(): boolean { return this.serverProcess !== null } } ``` -------------------------------------------------------------------------------- /webui/src/services/agentMemory.ts: -------------------------------------------------------------------------------- ```typescript /** * Agent Memory Service - Manages conversation memory, market context, and analysis history */ import type { ChatMessage } from '@/types/ai'; export interface ConversationMemory { id: string; timestamp: number; messages: ChatMessage[]; summary?: string; topics: string[]; symbols: string[]; analysisType?: 'quick' | 'standard' | 'comprehensive'; } export interface MarketContext { symbol: string; lastPrice?: number; priceChange24h?: number; volume24h?: number; lastUpdated: number; technicalIndicators?: { rsi?: number; macd?: any; orderBlocks?: any[]; }; sentiment?: 'bullish' | 'bearish' | 'neutral'; keyLevels?: { support: number[]; resistance: number[]; }; } export interface AnalysisHistory { id: string; timestamp: number; symbol: string; analysisType: 'quick' | 'standard' | 'comprehensive'; query: string; response: string; toolsUsed: string[]; duration: number; accuracy?: number; // User feedback on accuracy relevance?: number; // User feedback on relevance } export class AgentMemoryService { private static readonly CONVERSATION_STORAGE_KEY = 'bybit-mcp-conversations'; private static readonly MARKET_CONTEXT_STORAGE_KEY = 'bybit-mcp-market-context'; private static readonly ANALYSIS_HISTORY_STORAGE_KEY = 'bybit-mcp-analysis-history'; private static readonly MAX_CONVERSATIONS = 50; private static readonly MAX_ANALYSIS_HISTORY = 100; private static readonly CONTEXT_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours private conversations: ConversationMemory[] = []; private marketContexts: Map<string, MarketContext> = new Map(); private analysisHistory: AnalysisHistory[] = []; constructor() { this.loadFromStorage(); this.cleanupExpiredData(); } // Conversation Memory Management /** * Start a new conversation */ startConversation(initialMessage?: ChatMessage): string { const conversationId = this.generateId(); const conversation: ConversationMemory = { id: conversationId, timestamp: Date.now(), messages: initialMessage ? [initialMessage] : [], topics: [], symbols: [] }; this.conversations.unshift(conversation); this.trimConversations(); this.saveConversations(); return conversationId; } /** * Add message to current conversation */ addMessage(conversationId: string, message: ChatMessage): void { const conversation = this.conversations.find(c => c.id === conversationId); if (!conversation) { console.warn(`Conversation ${conversationId} not found`); return; } conversation.messages.push(message); // Extract symbols and topics from message content if (message.content) { this.extractSymbolsAndTopics(message.content, conversation); } this.saveConversations(); } /** * Get conversation by ID */ getConversation(conversationId: string): ConversationMemory | undefined { return this.conversations.find(c => c.id === conversationId); } /** * Get recent conversations */ getRecentConversations(limit: number = 10): ConversationMemory[] { return this.conversations.slice(0, limit); } /** * Get conversation context for a symbol */ getSymbolContext(symbol: string, limit: number = 5): ChatMessage[] { const relevantMessages: ChatMessage[] = []; for (const conversation of this.conversations) { if (conversation.symbols.includes(symbol.toUpperCase())) { relevantMessages.push(...conversation.messages); if (relevantMessages.length >= limit * 2) break; // Get more than needed to filter } } // Filter for most relevant messages return relevantMessages .filter(msg => msg.content?.toLowerCase().includes(symbol.toLowerCase())) .slice(0, limit); } // Market Context Management /** * Update market context for a symbol */ updateMarketContext(symbol: string, context: Partial<MarketContext>): void { const existing = this.marketContexts.get(symbol.toUpperCase()) || { symbol: symbol.toUpperCase(), lastUpdated: Date.now() }; const updated: MarketContext = { ...existing, ...context, lastUpdated: Date.now() }; this.marketContexts.set(symbol.toUpperCase(), updated); this.saveMarketContexts(); } /** * Get market context for a symbol */ getMarketContext(symbol: string): MarketContext | undefined { const context = this.marketContexts.get(symbol.toUpperCase()); // Check if context is still fresh if (context && Date.now() - context.lastUpdated < AgentMemoryService.CONTEXT_EXPIRY_MS) { return context; } return undefined; } /** * Get all market contexts */ getAllMarketContexts(): MarketContext[] { const now = Date.now(); return Array.from(this.marketContexts.values()) .filter(context => now - context.lastUpdated < AgentMemoryService.CONTEXT_EXPIRY_MS); } // Analysis History Management /** * Record an analysis */ recordAnalysis(analysis: Omit<AnalysisHistory, 'id' | 'timestamp'>): string { const analysisId = this.generateId(); const record: AnalysisHistory = { id: analysisId, timestamp: Date.now(), ...analysis }; this.analysisHistory.unshift(record); this.trimAnalysisHistory(); this.saveAnalysisHistory(); return analysisId; } /** * Get analysis history for a symbol */ getSymbolAnalysisHistory(symbol: string, limit: number = 10): AnalysisHistory[] { return this.analysisHistory .filter(analysis => analysis.symbol.toUpperCase() === symbol.toUpperCase()) .slice(0, limit); } /** * Get recent analysis history */ getRecentAnalysisHistory(limit: number = 20): AnalysisHistory[] { return this.analysisHistory.slice(0, limit); } /** * Update analysis feedback */ updateAnalysisFeedback(analysisId: string, accuracy?: number, relevance?: number): void { const analysis = this.analysisHistory.find(a => a.id === analysisId); if (analysis) { if (accuracy !== undefined) analysis.accuracy = accuracy; if (relevance !== undefined) analysis.relevance = relevance; this.saveAnalysisHistory(); } } // Context Building for AI /** * Build context summary for AI prompt */ buildContextSummary(symbol?: string): string { const contextParts: string[] = []; // Add market context if available if (symbol) { const marketContext = this.getMarketContext(symbol); if (marketContext) { contextParts.push(`Recent ${symbol} context: Price $${marketContext.lastPrice}, 24h change ${marketContext.priceChange24h}%`); if (marketContext.sentiment) { contextParts.push(`Market sentiment: ${marketContext.sentiment}`); } if (marketContext.technicalIndicators?.rsi) { contextParts.push(`RSI: ${marketContext.technicalIndicators.rsi}`); } } // Add recent analysis patterns const recentAnalyses = this.getSymbolAnalysisHistory(symbol, 3); if (recentAnalyses.length > 0) { const avgAccuracy = recentAnalyses .filter(a => a.accuracy !== undefined) .reduce((sum, a) => sum + (a.accuracy || 0), 0) / recentAnalyses.length; if (avgAccuracy > 0) { contextParts.push(`Recent analysis accuracy: ${(avgAccuracy * 100).toFixed(1)}%`); } } } return contextParts.length > 0 ? `\nContext: ${contextParts.join('. ')}` : ''; } // Utility Methods /** * Extract symbols and topics from message content */ private extractSymbolsAndTopics(content: string, conversation: ConversationMemory): void { // Extract crypto symbols (BTC, ETH, etc.) const symbolMatches = content.match(/\b[A-Z]{2,5}(?:USD|USDT|BTC|ETH)?\b/g); if (symbolMatches) { symbolMatches.forEach(symbol => { const cleanSymbol = symbol.replace(/(USD|USDT|BTC|ETH)$/, ''); if (cleanSymbol.length >= 2 && !conversation.symbols.includes(cleanSymbol)) { conversation.symbols.push(cleanSymbol); } }); } // Extract topics (price, analysis, technical, etc.) const topicKeywords = ['price', 'analysis', 'technical', 'support', 'resistance', 'trend', 'volume', 'rsi', 'macd']; topicKeywords.forEach(keyword => { if (content.toLowerCase().includes(keyword) && !conversation.topics.includes(keyword)) { conversation.topics.push(keyword); } }); } /** * Generate unique ID */ private generateId(): string { return Date.now().toString(36) + Math.random().toString(36).substr(2); } /** * Trim conversations to max limit */ private trimConversations(): void { if (this.conversations.length > AgentMemoryService.MAX_CONVERSATIONS) { this.conversations = this.conversations.slice(0, AgentMemoryService.MAX_CONVERSATIONS); } } /** * Trim analysis history to max limit */ private trimAnalysisHistory(): void { if (this.analysisHistory.length > AgentMemoryService.MAX_ANALYSIS_HISTORY) { this.analysisHistory = this.analysisHistory.slice(0, AgentMemoryService.MAX_ANALYSIS_HISTORY); } } /** * Clean up expired data */ private cleanupExpiredData(): void { const now = Date.now(); // Remove expired market contexts for (const [symbol, context] of this.marketContexts.entries()) { if (now - context.lastUpdated > AgentMemoryService.CONTEXT_EXPIRY_MS) { this.marketContexts.delete(symbol); } } // Remove old conversations (older than 7 days) const weekAgo = now - (7 * 24 * 60 * 60 * 1000); this.conversations = this.conversations.filter(c => c.timestamp > weekAgo); // Remove old analysis history (older than 30 days) const monthAgo = now - (30 * 24 * 60 * 60 * 1000); this.analysisHistory = this.analysisHistory.filter(a => a.timestamp > monthAgo); } // Storage Methods private loadFromStorage(): void { try { const conversationsData = localStorage.getItem(AgentMemoryService.CONVERSATION_STORAGE_KEY); if (conversationsData) { this.conversations = JSON.parse(conversationsData); } const marketContextData = localStorage.getItem(AgentMemoryService.MARKET_CONTEXT_STORAGE_KEY); if (marketContextData) { const contexts = JSON.parse(marketContextData); this.marketContexts = new Map(Object.entries(contexts)); } const analysisHistoryData = localStorage.getItem(AgentMemoryService.ANALYSIS_HISTORY_STORAGE_KEY); if (analysisHistoryData) { this.analysisHistory = JSON.parse(analysisHistoryData); } } catch (error) { console.warn('Failed to load memory data from storage:', error); } } private saveConversations(): void { try { localStorage.setItem(AgentMemoryService.CONVERSATION_STORAGE_KEY, JSON.stringify(this.conversations)); } catch (error) { console.warn('Failed to save conversations to storage:', error); } } private saveMarketContexts(): void { try { const contextsObj = Object.fromEntries(this.marketContexts); localStorage.setItem(AgentMemoryService.MARKET_CONTEXT_STORAGE_KEY, JSON.stringify(contextsObj)); } catch (error) { console.warn('Failed to save market contexts to storage:', error); } } private saveAnalysisHistory(): void { try { localStorage.setItem(AgentMemoryService.ANALYSIS_HISTORY_STORAGE_KEY, JSON.stringify(this.analysisHistory)); } catch (error) { console.warn('Failed to save analysis history to storage:', error); } } /** * Clear all memory data */ clearAllMemory(): void { this.conversations = []; this.marketContexts.clear(); this.analysisHistory = []; localStorage.removeItem(AgentMemoryService.CONVERSATION_STORAGE_KEY); localStorage.removeItem(AgentMemoryService.MARKET_CONTEXT_STORAGE_KEY); localStorage.removeItem(AgentMemoryService.ANALYSIS_HISTORY_STORAGE_KEY); } /** * Get memory statistics */ getMemoryStats() { return { conversations: this.conversations.length, marketContexts: this.marketContexts.size, analysisHistory: this.analysisHistory.length, totalSymbols: new Set([ ...Array.from(this.marketContexts.keys()), ...this.conversations.flatMap(c => c.symbols) ]).size }; } } // Singleton instance export const agentMemory = new AgentMemoryService(); ```