Skip to content

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


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:

  1. Read the 4 LevPro PDF fixtures to extract expected output values
  2. Create tests/fixtures/Term-loan/term-loan-ca.json — the golden fixture file
  3. Write tests/test_term_loan.py — parametrized pytest tests (all fail initially)
  4. Implement strategies/term_loan.py — port of CalcTermLevAnalysis from VB6
  5. Iterate until all tests pass

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 TermLoanInputsType
packages/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% tax
packages/sd-math/tests/fixtures/Term-loan/LevPro-Test2.pdf ← one-page summary, 50% tax
packages/sd-math/tests/fixtures/Term-loan/LevPro-Test3.pdf ← 10-year projection, 35% tax, 10% return
packages/sd-math/tests/fixtures/Term-loan/LevPro-Test4.pdf ← 10-year projection, 50% tax, 7% return

Read 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.py
packages/sd-math/tests/fixtures/Interest-only/interest-only-ca.json

Mirror the interest-only fixture JSON structure exactly. The term loan fixture should use the same 4-fixture-ID pattern.


  1. 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
  2. TDD: golden fixture JSON first, then tests, then implementation.

    • Create term-loan-ca.json from PDF values before writing any implementation code
    • Write all pytest assertions before implementing term_loan.py
  3. Never report complete until all 4 golden fixtures pass in pytest.

  4. Reuse existing helpers. CalcGenericFutureValues and CalcNetLevValues are already implemented in interest_only.py. Import and call them — do not copy or reimplement.

  5. Do not modify any existing test files or conftest.py for interest-only tests.


ParameterValue
loan_amount37,543
loan_payment5,850
loan_interest_rate0.09
n_loan_periods10
n_loan_repeats0
holding_period10
dcg_pct0.70
taxable_cg_pct0.25
div_pct0.05
interest_pct0.00
distrib_reinvest_ratio1.0
lev_to_non_lev_ratio0.0
RegionOntario (ON) — same flags as interest-only
Fixture IDmarginal_ratediv_tax_rateReturnType
tl-ca-35pct-summary0.350.1590%, 3%, 7%, 10% + BetterThan5 summary scenarios
tl-ca-50pct-summary0.500.3130%, 3%, 7%, 10% + BetterThan5 summary scenarios
tl-ca-35pct-10pct-projection0.350.15910%10 annual rows + summary
tl-ca-50pct-07pct-projection0.500.3137%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.


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 only
cashflow[1] = loan_amount
cashflow[2..N] = 0
end_of_period_investments = False # interest-only uses True
use_cashflow_array = True
initial_investment_acb = 0
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.

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.)

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], ...
)

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 = 0
  • initial_deposit = 0 (Cashflow array drives it)

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

Add to models/inputs.py — new dataclass:

@dataclass
class 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.0

The RegionProfile fields map to both TermLoanInputsType and GenericInputsType — same as interest-only. TaxProfile and ReturnProfile are unchanged.


The annual row output for term loan includes additional columns:

@dataclass
class 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: float

Add TermLoanAnnualRow to models/outputs.py. Also add TermLoanProjectionResult or extend ProjectionResult to allow annual_rows to be either type.


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:

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.


  1. Read the 4 PDFs → extract all output values
  2. 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)
  3. Add TermLoanProfile to models/inputs.py, TermLoanAnnualRow to models/outputs.py
  4. Write tests/test_term_loan.py — all assertions, all failing
  5. Implement strategies/term_loan.py — CalcTermLevAnalysis + BetterThan bisection
  6. Wire up in __init__.py
  7. Run pytest — iterate until all 4 fixture IDs pass

  • RRSP analysis
  • Quebec deductibility logic (Ontario only for Phase 1)
  • FastAPI server
  • Snapshot generator update (separate task)
  • Any front-end code

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.