SD App API — Design
Section titled “SD App API — Design”Status: Draft 5 — single-endpoint architecture implemented (A1-Task-1 + A1-Task-2 complete)
Location: packages/sd-api/ in the monorepo (FastAPI)
Purpose
Section titled “Purpose”The SD API serves two consumers:
- SD Snapshot (Phase 1) —
generate_snapshot.pycallssd-mathdirectly (no HTTP); the API is not needed for Phase 1 - Full SD App (Phase 2+) — SvelteKit PWA calls the API at runtime for interactive calculations
Hard Rules
Section titled “Hard Rules”- No math in the API. All calculations are delegated to
sd-math. The API is a transport layer only. - Field names match sd-math models exactly. sd-math uses VB6 variable names; the API mirrors them in Pydantic. Do not rename fields.
- Strategy implementations live in sd-math. The API never duplicates calculation logic.
Package Structure
Section titled “Package Structure”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 TestClientEndpoints
Section titled “Endpoints”POST /api/v1 — AnalysisProfile → AnalysisResult (all strategies, periods, analysis types)GET /api/v1/health — uptime checkGET /api/v1/profiles — predefined profile registrySingle-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.
Authentication
Section titled “Authentication”No auth gate in Phase A1. Cloudflare rate limiting always on. Auth added in A2 without breaking the API contract.
Predefined Profiles
Section titled “Predefined Profiles”Interest-Only (INTEREST_ONLY_PROFILES)
Section titled “Interest-Only (INTEREST_ONLY_PROFILES)”| Profile ID | Country | Marginal Rate | CG / Div Rate | Loan Amount | Rate | Period |
|---|---|---|---|---|---|---|
middle-income | CA (ON) | 35% | 50% incl / 15.9% | $100,000 | 9% | 10 yr |
high-income | CA (ON) | 50% | 50% incl / 31.3% | $100,000 | 9% | 10 yr |
us-middle | US fed | 22% | 15% LT CG / 15% | $100,000 | 9% | 10 yr |
us-high | US fed | 37% | 20% LT CG+NIIT / 23.8% | $100,000 | 9% | 10 yr |
Term Loan (TERM_LOAN_PROFILES)
Section titled “Term Loan (TERM_LOAN_PROFILES)”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
AnalysisProfile Request Schema
Section titled “AnalysisProfile Request Schema”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" }}AnalysisResult Response Schema
Section titled “AnalysisResult Response Schema”{ "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...": "..."}AnalysisRouter Dispatch Table
Section titled “AnalysisRouter Dispatch Table”strategy × period × analysis_type → sd-math function
interest_only × annual × projection → interest_only.calculateinterest_only × monthly × projection → interest_only_monthly.calculateterm_loan × annual × projection → term_loan.calculateterm_loan × monthly × projection → term_loan_monthly.calculate
historical variants → A1-Task-3 (not yet implemented → 501)A1 Implementation Status
Section titled “A1 Implementation Status”A1-Task-1: Project Scaffold ✅ Complete
Section titled “A1-Task-1: Project Scaffold ✅ Complete”A1-Task-2: IO + TL Endpoints (CA + US, annual + monthly) ✅ Complete
Section titled “A1-Task-2: IO + TL Endpoints (CA + US, annual + monthly) ✅ Complete”A1-Task-3: Historical Data Endpoints ⬜
Section titled “A1-Task-3: Historical Data Endpoints ⬜”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_rowsfetched from DB and passed tocalculate_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.
A2-Task-2: CI/CD Pipeline
Section titled “A2-Task-2: CI/CD Pipeline”GitHub Actions
Section titled “GitHub Actions”.github/workflows/test.yml — on every push and PR:
name: Testson: [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 -qDraft 5 — 2026-03-27. Single-endpoint architecture implemented. A1-Task-1 and A1-Task-2 complete.