"""Author-only reproducible educational inputs; not ETF forecasts or a reader app.""" import json from pathlib import Path ROOT=Path(__file__).resolve().parent INFLATION=.025 N=38 NORMAL={'nasdaq':.07,'vig':.06,'sp500':.06,'schd':.05,'deposit':.02} paths={a:[r]*N for a,r in NORMAL.items()} # Same return multiset through twenty years, with two blocks swapped. blocks={'nasdaq':([.25,.15],[-.45,-.15]),'vig':([.18,.12],[-.30,-.10]), 'sp500':([.20,.12],[-.35,-.12]),'schd':([.16,.10],[-.30,-.10])} for a,(good,bad) in blocks.items(): paths[a][8:10]=good paths[a][18:20]=bad badpaths={a:r.copy() for a,r in paths.items()} for a in blocks: badpaths[a][8:10],badpaths[a][18:20]=paths[a][18:20],paths[a][8:10] def run(person,scenario,reserve=.10,expense_factor=1.0): p=badpaths if scenario in ('bad_order','life_shock') else paths balance={'nasdaq':70e6,'vig':30e6} if person=='seoyeon' else {'sp500':160e6,'schd':160e6,'deposit':80e6} records=[] emergency=18e6 if person=='seoyeon' else 0 cumulative_shortfall=0 for i in range(N if person=='junho' else 20): year=i+1 opening=balance.copy() growth=sum(opening[a]*p[a][i] for a in balance) for a in balance: balance[a]*=1+p[a][i] contrib=withdraw=dividends=sale=refill=shortfall=0. pension=expense=0. if person=='seoyeon': contrib=12e6 if scenario=='life_shock' and year in (9,10): contrib=0 # Annual essential expense in current purchasing power; # emergency money has no interest in this isolated stress assumption. need=36e6*(1+INFLATION)**year used=min(emergency,need) emergency-=used withdraw=need-used total=sum(balance.values()) sale=min(withdraw,total) if total: for a in balance: balance[a]-=sale*balance[a]/total shortfall=max(withdraw-sale,0) for a,w in {'nasdaq':.7,'vig':.3}.items(): balance[a]+=contrib*w elif year<=8: contrib=24e6 for a,w in {'sp500':.4,'schd':.4,'deposit':.2}.items():balance[a]+=contrib*w else: factor=(1+INFLATION)**year expense=36e6*factor*expense_factor pension=14.4e6*factor if scenario=='life_shock': expense*=1.20 gap=max(expense-pension,0) withdraw=gap*(1+reserve) # Educational annual cash distributions carved out of total return, # never added as extra return. Halved in the two negative shock years. for a,yield_assumption in {'sp500':.01,'schd':.03}.items(): dist=min(balance[a],opening[a]*yield_assumption*(.5 if p[a][i]<0 else 1)) balance[a]-=dist dividends+=dist balance['deposit']+=dividends cash=min(balance['deposit'],withdraw) balance['deposit']-=cash still=withdraw-cash equity=balance['sp500']+balance['schd'] sale=min(still,equity) if equity: for a in ('sp500','schd'):balance[a]-=sale*balance[a]/equity shortfall=max(still-sale,0) # Refill only in years with a positive weighted equity total return. equity_gain=sum(opening[a]*p[a][i] for a in ('sp500','schd')) target=3*withdraw if equity_gain>0 and shortfall==0: equity=balance['sp500']+balance['schd'] refill=min(max(target-balance['deposit'],0),equity) if equity: for a in ('sp500','schd'):balance[a]-=refill*balance[a]/equity balance['deposit']+=refill closing=sum(balance.values()) residual=closing-(sum(opening.values())+growth+contrib-withdraw+shortfall) assert abs(residual)<1e-4,(person,scenario,year,residual) assert min(balance.values())>=-1e-6 cumulative_shortfall+=shortfall records.append({'year':year,'age':(31 if person=='seoyeon' else 57)+year, 'opening':opening,'returns':{a:p[a][i] for a in balance},'closing':balance.copy(), 'total':closing,'real_total':closing/(1+INFLATION)**year, 'contribution':contrib,'gross_withdrawal':withdraw,'expense':expense, 'other_income':pension,'cash_distribution':dividends,'equity_sale_for_gap':sale, 'equity_sale_for_refill':refill,'shortfall':shortfall,'emergency_remaining':emergency}) return {'records':records,'shortfall_total':cumulative_shortfall, 'first_shortfall_year':next((r['year'] for r in records if r['shortfall']>1),None)} out={'assumptions':{'inflation':INFLATION,'tax_and_cost_reserve_rate':.1,'normal_returns':NORMAL, 'return_basis':'hypothetical nominal KRW total returns, not market forecasts; FX embedded, not separately simulated', 'junho_distribution_assumptions':{'sp500':.01,'schd':.03,'negative_shock_multiplier':.5}, 'timing':'annual return, then distribution transfer, year-end contribution/withdrawal/refill; retirement after end-year8', 'account_limits':'no pension allocation in main path; all withdrawal funds accessible; ISA mature before year9', 'deposit_rate':'hypothetical 2% before individual tax; not a bank quote'}, 'annual_asset_returns':{'base':paths,'bad_order':badpaths},'results':{}} for scen in ('base','bad_order','life_shock'): out['results'][scen]={person:run(person,scen) for person in ('seoyeon','junho')} out['results']['bad_order_spending_270']={'junho':run('junho','bad_order',.10,.9)} for reserve in (0,.2): out['results'][f'bad_order_reserve_{reserve}']={'junho':run('junho','bad_order',reserve)} # Meaningful input checks: identical multiset per asset, twenty-year zero-cash-flow terminal equality. for a in paths: assert sorted(paths[a][:20])==sorted(badpaths[a][:20]) def terminal(rs): b=1 for r in rs[:20]:b*=1+r return b assert abs(terminal(paths[a])-terminal(badpaths[a]))<1e-10 (ROOT/'path-results.json').write_text(json.dumps(out,ensure_ascii=False,indent=2)+'\n') summary=[] for scen,res in out['results'].items(): for person,v in res.items(): rs=v['records']; r=rs[19] summary.append({'scenario':scen,'person':person,'year20_total':round(r['total']), 'year20_today_value':round(r['real_total']), 'year8_total':round(rs[7]['total']), 'year9_total':round(rs[8]['total']),'year9_gross_withdrawal':round(rs[8]['gross_withdrawal']), 'year9_dividends':round(rs[8]['cash_distribution']),'year9_cash':round(rs[8]['closing'].get('deposit',0)), 'year9_gap_sale':round(rs[8]['equity_sale_for_gap']), 'year10_gap_sale':round(rs[9]['equity_sale_for_gap']), 'final_total':round(rs[-1]['total']),'final_real':round(rs[-1]['real_total']), 'first_shortfall_year':v['first_shortfall_year'],'shortfall_total':round(v['shortfall_total'])}) (ROOT/'path-summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2)+'\n') print(json.dumps(summary,ensure_ascii=False,indent=2))