Model formulation
rebalancer builds one mixed-integer linear program per plan and solves it with HiGHS (scipy.optimize.milp). Almost everything is a plain LP; integer variables appear only where an ordering or an either-or rule needs them (FIFO/HIFO lot order, wash-sale). Minimum trade size and fixed fees are handled by re-solving, not by binaries.
All amounts inside the model are divided by scale = V / 100 (V = portfolio value after cash flows) so that coefficients sit near 1 and the solver tolerances behave the same for a 5,000 and a 50,000,000 portfolio. Results are scaled back before they are shown.
Sets and data
| Symbol | Meaning |
|---|---|
| a in A | accounts; type is taxable, tax_deferred or tax_free |
| s in S | securities, price p_s |
| c in C | asset classes, including the implicit cash class |
| w_sc | share of security s in class c (sums to 1 per security) |
| F_a | buy universe of account a (its funds) |
| L_as | tax lots of s in taxable account a, lot l has cost k_l per unit and holding term (st or lt) |
| H_as | current value held; cash_a = opening cash + cash flow of this run |
| T_c | target value = target% x V |
| f | variable cost rate (bps / 10,000), plus a 1e-7 tie-break so a buy and a sell of the same thing never cancel for free |
Variables
buy[a,s] >= 0for s in F_a.sell[a,s,l]in [0, value of lot l] for taxable accounts (one variable per lot);sell[a,s]in [0, H_as] for other accounts. Absent when the account hasno_sell, or when the strategy forbids selling s.d_c, e_c >= 0deviation variables,t >= -capandt_st, t_lt >= 0tax variables, binaries for lot order and wash-sale.
Constraints
Account cash (fees come out of the account, so no account can go negative):
cash_a + sum_s,l (1 - f) sell[a,s,l] - sum_s (1 + f) buy[a,s] >= reserve_a
reserve_a is the fixed fee of every trade planned in the account; it is filled in by the fixed-fee loop below.
No shorting and universe limits are variable bounds: sells cannot exceed what is held, buys exist only for s in F_a.
Class values after trading:
X_c = sum_a,s w_sc (H_as + buy[a,s] - sell[a,s]) + [c = cash] * sum_a cash_end_a
Objective
minimise lambda * sum_c d_c + eps * sum_c e_c + sum f * (buy + sell) + tax
where, with slack_c = band_c x V in threshold-style strategies (0 otherwise):
d_c >= (X_c - T_c) - slack_c d_c >= (T_c - X_c) - slack_c
e_c >= |X_c - T_c| (only in band strategies, eps = 0.002)
lambda = 1, so one unit of absolute deviation costs one unit of money. A full rebalance moves a unit from an over- to an under-weight class and removes two units of deviation, which is why it is always worth trading unless tax is about 100% of the amount. eps is a faint pull toward the exact target inside the band, so new cash lands in the most underweight class instead of an arbitrary one; it is too small to justify a taxable sale.
Tax
For each sold lot the realised gain is g_l * sell_l with g_l = 1 - k_l / p_s (negative for losses). Lots are put into a short-term or long-term bucket using the holding period of the category (tax.rules). With category rates r_st, r_lt:
- Categories without an exemption share one variable
t >= sum_l rate_l * g_l * sell_l, witht >= -cap_tax. The floor is the capped loss deduction:loss_deduction_cap x ordinary_rate(US: 3,000 x rate; India: 0). Losses therefore offset gains, and beyond that earn at most the capped credit. - Categories with an exemption (India equity) get separate buckets:
t_st >= r_st * G_st,t_lt >= r_lt * (G_lt - exemption), both floored at 0. The exemption only shelters long-term gains.
The plan then reports the exact tax (tax.py): short- and long-term net amounts per category, losses cross-netted, exemption on the long-term remainder, and the capped loss credit. The LP is deliberately convex; the exact netting rule is not, so the two can differ slightly (the LP never nets a long-term loss against a short-term gain at the short-term rate).
Lot selection
min_tax: nothing extra; the LP picks the lots with the smallest tax per unit sold.fifo/hifo: lots are ordered (oldest first, or highest cost first). For consecutive lots i, i+1 a binary y_i enforces
sell_i >= V_i * y_i sell_{i+1} <= V_{i+1} * y_i
so a later lot can only be touched once the earlier lot is sold completely. After rounding to whole units the allocation walks the lots in the same order.
Wash sale
For every security with loss lots, a binary z_s and big-M constraints enforce "bought somewhere" xor "sold at a loss somewhere":
sum_a buy[a,s] <= M z_s sum loss-lot sells of s <= M (1 - z_s)
Buys in a Roth or 401(k) count because the rule applies across accounts. In addition, if any lot of s was bought within wash_sale_days (30) of as_of, the loss lots of s cannot be sold.
Strategies
| Strategy | Sells allowed | Slack | Notes |
|---|---|---|---|
| cashflow | never | none | Errors out if a withdrawal exceeds available cash. |
| threshold | only securities that have weight in a breached, overweight class (everything if a withdrawal needs cash) | band_c | Classes inside their band are not pushed to target. |
| full | everything | none | Every class to target. |
| tlh | everything | band_c | Because a swap between two securities of one class costs no tracking error, loss lots are sold whenever the credit beats costs. |
The implicit cash class never gets slack, so idle cash is always pulled toward its target.
Heuristics around the solve
- Minimum trade. Solve; any aggregated (account, security, side) trade below
min_tradeis banned (its variable removed); re-solve; repeat until no trade is below the minimum. - Fixed fees. The fee per trade is not in the LP. After the minimum-trade loop, trades are tried in ascending size order: ban the trade, re-solve, and keep the ban if
objective + fixed x tradesimproves. The account reserve above is recomputed on each solve from the number of trades. - Rounding. Securities with
fractional: falsetrade in whole units; others to 4 decimals. Sells are rounded up (capped at the holding) and buys down, then buys are re-checked against each account's cash (fixed fee and bps included) so cash can never end below zero. Trades that fall below the minimum after rounding are dropped.
Worked examples
Hand-checkable rebalance
One tax-free account, X (equity) 60 and Y (bond) 40 at price 1, target 50/50, no costs. V = 100, T = 50 each. The optimum has zero deviation: sell 10 of X, buy 10 of Y. With 20 of new cash and holdings 50/30 the cash-only strategy buys 20 of Y and reaches 50/50 exactly. Both cases are tests.
Lot selection changes the tax
X: 50 units at cost 10 (2020) and 50 units at cost 90 (2024), price 100, plus 20 units of Y at price 100. Target 50/50 requires selling 40 units of X (4,000), long-term rate 15%.
| Lot method | Lots sold | Gain | Tax |
|---|---|---|---|
| min_tax or hifo | 40 from the cost-90 lot | 40 x 10 = 400 | 60 |
| fifo | 40 from the cost-10 lot | 40 x 90 = 3,600 | 540 |
FIFO is forced for the Indian demat and mutual-fund accounts in examples/india, so there the optimiser can only choose how much to sell, not which lot, and uses NPS (no tax) and the exempt LTCG band.
India LTCG exemption
In the India example the threshold plan sells 653 NIFTYBEES from the 2021 lot at cost 175: gain (285.40 - 175) x 653 = 72,091, all long term. It is below the 125,000 exemption, so t_lt >= 0.125 * (72,091 - 125,000) is negative, the floor of 0 applies, and the plan reports no tax. A bigger gain would be charged 12.5% on the excess only.
Bands
Band = min(absolute, relative x target). With 5/25: a 60% class has min(5, 15) = 5 points; an 8.35% class has min(5, 2.09) = 2.09 points. A class with target 0 uses the absolute band only.
Glide path
Targets are linearly interpolated between age points. Age 41 between points at 30 and 50 gives f = 11/20 = 0.55, so us_equity = 55 + 0.55 x (40 - 55) = 46.75%.
Asset location
location keeps class totals as they are and re-places them across accounts to maximise modelled after-tax wealth at the horizon. This is a transportation LP: supplies are the class totals, capacities the account sizes, x[a,c] = 0 when no fund in the account maps to c, and the profit of a unit of class c in account type t is the terminal multiple
tax_free: (1 + r)^n
tax_deferred: (1 + r)^n (1 - withdrawal_rate)
taxable: G - ltcg * max(0, G - 1), G = (1 + r - r (1 - eff) * ordinary)^n
with r the expected return and eff the tax-efficiency score (the share of return that is not taxed each year). The effect is the usual ranking: low-efficiency classes (bonds, REITs) go to tax-deferred space, the highest expected return to tax-free space, efficient equity to taxable. Accounts with no_sell are held fixed and their content is subtracted from the supply.