CLI · commit 35a5477

rebalancer

Tax-aware multi-account portfolio rebalancing

Download rebalancer.zip
Quickstart
unzip rebalancer.zip && cd rebalancer
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/python -m pytest -q

rebalancer

Tax-aware multi-account portfolio rebalancing from the command line. Given several accounts (taxable brokerage, EPF/NPS/401k/IRA, mutual funds), target allocations with drift bands, tax lots and prices, rebalancer solves a linear program (HiGHS) that picks the cheapest set of trades to get back to target: new cash first, lot-level tax cost, transaction costs, account constraints and the wash-sale rule are all part of the optimisation.

Features

Install

Python 3.12+.

cd rebalancer
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
source .venv/bin/activate   # or call .venv/bin/rebalancer directly

Quickstart

rebalancer -p examples/india/portfolio.yaml status
rebalancer -p examples/india/portfolio.yaml plan
rebalancer -p examples/us/portfolio.yaml compare
rebalancer -p examples/us/portfolio.yaml plan -s tlh --csv trades.csv
rebalancer -p examples/us/portfolio.yaml location
rebalancer -p examples/india/portfolio.yaml drift-history --dir examples/india/snapshots

-p/--portfolio defaults to portfolio.yaml in the current directory (or $REBALANCER_PORTFOLIO). Prices come from the CSV named in the portfolio file unless --prices is given.

Usage

Global options: -p/--portfolio FILE, --prices FILE, --age N (overrides the glide-path age).

Command What it does
status [--json] Current allocation, drift, band breaches, account summary.
plan [-s STRATEGY] [--cash N] [--cash-account ID] [--csv FILE\|-] [--json] Trade list for one strategy (default threshold). --cash adds (or with a negative value withdraws) cash in the first taxable account, or the one given by --cash-account.
compare [--cash N] [--json] Runs all four strategies and compares trades, tax, costs and residual drift.
location [--json] Suggests which classes belong in which account and estimates the after-tax wealth gain.
snapshot [--dir D] [--date YYYY-MM-DD] Saves the current allocation as D/<date>.json.
drift-history [--dir D] [--json] Drift per class across saved snapshots.

Strategies: cashflow (deploy cash, never sell), threshold (sell only from classes outside their bands, buy to bring breached classes back; classes inside their band are left alone), full (every class back to target), tlh (bands as slack, but sells are allowed everywhere so loss lots can be harvested into other securities of the same class).

Status

Indian investor (EPF, PPF, NPS, demat, mutual funds)  as of 2026-10-16  total ₹6,386,920
Allocation vs target                                                  
┏━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━┓
┃ Class  ┃      Value ┃ Actual % ┃ Target % ┃ Drift ┃  Band ┃ Action ┃
┡━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━┩
│ equity │ ₹3,985,260 │    62.40 │    55.00 │ +7.40 │ ±5.00 │ sell   │
│ debt   │ ₹2,056,160 │    32.19 │    35.00 │ -2.81 │ ±5.00 │ ok     │
│ gold   │   ₹295,500 │     4.63 │     8.00 │ -3.37 │ ±2.00 │ buy    │
│ cash   │    ₹50,000 │     0.78 │     2.00 │ -1.22 │ ±1.00 │ buy    │
└────────┴────────────┴──────────┴──────────┴───────┴───────┴────────┘
Accounts                                                          
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━┳━━━━━━━┓
┃ Account             ┃ Type         ┃      Value ┃ Cash ┃ Share ┃
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━╇━━━━━━━┩
│ EPF (no sells)      │ tax_free     │   ₹860,000 │   ₹0 │ 13.5% │
│ PPF (no sells)      │ tax_free     │   ₹410,000 │   ₹0 │  6.4% │
│ NPS Tier I          │ tax_deferred │   ₹404,100 │   ₹0 │  6.3% │
│ Demat (Zerodha)     │ taxable      │ ₹2,470,820 │   ₹0 │ 38.7% │
│ Mutual funds (Coin) │ taxable      │ ₹2,242,000 │   ₹0 │ 35.1% │
└─────────────────────┴──────────────┴────────────┴──────┴───────┘

Plan (India: FIFO lots, exemption, no-sell EPF)

Cash flows come from the cash_flow fields in the portfolio file. The demat account is FIFO, so the NIFTYBEES sale consumes the oldest lot first; the ₹72,091 long-term gain stays inside the ₹1.25L exemption, so no tax is due. EPF cannot sell and can only buy EPF, so its contribution is deployed there.

Plan: threshold (threshold: act only on classes outside their bands)
Cash flows: epf ₹30,000, nps ₹20,000, demat ₹100,000
Trades                                                                                 
┏━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━┓
┃ Account ┃ Action ┃ Security  ┃         Qty ┃  Price ┃   Amount ┃    Gain ┃ Tax term ┃
┡━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━┩
│ demat   │ SELL   │ NIFTYBEES │         653 │ 285.40 │ ₹186,366 │ ₹72,091 │ LT       │
│ demat   │ BUY    │ GOLDBEES  │       2,298 │  98.50 │ ₹226,353 │       - │ -        │
│ epf     │ BUY    │ EPF       │ 29,944.0671 │   1.00 │  ₹29,944 │       - │ -        │
│ nps     │ SELL   │ NPS_E     │       4,200 │  48.50 │ ₹203,700 │       - │ -        │
│ nps     │ BUY    │ NPS_C     │  4,898.4935 │  41.20 │ ₹201,818 │       - │ -        │
└─────────┴────────┴───────────┴─────────────┴────────┴──────────┴─────────┴──────────┘
Before                                                                
┏━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━┓
┃ Class  ┃      Value ┃ Actual % ┃ Target % ┃ Drift ┃  Band ┃ Action ┃
┡━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━┩
│ equity │ ₹3,985,260 │    60.97 │    55.00 │ +5.97 │ ±5.00 │ sell   │
│ debt   │ ₹2,056,160 │    31.45 │    35.00 │ -3.55 │ ±5.00 │ ok     │
│ gold   │   ₹295,500 │     4.52 │     8.00 │ -3.48 │ ±2.00 │ buy    │
│ cash   │   ₹200,000 │     3.06 │     2.00 │ +1.06 │ ±1.00 │ sell   │
└────────┴────────────┴──────────┴──────────┴───────┴───────┴────────┘
After                                                                 
┏━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━┓
┃ Class  ┃      Value ┃ Actual % ┃ Target % ┃ Drift ┃  Band ┃ Action ┃
┡━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━┩
│ equity │ ₹3,595,194 │    55.01 │    55.00 │ +0.01 │ ±5.00 │ ok     │
│ debt   │ ₹2,287,922 │    35.01 │    35.00 │ +0.01 │ ±5.00 │ ok     │
│ gold   │   ₹521,853 │     7.98 │     8.00 │ -0.02 │ ±2.00 │ ok     │
│ cash   │   ₹130,833 │     2.00 │     2.00 │ +0.00 │ ±1.00 │ ok     │
└────────┴────────────┴──────────┴──────────┴───────┴───────┴────────┘
Summary                                             
┌───────────────────────────────────────┬──────────┐
│ Bought                                │ ₹458,115 │
│ Sold                                  │ ₹390,066 │
│ Transaction costs                     │   ₹1,118 │
│ Estimated tax                         │       ₹0 │
│ Residual drift (sum |drift|, pct pts) │     0.03 │
└───────────────────────────────────────┴──────────┘

Compare strategies (US)

Strategy comparison                                                                                 
┏━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
┃           ┃        ┃          ┃         ┃       ┃       ┃             ┃             ┃        Max ┃
┃           ┃        ┃          ┃         ┃       ┃       ┃       Drift ┃             ┃    |drift| ┃
┃ Strategy  ┃ Trades ┃   Bought ┃    Sold ┃   Tax ┃ Costs ┃      before ┃ Drift after ┃      after ┃
┡━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
│ cashflow  │      3 │  $18,803 │      $0 │    $0 │    $4 │       23.18 │       16.63 │       8.28 │
│ threshold │      6 │  $66,302 │ $47,571 │ -$612 │   $23 │       23.18 │        0.09 │       0.04 │
│ full      │      7 │ $103,145 │ $84,517 │ -$960 │   $38 │       23.18 │        0.12 │       0.06 │
│ tlh       │      7 │ $103,145 │ $84,517 │ -$960 │   $38 │       23.18 │        0.12 │       0.06 │
└───────────┴────────┴──────────┴─────────┴───────┴───────┴─────────────┴─────────────┴────────────┘
Allocation after each strategy (actual %)                               
┏━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━┳━━━━━━━┓
┃ Class       ┃ Target ┃ Before ┃ cashflow ┃ threshold ┃  full ┃   tlh ┃
┡━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━╇━━━━━━━┩
│ us_equity   │  46.75 │  39.58 │    41.04 │     46.72 │ 46.71 │ 46.71 │
│ intl_equity │  19.50 │  27.78 │    27.78 │     19.50 │ 19.50 │ 19.50 │
│ bonds       │  25.40 │  23.59 │    25.40 │     25.40 │ 25.40 │ 25.40 │
│ reit        │   8.35 │   5.75 │     5.75 │      8.34 │  8.34 │  8.34 │
│ cash        │   0.00 │   3.31 │     0.03 │      0.04 │  0.06 │  0.06 │
└─────────────┴────────┴────────┴──────────┴───────────┴───────┴───────┘

The negative tax is a loss-harvesting credit: the underwater VXUS lot is sold and the loss deduction (capped at $3,000 times the ordinary rate) is worth $612 to $960.

Tax-loss-harvest aware plan (US)

The bond lot in the taxable account is sold at a loss while bonds are bought in the 401(k) instead, so the bond allocation is unchanged and no security is both sold at a loss and bought.

Plan: tlh (tax-loss-harvest aware: bands plus loss harvesting swaps)
Cash flows: brokerage $15,000, k401 $1,500
Trades                                                                                
┏━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━┓
┃ Account   ┃ Action ┃ Security ┃        Qty ┃  Price ┃  Amount ┃    Gain ┃ Tax term ┃
┡━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━┩
│ brokerage │ SELL   │ BND      │        400 │  72.10 │ $28,840 │ -$4,360 │ LT       │
│ brokerage │ SELL   │ VXUS     │        250 │  71.85 │ $17,962 │ -$1,913 │ LT       │
│ brokerage │ BUY    │ VNQ      │        158 │  94.30 │ $14,899 │       - │ -        │
│ brokerage │ BUY    │ VTI      │        157 │ 312.40 │ $49,047 │       - │ -        │
│ k401      │ SELL   │ VFIAX    │    13.2409 │ 612.20 │  $8,106 │       - │ -        │
│ k401      │ SELL   │ VTIAX    │   711.7448 │  41.60 │ $29,609 │       - │ -        │
│ k401      │ BUY    │ VBTLX    │ 3,979.6167 │   9.85 │ $39,199 │       - │ -        │
└───────────┴────────┴──────────┴────────────┴────────┴─────────┴─────────┴──────────┘
Before                                                                   
┏━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━┓
┃ Class       ┃    Value ┃ Actual % ┃ Target % ┃ Drift ┃  Band ┃ Action ┃
┡━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━┩
│ us_equity   │ $227,346 │    39.58 │    46.75 │ -7.17 │ ±5.00 │ buy    │
│ intl_equity │ $159,590 │    27.78 │    19.50 │ +8.28 │ ±4.88 │ sell   │
│ bonds       │ $135,515 │    23.59 │    25.40 │ -1.81 │ ±5.00 │ ok     │
│ reit        │  $33,005 │     5.75 │     8.35 │ -2.60 │ ±2.09 │ buy    │
│ cash        │  $19,000 │     3.31 │     0.00 │ +3.31 │ ±0.50 │ sell   │
└─────────────┴──────────┴──────────┴──────────┴───────┴───────┴────────┘
After                                                                    
┏━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━┓
┃ Class       ┃    Value ┃ Actual % ┃ Target % ┃ Drift ┃  Band ┃ Action ┃
┡━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━┩
│ us_equity   │ $268,287 │    46.71 │    46.75 │ -0.04 │ ±5.00 │ ok     │
│ intl_equity │ $112,019 │    19.50 │    19.50 │ +0.00 │ ±4.88 │ ok     │
│ bonds       │ $145,874 │    25.40 │    25.40 │ -0.00 │ ±5.00 │ ok     │
│ reit        │  $47,904 │     8.34 │     8.35 │ -0.01 │ ±2.09 │ ok     │
│ cash        │     $334 │     0.06 │     0.00 │ +0.06 │ ±0.50 │ ok     │
└─────────────┴──────────┴──────────┴──────────┴───────┴───────┴────────┘
Summary                                             
┌───────────────────────────────────────┬──────────┐
│ Bought                                │ $103,145 │
│ Sold                                  │  $84,517 │
│ Transaction costs                     │      $38 │
│ Estimated tax                         │    -$960 │
│ Residual drift (sum |drift|, pct pts) │     0.12 │
└───────────────────────────────────────┴──────────┘

CSV trade list

One row per lot for taxable sells, so a broker ticket can name the exact lots.

account,action,symbol,quantity,price,amount,fee,lot_date,lot_cost,est_gain,term
brokerage,SELL,VXUS,250,71.8500,17962.50,3.59,2024-01-10,79.5000,-1912.50,LT
brokerage,BUY,VNQ,158,94.3000,14899.40,2.98,,,,
brokerage,BUY,VTI,65,312.4000,20306.00,4.06,,,,
k401,SELL,VTIAX,711.745,41.6000,29608.58,5.92,,,,
k401,BUY,VBTLX,1053.2,9.8500,10374.00,2.07,,,,
k401,BUY,VFIAX,33.8491,612.2000,20722.42,4.14,,,,

Asset location

Current placement                                                              
┏━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┓
┃ Account   ┃ us_equity ┃ intl_equity ┃   bonds ┃    reit ┃   cash ┃    Total ┃
┡━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━┩
│ brokerage │   $89,034 │     $32,332 │ $46,865 │ $14,145 │ $2,500 │ $184,876 │
│ k401      │  $110,196 │    $116,480 │ $88,650 │       - │      - │ $315,326 │
│ roth      │   $28,116 │     $10,778 │       - │ $18,860 │      - │  $57,754 │
└───────────┴───────────┴─────────────┴─────────┴─────────┴────────┴──────────┘
Suggested placement                                                             
┏━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┓
┃ Account   ┃ us_equity ┃ intl_equity ┃    bonds ┃    reit ┃   cash ┃    Total ┃
┡━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━┩
│ brokerage │  $184,876 │           - │        - │       - │      - │ $184,876 │
│ k401      │   $17,721 │    $159,590 │ $135,515 │       - │ $2,500 │ $315,326 │
│ roth      │   $24,748 │           - │        - │ $33,005 │      - │  $57,754 │
└───────────┴───────────┴─────────────┴──────────┴─────────┴────────┴──────────┘
After-tax wealth in 25 years: current $2,825,885, suggested $2,888,019 ($62,134, +2.20%)

Drift history

Drift history (percentage points vs target)                                                    
┏━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
┃ Date       ┃     Total ┃ equity ┃  debt ┃  gold ┃  cash ┃ Worst        ┃ Breached           ┃
┡━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
│ 2026-01-15 │ 5,717,381 │  +3.55 │ +0.41 │ -2.83 │ -1.13 │ equity +3.55 │ gold, cash         │
│ 2026-04-15 │ 5,968,458 │  +5.09 │ -0.88 │ -3.05 │ -1.16 │ equity +5.09 │ equity, gold, cash │
│ 2026-07-15 │ 6,219,535 │  +6.51 │ -2.07 │ -3.25 │ -1.20 │ equity +6.51 │ equity, gold, cash │
│ 2026-10-16 │ 6,386,920 │  +7.40 │ -2.81 │ -3.37 │ -1.22 │ equity +7.40 │ equity, gold, cash │
└────────────┴───────────┴────────┴───────┴───────┴───────┴──────────────┴────────────────────┘

Input files

A portfolio is one YAML file plus a prices CSV (symbol,price). See examples/*/portfolio.yaml.

as_of: 2026-10-16
prices: prices.csv                  # relative to the YAML file
targets:
  age: 41                           # used with glide_path
  bands: {absolute: 5, relative: 25}   # percentage points / percent of target; the tighter one triggers
  classes:                          # fixed targets (percent) ...
    equity: 60
    cash: {target: 5, absolute: 2, relative: 50}
  glide_path:                       # ... or interpolated by age (class entries then only supply bands)
    - {age: 30, us_equity: 55, bonds: 10}
tax:
  preset: india | us | none
  slab_rate: 30                     # india: rate on non-equity gains
  stcg_rate: 32                     # us
  ltcg_rate: 15                     # us
  loss_deduction_cap: 3000          # us: net losses usable against ordinary income
  wash_sale: true                   # us default true
  categories:                       # optional per-category overrides
    gold: {holding_days: 365, stcg: 30, ltcg: 12.5, exemption: 0}
costs: {fixed: 20, bps: 12, min_trade: 5000}
location:
  tax_efficiency: {equity: 0.85}    # 0..1, higher = more tax efficient
  expected_return: {equity: 12}     # percent
  withdrawal_rate: 12               # tax on tax_deferred balances at withdrawal
  horizon: 20
securities:
  BAF: {name: Balanced fund, classes: {equity: 0.65, debt: 0.35}, tax_category: equity, fractional: true}
accounts:
  - id: demat
    type: taxable                   # taxable | tax_deferred | tax_free
    lot_method: fifo                # fifo | hifo | min_tax
    no_sell: false
    funds: [NIFTYBEES]              # buy universe (omit for everything)
    cash: 0
    cash_flow: 100000               # contribution (+) or withdrawal (-) to apply in this run
    positions:
      - symbol: NIFTYBEES
        lots:                       # taxable accounts need lots; cost is per unit
          - {date: 2021-06-15, qty: 2500, cost: 175.00}
      - {symbol: NPS_E, qty: 4200}  # non-taxable accounts need only qty

Rates are in percent, bps in basis points, cost is per unit. Idle cash is treated as a class named cash (target 0% unless you list it) so uninvested money is pulled into the portfolio.

Architecture

rebalancer/
  models.py       dataclasses: lots, accounts, targets, tax and cost config
  loader.py       YAML/CSV loading, tax presets, glide path, validation
  drift.py        class values, drift and band checks
  tax.py          holding periods and exact tax (netting, exemption, loss credit)
  lp.py           small sparse LP/MILP builder over scipy.optimize.milp (HiGHS)
  optimizer.py    model construction, min-trade/fixed-fee heuristics, rounding to units
  strategies.py   the four strategies and cash-flow resolution
  location.py     asset location (transportation LP) and after-tax wealth estimate
  snapshots.py    saved snapshots and drift history
  report.py       rich tables, CSV, JSON
  cli.py          click commands
docs/model.md        LP formulation, equations, worked examples
docs/assumptions.md  modelling assumptions and what is not modelled
examples/            India and US portfolios with prices and snapshots

Tests

.venv/bin/pytest -q

221 tests: drift and bands, glide path, tax per lot and netting, hand-checkable strategy results, FIFO/HIFO/min-tax lot choice, cash-flow-only never sells, wash-sale, min trade and fixed-fee pruning, location, CLI end-to-end on both examples, and property tests over 40 random portfolios times 4 strategies (cash never negative, no-sell and fund universe respected, whole units, FIFO/HIFO order, min trade, wash-sale, tax equals recomputed tax).

Limitations