Skip to content

Status: Draft 5 — single-endpoint architecture implemented (A1-Task-1 + A1-Task-2 complete) Location: packages/sd-api/ in the monorepo (FastAPI)


The SD API serves two consumers:

  1. SD Snapshot (Phase 1) — generate_snapshot.py calls sd-math directly (no HTTP); the API is not needed for Phase 1
  2. Full SD App (Phase 2+) — SvelteKit PWA calls the API at runtime for interactive calculations

  1. No math in the API. All calculations are delegated to sd-math. The API is a transport layer only.
  2. Field names match sd-math models exactly. sd-math uses VB6 variable names; the API mirrors them in Pydantic. Do not rename fields.
  3. Strategy implementations live in sd-math. The API never duplicates calculation logic.

packages/sd-api/
├── pyproject.toml
├── main.py # FastAPI app: POST /api/v1, GET /health, GET /profiles
├── router.py # AnalysisRouter — dispatches on strategy × period × analysis_type
├── profiles.py # Predefined profile registry (CA + US, all strategies)
├── schemas/
│ ├── __init__.py
│ ├── inputs.py # AnalysisProfile discriminated union
│ └── outputs.py # AnalysisResult
└── tests/
├── __init__.py
└── test_api.py # Integration tests via httpx TestClient

POST /api/v1 — AnalysisProfile → AnalysisResult (all strategies, periods, analysis types)
GET /api/v1/health — uptime check
GET /api/v1/profiles — predefined profile registry

Single-endpoint design: strategy, period, and analysis_type are request body fields, not URL paths. This avoids URL proliferation as the strategy × period × analysis_type matrix grows.


No auth gate in Phase A1. Cloudflare rate limiting always on. Auth added in A2 without breaking the API contract.


Profile IDCountryMarginal RateCG / Div RateLoan AmountRatePeriod
middle-incomeCA (ON)35%50% incl / 15.9%$100,0009%10 yr
high-incomeCA (ON)50%50% incl / 31.3%$100,0009%10 yr
us-middleUS fed22%15% LT CG / 15%$100,0009%10 yr
us-highUS fed37%20% LT CG+NIIT / 23.8%$100,0009%10 yr

Same tax/return parameters; loan replaced with: $37,543 / $5,850/yr / 9% / 10 yr. IDs: middle-income, high-income, us-middle, us-high

All profiles use: dcg_pct=0.70, taxable_cg_pct=0.25, div_pct=0.05, interest_pct=0.00, distrib_reinvest_ratio=1.0


Pydantic discriminated union on strategy field. Two variants:

InterestOnlyProfile (strategy: "interest_only")

Section titled “InterestOnlyProfile (strategy: "interest_only")”
{
"strategy": "interest_only",
"period": "annual",
"analysis_type": "projection",
"account": "taxable",
"profile_id": "middle-income",
"compounding": "monthly",
"scenario_returns": null
}

Minimal request (all defaults apply):

{"strategy": "interest_only"}

Custom inputs (profile_id must be “custom”):

{
"strategy": "interest_only",
"profile_id": "custom",
"loan": {
"loan_amount": 150000,
"interest_rate": 0.08,
"holding_period": 10,
"lev_to_non_lev_ratio": 0.0
},
"tax": {
"marginal_rate": 0.43,
"prov_tax_rate": 0.1815,
"fed_tax_rate": 0.2485,
"cg_inclusion_rate": 0.50,
"div_tax_rate": 0.218,
"region": {
"region": "ON",
"country": "ca",
"prov_deductibility_limited": false,
"fed_deductibility_limited": false,
"prov_ratio_of_interest_deductible": 1.0,
"fed_ratio_of_interest_deductible": 1.0,
"prov_cap_gains_are_income": false,
"fed_cap_gains_are_income": false,
"prov_defer_unused_deductions": false,
"fed_defer_unused_deductions": false
}
},
"returns": {
"expected_return": 0.07,
"dcg_pct": 0.70,
"taxable_cg_pct": 0.25,
"div_yield": 0.0035,
"interest_pct": 0.00
},
"scenario_returns": [0.00, 0.03, 0.07, 0.10]
}

div_yield vs div_pct: Either may be provided in returns. div_yield is preferred (resolves to div_pct = div_yield / expected_return). div_pct accepted for LevPro compatibility.

TermLoanAnalysisProfile (strategy: "term_loan")

Section titled “TermLoanAnalysisProfile (strategy: "term_loan")”

Same fields, but loan is TermLoanIn:

{
"strategy": "term_loan",
"profile_id": "custom",
"loan": {
"loan_amount": 37543,
"loan_payment": 5850,
"loan_interest_rate": 0.09,
"n_loan_periods": 10,
"n_loan_repeats": 0,
"lev_to_non_lev_ratio": 0.0
},
"tax": { "...": "same as interest_only" },
"returns": { "...": "same as interest_only" }
}

{
"strategy": "interest_only",
"period": "annual",
"analysis_type": "projection",
"profile_id": "middle-income",
"summary": {
"better_than_return": 0.0666,
"at_annual_investment": 5850,
"bt_annual_interest": 9000,
"scenarios": [
{
"expected_return": 0.00,
"no_lev_bt_balance": 58500,
"net_lev_bt_bal": -14321,
"net_increase": -72821,
"pct_increase": -1.245,
"is_better_than": false
},
{
"expected_return": 0.0666,
"no_lev_bt_balance": 83638,
"net_lev_bt_bal": 83638,
"net_increase": 0,
"pct_increase": 0.0,
"is_better_than": true
}
]
},
"details": {
"expected_return": 0.07,
"annual_rows": [
{
"year": 1,
"no_lev_bt_balance": 6257,
"no_lev_at_distrib": 306,
"no_lev_acb": 6257,
"lev_bt_balance": 9650,
"lev_at_distrib": 472,
"net_lev_bt_bal": 472,
"net_lev_acb": 472,
"net_increase": -5785,
"pct_increase": -0.9246
}
]
}
}

Term loan details.annual_rows include additional fields:

{
"loan_balance": 30871,
"interest_part_of_pmt": 3379,
"loan_reduction": 2471,
"at_cashflow": 4667,
"...same base fields...": "..."
}

strategy × period × analysis_type → sd-math function
interest_only × annual × projection → interest_only.calculate
interest_only × monthly × projection → interest_only_monthly.calculate
term_loan × annual × projection → term_loan.calculate
term_loan × monthly × projection → term_loan_monthly.calculate
historical variants → A1-Task-3 (not yet implemented → 501)

A1-Task-2: IO + TL Endpoints (CA + US, annual + monthly) ✅ Complete

Section titled “A1-Task-2: IO + TL Endpoints (CA + US, annual + monthly) ✅ Complete”

Historical endpoints take a ticker symbol and run calculate_historical() using the asset-history DB.

Request:

{
"strategy": "interest_only",
"analysis_type": "historical",
"profile_id": "middle-income",
"ticker": "XIC.TO",
"holding_period": 10
}

Implementation notes:

  • DB path: configured via env var ASSET_HISTORY_DB_PATH
  • Fallback: if ticker not in DB → 404
  • price_rows fetched from DB and passed to calculate_historical()

A1-Task-4: Deployment — Railway + Cloudflare ⬜

Section titled “A1-Task-4: Deployment — Railway + Cloudflare ⬜”

Decision: Railway for Python hosting, Cloudflare for DNS/rate-limiting.

railway.toml:
startCommand = "uvicorn main:app --host 0.0.0.0 --port $PORT"

Rate limiting (Cloudflare): 100 req/min per IP on /api/v1. CORS: allow smartdebt.ca, localhost:5173, configurable via env var.


.github/workflows/test.yml — on every push and PR:

name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv pip install -e packages/sd-math packages/sd-api[dev]
- run: python3 -m pytest packages/sd-math/tests packages/sd-api/tests -q

Draft 5 — 2026-03-27. Single-endpoint architecture implemented. A1-Task-1 and A1-Task-2 complete.