DEV Community

pickuma
pickuma

Posted on • Originally published at pickuma.com

Building a Portfolio Rebalancing Script in Python: From Drift to Trades

A target allocation is a decision you make once and then quietly betray. You pick 60% US stocks, 30% international, 10% bonds, and the market spends the next year pulling those numbers apart. Bonds rally, equities stall, and the portfolio you actually hold stops matching the one you designed. Rebalancing is the act of dragging it back. The mechanics are simple enough that you don't need a brokerage feature for it — about forty lines of Python turns a pile of holdings into an exact trade list.

This walks through that script in three pieces: measuring how far each position has drifted, converting that drift into share-level buy and sell orders, and adding the one rule that stops you from trading every time a price ticks.

Measuring drift before you trade

Drift is the gap between what you hold and what you meant to hold, expressed in percentage points. Before you can correct it, you have to compute it from the only inputs you reliably have: share counts, current prices, and your target weights.

holdings = {
    "VTI":  {"shares": 42, "price": 268.40, "target": 0.60},
    "VXUS": {"shares": 73, "price": 61.20,  "target": 0.30},
    "BND":  {"shares": 88, "price": 72.10,  "target": 0.10},
}

values = {t: h["shares"] * h["price"] for t, h in holdings.items()}
total = sum(values.values())

for ticker, h in holdings.items():
    weight = values[ticker] / total
    drift = weight - h["target"]
    print(f"{ticker}: {weight:5.1%} (target {h['target']:.0%}, drift {drift:+.1%})")
Enter fullscreen mode Exit fullscreen mode

Run against this portfolio and the output is unambiguous:

VTI:  51.0% (target 60%, drift -9.0%)
VXUS: 20.2% (target 30%, drift -9.8%)
BND:  28.7% (target 10%, drift +18.7%)
Enter fullscreen mode Exit fullscreen mode

The total is about $22,085. Bonds were supposed to be a tenth of it and have grown to nearly a third — a textbook case of the defensive sleeve swelling while equities lagged. Eyeballing account balances would never have surfaced an 18-point overweight that precisely. The single source of truth here is values, computed once; everything downstream divides into it.

Note what the script does not do: it doesn't fetch live prices. Hardcoding the price field keeps the logic testable and deterministic. When you're ready to automate, swap that field for a quote API call, but build and verify the math against fixed numbers first.

Turning drift into a trade list

Drift tells you the problem; it doesn't tell you how many shares to move. For that, you compare each position's current dollar value against its target dollar value — the target weight times the portfolio total — and divide the difference by the price.

THRESHOLD = 0.05  # only touch a sleeve once it drifts 5 points

trades = []
for ticker, h in holdings.items():
    current_value = values[ticker]
    target_value = h["target"] * total
    drift = current_value / total - h["target"]
    if abs(drift) < THRESHOLD:
        continue
    delta_value = target_value - current_value
    shares = delta_value / h["price"]
    trades.append((ticker, shares, delta_value))

for ticker, shares, delta in sorted(trades, key=lambda t: t[1]):
    action = "BUY " if shares > 0 else "SELL"
    print(f"{action} {abs(shares):6.1f} {ticker}  ({delta:+,.0f})")
Enter fullscreen mode Exit fullscreen mode

The result is a complete set of orders:

SELL   57.4 BND   (-4,136)
BUY     7.4 VTI   (+1,978)
BUY    35.3 VXUS  (+2,158)
Enter fullscreen mode Exit fullscreen mode

The sells and buys net to roughly zero on purpose. You sell the $4,136 of overweight bonds and use exactly that cash to top up the two underweight equity sleeves. No new money enters; the portfolio rearranges itself back to 60/30/10. That self-funding property is the whole appeal of rebalancing as a trade-generation problem — delta_value summed across all positions is always zero, because the target weights sum to one and they're all multiplied by the same total.

Those share counts are fractional, and the dollar deltas ignore taxes. Selling 57.4 shares of BND in a taxable account realizes capital gains; the IRS does not care that you immediately rebuilt the allocation. Rebalance inside tax-advantaged accounts where you can, direct new contributions toward underweight sleeves instead of selling, and round to whole shares if your broker doesn't support fractions.

The rules that keep you from over-trading

The THRESHOLD constant is doing quiet, important work. Without it, the script would generate a trade for any deviation at all — including a 0.3-point wobble that costs you spreads and tax for no meaningful benefit. A tolerance band says: ignore noise, act only on drift that has become structural.

Five percentage points is a common absolute band, but it treats a 10% target and a 60% target identically, which isn't quite right — a 5-point move is half of a 10% sleeve and a twelfth of a 60% one. A widely cited refinement is the "5/25" rule: rebalance a position when it drifts more than 5 absolute points or more than 25% of its own target weight, whichever is smaller. For your 10% bond sleeve, 25% of target is 2.5 points, so that's the trigger; for the 60% equity sleeve, the 5-point absolute band binds first.

def should_trade(current_weight, target):
    absolute = abs(current_weight - target)
    relative = abs(current_weight - target) / target
    return absolute > 0.05 or relative > 0.25
Enter fullscreen mode Exit fullscreen mode

Swap should_trade(...) in for the flat THRESHOLD check and small allocations get the tighter leash they need while large ones aren't whipsawed by every move. The other discipline worth adding is frequency: don't run this daily. Calendar-based rebalancing (quarterly or annually) combined with a band check tends to keep turnover low, because most checks return an empty trade list and cost you nothing.

The progression matters more than any single number. Get the drift calculation correct against fixed prices, confirm the trades net to zero, then layer thresholds on top. A rebalancing script that you've verified by hand is worth more than a more elaborate one you have to trust blindly — these are real orders against real money.

Write a test that asserts the sum of all delta_value entries is within a cent of zero. If it ever isn't, your target weights don't sum to 1.0 — usually a typo in the allocation. It's the cheapest possible guardrail against a script that quietly leaves cash on the table.


Originally published at pickuma.com. Subscribe to the RSS or follow @pickuma.bsky.social for new reviews.

Top comments (0)