Margin Trading
Until now, our backtests have been limited to cash-only trades. This notebook explores the margin trading features in PyBroker v2. We’ll use StrategyConfig to apply leverage to our buying power, calculate interest on borrowed funds, and set up collateral for short positions.
[1]:
import pybroker
from pybroker import Strategy, StrategyConfig, YFinance, sumv
pybroker.enable_data_source_cache("margin_trading")
[1]:
<pybroker.cache._L1Cache at 0x7fed5414fc80>
Configuring Leverage
Margin is enabled with the leverage config option, which multiplies buying power for both long and short positions.
[2]:
config = StrategyConfig(initial_cash=100_000, leverage=2.0)
The default of 1.0 buys with cash only, and setting leverage to 2.0 allows holding positions worth up to 2x our equity. The borrowed half of each position is tracked as a margin loan. PyBroker does not model margin calls. Orders are instead limited to the available buying power at fill time.
Next, we write a simple trend-following strategy that holds a symbol while it closes above its 50-day moving average:
[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()
Position sizing is where leverage comes into play. The ctx.calc_target_shares method sizes orders as a fraction of your deployable capital, which equals your total equity multiplied by leverage.
As a result, targeting 25% across four stocks deploys up to roughly 2x our equity when trading on margin:
[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 records the margin balances on every bar. When a levered position is opened, a portion of cash (entry cost / leverage) is posted as collateral. The borrowed remainder is tracked in margin_loan, and the
net_cash_balance equals cash - margin_loan.
The margin column is separate and tracks the current value of open short positions, so it stays at zero when the strategy is long only.
To see these margin mechanics in action, we filter the output to show only bars with an outstanding loan:
[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 |
On the first of these filtered bars, a single entry filled. Cash dropped by $25,413 to post half of the entry cost as collateral, while the margin_loan column records the borrowed remainder. While the collateral and margin loan are fixed at the entry price, the notional column tracks the position’s current market value at each close.
Comparing Against Cash-Only
To see the effect of leverage on our strategy, we rerun the strategy with a cash only leverage of 1.0. We also disable PyBroker’s logging to keep the output clean for the remaining runs using disable_logging:
[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%
Charging Margin Interest
Leverage more than doubled the return (and nearly doubled the max drawdown), but borrowing is not free. You can simulate this financing cost using the interest_rate config option. It applies an annual percentage rate to the portfolio’s net cash balance, accruing once per bar at interest_rate / bars_per_year. To use this feature, you must also set
bars_per_year.
Interest is charged when net cash is negative (the margin loan exceeds cash) and credited when net cash is positive. To see how these costs affect the portfolio, we will backtest a buy-and-hold strategy. Because both runs hold the same positions, any difference in their final market value will be the interest paid:
[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
Looking at the tail of the portfolio, cash will stay zero. Meanwhile, the accrued interest is added to the margin_loan and increases with every bar:
[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 |
Shorting on Margin
Short selling also uses margin. Shorts require collateral equal to entry cost / leverage and use the same buying power as long positions.
To demonstrate this, we will short symbols trading below their moving average. We will also enable record_position_bars to capture per-position balances:
[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 |
For open short positions, the margin column tracks their current notional exposure, which can exceed your total equity when using leverage.
When you open a short, collateral equal to entry cost / leverage is held from your cash, and the margin_loan column records the borrowed remainder. Because the collateral and the loan are fixed at entry, they do not change with the margin column.
Your net_cash_balance equals your remaining cash minus this margin loan, turning negative if the loan exceeds your available cash. Note that equity values shorts at their fixed entry cost, while market_value includes their unrealized PnL.
Because we enabled record_position_bars, result.positions tracks the balances for each individual position. This includes each short’s specific share of the portfolio’s margin, as well as its own unrealized PnL:
[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 |