Level 5 — MasteryAsset Allocation & Rebalancing

Asset Allocation & Rebalancing

90 minutes60 marksprintable — key stays hidden on paper

Level: 5 (Mastery — Cross-domain: math + quantitative finance + coding) Time limit: 90 minutes Total marks: 60

Instructions: Answer all three questions. Show full derivations. Where code is requested, write clean, runnable Python (NumPy/pandas allowed) and state complexity where asked. Use ...... / ...... for math.


Question 1 — Threshold Rebalancing & Portfolio Drift (20 marks)

A two-asset portfolio has a strategic target weight w\*=0.60w^\* = 0.60 in equity, 0.400.40 in bonds. The equity weight drifts to wtw_t over time. A threshold rebalancing rule triggers a trade whenever wtw\*τ|w_t - w^\*| \ge \tau (absolute band, τ=0.05\tau = 0.05).

(a) Starting from a portfolio value V0=1,000,000V_0 = 1{,}000{,}000 at target weights, suppose over one period equity returns rE=+25%r_E = +25\% and bonds return rB=0%r_B = 0\%. Compute the new equity weight w1w_1 and the drift w1w\*w_1 - w^\*. Does the threshold trigger? Show the exact fraction. (6)

(b) Prove the following general result: for a two-asset portfolio at target w\*w^\*, after equity return rEr_E and bond return rBr_B, the post-return equity weight is w1=w\*(1+rE)w\*(1+rE)+(1w\*)(1+rB).w_1 = \frac{w^\*(1+r_E)}{w^\*(1+r_E) + (1-w^\*)(1+r_B)}. Then derive the minimum equity outperformance δ=rErB\delta = r_E - r_B (assuming rBr_B fixed) that triggers an upper-band breach w1w\*+τw_1 \ge w^\* + \tau, expressed in closed form in terms of w\*,τ,rBw^\*, \tau, r_B. (8)

(c) Write a Python function rebalance_threshold(weights, targets, tau) that returns the trade vector (fraction of total portfolio to buy/sell per asset) to restore targets when any asset breaches its band, else a zero vector. State its time complexity in terms of nn assets. (6)


Question 2 — Rupee-Cost Averaging vs Lump Sum (20 marks)

An investor contributes a fixed amount CC each period into a single fund whose price per unit at period ii is pip_i (for i=1,,ni = 1,\dots,n).

(a) Show that the average cost per unit under rupee-cost averaging equals the harmonic mean of the prices: cˉ=ni=1n1pi,\bar{c} = \frac{n}{\sum_{i=1}^n \frac{1}{p_i}}, and prove using the AM–HM inequality that cˉpˉ\bar{c} \le \bar{p} (the arithmetic mean of prices), with equality iff all pip_i are equal. (9)

(b) Given prices over 4 months p=[100,80,125,100]p = [100, 80, 125, 100] with C=10,000C = 10{,}000 per month, compute the total units bought, the average cost per unit cˉ\bar{c}, and compare to the arithmetic mean price. State the final portfolio value at a closing price of 110110. (7)

(c) Explain quantitatively (with the variance/dispersion argument) under what price-path condition rupee-cost averaging beats lump-sum investing, and when it underperforms. Give one concrete counter-example price path. (4)


Question 3 — Core–Satellite Optimisation & Tax-Efficient Rebalancing (20 marks)

A core–satellite portfolio holds a passive core (weight cc, expected return μc=8%\mu_c = 8\%, volatility σc=12%\sigma_c = 12\%) and an active satellite (weight 1c1-c, μs=12%\mu_s = 12\%, σs=25%\sigma_s = 25\%), with correlation ρ=0.3\rho = 0.3.

(a) Derive the portfolio variance σp2(c)\sigma_p^2(c) and find the core weight c\*c^\* that minimises portfolio variance. Give the closed-form expression and evaluate numerically. (9)

(b) The satellite has drifted, requiring a sell that realises a gain. Short-term capital gains are taxed at 15%15\%, long-term at 10%10\%. A rebalance requires selling ₹200,000 of gains. Model the trade-off: rebalancing now (cost = tax) vs waiting (cost = expected tracking-error drift). If the tracking-error cost of not rebalancing is modelled as κd2\kappa \cdot d^2 where dd = drift fraction and κ=500,000\kappa = 500{,}000, and current drift is d=0.04d = 0.04, determine whether to rebalance now (short-term tax) assuming the position becomes long-term in the next period. Show the numeric comparison. (7)

(c) Write pseudocode / Python for a tax-aware rebalancing decision: given per-lot cost basis, current price, holding period, and target trade size, choose lots to sell that minimise realised tax while meeting the trade size. Identify which classic algorithmic problem this resembles. (4)

Answer keyMark scheme & solutions

Question 1

(a) Initial values: equity =600,000= 600{,}000, bond =400,000= 400{,}000. After returns: equity =600,000×1.25=750,000= 600{,}000 \times 1.25 = 750{,}000; bond =400,000= 400{,}000. Total V1=1,150,000V_1 = 1{,}150{,}000. w1=750,0001,150,000=75115=15230.6522.w_1 = \frac{750{,}000}{1{,}150{,}000} = \frac{75}{115} = \frac{15}{23} \approx 0.6522. (3 marks) Drift =w10.60=152335=7569115=61150.05217= w_1 - 0.60 = \frac{15}{23} - \frac{3}{5} = \frac{75 - 69}{115} = \frac{6}{115} \approx 0.05217. (2 marks) Since 0.052170.05=τ0.05217 \ge 0.05 = \tau, the threshold triggers. (1 mark)

(b) Proof. Post-return equity value =V0w\*(1+rE)= V_0 w^\*(1+r_E); bond value =V0(1w\*)(1+rB)= V_0(1-w^\*)(1+r_B). Total =V0[w\*(1+rE)+(1w\*)(1+rB)]= V_0[w^\*(1+r_E)+(1-w^\*)(1+r_B)]. Dividing equity by total (V0V_0 cancels): w1=w\*(1+rE)w\*(1+rE)+(1w\*)(1+rB).w_1 = \frac{w^\*(1+r_E)}{w^\*(1+r_E)+(1-w^\*)(1+r_B)}. \checkmark (3 marks)

Trigger condition w1w\*+τw_1 \ge w^\*+\tau. Let a=w\*(1+rE)a = w^\*(1+r_E), b=(1w\*)(1+rB)b=(1-w^\*)(1+r_B). Then aa+bw\*+τ    a(w\*+τ)(a+b)    a[1(w\*+τ)](w\*+τ)b.\frac{a}{a+b} \ge w^\*+\tau \iff a \ge (w^\*+\tau)(a+b) \iff a[1-(w^\*+\tau)] \ge (w^\*+\tau) b. So a(1w\*τ)(w\*+τ)(1w\*)(1+rB)a \cdot (1-w^\*-\tau) \ge (w^\*+\tau)(1-w^\*)(1+r_B), giving 1+rE(w\*+τ)(1w\*)(1+rB)w\*(1w\*τ).1+r_E \ge \frac{(w^\*+\tau)(1-w^\*)(1+r_B)}{w^\*(1-w^\*-\tau)}. With rBr_B fixed, minimum outperformance: δ=rErB(1+rB)[(w\*+τ)(1w\*)w\*(1w\*τ)1].\delta = r_E - r_B \ge (1+r_B)\left[\frac{(w^\*+\tau)(1-w^\*)}{w^\*(1-w^\*-\tau)} - 1\right]. (5 marks) Check with rB=0r_B=0, w\*=0.6w^\*=0.6, τ=0.05\tau=0.05: 0.650.40.60.351=0.260.211=0.2381\frac{0.65\cdot0.4}{0.6\cdot0.35}-1 = \frac{0.26}{0.21}-1 = 0.2381. Any rE23.81%r_E \ge 23.81\% triggers; 25%25\% does — consistent with (a).

(c) (6 marks: 4 correct logic, 1 zero-vector case, 1 complexity)

import numpy as np
def rebalance_threshold(weights, targets, tau):
    weights = np.asarray(weights, float)
    targets = np.asarray(targets, float)
    if np.any(np.abs(weights - targets) >= tau):
        return targets - weights   # buy/sell fractions to restore targets
    return np.zeros_like(weights)

Complexity: O(n)O(n) — one pass to test bands, one pass to compute the trade.


Question 2

(a) In period ii, units bought =C/pi= C/p_i. Total units U=iC/pi=Ci1/piU = \sum_i C/p_i = C\sum_i 1/p_i. Total invested =nC= nC. Average cost: cˉ=nCC1/pi=n1/pi=HM(p).\bar c = \frac{nC}{C\sum 1/p_i} = \frac{n}{\sum 1/p_i} = \text{HM}(p). \checkmark (5 marks) By AM–HM inequality, for positive pip_i: HMAM\text{HM} \le \text{AM}, i.e. cˉpˉ\bar c \le \bar p, with equality iff all pip_i equal (proof: AM–HM follows from AM–GM or Cauchy–Schwarz (pi)(1/pi)n2(\sum p_i)(\sum 1/p_i)\ge n^2). (4 marks)

(b) Units: 100100100 \to 100, 8012580 \to 125, 12580125 \to 80, 100100100 \to 100. Total U=405U = 405 units. (2) Total invested =40,000= 40{,}000. cˉ=40,000/405=98.765\bar c = 40{,}000/405 = 98.765. (2) Arithmetic mean price =(100+80+125+100)/4=101.25= (100+80+125+100)/4 = 101.25; indeed cˉ<pˉ\bar c < \bar p. (1) Final value at price 110110: 405×110=44,550405 \times 110 = 44{,}550. (2)

(c) RCA outperforms lump sum when prices are volatile / dispersed and mean-reverting or dip early — higher price dispersion widens the AM–HM gap, lowering average cost. It underperforms in a steadily rising market, where early lump-sum capital captures more upside (opportunity cost of un-invested cash dominates). (3) Counter-example: monotone rising path p=[80,90,100,110]p=[80,90,100,110] — lump sum at 8080 beats RCA. (1)


Question 3

(a) Portfolio variance: σp2(c)=c2σc2+(1c)2σs2+2c(1c)ρσcσs.\sigma_p^2(c)=c^2\sigma_c^2+(1-c)^2\sigma_s^2+2c(1-c)\rho\sigma_c\sigma_s. (3) Minimise: ddc=0\frac{d}{dc}=0 gives c\*=σs2ρσcσsσc2+σs22ρσcσs.c^\*=\frac{\sigma_s^2-\rho\sigma_c\sigma_s}{\sigma_c^2+\sigma_s^2-2\rho\sigma_c\sigma_s}. (3) Numerics: σc=0.12,σs=0.25,ρ=0.3\sigma_c=0.12,\sigma_s=0.25,\rho=0.3. ρσcσs=0.30.120.25=0.009\rho\sigma_c\sigma_s=0.3\cdot0.12\cdot0.25=0.009. Numerator =0.06250.009=0.0535=0.0625-0.009=0.0535. Denominator =0.0144+0.06250.018=0.0589=0.0144+0.0625-0.018=0.0589. c\*=0.0535/0.05890.9083.c^\*=0.0535/0.0589\approx0.9083. (3)

(b) Rebalance now (short-term): tax =0.15×200,000=30,000=0.15\times200{,}000=₹30{,}000. (2) Wait one period → long-term tax =0.10×200,000=20,000=0.10\times200{,}000=₹20{,}000, but incur tracking cost of holding the drift: κd2=500,000×0.042=500,000×0.0016=800\kappa d^2 = 500{,}000\times0.04^2 = 500{,}000\times0.0016=₹800. (3) Compare: rebalance now =30,000=30{,}000; wait =20,000+800=20,800=20{,}000+800=20{,}800. Wait — waiting saves 9,200₹9{,}200 (the long-term tax saving of ₹10,000 far exceeds the ₹800 drift cost). (2)

(c) (4 marks)

def tax_aware_sell(lots, target_value, price, st_rate, lt_rate):
    # lots: list of dicts {basis, qty, long_term(bool)}
    # compute tax per rupee sold for each lot, sell cheapest-tax first
    def tax_rate(lot): return lt_rate if lot['long_term'] else st_rate
    def unit_tax(lot):
        gain = max(price - lot['basis'], 0)
        return tax_rate(lot) * gain / price   # tax per rupee of proceeds
    lots_sorted = sorted(lots, key=unit_tax)   # greedy: lowest tax first
    proceeds, tax = 0.0, 0.0
    for lot in lots_sorted:
        cap = lot['qty'] * price
        take = min(cap, target_value - proceeds)
        units = take / price
        tax += tax_rate(lot) * max(price - lot['basis'],0) * units
        proceeds += take
        if proceeds >= target_value: break
    return proceeds, tax

This resembles a fractional knapsack / greedy selection problem (sort by tax-per-proceeds ratio, fill greedily), which is optimal because lots are divisible.

[
 {"claim":"Q1a equity weight after +25% equity, 0% bond from 0.6 target = 15/23",
  "code":"w1=Rational(600000*1.25,1150000); result=(w1==Rational(15,23))"},
 {"claim":"Q1a drift exceeds tau=0.05",
  "code":"drift=Rational(15,23)-Rational(3,5); result=(drift>=Rational(1,20))"},
 {"claim":"Q1b min outperformance ~0.2381 for wstar=0.6,tau=0.05,rB=0",
  "code":"val=(0.65*0.4)/(0.6*0.35)-1; result=abs(val-0.238095)<1e-4"},
 {"claim":"Q2b average cost = 40000/405 and less than 101.25",
  "code":"c=40000/405; result=(abs(c-98.7654)<1e-3 and c<101.25)"},
 {"claim":"Q2b final value at 110 = 44550",
  "code":"U=10000/100+10000/80+10000/125+10000/100; result=(abs(U*110-44550)<1e-6)"},
 {"claim":"Q3a min-variance core weight approx 0.9083",
  "code":"num=0.0625-0.009; den=0.0144+0.0625-0.018; result=abs(num/den-0.90832)<1e-3"},
 {"claim":"Q3b waiting (20800) cheaper than rebalancing now (30000)",
  "code":"now=0.15*200000; wait=0.10*200000+500000*0.04**2; result=(wait<now and abs(wait-20800)<1e-6)"}
]