n8n-nodes-mypet-analytics
Version:
专业的 MyPet Stocks 交易数据分析节点,提供盈利分析、风险评估、绩效评分和可视化报告功能 | Professional MyPet Stocks trading data analysis node with profit analysis, risk assessment, performance scoring and visualization reports
1,261 lines (1,108 loc) • 63 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MyPetAnalytics = void 0;
// Helper functions
function parseNumber(value, defaultValue = 0) {
if (value === null || value === undefined || value === '') {
return defaultValue;
}
const parsed = parseFloat(value);
return isNaN(parsed) ? defaultValue : parsed;
}
function parseDate(value) {
if (!value) {
return new Date();
}
if (value instanceof Date) {
return value;
}
try {
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? new Date() : parsed;
} catch (error) {
console.warn('Failed to parse date:', value);
return new Date();
}
}
function normalizeTradeDataArray(trades) {
// Normalize array of trade data
return trades.filter(trade => trade && typeof trade === 'object')
.map((trade, index) => {
try {
return {
id: trade.id || trade.orderId || trade.ticket || `trade_${index}`,
symbol: trade.symbol || trade.instrument || trade.pair || 'UNKNOWN',
volume: parseNumber(trade.volume || trade.size || trade.lots || trade.amount, 0),
openPrice: parseNumber(trade.openPrice || trade.price || trade.entryPrice, 0),
closePrice: parseNumber(trade.closePrice || trade.exitPrice, null),
profit: parseNumber(trade.orderProfit || trade.profit || trade.pnl || trade.netProfit, 0),
commission: parseNumber(trade.commission || trade.fee || trade.cost, 0),
openTime: parseDate(trade.openTime || trade.timestamp || trade.time || trade.opentime),
closeTime: trade.closeTime || trade.closeTimestamp || trade.closetime ?
parseDate(trade.closeTime || trade.closeTimestamp || trade.closetime) : null,
type: trade.tradeType || trade.type || trade.side || 'unknown',
swap: parseNumber(trade.swap, 0),
comment: trade.comment || trade.description || '',
};
} catch (error) {
console.error('Error normalizing trade:', error, trade);
return null;
}
})
.filter(trade => trade !== null);
}
function extractTradingData(inputData) {
console.log('=== EXTRACTING TRADING DATA ===');
console.log('Input data type:', typeof inputData);
console.log('Input data keys:', Object.keys(inputData || {}));
console.log('Input data size (JSON):', JSON.stringify(inputData).length, 'characters');
// Handle null or undefined input
if (!inputData) {
console.log('No input data provided');
return { trades: [], orderInfo: null };
}
let trades = [];
let orderInfo = null;
// Handle multiple pages of data (if input is an array of pages)
if (Array.isArray(inputData) && inputData.length > 0 && inputData[0].orders) {
console.log('🔄 Detected multiple pages of data:', inputData.length, 'pages');
let allTrades = [];
let combinedOrderInfo = null;
inputData.forEach((page, index) => {
console.log(`Processing page ${index + 1}:`, page.orders?.length || 0, 'trades');
if (page.orders && Array.isArray(page.orders)) {
allTrades = allTrades.concat(page.orders);
}
// Use the most recent orderInfo (usually from the last page)
if (page.orderInfo) {
combinedOrderInfo = page.orderInfo;
}
});
console.log('✅ Combined all pages:', allTrades.length, 'total trades');
return { trades: normalizeTradeDataArray(allTrades), orderInfo: combinedOrderInfo };
}
// Extract orderInfo if available
if (inputData.orderInfo) {
orderInfo = inputData.orderInfo;
console.log('Found orderInfo with keys:', Object.keys(orderInfo));
}
// Try different possible data structures with pagination support
console.log('Checking for orders array:', !!inputData.orders, Array.isArray(inputData.orders));
// Handle MyPet API structure: result.results.data
if (inputData.result && inputData.result.results && Array.isArray(inputData.result.results.data)) {
console.log('✅ Found MyPet API structure: result.results.data with', inputData.result.results.data.length, 'items');
console.log('Pagination info:', {
count: inputData.result.count,
next: !!inputData.result.next,
previous: !!inputData.result.previous
});
trades = inputData.result.results.data;
// Extract orderInfo from result.results
if (inputData.result.results.order_info) {
orderInfo = inputData.result.results.order_info;
console.log('Found orderInfo in result.results');
}
}
// Handle direct orders array
else if (inputData.orders && Array.isArray(inputData.orders)) {
console.log('✅ Found orders array with', inputData.orders.length, 'items');
console.log('First order sample:', JSON.stringify(inputData.orders[0], null, 2));
trades = inputData.orders;
}
// Handle trades array
else if (inputData.trades && Array.isArray(inputData.trades)) {
console.log('✅ Found trades array with', inputData.trades.length, 'items');
trades = inputData.trades;
}
// Handle data array
else if (inputData.data && Array.isArray(inputData.data)) {
console.log('✅ Found data array with', inputData.data.length, 'items');
trades = inputData.data;
}
// Handle direct array input
else if (Array.isArray(inputData)) {
console.log('✅ Input is direct array with', inputData.length, 'items');
trades = inputData;
}
// Handle result array
else if (inputData.result && Array.isArray(inputData.result)) {
console.log('✅ Found result array with', inputData.result.length, 'items');
trades = inputData.result;
}
// Search for any array property
else {
console.log('🔍 Searching for array properties...');
for (const key in inputData) {
console.log(`Checking property "${key}":`, Array.isArray(inputData[key]), inputData[key]?.length || 'N/A');
if (Array.isArray(inputData[key]) && inputData[key].length > 0) {
console.log('✅ Found array in property:', key, 'with', inputData[key].length, 'items');
trades = inputData[key];
break;
}
}
}
// If still no trades found, try to wrap single object
if (trades.length === 0 && typeof inputData === 'object' && inputData !== null) {
console.log('Wrapping single object as trade');
trades = [inputData];
}
console.log('Raw trades found:', trades.length);
// Filter and normalize trade data
const normalizedTrades = trades.filter(trade => trade && typeof trade === 'object')
.map((trade, index) => {
try {
return {
id: trade.id || trade.orderId || trade.ticket || `trade_${index}`,
symbol: trade.symbol || trade.instrument || trade.pair || 'UNKNOWN',
volume: parseNumber(trade.volume || trade.size || trade.lots || trade.amount, 0),
openPrice: parseNumber(trade.openPrice || trade.price || trade.entryPrice, 0),
closePrice: parseNumber(trade.closePrice || trade.exitPrice, null),
profit: parseNumber(trade.orderProfit || trade.profit || trade.pnl || trade.netProfit, 0),
commission: parseNumber(trade.commission || trade.fee || trade.cost, 0),
openTime: parseDate(trade.openTime || trade.timestamp || trade.time || trade.opentime),
closeTime: trade.closeTime || trade.closeTimestamp || trade.closetime ?
parseDate(trade.closeTime || trade.closeTimestamp || trade.closetime) : null,
type: trade.tradeType || trade.type || trade.side || 'unknown',
swap: parseNumber(trade.swap, 0),
comment: trade.comment || trade.description || '',
};
} catch (error) {
console.error('Error normalizing trade:', error, trade);
return null;
}
})
.filter(trade => trade !== null);
console.log('Normalized trades:', normalizedTrades.length);
return { trades: normalizedTrades, orderInfo };
}
function generateBasicInsights(trades, closedTrades) {
const insights = [];
if (trades.length > 0) {
insights.push(`Analyzed ${trades.length} total trades`);
}
if (closedTrades.length > 0) {
const profitableTrades = closedTrades.filter(t => (t.profit || 0) > 0).length;
const winRate = (profitableTrades / closedTrades.length) * 100;
insights.push(`Win rate: ${Math.round(winRate)}%`);
}
return insights;
}
function performBasicAnalysis(trades, orderInfo = null) {
console.log('Performing basic analysis on', trades.length, 'trades');
const totalTrades = trades.length;
const closedTrades = trades.filter(t => t.closeTime && t.profit !== undefined && t.profit !== null);
if (totalTrades === 0) {
return {
error: 'No trading data found',
debug: {
totalTrades,
closedTrades: closedTrades.length,
sampleTrade: trades[0] || null
}
};
}
const totalVolume = trades.reduce((sum, t) => sum + (t.volume || 0), 0);
const totalProfit = closedTrades.reduce((sum, t) => sum + (t.profit || 0), 0);
const totalCommission = closedTrades.reduce((sum, t) => sum + (t.commission || 0), 0);
// Symbol distribution
const symbolCounts = {};
trades.forEach(t => {
const symbol = t.symbol || 'UNKNOWN';
symbolCounts[symbol] = (symbolCounts[symbol] || 0) + 1;
});
const result = {
summary: {
totalTrades,
closedTrades: closedTrades.length,
openTrades: totalTrades - closedTrades.length,
totalVolume: Math.round(totalVolume * 100) / 100,
totalProfit: Math.round(totalProfit * 100) / 100,
totalCommission: Math.round(totalCommission * 100) / 100,
netProfit: Math.round((totalProfit - totalCommission) * 100) / 100,
},
symbolDistribution: symbolCounts,
insights: generateBasicInsights(trades, closedTrades),
debug: {
sampleTrade: trades[0] || null,
tradeFields: trades[0] ? Object.keys(trades[0]) : []
}
};
// Add orderInfo if available
if (orderInfo) {
result.orderInfo = {
total: orderInfo.total || {},
open: orderInfo.open || {},
sell_close: orderInfo.sell_close || {},
buy_close: orderInfo.buy_close || {},
fund: orderInfo.fund || {}
};
// Add comparison between calculated and API provided data
if (orderInfo.total) {
result.dataComparison = {
calculatedVsApi: {
totalTrades: { calculated: totalTrades, api: orderInfo.total.total || 0 },
totalProfit: { calculated: Math.round(totalProfit * 100) / 100, api: orderInfo.total.total_orderProfit || 0 },
totalVolume: { calculated: Math.round(totalVolume * 100) / 100, api: orderInfo.total.total_lots || 0 }
}
};
}
}
return result;
}
function generateProfitInsights(winRate, profitFactor, totalProfit) {
const insights = [];
if (winRate > 60) {
insights.push('High win rate indicates good trade selection');
} else if (winRate < 40) {
insights.push('Low win rate suggests need for better entry criteria');
}
if (profitFactor > 1.5) {
insights.push('Strong profit factor shows good risk/reward management');
} else if (profitFactor < 1.0) {
insights.push('Profit factor below 1.0 indicates losses exceed profits');
}
if (totalProfit > 0) {
insights.push('Overall profitable trading performance');
} else {
insights.push('Overall trading performance shows losses');
}
return insights;
}
function calculateProfitRanges(profits) {
const ranges = {
'Large Loss (< -100)': 0,
'Medium Loss (-100 to -50)': 0,
'Small Loss (-50 to 0)': 0,
'Small Profit (0 to 50)': 0,
'Medium Profit (50 to 100)': 0,
'Large Profit (> 100)': 0,
};
profits.forEach(p => {
if (p < -100) ranges['Large Loss (< -100)']++;
else if (p < -50) ranges['Medium Loss (-100 to -50)']++;
else if (p < 0) ranges['Small Loss (-50 to 0)']++;
else if (p < 50) ranges['Small Profit (0 to 50)']++;
else if (p < 100) ranges['Medium Profit (50 to 100)']++;
else ranges['Large Profit (> 100)']++;
});
return ranges;
}
function performProfitAnalysis(trades) {
const closedTrades = trades.filter(t => t.closeTime && t.profit !== undefined && t.profit !== null);
if (closedTrades.length === 0) {
return {
error: 'No closed trades found for profit analysis',
debug: {
totalTrades: trades.length,
tradesWithProfit: trades.filter(t => t.profit !== undefined).length,
tradesWithCloseTime: trades.filter(t => t.closeTime).length
}
};
}
const profits = closedTrades.map(t => t.profit || 0);
const winningTrades = profits.filter(p => p > 0);
const losingTrades = profits.filter(p => p < 0);
const totalProfit = profits.reduce((sum, p) => sum + p, 0);
const winRate = profits.length > 0 ? (winningTrades.length / profits.length) * 100 : 0;
const avgWin = winningTrades.length > 0 ? winningTrades.reduce((sum, p) => sum + p, 0) / winningTrades.length : 0;
const avgLoss = losingTrades.length > 0 ? Math.abs(losingTrades.reduce((sum, p) => sum + p, 0) / losingTrades.length) : 0;
const profitFactor = avgLoss > 0 ? (winningTrades.reduce((sum, p) => sum + p, 0) / Math.abs(losingTrades.reduce((sum, p) => sum + p, 0))) : 0;
return {
summary: {
totalProfit: Math.round(totalProfit * 100) / 100,
winRate: Math.round(winRate * 100) / 100,
profitFactor: Math.round(profitFactor * 100) / 100,
averageWin: Math.round(avgWin * 100) / 100,
averageLoss: Math.round(avgLoss * 100) / 100,
winningTrades: winningTrades.length,
losingTrades: losingTrades.length,
largestWin: profits.length > 0 ? Math.max(...profits) : 0,
largestLoss: profits.length > 0 ? Math.min(...profits) : 0,
},
distribution: {
profits: profits.sort((a, b) => b - a),
profitRanges: calculateProfitRanges(profits),
},
insights: generateProfitInsights(winRate, profitFactor, totalProfit),
};
}
function generateRiskInsights(volatility, maxDrawdown, var95) {
const insights = [];
if (maxDrawdown > 0.2) {
insights.push('High maximum drawdown indicates significant risk exposure');
} else if (maxDrawdown < 0.1) {
insights.push('Low maximum drawdown shows good risk control');
}
if (volatility > 0.05) {
insights.push('High volatility suggests aggressive trading style');
} else if (volatility < 0.02) {
insights.push('Low volatility indicates conservative approach');
}
return insights;
}
function classifyRiskLevel(volatility, maxDrawdown) {
if (volatility > 0.05 || maxDrawdown > 0.25) return 'High';
if (volatility > 0.03 || maxDrawdown > 0.15) return 'Medium';
return 'Low';
}
function calculateSkewness(returns) {
if (returns.length < 3) return 0;
const n = returns.length;
const mean = returns.reduce((sum, r) => sum + r, 0) / n;
const variance = returns.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / n;
const stdDev = Math.sqrt(variance);
if (stdDev === 0) return 0;
const skewness = returns.reduce((sum, r) => sum + Math.pow((r - mean) / stdDev, 3), 0) / n;
return Math.round(skewness * 100) / 100;
}
function calculateKurtosis(returns) {
if (returns.length < 4) return 0;
const n = returns.length;
const mean = returns.reduce((sum, r) => sum + r, 0) / n;
const variance = returns.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / n;
const stdDev = Math.sqrt(variance);
if (stdDev === 0) return 0;
const kurtosis = returns.reduce((sum, r) => sum + Math.pow((r - mean) / stdDev, 4), 0) / n - 3;
return Math.round(kurtosis * 100) / 100;
}
function performRiskAnalysis(trades) {
const closedTrades = trades.filter(t => t.closeTime && t.profit !== undefined && t.profit !== null);
if (closedTrades.length === 0) {
return { error: 'No closed trades found for risk analysis' };
}
const profits = closedTrades.map(t => t.profit || 0);
const returns = profits.map(p => p / 1000); // Normalize returns
// Basic risk metrics
const mean = returns.length > 0 ? returns.reduce((sum, r) => sum + r, 0) / returns.length : 0;
const variance = returns.length > 0 ? returns.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / returns.length : 0;
const stdDev = Math.sqrt(variance);
// Simple VaR calculation (95% confidence)
const sortedReturns = [...returns].sort((a, b) => a - b);
const varIndex = Math.floor(0.05 * sortedReturns.length);
const var95 = sortedReturns[varIndex] || 0;
// Maximum drawdown calculation
let maxDrawdown = 0;
let peak = profits[0] || 0;
let cumulativeProfit = 0;
for (const profit of profits) {
cumulativeProfit += profit;
if (cumulativeProfit > peak) {
peak = cumulativeProfit;
}
const drawdown = peak !== 0 ? (peak - cumulativeProfit) / Math.abs(peak) : 0;
if (drawdown > maxDrawdown) {
maxDrawdown = drawdown;
}
}
return {
summary: {
volatility: Math.round(stdDev * 10000) / 100, // As percentage
var95: Math.round(var95 * 1000 * 100) / 100,
maxDrawdown: Math.round(maxDrawdown * 10000) / 100, // As percentage
sharpeRatio: stdDev > 0 ? Math.round((mean / stdDev) * 100) / 100 : 0,
riskLevel: classifyRiskLevel(stdDev, maxDrawdown),
},
metrics: {
meanReturn: Math.round(mean * 1000 * 100) / 100,
standardDeviation: Math.round(stdDev * 1000 * 100) / 100,
skewness: calculateSkewness(returns),
kurtosis: calculateKurtosis(returns),
},
insights: generateRiskInsights(stdDev, maxDrawdown, var95),
};
}
function generatePerformanceRecommendations(winRate, profitFactor, maxDrawdown) {
const recommendations = [];
if (winRate < 50) {
recommendations.push('Consider improving trade entry criteria to increase win rate');
}
if (profitFactor < 1.2) {
recommendations.push('Focus on improving risk/reward ratio per trade');
}
if (maxDrawdown > 15) {
recommendations.push('Implement stricter risk management to reduce drawdowns');
}
if (recommendations.length === 0) {
recommendations.push('Trading performance is within acceptable ranges');
}
return recommendations;
}
function calculatePerformanceScore(winRate, profitFactor, sharpeRatio, maxDrawdown) {
const winRateScore = Math.min(winRate / 60 * 25, 25);
const profitFactorScore = Math.min(profitFactor / 2 * 25, 25);
const sharpeScore = Math.min((sharpeRatio + 1) / 2 * 25, 25);
const drawdownScore = Math.max(0, 25 - (maxDrawdown / 20 * 25));
return Math.round(winRateScore + profitFactorScore + sharpeScore + drawdownScore);
}
function getPerformanceGrade(score) {
if (score >= 90) return 'A+';
if (score >= 80) return 'A';
if (score >= 70) return 'B';
if (score >= 60) return 'C';
if (score >= 50) return 'D';
return 'F';
}
function performPerformanceAnalysis(trades) {
const basicAnalysis = performBasicAnalysis(trades);
const profitAnalysis = performProfitAnalysis(trades);
const riskAnalysis = performRiskAnalysis(trades);
if (basicAnalysis.error || profitAnalysis.error || riskAnalysis.error) {
return {
error: 'Insufficient data for performance analysis',
details: {
basicError: basicAnalysis.error,
profitError: profitAnalysis.error,
riskError: riskAnalysis.error
}
};
}
// Performance score calculation
const winRate = profitAnalysis.summary.winRate || 0;
const profitFactor = profitAnalysis.summary.profitFactor || 0;
const sharpeRatio = riskAnalysis.summary.sharpeRatio || 0;
const maxDrawdown = riskAnalysis.summary.maxDrawdown || 0;
const performanceScore = calculatePerformanceScore(winRate, profitFactor, sharpeRatio, maxDrawdown);
return {
summary: {
performanceScore,
grade: getPerformanceGrade(performanceScore),
totalTrades: basicAnalysis.summary.totalTrades,
netProfit: basicAnalysis.summary.netProfit,
winRate: profitAnalysis.summary.winRate,
profitFactor: profitAnalysis.summary.profitFactor,
maxDrawdown: riskAnalysis.summary.maxDrawdown,
sharpeRatio: riskAnalysis.summary.sharpeRatio,
},
breakdown: {
profitability: Math.min(100, (profitFactor / 2) * 100),
consistency: Math.min(100, winRate),
riskManagement: Math.max(0, 100 - maxDrawdown),
efficiency: Math.min(100, (sharpeRatio + 1) * 50),
},
recommendations: generatePerformanceRecommendations(winRate, profitFactor, maxDrawdown),
};
}
function generateChartData(analysisResult, analysisType) {
const charts = [];
try {
switch (analysisType) {
case 'profit':
if (analysisResult.summary && !analysisResult.error) {
charts.push({
type: 'pie',
title: 'Win/Loss Distribution',
data: {
labels: ['Winning Trades', 'Losing Trades'],
values: [analysisResult.summary.winningTrades || 0, analysisResult.summary.losingTrades || 0],
},
});
}
break;
case 'risk':
if (analysisResult.metrics && !analysisResult.error) {
charts.push({
type: 'bar',
title: 'Risk Metrics',
data: {
labels: ['Volatility %', 'Max Drawdown %', 'Sharpe Ratio x10'],
values: [
analysisResult.summary.volatility || 0,
analysisResult.summary.maxDrawdown || 0,
(analysisResult.summary.sharpeRatio || 0) * 10,
],
},
});
}
break;
}
} catch (error) {
console.error('Error generating chart data:', error);
}
return charts;
}
function generateHtmlReportContent(analysisResult, analysisType, metadata) {
const timestamp = new Date().toLocaleString();
// Generate chart data for HTML
const chartData = generateChartDataForHtml(analysisResult, analysisType);
const html = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MyPet Analytics Report - ${analysisType}</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 2.5em;
font-weight: 300;
}
.header p {
margin: 10px 0 0 0;
opacity: 0.9;
font-size: 1.1em;
}
.content {
padding: 30px;
}
.section {
margin-bottom: 40px;
padding: 25px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.section h2 {
margin-top: 0;
color: #667eea;
font-size: 1.8em;
font-weight: 500;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin: 20px 0;
}
.metric-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
border-top: 3px solid #667eea;
}
.metric-value {
font-size: 2em;
font-weight: bold;
color: #667eea;
margin: 10px 0;
}
.metric-label {
color: #666;
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 1px;
}
.positive { color: #28a745; }
.negative { color: #dc3545; }
.neutral { color: #6c757d; }
.insights {
background: #e3f2fd;
padding: 20px;
border-radius: 8px;
border-left: 4px solid #2196f3;
}
.insights h3 {
margin-top: 0;
color: #1976d2;
}
.insights ul {
margin: 0;
padding-left: 20px;
}
.insights li {
margin: 8px 0;
color: #424242;
}
.chart-container {
margin: 30px 0;
text-align: center;
}
.chart-canvas {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.footer {
background: #f8f9fa;
padding: 20px 30px;
text-align: center;
color: #666;
border-top: 1px solid #dee2e6;
}
.data-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.data-table th,
.data-table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #dee2e6;
}
.data-table th {
background: #667eea;
color: white;
font-weight: 500;
text-transform: uppercase;
font-size: 0.9em;
letter-spacing: 1px;
}
.data-table tr:hover {
background: #f8f9fa;
}
@media (max-width: 768px) {
.metrics-grid {
grid-template-columns: 1fr;
}
.header h1 {
font-size: 2em;
}
.content {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 MyPet Analytics Report</h1>
<p>Analysis Type: ${analysisType.toUpperCase()} | Generated: ${timestamp}</p>
</div>
<div class="content">
${generateSummarySection(analysisResult)}
${generateChartsSection(chartData)}
${generateInsightsSection(analysisResult)}
${generateOrderInfoSection(analysisResult)}
${generateDetailedDataSection(analysisResult)}
</div>
<div class="footer">
<p>Generated by MyPet Analytics v${metadata.version} | Data Points: ${metadata.dataPoints}</p>
<p>${metadata.note || ''}</p>
</div>
</div>
</body>
</html>`;
return html;
}
function generateChartDataForHtml(analysisResult, analysisType) {
// Generate base64 chart images for HTML embedding
const charts = [];
try {
switch (analysisType) {
case 'basic':
// 1. Symbol Distribution Pie Chart
if (analysisResult.symbolDistribution) {
const symbolChart = generatePieChartBase64(
'Symbol Distribution',
Object.keys(analysisResult.symbolDistribution),
Object.values(analysisResult.symbolDistribution)
);
if (symbolChart) charts.push(symbolChart);
}
// 2. Volume Distribution Bar Chart
if (analysisResult.summary) {
const volumeChart = generateBarChartBase64(
'Trading Volume Metrics',
['Total Volume', 'Avg Volume', 'Total Trades'],
[
analysisResult.summary.totalVolume || 0,
(analysisResult.summary.totalVolume || 0) / Math.max(analysisResult.summary.totalTrades || 1, 1),
analysisResult.summary.totalTrades || 0
]
);
if (volumeChart) charts.push(volumeChart);
}
// 3. Profit vs Commission Chart
if (analysisResult.summary) {
const profitChart = generateBarChartBase64(
'Profit vs Commission',
['Total Profit', 'Total Commission', 'Net Profit'],
[
analysisResult.summary.totalProfit || 0,
Math.abs(analysisResult.summary.totalCommission || 0),
analysisResult.summary.netProfit || 0
]
);
if (profitChart) charts.push(profitChart);
}
break;
case 'profit':
// 1. Win/Loss Distribution Pie Chart
if (analysisResult.summary && !analysisResult.error) {
const winLossChart = generatePieChartBase64(
'Win/Loss Distribution',
['Winning Trades', 'Losing Trades'],
[analysisResult.summary.winningTrades || 0, analysisResult.summary.losingTrades || 0]
);
if (winLossChart) charts.push(winLossChart);
}
// 2. Profit Metrics Bar Chart
if (analysisResult.summary && !analysisResult.error) {
const profitMetricsChart = generateBarChartBase64(
'Profit Metrics',
['Total Profit', 'Average Win', 'Average Loss', 'Profit Factor'],
[
analysisResult.summary.totalProfit || 0,
analysisResult.summary.averageWin || 0,
Math.abs(analysisResult.summary.averageLoss || 0),
(analysisResult.summary.profitFactor || 0) * 10
]
);
if (profitMetricsChart) charts.push(profitMetricsChart);
}
// 3. Profit Range Distribution
if (analysisResult.distribution && analysisResult.distribution.profitRanges) {
const rangeChart = generateBarChartBase64(
'Profit Range Distribution',
Object.keys(analysisResult.distribution.profitRanges),
Object.values(analysisResult.distribution.profitRanges)
);
if (rangeChart) charts.push(rangeChart);
}
break;
case 'risk':
// 1. Risk Metrics Bar Chart
if (analysisResult.summary && !analysisResult.error) {
const riskChart = generateBarChartBase64(
'Risk Metrics',
['Volatility %', 'Max Drawdown %', 'Sharpe Ratio x10', 'VaR 95%'],
[
analysisResult.summary.volatility || 0,
analysisResult.summary.maxDrawdown || 0,
(analysisResult.summary.sharpeRatio || 0) * 10,
Math.abs(analysisResult.summary.var95 || 0)
]
);
if (riskChart) charts.push(riskChart);
}
// 2. Risk Level Pie Chart
if (analysisResult.summary && !analysisResult.error) {
const riskLevel = analysisResult.summary.riskLevel || 'Medium';
const riskLevelChart = generatePieChartBase64(
'Risk Level Assessment',
['Low Risk', 'Medium Risk', 'High Risk'],
[
riskLevel === 'Low' ? 1 : 0,
riskLevel === 'Medium' ? 1 : 0,
riskLevel === 'High' ? 1 : 0
]
);
if (riskLevelChart) charts.push(riskLevelChart);
}
break;
case 'performance':
// 1. Performance Breakdown Bar Chart
if (analysisResult.breakdown && !analysisResult.error) {
const performanceChart = generateBarChartBase64(
'Performance Breakdown',
['Profitability', 'Consistency', 'Risk Management', 'Efficiency'],
[
analysisResult.breakdown.profitability || 0,
analysisResult.breakdown.consistency || 0,
analysisResult.breakdown.riskManagement || 0,
analysisResult.breakdown.efficiency || 0
]
);
if (performanceChart) charts.push(performanceChart);
}
// 2. Performance Score Gauge (as pie chart)
if (analysisResult.summary && !analysisResult.error) {
const score = analysisResult.summary.performanceScore || 0;
const scoreChart = generatePieChartBase64(
'Performance Score',
['Score', 'Remaining'],
[score, Math.max(0, 100 - score)]
);
if (scoreChart) charts.push(scoreChart);
}
// 3. Key Metrics Comparison
if (analysisResult.summary && !analysisResult.error) {
const metricsChart = generateBarChartBase64(
'Key Performance Metrics',
['Win Rate %', 'Profit Factor x10', 'Max DD %', 'Sharpe x10'],
[
analysisResult.summary.winRate || 0,
(analysisResult.summary.profitFactor || 0) * 10,
analysisResult.summary.maxDrawdown || 0,
(analysisResult.summary.sharpeRatio || 0) * 10
]
);
if (metricsChart) charts.push(metricsChart);
}
break;
}
} catch (error) {
console.error('Error generating chart data for HTML:', error);
}
return charts.filter(chart => chart !== null);
}
function generatePieChartBase64(title, labels, data) {
// Generate SVG pie chart
const width = 400;
const height = 300;
const radius = Math.min(width, height) / 2 - 40;
const centerX = width / 2;
const centerY = height / 2;
// 数据验证
if (!data || !labels || data.length === 0 || labels.length === 0) {
console.log('Invalid data for pie chart:', { title, labels, data });
return null;
}
const total = data.reduce((sum, value) => sum + (parseFloat(value) || 0), 0);
if (total === 0) {
console.log('Total is zero for pie chart:', title);
return null;
}
const colors = ['#667eea', '#764ba2', '#f093fb', '#f5576c', '#4facfe', '#00f2fe'];
let currentAngle = 0;
let paths = '';
let legends = '';
data.forEach((value, index) => {
const percentage = (value / total) * 100;
const angle = (value / total) * 2 * Math.PI;
const endAngle = currentAngle + angle;
const x1 = centerX + radius * Math.cos(currentAngle);
const y1 = centerY + radius * Math.sin(currentAngle);
const x2 = centerX + radius * Math.cos(endAngle);
const y2 = centerY + radius * Math.sin(endAngle);
const largeArcFlag = angle > Math.PI ? 1 : 0;
paths += `<path d="M ${centerX} ${centerY} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2} Z" fill="${colors[index % colors.length]}" stroke="white" stroke-width="2"/>`;
legends += `<div style="display: flex; align-items: center; margin: 5px 0;">
<div style="width: 20px; height: 20px; background: ${colors[index % colors.length]}; margin-right: 10px; border-radius: 3px;"></div>
<span>${labels[index]}: ${value} (${percentage.toFixed(1)}%)</span>
</div>`;
currentAngle = endAngle;
});
const svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<text x="${centerX}" y="30" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="#333">${title}</text>
${paths}
</svg>`;
// 确保 SVG 正确编码
const cleanSvg = svg.replace(/\s+/g, ' ').trim();
const base64 = Buffer.from(cleanSvg, 'utf8').toString('base64');
return {
title,
type: 'pie',
image: `data:image/svg+xml;base64,${base64}`,
legend: legends
};
}
function generateBarChartBase64(title, labels, data) {
// 数据验证
if (!data || !labels || data.length === 0 || labels.length === 0) {
console.log('Invalid data for bar chart:', { title, labels, data });
return null;
}
const width = 500;
const height = 300;
const margin = { top: 50, right: 30, bottom: 60, left: 60 };
const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom;
// 确保数据是数字
const numericData = data.map(d => parseFloat(d) || 0);
const maxValue = Math.max(...numericData, 0);
const minValue = Math.min(...numericData, 0);
const range = maxValue - minValue || 1;
const colors = ['#667eea', '#764ba2', '#f093fb', '#f5576c', '#4facfe', '#00f2fe'];
const barWidth = chartWidth / data.length * 0.8;
const barSpacing = chartWidth / data.length * 0.2;
let bars = '';
let xLabels = '';
numericData.forEach((value, index) => {
const barHeight = Math.abs(value - minValue) / range * chartHeight;
const x = margin.left + index * (barWidth + barSpacing) + barSpacing / 2;
const y = margin.top + chartHeight - barHeight;
bars += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" fill="${colors[index % colors.length]}" stroke="white" stroke-width="1"/>`;
bars += `<text x="${x + barWidth/2}" y="${y - 5}" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#333">${value.toFixed(1)}</text>`;
// X-axis labels - 截断长标签
const label = labels[index] ? labels[index].toString().substring(0, 10) : '';
xLabels += `<text x="${x + barWidth/2}" y="${height - 10}" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" fill="#666">${label}</text>`;
});
const svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<text x="${width/2}" y="25" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="#333">${title}</text>
<rect x="${margin.left}" y="${margin.top}" width="${chartWidth}" height="${chartHeight}" fill="#f8f9fa" stroke="#dee2e6"/>
<line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + chartHeight}" stroke="#666" stroke-width="1"/>
<line x1="${margin.left}" y1="${margin.top + chartHeight}" x2="${margin.left + chartWidth}" y2="${margin.top + chartHeight}" stroke="#666" stroke-width="1"/>
${bars}
${xLabels}
</svg>`;
// 确保 SVG 正确编码
const cleanSvg = svg.replace(/\s+/g, ' ').trim();
const base64 = Buffer.from(cleanSvg, 'utf8').toString('base64');
return {
title,
type: 'bar',
image: `data:image/svg+xml;base64,${base64}`,
legend: ''
};
}
function generateSimpleChartBase64(title, labels, data, type = 'bar') {
// 生成简单的 HTML Canvas 图表作为备选方案
const width = 400;
const height = 300;
const canvasScript = `
<canvas id="chart" width="${width}" height="${height}"></canvas>
<script>
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
// 清除画布
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, ${width}, ${height});
// 绘制标题
ctx.fillStyle = '#333333';
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.fillText('${title}', ${width/2}, 25);
const data = [${data.join(',')}];
const labels = [${labels.map(l => `'${l}'`).join(',')}];
const colors = ['#667eea', '#764ba2', '#f093fb', '#f5576c', '#4facfe', '#00f2fe'];
if ('${type}' === 'pie') {
// 饼图
const centerX = ${width/2};
const centerY = ${height/2};
const radius = 80;
const total = data.reduce((sum, val) => sum + val, 0);
let currentAngle = 0;
data.forEach((value, index) => {
const sliceAngle = (value / total) * 2 * Math.PI;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + sliceAngle);
ctx.closePath();
ctx.fillStyle = colors[index % colors.length];
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
currentAngle += sliceAngle;
});
} else {
// 柱状图
const margin = 60;
const chartWidth = ${width} - margin * 2;
const chartHeight = ${height} - margin * 2;
const barWidth = chartWidth / data.length * 0.8;
const maxValue = Math.max(...data);
data.forEach((value, index) => {
const barHeight = (value / maxValue) * chartHeight;
const x = margin + index * (chartWidth / data.length) + (chartWidth / data.length - barWidth) / 2;
const y = margin + chartHeight - barHeight;
ctx.fillStyle = colors[index % colors.length];
ctx.fillRect(x, y, barWidth, barHeight);
// 标签
ctx.fillStyle = '#666666';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(labels[index], x + barWidth/2, ${height} - 10);
ctx.fillText(value.toFixed(1), x + barWidth/2, y - 5);
});
}
// 转换为 base64
const dataURL = canvas.toDataURL('image/png');
document.body.innerHTML = '<img src="' + dataURL + '" alt="${title}" style="max-width: 100%; height: auto;" />';
</script>`;
return {
title,
type,
image: `data:text/html;base64,${Buffer.from(canvasScript).toString('base64')}`,
legend: ''
};
}
function generateSummarySection(analysisResult) {
if (!analysisResult.summary) return '';
const s = analysisResult.summary;
return `
<div class="section">
<h2>📈 Summary Statistics</h2>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Total Trades</div>
<div class="metric-value">${s.totalTrades || 0}</div>
</div>
<div class="metric-card">
<div class="metric-label">Closed Trades</div>
<div class="metric-value">${s.closedTrades || 0}</div>
</div>
<div class="metric-card">
<div class="metric-label">Total Profit</div>
<div class="metric-value ${(s.totalProfit || 0) >= 0 ? 'positive' : 'negative'}">${(s.totalProfit || 0).toFixed(2)}</div>
</div>
<div class="metric-card">
<div class="metric-label">Net Profit</div>
<div class="metric-value ${(s.netProfit || 0) >= 0 ? 'positive' : 'negative'}">${(s.netProfit || 0).toFixed(2)}</div>
</div>
${s.winRate !== undefined ? `
<div class="metric-card">
<div class="metric-label">Win Rate</div>
<div class="metric-value">${s.winRate.toFixed(1)}%</div>
</div>` : ''}
${s.performanceScore !== undefined ? `
<div class="metric-card">
<div class="metric-label">Performance Score</div>
<div class="metric-value">${s.performanceScore} (${s.grade})</div>
</div>` : ''}
</div>
</div>
`;
}
function generateChartsSection(chartData) {
if (!chartData || chartData.length === 0) return '';
let chartsHtml = '';
chartData.forEach(chart => {
if (chart) {
chartsHtml += `
<div class="chart-container">
<h3>${chart.title}</h3>
<img src="${chart.image}" alt="${chart.title}" class="chart-canvas" />
${chart.legend ? `<div style="margin-top: 20px; display: flex; flex-wrap: wrap; justify-content: center; gap: 15px;">${chart.legend}</div>` : ''}
</div>
`;
}
});
return chartsHtml ? `
<div class="section">
<h2>📊 Charts</h2>
${chartsHtml}
</div>
` : '';
}
function generateInsightsSection(analysisResult) {
if (!analysisResult.insights || analysisResult.insights.length === 0) return '';
const insightsList = analysisResult.insights.map(insight => `<li>${insight}</li>`).join('');
return `
<div class="section">
<div class="insights">
<h3>💡 Key Insights</h3>
<ul>${insightsList}</ul>
</div>
</div>
`;
}
function generateOrderInfoSection(analysisResult) {
if (!analysisResult.orderInfo) return '';
const orderInfo = analysisResult.orderInfo;
return `
<div class="section">
<h2>📋 Order Information (API Data)</h2>
<div class="metrics-grid">