Greasy Fork

Greasy Fork is available in English.

AGSV股票持仓收益及借入收益分析

AGSV股市辅助收益计算,结构优化,逻辑更健壮,包含借入收益分析。

// ==UserScript==
// @name         AGSV股票持仓收益及借入收益分析
// @namespace    http://tampermonkey.net/
// @version      0.3.1
// @license      MIT License
// @description  AGSV股市辅助收益计算,结构优化,逻辑更健壮,包含借入收益分析。
// @author       PandaChan & AGSV骄阳
// @match        https://stock.agsvpt.cn/
// @icon         https://stock.agsvpt.cn/plugins/stock/favicon.svg
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(async 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',
        },
    };

    // 获取身份验证的 token
    const token = localStorage.getItem(CONFIG.TOKEN_KEY);
    if (!token) {
        console.warn('未找到认证Token,脚本无法运行。');
        return;
    }

    // --- 1. API 请求模块 ---
    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('请求超时')),
            });
        });
    }

    // 获取当前价格的函数
    const getCurrentPrice = async function () {
        return fetchApiData(CONFIG.API_INFO_URL, {
            headers: {
                'authorization': 'Bearer ' + token
            }
        });
    };

    // --- 2. 数据计算模块 ---
    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 操作模块 ---
    function createCell(content, color = null) {
        const cell = document.createElement('td');
        cell.textContent = content;
        if (color) {
            cell.style.color = color;
        }
        return cell;
    }

    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');
        const dataBody = table.querySelectorAll('tbody')[1] || table.querySelector('tbody');

        if (!headerRow || !dataBody) {
            console.warn('未找到表格的表头或数据体。');
            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('持仓收益分析数据已成功注入表格。');
    }

    // --- 4. 借入收益分析模块 ---
    const insertEstimatedProfitColumns = function (table) {
        const headerRow = table.querySelector('thead tr');
        const thCostBasis = document.createElement('th');
        thCostBasis.textContent = '持仓成本';
        headerRow.appendChild(thCostBasis);

        const thCurrentMarketValue = document.createElement('th');
        thCurrentMarketValue.textContent = '当前市值';
        headerRow.appendChild(thCurrentMarketValue);

        const thEstimatedProfit = document.createElement('th');
        thEstimatedProfit.textContent = '预计收益';
        headerRow.appendChild(thEstimatedProfit);

        const thEstimatedProfitRate = document.createElement('th');
        thEstimatedProfitRate.textContent = '预计收益率 (%)';
        headerRow.appendChild(thEstimatedProfitRate);
    };

    const appendEstimatedProfitColumnsToTable = async function () {
        const currentPrices = await getCurrentPrice();
        const pricesMap = new Map();
        currentPrices.forEach(item => {
            pricesMap.set(item.name, item.price);
        });

        const dataTbody = document.querySelector("#root > div > div.positions-container > div > div:nth-child(2) > table > tbody");
        if (!dataTbody) {
            console.warn('未找到包含数据行的tbody元素。');
            return;
        }

        const table = dataTbody.closest('table');
        insertEstimatedProfitColumns(table);

        dataTbody.querySelectorAll('tr').forEach(row => {
            const cells = row.querySelectorAll('td');
            if (cells.length > 0) {
                const stockName = cells[1].textContent.trim();
                const borrowedQuantity = parseFloat(cells[2].textContent.trim());
                const unitPrice = parseFloat(cells[3].textContent.trim());
                const unpaidInterest = parseFloat(cells[5].textContent.trim());

                const currentPrice = pricesMap.get(stockName);
                console.log(`股票名称: ${stockName}, 借入数量: ${borrowedQuantity}, 单位价值: ${unitPrice}, 当前价格: ${currentPrice}`);

                const tdCostBasis = document.createElement('td');
                const tdCurrentMarketValue = document.createElement('td');
                const tdEstimatedProfit = document.createElement('td');
                const tdEstimatedProfitRate = document.createElement('td');

                const costBasis = (unitPrice * borrowedQuantity) + unpaidInterest;
                tdCostBasis.textContent = costBasis.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });

                if (!isNaN(borrowedQuantity) && !isNaN(unitPrice) && currentPrice !== undefined) {
                    const currentMarketValue = borrowedQuantity * currentPrice;
                    tdCurrentMarketValue.textContent = currentMarketValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });

                    const estimatedProfit = costBasis - currentMarketValue;
                    tdEstimatedProfit.textContent = estimatedProfit.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });

                    const estimatedProfitRate = costBasis > 0 ? (estimatedProfit / costBasis) * 100 : 0;
                    tdEstimatedProfitRate.textContent = estimatedProfitRate.toFixed(2) + '%';

                    if (estimatedProfit < 0) {
                        tdEstimatedProfit.style.color = 'red';
                        tdEstimatedProfitRate.style.color = 'red';
                    } else {
                        tdEstimatedProfit.style.color = 'green';
                        tdEstimatedProfitRate.style.color = 'green';
                    }
                } else {
                    tdCurrentMarketValue.textContent = 'N/A';
                    tdEstimatedProfit.textContent = 'N/A';
                    tdEstimatedProfitRate.textContent = 'N/A';
                }

                row.appendChild(tdCostBasis);
                row.appendChild(tdCurrentMarketValue);
                row.appendChild(tdEstimatedProfit);
                row.appendChild(tdEstimatedProfitRate);
            }
        });
    };

    // --- 5. 主执行函数 ---
    async function main() {
        let prices;
        let history;

        const authHeader = { 'authorization': `Bearer ${token}` };

        let dataLoadFinish = false;
        let tableDomReady = false;

        const processExpandInfo = function () {
            if (!dataLoadFinish || !tableDomReady) {
                return;
            }
            const calculatedHoldings = calculatePortfolioPerformance(history.data, prices);
            console.log("分析结果", calculatedHoldings);
            injectDataIntoTable(calculatedHoldings);
            appendEstimatedProfitColumnsToTable();
        };

        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 => {
            console.error('数据请求错误:', error);
        });

        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('启动时发生严重错误:', error);
    });

})();