"""R01 原创构造底稿:独立 Decimal 按月模型,不导入网站计算代码。""" from decimal import Decimal as D, ROUND_HALF_UP, getcontext from pathlib import Path import argparse import json import re getcontext().prec = 48 CENT = D('0.01') ZERO = D(0) HERE = Path(__file__).resolve().parent def money(v): return D(str(v)).quantize(CENT, rounding=ROUND_HALF_UP) def installment(balance, rate, months): if rate == 0: return balance / months a = (1 + rate) ** months return balance * rate * a / (a - 1) def count_months(balance, rate, payment, method, remaining): """逐月模拟且不超当前到期月,末期清余本,防整分尾差把期限延长。""" for n in range(1, remaining+1): interest = money(balance * rate) principal = min(balance, money(payment) if method == 'principal' else money(payment)-interest) if n == remaining: principal = balance if principal <= 0: raise ValueError('正常付款不能摊还本金') balance -= principal if balance == 0: return n raise ValueError('超过600期') def simulate(principal, annual_pct, months, method='annuity', events=None, rates=None, early_order=False): """期初调率、计息/正常付款、额外还本;early_order仅供文内独立对照。""" assert method in ('annuity', 'principal') and 1 <= months <= 600 events = events or [] assert len(events) <= 24 rate_map = {e['month']: D(str(e['annual_pct']))/1200 for e in (rates or [])} event_map = {} for e in events: m = e['month'] if m in event_map: assert event_map[m]['mode'] == e['mode'], '同月选择冲突' event_map[m]['amount'] += money(e['amount']) event_map[m]['fee'] += money(e.get('fee', 0)) else: event_map[m] = {'mode': e['mode'], 'amount': money(e['amount']), 'fee': money(e.get('fee', 0))} balance = money(principal) initial = balance rate = D(str(annual_pct))/1200 end = months payment = installment(balance, rate, months) if method == 'annuity' else balance/months rows = [] for m in range(1, 601): if balance == 0: break opening = balance old_end = end event = event_map.get(m) requested = event['amount'] if event else ZERO extra = ZERO # DJ2-002的B约定:当月额外本金在整月计息前生效。 if early_order and event: extra = min(balance, requested) balance -= extra if m in rate_map: rate = rate_map[m] if method == 'annuity': payment = installment(balance, rate, end-m+1) elif early_order and event and method == 'annuity': payment = installment(balance, rate, end-m+1) interest_base = balance interest = money(balance*rate) if method == 'annuity': normal_principal = min(balance, money(payment)-interest) else: normal_principal = min(balance, money(payment)) if m == end: normal_principal = balance assert normal_principal > 0 or balance == 0 normal = normal_principal+interest balance -= normal_principal after_normal = balance if event and not early_order: extra = min(balance, requested) balance -= extra fee = event['fee'] if event else ZERO if event and extra > 0 and balance > 0: if event['mode'] == 'reduce': payment = installment(balance, rate, end-m) if method == 'annuity' else balance/(end-m) else: end = m+count_months(balance, rate, payment, method, end-m) rows.append({'month': m, 'opening': opening, 'annual_pct': rate*1200, 'interest_base': interest_base, 'interest': interest, 'normal': normal, 'normal_principal': normal_principal, 'after_normal': after_normal, 'requested_extra': requested, 'extra': extra, 'unused_extra': requested-extra, 'fee': fee, 'cash': normal+extra+fee, 'closing': balance, 'previous_end': old_end, 'planned_end': m if balance == 0 else end, 'next_payment_or_q': money(payment), 'mode': event['mode'] if event else None}) assert opening-normal_principal-extra == balance assert balance == 0 skipped = [{'month': m, 'requested_extra': e['amount'], 'extra': ZERO, 'unused_extra': e['amount'], 'fee': ZERO} for m, e in event_map.items() if m > len(rows)] total_principal = sum((r['normal_principal']+r['extra'] for r in rows), ZERO) total_interest = sum((r['interest'] for r in rows), ZERO) total_fees = sum((r['fee'] for r in rows), ZERO) total_cash = sum((r['cash'] for r in rows), ZERO) assert total_principal == initial and total_cash == initial+total_interest+total_fees return {'rows': rows, 'skipped_events': skipped, 'payoff_month': len(rows), 'total_normal_principal': sum((r['normal_principal'] for r in rows), ZERO), 'total_extra': sum((r['extra'] for r in rows), ZERO), 'total_principal': total_principal, 'total_interest': total_interest, 'total_fees': total_fees, 'total_cash': total_cash, 'unused_extra': sum((r['unused_extra'] for r in rows), ZERO)+sum((r['unused_extra'] for r in skipped), ZERO)} def ev(m, amount, mode='shorten', fee=0): return {'month': m, 'amount': amount, 'mode': mode, 'fee': fee} def row(plan, month): return plan['rows'][month-1] if month <= len(plan['rows']) else {'interest': ZERO, 'closing': ZERO, 'cash': ZERO} def build(): out = {} i = {'principal': 120000, 'annual_pct': '3.6', 'months': 24, 'events': [ev(3,15000,'reduce'), ev(8,12000), ev(12,8000,'reduce')]} p = simulate(**i); base = simulate(120000,'3.6',24) out['DJ2-001'] = {'input': i, 'outputs': {'plan': p, 'baseline': base, 'interest_difference': base['total_interest']-p['total_interest']}} i = {'principal': 120000, 'annual_pct': '4.8', 'months': 24, 'rates': [{'month':4,'annual_pct':'3.6'}], 'events': [ev(4,20000,'reduce')]} a = simulate(**i); b = simulate(**i,early_order=True) comparison = [] for m in (3,4,5,24): comparison.append({'month':m,'a_interest':row(a,m)['interest'],'b_interest':row(b,m)['interest'], 'a_normal':row(a,m).get('normal',ZERO),'b_normal':row(b,m).get('normal',ZERO), 'a_closing':row(a,m)['closing'],'b_closing':row(b,m)['closing']}) out['DJ2-002'] = {'input': {**i,'B_order':'当月额外还本先于整月计息,之后调率、重算当期正常付款;仅文内构造'}, 'outputs': {'standard_A':a,'independent_B':b,'comparison':comparison, 'interest_difference_A_minus_B':a['total_interest']-b['total_interest']}} i = {'principal':60000,'annual_pct':'6','months':12,'events':[ev(2,12000),ev(5,6000,'reduce')]} p = simulate(**i); all_reduce = simulate(60000,'6',12,events=[ev(2,12000,'reduce'),ev(5,6000,'reduce')]) out['DJ2-003'] = {'input':i,'outputs':{'mixed':p,'both_reduce':all_reduce}} # 奖金计划:先按原正常计划取得每月正常付款,再以同一家庭现金路径选额外额。 bonuses = {12:50000,24:20000,36:45000} annual_costs = {12:15000,24:25000,36:20000} def budget_plan(policy): events=[]; cash=D(30000); ledger=[]; loan=simulate(360000,'3.6',60) for m in range(1,37): normal=row(loan,m).get('normal',ZERO) before=cash+D(15000)-D(8000)-normal+D(bonuses.get(m,0))-D(annual_costs.get(m,0)) extra=ZERO if m in bonuses: extra=D(30000) if policy=='fixed' else max(ZERO,before-D(30000)) events.append(ev(m,str(extra))) loan=simulate(360000,'3.6',60,events=events) extra=row(loan,m)['extra'] cash=before-extra ledger.append({'month':m,'opening_cash':ledger[-1]['closing_cash'] if ledger else D(30000), 'income':D(15000),'living':D(8000),'normal':normal,'bonus':D(bonuses.get(m,0)), 'annual_cost':D(annual_costs.get(m,0)),'before_extra':before,'extra':extra,'closing_cash':cash, 'below_floor':max(ZERO,D(30000)-cash),'loan_closing':row(loan,m)['closing']}) totals={k:sum((r[k] for r in ledger),ZERO) for k in ('income','living','normal','bonus','annual_cost','extra')} assert D(30000)+totals['income']+totals['bonus']-totals['living']-totals['normal']-totals['annual_cost']-totals['extra']==cash return {'cash_rows':ledger,'loan':loan,'minimum_cash':min(r['closing_cash'] for r in ledger), 'end_cash':cash,'events':events,'cash_totals':totals,'total_extra_36':totals['extra']} out['DJ2-004']={'input':{'principal':360000,'annual_pct':'3.6','months':60,'opening_cash':30000, 'monthly_income':15000,'monthly_living':8000,'cash_floor':30000,'bonuses':bonuses,'annual_costs':annual_costs, 'observation_months':36,'fixed_extra':30000,'event_mode':'shorten','cash_interest':0}, 'outputs':{'fixed':budget_plan('fixed'),'surplus':budget_plan('surplus')}} i={'principal':10000,'annual_pct':'12','months':6,'events':[ev(2,9000),ev(4,2000)]} p=simulate(**i) out['DJ2-005']={'input':{**i,'opening_cash':15000},'outputs':{'plan':p,'cash_after_payoff':D(15000)-p['total_cash'], 'wrong_cash_if_all_requested_debited':D(15000)-sum((r['normal'] for r in p['rows']),ZERO)-D(11000)}} base=simulate(120000,'6',24); p=simulate(120000,'6',24,events=[ev(3,30000,'reduce',900)]) assert max(D(300),p['total_extra']*D('0.03'))==p['total_fees']==D(900) cumulative=ZERO; fee_rows=[] for m in range(1,25): delta=row(base,m)['interest']-row(p,m)['interest']; cumulative+=delta fee_rows.append({'month':m,'baseline_interest':row(base,m)['interest'],'event_interest':row(p,m)['interest'], 'interest_difference':delta,'cumulative_difference':cumulative,'net_after_fee':cumulative-D(900)}) thresholds=[{'fee':D(f),'first_month':next((r['month'] for r in fee_rows if r['month']>=3 and r['cumulative_difference']>=D(f)),None), 'lifetime_net':cumulative-D(f)} for f in (0,900,10000)] out['DJ2-006']={'input':{'principal':120000,'annual_pct':'6','months':24,'events':[ev(3,30000,'reduce',900)], 'fee_rule':'构造:max(300元,实际额外还本的3%),额外还本同月另付','comparison_fees':[0,900,10000]}, 'outputs':{'baseline':base,'event_plan':p,'monthly_comparison':fee_rows,'thresholds':thresholds}} planned=simulate(120000,'6',24,events=[ev(4,20000),ev(10,10000,'reduce')]) delayed=simulate(120000,'6',24,events=[ev(7,20000),ev(10,10000,'reduce')]) cancelled=simulate(120000,'6',24,events=[ev(10,10000,'reduce')]) comparisons=[] for m in (4,7,10): comparisons.append({'month':m,'planned_balance':row(planned,m)['closing'],'delayed_balance':row(delayed,m)['closing'], 'cancelled_balance':row(cancelled,m)['closing'],'delayed_minus_planned':row(delayed,m)['closing']-row(planned,m)['closing'], 'cancelled_minus_planned':row(cancelled,m)['closing']-row(planned,m)['closing']}) out['DJ2-007']={'input':{'principal':120000,'annual_pct':'6','months':24,'planned_events':[ev(4,20000),ev(10,10000,'reduce')], 'delayed_events':[ev(7,20000),ev(10,10000,'reduce')],'cancelled_events':[ev(10,10000,'reduce')],'cash_interest':0}, 'outputs':{'baseline':base,'planned':planned,'delayed':delayed,'cancelled':cancelled,'balance_comparison':comparisons, 'interest_savings':{k:base['total_interest']-v['total_interest'] for k,v in [('planned',planned),('delayed',delayed),('cancelled',cancelled)]}, 'delayed_extra_interest':delayed['total_interest']-planned['total_interest']}} a=simulate(120000,'4.8',24,events=[ev(3,20000),ev(8,5000,'reduce')]) b=simulate(80000,'3',24,method='principal',events=[ev(3,10000,'reduce'),ev(8,5000)]) a2=simulate(120000,'4.8',24,events=[ev(3,30000),ev(8,10000,'reduce')]) b2=simulate(80000,'3',24,method='principal') def combine(a,b): rows=[] for m in range(1,max(a['payoff_month'],b['payoff_month'])+1): ar=row(a,m);br=row(b,m) rows.append({'month':m,'a_normal':ar.get('normal',ZERO),'b_normal':br.get('normal',ZERO), 'a_extra':ar.get('extra',ZERO),'b_extra':br.get('extra',ZERO),'cash':ar['cash']+br['cash'], 'balance':ar['closing']+br['closing']}) assert sum((r['cash'] for r in rows),ZERO)==a['total_cash']+b['total_cash'] assert rows[-1]['balance']==0 return {'rows':rows,'total_interest':a['total_interest']+b['total_interest'], 'total_cash':a['total_cash']+b['total_cash'],'payoff_month':len(rows),'total_extra':a['total_extra']+b['total_extra']} out['DJ2-008']={'input':{'A':{'principal':120000,'annual_pct':'4.8','months':24,'method':'annuity', 'events':[ev(3,20000),ev(8,5000,'reduce')]},'B':{'principal':80000,'annual_pct':'3','months':24,'method':'principal', 'events':[ev(3,10000,'reduce'),ev(8,5000)]},'alternate_A_events':[ev(3,30000),ev(8,10000,'reduce')], 'alternate_B_events':[],'fees':0},'outputs':{'A':a,'B':b,'combined':combine(a,b), 'alternate_A':a2,'alternate_B':b2,'alternate_combined':combine(a2,b2)}} i={'principal':24000,'annual_pct':'6','months':12,'method':'principal','rates':[{'month':4,'annual_pct':'3.6'}], 'events':[ev(2,5000,'shorten',30),ev(5,3000,'reduce',20)]} p=simulate(**i);base=simulate(24000,'6',12,method='principal',rates=i['rates']) sums=[] for start,end in ((1,2),(3,5),(6,p['payoff_month'])): rr=p['rows'][start-1:end] sums.append({'start':start,'end':end,'opening':rr[0]['opening'],'normal_principal':sum((r['normal_principal'] for r in rr),ZERO), 'extra':sum((r['extra'] for r in rr),ZERO),'interest':sum((r['interest'] for r in rr),ZERO), 'fees':sum((r['fee'] for r in rr),ZERO),'cash':sum((r['cash'] for r in rr),ZERO),'closing':rr[-1]['closing']}) out['DJ2-009']={'input':i,'outputs':{'plan':p,'baseline':base,'segment_audit':sums, 'interest_difference':base['total_interest']-p['total_interest'], 'net_cost_difference':base['total_cash']-p['total_cash']}} # 结构与守恒反例:零利率、同月同模式合并、超额、等本金调率不改变q。 z=simulate(100,0,3,events=[ev(1,30,'reduce')]);assert z['total_interest']==0 and z['total_principal']==100 x=simulate(1000,0,10,events=[ev(2,100),ev(2,200)]);y=simulate(1000,0,10,events=[ev(2,300)]) assert x['rows']==y['rows'] assert out['DJ2-005']['outputs']['plan']['unused_extra']>0 assert p['rows'][3]['normal_principal']==2000 tiny=simulate(1,0,7,events=[ev(1,'0.01')]) assert tiny['payoff_month']==7 and tiny['rows'][-1]['normal']==D('0.15') unchanged=simulate(1000,'3.6',12,events=[ev(2,0,'reduce')]) noevent=simulate(1000,'3.6',12) assert unchanged['total_interest']==noevent['total_interest'] and unchanged['total_cash']==noevent['total_cash'] fee_after_normal=simulate('0.10',0,1,events=[ev(1,1,'shorten','0.20'),ev(2,1,'shorten',2)]) assert fee_after_normal['total_extra']==0 and fee_after_normal['total_fees']==D('0.20') assert fee_after_normal['skipped_events'][0]['fee']==0 assumptions={'interest':'期初有效年利率除12,期初本金计息,利息按分half-up', 'cash':'正常付款按分half-up,本金=正常付款-利息;末期归还剩余本金', 'order':'期初调率→正常计息与付款→额外还本;DJ2-002 B为独立另序对照', 'shorten':'保留正常月供/每期还本额,模拟整数期且不超当前到期月,允许缩0期', 'reduce':'保留当前到期月重算下一期计划;实际额外还本为0不重排', 'fee':'月初仍有贷款时按已录入独立费用扣现金;更早结清后事件费用不再扣', 'sample':'全部金额、利率、日期与条款均为原创构造;无日息、预测或真实合同认定'} for record in out.values(): record['input']['model_assumptions']=assumptions if 'principal' in record['input']: record['input'].setdefault('method','annuity') out['DJ2-001']['input']['baseline_events']=[] out['DJ2-003']['input']['both_reduce_events']=[ev(2,12000,'reduce'),ev(5,6000,'reduce')] out['DJ2-006']['input']['baseline_events']=[] out['DJ2-007']['input']['baseline_events']=[] out['DJ2-009']['input']['baseline_events']=[] return out def serial(v): if isinstance(v,D): return format(v.quantize(CENT,rounding=ROUND_HALF_UP),'f') raise TypeError(type(v).__name__) def verify_article_tables(result): """可选仓库内核对:把正文每张表的全部数字逐行与独立结果对照。""" x={k:v['outputs'] for k,v in result.items()} def fields(rows,keys,months=None): return [[r[k] for k in keys] for r in rows if months is None or r['month'] in months] def rows(id,key,keys,months=None):return fields(x[id][key]['rows'],keys,months) main=['month','opening','interest','normal','normal_principal','extra','closing'] expected={} y=x['DJ2-001']['plan'] expected['DJ2-001']=[rows('DJ2-001','plan',main,[1,3,4,8,9,12,13,22]), fields(y['rows'],['month','previous_end','planned_end','next_payment_or_q'],[3,8,12])] expected['DJ2-002']=[fields(x['DJ2-002']['comparison'], ['month','a_interest','b_interest','a_normal','b_normal','a_closing','b_closing'])] expected['DJ2-003']=[rows('DJ2-003','mixed',main), [[x['DJ2-003'][k][f] for f in ['total_normal_principal','total_extra','total_interest','payoff_month']] for k in ['mixed','both_reduce']]] y=x['DJ2-004'] expected['DJ2-004']=[[v for k in ['fixed','surplus'] for v in fields(y[k]['cash_rows'], ['month','before_extra','bonus','annual_cost','extra','closing_cash','loan_closing'],[12,24,36])], [[36,y['fixed']['total_extra_36'],y['surplus']['total_extra_36']], [y['fixed']['minimum_cash'],y['surplus']['minimum_cash']], [36,y['fixed']['end_cash'],y['surplus']['end_cash']], [36,y['fixed']['loan']['rows'][35]['closing'],y['surplus']['loan']['rows'][35]['closing']], [y['fixed']['loan']['payoff_month'],y['surplus']['loan']['payoff_month']], [y['fixed']['loan']['total_interest'],y['surplus']['loan']['total_interest']]]] y=x['DJ2-005']['plan'] expected['DJ2-005']=[fields(y['rows'],['month','opening','interest','normal','normal_principal','requested_extra','extra','closing']), [[2,9000,y['rows'][1]['extra'],y['rows'][1]['unused_extra']],[4,2000,0,2000],[11000,y['total_extra'],y['unused_extra']]]] y=x['DJ2-006'] expected['DJ2-006']=[fields(y['monthly_comparison'],['month','baseline_interest','event_interest', 'interest_difference','cumulative_difference','net_after_fee'],range(3,12)), [[t['fee']]+([t['first_month']] if t['first_month'] is not None else [])+[t['lifetime_net']] for t in y['thresholds']]] y=x['DJ2-007'] expected['DJ2-007']=[fields(y['balance_comparison'],['month','planned_balance','delayed_balance','cancelled_balance', 'delayed_minus_planned','cancelled_minus_planned']), [[y['baseline']['total_extra'],24,y['baseline']['total_interest'],0], [y['planned']['total_extra'],20,y['planned']['total_interest'],y['interest_savings']['planned']], [7,y['delayed']['total_extra'],20,y['delayed']['total_interest'],y['interest_savings']['delayed']], [y['cancelled']['total_extra'],24,y['cancelled']['total_interest'],y['interest_savings']['cancelled']]]] y=x['DJ2-008'] expected['DJ2-008']=[[[120000,'4.8',20000,5000],[80000,'3.0',10000,5000]], fields(y['combined']['rows'],['month','a_normal','b_normal','a_extra','b_extra','cash','balance'],[1,3,4,8,9,20,21,23]), [[y['A']['total_interest'],y['alternate_A']['total_interest']], [y['B']['total_interest'],y['alternate_B']['total_interest']], [y['combined']['total_interest'],y['alternate_combined']['total_interest']], [y['combined']['total_cash'],y['alternate_combined']['total_cash']], [y['A']['payoff_month'],y['alternate_A']['payoff_month']], [y['B']['payoff_month'],y['alternate_B']['payoff_month']], [y['combined']['payoff_month'],y['alternate_combined']['payoff_month']]]] y=x['DJ2-009'] expected['DJ2-009']=[fields(y['plan']['rows'],['month','opening','interest','normal_principal','normal','extra','fee','closing']), [[r['start'],r['end']]+[r[k] for k in ['opening','normal_principal','extra','interest','fees','cash','closing']] for r in y['segment_audit']]] root=HERE.parents[4] plan=json.loads((HERE.parent/'content-plan.json').read_text('utf-8'))['articles'] checks=[] for r in plan: if r['cluster']!='R01':continue f=root/'sites/daojijun.com/src/content/article'/(r['planned_path'].strip('/').split('/')[-1]+'.md') core=f.read_text('utf-8').split('---',2)[2].split('')[0] tables=re.findall(r'(?:^\|.*\|\n?)+',core,re.M) assert len(tables)==len(expected[r['id']]),(r['id'],'数据表数量不一致') for ti,(table,target) in enumerate(zip(tables,expected[r['id']]),1): numeric_rows=[[D(v) for v in re.findall(r'-?\d+(?:\.\d+)?',line)] for line in table.splitlines()[2:]] expected_rows=[[D(str(v)) for v in row] for row in target] assert numeric_rows==expected_rows,(r['id'],ti,'正文表格数值与独立结果不符') checks.append({'id':r['id'],'table':ti,'rows':len(numeric_rows), 'numeric_cells':sum(map(len,numeric_rows)),'status':'逐行全部数值与独立JSON匹配'}) return checks if __name__=='__main__': parser=argparse.ArgumentParser() parser.add_argument('--verify-articles',action='store_true',help='仅仓库内:逐表核对R01正文全部数值') args=parser.parse_args() result=build() target=HERE/'calc_r01.json' target.write_text(json.dumps(result,ensure_ascii=False,indent=2,default=serial)+'\n','utf-8') if args.verify_articles: checks=verify_article_tables(result) print(json.dumps({'tables':len(checks),'rows':sum(r['rows'] for r in checks), 'numeric_cells':sum(r['numeric_cells'] for r in checks),'passed':True},ensure_ascii=False)) print('R01:九个编号、完整逐月输出及本金/现金守恒检查通过;'+str(target))