Skip to main content
Glama

CCXT MCP Server

analysis-tools.ts23.8 kB
/** * 거래 분석 관련 도구들을 정의합니다. * 이 파일은 거래 성과 분석, 승률, 수익률 계산 등의 기능을 제공합니다. */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { CcxtMcpServer } from "../server.js"; /** * 거래 분석 관련 도구들을 서버에 등록합니다. */ export function registerAnalysisTools( server: McpServer, ccxtServer: CcxtMcpServer ) { // 거래 성과 요약 분석 도구 server.tool( "analyzeTradingPerformance", "Analyze trading performance for a configured account", { accountName: z .string() .describe( "Account name defined in the configuration file (e.g., 'bybit_main')" ), symbol: z .string() .optional() .describe("Optional trading symbol (e.g., 'BTC/USDT') to filter trades"), period: z .enum(["7d", "30d", "90d", "all"]) .default("30d") .describe("Analysis period: '7d', '30d', '90d', or 'all'"), }, async ({ accountName, symbol, period }) => { try { const exchange = ccxtServer.getExchangeInstance(accountName); // fetchMyTrades 메서드가 지원되는지 확인 if (!exchange.has["fetchMyTrades"]) { return { content: [ { type: "text", text: `Account '${accountName}' (Exchange: ${exchange.id}) does not support fetching personal trades for analysis`, }, ], isError: true, }; } // 기간에 따른 since 값 계산 const now = Date.now(); let since; switch (period) { case "7d": since = now - 7 * 24 * 60 * 60 * 1000; // 7일 break; case "30d": since = now - 30 * 24 * 60 * 60 * 1000; // 30일 break; case "90d": since = now - 90 * 24 * 60 * 60 * 1000; // 90일 break; case "all": since = undefined; // 전체 데이터 break; } // 거래 데이터 가져오기 const trades = await exchange.fetchMyTrades(symbol, since, undefined); // 기본 분석 지표 계산 const analysis = analyzeTradeData(trades, period); return { content: [ { type: "text", text: JSON.stringify(analysis, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error analyzing trading performance for account '${accountName}': ${ (error as Error).message }`, }, ], isError: true, }; } } ); // 승률 및 수익률 분석 도구 server.tool( "calculateWinRate", "Calculate win rate and profit metrics for a configured account", { accountName: z .string() .describe( "Account name defined in the configuration file (e.g., 'bybit_main')" ), symbol: z .string() .optional() .describe("Optional trading symbol (e.g., 'BTC/USDT') to filter trades"), period: z .enum(["7d", "30d", "all"]) .default("30d") .describe("Analysis period: '7d', '30d', or 'all'"), }, async ({ accountName, symbol, period }) => { try { const exchange = ccxtServer.getExchangeInstance(accountName); // fetchMyTrades 메서드가 지원되는지 확인 if (!exchange.has["fetchMyTrades"]) { return { content: [ { type: "text", text: `Account '${accountName}' (Exchange: ${exchange.id}) does not support fetching personal trades for win rate calculation`, }, ], isError: true, }; } // 기간에 따른 since 값 계산 const now = Date.now(); let since; switch (period) { case "7d": since = now - 7 * 24 * 60 * 60 * 1000; // 7일 break; case "30d": since = now - 30 * 24 * 60 * 60 * 1000; // 30일 break; case "all": since = undefined; // 전체 데이터 break; } // 거래 데이터 가져오기 const trades = await exchange.fetchMyTrades(symbol, since, undefined); // 승률 및 수익률 계산 const metrics = calculateWinRateAndProfitMetrics(trades); return { content: [ { type: "text", text: JSON.stringify(metrics, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error calculating win rate for account '${accountName}': ${ (error as Error).message }`, }, ], isError: true, }; } } ); // 최대 연속 손실/이익 분석 도구 server.tool( "analyzeConsecutiveProfitLoss", "Analyze consecutive winning and losing trades", { accountName: z .string() .describe( "Account name defined in the configuration file (e.g., 'bybit_main')" ), symbol: z .string() .optional() .describe("Optional trading symbol (e.g., 'BTC/USDT') to filter trades"), period: z .enum(["30d", "all"]) .default("all") .describe("Analysis period: '30d' or 'all'"), }, async ({ accountName, symbol, period }) => { try { const exchange = ccxtServer.getExchangeInstance(accountName); // fetchMyTrades 메서드가 지원되는지 확인 if (!exchange.has["fetchMyTrades"]) { return { content: [ { type: "text", text: `Account '${accountName}' (Exchange: ${exchange.id}) does not support fetching personal trades for consecutive analysis`, }, ], isError: true, }; } // 기간에 따른 since 값 계산 const now = Date.now(); let since; switch (period) { case "30d": since = now - 30 * 24 * 60 * 60 * 1000; // 30일 break; case "all": default: since = undefined; // 전체 데이터 break; } // 거래 내역 가져오기 const trades = await exchange.fetchMyTrades(symbol, since, undefined); // 최대 연속 손실/이익 계산 const analysis = analyzeConsecutiveTrades(trades); return { content: [ { type: "text", text: JSON.stringify(analysis, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error analyzing consecutive trades for account '${accountName}': ${ (error as Error).message }`, }, ], isError: true, }; } } ); // 월간/일간 수익률 분석 도구 server.tool( "analyzePeriodicReturns", "Analyze daily and monthly returns for a configured account", { accountName: z .string() .describe( "Account name defined in the configuration file (e.g., 'bybit_main')" ), symbol: z .string() .optional() .describe("Optional trading symbol (e.g., 'BTC/USDT') to filter trades"), period: z .enum(["30d", "90d", "180d", "1y"]) .default("90d") .describe("Analysis period: '30d', '90d', '180d', or '1y'"), interval: z .enum(["daily", "weekly", "monthly"]) .default("daily") .describe("Return calculation interval"), }, async ({ accountName, symbol, period, interval }) => { try { const exchange = ccxtServer.getExchangeInstance(accountName); // fetchMyTrades 메서드가 지원되는지 확인 if (!exchange.has["fetchMyTrades"]) { return { content: [ { type: "text", text: `Account '${accountName}' (Exchange: ${exchange.id}) does not support fetching personal trades for periodic returns analysis`, }, ], isError: true, }; } // 기간에 따른 since 값 계산 const now = Date.now(); let since; switch (period) { case "30d": since = now - 30 * 24 * 60 * 60 * 1000; break; case "90d": since = now - 90 * 24 * 60 * 60 * 1000; break; case "180d": since = now - 180 * 24 * 60 * 60 * 1000; break; case "1y": since = now - 365 * 24 * 60 * 60 * 1000; break; } // 거래 내역 가져오기 const trades = await exchange.fetchMyTrades(symbol, since, undefined); // 기간별 수익률 계산 const returns = calculatePeriodicReturns(trades, interval); return { content: [ { type: "text", text: JSON.stringify(returns, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error analyzing periodic returns for account '${accountName}': ${ (error as Error).message }`, }, ], isError: true, }; } } ); } /** * 거래 데이터를 분석하여 성과 지표를 계산합니다. */ function analyzeTradeData(trades: any[], period: string) { if (!trades || trades.length === 0) { return { period, totalTrades: 0, message: "No trades found for the specified period.", }; } // 거래 데이터 정렬 (시간순) trades.sort((a, b) => a.timestamp - b.timestamp); // 기본 지표 계산 let totalProfit = 0; let totalLoss = 0; let winCount = 0; let lossCount = 0; let totalFees = 0; let largestWin = 0; let largestLoss = 0; let totalVolume = 0; // 거래별 분석 trades.forEach((trade) => { // 수수료 계산 const fee = trade.fee?.cost || 0; totalFees += fee; // 거래량 계산 totalVolume += trade.amount * trade.price; // 손익 계산 (단순화된 버전 - 실제로는 포지션 추적 필요) const cost = trade.amount * trade.price; const profit = trade.side === "buy" ? -cost : cost; // 매우 단순화된 계산 if (profit > 0) { totalProfit += profit; winCount++; largestWin = Math.max(largestWin, profit); } else if (profit < 0) { totalLoss += Math.abs(profit); lossCount++; largestLoss = Math.max(largestLoss, Math.abs(profit)); } }); const totalTrades = trades.length; const winRate = totalTrades > 0 ? (winCount / totalTrades) * 100 : 0; const netProfit = totalProfit - totalLoss - totalFees; const profitFactor = totalLoss > 0 ? totalProfit / totalLoss : totalProfit > 0 ? Infinity : 0; // 거래 패턴 분석 const firstTradeDate = new Date(trades[0].timestamp); const lastTradeDate = new Date(trades[trades.length - 1].timestamp); const tradingDurationDays = (lastTradeDate.getTime() - firstTradeDate.getTime()) / (1000 * 60 * 60 * 24); const tradesPerDay = tradingDurationDays > 0 ? totalTrades / tradingDurationDays : totalTrades; return { period, totalTrades, winCount, lossCount, winRate: winRate.toFixed(2) + "%", totalProfit: totalProfit.toFixed(8), totalLoss: totalLoss.toFixed(8), netProfit: netProfit.toFixed(8), totalFees: totalFees.toFixed(8), profitFactor: profitFactor.toFixed(2), largestWin: largestWin.toFixed(8), largestLoss: largestLoss.toFixed(8), totalVolume: totalVolume.toFixed(2), tradingPeriod: { firstTrade: firstTradeDate.toISOString(), lastTrade: lastTradeDate.toISOString(), durationDays: tradingDurationDays.toFixed(1), tradesPerDay: tradesPerDay.toFixed(1), } }; } /** * 거래 데이터를 분석하여 승률과 수익률 지표를 계산합니다. */ function calculateWinRateAndProfitMetrics(trades: any[]) { if (!trades || trades.length === 0) { return { totalTrades: 0, message: "No trades found for the specified period.", }; } // 거래를 시간순으로 정렬 trades.sort((a, b) => a.timestamp - b.timestamp); // 포지션별 거래 그룹화 (매우 단순화된 버전) // 실제로는 더 복잡한 포지션 추적 로직이 필요할 수 있음 const positions: any[] = []; let currentPosition: any = null; trades.forEach((trade) => { if (!currentPosition) { currentPosition = { symbol: trade.symbol, side: trade.side, entryTime: trade.datetime, entryPrice: trade.price, amount: trade.amount, cost: trade.amount * trade.price, fees: trade.fee?.cost || 0, exitTime: null, exitPrice: null, profit: null, }; } else if (currentPosition.side !== trade.side && currentPosition.symbol === trade.symbol) { // 반대 방향 거래는 포지션 종료로 간주 currentPosition.exitTime = trade.datetime; currentPosition.exitPrice = trade.price; // 손익 계산 (매우 단순화된 버전) if (currentPosition.side === "buy") { // 매수 후 매도 currentPosition.profit = (trade.price - currentPosition.entryPrice) * currentPosition.amount - currentPosition.fees - (trade.fee?.cost || 0); } else { // 매도 후 매수 currentPosition.profit = (currentPosition.entryPrice - trade.price) * currentPosition.amount - currentPosition.fees - (trade.fee?.cost || 0); } positions.push(currentPosition); currentPosition = null; } else { // 같은 방향 거래는 포지션에 추가 (average down/up) const newAmount = currentPosition.amount + trade.amount; const newCost = currentPosition.cost + (trade.amount * trade.price); currentPosition.entryPrice = newCost / newAmount; currentPosition.amount = newAmount; currentPosition.cost = newCost; currentPosition.fees += trade.fee?.cost || 0; } }); // 완료된 포지션만 분석 const completedPositions = positions.filter(p => p.exitTime !== null); if (completedPositions.length === 0) { return { totalTrades: trades.length, completedPositions: 0, message: "No completed positions found for analysis.", }; } // 기본 지표 계산 let winCount = 0; let lossCount = 0; let totalProfit = 0; let totalLoss = 0; let maxConsecutiveWins = 0; let maxConsecutiveLosses = 0; let currentConsecutiveWins = 0; let currentConsecutiveLosses = 0; completedPositions.forEach(position => { if (position.profit > 0) { winCount++; totalProfit += position.profit; currentConsecutiveWins++; currentConsecutiveLosses = 0; maxConsecutiveWins = Math.max(maxConsecutiveWins, currentConsecutiveWins); } else { lossCount++; totalLoss += Math.abs(position.profit); currentConsecutiveLosses++; currentConsecutiveWins = 0; maxConsecutiveLosses = Math.max(maxConsecutiveLosses, currentConsecutiveLosses); } }); const totalPositions = completedPositions.length; const winRate = (winCount / totalPositions) * 100; const averageWin = winCount > 0 ? totalProfit / winCount : 0; const averageLoss = lossCount > 0 ? totalLoss / lossCount : 0; const profitFactor = totalLoss > 0 ? totalProfit / totalLoss : totalProfit > 0 ? Infinity : 0; const expectancy = (winRate / 100 * averageWin) - ((100 - winRate) / 100 * averageLoss); // R-multiple 계산 (평균 수익 / 평균 손실) const rMultiple = averageLoss > 0 ? averageWin / averageLoss : 0; return { totalTrades: trades.length, completedPositions: totalPositions, winCount, lossCount, winRate: winRate.toFixed(2) + "%", profitFactor: profitFactor.toFixed(2), netProfit: (totalProfit - totalLoss).toFixed(8), averageWin: averageWin.toFixed(8), averageLoss: averageLoss.toFixed(8), rMultiple: rMultiple.toFixed(2), expectancy: expectancy.toFixed(8), maxConsecutiveWins, maxConsecutiveLosses, firstTradeDate: completedPositions[0].entryTime, lastTradeDate: completedPositions[completedPositions.length - 1].exitTime, }; } /** * 최대 연속 손실/이익 분석을 수행합니다. */ function analyzeConsecutiveTrades(trades: any[]) { if (!trades || trades.length === 0) { return { totalTrades: 0, message: "No trades found for the specified period.", }; } // 거래를 시간순으로 정렬 trades.sort((a, b) => a.timestamp - b.timestamp); // 간단한 분석을 위해 거래를 승/패로 변환 (단순화된 버전) const tradeResults: boolean[] = []; let currentSide = null; let entryPrice = 0; trades.forEach((trade) => { if (currentSide === null) { // 첫 거래는 진입으로 간주 currentSide = trade.side; entryPrice = trade.price; } else if (currentSide !== trade.side) { // 반대 방향 거래는 포지션 종료로 간주 const isWin = (currentSide === 'buy' && trade.price > entryPrice) || (currentSide === 'sell' && trade.price < entryPrice); tradeResults.push(isWin); // 새로운 포지션 시작 currentSide = trade.side; entryPrice = trade.price; } }); // 연속 승/패 분석 let maxConsecutiveWins = 0; let maxConsecutiveLosses = 0; let currentConsecutiveWins = 0; let currentConsecutiveLosses = 0; let currentWinStreak = 0; let currentLossStreak = 0; let maxWinStreak = { count: 0, startIndex: 0, endIndex: 0 }; let maxLossStreak = { count: 0, startIndex: 0, endIndex: 0 }; tradeResults.forEach((isWin, index) => { if (isWin) { currentConsecutiveWins++; currentConsecutiveLosses = 0; if (currentWinStreak === 0) { currentWinStreak = 1; currentLossStreak = 0; } else { currentWinStreak++; } if (currentWinStreak > maxWinStreak.count) { maxWinStreak = { count: currentWinStreak, startIndex: index - currentWinStreak + 1, endIndex: index }; } } else { currentConsecutiveLosses++; currentConsecutiveWins = 0; if (currentLossStreak === 0) { currentLossStreak = 1; currentWinStreak = 0; } else { currentLossStreak++; } if (currentLossStreak > maxLossStreak.count) { maxLossStreak = { count: currentLossStreak, startIndex: index - currentLossStreak + 1, endIndex: index }; } } maxConsecutiveWins = Math.max(maxConsecutiveWins, currentConsecutiveWins); maxConsecutiveLosses = Math.max(maxConsecutiveLosses, currentConsecutiveLosses); }); // 최대 연속 승/패 시 총 손익 계산 const maxWinStreakTrades = maxWinStreak.count > 0 ? tradeResults.slice(maxWinStreak.startIndex, maxWinStreak.endIndex + 1) : []; const maxLossStreakTrades = maxLossStreak.count > 0 ? tradeResults.slice(maxLossStreak.startIndex, maxLossStreak.endIndex + 1) : []; return { totalCompletedTrades: tradeResults.length, winCount: tradeResults.filter(result => result).length, lossCount: tradeResults.filter(result => !result).length, maxConsecutiveWins, maxConsecutiveLosses, maxWinStreak: { count: maxWinStreak.count, startDate: maxWinStreak.count > 0 ? trades[maxWinStreak.startIndex].datetime : null, endDate: maxWinStreak.count > 0 ? trades[maxWinStreak.endIndex].datetime : null, }, maxLossStreak: { count: maxLossStreak.count, startDate: maxLossStreak.count > 0 ? trades[maxLossStreak.startIndex].datetime : null, endDate: maxLossStreak.count > 0 ? trades[maxLossStreak.endIndex].datetime : null, }, currentStreakType: currentConsecutiveWins > 0 ? "win" : "loss", currentStreakCount: Math.max(currentConsecutiveWins, currentConsecutiveLosses), }; } /** * 일간/주간/월간 수익률을 계산합니다. */ function calculatePeriodicReturns(trades: any[], interval: string) { if (!trades || trades.length === 0) { return { totalTrades: 0, message: "No trades found for the specified period.", }; } // 거래를 시간순으로 정렬 trades.sort((a, b) => a.timestamp - b.timestamp); // 기간별로 그룹화 const periodicData: Record<string, { profit: number, trades: number }> = {}; trades.forEach(trade => { const date = new Date(trade.timestamp); let key: string; switch(interval) { case 'weekly': // 주차 계산 (ISO 주 - 1부터 53까지) const weekOfYear = getWeekNumber(date); key = `${date.getFullYear()}-W${weekOfYear}`; break; case 'monthly': // 월 (1월은 0) key = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`; break; case 'daily': default: // 일 (YYYY-MM-DD) key = date.toISOString().split('T')[0]; } if (!periodicData[key]) { periodicData[key] = { profit: 0, trades: 0 }; } // 단순화된 손익 계산 const profit = trade.side === 'buy' ? -(trade.amount * trade.price) : (trade.amount * trade.price); periodicData[key].profit += profit - (trade.fee?.cost || 0); periodicData[key].trades++; }); // 결과 처리 const returns = Object.entries(periodicData).map(([period, data]) => ({ period, profit: data.profit.toFixed(8), trades: data.trades })).sort((a, b) => a.period.localeCompare(b.period)); // 통계 계산 const profitValues = returns.map(r => parseFloat(r.profit)); const totalProfit = profitValues.reduce((sum, profit) => sum + profit, 0); const averagePeriodProfit = profitValues.length > 0 ? totalProfit / profitValues.length : 0; const positiveReturns = profitValues.filter(p => p > 0); const negativeReturns = profitValues.filter(p => p < 0); return { interval, totalPeriods: returns.length, totalProfit: totalProfit.toFixed(8), averagePeriodProfit: averagePeriodProfit.toFixed(8), profitablePeriods: positiveReturns.length, lossPeriods: negativeReturns.length, profitablePeriodRatio: returns.length > 0 ? ((positiveReturns.length / returns.length) * 100).toFixed(2) + '%' : '0%', bestPeriod: profitValues.length > 0 ? returns[profitValues.indexOf(Math.max(...profitValues))] : null, worstPeriod: profitValues.length > 0 ? returns[profitValues.indexOf(Math.min(...profitValues))] : null, periodicReturns: returns }; } /** * 날짜의 ISO 주(week) 번호를 계산합니다. */ function getWeekNumber(date: Date): number { const d = new Date(date); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7)); const week1 = new Date(d.getFullYear(), 0, 4); return 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/lazy-dinosaur/ccxt-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server