#
tokens: 48543/50000 20/101 files (page 2/6)
lines: off (toggle) GitHub
raw markdown copy
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();

```
Page 2/6FirstPrevNextLast