sd-math — Library Design
Section titled “sd-math — Library Design”Status: Draft 3 — VB6 source reviewed; algorithms confirmed; field names finalized
Location: packages/sd-math/ in the monorepo
Philosophy
Section titled “Philosophy”Port LevPro as-is first. All LevPro VB6 math is ported verbatim — no logic separation, no upgrades, no Quebec exclusion. Once the port is complete and all golden fixtures pass, that becomes the locked foundational artifact. Upgrades (monthly cash flows, new strategies, refactored models) happen in a subsequent phase from that stable base.
Hard Rule — No Math Inference
Section titled “Hard Rule — No Math Inference”All math comes from VB6 source code. Only from VB6 source code.
- The LevPro PDFs are test fixtures — they define what the calculations must produce, not how to calculate them
- Never infer, derive, or assume formulas from PDF outputs — doing so produces confusion at best and hard-to-detect errors at worst
- If anything in the VB6 source is unclear, stop and ask before proceeding
This rule applies at every level: field names, intermediate variables, loop logic, rounding, bisection bounds, ACB treatment, unleveraged baseline — everything. The spec is the VB6 code.
VB6 Source Files
Section titled “VB6 Source Files”| File | Contents |
|---|---|
LevPro/modTypes.bas | All type definitions: GenericInputsType, IntOnlyLoanInputsType, IntOnlyLoanOutputsType, GenericFutureValuesType, etc. |
LevPro/modMath.bas | Math functions: CalcGenericFutureValues, CalcIntOnlyLevAnalysis, CalcNetLevValues, CalcTermLevAnalysis, CalcRRSPAnalysis |
LevPro/modLeveragePro.bas | Application code: IntOnlyBetterThan, SolveForIntOnly, UI wiring — reference only |
Primary source for Phase 1: modTypes.bas + modMath.bas. Refer to modLeveragePro.bas only if something is unclear from the other two.
VB6 Constants (preserve in Python)
Section titled “VB6 Constants (preserve in Python)”MAX_YEARS = 80MAX_RETURN_ERROR = 0.000005 # bisection convergence tolerance (modMath.bas)MAX_DOLLAR_ERROR = 0.02 # dollar rounding tolerance (modMath.bas)DIVIDEND_GROSSUP_RATIO = 1.25 # for calculating taxable dividend income (modMath.bas)Purpose
Section titled “Purpose”sd-math is the Python math library that is the authoritative implementation of all $MART DEBT financial calculations. It serves two consumers:
- Snapshot generator (
tools/generate_snapshot.py) — pre-calculates JSON for the SD Snapshot demo-teaser - SD App API (
packages/sd-api/) — serves the full interactive SD App at runtime
Package Location
Section titled “Package Location”packages/sd-math/├── pyproject.toml├── README.md├── src/│ └── sd_math/│ └── ...└── tests/ └── fixtures/ ├── LevPro-Test1.pdf # Source: one-page summary, 35% tax ├── LevPro-Test2.pdf # Source: one-page summary, 50% tax ├── LevPro-Test3.pdf # Source: projection, 35% tax, 10% return ├── LevPro-Test4.pdf # Source: projection, 50% tax, 7% return └── interest-only-ca.json # Golden fixture JSON (extracted from PDFs above)Module Structure
Section titled “Module Structure”src/sd_math/├── __init__.py├── models/│ ├── inputs.py # All input dataclasses (RegionProfile, TaxProfile, ReturnProfile, LoanProfile, UserProfile)│ └── outputs.py # All output dataclasses (AnnualRow, SummaryScenario, ProjectionSummary, ProjectionResult)├── tax/│ ├── canada.py # Canadian tax helpers (QC deductibility, dividend gross-up)│ └── us.py # US tax (Phase 4 — stub with NotImplemented)└── strategies/ ├── interest_only.py # Phase 1: port of CalcIntOnlyLevAnalysis ├── term_loan.py # Phase 5: port of CalcTermLevAnalysis └── rrsp.py # Phase 5+: port of CalcRRSPAnalysisInput Models (Draft 3 — VB6 names confirmed)
Section titled “Input Models (Draft 3 — VB6 names confirmed)”The Python public API uses higher-level profiles that map to the VB6 GenericInputsType and IntOnlyLoanInputsType. The VB6 source variable name is annotated in comments.
from dataclasses import dataclass
@dataclassclass RegionProfile: """ Region-specific tax environment. Maps to the prov/fed fields in IntOnlyLoanInputsType. "Region" rather than "country" because sub-country regions (QC, US states) have meaningfully different deductibility rules.
VB6 source: modTypes.bas IntOnlyLoanInputsType — ProvXxx / FedXxx fields. """ region: str # Province/state code: "ON", "QC", "BC", "CA", "NY", etc. country: str # "ca" or "us"
# Deductibility rules — populated from LevPro region config prov_deductibility_limited: bool # ProvDeductibilityLimited (QC: True, others: False) fed_deductibility_limited: bool # FedDeductibilityLimited (usually False) prov_ratio_of_interest_deductible: float # ProvRatioOfInterestDeductible (1.0 = 100%) fed_ratio_of_interest_deductible: float # FedRatioOfInterestDeductible (1.0 = 100%) prov_cap_gains_are_income: bool # ProvCapGainsAreIncome (QC: True, others: False) fed_cap_gains_are_income: bool # FedCapGainsAreIncome prov_defer_unused_deductions: bool # ProvDeferUnusedDeductions (QC: True) fed_defer_unused_deductions: bool # FedDeferUnusedDeductions
@dataclassclass TaxProfile: """ VB6 source: modTypes.bas GenericInputsType — TaxRate, ProvTaxRate, FedTaxRate, CapGainsInclusionRate, DivTaxRate Note: TaxRate = ProvTaxRate + FedTaxRate (combined marginal rate). All three are tracked separately because the deductibility calculation uses provincial and federal rates independently. """ marginal_rate: float # TaxRate (combined = prov + fed) prov_tax_rate: float # ProvTaxRate — provincial portion fed_tax_rate: float # FedTaxRate — federal portion cg_inclusion_rate: float # CapGainsInclusionRate (Canada: 0.50) div_tax_rate: float # DivTaxRate — explicit input; e.g. 0.159 at 35%, 0.313 at 50% region_profile: RegionProfile
@dataclassclass ReturnProfile: """ VB6 source: modTypes.bas GenericInputsType — ReturnPerPeriod, DCGRatioOfReturn, CapGainsRatioOfReturn, DivRatioOfReturn, IntRatioOfReturn, DistribReinvestRatio Invariant: dcg_pct + taxable_cg_pct + div_pct + interest_pct = 1.0 """ expected_return: float # ReturnPerPeriod — annual total before-tax return (e.g. 0.07) dcg_pct: float # DCGRatioOfReturn — deferred CG portion (e.g. 0.70) taxable_cg_pct: float # CapGainsRatioOfReturn — annually-realized CG portion (e.g. 0.25) div_pct: float # DivRatioOfReturn — dividend portion (e.g. 0.05) interest_pct: float # IntRatioOfReturn — interest income portion (typically 0.00) distrib_reinvest_ratio: float # DistribReinvestRatio — portion of AT distributions reinvested # Confirmed: always 1.0 (100% reinvested)
@dataclassclass LoanProfile: """ VB6 source: modTypes.bas IntOnlyLoanInputsType (LoanAmount, LoanInterestRate) GenericInputsType (NPeriods) """ loan_amount: float # LoanAmount (e.g. 100000.0) interest_rate: float # LoanInterestRate — annual before-tax rate (e.g. 0.09) holding_period: int # NPeriods — analysis horizon in years (e.g. 10) lev_to_non_lev_ratio: float # LevToNonLevRatio — leveraged:non-leveraged income ratio # Used to calculate taxable income ceiling (QC deductibility) # Confirmed: always 0.0 for pure leveraged-only analysis
@dataclassclass UserProfile: """ Aggregates all inputs into a single named profile.
Serves two purposes: 1. Predefined canonical profiles (middle-income, high-income, hnw) for snapshots — identified by id, understood by both back end and front end. 2. Personalized analysis for the full interactive app (id = "custom"). """ id: str # e.g., "middle-income", "high-income", "hnw", "custom" label: str # e.g., "Middle Income Investor" loan_profile: LoanProfile tax_profile: TaxProfile return_profile: ReturnProfilePredefined UserProfiles (values TBD — calibrate once VB6 defaults confirmed)
Section titled “Predefined UserProfiles (values TBD — calibrate once VB6 defaults confirmed)”| ID | Label | Marginal Rate | Province |
|---|---|---|---|
middle-income | Middle Income | ~35% | ON |
high-income | High Income | ~43% | ON |
hnw | High Net Worth | ~50% | ON |
Output Models (Draft 3 — VB6 names confirmed)
Section titled “Output Models (Draft 3 — VB6 names confirmed)”from dataclasses import dataclass
@dataclassclass AnnualRow: """ One row of the LevPro annual projection table.
VB6 source: - No-leverage columns: IntOnlyNoLevGenericOutputs (GenericFutureValuesType.BTBalance, ACB, ATDistrib) - Leverage gross: IntOnlyLevGenericOutputs (GenericFutureValuesType.BTBalance, ATDistrib) - Net leverage: IntOnlyLoanOutputs (IntOnlyLoanOutputsType.NetLevBTBal, NetLevACB) """ year: int
# No-Leverage columns (IntOnlyNoLevGenericOutputs) no_lev_bt_balance: float # BTBalance[year] — before-tax portfolio value no_lev_at_distrib: float # ATDistrib[year] — after-tax distributions no_lev_acb: float # ACB[year] — adjusted cost base
# Leverage columns lev_bt_balance: float # IntOnlyLevGenericOutputs.BTBalance[year] — gross leveraged balance lev_at_distrib: float # IntOnlyLevGenericOutputs.ATDistrib[year] net_lev_bt_bal: float # IntOnlyLoanOutputs.NetLevBTBal[year] — net after paying loan net_lev_acb: float # IntOnlyLoanOutputs.NetLevACB[year]
# Comparison columns net_increase: float # net_lev_bt_bal - no_lev_bt_balance pct_increase: float # net_increase / no_lev_bt_balance
@dataclassclass SummaryScenario: """One row of the LevPro one-page summary table (end-of-holding-period values).""" expected_return: float no_lev_bt_balance: float # NoLev BTBalance at end of NPeriods net_lev_bt_bal: float # NetLevBTBal at end of NPeriods net_increase: float pct_increase: float
@dataclassclass ProjectionSummary: """Summary values from the LevPro one-page summary report.""" better_than_return: float # IntOnlyBetterThan() result (bisection) at_annual_investment: float # After-tax annual investor cost = GenericInputs.Cashflow[1] bt_annual_interest: float # Before-tax annual interest = LoanAmount × LoanInterestRate scenarios: list[SummaryScenario] # One per return (e.g. 0%, 3%, 7%, 10%, BetterThan)
@dataclassclass ProjectionResult: """Full result returned by calculate().""" loan_profile: LoanProfile tax_profile: TaxProfile return_profile: ReturnProfile annual_rows: list[AnnualRow] # One per year; empty list if summary-only requested summary: ProjectionSummaryAlgorithm — Interest-Only Leveraged Analysis (Confirmed from VB6 Source)
Section titled “Algorithm — Interest-Only Leveraged Analysis (Confirmed from VB6 Source)”VB6 source: modMath.bas — CalcIntOnlyLevAnalysis, CalcGenericFutureValues, CalcNetLevValues
Step 1 — Leveraged Portfolio (CalcGenericFutureValues)
Section titled “Step 1 — Leveraged Portfolio (CalcGenericFutureValues)”Set up leveraged initial conditions:
Cashflow[0] = LoanAmount # InitialInvestmentCashflow[1..NPeriods] = 0 # no further periodic investmentsACB[0] = LoanAmountSharePrice[0] = 1.0NShares[0] = LoanAmountEndOfPeriodInvestments = True # for interest-only leveraged caseConvert return ratios from “of return” to “of distributions”:
TotDistrib = CapGainsRatioOfReturn + DivRatioOfReturn + IntRatioOfReturnCapGainsRatioOfDistrib = CapGainsRatioOfReturn / TotDistribDivRatioOfDistrib = DivRatioOfReturn / TotDistribIntRatioOfDistrib = IntRatioOfReturn / TotDistribFor each year t = 1 to NPeriods (positive return, EndOfPeriodInvestments = True):
SharePrice[t] = SharePrice[t-1] × (1 + ReturnPerPeriod × DCGRatioOfReturn)BTDistrib[t] = BTBalance[t-1] × ReturnPerPeriod × (1 - DCGRatioOfReturn)ATDistrib[t] = BTDistrib[t] × (1 - (CapGainsRatioOfDistrib × TaxRate × CapGainsInclusionRate + DivRatioOfDistrib × DivTaxRate + IntRatioOfDistrib × TaxRate))NewSharesFromDistrib = ATDistrib[t] × DistribReinvestRatio / SharePrice[t]NewSharesFromCashflow = Cashflow[t] / SharePrice[t] # = 0 for years 1..NNShares[t] = NShares[t-1] + NewSharesFromCashflow + NewSharesFromDistribBTBalance[t] = SharePrice[t] × NShares[t]ACB[t] = ACB[t-1] + ATDistrib[t] × DistribReinvestRatio + Cashflow[t]Note: For negative returns, the VB6 code uses a simpler path (no distributions, share price deflates).
See CalcGenericFutureValues in modMath.bas for the full negative-return branch.
Step 2 — Annual Tax Deductibility and AT Cashflow
Section titled “Step 2 — Annual Tax Deductibility and AT Cashflow”For each year t = 1 to NPeriods:
BTInterestCost = LoanAmount × LoanInterestRate
# Taxable investment income (prior year balance × return, income-type weighted)if t > 1: BaseTaxableIncome = BTBalance[t-1] × ReturnPerPeriodelse: BaseTaxableIncome = LoanAmount × ReturnPerPeriod
# Provincial taxable income (used as deductibility ceiling for QC)if ProvCapGainsAreIncome: ProvTaxableInvestmentIncome = BaseTaxableIncome × (CapGainsRatioOfReturn × CapGainsInclusionRate + IntRatioOfReturn + DivRatioOfReturn × DIVIDEND_GROSSUP_RATIO) × (1 + LevToNonLevRatio)else: ProvTaxableInvestmentIncome = BaseTaxableIncome × (IntRatioOfReturn + DivRatioOfReturn × DIVIDEND_GROSSUP_RATIO) × (1 + LevToNonLevRatio)
# Federal taxable income (same formula using FedCapGainsAreIncome)
# Provincial deductible amount this yearif ProvDeductibilityLimited: ProvAmountDeductible[t] = max(0, min(ProvTaxableInvestmentIncome, BTInterestCost × ProvRatioOfInterestDeductible + ProvTotalUndeductedExpenses[t-1]))else: ProvAmountDeductible[t] = BTInterestCost × ProvRatioOfInterestDeductible
# Federal deductible amount (identical pattern using Fed* variables)
# Cumulative undeducted expenses (carryforward)ProvTotalUndeductedExpenses[t] = ( ProvTotalUndeductedExpenses[t-1] × (1 if ProvDeferUnusedDeductions else 0) + BTInterestCost - ProvAmountDeductible[t])# (same for Fed)
# After-tax annual cashflow (= investor's annual net cost = unlev comparison investment)ATCashflow[t] = BTInterestCost - (ProvAmountDeductible[t] × ProvTaxRate) - (FedAmountDeductible[t] × FedTaxRate)ATCashflow[1] is the fixed annual investment used for the unleveraged comparison (all years use the same value).
Step 3 — Net Leveraged Balance (CalcNetLevValues)
Section titled “Step 3 — Net Leveraged Balance (CalcNetLevValues)”For each year t, calculates the balance if the investor sold and repaid the loan:
CapGainsTaxRate = CapGainsInclusionRate × TaxRateACPS = ACB[t] / BTBalance[t] # average cost per share
# Basic case (no remaining QC deductions):if BTBalance[t] > LoanBal: LoanTaxableIncome = LoanBal × (1 - ACPS) × CapGainsInclusionRate LoanTax = LoanTaxableIncome × TaxRate / (1 - (1 - ACPS) × CapGainsTaxRate) NetLevBTBal[t] = BTBalance[t] - LoanBal - LoanTax NetLevACB[t] = NetLevBTBal[t] × ACPSelse: NetLevBTBal[t] = BTBalance[t] - LoanBal # no cap. gains taxes NetLevACB[t] = ACB[t]The QC deduction case is more complex — see CalcNetLevValues in modMath.bas for the full
provincial/federal deduction logic (multiple branches depending on which deductions remain).
Step 4 — Unleveraged Comparison (CalcGenericFutureValues)
Section titled “Step 4 — Unleveraged Comparison (CalcGenericFutureValues)”Invest the same after-tax annual interest cost unleveraged, at END of each year:
Cashflow[0] = 0Cashflow[1..NPeriods] = ATCashflow[1] # constant (from Step 2, year 1)InitialInvestment = 0InitialInvestmentACB = 0EndOfPeriodInvestments = TrueRun CalcGenericFutureValues with the same return parameters.
Step 5 — “Better Than” Return (IntOnlyBetterThan in modLeveragePro.bas)
Section titled “Step 5 — “Better Than” Return (IntOnlyBetterThan in modLeveragePro.bas)”Find the return at which NetLevBTBal[NPeriods] == UnlevBTBalance[NPeriods]:
objective(r) = NetLevBTBal(r)[NPeriods] - UnlevBTBalance(r)[NPeriods]
# Lower bound: 0% return (objective < 0: unlev wins)ReturnLow = 0.0
# Upper bound: start at LoanInterestRate × 1.2, expand by 1.2× until objective > 0ReturnHigh = LoanInterestRate × 1.2while objective(ReturnHigh) <= 0: ReturnHigh *= 1.2
OriginalResultMagnitude = max(abs(objective(ReturnLow)), abs(objective(ReturnHigh)))
# Bisection loop (terminates when width < MAX_RETURN_ERROR = 0.000005):while (ReturnHigh - ReturnLow) >= MAX_RETURN_ERROR: ReturnMid = ReturnLow + 0.5 × (ReturnHigh - ReturnLow) if sign(objective(ReturnLow)) == sign(objective(ReturnMid)): ReturnLow = ReturnMid else: ReturnHigh = ReturnMid
# Convergence verification:if abs(objective(ReturnMid)) < 0.0005 × OriginalResultMagnitude: BetterThanReturn = ReturnMidelse: BetterThanReturn = -999 # failed to convergeDisplay artifact (summary table only): For the “Better Than” return row, both the leveraged and
unleveraged end-balances are displayed as their average — making them appear equal. This is a
presentation adjustment in modLeveragePro.bas OutputIntOnly_Summary(), not a change to the
underlying math. The Python generator must replicate this behavior when building the SummaryScenario
for the “Better Than” return.
Strategy Interface Pattern
Section titled “Strategy Interface Pattern”"""Port of LevPro VB6 interest-only leveraged analysis.VB6 source: modMath.bas CalcIntOnlyLevAnalysis + CalcGenericFutureValues + CalcNetLevValues modLeveragePro.bas IntOnlyBetterThan"""
def calculate( loan_profile: LoanProfile, tax_profile: TaxProfile, return_profile: ReturnProfile, returns: list[float], # e.g. [0.00, 0.03, 0.07, 0.10]; BetterThan added automatically include_annual_rows: bool = True,) -> ProjectionResult: ...Common entry point:
def calculate( strategy: str, loan_profile: LoanProfile, tax_profile: TaxProfile, return_profile: ReturnProfile, returns: list[float], include_annual_rows: bool = True,) -> ProjectionResult: strategies = { "interest-only": interest_only.calculate, "term-loan": term_loan.calculate, "rrsp": rrsp.calculate, } if strategy not in strategies: raise ValueError(f"Unknown strategy: {strategy}") return strategies[strategy](loan_profile, tax_profile, return_profile, returns, include_annual_rows)Testing
Section titled “Testing”tests/├── fixtures/│ ├── LevPro-Test1.pdf # Golden source PDFs (authoritative)│ ├── LevPro-Test2.pdf│ ├── LevPro-Test3.pdf│ ├── LevPro-Test4.pdf│ └── interest-only-ca.json # Golden fixture JSON (field names match Python model)├── test_interest_only.py # Golden-fixture pytest (Phase 1)├── test_term_loan.py # Phase 5└── test_rrsp.py # Phase 5+What the golden fixtures cover:
| Fixture ID | Tax Rate | Return | Coverage |
|---|---|---|---|
io-ca-35pct-summary | 35% | 0%, 3%, 6.7%, 7%, 10% | Summary only (5 scenarios) |
io-ca-50pct-summary | 50% | 0%, 3%, 5.6%, 7%, 10% | Summary only (5 scenarios) |
io-ca-35pct-10pct-projection | 35% | 10% | Full 10-year annual rows + summary |
io-ca-50pct-07pct-projection | 50% | 7% | Full 10-year annual rows + summary |
Total assertions: 10 summary bottom-line values + 2 full annual schedules (10 rows × 9 columns = 180 column assertions).
TDD requirement: All 4 fixtures must pass in pytest before any Phase 1 math implementation is considered complete.
Draft 3 — 2026-03-22. VB6 source reviewed. Algorithms confirmed. Field names finalized. Ready for implementation.