"""BYL illustrative consolidation decisions v1.0.0. Standard library only. Run: python model.py --output ./results Not market observations, quotes, credit scoring or an underwriting model. """ import argparse, csv, json, math from pathlib import Path def payment(principal, annual_note_rate, months): r=annual_note_rate/1200 return principal/months if r==0 else principal*r/-math.expm1(-months*math.log1p(r)) def fixed_plan(balance, annual_rate, monthly_payment): r=annual_rate/1200 if balance>0 and monthly_payment<=balance*r:raise ValueError('Payment does not amortize balance') rows=[] while balance>1e-8: opening=balance;interest=opening*r;paid=min(monthly_payment,opening+interest);balance=max(0,opening+interest-paid) rows.append({'month':len(rows)+1,'opening':opening,'interest':interest,'payment':paid,'closing':balance}) if len(rows)>1200:raise ValueError('Schedule exceeds model bound') return {'months':len(rows),'repayment':sum(x['payment'] for x in rows),'interest':sum(x['interest'] for x in rows),'schedule':rows} def annualized_cashflow_rate(net_cash, monthly_payment, months): lo,hi=0.,1200. for _ in range(100): mid=(lo+hi)/2 if payment(net_cash,mid,months)current_repayment:return None lo,hi=0.,1200. for _ in range(100): mid=(lo+hi)/2 if payment(gross,mid,months)*months0) for rate in [15,22]: for n in [36,60]: net=5000;fee=.05;gross=net/(1-fee);m=payment(gross,rate,n);total=m*n+residual_total partial.append({'kind':'partial','policy':policy,'allocation':allocation,'net_proceeds':net,'annual_note_rate':rate,'months':n,'fee_fraction':fee,'principal':gross,'fee':gross-net,'payment':m,'interest':m*n-gross,'repayment':m*n,'model_apr':annualized_cashflow_rate(net,m,n),'residual_balances':[x['balance'] for x in residual],'residual_repayment':residual_total,'residual_initial_payment':residual_payment,'combined_initial_payment':m+residual_payment,'total_future_repayment':total,'current_repayment':old['repayment'],'difference_vs_current':total-old['repayment']}) return {'version':'1.0.0','scope':'Hypothetical equal-month fixed-dollar card plans; no new purchases, promotional rate changes, minimum-payment rules, rollover, other charges, daily interest or discounting. Withheld fee financed once. Full precision; display rounding only. APR column is annualized monthly cash-flow IRR (12 times monthly rate), not certification of a legal APR disclosure.','cards':accounts,'baseline':old,'full':full,'partial':partial,'thresholds':[{'months':n,'fee_fraction':.03,'break_even_annual_note_rate':break_even_note(10000,.03,n,old['repayment']),'max_fee_fraction_at_15_percent_note':1-payment(10000,15,n)*n/old['repayment']} for n in [36,60]]} def export(output): output.mkdir(parents=True,exist_ok=True);data=build() (output/'scenarios.json').write_text(json.dumps(data,indent=2)+'\n',encoding='utf-8') rows=data['full']+data['partial'];fields=list(dict.fromkeys(k for row in rows for k in row)) with (output/'scenarios.csv').open('w',encoding='utf-8',newline='') as f: w=csv.DictWriter(f,fieldnames=fields);w.writeheader();w.writerows([{k:json.dumps(v) if isinstance(v,list) else v for k,v in row.items()} for row in rows]) return data if __name__=='__main__': p=argparse.ArgumentParser();p.add_argument('--output',type=Path,default=Path('results'));args=p.parse_args();d=export(args.output);print('Wrote36full and8partial illustrative scenarios to',args.output)