Files
server-configs/stock_report.js
2026-02-13 22:24:27 +08:00

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