给程序员的投资指南——构建资产监控器

126 阅读25分钟

本章内容(This chapter covers)

  • 线上与线下来源收集投资组合数据
  • 分析投资组合中的资产表现
  • 预测资产与组合的未来回报
  • 制作月度被动收入与收益报告
  • Google 表格中收集与管理数据

在前几章中,我们介绍了如何为潜在投资筛选资产。假设你已经完成了部分买入,现在是时候学习如何监控现有投资组合了。本章旨在帮助你掌控全局、持续掌握信息,并最大化资产的潜在回报

我们将先带你从不同券商收集持仓;随后把数据汇总为可行动的洞见:追踪总被动收入、识别表现突出(正/负)标的、并找出需要改进的环节。最后,我们会预测未来的收益与增长,回答那个关键问题:什么时候你能达到自己的财务里程碑?

我们将使用 Google 表格来采集与可视化数据,支持筛选与深入分析。一起解锁像专业人士那样管理股票投资组合的诀窍。准备升级?开始吧。

注意:配套的 Git 仓库与下载区提供了完整可运行的 Jupyter Notebook。书中的代码片段用于帮助理解,未必在清单中声明了所有辅助变量。所需的 Python 库可参见附录。

6.1 架构(Architecture)

本章将构建一个数据科学笔记本,把来自多个来源的持仓信息整合进中央仓库。我们希望跟踪的关键指标包括:当前组合市值、以及来自资本增值被动收入年度预测回报。如图 6.1 所示,我们会按资产类别抽取数据并导出到专属工作表,让你在同一份 Google 表格中分别查看股票、加密货币、债券等不同资产标签页。

image.png

图 6.1 从资产数据到 Google 表格各工作表的数据流

本章将以两个股票券商与一个加密货币交易所构成的演示组合为例。即便你使用不同平台,文中的代码也能作为模板来搭建自定义的数据管道,从你的券商获取信息。

全球范围内有数百家机构与产品(银行、券商、交易所、加密钱包等)可供你存放资产。不幸的是,它们没有统一标准供程序化访问;有些甚至不提供自动化拉取数据的接口。

image.png

图 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 列是导出时的持仓总值,借助实时数据还可跟踪自上次更新以来的变化

image.png

图 6.3 含股票信息的 Google 表格工作表示例

图 6.4 展示了名为 assets总览页,也带有部分参考数据。该页汇总了资产价值收益;右侧图表展示资产类型分布,便于一眼看出你的配置结构。比如,此参考数据代表了一个风险偏高的组合:大多数风险厌恶型投资者会更偏好指数基金(ETF)持仓占比更高的配置。

image.png

图 6.4 资产按价值(USD 列)与被动收入(Yield 列)的分组总览

接下来我们演示如何采集数据、抽取关键指标、把不同货币统一换算为单一货币(若多币种持仓),并将数据导出至 Google 表格。开始吧!

6.3 抽取数据(Extracting data)

证券券商为投资者提供资产交易托管服务。本书选取两家可用 Python API访问账户数据的券商:AlpacaInteractive Brokers(IB) 。与很多仅覆盖少数国家的券商不同,这两家在全球范围提供服务,更便于读者实操。请注意,开户需要身份核验(如护照)与税务居民信息,这一流程称为 KYC(了解你的客户),是投资服务业的强制要求。附录会补充更多券商信息。

注意:选择券商前务必调研。许多券商只服务少数国家下单/转账费用高低也会影响盈亏。若你想自动化,需确认券商是否向客户开放 API。如果拿不准,咨询专业顾问

为了处理来自不同来源的数据,我们需要把它们统一格式化合并。因此,先定义目标 DataFrame字段结构,如下:

  • Ticker:股票代码,作唯一标识
  • Number of shares:当前持有的股数
  • Average price:所有交易汇总后的平均买入价
  • Exchange:该资产交易所/平台
  • Broker:用于标识数据来源的常量(此处为 INTERACTIVE_BROKERALPACA

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() 拉取并清洗字段

以上完成后,你就能把 AlpacaIB 的 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 FinanceGoogle Finance 获取财务数据。在把持仓与平台数据合并之前,还剩一个任务:大多数情况下,券商给出的 ticker 与财务平台上的一致,但也有例外。例如,Berkshire HathawayBRK.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 一致,可将 yahoogoogle 留空(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。

演示映射只包含少量交易所(如 AEBVSE);请按需扩充。

清单 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 以每股 **100买入A公司10股(100** 买入 **A 公司** 10 股(1,000),以每股 **5买入B公司100股(5** 买入 **B 公司** 100 股(500)。现价分别为 A:130B130**、**B:7.5。账面增值 A:300B300**、**B:250。严格按百分比,A:30%B:50% 。如果当初在 B 上投 1,000、在A上投1,000、在 **A** 上投 500,则收益会是 650而非650** 而非 **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 使用的指标(如 currentPricedividendRate)与 ETF 用的(navPriceyield)不同。抽象层面上,它们回答的是同一类问题:现在的价格是多少?投资者能拿到多少被动收入?

这些“小差别”有助于我们在程序员视角下加深对资产本质的认识。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 ≠ 股票,某些适用于单一公司的指标,并不适合直接聚合到池化投资上。

image.png

图 6.5 展示了 ETF 工作表示例。注意,某些数据——如目标价推断的预期收益——可能无法通过 yfinance 获得,因此不在表中呈现。

6.5.3 债券(Bonds)

当你买股票时,会通过**股票代码(ticker)**来识别公司。熟练之后,你可能更习惯说 MSFT、AAPL、NVDA,而不是 Microsoft、Apple、NVIDIAETF 也类似——虽然本质上是一篮子资产,许多投资者看到 VOO、SPY 就会自然联想到 Vanguard S&P 500 ETFSPDR S&P 500 ETF Trust

但在从券商处采集的数据里,债券往往比股票更难识别。原因在于债券会按固定节奏分批发行每一批(每期)都有不同的标识符。你不能像说“再买点 VOO 或 NVDA”那样表达;实质上,你是按约定条款(票息、到期日等)把钱借给某个主体。若你在之后想再借出更多资金,等于认购新一期发行,其条款可能不同。每只债券仍可通过唯一标识识别,例如金融平台常用的 ISIN(如 US9128CLA70)。

因此,对债券我们不一定像股票那样用 yfinance 丰富数据。下面的代码展示了另一种路径:把我们存放在 SQLiteoffline_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 展示了一个带参考数据的债券工作表。

image.png

图 6.6 在债券工作表中,Value 表示票面价值(par value) (到期兑付的金额);同时还展示了年化收益持有数量

6.5.4 加密货币(Cryptocurrencies)

本例展示如何从加密货币交易所获取资产信息。清单 6.10Binance 为例,使用 python_binance API。我们通过 get_simple_earn_locked_product_positionget_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 Pricescryptoprices.cc) 提供的接口。由于访问频率等原因,可能会偶发错误,因此在获取失败时,单元格会回落到上次导出的值图 6.7 为将加密资产导出到工作表的示例页面。

image.png

图 6.7 加密资产工作表会展示总价值质押收益(年化)持有数量以及实时数据

6.6 展望(Outlook)

本章构建的 Jupyter Notebook 可以作为你个人资产监控器的模板。在这一框架中,我们为每种资产类型建立专属工作表,强调一个关键理念:虽然所有资产都既可能资本增值,也可能产生被动收入,但它们的具体属性与行为差异很大。

没有任何一本书能提供一夜暴富的万无一失步骤。若有人声称相反,多半是骗局。想想看——如果真有人掌握了凭空致富的必胜法,他们大概率早已乘游艇环球跻身顶级富豪,而不是把秘诀分享给你。

像本书这样的资料,能为你提供工具与方法,让你把注意力放在打造稳固成果所需的细节上。资产监控器只是起点。哪些方面值得你更深入分析?也许你专注于某一资产类别,但想获得更深的洞察。不妨针对你的兴趣定制工作表——无论是风险分析ESG(环境、社会与治理)因素,还是估值指标取舍由你

能够跟踪资产是监控股票表现的前提。若能评估表现,我们同样具备了跟踪风险的良好基础。关于这部分内容,下一章(第 7 章)将进一步展开。

总结(Summary)

  • 资产监控器有助于把你所有资产的信息集中在一处
  • 借助 Python,我们可以访问 AlpacaInteractive Brokers 等券商,抽取数据并合并到一个数据集。
  • 我们可用 Yahoo Finance 等金融平台的数据来充实数据集,获得持仓所需的详细信息。
  • 你可以把 Google 表格用作资产监控器,配合 gspread 库把数据写入工作表
  • 统一资产视图有助于在多币种持仓时,将所有头寸换算为同一币种
  • 有时券商的代码(ticker)与金融平台使用的代码不一致,可通过查找表(映射表)解决。
  • 偶尔我们无法获取某些资产的数据,但可在数据库中复刻记录这些资产的信息,仍能进行财务分析处理
  • 股票与 ETF 共享类似的接口,通常可用相同指令收集,但并非一种资产可用的方法适用于另一种。
  • 虽然细节不同,但我们可以把股票分红债券票息加密质押奖励统称为 “收益(yield)” ,作为被动收入的通用术语。
  • Binance 收集数据时,现价可从 CoinMarketCap 获取。
  • 传统金融平台对加密资产支持有限,但可改用 Crypto Prices(cryptoprices.cc)/)CoinMarketCap 等数据源,把实时数据填入单元格。