Skip to content

Status: Draft 3 — VB6 source reviewed; algorithms confirmed; field names finalized Location: packages/sd-math/ in the monorepo


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.

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.


FileContents
LevPro/modTypes.basAll type definitions: GenericInputsType, IntOnlyLoanInputsType, IntOnlyLoanOutputsType, GenericFutureValuesType, etc.
LevPro/modMath.basMath functions: CalcGenericFutureValues, CalcIntOnlyLevAnalysis, CalcNetLevValues, CalcTermLevAnalysis, CalcRRSPAnalysis
LevPro/modLeveragePro.basApplication 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.


MAX_YEARS = 80
MAX_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)

sd-math is the Python math library that is the authoritative implementation of all $MART DEBT financial calculations. It serves two consumers:

  1. Snapshot generator (tools/generate_snapshot.py) — pre-calculates JSON for the SD Snapshot demo-teaser
  2. SD App API (packages/sd-api/) — serves the full interactive SD App at runtime

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)

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 CalcRRSPAnalysis

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

models/inputs.py
from dataclasses import dataclass
@dataclass
class 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
@dataclass
class 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
@dataclass
class 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)
@dataclass
class 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
@dataclass
class 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: ReturnProfile

Predefined UserProfiles (values TBD — calibrate once VB6 defaults confirmed)

Section titled “Predefined UserProfiles (values TBD — calibrate once VB6 defaults confirmed)”
IDLabelMarginal RateProvince
middle-incomeMiddle Income~35%ON
high-incomeHigh Income~43%ON
hnwHigh Net Worth~50%ON

Output Models (Draft 3 — VB6 names confirmed)

Section titled “Output Models (Draft 3 — VB6 names confirmed)”
models/outputs.py
from dataclasses import dataclass
@dataclass
class 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
@dataclass
class 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
@dataclass
class 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)
@dataclass
class 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: ProjectionSummary

Algorithm — Interest-Only Leveraged Analysis (Confirmed from VB6 Source)

Section titled “Algorithm — Interest-Only Leveraged Analysis (Confirmed from VB6 Source)”

VB6 source: modMath.basCalcIntOnlyLevAnalysis, CalcGenericFutureValues, CalcNetLevValues

Step 1 — Leveraged Portfolio (CalcGenericFutureValues)

Section titled “Step 1 — Leveraged Portfolio (CalcGenericFutureValues)”

Set up leveraged initial conditions:

Cashflow[0] = LoanAmount # InitialInvestment
Cashflow[1..NPeriods] = 0 # no further periodic investments
ACB[0] = LoanAmount
SharePrice[0] = 1.0
NShares[0] = LoanAmount
EndOfPeriodInvestments = True # for interest-only leveraged case

Convert return ratios from “of return” to “of distributions”:

TotDistrib = CapGainsRatioOfReturn + DivRatioOfReturn + IntRatioOfReturn
CapGainsRatioOfDistrib = CapGainsRatioOfReturn / TotDistrib
DivRatioOfDistrib = DivRatioOfReturn / TotDistrib
IntRatioOfDistrib = IntRatioOfReturn / TotDistrib

For 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..N
NShares[t] = NShares[t-1] + NewSharesFromCashflow + NewSharesFromDistrib
BTBalance[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] × ReturnPerPeriod
else:
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 year
if 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 × TaxRate
ACPS = 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] × ACPS
else:
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] = 0
Cashflow[1..NPeriods] = ATCashflow[1] # constant (from Step 2, year 1)
InitialInvestment = 0
InitialInvestmentACB = 0
EndOfPeriodInvestments = True

Run 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 > 0
ReturnHigh = LoanInterestRate × 1.2
while 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 = ReturnMid
else:
BetterThanReturn = -999 # failed to converge

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


strategies/interest_only.py
"""
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:

sd_math/__init__.py
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)

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 IDTax RateReturnCoverage
io-ca-35pct-summary35%0%, 3%, 6.7%, 7%, 10%Summary only (5 scenarios)
io-ca-50pct-summary50%0%, 3%, 5.6%, 7%, 10%Summary only (5 scenarios)
io-ca-35pct-10pct-projection35%10%Full 10-year annual rows + summary
io-ca-50pct-07pct-projection50%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.