# 投资组合记账 (/zh/features/backtesting/portfolio-accounting)

现金去了哪里，每根 K 线上投资组合持有什么？VBT
为每次回测保留完整账目：资产间共享现金、入金与出金、股息与利息、手续费、持仓及其价值。全部可作为时间序列或记录检查，使共享现金的多资产回测精确核对到分。

```python title="Invest $500 a month in a 60/40 portfolio and collect dividends"
>>> data = vbt.YFData.pull(
...     ["SPY", "TLT"],
...     start="2020-01-01",
...     end="2025-01-01",
...     auto_adjust=False,  # (1)
... )
>>> month_start = ~data.index.tz_localize(None).to_period("M").duplicated()
>>> deposits = pd.Series(0.0, index=data.index)
>>> deposits[month_start] = 500
>>> weights = data.symbol_wrapper.fill(np.nan)
>>> weights[month_start] = [0.6, 0.4]

>>> pf = vbt.PF.from_orders(
...     data,
...     size=weights,
...     size_type="targetpercent",
...     group_by=True,
...     cash_sharing=True,  # (2)
...     call_seq="auto",
...     init_cash=0,
...     cash_deposits=deposits,
...     cash_dividends=data.get("Dividends"),  # (3)
...     fees=0.0005,
... )
>>> pd.Series({
...     "deposits": pf.cash_deposits.sum(),
...     "dividends": pf.cash_earnings.sum(),
...     "fees": pf.orders.fees.sum(),
...     "cash": pf.cash.iloc[-1],
...     "assets": pf.asset_value.iloc[-1],
...     "value": pf.final_value,
... }).round(2)
deposits     30000.00
dividends     1756.07
fees            16.96
cash           174.82
assets       34520.04
value        34694.86
dtype: float64

>>> pd.DataFrame({
...     "Deposited": pf.cash_deposits.cumsum(),
...     "Portfolio value": pf.value,
... }).vbt.plot().show()
```

1.  使用原始价格。复权价格已包含股息，再加入会重复计算。
2.  两只 ETF 使用同一现金余额。
3.  每股股息。每次支付乘以当天持有股数。

2020 至 2024 年按月投入的 SPY 与 TLT 60/40 储蓄计划，累计入金和投资组合净值. [Figure data (JSON)](/assets/figures/features/backtesting/portfolio-accounting-deposits.0dc61451e905.json)

共存入六十笔 500 美元及 1,756 美元股息，支付 16.96 美元手续费，股息在下次月度再平衡时投入。最终净值等于剩余现金加股票价值。

## 共享现金 \[#shared-cash]

默认每列是独立账户。通过 `group_by` 分组并设置 `cash_sharing=True`，可将多列变成同一账户：持仓仍按资产跟踪，现金、净值和收益按组跟踪。此时 `init_cash`
是每组而非每个资产的起始余额。一根 K 线内，组中资产依次交易，`call_seq="auto"`
使卖出先执行，以便用所得资金买入。结果可按组或资产读取。例如，`pf.get_cash_earnings(group_by=False)` 将上例股息拆分为 SPY 的 719.56 美元和 TLT
的 1,036.51 美元。

## 起始状态 \[#starting-state]

模拟以 `init_cash` 开始，也可通过 `init_position` 提供持仓、`init_price`
提供入场价，例如从实盘账户继续。若不希望资金限制测试，`init_cash="auto"` 用无限现金模拟，再将起始余额设为策略曾需要的最大金额：

```python title="Find the cash a sequence of orders requires"
>>> index = pd.date_range("2026-01-01", periods=4)
>>> close = pd.Series([10.0, 12.0, 8.0, 11.0], index=index)
>>> pf = vbt.PF.from_orders(close, size=[5, 5, -10, 20], init_cash="auto")
>>> print(pf.init_cash)
250.0
>>> pf.cash.tolist()
[200.0, 140.0, 220.0, 0.0]
```

多列时，`"autoalign"` 为全部列使用最大的资金需求，使收益共享同一资金基数。`"percent"`、`"valuepercent"`
和目标权重等相对现金或净值的数量，在这种运行中没有有限参照，因此会报错。应使用真实现金模拟；无杠杆时，之后调用 `pf.replace(init_cash="auto")`，了解策略实际用了多少资金。

### 初始持仓 \[#existing-holdings]

可以测试已持有资产的账户接下来会怎样。这里账户初始有 1,000 美元现金和十股股票，随后分两次卖出：

```python title="Start with shares already in the account"
>>> held_close = pd.Series([100.0, 105.0, 110.0], index=pd.date_range("2026-01-01", periods=3))
>>> held_pf = vbt.PF.from_orders(
...     held_close,
...     size=[np.nan, -4, -6],
...     init_cash=1000,
...     init_position=10,
...     init_price=100,
... )
>>> pd.DataFrame({"cash": held_pf.cash, "shares": held_pf.assets, "value": held_pf.value})
              cash  shares   value
2026-01-01  1000.0    10.0  2000.0
2026-01-02  1420.0     6.0  2050.0
2026-01-03  2080.0     0.0  2080.0
```

初始股票从一开始计入组合净值。105 美元卖出四股，110 美元卖出余下六股，最终现金为 2,080 美元。新 K
线到达时延续已有模拟，参见[实时模拟](/zh/features/backtesting/live-simulation/)。

## 现金流 \[#cash-flows]

除交易外，资金还通过四种方式进出账户：

| 现金流        | 参数                  | 说明             |
| ---------- | ------------------- | -------------- |
| 入金与出金      | `cash_deposits`     | 负数表示出金，不超过自由现金 |
| 股息         | `cash_dividends`    | 每股金额，乘以持仓      |
| 利息及其他收入或成本 | `cash_earnings`     | 现金金额，在本根结束时入账  |
| 交易成本       | `fees`、`fixed_fees` | 逐笔订单收取         |

入金计为投入资本，因此增加投入金额但不增加收益率。依赖账户本身的现金流，例如按当前净值收取管理费、年末业绩费或已实现收益税，可在模拟期间由回调写入 `cash_earnings`。

## 估值 \[#valuation]

报告的组合净值是现金加按收盘价计算的持仓价值。模拟期间，`val_price`
可选择仓位决策使用的估值价格，例如目标配置对应多少股。缺失收盘价默认填充，因此数据缺口不会使持仓价值归零。组收益来自合并净值，因此不是资产收益之和或均值；逐资产收益由
`pf.get_returns(group_by=False)`
提供。利润分为已平仓交易的已实现利润与未平仓交易的未实现利润，所有序列均可逐根获取：`pf.cash`、`pf.assets`、`pf.asset_value`、`pf.value` 和
`pf.returns`。

## 记录 \[#records]

账户保留四类记录，各有可读表格：

| 记录             | 每行含义                   |
| -------------- | ---------------------- |
| `pf.orders`    | 已成交订单；信号策略还含信号、创建和成交时间 |
| `pf.logs`      | 请求订单及前后状态，无论是否成交       |
| `pf.trades`    | 与离场匹配的入场，包括部分离场        |
| `pf.positions` | 从开仓到空仓、合并各笔交易的持仓       |

模拟时设置 `log=True` 保留订单日志，包括未成交请求。

```python title="Read positions, including the one still open"
>>> pf = vbt.PF.from_orders(close, size=[5, 5, -10, 20], init_cash=1000)
>>> cols = ["Entry Index", "Exit Index", "Size", "Avg Entry Price", "PnL", "Status"]
>>> pf.positions.readable[cols]
  Entry Index Exit Index  Size  Avg Entry Price   PnL  Status
0  2026-01-01 2026-01-03  10.0             11.0 -30.0  Closed
1  2026-01-04 2026-01-04  20.0             11.0   0.0    Open
```

若要为订单附加自定义信息，例如离场原因，可在回调中写入旁侧数组，再按行列与订单记录连接。

## 配置与敞口 \[#allocations-and-exposure]

`pf.allocations` 给出每根 K 线上各资产在组中的权重。敞口概括组合：多头和空头敞口分别是多空资产价值相对组合净值的比例，总敞口是两者之和，净敞口是两者之差。这里初始做多 SPY
60%、做空 TLT 40%，之后任其漂移：

```python title="Track gross and net exposure of a long/short book"
>>> weights = data.symbol_wrapper.fill(np.nan)
>>> weights.iloc[0] = [0.6, -0.4]
>>> book_pf = vbt.PF.from_orders(
...     data,
...     size=weights,
...     size_type="targetpercent",
...     group_by=True,
...     cash_sharing=True,
...     call_seq="auto",
...     init_cash=10_000,
... )
>>> pd.DataFrame({
...     "long": book_pf.get_gross_exposure(direction="longonly"),
...     "short": book_pf.get_gross_exposure(direction="shortonly"),
...     "gross": book_pf.gross_exposure,
...     "net": book_pf.net_exposure,
... }).iloc[[0, 500, 1000]].round(3)
                            long  short  gross    net
Date
2020-01-02 00:00:00-05:00  0.600  0.400  1.000  0.200
2021-12-27 00:00:00-05:00  0.707  0.349  1.056  0.358
2023-12-21 00:00:00-05:00  0.631  0.209  0.839  0.422
```

SPY 上涨、TLT 下跌时，空头缩小，组合净多头增加。若要了解在场时间，`pf.position_coverage` 给出持有未平仓头寸的 K 线比例。

## 资产加权 \[#asset-weighting]

新增于 v2024.8.20

✅ 资产加权可精细调整组合内各资产或策略的影响，增强对整体表现的控制。关键好处是权重不限于收益率，而是一致应用于全部时间序列和度量，包括订单、现金流等，确保投资组合各方面精确对应。

`pf.apply_weights`
在不重新模拟的情况下，为已完成结果重新加权，适合比较已测试资产或策略的配置。若要测试交易期间资产如何竞争同一笔现金，使用[共享现金](#shared-cash)。组合多个策略参见[多策略投资组合](/zh/features/strategies/multi-strategy-portfolios/)。

```python title="Maximize Sharpe of a random portfolio"
>>> data = vbt.YFData.pull(["AAPL", "MSFT", "GOOG"], start="2020")
>>> pf = data.run("from_random_signals", n=vbt.Default(50), seed=42, group_by=True)
>>> pf.get_sharpe_ratio(group_by=False)  # (1)
symbol
AAPL    1.401012
MSFT    0.456162
GOOG    0.852490
Name: sharpe_ratio, dtype: float64

>>> pf.sharpe_ratio  # (2)
1.2132857343006869

>>> prices = pf.get_value(group_by=False)
>>> weights = vbt.pypfopt_optimize(prices=prices)  # (3)
>>> weights
{'AAPL': 0.85232, 'MSFT': 0.0, 'GOOG': 0.14768}

>>> weighted_pf = pf.apply_weights(weights, rescale=True)  # (4)
>>> weighted_pf.weights
symbol
AAPL    2.55696
MSFT    0.00000
GOOG    0.44304
dtype: float64

>>> weighted_pf.get_sharpe_ratio(group_by=True)  # (5)
1.426112580298898
```

1.  各单独资产的夏普比率。
2.  合并投资组合的夏普比率。目标是最大化此值。
3.  基于净值（即投资组合价值）变化最大化夏普比率。
4.  重新缩放权重以便相乘，并创建应用权重的新投资组合。
5.  优化后投资组合的夏普比率。

## 持仓视图 \[#position-views]

新增于 v2024.8.20

✅ 持仓视图可聚焦多头或空头分析投资组合，为各投资策略提供清晰独立的视角。

```python title="Separate long and short positions of a basic SMA crossover portfolio"
>>> data = vbt.YFData.pull("BTC-USD")
>>> fast_sma = data.run("talib_func:sma", timeperiod=20)
>>> slow_sma = data.run("talib_func:sma", timeperiod=50)
>>> long_entries = fast_sma.vbt.crossed_above(slow_sma)
>>> short_entries = fast_sma.vbt.crossed_below(slow_sma)
>>> pf = vbt.PF.from_signals(
...     data,
...     long_entries=long_entries,
...     short_entries=short_entries,
...     fees=0.01,
...     fixed_fees=1.0
... )

>>> long_pf = pf.long_view
>>> short_pf = pf.short_view

>>> fig = vbt.make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.01)
>>> fig = long_pf.assets.vbt.plot_against(
...     0,
...     trace_kwargs=dict(name="Long position", line_shape="hv", line_color="mediumseagreen"),
...     other_trace_kwargs=dict(visible=False),
...     add_trace_kwargs=dict(row=1, col=1),
...     fig=fig
... )
>>> fig = short_pf.assets.vbt.plot_against(
...     0,
...     trace_kwargs=dict(name="Short position", line_shape="hv", line_color="coral"),
...     other_trace_kwargs=dict(visible=False),
...     add_trace_kwargs=dict(row=2, col=1),
...     fig=fig
... )
>>> fig.show()
```

BTC-USD SMA 交叉投资组合，分别显示多头和空头资产. [Figure data (JSON)](/assets/figures/features/portfolio/position-views.e9ab9910acba.json)

```python
>>> long_pf.sharpe_ratio
0.9185961894435091

>>> short_pf.sharpe_ratio
0.2760864152147919
```

## 持仓信息 \[#position-info]

新增于 1.11.0

✅ 动态信号函数现在可以访问当前持仓信息，例如未平仓损益。

```python title="Enter randomly, exit randomly but only if in profit"
>>> @njit
... def signal_func_nb(ctx, entries, exits):
...     is_entry = vbt.pf_nb.select_nb(ctx, entries)
...     is_exit = vbt.pf_nb.select_nb(ctx, exits)
...     if is_entry:
...         return True, False, False, False
...     if is_exit:
...         pos_info = ctx.last_pos_info[ctx.col]
...         if pos_info["status"] == vbt.pf_enums.TradeStatus.Open:
...             if pos_info["pnl"] >= 0:
...                 return False, True, False, False
...     return False, False, False, False

>>> data = vbt.YFData.pull("BTC-USD")
>>> entries, exits = data.run("RANDNX", n=10, seed=42, unpack=True)
>>> pf = vbt.Portfolio.from_signals(
...     data,
...     signal_func_nb=signal_func_nb,
...     signal_args=(vbt.Rep("entries"), vbt.Rep("exits")),
...     broadcast_named_args=dict(entries=entries, exits=exits),
...     jitted=False  # (1)
... )
>>> pf.trades.readable[["Entry Index", "Exit Index", "PnL"]]
                Entry Index                Exit Index           PnL
0 2014-11-01 00:00:00+00:00 2016-01-08 00:00:00+00:00     39.134739
1 2016-03-27 00:00:00+00:00 2016-09-07 00:00:00+00:00     61.220063
2 2016-12-24 00:00:00+00:00 2016-12-31 00:00:00+00:00     14.471414
3 2017-03-16 00:00:00+00:00 2017-08-05 00:00:00+00:00    373.492028
4 2017-09-12 00:00:00+00:00 2018-05-05 00:00:00+00:00    815.699284
5 2019-02-15 00:00:00+00:00 2019-11-10 00:00:00+00:00   2107.383227
6 2019-12-04 00:00:00+00:00 2019-12-10 00:00:00+00:00     12.630214
7 2020-07-12 00:00:00+00:00 2021-11-14 00:00:00+00:00  21346.035444
8 2022-01-15 00:00:00+00:00 2023-03-06 00:00:00+00:00 -11925.133817
```

1.  测试期间关闭 Numba，避免编译。

## 现金入金 \[#cash-deposits]

新增于 1.0.0

✅ 可随时存入或提取现金。

```python title="DCA $10 into Bitcoin each month"
>>> data = vbt.YFData.pull("BTC-USD")
>>> cash_deposits = data.symbol_wrapper.fill(0.0)
>>> month_start_mask = ~data.index.tz_convert(None).to_period("M").duplicated()
>>> cash_deposits[month_start_mask] = 10
>>> pf = vbt.PF.from_orders(
...     data.close,
...     init_cash=0,
...     cash_deposits=cash_deposits
... )

>>> pf.input_value  # (1)
1020.0

>>> pf.final_value
20674.328828315127
```

1.  总投入。

## 现金收益 \[#cash-earnings]

新增于 1.0.0

✅ 现金可以根据当前持仓持续流入或流出。

```python title="Backtest Apple without and with dividend reinvestment"
>>> data = vbt.YFData.pull("AAPL", start="2010")

>>> pf_kept = vbt.PF.from_holding(  # (1)
...     data.close,
...     cash_dividends=data.get("Dividends")
... )
>>> pf_kept.cash.iloc[-1]  # (2)
93.9182408043298

>>> pf_kept.assets.iloc[-1]  # (3)
15.37212731743495

>>> pf_reinvested = vbt.PF.from_orders(  # (4)
...     data.close,
...     cash_dividends=data.get("Dividends")
... )
>>> pf_reinvested.cash.iloc[-1]
0.0

>>> pf_reinvested.assets.iloc[-1]
18.203284859405468

>>> fig = pf_kept.value.rename("Value (kept)").vbt.plot()
>>> pf_reinvested.value.rename("Value (reinvested)").vbt.plot(fig=fig)
>>> fig.show()
```

1.  将股息保留为现金。
2.  最终现金余额。
3.  组合最终股数。
4.  在下一根 K 线再投入股息。

2010 年起 AAPL 投资组合净值，比较股息保留为现金与再投入. [Figure data (JSON)](/assets/figures/features/portfolio/dividends.5b1cc406d059.json)
