Source code · Python
study.py
"""S&P 500: buy at the trend line (holding cash) vs bi-weekly automatic buying.Shiller monthly data, real total return, ex-ante regression, equal contributions."""import csv, json, math, sys PAYCHECK = 100.0 # real dollars per bi-weekly paycheck (June 2023 dollars)MONTHLY = PAYCHECK * 26 / 12 # 26 paychecks a year, spread over 12 monthsMIN_HISTORY = 240 # months of history needed before the trend signal is usedrows = list(csv.DictReader(open(sys.argv[1])))rows = [r for r in rows if float(r['Dividend']) > 0 and float(r['Consumer Price Index']) > 0]dates = [r['Date'][:7] for r in rows]P = [float(r['SP500']) for r in rows]D = [float(r['Dividend']) for r in rows]CPI = [float(r['Consumer Price Index']) for r in rows]Y = [float(r['Long Interest Rate']) / 100 for r in rows]n = len(rows) # nominal total return index, dividends (annual rate / 12) reinvested each monthTR = [1.0]for t in range(1, n): TR.append(TR[-1] * (P[t] + D[t] / 12) / P[t - 1])# real total return level in end-of-sample dollarsR = [TR[t] / CPI[t] * CPI[-1] for t in range(n)]LR = [math.log(x) for x in R] # expanding window OLS of ln(real TR) on month index, using data through t onlydef trend_line(): out = [None] * n sx = sy = sxx = sxy = 0.0 for t in range(n): sx += t; sy += LR[t]; sxx += t * t; sxy += t * LR[t] k = t + 1 if k >= MIN_HISTORY: b = (k * sxy - sx * sy) / (k * sxx - sx * sx) a = (sy - b * sx) / k out[t] = a + b * t return outTL = trend_line() def run(start, end, cash_mode): """cash_mode: 'zero' = cash earns 0% nominal; 'yield' = cash earns the 10 year yield (upper bound).""" units_d = units_t = cash_t = 0.0 contrib = 0.0 flows_d, flows_t = [], [] curve = [] invested_months = 0; months = 0; cash_share = 0.0 for t in range(start, end + 1): # existing cash loses (or earns) value in real terms before this month's paycheck arrives if t > start: infl = CPI[t - 1] / CPI[t] grow = (1 + (Y[t - 1] / 12 if cash_mode == 'yield' else 0.0)) * infl cash_t *= grow price = R[t] contrib += MONTHLY units_d += MONTHLY / price # bi-weekly: buy at whatever the price is cash_t += MONTHLY # trend: hold the paycheck as cash... if LR[t] <= TL[t]: # ...and buy only when price is at or below the line units_t += cash_t / price; cash_t = 0.0 v_d = units_d * price v_t = units_t * price + cash_t flows_d.append(-MONTHLY); flows_t.append(-MONTHLY) curve.append((dates[t], contrib, v_d, v_t, cash_t)) months += 1; invested_months += (cash_t == 0.0); cash_share += cash_t / v_t flows_d[-1] += curve[-1][2]; flows_t[-1] += curve[-1][3] return curve, flows_d, flows_t, invested_months / months, cash_share / months def irr(flows): lo, hi = -0.5, 0.5 # monthly rate bounds f = lambda r: sum(c / (1 + r) ** i for i, c in enumerate(flows)) for _ in range(200): mid = (lo + hi) / 2 if f(mid) > 0: lo = mid else: hi = mid return (1 + mid) ** 12 - 1 def max_dd(values): peak = values[0]; worst = 0.0 for v in values: peak = max(peak, v); worst = min(worst, v / peak - 1) return worst def summarise(start, end, cash_mode): curve, fd, ft, inv, cs = run(start, end, cash_mode) c, vd, vt = curve[-1][1], curve[-1][2], curve[-1][3] return dict(contributed=c, dca=vd, trend=vt, dca_x=vd / c, trend_x=vt / c, dca_irr=irr(fd), trend_irr=irr(ft), dca_dd=max_dd([x[2] for x in curve]), trend_dd=max_dd([x[3] for x in curve]), pct_invested=inv, avg_cash_share=cs, months=len(curve)), curve end = n - 1start100 = end - 100 * 12 + 1first_signal = MIN_HISTORY - 1out = {'meta': dict(first=dates[0], last=dates[-1], start=dates[start100], paycheck=PAYCHECK, monthly=MONTHLY, min_history_months=MIN_HISTORY, months=n)}for mode in ('zero', 'yield'): s, curve = summarise(start100, end, mode) out[mode] = s if mode == 'zero': out['curve'] = curve# rolling windows: share of start months in which trend beats bi-weekly, per horizon (final wealth only)def final_values(start, end, cash_mode): units_d = units_t = cash_t = 0.0 for t in range(start, end + 1): if t > start: cash_t *= (1 + (Y[t - 1] / 12 if cash_mode == 'yield' else 0.0)) * CPI[t - 1] / CPI[t] price = R[t] units_d += MONTHLY / price cash_t += MONTHLY if LR[t] <= TL[t]: units_t += cash_t / price; cash_t = 0.0 return units_d * R[end], units_t * R[end] + cash_troll = {}for years in (10, 20, 30, 40): h = years * 12 for mode in ('zero', 'yield'): wins = tot = 0; ratios = [] for st in range(first_signal, n - h): d, tr = final_values(st, st + h - 1, mode) tot += 1; wins += tr > d; ratios.append(tr / d) ratios.sort() roll[f'{years}y_{mode}'] = dict(windows=tot, win_rate=wins / tot, median_ratio=ratios[len(ratios)//2], worst_ratio=ratios[0], best_ratio=ratios[-1])out['rolling'] = roll# series for the charts (zero-cash base case), real index rebased to 100 at the start of the 100 year windowbase = R[start100]out['series'] = dict( dates=[dates[t] for t in range(start100, end + 1)], index=[round(R[t] / base * 100, 2) for t in range(start100, end + 1)], trend_line=[round(math.exp(TL[t]) / base * 100, 2) for t in range(start100, end + 1)], below=[1 if LR[t] <= TL[t] else 0 for t in range(start100, end + 1)],)zc = run(start100, end, 'zero')[0]out['series']['contributed'] = [round(x[1]) for x in zc]out['series']['dca'] = [round(x[2]) for x in zc]out['series']['trend'] = [round(x[3]) for x in zc]yc = run(start100, end, 'yield')[0]out['series']['ratio_zero'] = [round(x[3] / x[2], 4) for x in zc]out['series']['ratio_yield'] = [round(x[3] / x[2], 4) for x in yc]out.pop('curve', None)sig = out['series']['below']out['signal'] = dict(share_of_months=sum(sig) / len(sig))longest = cur = 0for x in zc: cur = cur + 1 if x[4] > 0 else 0 longest = max(longest, cur)out['signal']['longest_cash_stretch_months'] = longestout['signal']['peak_cash_in_paychecks'] = max(x[4] for x in zc) / PAYCHECK# the same test started after the 1929 to 1932 crash, to show how much of the result rests on iti33 = dates.index('1933-01')d0, t0 = final_values(i33, end, 'zero'); d1, t1 = final_values(i33, end, 'yield')out['from_1933'] = dict(start='1933-01', ratio_zero=t0 / d0, ratio_yield=t1 / d1)# before inflation: the same paychecks in the dollars of their own day, so the# money weighted return includes inflation (the gap to the real return is inflation)def nominal_irr(values_end, start, stop): flows = [-MONTHLY * CPI[t] / CPI[stop] for t in range(start, stop + 1)] flows[-1] += values_end return irr(flows)out['nominal'] = dict( dca_irr=nominal_irr(out['zero']['dca'], start100, end), trend_irr=nominal_irr(out['zero']['trend'], start100, end), inflation=(CPI[end] / CPI[start100]) ** (12 / (end - start100)) - 1, contributed_nominal=sum(MONTHLY * CPI[t] / CPI[end] for t in range(start100, end + 1)),) # the thesis: what bi-weekly buying of one paycheck amount became, after inflation,# over a working life, from every start month since 1871 (no trend line needed)def dca_final(start, stop): units = 0.0 for t in range(start, stop + 1): units += MONTHLY / R[t] return units * R[stop]hz = {}for years in (10, 20, 30, 40): h = years * 12 finals = sorted(dca_final(st, st + h - 1) for st in range(0, n - h + 1)) put_in = MONTHLY * h hz[str(years)] = dict(windows=len(finals), put_in=put_in, worst=finals[0], p10=finals[len(finals) // 10], median=finals[len(finals) // 2], best=finals[-1], below_put_in=sum(f < put_in for f in finals) / len(finals))out['horizons'] = hzjson.dump(out, open(sys.argv[2], 'w'), separators=(',', ':'))for k in ('zero', 'yield'): print(k, {a: (round(b, 4) if isinstance(b, float) else b) for a, b in out[k].items()})for k, v in roll.items(): print(k, {a: round(b, 3) for a, b in v.items()})print(out['meta'])