"""R02构造教学样本。仅用标准库;从任意工作目录运行,输出到脚本同目录。""" from decimal import Decimal, ROUND_HALF_UP from pathlib import Path import json D = Decimal CENT = D('0.01') def money(value): return D(str(value)).quantize(CENT, rounding=ROUND_HALF_UP) def debt(name, balance, rate, minimum): return {'id': name, 'balance': str(balance), 'annual_rate': str(rate), 'minimum': str(minimum)} def simulate(debts, budget, strategy='avalanche', bonus=None, fixed_order=None, bonus_order=None, bonus_equal=False, drop_released=False, blocked=None): """按分执行,排序采用当期期初余额;扩展选项只用于文内独立情景。""" balances = [money(x['balance']) for x in debts] rates = [D(x['annual_rate']) / 12 for x in debts] mins = [money(x['minimum']) for x in debts] initial = sum(balances) rows, cleared = [], {} total_interest = D(0) bonus = bonus or {} blocked = blocked or {} for month in range(1, 601): opening = list(balances) normal_budget = money(budget) if drop_released: normal_budget -= sum(mins[i] for i in range(len(debts)) if opening[i] == 0) extra_budget = money(bonus.get(month, 0)) available = normal_budget + extra_budget interest = [money(b * r) for b, r in zip(opening, rates)] due = [b + j for b, j in zip(opening, interest)] minimum = [min(p, q) for p, q in zip(mins, due)] if available < sum(minimum): return {'status': 'budget_below_minimum', 'month': month, 'required': str(sum(minimum)), 'available': str(available), 'gap': str(sum(minimum) - available), 'rows': rows} paid = list(minimum) left = available - sum(paid) eligible = [i for i in range(len(debts)) if month > blocked.get(i, 0)] if fixed_order is not None: order = list(fixed_order) elif strategy == 'snowball': order = sorted(range(len(debts)), key=lambda i: (opening[i], debts[i]['id'])) else: order = sorted(range(len(debts)), key=lambda i: (-rates[i], opening[i], debts[i]['id'])) # 定向奖金在正常预算完成分配后单列处理。 targeted = (bonus_order is not None or bonus_equal) and extra_budget > 0 normal_left = left - extra_budget if targeted else left for i in order: if i not in eligible: continue take = min(normal_left, due[i] - paid[i]) paid[i] += take normal_left -= take if targeted: bonus_left = extra_budget if bonus_equal: active = [i for i in eligible if due[i] > paid[i]] while active and bonus_left > 0: share = (bonus_left / len(active)).quantize(CENT, rounding='ROUND_DOWN') if share == 0: share = min(CENT, bonus_left) before = bonus_left for i in active: take = min(share, bonus_left, due[i] - paid[i]) paid[i] += take bonus_left -= take active = [i for i in active if due[i] > paid[i]] assert bonus_left < before else: for i in bonus_order: if i not in eligible: continue take = min(bonus_left, due[i] - paid[i]) paid[i] += take bonus_left -= take left = normal_left + bonus_left else: left = normal_left balances = [q - p for q, p in zip(due, paid)] details = [] for i, x in enumerate(debts): details.append({'id': x['id'], 'opening': str(opening[i]), 'interest': str(interest[i]), 'minimum': str(minimum[i]), 'payment': str(paid[i]), 'extra': str(paid[i] - minimum[i]), 'closing': str(balances[i])}) if opening[i] > 0 and balances[i] == 0: cleared[x['id']] = month assert balances[i] >= 0 assert opening[i] + interest[i] - paid[i] == balances[i] total_interest += sum(interest) rows.append({'month': month, 'normal_budget': str(normal_budget), 'bonus': str(extra_budget), 'used': str(sum(paid)), 'unused': str(left), 'interest': str(sum(interest)), 'total_balance': str(sum(balances)), 'debts': details}) assert sum(paid) + left == available if any(balances[i] > opening[i] for i in range(len(debts))): return {'status': 'negative_amortization', 'month': month, 'rows': rows, 'total_interest': str(total_interest)} if sum(balances) == 0: assert sum(D(x['used']) for x in rows) == initial + total_interest return {'status': 'paid', 'months': month, 'total_interest': str(total_interest), 'total_paid': str(initial + total_interest), 'cleared': cleared, 'rows': rows} return {'status': 'horizon_exceeded', 'months': 600, 'total_interest': str(total_interest), 'rows': rows} def recurrence(balance, annual_rate, payment, count): rows = [] b = money(balance) for m in range(1, count + 1): j = money(b * D(str(annual_rate)) / 12) p = min(money(payment), b + j) end = b + j - p rows.append({'month': m, 'opening': str(b), 'interest': str(j), 'payment': str(p), 'principal_reduction': str(p-j), 'closing': str(end)}) b = end return rows def historical_rate(net_received, payments): # 原借款净到账为正、后续支付为负,求周期内成本;历史费用不进入未来排序。 lo, hi = 0.0, 1.0 for _ in range(150): r = (lo + hi) / 2 pv = sum(float(p) / (1+r)**m for m, p in enumerate(payments, 1)) if pv > net_received: lo = r else: hi = r r = (lo + hi) / 2 return {'monthly': r, 'nominal_annual': 12*r, 'effective_annual': (1+r)**12-1, 'pv_residual': sum(float(p) / (1+r)**m for m,p in enumerate(payments,1))-net_received} def main(): out = {} ds = [debt('甲',4000,.18,300),debt('乙',6000,.12,250),debt('丙',8000,.06,200)] out['DJ2-010'] = {'input': {'debts':ds,'budget':650}, 'output': { 'diagnosis':simulate(ds,650), 'budget_750':simulate(ds,750), 'budget_850':simulate(ds,850), 'oneoff_100':simulate(ds,650,bonus={1:100})}} ds = [debt('甲',1200,.06,100),debt('乙',3600,.24,180),debt('丙',6000,.12,220)] out['DJ2-011'] = {'input':{'debts':ds,'budget':1800}, 'output':{ 'avalanche':simulate(ds,1800),'snowball':simulate(ds,1800,strategy='snowball'), 'coinciding_order':simulate([debt('甲',1200,.24,100),debt('乙',3600,.12,180),debt('丙',6000,.06,220)],1800), 'coinciding_snowball':simulate([debt('甲',1200,.24,100),debt('乙',3600,.12,180),debt('丙',6000,.06,220)],1800,strategy='snowball'), 'zero_rates':simulate([debt('甲',1200,0,100),debt('乙',3600,0,180),debt('丙',6000,0,220)],1800)}} ds = [debt('甲',900,0,300),debt('乙',3600,.12,300)] out['DJ2-012'] = {'input':{'debts':ds,'budget':900}, 'output':{ 'rollover':simulate(ds,900,strategy='snowball'), 'reduce_budget':simulate(ds,900,strategy='snowball',drop_released=True)}} ds = [debt('甲',3000,.12,300),debt('乙',6000,.12,200)] out['DJ2-013'] = {'input':{'debts':ds,'budget':1200}, 'output':{ 'small_first':simulate(ds,1200,fixed_order=[0,1]), 'large_first':simulate(ds,1200,fixed_order=[1,0]), 'changed_rate':simulate([debt('甲',3000,.06,300),debt('乙',6000,.18,200)],1200)}} ds = [debt('甲',1200,.06,100),debt('乙',3600,.24,180),debt('丙',6000,.12,220)] out['DJ2-014'] = {'input':{'debts':ds,'budget':1400,'bonus_month':2,'bonus':1500}, 'output':{ 'high_rate':simulate(ds,1400,bonus={2:1500},bonus_order=[1,2,0]), 'small_balance':simulate(ds,1400,bonus={2:1500},bonus_order=[0,1,2]), 'equal':simulate(ds,1400,bonus={2:1500},bonus_equal=True), 'delayed':simulate(ds,1400,bonus={4:1500},bonus_order=[1,2,0])}} ds = [debt('甲',10000,.24,150),debt('乙',2000,0,100)] out['DJ2-015'] = {'input':{'debts':ds,'budget':250,'capitalization_assumption':True},'output':{ 'tool_style_stop':simulate(ds,250),'payment_150':recurrence(10000,.24,150,6), 'payment_200':recurrence(10000,.24,200,6),'payment_300':recurrence(10000,.24,300,6), 'first_month_nonincreasing_total':300}} ds = [debt('甲',2400,.24,300),debt('乙',3600,.12,300)] out['DJ2-016'] = {'input':{'debts':ds,'budget':1200,'restriction':'甲第1至4月只能付最低额,5月起解除'},'output':{ 'unrestricted':simulate(ds,1200),'restricted':simulate(ds,1200,blocked={0:4}), 'longer_restriction':simulate(ds,1200,blocked={0:6})}} ds = [debt('甲',5000,.06,500),debt('乙',5000,.12,500)] j = D('.06')/12 pmt = money(D(10000)*j/(1-(1+j)**-12)) hist = recurrence(10000,.06,pmt,11) last = money(D(hist[-1]['closing'])*(1+j)) payments = [str(pmt)]*11+[str(last)] out['DJ2-017'] = {'input':{'debts':ds,'budget':1500,'bonus':2000, 'historic_A':{'face':10000,'nonrefundable_fee':1000,'net_received':9000,'payments':payments}},'output':{ 'historical_A_rate':historical_rate(9000,payments), 'A_first':simulate(ds,1500,bonus={1:2000},fixed_order=[0,1]), 'B_first':simulate(ds,1500,bonus={1:2000},fixed_order=[1,0]), 'first_month_extra_2000_interest_saved_A':10,'first_month_extra_2000_interest_saved_B':20, 'hypothetical_A_refund':80,'one_month_A_net_saving_if_refundable':90}} ds = [debt('甲',6000,.18,400),debt('乙',6000,.06,400)] scenarios = {} for reserve in [0,2000,4000]: scenarios[str(reserve)] = {'debt_path_if_no_shock':simulate(ds,1500,bonus={1:6000-reserve}), 'cash_reserve':reserve,'month_3_shock':3500, 'cash_after_shock':max(0,reserve-3500),'unfunded_gap':max(0,3500-reserve)} out['DJ2-018'] = {'input':{'debts':ds,'budget':1500,'cash_before_allocation':6000,'shock_month':3,'shock_amount':3500}, 'output':scenarios} # 与递推不同形式的单笔闭式核验,显示舍入误差不超过六期误差界。 raw = D(10000)*(D('1.02')**6)-D(150)*((D('1.02')**6-1)/D('.02')) assert abs(raw-D(out['DJ2-015']['output']['payment_150'][-1]['closing'])) < D('.04') assert out['DJ2-010']['output']['diagnosis']['gap'] == '100.00' assert D(out['DJ2-011']['output']['avalanche']['total_interest']) < D(out['DJ2-011']['output']['snowball']['total_interest']) assert out['DJ2-011']['output']['coinciding_order'] == out['DJ2-011']['output']['coinciding_snowball'] assert out['DJ2-011']['output']['zero_rates']['total_interest'] == '0.00' assert out['DJ2-011']['output']['zero_rates']['total_paid'] == '10800.00' assert out['DJ2-013']['output']['small_first']['total_interest'] == out['DJ2-013']['output']['large_first']['total_interest'] assert out['DJ2-010']['output']['oneoff_100']['month'] == 2 assert out['DJ2-015']['output']['tool_style_stop']['status'] == 'negative_amortization' for value in out['DJ2-018']['output'].values(): assert D(value['debt_path_if_no_shock']['rows'][0]['bonus']) + value['cash_reserve'] == 6000 assert abs(out['DJ2-017']['output']['historical_A_rate']['pv_residual']) < 1e-7 target = Path(__file__).resolve().with_suffix('.json') target.write_text(json.dumps(out,ensure_ascii=False,indent=2)+'\n',encoding='utf-8') print(f'已写出 {target.name},{len(out)} 篇输入输出;资金守恒与核验断言通过。') if __name__ == '__main__': main()