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();
```