Optimizer, ladder generator and simulation

Decision variables

For every candidate instrument i (issuer, type, tenor) the optimizer picks an integer number of lots k_i (default lot Rs 1,000), an on/off binary z_i, and one continuous balance L for a liquid reserve (savings account at idle_rate). Candidates are the rate card's band minimum tenors plus extra tenors that fall inside a band: each withdrawal date, each whole year and the horizon. The extra tenors let an FD mature exactly on the day money is needed.

Per-rupee coefficients (valuation.py)

Each instrument has cash flows per rupee (day, amount, interest). Interest is taxed at the effective slab rate when received (tax-free bonds are not taxed). The post-tax flow is then parked at the idle rate, taxed as well, until the horizon:

v_i = sum_j (amount_j - tax * interest_j) * (1 + idle * (1 - tax))^((H - day_j) / 365)

Model

maximise    sum_i (v_i - lambda * tenor_i / 365) * k_i + v_liquid * L
subject to  sum_i k_i + L = amount / lot
            L >= emergency reserve                                  (day 0)
            sum_i C_i(D_m) * k_i + L >= cumulative withdrawals up to D_m + reserve   for every withdrawal date D_m
            sum_{i of bank b, not sovereign} k_i <= max_per_bank / lot
            sum_{i of bank b, insured} g_i * k_i <= 5,00,000 * capacities / lot      (if only_dicgc_insured)
            k_i <= (amount / lot) * z_i,  k_i >= ceil(min_amount_i / lot) * z_i

Worked example

Rs 10 lakh, 1-year horizon, bank A pays 7.5% and bank B 7.0%, cap Rs 4 lakh per bank. Cash after tax is higher at A, so the optimizer fills A to the cap (4,00,000), B to the cap (4,00,000) and the remaining 2,00,000 goes to the liquid reserve rather than a third bank that is not on the card. With a third bank C at 6.5% it would fill C instead of leaving cash in savings, because v_C beats the savings coefficient.

Equal-rung ladder (ladder.py)

N rungs (default 5) with tenors 365 * k days capped at the horizon, each getting (amount - reserve) / N. Each rung is filled greedily with the best post-tax instrument that covers the tenor and still has room under the per-bank cap and the DICGC limit; money that cannot be placed lands in the liquid reserve and is reported. The ladder does not know about withdrawals, so plan shows its liquidity check, which can fail where the optimizer's passes.

Reinvestment simulation (simulate.py)

compare replays each strategy day by day over the horizon:

  1. Cash earns the idle rate (post-tax).
  2. Instrument cash flows arrive; interest is taxed at slab when received (cumulative FDs at maturity or closure).
  3. Withdrawals are paid from cash; if cash is short, the instruments closest to maturity are closed prematurely with the card penalty. A remaining gap is reported as shortfall.
  4. A matured instrument is rolled into the same issuer, type and tenor at original rate + shift, keeping back cash for the reserve and for withdrawals due before the new maturity. If the new tenor would overrun the horizon the money stays in cash.

Scenarios shift reinvestment rates once, at the first roll: flat (0), falling (-1 point) and rising (+1 point). Initial deposits keep their locked rates, so a single long FD is identical in all three scenarios. The debt fund strategy puts everything into a fund returning --debt-fund-return (default 7%) plus the shift, taxed at slab on redemption (post-April-2023 rules, no indexation), with partial redemptions taxed pro rata to the gain share.

Strategies compared: single long FD (one tenor equal to the horizon, spread to respect caps), the 5-rung ladder, the optimizer allocation, and the debt fund. IRR is computed from the invested amount, the withdrawals actually paid and the terminal post-tax value.

Why plan and compare differ slightly

plan reports the value at the horizon when each maturity is parked at the idle rate (it is a linear model, which is what lets the MILP be exact). compare rolls maturities into new deposits. Both use the same tax rules; the numbers answer different questions.

Limitations