本章内容(This chapter covers)
- 从线上与线下来源收集投资组合数据
- 分析投资组合中的资产表现
- 预测资产与组合的未来回报
- 制作月度被动收入与收益报告
- 在 Google 表格中收集与管理数据
在前几章中,我们介绍了如何为潜在投资筛选资产。假设你已经完成了部分买入,现在是时候学习如何监控现有投资组合了。本章旨在帮助你掌控全局、持续掌握信息,并最大化资产的潜在回报。
我们将先带你从不同券商收集持仓;随后把数据汇总为可行动的洞见:追踪总被动收入、识别表现突出(正/负)标的、并找出需要改进的环节。最后,我们会预测未来的收益与增长,回答那个关键问题:什么时候你能达到自己的财务里程碑?
我们将使用 Google 表格来采集与可视化数据,支持筛选与深入分析。一起解锁像专业人士那样管理股票投资组合的诀窍。准备升级?开始吧。
注意:配套的 Git 仓库与下载区提供了完整可运行的 Jupyter Notebook。书中的代码片段用于帮助理解,未必在清单中声明了所有辅助变量。所需的 Python 库可参见附录。
6.1 架构(Architecture)
本章将构建一个数据科学笔记本,把来自多个来源的持仓信息整合进中央仓库。我们希望跟踪的关键指标包括:当前组合市值、以及来自资本增值与被动收入的年度预测回报。如图 6.1 所示,我们会按资产类别抽取数据并导出到专属工作表,让你在同一份 Google 表格中分别查看股票、加密货币、债券等不同资产标签页。
图 6.1 从资产数据到 Google 表格各工作表的数据流
本章将以两个股票券商与一个加密货币交易所构成的演示组合为例。即便你使用不同平台,文中的代码也能作为模板来搭建自定义的数据管道,从你的券商获取信息。
全球范围内有数百家机构与产品(银行、券商、交易所、加密钱包等)可供你存放资产。不幸的是,它们没有统一标准供程序化访问;有些甚至不提供自动化拉取数据的接口。
图 6.2 展示了数据抽取的挑战:有些资产能从多个平台获取,而另一些没有 API。为此,我们把线上数据与手动记录的持仓合并。若资产本身没有数字化足迹(如房地产),那么把细节手动录入表格可能是最务实的办法。
图 6.2 将线上来源汇总为 DataFrame
我们先创建一个表来跟踪无法自动获取的持仓,命名为 offline_asset。可下载的 Jupyter Notebook 使用 SQLite 作为文件型存储,便于演示与探索。注意笔记本里有些变量不会在示例中逐一声明。
下面是需要在数据库中执行的 DDL(数据定义语言)语句。附录中为不熟悉 SQLite 的读者提供了简要指南;代码仓库还包含一个带少量预填数据的小型参考库。若要使用你自己的数据,需往这些表中新增与你投资组合相符的记录。
create table main.offline_asset
(
ticker TEXT
constraint InterestTable_pk
primary key,
yield REAL,
avg_price REAL,
exchange TEXT,
amount REAL,
asset_type TEXT
);
我们用 ticker 作为唯一标识。即使偶尔记不住公司全名,只要唯一识别出资产,就能拉全所需数据。yield 列用于记录被动收入(如债券利息、分红股股息、或加密质押收益)。avg_price 记录加权买入均价。
exchange 列记录资产存放/交易平台,amount 为持有数量。asset_type 用于标识资产类型(如股票、ETF、加密、债券等)。
建表后,需要把你的仓位作为数据集写入。下面的一行命令把离线数据加载为 DataFrame。稍后我们会把该 DataFrame 的内容与从券商/交易所获取的数据合并:
all_offline_assets = pd.read_sql('offline_asset', engine, index_col='ticker')
本章完整 Notebook 可在出版社网站下载;文中将解释关键的数据抽取、转换与导出代码。
6.2 电子表格(The spreadsheet)
先看期望成果。我们会把数据导出到一份电子表格;可从 mng.bz/Bzyl 下载模板,克隆后设置访问权限。随后,下载一个 JSON 凭据文件以在线访问该 Google 表格;在源代码中引用此文件后,即可用本章代码把财务数据导出到各个工作表。或者,你也可以从下载区获取 Excel 文件并转换为 Google 表格。该文件同样托管在本书的 GitHub 仓库(github.com/StefanPapp/…)。
接着看看各工作表的样子。图 6.3 为股票参考数据截图。每个 ticker 都隶属于某个投资组合,并支持按列筛选。我们展示了持股数量、总价值与预期年化收益,还用两列计算了潜在盈亏。Live 列通过单元格公式从 Google Finance 拉取实时数据;Value 列是导出时的持仓总值,借助实时数据还可跟踪自上次更新以来的变化。
图 6.3 含股票信息的 Google 表格工作表示例
图 6.4 展示了名为 assets 的总览页,也带有部分参考数据。该页汇总了资产价值与收益;右侧图表展示资产类型分布,便于一眼看出你的配置结构。比如,此参考数据代表了一个风险偏高的组合:大多数风险厌恶型投资者会更偏好指数基金(ETF)持仓占比更高的配置。
图 6.4 资产按价值(USD 列)与被动收入(Yield 列)的分组总览
接下来我们演示如何采集数据、抽取关键指标、把不同货币统一换算为单一货币(若多币种持仓),并将数据导出至 Google 表格。开始吧!
6.3 抽取数据(Extracting data)
证券券商为投资者提供资产交易与托管服务。本书选取两家可用 Python API访问账户数据的券商:Alpaca 与 Interactive Brokers(IB) 。与很多仅覆盖少数国家的券商不同,这两家在全球范围提供服务,更便于读者实操。请注意,开户需要身份核验(如护照)与税务居民信息,这一流程称为 KYC(了解你的客户),是投资服务业的强制要求。附录会补充更多券商信息。
注意:选择券商前务必调研。许多券商只服务少数国家;下单/转账费用高低也会影响盈亏。若你想自动化,需确认券商是否向客户开放 API。如果拿不准,咨询专业顾问。
为了处理来自不同来源的数据,我们需要把它们统一格式化并合并。因此,先定义目标 DataFrame 的字段结构,如下:
- Ticker:股票代码,作唯一标识
- Number of shares:当前持有的股数
- Average price:所有交易汇总后的平均买入价
- Exchange:该资产交易所/平台
- Broker:用于标识数据来源的常量(此处为
INTERACTIVE_BROKER或ALPACA)
6.3.1 Alpaca:面向开发者优先的券商
Alpaca 将自己定位为开发者优先的券商,提供现代 API 便于集成。使用前需注册账户并在账户里生成访问密钥(API key/secret),以便程序化获取余额或下单。
在本书演示中,我们用 dotenv 从本地 .env 文本文件安全加载密钥。请谨慎使用!
警告:不要在共享或不安全的电脑上存储敏感信息。密钥泄露可能导致未授权操作,危及你的账户安全。
.env 文件的格式示例:
ALPACA_API_KEY=your_api_key_here
ALPACA_SECRET_KEY=your_secret_key_here
环境就绪后,清单 6.1 展示了如何用 alpaca-py 连接 Alpaca。创建 trading_client 后,调用 get_all_positions() 拉取所有持仓并装入 DataFrame,同时选取需要的列,以便与其他券商数据合并。安装命令:pip install alpaca-py。
# 清单 6.1 从 Alpaca 抽取数据
from alpaca.trading.client import TradingClient
ALPACA_KEY = os.getenv("ALPACA_API_KEY") #1
ALPACA_SECRET = os.getenv("ALPACA_SECRET_KEY") #1
trading_client = TradingClient(ALPACA_KEY, ALPACA_SECRET, paper=False)
positions_data = [
[position.symbol, position.qty,
position.avg_entry_price,
position.exchange.value, 'ALPACA'] #2
for position in trading_client.get_all_positions() #2
]
alpaca_balances = pd.DataFrame( #2
positions_data, #2
columns=[COL_TICKER, COL_AMOUNT, COL_PRICE_INIT,
COL_EXCHANGE, COL_BROKER]) #2
#1 从本地 .env 文件读取密钥
#2 通过 get_all_positions() 拉取持仓并整理字段
若你不想用真金白银试验,可将 paper 参数设为 True 使用 模拟账户,相当于投资的测试环境。
注意:Alpaca 只支持美元计价。虽可用其他货币入金,但跨币种汇款常费用不菲且到账需时。有的投资者会先转入加密货币,再在 Alpaca 场内换成 USD。
6.3.2 Interactive Brokers:传统巨头的现代玩法
Interactive Brokers(IB) 创立于 1978 年,以功能全面著称。但对部分程序员来说,它的 API 集成方式略显**“老派” :不同于“纯云”的现代券商,IB 需要你先在本地安装 Trader Workstation(TWS) ,Python API 再连接本地 TWS**,这对要做云端投资系统的开发者增添了一些复杂度。
不过,一旦配置妥当,IB 在检索与管理投资数据方面相当强大。借助 ib_insync 库,你可以程序化连接 TWS。注意你可能需要在 TWS 内调整一些设置(视配置而定),附录会补充细节。清单 6.2 展示了如何通过该库拉取数据。我们调用 positions() 获取持仓,再把返回结构展开,并整理为与 Alpaca 一致的字段。
# 清单 6.2 从 Interactive Brokers 抽取数据
from ib_insync import * #1
util.startLoop() #1
IB() #1
ib = IB() #1
ib.connect() #1
ibr = util.df(ib.positions()) #2
ibr[COL_TICKER] = (ibr.contract.apply
(lambda x: x.symbol)) #2
ibr["exchange"] = (ibr.contract.apply
(lambda x: x.exchange)) #2
ibr["broker"] = "INTERACTIVE_BROKER" #2
IB_balances = (ibr[[COL_TICKER, 'position', 'avgCost',
COL_EXCHANGE, COL_BROKER]].
rename(columns={'position': COL_AMOUNT,
'avgCost': COL_PRICE_INIT}))
#1 连接本地 Trader Workstation
#2 通过 ib.positions() 拉取并清洗字段
以上完成后,你就能把 Alpaca 与 IB 的 DataFrame 与 all_offline_assets 合并,统一做估值、收益、汇率换算与导出到 Google 表格的流程。
6.4 数据充实(Enriching data)
现在我们已有两个 DataFrame,分别装载了两家券商的数据,且结构一致;同时也有一个包含离线资产的 DataFrame。可以像下面这样把它们合并:
all_positions_raw = pd.concat([alpaca_balances, IB_balances, offline_assets])
至此,来自各数据源的信息已整合进同一个 DataFrame,数据抽取阶段完成。
接下来我们希望把 all_positions_raw 中的 ticker 作为标识,到 Yahoo Finance 与 Google Finance 获取财务数据。在把持仓与平台数据合并之前,还剩一个任务:大多数情况下,券商给出的 ticker 与财务平台上的一致,但也有例外。例如,Berkshire Hathaway 的 BRK.B 在 Yahoo Finance 上写作 BRK-B。为避免“代码不匹配”导致的问题,我们可以创建一张映射表,将券商的 ticker 映射到财务平台使用的 ticker。
这张表还能用来把每个资产映射到某个投资组合,便于投资者在更抽象的层面做分组、筛选与横向比较。该查找表需要以下五列:
- Ticker —— 券商用于标识股票的唯一代码
- Asset type —— 资产类型(此处为股票;其他类型稍后采集)
- Portfolio —— 该资产所属的投资组合
- Yahoo —— Yahoo Finance 上的代码
- Google —— Google Finance 上的代码
演示中我们继续使用 SQLite 存储这些值,可用以下 DDL/SQL 创建查找表:
create table main.asset_lookup
(
ticker TEXT not null
constraint base_security_pk
primary key,
asset_type TEXT not null,
portfolio TEXT,
yahoo TEXT,
google TEXT
);
向表中填充持仓对应的数据时,若券商与平台 ticker 一致,可将 yahoo 与 google 留空(NULL)。清单 6.3 展示了如何从本地 SQLite 读取数据。装载后,我们将这些列与内存中的 all_positions_raw 合并;若 yahoo/google 为空,就以 ticker 值回填。换言之,若我们为 Yahoo 存了 “BRK-B”,而 ticker 列是 “BRK.B”,最终就形成了 “BRK.B ↔ BRK-B” 的映射。注意:要想让该映射表起作用,你需要把投资组合中涉及的每一行都填好。
清单 6.3 载入元数据(Loading metadata)
from sqlalchemy import create_engine
engine = create_engine('sqlite:///portfolio.sqlite')
asset_lookup = pd.read_sql('asset_lookup',
engine,
index_col=COL_TICKER) #1
all_positions = all_positions_raw.merge(asset_lookup, on=COL_TICKER)
all_positions[COL_YAHOO] = (all_positions[COL_YAHOO].
fillna(
all_positions[COL_TICKER])) #2
all_positions[COL_GOOGLE] = (all_positions[COL_GOOGLE].fillna(
all_positions[COL_TICKER])) #2
#1 从 SQLite 读取
#2 用 ticker 数据回填 NULL
最后,我们就可以开始处理数据了。下面的 collect_fin_data 函数接收 ticker 列表,返回包含各资产公司信息的对象:
def collect_fin_data(tickers):
return {ticker: yf.Ticker(ticker).info for ticker in tickers}
如果传入的列表很长,Ticker 在循环中会为多个资产从 Yahoo Finance 拉取数据,查询可能需要几秒。
6.5 处理资产(Processing assets)
在完成数据充实后,接下来展示如何对资产做进一步聚合。本节以股票为例,并演示多币种如何统一换算到一个目标币种(该方法同样适用于其他资产)。
6.5.1 股票(Stocks)
以下代码筛出股票并用 collect_fin_data 收集其信息。我们先从总数据集中筛出股票,再批量拉取公司数据:
shares = all_positions[all_positions[COL_ASSET_TYPE].isin(["STOCK"])]
unique_stock_tickers = shares[COL_YAHOO].unique().tolist()
yfin_data_stocks = collect_fin_info(unique_stock_tickers)
现在我们把 Yahoo 的数据放在 yfin_data(此处为 yfin_data_stocks)里,接下来把所需指标并入持股 DataFrame。清单 6.4 的 merge_fin_data 会按参数抽取指定指标。
清单 6.4 将 Yahoo Finance 数据并入 DataFrame
def merge_fin_data (df_orig, ticker_data, metrics):
df = df_orig.copy(deep=True)
for m in metrics:
df[m] = None
for ind in df.index:
ticker_symbol = df.loc[ind, "yahoo"] #1
company = ticker_data.get(ticker_symbol, {}) #1
for m in metrics: #2
df.loc[ind, m] = company.get(m, None) #2
return df
ratios = ["currentPrice", "targetMeanPrice", "dividendRate"]
stocks = merge_fin_data(shares, yfin_data_stocks, ratios)
#1 从 DataFrame 获取 symbol
#2 将所需指标写入 DataFrame
一个常见挑战是币种不一。例如,投资者可能在 纳斯达克持有以美元计价的 Microsoft,同时在法兰克福交易所持有以欧元计价的 BMW。为了汇总总市值,需要把不同币种换算到统一币种。清单 6.5 展示使用 CurrencyConverter 的换算方法。我们需要先约定一个目标币种;示例中按交易所对应国家默认映射到美元(USD) 。
注意 一些投资者尽量避免多币种,以减少汇率波动带来的附加风险。亦可通过 **ADR(美国存托凭证)**在本地交易所持有海外公司股票,但并非所有资产都有 ADR。
演示映射只包含少量交易所(如 AEB、VSE);请按需扩充。
清单 6.5 货币换算(Converting currency)
from currency_converter import CurrencyConverter
def get_conversion(target_currency = "USD"):
c = CurrencyConverter()
mapping_exchange_currency = { #1
'ARCA': c.convert(1, 'USD', target_currency),
'NASDAQ': c.convert(1, 'USD', target_currency),
'NYSE': c.convert(1, 'USD', target_currency),
'BATS': c.convert(1, 'USD', target_currency),
'PINK': c.convert(1, 'USD', target_currency),
'IBIS': c.convert(1, 'EUR', target_currency),
'AEB': c.convert(1, 'EUR', target_currency),
'VSE': c.convert(1, 'EUR', target_currency),
'AMEX': c.convert(1, 'USD', target_currency),
'BVME': c.convert(1, 'EUR', target_currency),
'SBF': c.convert(1, 'EUR', target_currency),
'EBS': c.convert(1, 'CHF', target_currency),
'CPH': c.convert(1, 'DKK', target_currency),
'PRA': c.convert(1, 'CZK', target_currency)
}
return mapping_exchange_currency
conversion = get_conversion() #2
#1 建立交易所 → 货币 → 目标币种 的映射
#2 目标币种设为 USD
完成后,我们就能把不同币种的列统一到一个币种。清单 6.6 把资产的以下参数换算为单一货币:
- 当前价格(Current price)
- 平均买入价(Average price)
- 股息率/派息(Dividend rate)
代码如下:传入待换算列名,为初始价格、当前价格与**收益(yield)**转换为美元。
清单 6.6 执行换算(Executing conversion)
def convert(row, column_name):
return (row[column_name] *
conversion[row[COL_EXCHANGE]]) #1
df[COL_PRICE_INIT_USD] = df.apply(convert,
column_name=COL_PRICE_INIT,
axis=1) #1
df[COL_PRICE_USD] = df.apply(convert,
column_name=col_price,
axis=1) #2
df[COL_YIELD_USD] = df.apply(convert,
column_name=col_yield,
axis=1) #2
#1 基于交易所列做换算
#2 将字段统一到单一币种
当前 DataFrame 的信息是单股维度。清单 6.7 展示按持股数量聚合:我们需要把价格/收益乘以股数,得到整笔持仓的总价值与年化收益;再基于价格估计计算历史与潜在盈亏。
清单 6.7 聚合数值(Aggregating values)
df[COL_TOT_INIT_VALUE] = (df[COL_AMOUNT] * df[COL_PRICE_INIT_USD]).round(2)
df[COL_TOT_VALUE] = (df[COL_AMOUNT] * df[COL_PRICE_USD]).round(2)
df[COL_TOT_YIELD] = df[COL_AMOUNT] * df[COL_YIELD_USD]
df[COL_PAST_GAIN] = df[COL_TOT_VALUE] - df[COL_TOT_INIT_VALUE]
df[COL_PROJ_GAIN] = ((df[COL_TARGET_PRICE] - df[COL_PRICE_USD]) *
df[COL_AMOUNT])
从不同视角看投资组合(Perspectives on a portfolio)
我们可以用多种方式分析变化。假设 Jeff 以每股 **1,000),以每股 **500)。现价分别为 A:7.5。账面增值 A:250。严格按百分比,A:30% ,B:50% 。如果当初在 B 上投 500,则收益会是 550。
明智投资的一种方式,是对资产建立多重视角。若只想“感觉良好”,看到大盘上涨、组合市值多出 $5,000 的绝对额会更有冲击力;但评估个股表现时,更应关注百分比变化,便于横向比较。
同时别忘了看不同时间窗口。有时短期表现黯淡,但拉长周期便截然不同。数据都在,你只需要决定关注什么。
有了所需数据后,就可以把它导出到 Google 表格。我们将使用 gspread,在此之前需正确配置写入权限。网上有大量指南讲解如何为应用配置 Google 表格访问;最终你会获得一个JSON 凭据用于鉴权。
同时务必妥善保管你的访问密钥。即使他人仅能查看你的表格也许不会直接造成资金损失,但投资组合被未授权窥见依然令人担忧。
安全配置完成后,我们把所有数据导出到名为 stock 的工作表。以下两段方法中,第一段接收工作表名与 DataFrame,第二段负责导出:
def export_df_to_sheet(worksheet_name, df):
worksheet = get_spreadsheet().worksheet(worksheet_name)
worksheet.update([df.columns.values.tolist()] + df.values.tolist())
把 DataFrame 导入 Google 表格很简单,但清单 6.8 展示了如何加入实时数据:我们为单元格拼装 GOOGLEFINANCE 函数,通过 ticker 动态拉取行情。先根据列位置获取列字母(如第 4 列为 D),再遍历所有 ticker,为每一行生成公式,写入对应的单元格。
清单 6.8 添加实时数据(Adding live data)
col_amount_character = (get_char_by_col_pos
(pos_col_amount)) #1
col_tot_val_character = (get_char_by_col_pos
(pos_col_tot_val)) #1
col_live_character = (get_char_by_col_pos
(pos_col_live)) #1
col_update_character = (get_char_by_col_pos
(pos_col_live+1)) #1
update_cols = []
counter = 0
for ticker in tickers:
update_cols.append(f'=GOOGLEFINANCE("{ticker}")*'
f'{col_amount_character}'
f'{counter+2}') #2
counter += 1
worksheet.update([update_cols],
col_live_character + "2",
raw=False,
major_dimension = "COLUMNS" ) #3
#1 获取列位置
#2 组装单元格函数
#3 批量更新工作表
把股票导出到工作表后,我们有了扩展到其他资产的坚实基础。很多股票处理代码可复用。下面继续处理 ETF、债券、加密货币并导出。
6.5.2 交易型开放式指数基金(Exchange-traded funds, ETFs)
ETF 用代码(ticker)标识,如 VOO(Vanguard S&P 500)或 SPY(SPDR S&P 500 Trust)。价格在交易时段更新,且有时向股东分红。
你可以像处理股票一样,用 yfinance 接口拉取 ETF 数据。但指标略有不同:单一公司的 Ticker 返回的是该公司的数据;而 VOO 这类池化投资,例如 市盈率(P/E) ,通常是成分资产 P/E 的加权平均。
我们把所有 ETF 收集后导出到电子表格的新工作表。可复用 merge_value_into_df(或 merge_fin_data)将券商数据与 yfinance 数据充实。其中 navPrice 代表 ETF 的当前净值/市价,yield 表示被动收入(分红/利息):
all_etfs_held = all_ positions[all_positions['asset_type'].isin(["ETF"])]
full_etf_data = all_etf["yahoo"].unique().tolist()
essential_etf_data = merge_values_into_df(all_etfs_held,
full_etf_data,
["navPrice", "yield"])
细微差异观察(Observe the fine-grained differences)
细心的读者可能注意到:清单 6.4 使用的指标(如 currentPrice、dividendRate)与 ETF 用的(navPrice、yield)不同。抽象层面上,它们回答的是同一类问题:现在的价格是多少?投资者能拿到多少被动收入?
这些“小差别”有助于我们在程序员视角下加深对资产本质的认识。ETF 是一种池化投资,包含多种资产(股票、债券、甚至其他 ETF)。可把它想象成“装着不同对象的数组”。阵中每个资产都可能产出被动收入。虽然股息(来自股票)与票息(来自债券)都会“给投资者送钱”,但细节迥异:股息是公司自愿分配,发放时可能影响股价,也可能停止;票息则是债务人必须支付给债券持有人的刚性义务。因此,在抽象层面我们把它们统一称为 yield。同理,NAV 与当前价的关系也在于前者反映成分资产价格的聚合。
在表格中,我们在抽象层统一称回报为 yield——关注点是被动收入的多少。由于我们也把加密质押称作 yield,若所持币价格波动大,该数值也会随之剧烈波动。
收集完 ETF 后,按股票章节的方式统一币种换算(见清单 6.6),再聚合与导出。由于我们把流程封装为函数(见清单 6.9),处理 ETF 的代码可简化为几行。
清单 6.9 导出 ETF(Exporting ETFs)
all_etfs_held = all_positions[all_positions['asset_type'].isin(["ETF"])]
yfin_data_etf = collect_fin_data(all_etfs_held[COL_YAHOO].unique().tolist())
etf_info = merge_fin_data(all_etfs_held, yfin_data_etf, ["navPrice", "yield"])
etf_info = calculating_price_data(etf_info, "navPrice", "yield")
export_etf_columns = [COL_TICKER, COL_PORTFOLIO,
COL_TOT_VALUE, COL_TOT_YIELD,
COL_AMOUNT, COL_TOT_INIT_VALUE]
export_df_to_sheet(COL_WORKSHEET_ETF, etf_info[export_etf_columns])
add_live_data(COL_WORKSHEET_ETF,
etf_info[COL_GOOGLE].tolist(),
export_etf_columns)
使用 yfinance 等库的一个好处在于:如果接口一致,我们就能把大量功能封装起来,轻松扩展新的资产类型。同时也要意识到:ETF ≠ 股票,某些适用于单一公司的指标,并不适合直接聚合到池化投资上。
图 6.5 展示了 ETF 工作表示例。注意,某些数据——如目标价推断的预期收益——可能无法通过 yfinance 获得,因此不在表中呈现。
6.5.3 债券(Bonds)
当你买股票时,会通过**股票代码(ticker)**来识别公司。熟练之后,你可能更习惯说 MSFT、AAPL、NVDA,而不是 Microsoft、Apple、NVIDIA。ETF 也类似——虽然本质上是一篮子资产,许多投资者看到 VOO、SPY 就会自然联想到 Vanguard S&P 500 ETF 或 SPDR S&P 500 ETF Trust。
但在从券商处采集的数据里,债券往往比股票更难识别。原因在于债券会按固定节奏分批发行,每一批(每期)都有不同的标识符。你不能像说“再买点 VOO 或 NVDA”那样表达;实质上,你是按约定条款(票息、到期日等)把钱借给某个主体。若你在之后想再借出更多资金,等于认购新一期发行,其条款可能不同。每只债券仍可通过唯一标识识别,例如金融平台常用的 ISIN(如 US9128CLA70)。
因此,对债券我们不一定像股票那样用 yfinance 丰富数据。下面的代码展示了另一种路径:把我们存放在 SQLite 表 offline_asset 中的**收益率(yield)**读出并用于计算。数据库里保存了债券的收益率,我们将其并入 DataFrame:
bond_rates = all_offline_assets[
all_offline_assets[COL_ASSET_TYPE].isin(['BOND'])
][["yield"]]
bond_df_merged_rates = all_bonds_held.merge(bond_rates, on=COL_TICKER)
bond_df_merged_rates[COL_TOT_YIELD] = (
bond_df_merged_rates[COL_TOT_VALUE]
* bond_df_merged_rates[COL_YIELD]
/ 100)
当然,我们也可以进一步评估债券的资本增值,并扩展加入信用评级等信息。但若你主要配置的是较稳健的债,这些数值变化并不频繁,因此这里保持简化。图 6.6 展示了一个带参考数据的债券工作表。
图 6.6 在债券工作表中,Value 表示票面价值(par value) (到期兑付的金额);同时还展示了年化收益与持有数量。
6.5.4 加密货币(Cryptocurrencies)
本例展示如何从加密货币交易所获取资产信息。清单 6.10 以 Binance 为例,使用 python_binance API。我们通过 get_simple_earn_locked_product_position 与 get_simple_earn_flexible_product_position 两个方法来抓取数据:前者是锁定(通常收益更高)、后者是灵活(可随时卖出,收益较低)。两者返回的持仓结构一致,便于合并。
清单 6.10 从 Binance 载入数据(Loading data from Binance)
from binance.client import Client
binance_client = Client(os.getenv("exchange.binance.api"),
os.getenv("exchange.binance.secret"))
locked_assets = pd.DataFrame( #1
[
(pos["asset"], pos["amount"], pos["apy"])
for pos in binance_client
.get_simple_earn_locked_product_position()['rows']
],
columns=[COL_TICKER, COL_AMOUNT, COL_YIELD]) #1
flexible_assets = pd.DataFrame( #2
[(pos["asset"], pos["totalAmount"],
pos["latestAnnualPercentageRate"])
for pos in binance_client
.get_simple_earn_flexible_product_position()['rows']],
columns=[COL_TICKER, COL_AMOUNT, COL_YIELD]) #2
assets = pd.concat([locked_assets,
flexible_assets]) #3
assets = assets.astype({COL_AMOUNT: 'float',
COL_YIELD: 'float',
COL_TICKER: 'string'}) #3
assets.set_index(COL_TICKER, inplace=True) #3
#1 载入“锁定收益”持仓
#2 载入“灵活收益”持仓
#3 合并并统一结构
既然拿到了 Binance 的仓位数据,我们也可以载入离线资产。例如,假设部分加密资产保存在硬件钱包(由你自持私钥)。它们的数量与当前质押利率记录在 offline_asset 表中:
ledger_cryptos = all_offline_assets[
all_offline_assets[COL_ASSET_TYPE].isin(["CRYPTO"])]
至此,我们已将所有加密资产放入 DataFrame,只差价格数据。价格可从 CoinMarketCap 获取。清单 6.11 展示了做法:创建会话、按币种列表请求 USD 报价,解析后合并回持仓 DataFrame。注意你需要在 CoinMarketCap 注册并获取 API Key。
清单 6.11 从 CoinMarketCap 载入价格(Loading price data from CoinMarketCap)
session = Session()
session.headers.update({
'Accepts': 'application/json',
'X-CMC_PRO_API_KEY': os.getenv("broker.coinmarketcap.com")
})
res = session.get(
'https://pro-api.coinmarketcap.com/v2/'
'cryptocurrency/quotes/latest',
params={
'symbol': ','.join(df.index.unique()),
'convert': 'USD'
}
) #1
if res.status_code != 200:
print(f"API Error: {res.status_code} - {res.text}")
return df
data = res.json().get("data", {}) #2
prices = {
coin: data[coin][0]['quote']['USD']['price']
for coin in data
if 'USD' in data[coin][0]['quote']
} #2
df[COL_PRICE] = df.index.map(prices)
#1 请求 CoinMarketCap 接口
#2 解析响应并提取价格
在导出到表格时还有一个细节:Google Finance 对许多加密货币没有数据。因此,我们需要改用其他数据源生成单元格公式。组装示例如下:
update_cols.append(
f'=IFERROR(REGEXREPLACE('
f'IMPORTDATA("https://cryptoprices.cc/{ticker}");'
f'"[.]";","'
f')*{col_amount_character}{counter+2};'
f'{col_tot_val_character}{counter+2})'
)
这里我们调用 Crypto Prices(cryptoprices.cc) 提供的接口。由于访问频率等原因,可能会偶发错误,因此在获取失败时,单元格会回落到上次导出的值。图 6.7 为将加密资产导出到工作表的示例页面。
图 6.7 加密资产工作表会展示总价值、质押收益(年化) 、持有数量以及实时数据。
6.6 展望(Outlook)
本章构建的 Jupyter Notebook 可以作为你个人资产监控器的模板。在这一框架中,我们为每种资产类型建立专属工作表,强调一个关键理念:虽然所有资产都既可能资本增值,也可能产生被动收入,但它们的具体属性与行为差异很大。
没有任何一本书能提供一夜暴富的万无一失步骤。若有人声称相反,多半是骗局。想想看——如果真有人掌握了凭空致富的必胜法,他们大概率早已乘游艇环球或跻身顶级富豪,而不是把秘诀分享给你。
像本书这样的资料,能为你提供工具与方法,让你把注意力放在打造稳固成果所需的细节上。资产监控器只是起点。哪些方面值得你更深入分析?也许你专注于某一资产类别,但想获得更深的洞察。不妨针对你的兴趣定制工作表——无论是风险分析、ESG(环境、社会与治理)因素,还是估值指标,取舍由你。
能够跟踪资产是监控股票表现的前提。若能评估表现,我们同样具备了跟踪风险的良好基础。关于这部分内容,下一章(第 7 章)将进一步展开。
总结(Summary)
- 资产监控器有助于把你所有资产的信息集中在一处。
- 借助 Python,我们可以访问 Alpaca、Interactive Brokers 等券商,抽取数据并合并到一个数据集。
- 我们可用 Yahoo Finance 等金融平台的数据来充实数据集,获得持仓所需的详细信息。
- 你可以把 Google 表格用作资产监控器,配合 gspread 库把数据写入工作表。
- 统一资产视图有助于在多币种持仓时,将所有头寸换算为同一币种。
- 有时券商的代码(ticker)与金融平台使用的代码不一致,可通过查找表(映射表)解决。
- 偶尔我们无法获取某些资产的数据,但可在数据库中复刻记录这些资产的信息,仍能进行财务分析处理。
- 股票与 ETF 共享类似的接口,通常可用相同指令收集,但并非一种资产可用的方法都适用于另一种。
- 虽然细节不同,但我们可以把股票分红、债券票息、加密质押奖励统称为 “收益(yield)” ,作为被动收入的通用术语。
- 从 Binance 收集数据时,现价可从 CoinMarketCap 获取。
- 传统金融平台对加密资产支持有限,但可改用 Crypto Prices(cryptoprices.cc)/) 与 CoinMarketCap 等数据源,把实时数据填入单元格。