Cursor Task — Term Loan: sd-math Python Library
Section titled “Cursor Task — Term Loan: sd-math Python Library”Model: claude-sonnet-4-6 Scope: Term loan leveraged analysis only (Phase 1 extension) Complete when: All 4 golden fixtures pass in pytest with no failures
Context
Section titled “Context”You are extending the sd-math Python library (a port of LevPro VB6 software) with term loan analysis. The interest-only strategy (strategies/interest_only.py) is already complete and all 37 tests pass. You are adding a parallel strategy: strategies/term_loan.py.
Your job:
- Read the 4 LevPro PDF fixtures to extract expected output values
- Create
tests/fixtures/Term-loan/term-loan-ca.json— the golden fixture file - Write
tests/test_term_loan.py— parametrized pytest tests (all fail initially) - Implement
strategies/term_loan.py— port ofCalcTermLevAnalysisfrom VB6 - Iterate until all tests pass
Files to Read (in this order)
Section titled “Files to Read (in this order)”1. VB6 source — the authoritative math spec
Section titled “1. VB6 source — the authoritative math spec”packages/sd-math/LevPro/modTypes.bas ← type definitions, especially TermLoanInputsTypepackages/sd-math/LevPro/modMath.bas ← CalcTermLevAnalysis (line ~866)packages/sd-math/LevPro/modLeveragePro.bas ← TermBetterThanReturn bisection (line ~4148)Also read strategies/interest_only.py — the term loan implementation is structurally parallel; reuse CalcGenericFutureValues and CalcNetLevValues verbatim.
2. Fixture PDFs — extract golden output values
Section titled “2. Fixture PDFs — extract golden output values”packages/sd-math/tests/fixtures/Term-loan/LevPro-Test1.pdf ← one-page summary, 35% taxpackages/sd-math/tests/fixtures/Term-loan/LevPro-Test2.pdf ← one-page summary, 50% taxpackages/sd-math/tests/fixtures/Term-loan/LevPro-Test3.pdf ← 10-year projection, 35% tax, 10% returnpackages/sd-math/tests/fixtures/Term-loan/LevPro-Test4.pdf ← 10-year projection, 50% tax, 7% returnRead each PDF and extract all dollar values and percentages. These are the expected outputs for the golden fixture JSON.
3. Existing conftest and fixture structure — for reference
Section titled “3. Existing conftest and fixture structure — for reference”packages/sd-math/tests/conftest.pypackages/sd-math/tests/fixtures/Interest-only/interest-only-ca.jsonMirror the interest-only fixture JSON structure exactly. The term loan fixture should use the same 4-fixture-ID pattern.
Hard Rules
Section titled “Hard Rules”-
All math comes from VB6 source code. Only from VB6 source code.
- Never infer formulas from PDF outputs
- The PDFs define what to produce; VB6 defines how to calculate
-
TDD: golden fixture JSON first, then tests, then implementation.
- Create
term-loan-ca.jsonfrom PDF values before writing any implementation code - Write all pytest assertions before implementing
term_loan.py
- Create
-
Never report complete until all 4 golden fixtures pass in pytest.
-
Reuse existing helpers.
CalcGenericFutureValuesandCalcNetLevValuesare already implemented ininterest_only.py. Import and call them — do not copy or reimplement. -
Do not modify any existing test files or conftest.py for interest-only tests.
Input Parameters (all 4 fixtures)
Section titled “Input Parameters (all 4 fixtures)”| Parameter | Value |
|---|---|
loan_amount | 37,543 |
loan_payment | 5,850 |
loan_interest_rate | 0.09 |
n_loan_periods | 10 |
n_loan_repeats | 0 |
holding_period | 10 |
dcg_pct | 0.70 |
taxable_cg_pct | 0.25 |
div_pct | 0.05 |
interest_pct | 0.00 |
distrib_reinvest_ratio | 1.0 |
lev_to_non_lev_ratio | 0.0 |
| Region | Ontario (ON) — same flags as interest-only |
| Fixture ID | marginal_rate | div_tax_rate | Return | Type |
|---|---|---|---|---|
tl-ca-35pct-summary | 0.35 | 0.159 | 0%, 3%, 7%, 10% + BetterThan | 5 summary scenarios |
tl-ca-50pct-summary | 0.50 | 0.313 | 0%, 3%, 7%, 10% + BetterThan | 5 summary scenarios |
tl-ca-35pct-10pct-projection | 0.35 | 0.159 | 10% | 10 annual rows + summary |
tl-ca-50pct-07pct-projection | 0.50 | 0.313 | 7% | 10 annual rows + summary |
Use the same prov_tax_rate / fed_tax_rate splits as conftest.py (TAX_35, TAX_50).
Note on n_loan_periods vs holding_period: Both are 10 for these fixtures. n_loan_periods is the amortization term; holding_period (NPeriods in GenericInputsType) is the total analysis horizon. They are equal here.
Algorithm: CalcTermLevAnalysis
Section titled “Algorithm: CalcTermLevAnalysis”The full VB6 source is in modMath.bas lines ~866–1060. Key differences from interest-only:
1. Investment timing — START of period (not end)
Section titled “1. Investment timing — START of period (not end)”# Cashflow array: loan deposited at start of period 1 onlycashflow[1] = loan_amountcashflow[2..N] = 0end_of_period_investments = False # interest-only uses Trueuse_cashflow_array = Trueinitial_investment_acb = 02. Loan amortization per period
Section titled “2. Loan amortization per period”for t in range(1, N + 1): prev_balance = loan_amount if t == 1 else loan_balance[t - 1] interest_part[t] = prev_balance * loan_interest_rate reduction_part[t] = loan_payment - interest_part[t] loan_balance[t] = prev_balance - reduction_part[t]VB6 validates: |PMT(loan_interest_rate, n_loan_periods, loan_amount)| ≈ loan_payment — raise ValueError if not within MAX_DIFF = 0.005.
3. After-tax cashflow per period (key formula)
Section titled “3. After-tax cashflow per period (key formula)”at_cashflow[t] = ( interest_part[t] - (prov_amount_deductible[t] * prov_tax_rate) - (fed_amount_deductible[t] * fed_tax_rate) + reduction_part[t] # ← this is the critical difference from interest-only)This cashflow is stored and used as the no-leverage comparison investment each period.
4. Deductibility logic
Section titled “4. Deductibility logic”For Ontario (unlimited deductibility):
prov_amount_deductible[t] = interest_part[t] * prov_ratio_of_interest_deductible # = interest_part[t]fed_amount_deductible[t] = interest_part[t] * fed_ratio_of_interest_deductible # = interest_part[t](Same as interest-only — the limited-deductibility branch is for Quebec.)
5. Net values after loan repayment
Section titled “5. Net values after loan repayment”Call CalcNetLevValues with loan_balance[t] (not the full loan_amount):
net_lev_bt_bal[t], net_lev_acb[t] = CalcNetLevValues( lev_bt_balance[t], lev_acb[t], loan_balance[t], ...)6. No-leverage comparison
Section titled “6. No-leverage comparison”After computing all leveraged outputs, run CalcGenericFutureValues again with:
cashflow[t] = at_cashflow[t]for t = 1..N (the actual AT cost each year)end_of_period_investments = True(end-of-period for no-lev comparison)initial_investment_acb = 0initial_deposit = 0(Cashflow array drives it)
7. BetterThan bisection
Section titled “7. BetterThan bisection”Identical algorithm to IntOnlyBetterThan in interest_only.py:
lower = 0.0,upper = loan_interest_rate × 1.2(expand by 1.2× until positive)- Converge at
width < MAX_RETURN_ERROR = 0.000005 - Display artifact: set both lev and no-lev end-balances to their average at the BetterThan return
New Python Model Fields
Section titled “New Python Model Fields”Add to models/inputs.py — new dataclass:
@dataclassclass TermLoanProfile: """Inputs specific to term loan analysis (wraps VB6 TermLoanInputsType).""" loan_amount: float # VB6: LoanAmount loan_payment: float # VB6: LoanPayment — must be consistent with PMT formula loan_interest_rate: float # VB6: LoanInterestRate n_loan_periods: int # VB6: NLoanPeriods — amortization term in years n_loan_repeats: int = 0 # VB6: NLoanRepeats — 0 = single loan term lev_to_non_lev_ratio: float = 0.0 # VB6: LevToNonLevRatio — always 0.0The RegionProfile fields map to both TermLoanInputsType and GenericInputsType — same as interest-only. TaxProfile and ReturnProfile are unchanged.
Output Model
Section titled “Output Model”The annual row output for term loan includes additional columns:
@dataclassclass TermLoanAnnualRow: year: int # No-leverage columns (same as interest-only AnnualRow) no_lev_bt_balance: float no_lev_at_distrib: float no_lev_acb: float # Leveraged columns (same) lev_bt_balance: float lev_at_distrib: float net_lev_bt_bal: float net_lev_acb: float # Loan-specific columns (new vs. interest-only) loan_balance: float # VB6: TermLevLoanOutputs.LoanBalance[t] interest_part_of_pmt: float # VB6: TermLevLoanOutputs.InterestPartOfPmt[t] loan_reduction: float # VB6: TermLevLoanOutputs.LoanReductionPartOfPmt[t] at_cashflow: float # VB6: TermLevLoanOutputs.ATCashflow[t] # Derived net_increase: float pct_increase: floatAdd TermLoanAnnualRow to models/outputs.py. Also add TermLoanProjectionResult or extend ProjectionResult to allow annual_rows to be either type.
Public API
Section titled “Public API”Update src/sd_math/__init__.py:
strategies = { 'interest-only': interest_only.calculate, 'term-loan': term_loan.calculate, # ← implement this 'rrsp': rrsp.calculate,}The term_loan.calculate signature must match interest_only.calculate:
def calculate( loan_profile, # Note: will be TermLoanProfile, not LoanProfile tax_profile: TaxProfile, return_profile: ReturnProfile, returns: list[float], include_annual_rows: bool = True,) -> ProjectionResult:Test Structure
Section titled “Test Structure”Create packages/sd-math/tests/test_term_loan.py modelled on test_interest_only.py.
Add a new conftest fixture or section in conftest.py for term loan:
TERM_LOAN_FIXTURES_DIR = Path(__file__).parent / 'fixtures' / 'Term-loan'
COMMON_TERM_LOAN = TermLoanProfile( loan_amount=37_543.0, loan_payment=5_850.0, loan_interest_rate=0.09, n_loan_periods=10, n_loan_repeats=0,)Use pytest.approx(value, abs=1) for dollar amounts, pytest.approx(value, abs=0.01) for percentages.
Suggested Implementation Order
Section titled “Suggested Implementation Order”- Read the 4 PDFs → extract all output values
- Create
tests/fixtures/Term-loan/term-loan-ca.json(mirror interest-only-ca.json structure, fixture IDs:tl-ca-35pct-summary,tl-ca-50pct-summary,tl-ca-35pct-10pct-projection,tl-ca-50pct-07pct-projection) - Add
TermLoanProfiletomodels/inputs.py,TermLoanAnnualRowtomodels/outputs.py - Write
tests/test_term_loan.py— all assertions, all failing - Implement
strategies/term_loan.py— CalcTermLevAnalysis + BetterThan bisection - Wire up in
__init__.py - Run pytest — iterate until all 4 fixture IDs pass
Out of Scope
Section titled “Out of Scope”- RRSP analysis
- Quebec deductibility logic (Ontario only for Phase 1)
- FastAPI server
- Snapshot generator update (separate task)
- Any front-end code
Validation Checklist
Section titled “Validation Checklist”Before reporting complete:
-
pytest tests/test_interest_only.py— all 37 still pass (no regressions) -
pytest tests/test_term_loan.py— all new fixtures pass -
pytest tests/— combined total passes with no failures
Created 2026-03-22. Scope: Phase 1 term loan extension.