保证金交易
到目前为止,我们的回测一直局限于纯现金交易。本文档将探讨 PyBroker v2 中的保证金交易功能。我们将使用 StrategyConfig 为购买力设置杠杆、计算借入资金的利息,并为空头仓位设置抵押品。
[1]:
import pybroker
from pybroker import Strategy, StrategyConfig, YFinance, sumv
pybroker.enable_data_source_cache("margin_trading")
[1]:
<pybroker.cache._L1Cache at 0x7fed5414fc80>
配置杠杆
保证金通过 leverage 配置项启用,它会为多头和空头仓位放大购买力。
[2]:
config = StrategyConfig(initial_cash=100_000, leverage=2.0)
默认值 1.0 表示仅使用现金买入,将 leverage 设置为 2.0 则允许持有价值最高达自身权益 2 倍的仓位,每个仓位中借入的那一半会作为保证金贷款进行跟踪。PyBroker 不模拟追缴保证金,订单只会在成交时受到可用购买力的限制。
接下来,我们编写一个简单的趋势跟踪策略,只要某个股票代码的收盘价高于其 50 日移动平均线,就持有该股票代码:
[3]:
sma_50 = pybroker.indicator("sma_50", lambda data: sumv(data.close, 50) / 50)
def trend_follow(ctx):
sma = ctx.indicator("sma_50")[-1]
if ctx.long_pos() is None and ctx.close[-1] > sma:
ctx.buy_shares = ctx.calc_target_shares(0.25)
elif ctx.long_pos() is not None and ctx.close[-1] < sma:
ctx.sell_all_shares()
杠杆正是在仓位规模计算中发挥作用的地方。ctx.calc_target_shares 方法会按可部署资本的一定比例来确定订单规模,而这一资本等于你的总权益乘以 leverage。
因此,在使用保证金交易时,对四只股票各设定 25% 的目标配置,最终部署的资金最多可达自身权益的约 2 倍:
[4]:
yfinance = YFinance()
strategy = Strategy(
yfinance, start_date="1/1/2025", end_date="8/1/2026", config=config
)
strategy.add_execution(
trend_follow, ["GS", "MS", "C", "USB"], indicators=sma_50
)
result_2x = strategy.backtest()
result_2x.portfolio.tail()
Backtesting: 2025-01-01 00:00:00 to 2026-08-01 00:00:00
Loading bar data...
[*********************100%***********************] 4 of 4 completed
Loaded bar data: 0:00:00
Computing indicators...
100% (4 of 4) |##########################| Elapsed Time: 0:00:00 Time: 0:00:00
Test split: 2025-01-02 00:00:00 to 2026-07-31 00:00:00
100% (395 of 395) |######################| Elapsed Time: 0:00:00 Time: 0:00:00
Finished backtest: 0:00:01
[4]:
| cash | equity | notional | margin | margin_loan | net_cash_balance | market_value | pnl | unrealized_pnl | fees | |
|---|---|---|---|---|---|---|---|---|---|---|
| date | ||||||||||
| 2026-07-27 | 30108.36 | 208216.89 | 311083.77 | 0.0 | 132975.24 | -102866.88 | 208216.89 | 108216.89 | 0.0 | 0.0 |
| 2026-07-28 | 30108.36 | 207216.77 | 310083.65 | 0.0 | 132975.24 | -102866.88 | 207216.77 | 107216.77 | 0.0 | 0.0 |
| 2026-07-29 | 135311.71 | 199685.90 | 115525.98 | 0.0 | 51151.79 | 84159.92 | 199685.90 | 99685.90 | 0.0 | 0.0 |
| 2026-07-30 | 135311.71 | 199833.02 | 115673.10 | 0.0 | 51151.79 | 84159.92 | 199833.02 | 99833.02 | 0.0 | 0.0 |
| 2026-07-31 | 135311.71 | 200035.31 | 115875.39 | 0.0 | 51151.79 | 84159.92 | 200035.31 | 100035.31 | 0.0 | 0.0 |
result.portfolio 会在每根 K 线记录保证金余额。开立杠杆仓位时,一部分现金(entry cost / leverage)会作为抵押品被占用。借入的剩余部分会在 margin_loan 中进行跟踪,而 net_cash_balance 等于 cash - margin_loan。
margin 列则是独立的,它跟踪未平仓空头仓位的当前价值,因此在只做多的策略中始终为零。
为了实际观察这些保证金机制,我们对输出进行筛选,仅显示存在未偿还贷款的 K 线:
[5]:
levered = result_2x.portfolio[result_2x.portfolio["margin_loan"] > 0]
levered.head()
[5]:
| cash | equity | notional | margin | margin_loan | net_cash_balance | market_value | pnl | unrealized_pnl | fees | |
|---|---|---|---|---|---|---|---|---|---|---|
| date | ||||||||||
| 2025-05-02 | 74586.70 | 100122.40 | 50949.00 | 0.0 | 25413.30 | 49173.40 | 100122.40 | 122.40 | 0.0 | 0.0 |
| 2025-05-05 | 24856.76 | 99627.67 | 149914.16 | 0.0 | 75143.25 | -50286.49 | 99627.67 | -372.33 | 0.0 | 0.0 |
| 2025-05-06 | 24856.76 | 97635.42 | 147921.91 | 0.0 | 75143.25 | -50286.49 | 97635.42 | -2364.58 | 0.0 | 0.0 |
| 2025-05-07 | 24856.76 | 98739.16 | 149025.65 | 0.0 | 75143.25 | -50286.49 | 98739.16 | -1260.84 | 0.0 | 0.0 |
| 2025-05-08 | 24856.76 | 102121.35 | 152407.84 | 0.0 | 75143.25 | -50286.49 | 102121.35 | 2121.35 | 0.0 | 0.0 |
在这些筛选出的 K 线中的第一根上,成交了一笔入场交易。现金减少了 $25,413,用于将入场成本的一半作为抵押品,而 margin_loan 列则记录了借入的剩余部分。抵押品与保证金贷款按入场价格固定不变,而 notional 列会在每根 K 线收盘时跟踪该仓位的当前市值。
与纯现金交易进行比较
为了观察杠杆对策略的影响,我们将 leverage 设置为纯现金的 1.0 并重新运行策略。我们还会使用 disable_logging 禁用 PyBroker 的日志记录,以保持后续运行的输出简洁:
[6]:
pybroker.disable_logging()
def run_backtest(
config, exec_fn=trend_follow, symbols=("GS", "MS", "C", "USB")
):
strategy = Strategy(
yfinance, start_date="1/1/2025", end_date="8/1/2026", config=config
)
strategy.add_execution(exec_fn, symbols, indicators=sma_50)
return strategy.backtest()
result_1x = run_backtest(StrategyConfig(initial_cash=100_000))
print(f"1x total return: {result_1x.metrics.total_return_pct:.2f}%")
print(f"2x total return: {result_2x.metrics.total_return_pct:.2f}%")
print(f"1x max drawdown: {result_1x.metrics.max_drawdown_pct:.2f}%")
print(f"2x max drawdown: {result_2x.metrics.max_drawdown_pct:.2f}%")
1x total return: 41.20%
2x total return: 86.46%
1x max drawdown: -10.82%
2x max drawdown: -19.91%
收取保证金利息
杠杆使回报率提高了一倍多(最大回撤也几乎翻倍),但借入资金并非没有成本。你可以使用 interest_rate 配置项来模拟这一融资成本:它会对投资组合的净现金余额按年利率计息,并以 interest_rate / bars_per_year 的比例按每根 K 线计提一次。要使用此功能,你还必须设置 bars_per_year。
当净现金为负(即保证金贷款超过现金)时会收取利息,当净现金为正时则会计入利息收益。为了观察这些成本对投资组合的影响,我们将回测一个买入并持有的策略。由于两次运行持有相同的仓位,它们最终市值的任何差额都是所支付的利息:
[7]:
def buy_and_hold(ctx):
if ctx.long_pos() is None:
ctx.buy_shares = ctx.calc_target_shares(0.25)
config_interest = StrategyConfig(
initial_cash=100_000,
leverage=2.0,
interest_rate=6.0,
bars_per_year=252,
)
result_hold = run_backtest(config, buy_and_hold)
result_interest = run_backtest(config_interest, buy_and_hold)
print(
"Final market value (no interest):",
result_hold.portfolio["market_value"].iloc[-1],
)
print(
"Final market value (6% interest):",
result_interest.portfolio["market_value"].iloc[-1],
)
Final market value (no interest): 231518.52
Final market value (6% interest): 221770.63
观察投资组合的末尾部分,cash 会始终保持为零。与此同时,计提的利息会被加到 margin_loan 中,并随着每根 K 线不断增加:
[8]:
result_interest.portfolio[
["cash", "margin_loan", "net_cash_balance", "market_value"]
].tail()
[8]:
| cash | margin_loan | net_cash_balance | market_value | |
|---|---|---|---|---|
| date | ||||
| 2026-07-27 | 0.0 | 109727.59 | -109727.59 | 227059.35 |
| 2026-07-28 | 0.0 | 109753.72 | -109753.72 | 225082.19 |
| 2026-07-29 | 0.0 | 109779.85 | -109779.85 | 211670.48 |
| 2026-07-30 | 0.0 | 109805.99 | -109805.99 | 222003.37 |
| 2026-07-31 | 0.0 | 109832.13 | -109832.13 | 221770.63 |
使用保证金做空
卖空同样会使用保证金。空头需要相当于 entry cost / leverage 的抵押品,并与多头共用同一份购买力。
为了演示这一点,我们将做空那些价格低于其移动平均线的股票代码。我们还会启用 record_position_bars 来捕获每个仓位各自的余额:
[9]:
def trend_short(ctx):
sma = ctx.indicator("sma_50")[-1]
if ctx.short_pos() is None and ctx.close[-1] < sma:
ctx.sell_shares = ctx.calc_target_shares(0.25)
elif ctx.short_pos() is not None and ctx.close[-1] > sma:
ctx.cover_all_shares()
config_short = StrategyConfig(
initial_cash=100_000, leverage=2.0, record_position_bars=True
)
result_short = run_backtest(
config_short, trend_short, ["HD", "LOW", "CMCSA", "KHC"]
)
shorted = result_short.portfolio[result_short.portfolio["margin"] > 0]
shorted[
[
"cash",
"equity",
"margin",
"margin_loan",
"net_cash_balance",
"market_value",
]
].head()
[9]:
| cash | equity | margin | margin_loan | net_cash_balance | market_value | |
|---|---|---|---|---|---|---|
| date | ||||||
| 2025-03-18 | 25446.39 | 100000.00 | 148914.57 | 74553.61 | -49107.22 | 100192.65 |
| 2025-03-19 | 50198.67 | 99686.68 | 99669.93 | 49488.01 | 710.66 | 98992.77 |
| 2025-03-20 | 25260.35 | 99686.68 | 149658.35 | 74426.33 | -49165.98 | 98880.99 |
| 2025-03-21 | 25260.35 | 99686.68 | 148904.46 | 74426.33 | -49165.98 | 99634.88 |
| 2025-03-24 | 25260.35 | 99686.68 | 151303.30 | 74426.33 | -49165.98 | 97236.04 |
对于未平仓的空头仓位,margin 列会跟踪它们当前的名义敞口,在使用杠杆时,这一敞口可能超过你的总权益。
开立空头仓位时,会从你的现金中扣留相当于 entry cost / leverage 的抵押品,而 margin_loan 列则记录借入的剩余部分。由于抵押品与贷款在入场时即已固定,它们不会随 margin 列变化。
你的 net_cash_balance 等于剩余现金减去这笔保证金贷款,一旦贷款超过可用现金,其数值就会变为负数。请注意,equity 按固定的入场成本为空头仓位估值,而 market_value 则包含了它们的未实现盈亏。
由于我们启用了 record_position_bars,result.positions 会跟踪每个单独仓位的余额。这包括每个空头仓位在投资组合 margin 中所占的具体份额,以及它自身的未实现盈亏:
[10]:
result_short.positions[
["short_shares", "close", "margin", "unrealized_pnl"]
].head()
[10]:
| short_shares | close | margin | unrealized_pnl | ||
|---|---|---|---|---|---|
| symbol | date | ||||
| CMCSA | 2025-03-18 | 1492 | 33.75 | 50353.25 | -222.05 |
| HD | 2025-03-18 | 141 | 349.57 | 49289.37 | 164.97 |
| LOW | 2025-03-18 | 221 | 222.95 | 49271.95 | 249.73 |
| HD | 2025-03-19 | 141 | 353.42 | 49832.22 | -377.88 |
| LOW | 2025-03-19 | 221 | 225.51 | 49837.71 | -316.03 |