const { createCanvas } = require('canvas'); const fs = require('fs'); const https = require('https'); function getPrice(code) { return new Promise((resolve) => { https.get(`https://qt.gtimg.cn/q=us${code}`, (resp) => { let data = ''; resp.on('data', (chunk) => { data += chunk; }); resp.on('end', () => { try { const parts = data.split('~'); resolve(parseFloat(parts[3])); } catch (e) { resolve(null); } }); }).on('error', () => resolve(null)); }); } const holdings = { "SGOV": { shares: 33.7072, cost: 100.34, name: "iShares 0-3月国债ETF" }, "BND": { shares: 6.7219, cost: 74.62, name: "Vanguard 全债ETF" }, "VOO": { shares: 3.1963, cost: 616.32, name: "Vanguard 标普500 ETF" }, "MSFT": { shares: 1, cost: 439.00, name: "Microsoft" } }; function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x+r, y); ctx.lineTo(x+w-r, y); ctx.quadraticCurveTo(x+w, y, x+w, y+r); ctx.lineTo(x+w, y+h-r); ctx.quadraticCurveTo(x+w, y+h, x+w-r, y+h); ctx.lineTo(x+r, y+h); ctx.quadraticCurveTo(x, y+h, x, y+h-r); ctx.lineTo(x, y+r); ctx.quadraticCurveTo(x, y, x+r, y); ctx.closePath(); } async function createReport() { const width = 450; const height = 520; const canvas = createCanvas(width, height); const ctx = canvas.getContext('2d'); // 设置中文字体 ctx.font = '32px "Noto Sans CJK SC", "WenQuanYi Micro Hei", Arial'; // 背景 const bgGradient = ctx.createLinearGradient(0, 0, width, height); bgGradient.addColorStop(0, '#1a1a2e'); bgGradient.addColorStop(1, '#0f0f1a'); ctx.fillStyle = bgGradient; ctx.fillRect(0, 0, width, height); // 顶部标题 ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 15; ctx.fillStyle = '#ffffff'; ctx.font = 'bold 32px "Noto Sans CJK SC", "WenQuanYi Micro Hei", Arial'; ctx.textAlign = 'center'; ctx.fillText('我的持仓', width/2, 45); ctx.shadowBlur = 0; ctx.font = '16px "Noto Sans CJK SC", "WenQuanYi Micro Hei", Arial'; ctx.fillStyle = '#666666'; ctx.fillText(new Date().toISOString().split('T')[0], width/2, 75); // 获取数据 const items = []; for (const [code, info] of Object.entries(holdings)) { const price = await getPrice(code); if (price) { const value = info.shares * price; const cost = info.shares * info.cost; const pnl = value - cost; const pnlPct = (pnl / cost) * 100; items.push({ code, name: info.name, price, shares: info.shares, pnl, pnlPct, value, cost }); } } items.sort((a, b) => b.pnl - a.pnl); // 绘制每只股票 let y = 105; const cardHeight = 85; for (let i = 0; i < items.length; i++) { const item = items[i]; const pnlColor = item.pnl >= 0 ? '#00ff88' : '#ff4757'; const isProfit = item.pnl >= 0; // 卡片背景 ctx.fillStyle = isProfit ? 'rgba(0,255,136,0.08)' : 'rgba(255,71,87,0.08)'; roundRect(ctx, 25, y, width-50, cardHeight, 12); ctx.fill(); // 左侧装饰条 ctx.fillStyle = pnlColor; roundRect(ctx, 25, y, 4, cardHeight, 12); ctx.fill(); // 股票代码 ctx.fillStyle = '#ffffff'; ctx.font = 'bold 24px Arial'; ctx.textAlign = 'left'; ctx.fillText(item.code, 40, y+30); // 股票名 ctx.fillStyle = '#666666'; ctx.font = '12px "Noto Sans CJK SC", "WenQuanYi Micro Hei", Arial'; ctx.fillText(item.name.substring(0, 16), 40, y+48); // 价格 ctx.fillStyle = '#ffffff'; ctx.font = 'bold 26px Arial'; ctx.textAlign = 'right'; ctx.fillText(`$${item.price.toFixed(2)}`, width-35, y+30); // 盈亏 ctx.fillStyle = pnlColor; ctx.font = 'bold 16px Arial'; const pnlSign = item.pnl >= 0 ? '+' : ''; ctx.fillText(`${pnlSign}$${item.pnl.toFixed(2)} (${pnlSign}${item.pnlPct.toFixed(2)}%)`, width-35, y+55); y += cardHeight + 10; } // 底部总计 const totalCost = items.reduce((sum, i) => sum + i.cost, 0); const totalValue = items.reduce((sum, i) => sum + i.value, 0); const totalPnl = totalValue - totalCost; const totalPnlPct = (totalPnl / totalCost) * 100; const totalColor = totalPnl >= 0 ? '#00ff88' : '#ff4757'; // 总计背景 y += 5; ctx.fillStyle = 'rgba(0,255,136,0.12)'; roundRect(ctx, 20, y, width-40, 120, 16); ctx.fill(); // 左侧装饰条 ctx.fillStyle = totalColor; roundRect(ctx, 20, y, 6, 120, 16); ctx.fill(); // 标签 ctx.fillStyle = '#888888'; ctx.font = '14px "Noto Sans CJK SC", "WenQuanYi Micro Hei", Arial'; ctx.textAlign = 'center'; ctx.fillText('总资产', width/2, y+32); // 总资产 ctx.fillStyle = '#ffffff'; ctx.font = 'bold 38px Arial'; ctx.fillText(`$${totalValue.toLocaleString('en-US', {minimumFractionDigits: 2})}`, width/2, y+68); // 总盈亏 ctx.fillStyle = totalColor; ctx.font = 'bold 24px Arial'; const pnlSign = totalPnl >= 0 ? '+' : ''; ctx.fillText(`${pnlSign}$${totalPnl.toFixed(2)} (${pnlSign}${totalPnlPct.toFixed(2)}%)`, width/2, y+100); // 保存 const buffer = canvas.toBuffer('image/png'); fs.writeFileSync('/root/.openclaw/workspace/stock_report.png', buffer); console.log('图片已生成'); } createReport().catch(console.error);