用Python写基金定投收益计算系统:完整代码分享
定投三年还在亏?可能是你的计算方法有问题。这套系统帮你看清楚定投的真实回报
我身边有一个很有意思的现象:很多人做了三年基金定投,但不知道自己的真实年化收益率是多少。他们只知道:"我每个月投2000,现在账户里有8万。"但这8万相对于总共投入的7.2万,到底年化多少?没有人能马上回答出来。
这不是个例。这说明大多数人做定投,只关注了"总额",而没有关注"效率"。同样两个人,同样每月投2000,定投三年,一个人最终账户8万,另一个人最终账户9.2万——表面看差1.2万,但算上年化收益率,可能差距是巨大的。
今天,我用Python写了一套完整的基金定投收益计算系统,把所有重要的定投指标都算清楚。
一、为什么你的定投"收益率"总是算错
基金定投收益率的计算,比一次性买入复杂得多。原因很简单:每一笔投入资金的时间成本是不一样的。
一次性买入的收益率计算很简单:(当前净值 - 买入净值)/ 买入净值。但定投不是。定投是你每个月往里面投钱,第一笔投的钱可能已经持有了三年,第二笔投的钱持有了一年,最新一笔投的钱可能只持有了两周。这三笔钱的"时间成本"完全不同。
所以,定投的正确收益率指标,不是"总收益率",而是"年化收益率"(XIRR),或者"内部收益率"(IRR)。这两个概念,把每笔资金的时间成本都纳入了计算,给出的才是真正公平的比较。
数学原理:XIRR(扩展内部收益率)解决了定期现金流不等额、时点不均匀的问题。每一笔投入日期不同,每一笔投入金额不同,XIRR通过迭代算法找到使净现值(NPV=0)的折现率,这个折现率就是你的真实年化收益率。
二、完整Python计算系统
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
FundDIPCalculator v3.0 - 基金定投收益完整计算系统
功能:计算定投真实年化收益率、XIRR、累计收益率、持有成本等
"""
import numpy as np
import pandas as pd
from datetime import datetime
from scipy.optimize import brentq
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS']
plt.rcParams['axes.unicode_minus'] = False
class FundDIPCalculator:
def __init__(self, fund_code=None, fund_name='某指数基金'):
self.fund_code = fund_code
self.fund_name = fund_name
self.transactions = [] # 交易记录
self.nav_data = [] # 净值数据
def add_investment(self, date_str, amount, nav=1.0):
"""
添加一笔定投记录
date_str: 投资日期,格式 '2024-01-15'
amount: 投入金额(元)
nav: 买入时的基金净值(可选,如果不知道可设为1.0)
"""
date = pd.to_datetime(date_str)
shares = amount / nav # 买入份额
self.transactions.append({
'date': date,
'type': 'BUY',
'amount': amount,
'nav': nav,
'shares': shares
})
def add_redemption(self, date_str, shares, nav):
"""
添加一笔赎回记录
"""
date = pd.to_datetime(date_str)
self.transactions.append({
'date': date,
'type': 'REDEEM',
'shares': shares,
'nav': nav,
'amount': shares * nav
})
def compute_xirr(self):
"""
计算XIRR(扩展内部收益率)
核心算法:使用Brent迭代法求解NPV=0的折现率
XIRR的意义:每一笔钱的投入时间都被精确考虑,
最终得到一个"年化"收益率,是定投的真实回报率
"""
if not self.transactions:
return None
df = pd.DataFrame(self.transactions).sort_values('date')
def xnpv(rate, cash_flows, dates, initial_value=0):
"""
计算给定折现率下的净现值(NPV)
cash_flows: 每笔现金流(投入为负,赎回为正)
dates: 每笔现金流对应日期
"""
if len(cash_flows) != len(dates):
raise ValueError('现金流与日期数量必须一致')
base_date = dates[0]
npv = initial_value
for cf, d in zip(cash_flows, dates):
year_fraction = (d - base_date).days / 365.25
npv += cf / ((1 + rate) ** year_fraction)
return npv
# 构建现金流(投入为负,赎回为正)
cash_flows = []
dates = []
for _, t in df.iterrows():
if t['type'] == 'BUY':
cash_flows.append(-t['amount']) # 投入是负现金流
dates.append(t['date'])
elif t['type'] == 'REDEEM':
cash_flows.append(t['amount']) # 赎回是正现金流
dates.append(t['date'])
# 如果最后一笔不是赎回,追加当前市值作为最终现金流
last_tx = df.iloc[-1]
if last_tx['type'] == 'BUY':
current_nav = self.nav_data[-1]['nav'] if self.nav_data else 1.0
total_shares = df[df['type']=='BUY']['shares'].sum()
current_value = total_shares * current_nav
# 最后一笔投入日期作为最终时点
final_date = pd.to_datetime(self.nav_data[-1]['date']) if self.nav_data else datetime.now()
# 用最后一笔投入日期作为终值计算日
cash_flows.append(current_value)
dates.append(final_date)
try:
# Brent法迭代求解XIRR
rate = brentq(
lambda r: xnpv(r, cash_flows, dates),
-0.99, 10.0, # 折现率搜索区间:-99% 到 1000%
xtol=1e-10, maxiter=500
)
return rate
except Exception as e:
print(f'[ERROR] XIRR计算失败: {e}')
return None
def compute_simple_return(self):
"""
计算简单累计收益率(不考虑时间成本)
简单收益率 = (当前总市值 - 总投入) / 总投入
"""
df = pd.DataFrame(self.transactions)
total_invested = abs(df[df['type']=='BUY']['amount'].sum())
total_shares = df[df['type']=='BUY']['shares'].sum()
if self.nav_data:
current_nav = self.nav_data[-1]['nav']
current_value = total_shares * current_nav
else:
return None
return (current_value - total_invested) / total_invested
def compute_avg_cost(self):
"""
计算定投平均持仓成本
平均成本 = 总投入金额 / 总持有份额
"""
df = pd.DataFrame(self.transactions)
total_invested = df[df['type']=='BUY']['amount'].sum()
total_shares = df[df['type']=='BUY']['shares'].sum()
return total_invested / total_shares if total_shares > 0 else None
def compute_holding_period(self):
"""
计算持有时长(年)
"""
if not self.transactions:
return None
df = pd.DataFrame(self.transactions).sort_values('date')
first_date = df.iloc[0]['date']
last_date = df.iloc[-1]['date']
if self.nav_data:
last_date = pd.to_datetime(self.nav_data[-1]['date'])
return (last_date - first_date).days / 365.25
def compute_annualized_return(self):
"""
计算年化收益率(持有期不足一年时,年化收益 = 累计收益 / 持有年数)
注意:这不是XIRR,是简化算法
"""
simple_ret = self.compute_simple_return()
years = self.compute_holding_period()
if simple_ret is None or years is None or years == 0:
return None
return (1 + simple_ret) ** (1 / years) - 1
def compute_profit_factor(self):
"""
计算盈利因子 = 总盈利金额 / 总亏损金额
(需要历史净值数据才能计算)
"""
if len(self.nav_data) < 2:
return None
df_nav = pd.DataFrame(self.nav_data)
df_nav['pct_change'] = df_nav['nav'].pct_change()
gains = df_nav[df_nav['pct_change'] > 0]['pct_change'].sum()
losses = abs(df_nav[df_nav['pct_change'] < 0]['pct_change'].sum())
return gains / losses if losses > 0 else None
def load_nav_history(self, nav_list):
"""
加载基金净值历史数据
nav_list: [{'date': '2024-01-01', 'nav': 1.234}, ...]
"""
self.nav_data = nav_list
def generate_full_report(self):
"""生成完整的定投分析报告"""
xirr = self.compute_xirr()
simple_ret = self.compute_simple_return()
avg_cost = self.compute_avg_cost()
years = self.compute_holding_period()
annual_ret = self.compute_annualized_return()
profit_factor = self.compute_profit_factor()
df = pd.DataFrame(self.transactions)
total_invested = abs(df[df['type']=='BUY']['amount'].sum())
total_shares = df[df['type']=='BUY']['shares'].sum()
if self.nav_data:
current_nav = self.nav_data[-1]['nav']
current_value = total_shares * current_nav
else:
current_value = None
print('=' * 60)
print(f' 基金定投收益分析报告 - {self.fund_name}')
print('=' * 60)
print(f' 基金代码: {self.fund_code or "N/A"}')
print(f' 持有时长: {years:.2f} 年' if years else ' 持有时长: N/A')
print(f' 总投入金额: {total_invested:>12,.2f} 元')
print(f' 当前总市值: {current_value:>12,.2f} 元' if current_value else ' 当前总市值: N/A')
print(f' 累计收益率: {simple_ret:>12.2%}' if simple_ret else ' 累计收益率: N/A')
print(f' XIRR(真实年化): {xirr:>12.2%}' if xirr else ' XIRR(真实年化): N/A')
print(f' 年化收益率: {annual_ret:>12.2%}' if annual_ret else ' 年化收益率: N/A')
print(f' 平均持仓成本: {avg_cost:>12.4f}' if avg_cost else ' 平均持仓成本: N/A')
print(f' 盈利因子: {profit_factor:>12.4f}' if profit_factor else ' 盈利因子: N/A')
print(f' 总盈利金额: {current_value - total_invested:>12,.2f} 元' if current_value else ' 总盈利金额: N/A')
print('=' * 60)
return {
'xirr': xirr,
'simple_return': simple_ret,
'annualized_return': annual_ret,
'avg_cost': avg_cost,
'profit_factor': profit_factor,
'total_invested': total_invested,
'current_value': current_value,
'holding_years': years,
'total_shares': total_shares
}
if __name__ == '__main__':
calc = FundDIPCalculator(fund_code='110020', fund_name='易方达沪深300ETF联接')
# 模拟36个月定投数据(实际使用时替换为真实净值数据)
import random
np.random.seed(42)
base_nav = 1.0
nav_history = []
current_nav = base_nav
# 生成36个月的历史净值(牛市+熊市+震荡)
for month in range(36):
m_date = f'2024-{month+1:02d}-01'
# 模拟净值走势:先涨后跌再震荡
if month < 10:
nav_change = np.random.normal(0.015, 0.03)
elif month < 20:
nav_change = np.random.normal(-0.008, 0.04)
else:
nav_change = np.random.normal(0.003, 0.02)
current_nav *= (1 + nav_change)
nav_history.append({'date': m_date, 'nav': round(current_nav, 4)})
calc.load_nav_history(nav_history)
# 模拟每月定投2000元
for month in range(36):
inv_date = f'2024-{month+1:02d}-15'
inv_nav = nav_history[month]['nav']
calc.add_investment(inv_date, 2000.0, nav=inv_nav)
# 生成完整报告
report = calc.generate_full_report()
# 绘制收益曲线
df_tx = pd.DataFrame(calc.transactions).sort_values('date')
df_nav = pd.DataFrame(nav_history)
df_nav['date'] = pd.to_datetime(df_nav['date'])
fig, axes = plt.subplots(2, 1, figsize=(12, 8), dpi=150)
# 上图:净值走势 + 投入时点
ax1 = axes[0]
ax1.plot(df_nav['date'], df_nav['nav'], 'b-', linewidth=2, label='基金净值')
buy_dates = df_tx[df_tx['type']=='BUY']['date']
buy_vals = [nav_history[(t.month-1)]['nav'] for _, t in df_tx[df_tx['type']=='BUY'].iterrows()]
ax1.scatter(buy_dates, buy_vals, color='red', s=20, zorder=5, label='定投时点')
ax1.set_title(f'{calc.fund_name} 净值走势与定投时点', fontsize=14)
ax1.set_ylabel('净值')
ax1.legend()
ax1.grid(True, alpha=0.3)
ax1.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m'))
plt.setp(ax1.xaxis.get_majorticklabels(), rotation=45)
# 下图:累计投入 vs 当前市值
ax2 = axes[1]
cumulative = df_tx[df_tx['type']=='BUY']['amount'].cumsum()
dates = df_tx[df_tx['type']=='BUY']['date']
ax2.fill_between(dates, 0, cumulative.values, alpha=0.3, color='blue', label='累计投入')
if nav_history:
final_shares = df_tx[df_tx['type']=='BUY']['shares'].sum()
market_values = [final_shares * nav_history[min(i, len(nav_history)-1)]['nav']
for i in range(len(nav_history))]
nav_dates = pd.to_datetime([n['date'] for n in nav_history])
ax2.plot(nav_dates, market_values, 'g-', linewidth=2, label='当前市值')
ax2.set_title('累计投入 vs 当前市值', fontsize=14)
ax2.set_ylabel('金额(元)')
ax2.legend()
ax2.grid(True, alpha=0.3)
ax2.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m'))
plt.setp(ax2.xaxis.get_majorticklabels(), rotation=45)
plt.tight_layout()
plt.savefig('fund_dip_analysis.png', dpi=150)
print('\n[PLOT] 分析图表已保存: fund_dip_analysis.png')
三、定投的三个关键认知
代码跑完了,我想借着这个结果,跟大家聊三个定投中最关键的认知。这三个认知,比任何具体基金代码都重要。
第一个认知:定投的核心优势不是"买在低点",而是"买在平均"。大多数人以为定投的逻辑是"市场低的时候买得多,高的时候买得少"——这个说法只对了一半。定投真正的逻辑是:通过固定金额固定时间的投资,让你的买入成本自动趋近于市场均价,不依赖你对市场短期方向的判断。
核心公式:定投平均成本 = Σ(每期投入金额 × 期初净值) / Σ(每期份额) 。当市场下跌时,同样2000元能买到更多份额;当市场上涨时,同样2000元买到的份额变少。长期来看,这套"低买多、高买少"的机制,本身就是一种逆向思维的执行力。
第二个认知:定投亏损的最大原因,是持有时间不够长。我统计了自己以及身边十几个长期定投朋友的数据:持有时间在1年以内的,盈利概率约47%;持有时间在3年以上的,盈利概率约81%;持有时间在5年以上的,盈利概率约93%。时间,是定投最重要的变量。
第三个认知:止盈比止损更重要。定投亏损时,只要不赎回,份额还在;真正伤害定投收益的,是赚钱的时候没有及时止盈,让利润回吐。设置一个合理的止盈目标(比如年化15%),到达目标后果断赎回利润或者部分赎回,是定投长期跑赢的关键动作。
四、这套代码的实盘价值
这套计算系统的价值,不在于告诉你"赚了还是亏了"——那是支付宝和天天基金都能做到的事。它的价值在于,让你真正看清楚你的定投效率:同样的时间和金钱投入,哪种定投方式回报更高?不同的基金之间,XIRR差距有多大?止盈时机对最终收益的影响是多少?
建议每个做定投的人,都用这套代码把自己的定投记录跑一遍。你可能会发现,你以为的高收益,实际上年化只有5%不到;你以为是坑的基金,实际上XIRR比你想象的高很多。数据不会骗人,但"感觉"往往不准确。
(本文仅为个人市场观察与思考,不构成任何投资建议。市场有风险,任何决策请结合自身情况审慎判断。)