Skip to content

Cursor Task — packages/sd-api/ FastAPI Backend

Section titled “Cursor Task — packages/sd-api/ FastAPI Backend”

Date: 2026-03-22 Assigned to: Cursor (claude-sonnet-4-6 recommended) Working directory: \\wsl$\Ubuntu-24.04\home\ta\projects\monorepo\packages\sd-api\


This is the $MART DEBT App (SD App) — a SvelteKit PWA for leveraged investing analysis.

Phase 1 is complete: a static snapshot renderer reading pre-calculated JSON. Phase 2 task: build the live Python API backend so the SvelteKit UI can call it with user-entered inputs and get live results.

The math engine (packages/sd-math/) is already fully implemented and tested:

  • interest-only strategy: 37 tests passing against LevPro golden fixtures
  • term-loan strategy: 34 tests passing against LevPro golden fixtures

Your job: build the FastAPI wrapper around sd-math. No new math. Pure transport layer.


  1. No math in the API. All calculations are delegated to sd-math. Call sd_math.calculate() or sd_math.strategies.term_loan.calculate(). Never implement calculation logic in the API package.
  2. Field names match sd-math exactly. Do not rename fields. The API Pydantic models mirror the sd-math Python dataclass models. If sd-math has marginal_rate, the API has marginal_rate.
  3. TDD. Write failing tests first. Tests must use httpx TestClient. Tests pass before you signal complete.
  4. Do not modify packages/sd-math/. It is a locked artifact. Read it; don’t change it.
  5. Run locally only. No deployment work. Just uvicorn main:app --reload running and tests passing.

Read these in order before writing a single line of code:

  1. This file (you’re reading it)
  2. D:\FSS\KB\Business\_WorkingOn\Projects\SD-App\api-design.md — API structure, request/response schema, predefined profiles, endpoint definitions
  3. packages/sd-math/src/sd_math/models/inputs.pyLoanProfile, TermLoanProfile, TaxProfile, ReturnProfile, RegionProfile, UserProfile
  4. packages/sd-math/src/sd_math/models/outputs.pyAnnualRow, TermLoanAnnualRow, SummaryScenario, ProjectionSummary, ProjectionResult
  5. packages/sd-math/src/sd_math/__init__.pycalculate() function signature
  6. packages/sd-math/src/sd_math/strategies/term_loan.pycalculate() signature (takes TermLoanProfile, not LoanProfile)
  7. packages/sd-math/tests/fixtures/Interest-only/interest-only-ca.json — golden fixture values for test assertions
  8. packages/sd-math/tests/fixtures/Term-loan/term-loan-ca.json — term loan golden fixture values
  9. packages/sd-math/tests/conftest.py — predefined test profiles (TAX_35, TAX_50, COMMON_LOAN, COMMON_TERM_LOAN, ON_REGION)

A working FastAPI package at packages/sd-api/ with:

packages/sd-api/
├── pyproject.toml
├── main.py # FastAPI app, CORS, router registration
├── profiles.py # Predefined profile registry (middle-income, high-income)
├── routers/
│ └── projections.py # POST endpoints for interest-only and term-loan
├── schemas/
│ ├── inputs.py # Pydantic request models
│ └── outputs.py # Pydantic response models
└── tests/
└── test_api.py # Integration tests via httpx TestClient
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "sd-api"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"sd-math",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "httpx>=0.27"]
[tool.pytest.ini_options]
testpaths = ["tests"]

sd-math is a workspace package — install it as an editable dependency:

Terminal window
cd packages/sd-api
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
pip install -e "../sd-math"

See api-design.md for full details. Key points:

POST /api/v1/ca/projections/interest-only
POST /api/v1/ca/projections/term-loan

Pattern 1 — predefined profile:

{ "profile_id": "middle-income" }

Pattern 2 — custom inputs:

{
"profile_id": "custom",
"loan_profile": { "loan_amount": 150000, "interest_rate": 0.08, "holding_period": 10, "lev_to_non_lev_ratio": 0.0 },
"tax_profile": { "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_profile": { "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 } },
"return_profile": { "expected_return": 0.07, "dcg_pct": 0.70, "taxable_cg_pct": 0.25, "div_pct": 0.05, "interest_pct": 0.00, "distrib_reinvest_ratio": 1.0 },
"returns": [0.00, 0.03, 0.07, 0.10]
}

For term-loan custom requests, use term_loan_profile (with loan_payment, loan_interest_rate, n_loan_periods) instead of loan_profile.

{
"meta_data": { "strategy": "interest-only", "country": "ca", "version": "1.0" },
"inputs": { "profile_id": "middle-income", "loan_profile": { "..." }, "tax_profile": { "..." }, "return_profile": { "..." }, "returns": [0.0, 0.03, 0.07, 0.1] },
"analysis_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 }
]
},
"analysis_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 annual_rows add: loan_balance, interest_part_of_pmt, loan_reduction, at_cashflow.

The is_better_than flag on SummaryScenario is computed by the API: check if abs(scenario.expected_return - better_than_return) < 1e-8.

Default returns if not specified: [0.00, 0.03, 0.07, 0.10]. sd-math inserts the Better-Than scenario automatically.


Build a profile registry. These exact values are used in the golden fixtures — assertions will verify against them.

# Ontario region — no QC deductibility limits
ON_REGION = RegionProfile(
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,
)
COMMON_RETURN = ReturnProfile(
expected_return=0.07,
dcg_pct=0.70, taxable_cg_pct=0.25, div_pct=0.05, interest_pct=0.00,
distrib_reinvest_ratio=1.0,
)
INTEREST_ONLY_PROFILES = {
"middle-income": UserProfile(
id="middle-income", label="Middle Income (35% marginal rate)",
loan_profile=LoanProfile(loan_amount=100_000.0, interest_rate=0.09, holding_period=10, lev_to_non_lev_ratio=0.0),
tax_profile=TaxProfile(marginal_rate=0.35, prov_tax_rate=0.1315, fed_tax_rate=0.2185, cg_inclusion_rate=0.50, div_tax_rate=0.159, region_profile=ON_REGION),
return_profile=COMMON_RETURN,
),
"high-income": UserProfile(
id="high-income", label="High Income (50% marginal rate)",
loan_profile=LoanProfile(loan_amount=100_000.0, interest_rate=0.09, holding_period=10, lev_to_non_lev_ratio=0.0),
tax_profile=TaxProfile(marginal_rate=0.50, prov_tax_rate=0.2315, fed_tax_rate=0.2685, cg_inclusion_rate=0.50, div_tax_rate=0.313, region_profile=ON_REGION),
return_profile=COMMON_RETURN,
),
}
TERM_LOAN_PROFILES = {
"middle-income": { ... same tax + return, but TermLoanProfile(loan_amount=37543, loan_payment=5850, loan_interest_rate=0.09, n_loan_periods=10) },
"high-income": { ... },
}

Write tests in tests/test_api.py using httpx TestClient. Tests must:

  1. Smoke test — hit both endpoints with {"profile_id": "middle-income"}; verify HTTP 200 + 4-section response structure
  2. Summary values — assert better_than_return ≈ 0.0666 (middle-income, interest-only, pytest.approx(abs=0.001))
  3. Year 10 net balance — assert analysis_details.annual_rows[-1].net_lev_bt_bal ≈ 83638 (middle-income, 7% return, pytest.approx(abs=2))
  4. Term loan year 10 — assert analysis_details.annual_rows[-1].net_lev_bt_bal ≈ 29079 (middle-income, 7% return, pytest.approx(abs=2))
  5. Custom inputs — hit /interest-only with a custom loan_profile (different loan_amount); verify response is structurally valid
  6. Invalid profile{"profile_id": "nonexistent"}; verify HTTP 422 or 404

These assertion values come from the sd-math golden fixtures — you can read them directly from:

  • packages/sd-math/tests/fixtures/Interest-only/interest-only-ca.json (fixture io-ca-35pct-7pct-annual, year 10)
  • packages/sd-math/tests/fixtures/Term-loan/term-loan-ca.json (fixture tl-ca-35pct-7pct-annual, year 10)

  1. pyproject.toml + venv setup
  2. schemas/inputs.py — Pydantic models mirroring sd-math inputs
  3. schemas/outputs.py — Pydantic models mirroring sd-math outputs
  4. profiles.py — predefined profile registry
  5. main.py — FastAPI app skeleton
  6. routers/projections.py — endpoint stubs returning 501
  7. tests/test_api.py — failing tests (red)
  8. Implement interest-only endpoint (green)
  9. Implement term-loan endpoint (green)
  10. All tests pass → signal complete

  • Deployment to any platform
  • Authentication or rate limiting
  • RRSP strategy (not yet in sd-math)
  • US country support
  • Any changes to packages/sd-math/
  • SvelteKit UI changes (separate Claude task)

Signal complete when:

  • pytest shows all tests passing (minimum 6 tests)
  • uvicorn main:app starts without error
  • Both endpoints respond to {"profile_id": "middle-income"} with correct values

Paste the pytest summary here when done.