171 lines
5.5 KiB
JavaScript
171 lines
5.5 KiB
JavaScript
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);
|