给博客添加一个自动更新基金持仓盈亏的页面

技术 1个月前

今年开始理财,但因为购买渠道众多导致持仓比较分散需要打开各个APP查看盈亏情况,所以给博客加了一个自动更新持仓净值的页面(参考:示例页面)。主要使用的是天天基金的API接口。

代码分3个文件,分别是用以自动更新基金净值的update_funds.php,用以存储基金信息的funds.json,以及用以展示基金信息的模板文件。

使用方法

首先需要把update_funds.php文件上传到支持php的web服务器中,然后在/data目录创建名为funds.json的数据文件,其中name、code、cost_price、shares四个字段需要手动填写,分别对应基金名称、基金代码、持仓成本、持仓份额,每次update_funds.php文件时,脚本会把最新的净值写入到latest_net_value字段中并将更新时间写入到last_updated中,可以考虑设置计划任务来定期访问该脚本。

另外可以向json中加入update_enabled字段,当值为false时,会跳过更新基金净值,可以在基金清仓后添加,用以跳过基金净值更新。

前端展现的代码仅供参考,原理无非就是调取funds.json文件中的内容,并计算出收益((最新净值-持仓成本)*持仓份额)、收益率在前端页面展现。

update_funds.php

<?php
/*
 * 基金净值自动更新脚本
 * 数据源:https://fund.eastmoney.com/
 * 配置文件:funds.json
 */

// 配置参数 ================================================
define('USER_AGENTS', [
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
    'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15'
]);

define('REQUEST_DELAY', 1); // 请求间隔(秒)
define('MAX_RETRY', 2);     // 单基金重试次数

// 主程序 ==================================================
try {
    // 加载基金数据
    $funds = loadFundsData();
    
    // 遍历更新净值
    foreach ($funds as &$fund) {
        // 新增逻辑:检查是否允许更新
        if (isset($fund['update_enabled']) && !$fund['update_enabled']) {
            echo "跳过更新:{$fund['code']} (已禁用更新)\n";
            continue;
        }
        try {
            $result = fetchFundValue($fund['code']);
            
            // 只更新净值相关字段
            $fund['latest_net_value'] = $result['value'];
            $fund['last_updated'] = date('Y-m-d H:i:s');
            
            echo "成功更新:{$fund['code']} => {$result['value']}\n";
        } catch (Exception $e) {
            echo "更新失败:{$fund['code']} - {$e->getMessage()}\n";
            logError($fund['code'], $e->getMessage());
            continue;
        }
        
        // 遵守请求间隔
        sleep(REQUEST_DELAY);
    }
    
    // 保存更新后的数据
    saveFundsData($funds);
    echo "全部更新完成!\n";
    
} catch (Exception $e) {
    die("致命错误:" . $e->getMessage());
}

// 核心函数 ================================================

/**
 * 加载基金数据文件
 */
function loadFundsData() {
    $filename = '/data/funds.json';
    
    if (!file_exists($filename)) {
        throw new Exception("基金数据文件不存在");
    }
    
    $data = json_decode(file_get_contents($filename), true);
    
    if (json_last_error() !== JSON_ERROR_NONE) {
        throw new Exception("JSON解析错误:" . json_last_error_msg());
    }
    
    return $data;
}

/**
 * 保存基金数据文件
 */
function saveFundsData($data) {
    $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
    
    if (file_put_contents('/data/funds.json', $json) === false) {
        throw new Exception("文件保存失败");
    }
}

/**
 * 获取基金净值(带重试机制)
 */
function fetchFundValue($code) {
    for ($i = 0; $i <= MAX_RETRY; $i++) {
        try {
            return [
                'value' => getLatestNetValue($code),
                'timestamp' => time()
            ];
        } catch (Exception $e) {
            if ($i == MAX_RETRY) {
                throw $e;
            }
            usleep(500000 * ($i + 1)); // 递增延时
        }
    }
}

/**
 * 核心抓取逻辑
 */
function getLatestNetValue($code) {
    $ch = curl_init();
    
    curl_setopt_array($ch, [
        CURLOPT_URL => "https://fund.eastmoney.com/pingzhongdata/{$code}.js",
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 8,
        CURLOPT_HTTPHEADER => [
            'Referer: https://fund.eastmoney.com/',
            'User-Agent: USER_AGENTS[array_rand(USER_AGENTS)]'
        ]
    ]);
    
    $content = curl_exec($ch);
    
    // 错误处理
    if (curl_errno($ch)) {
        throw new Exception("网络请求失败:" . curl_error($ch));
    }
    
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    if ($httpCode != 200) {
        throw new Exception("HTTP错误代码:{$httpCode}");
    }
    
    curl_close($ch);
    
    // 解析数据
    if (preg_match('/Data_netWorthTrend\s*=\s*(\[.*?\])/s', $content, $matches)) {
        $data = json_decode($matches[1], true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            throw new Exception("JSON解析失败");
        }
        
        $latest = end($data);
        return $latest['y'];
    }
    
    throw new Exception("未找到净值数据");
}

funds.json示例

[
    {
        "name": "南方红利",
        "code": "008163",
        "cost_price": 1.1424,
        "shares": 43766.09,
        "latest_net_value": 1.1679,
        "last_updated": "2025-07-16 03:12:56"
    },
    {
        "name": "南方中债",
        "code": "006961",
        "cost_price": 1.3677,
        "shares": 36535.8,
        "latest_net_value": 1.3675,
        "last_updated": "2025-07-16 03:12:58"
    },
    {
        "name": "鹏华中债",
        "code": "008040",
        "cost_price": 1.0818,
        "shares": 46196.16,
        "latest_net_value": 1.0817,
        "last_updated": "2025-07-16 03:12:59"
    },
    {
        "name": "华泰红利",
        "code": "007467",
        "cost_price": 1.6458,
        "shares": 30380.4,
        "latest_net_value": 1.7166,
        "last_updated": "2025-07-15 14:01:51",
        "update_enabled": false
    },
    {
        "name": "摩根标普",
        "code": "019305",
        "cost_price": 1.4634,
        "shares": 823.61,
        "latest_net_value": 1.4636,
        "last_updated": "2025-07-16 03:13:00"
    },
    {
        "name": "纳斯达克",
        "code": "006479",
        "cost_price": 6.6801,
        "shares": 180.87,
        "latest_net_value": 6.6769,
        "last_updated": "2025-07-16 03:13:01"
    }
]

前端模板文件

<style>
    table.fund h1 {
        color: #2d2d2d;
        font-weight: 600;
        margin-bottom: 1.5rem;
        padding-bottom: 0.5rem;
        border-bottom: 2px solid #c62541;
    }
    
    table.fund {
        border-collapse: collapse;
        width: 100%;
        background: white;
        text-align: center;
        border-radius: 8px;
        margin-bottom: 5rem;
        overflow: hidden;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
        transition: box-shadow 0.3s ease;
    }
    
    table.fund:hover {
        box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
    }
    
    table.fund th {
        background: #c62541;
        color: white;
        padding: 14px 16px;
        font-weight: 600;
        text-transform: uppercase;
        font-size: 0.9em;
    }
    
    table.fund td {
        padding: 12px 16px;
        color: #444;
        border: 1px solid #f0f0f0;
    }
    
    table.fund tr:last-child td {
        border-bottom: none;
    }
    
    table.fund tr:hover td {
        background: #fff5f7;
    }
    
    table.fund .positive {
        color: #c62541;
        font-weight: 500;
    }
    
    table.fund .negative {
        color: #27ae60;
        font-weight: 500;
    }
    
    /* 响应式处理 */
    @media (max-width: 768px) {
        table.fund td, th {
            padding: 10px 12px;
            font-size: 0.9em;
        }
        
        table.fund h1 {
            font-size: 1.4rem;
        }
    }
    
    /* 时间显示样式 */
    table.fund #current-time {
        color: #c62541;
        font-weight: 500;
    }
</style>
<table>
    <thead>
        <tr>
            <th>基金代码</th>
            <th>持有份额</th>
            <th>成本价</th>
            <th>当前净值</th>
            <th>持仓收益</th>
            <th>更新时间</th>
        </tr>
    </thead>
    <tbody id="funds-body"></tbody>
    <tfoot class="highlight" id="summary-footer"></tfoot>
</table>
<p style="margin-top: 1rem; color: #666;">
    数据更新频率:每日凌晨3点自动更新
    <br>当前时间:<span id="current-time"></span>
</p>
<script>
    // 配置参数
    const JSON_URL = 'https://static.goldrun.click/json/fund.json';
    // 初始化
    document.addEventListener('DOMContentLoaded', async () => {
        try {
            const funds = await loadFundsData();
            renderTable(funds);
            startClock();
        } catch (error) {
            showError(error.message);
        }
    });
    // 加载远程数据
    async function loadFundsData() {
        try {
            const response = await fetch(JSON_URL);
            if (!response.ok) throw new Error('网络响应异常');
            return await response.json();
        } catch (error) {
            throw new Error('数据加载失败,请稍后刷新');
        }
    }
    // 渲染表格
    function renderTable(funds) {
        let total = { cost: 0, current: 0, profit: 0 };
        const tbody = document.getElementById('funds-body');
        // 生成数据行
        tbody.innerHTML = funds.map(fund => {
            const fundCost = fund.cost_price * fund.shares;
            const fundCurrent = fund.latest_net_value * fund.shares;
            const profit = fundCurrent - fundCost;
            // 累计总数
            total.cost += fundCost;
            total.current += fundCurrent;
            total.profit += profit;
            return `
            <tr>
                <td>${fund.code}</td>
                <td>${formatNumber(fund.shares, 2)}</td>
                <td>${formatNumber(fund.cost_price, 4)}</td>
                <td>${fund.latest_net_value ? formatNumber(fund.latest_net_value, 4) : '--'}</td>
                <td class="${profit >= 0 ? 'positive' : 'negative'}">
                    ${formatNumber(profit, 2)}
                </td>
                <td>${fund.last_updated || '--'}</td>
            </tr>`;
        }).join('');
        // 生成汇总行
        const footer = document.getElementById('summary-footer');
        footer.innerHTML = `
            <tr>
                <td colspan="4">总收益</td>
                <td class="${total.profit >= 0 ? 'positive' : 'negative'}">
                    ${formatNumber(total.profit, 2)}
                </td>
                <td></td>
            </tr>
            <tr>
                <td colspan="4">总收益率</td>
                <td class="${total.profit >= 0 ? 'positive' : 'negative'}">
                    ${total.cost ? formatNumber((total.profit / total.cost) * 100, 2) + '%' : '--'}
                </td>
                <td></td>
            </tr>`;
    }
    // 数字格式化
    function formatNumber(num, digits) {
        return num.toLocaleString('zh-CN', {
            minimumFractionDigits: digits,
            maximumFractionDigits: digits
        });
    }
    // 实时时钟
    function startClock() {
        function updateTime() {
            document.getElementById('current-time').textContent = 
                new Date().toLocaleString('zh-CN');
        }
        updateTime();
        setInterval(updateTime, 1000);
    }
    // 错误显示
    function showError(message) {
        const tbody = document.getElementById('funds-body');
        tbody.innerHTML = `<tr><td colspan="6" style="color:red">${message}</td></tr>`;
    }
</script>

评论(0)

发布评论

相关文章