// ==UserScript== // @name AGSV股票持仓收益分析 // @namespace http://tampermonkey.net/ // @version 0.2.0 // @license MIT License // @description AGSV股市辅助收益计算,结构优化,逻辑更健壮。 // @author PandaChan // @match https://stock.agsvpt.cn/ // @icon https://stock.agsvpt.cn/plugins/stock/favicon.svg // @grant GM_xmlhttpRequest // @downloadURL none // ==/UserScript== (function () { 'use strict'; // --- 配置常量 --- const API_BASE_URL = 'https://stock.agsvpt.cn/api'; const CONFIG = { API_INFO_URL: `${API_BASE_URL}/stocks/info`, API_HISTORY_URL: `${API_BASE_URL}/user/history?&page=1&page_size=10000`, TARGET_TABLE_DIV: 'div.positions-container', TARGET_TABLE_SELECTOR: 'div.positions-container table', TOKEN_KEY: 'auth_token', HEADERS: { 'Content-Type': 'application/json', }, }; // --- 1. API 请求模块 --- /** * 封装 GM_xmlhttpRequest 为一个 Promise-based 函数 * @param {string} url - 请求的URL * @param {object} options - GM_xmlhttpRequest 的配置对象 * @returns {Promise} - 返回解析后的 JSON 数据 */ function fetchApiData(url, options) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'json', timeout: 8000, ...options, onload: response => { if (response.status >= 200 && response.status < 300) { resolve(response.response); } else { reject(new Error(`HTTP Error: ${response.status} ${response.statusText}`)); } }, onerror: response => reject(new Error('请求失败: ' + response.statusText)), ontimeout: () => reject(new Error('请求超时')), }); }); } // --- 2. 数据计算模块 --- /** * 使用加权平均法计算投资组合表现 * @param {Array} transactions - 交易历史记录 * @param {Array} realTimePrices - 实时价格数据 * @returns {object} - 计算后的持仓摘要 */ function calculatePortfolioPerformance(transactions, realTimePrices) { const holdings = {}; // 从最早的交易开始计算 transactions.slice().reverse().forEach(transaction => { const {stock_code, quantity, price, fee, type, name} = transaction; if (!holdings[stock_code]) { holdings[stock_code] = { name, quantity: 0, totalCost: 0.0, }; } const stock = holdings[stock_code]; if (type === 'BUY') { stock.quantity += quantity; stock.totalCost += (price * quantity) + fee; } else if (type === 'SELL') { if (stock.quantity > 0) { const avgCost = stock.totalCost / stock.quantity; stock.totalCost -= avgCost * quantity; stock.quantity -= quantity; } else { // 处理无持仓卖出的情况(例如融券),仅调整数量 stock.quantity -= quantity; } // 避免浮点数精度问题 if (stock.quantity <= 0) { stock.quantity = 0; stock.totalCost = 0; } } }); const pricesMap = new Map(realTimePrices.map(item => [item.code, item.price])); const portfolioSummary = {}; for (const code in holdings) { const stock = holdings[code]; const {name, quantity, totalCost} = stock; const currentPrice = pricesMap.get(code); if (quantity <= 0) continue; // 如果没有持仓,则不显示 const costPerShare = totalCost / quantity; let profitLoss = 'N/A', returnRate = 'N/A'; if (currentPrice !== undefined) { const marketValue = currentPrice * quantity; const calculatedProfitLoss = marketValue - totalCost; profitLoss = parseFloat(calculatedProfitLoss.toFixed(2)); returnRate = totalCost !== 0 ? parseFloat(((calculatedProfitLoss / totalCost) * 100).toFixed(2)) : 0; } portfolioSummary[code] = { name, quantity, totalHoldingCost: parseFloat(totalCost.toFixed(2)), costPerShare: parseFloat(costPerShare.toFixed(2)), estimatedProfitLoss: profitLoss, estimatedReturnRate: returnRate, }; } return portfolioSummary; } // --- 3. DOM 操作模块 --- /** * 创建并格式化一个表格单元格 () * @param {string|number} content - 单元格内容 * @param {string|null} color - 文本颜色 (e.g., 'green', 'red') * @returns {HTMLTableCellElement} */ function createCell(content, color = null) { const cell = document.createElement('td'); cell.textContent = content; if (color) { cell.style.color = color; } return cell; } /** * 向表格中注入计算后的持仓数据 * @param {object} calculatedHoldings - 计算后的持仓数据 */ function injectDataIntoTable(calculatedHoldings) { const table = document.querySelector(CONFIG.TARGET_TABLE_SELECTOR); if (!table || table.dataset.enhanced) return; // 如果没找到表格或已处理过,则退出 table.dataset.enhanced = 'true'; // 标记为已处理 const headerRow = table.querySelector('thead tr, tbody tr'); // 兼容不同 a-table 的 a-table-thead const dataBody = table.querySelectorAll('tbody')[1] || table.querySelector('tbody'); if (!headerRow || !dataBody) { console.warn('AGSV脚本: 未找到表格的表头或数据体。'); return; } // 添加表头 const headers = ['持仓均价', '持仓成本', '预计收益', '预计收益率']; headers.forEach(text => { const th = document.createElement('th'); th.textContent = text; headerRow.appendChild(th); }); const stockNameToCodeMap = Object.fromEntries( Object.entries(calculatedHoldings).map(([code, data]) => [data.name, code]) ); // 填充数据行 dataBody.querySelectorAll('tr').forEach(row => { const stockName = row.cells[0]?.textContent.trim(); if (!stockName) return; const stockCode = stockNameToCodeMap[stockName]; const stockData = calculatedHoldings[stockCode]; if (stockData) { const {costPerShare, totalHoldingCost, estimatedProfitLoss, estimatedReturnRate} = stockData; const profitColor = estimatedProfitLoss > 0 ? 'green' : (estimatedProfitLoss < 0 ? 'red' : null); row.appendChild(createCell(costPerShare.toFixed(2))); row.appendChild(createCell(totalHoldingCost.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }))); row.appendChild(createCell( estimatedProfitLoss === 'N/A' ? 'N/A' : estimatedProfitLoss.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }), profitColor )); row.appendChild(createCell( estimatedReturnRate === 'N/A' ? '--' : `${estimatedReturnRate.toFixed(2)}%`, profitColor )); } else { // 如果没有数据,填充空单元格以保持对齐 for (let i = 0; i < 4; i++) { row.appendChild(createCell('--')); } } }); console.log('AGSV脚本: 持仓收益分析数据已成功注入表格。'); } // --- 4. 主执行函数 --- async function main() { const token = localStorage.getItem(CONFIG.TOKEN_KEY); if (!token) { console.warn('AGSV脚本: 未找到认证Token,脚本无法运行。'); return; } const authHeader = {'authorization': `Bearer ${token}`}; let dataLoadFinish = false; let tableDomReady = false; let prices; let history; // 处理函数, 当数据加载完成且表格渲染出TD后执行逻辑 const processExpandInfo = function () { if (!dataLoadFinish || !tableDomReady) { return; } const calculatedHoldings = calculatePortfolioPerformance(history.data, prices); console.log("AGSV脚本: 分析结果", calculatedHoldings); injectDataIntoTable(calculatedHoldings); }; // 异步请求数据, 数据请求完成后调用处理函数 Promise.all([ fetchApiData(CONFIG.API_INFO_URL, {headers: authHeader}), fetchApiData(CONFIG.API_HISTORY_URL, {headers: authHeader}) ]).then(results => { prices = results[0]; history = results[1]; dataLoadFinish = true; processExpandInfo(); }).catch(error => { }); // 使用 MutationObserver 监视表格渲染出TD, 渲染完成后调用处理函数 const observer = new MutationObserver(async (mutations, obs) => { const tableDom = document.querySelector(CONFIG.TARGET_TABLE_SELECTOR); const td = tableDom.querySelector('td'); if (td) { obs.disconnect(); // 找到表格后停止观察 tableDomReady = true; processExpandInfo(); } }); // 启动观察器 observer.observe(document, { childList: true, subtree: true }); } // --- 启动脚本 --- main().catch(error => { console.error('AGSV脚本: 启动时发生严重错误:', error); }); })();