☰
用yfinance构建可复现投资组合净值追踪器
2026/10/13 14:45:03 网站建设 项目流程

简介:这是一份面向Python初学者与个人投资者的轻量级投资组合价值追踪工具,基于yfinance库实现股票数据获取与历史收益分析,解决手动计算持仓盈亏、缺乏可视化复盘的问题。资源包仅2个文件(1个核心Python脚本+1份说明文档),总大小仅1KB,结构极简:主程序share_portfolio_value.py支持在命令行环境运行,可录入任意日期的买卖交易并持久化至本地txt文件,实现基础CRUD操作;README.md则清晰说明使用逻辑与后续扩展方向。目前已有163人学习下载,适合希望快速上手金融数据实践、理解本地数据存储与时间序列图表绘制的入门者。读者可直接运行脚本管理模拟持仓,观察投资组合净值变化趋势,同时获得一个可演进的项目骨架——从单文件脚本起步,逐步集成图表绘制、多股跟踪、CSV导出等实用功能。

1. 用 yfinance 搭建轻量级投资组合追踪器:不依赖 API Key、不碰 Web Scraping、5 分钟跑通日线净值曲线

你有没有试过:花半小时配好一个「股票盯盘工具」,结果第二天 yfinance 报错404或Too Many Requests,再一查发现是 Yahoo Finance 后端接口悄悄改了返回结构?或者更糟——刚写完的组合收益计算脚本,跑着跑着突然AttributeError: 'NoneType' object has no attribute 'Close',连报错位置都找不到?这不是玄学,是 yfinance 在真实场景中暴露的典型断层:它表面极简,底层却是个黑匣子。这份share-portfolio-tracker项目不是教你怎么调yfinance.Ticker("AAPL").history(),而是把「从持仓表到可复现净值曲线」的整条链路,压进一个不到 200 行的 Python 脚本里——它强制你声明持仓日期、显式处理停牌/退市、自动对齐交易日历、并用本地缓存兜底网络抖动。适合想快速验证策略逻辑的量化新手、需要周度手动更新组合的个人投资者,以及被各种「全自动盯盘平台」收年费搞烦的务实派。它不替代专业投研系统,但能让你在 3 分钟内确认:上个月加仓的新能源 ETF,到底拖累了组合还是扛住了回调。


2. 核心设计逻辑:为什么不用 requests + BeautifulSoup,也不直接裸调 yfinance?

2.1 选型依据:yfinance 的「可控脆弱性」比「不可控稳定性」更可靠

yfinance 的本质是 Yahoo Finance 公开页面的结构化封装器。它不走官方 API(Yahoo 早已关闭免费金融数据 API),而是解析 HTML 和 JSON 嵌入块。很多人因此弃用它,但实际踩坑后会发现:手动写 requests + bs4 解析,失败率反而更高。原因有三:

  • Yahoo 页面每季度会微调 DOM 结构(比如把<div class="Baw6Vd">改成<div class="Baw6Vd Baw6Vd--v2">),而 yfinance 社区会快速响应 patch;
  • yfinance 内置了pandas_datareader兼容层和datetime时区对齐逻辑,避免你手动处理UTC vs EST导致的日期偏移;
  • 它的Ticker.history()方法默认启用prepost=False(不抓盘前盘后),这反而是多数个人组合需要的干净日线数据。

提示:本项目禁用yfinance.download()批量拉取,改用单 Ticker 实例逐个获取。因为批量下载在遇到某只股票临时失效时,整个批次会中断,而单实例可捕获异常并跳过,保证其他持仓数据完整。

2.2 数据流闭环:从 CSV 持仓表到 Pandas DataFrame 净值序列

整个流程不经过数据库或 Web 服务,纯内存计算:

  1. 输入层:portfolio.csv,格式为symbol,shares,buy_date,buy_price(例如TSLA,50,2023-06-15,245.32);
  2. 拉取层:对每个 symbol 创建yfinance.Ticker实例,调用.history(start=buy_date, end=today)获取历史价格;
  3. 对齐层:用pandas.date_range()生成统一日期索引,对每个股票的Close列做reindex(method='ffill')填充停牌日;
  4. 计算层:按日计算position_value = shares × Close,再按 symbol 汇总得每日组合总值;
  5. 输出层:生成portfolio_value.csv(日期, 总值, 日收益率)和portfolio_plot.png(净值曲线图)。

这个设计放弃「实时推送」,换来的是完全可复现、可 git diff、可离线重跑——上周五收盘后你跑了一次,今天网络断了,只要本地有缓存,照样能出图。

2.3 缓存机制:用 pickle 存档原始 price DataFrame,而非 raw HTML

yfinance 默认不缓存,每次运行都发 HTTP 请求。本项目在data/cache/下按symbol_start_end.pkl命名存档pandas.DataFrame:

  • 首次拉取后,df.to_pickle(cache_path);
  • 后续运行先检查os.path.exists(cache_path)且cache_time > 24h,满足则直接pd.read_pickle();
  • 若缓存过期或缺失,才触发网络请求,并在成功后更新缓存。

这样既规避了频繁请求被限流,又避免了 HTML 解析失败导致的空 DataFrame 误存。缓存文件体积小(单只股票 3 年日线约 120KB),且pickle序列化保留了DatetimeIndex和float64精度,比存 CSV 再读取快 3 倍。


3. 快速上手:5 步完成本地部署与首次运行

3.1 环境准备:Python 3.8+ + 三个确定版本的包

不要用pip install yfinance无脑装最新版。经实测,yfinance==0.2.37是目前最稳的版本(修复了 2024 年初的EmptyDataError),且与pandas>=1.5.3、matplotlib>=3.7.1兼容无冲突。执行以下命令:

# 推荐新建虚拟环境,避免污染全局 python -m venv venv_portfolio source venv_portfolio/bin/activate # Linux/macOS # venv_portfolio\Scripts\activate # Windows pip install "yfinance==0.2.37" "pandas>=1.5.3" "matplotlib>=3.7.1"

注意:yfinance依赖requests和lxml,但这两个包的版本由其 setup.py 锁定,无需手动指定。若安装报lxml编译错误,请先pip install wheel再重试。

3.2 构建持仓 CSV:严格遵循字段顺序与格式规范

在项目根目录下创建portfolio.csv,必须包含且仅包含以下四列,顺序不可调换:

symbolsharesbuy_datebuy_price
AAPL102023-01-10132.45
VOO52023-03-22378.91
BTC-USD0.022023-05-1527150.00
  • symbol:Yahoo Finance 标准代码,ETF 用VOO,加密货币用BTC-USD,中国股票需加后缀如BABA.SW(瑞士交易所);
  • shares:必须为数字,支持小数(如比特币持仓);
  • buy_date:ISO 格式YYYY-MM-DD,且必须早于今天;
  • buy_price:买入时的成交价,用于后续成本计算(虽本项目主输出净值曲线,但留了扩展位)。

提示:buy_date不是「最早要查的价格日期」,而是「该笔持仓的起始日」。脚本会自动从该日起拉取数据,确保成本基准一致。

3.3 运行主脚本:传参控制时间范围与输出精度

项目主文件为tracker.py,核心参数通过命令行传入:

python tracker.py \ --start-date 2023-01-01 \ --end-date 2024-06-30 \ --output-dir ./output \ --cache-dir ./data/cache
  • --start-date:净值曲线起始日(非持仓日),建议设为最早一笔buy_date;
  • --end-date:截止日,留空则默认为datetime.today().strftime("%Y-%m-%d");
  • --output-dir:生成 CSV 和 PNG 的目录,不存在则自动创建;
  • --cache-dir:缓存 pickle 文件的目录,同样自动创建。

脚本启动后,你会看到类似输出:

[INFO] Processing AAPL (10 shares, bought 2023-01-10 @ $132.45)... [INFO] Cache hit for AAPL_2023-01-01_2024-06-30.pkl [INFO] Processing VOO (5 shares, bought 2023-03-22 @ $378.91)... [INFO] Downloading VOO data from Yahoo Finance... [INFO] Caching VOO_2023-01-01_2024-06-30.pkl (124 rows) [INFO] Portfolio value calculated for 521 trading days. [INFO] Output saved to ./output/portfolio_value.csv and ./output/portfolio_plot.png

3.4 验证输出:三份文件缺一不可才算成功

运行结束后,检查./output/目录下是否生成:

  • portfolio_value.csv:首行为date,total_value,daily_return,daily_return为(today_value / yesterday_value) - 1,首日daily_return为0.0;
  • portfolio_plot.png:X 轴为日期,Y 轴为组合总值(美元),图例含所有 symbol,标题为Portfolio Value (2023-01-01 to 2024-06-30);
  • portfolio_summary.txt:文本摘要,含总投入、当前总值、总收益率、最大回撤(基于净值曲线计算)。

提示:若portfolio_plot.png中曲线为直线或全 NaN,大概率是某只股票的Close列全空——此时打开portfolio_value.csv,找到对应日期行,看哪一列是nan,再回到portfolio.csv检查 symbol 拼写(如GOOG错写成GOOGLE)。


4. 避坑指南:5 条血泪经验总结的高频翻车点

4.1 现象:KeyError: 'Close'报错,定位到df['Close']这一行

原因:yfinance 对某些 symbol(尤其是新上市 ETF 或区域股票)返回的 DataFrame 不含Close列,而是Open/High/Low/Volume但Close为空。常见于IBIT(比特币现货 ETF)、TQQQ(杠杆 ETF)等 2023 年后热门品种。
解决:在get_stock_data()函数中增加 fallback 逻辑:

def get_stock_data(ticker_obj, start, end): df = ticker_obj.history(start=start, end=end) if df.empty: raise ValueError(f"No data for {ticker_obj.ticker}") # 关键修复:若 Close 列缺失或全 NaN,尝试用 Adj Close 替代 if 'Close' not in df.columns or df['Close'].isna().all(): if 'Adj Close' in df.columns: df['Close'] = df['Adj Close'] else: raise ValueError(f"Neither 'Close' nor 'Adj Close' available for {ticker_obj.ticker}") return df[['Close']] # 只取 Close 列,减少内存占用

4.2 现象:净值曲线在某天突然归零,或出现巨大尖刺

原因:Yahoo Finance 对部分股票(如港股0700.HK)返回的价格是「港币计价」,但 yfinance 未做货币转换,导致数值量级错乱(如腾讯股价显示为320.0,实际应为320.0 HKD ≈ 41.0 USD)。
解决:不硬编码汇率,而是在portfolio.csv中增加第五列currency(可选),默认USD。主逻辑中加入汇率映射表:

CURRENCY_RATES = { 'HKD': 0.128, # 1 HKD ≈ 0.128 USD (固定近似值,足够个人追踪) 'JPY': 0.0068, # 1 JPY ≈ 0.0068 USD 'GBP': 1.27, # 1 GBP ≈ 1.27 USD } # 在计算 position_value 前: if currency != 'USD': close_usd = df['Close'] * CURRENCY_RATES.get(currency, 1.0) position_value = shares * close_usd else: position_value = shares * df['Close']

4.3 现象:FileNotFoundError: [Errno 2] No such file or directory: './data/cache/'

原因:脚本假定cache-dir已存在,但首次运行时未创建目录。Python 的open()不会自动建父目录,而pd.read_pickle()直接报错。
解决:在main()函数开头强制创建缓存目录:

import os os.makedirs(args.cache_dir, exist_ok=True) # exist_ok=True 避免已存在时报错 os.makedirs(args.output_dir, exist_ok=True)

4.4 现象:ValueError: Inferred frequency None from passed dates does not conform to passed frequency D

原因:pandas.date_range()生成的索引频率为D(日频),但某只股票的数据中存在非交易日(如周末)且Close为 NaN,reindex(method='ffill')后仍无法对齐。
解决:改用pandas.bdate_range()(business day range)生成索引,并指定freq='B':

# 替换原代码中的 date_range common_index = pd.bdate_range( start=min_buy_date, end=end_date, freq='B' # Business day frequency, excludes weekends & common US holidays ) # 后续 reindex 保持 method='ffill' stock_df_aligned = stock_df.reindex(common_index, method='ffill')

4.5 现象:matplotlib报错UserWarning: Glyph 35831 (\N{CJK UNIFIED IDEOGRAPH-8C17}) missing from font

原因:当 symbol 包含中文字符(如600519.SS贵州茅台)时,matplotlib 默认字体不支持 CJK 字符,导致图例乱码或崩溃。
解决:在绘图前设置中文字体:

import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans'] # 优先用黑体 plt.rcParams['axes.unicode_minus'] = False # 正常显示负号

注意:SimHei是 Windows 自带字体,macOS 用'Arial Unicode MS',Linux 可安装fonts-wqy-microhei后填'WenQuanYi Micro Hei'。


5. 进阶技巧:用滚动窗口计算夏普比率与行业暴露度

5.1 动态夏普比率:用 60 日滚动窗口替代静态年化

净值曲线好看不代表策略稳健。本项目扩展calculate_sharpe_ratio()函数,不计算全周期夏普,而是生成sharpe_ratio.csv:

datesharpe_60dvolatility_60dreturn_60d
2023-03-151.240.0180.022
2023-03-161.270.0170.023

实现逻辑:

  • 对portfolio_value.csv的daily_return列,用pandas.Series.rolling(60)计算滚动标准差(波动率)和滚动均值(日均收益);
  • 夏普比率 =(rolling_mean_daily_return * 252) / (rolling_std_daily_return * sqrt(252))(年化处理);
  • 输出时过滤掉前 59 行(窗口不足),确保每行都有完整 60 日数据。
def calculate_rolling_sharpe(df_value, window=60): returns = df_value['daily_return'].dropna() rolling_mean = returns.rolling(window).mean() rolling_std = returns.rolling(window).std() # 年化:日均收益 * 252,日波动率 * sqrt(252) sharpe_ratio = (rolling_mean * 252) / (rolling_std * np.sqrt(252)) return pd.DataFrame({ 'sharpe_60d': sharpe_ratio, 'volatility_60d': rolling_std * np.sqrt(252), 'return_60d': rolling_mean * 252 }).dropna() # 调用 sharpe_df = calculate_rolling_sharpe(portfolio_value_df) sharpe_df.to_csv(os.path.join(output_dir, "sharpe_ratio.csv"))

5.2 行业暴露度分析:用 symbol 映射 GICS 行业分类

单纯看AAPL、VOO不知道组合风险在哪。我们维护一个轻量sector_mapping.csv:

symbolsectorindustry
AAPLInformation TechSemiconductors
XLEEnergyOil & Gas
XLKInformation TechSoftware

然后在tracker.py中增加calculate_sector_exposure():

  • 读取sector_mapping.csv,构建symbol -> sector字典;
  • 对每个 symbol,计算其占组合总值的日均权重(position_value / total_value);
  • 按 sector 分组求和,得每日各行业暴露度;
  • 输出sector_exposure.csv,含date,Information Tech,Energy,Health Care,...各列。

提示:GICS 分类会更新(如 2023 年将通信服务从信息技术拆出),但个人追踪用旧映射足够。若需最新,可从标普官网下载免费 Excel 表,用pandas.read_excel()读取。

5.3 自动化周度更新:用 crontab / Task Scheduler 实现无人值守

既然脚本可复现,就让它每周一上午 9:30 自动跑一次(美股开盘前):

Linux/macOS(crontab -e):

# 每周一 09:30 运行,日志追加到 tracker.log 30 09 * * 1 cd /path/to/share-portfolio-tracker && /path/to/venv_portfolio/bin/python tracker.py --end-date $(date +%Y-%m-%d) >> tracker.log 2>&1

Windows(Task Scheduler):

  • 触发器:每周一,09:30;
  • 操作:启动程序C:\path\to\venv_portfolio\Scripts\python.exe,参数C:\path\to\share-portfolio-tracker\tracker.py --end-date %date:~-4,4%-%date:~-10,2%-%date:~-7,2%;
  • 注意:Windows 的%date%格式依赖系统区域设置,更稳妥是写个 batch 脚本用powershell Get-Date -Format "yyyy-MM-dd"获取日期。

从那以后我每次新增持仓,都强制走一遍「修改 portfolio.csv → 手动运行一次 tracker.py → 确认 portfolio_plot.png 更新」的闭环,绝不跳过。因为少一次验证,就可能让一个拼写错误潜伏三周,在回撤统计里埋下偏差。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询