As-built record of the two new computed read-only ETF endpoints in bankofus / GSR Transformer. The why was decided earlier in 2026-05-11 Computed performance endpoints; this note captures what shipped: file layout, route contracts, query patterns, the vitest setup, and the one Prisma typing gotcha. Implemented, reviewed (spec + code quality per-task + a final whole-implementation review — ship-ready, no issues), pnpm test 32/32 green, pnpm build clean.
For Agents
All changes are uncommitted in the working tree at
/Users/levander/coding/scharge/bankofus(project policy: nevergit commitunless asked). Plan:docs/plans/2026-05-11-computed-performance-endpoints-plan.md. Design:docs/plans/2026-05-11-computed-performance-endpoints-design.md. Both endpoints are documented indocs/API.mdand exposed in Swagger. No DB schema change, no new ingest job, no US Bank feed change — pure read-side computation over already-ingesteddailyEtfDsdaily data.
What shipped
Endpoint 1 — GET /etf/:ticker/performance/since-inception
Daily-fresh cumulative since-inception return (NAV and market price). Fund inception date 2026-04-22, configurable via FUND_INCEPTION_DATE env (default constant DEFAULT_INCEPTION_DATE = '2026-04-22' in computed-routes.ts).
- 200 —
{ ticker, fundName, inceptionDate, baselineDate?, asOfDate, navReturn, marketPriceReturn }navReturn/marketPriceReturnare percentages, 2 dp (Math.round((end/start - 1) * 10000) / 100);nullwhen either value is unparseable orstart <= 0.asOfDate= date key of the latest data row (nullif no usable row).baselineDateis only present when the earliest data row’srateDatepostdates the configured inception (incomplete feed). When present, alogger.warnis emitted server-side.
- 404 — no daily rows for the ticker (
earliestis null). - Calc lives in
src/calc/since-inception.ts→computeSinceInceptionReturn(earliest, latest, inceptionDate). No distribution adjustment (fund has paid none).
Endpoint 2 — GET /etf/:ticker/premium-discount/quarterly
Per-calendar-quarter (UTC) premium/discount summary, from inception through the current quarter.
- 200 — array of
{ quarter, tradingDays, daysAtPremium, daysAtDiscount, pctDaysAtPremium, pctDaysAtDiscount, isCurrentQuarter }, sorted ascending by (year, quarter).quarterstring format:"Q2 2026".- Quarter bucketing is UTC:
quarter = floor(getUTCMonth() / 3) + 1,year = getUTCFullYear(). - Bucket assignment:
value > 0⇒ premium,value < 0⇒ discount, exactly0⇒ neither bucket (the two pct fields can sum to < 100 — by design). pctDaysAtPremium/pctDaysAtDiscount=round2((count / tradingDays) * 100).isCurrentQuarter= bucket matchesquarterOf(now).
200 []— rows exist for the ticker but none are usable (norateDate, or unparseablepremiumdiscountPercentage).- 404 — no rows at all for the ticker.
- Dedup: one entry per date key (
toISOString().slice(0,10)), keeping the row with the latestcreatedAt(rows lackingcreatedAtlose to any row that has one). - Calc lives in
src/calc/premium-discount.ts→aggregatePremiumDiscountByQuarter(rows, now).
File layout (as built)
| File | Role |
|---|---|
src/calc/parse-number.ts | parseLooseNumber(raw) — strips $ , % and whitespace via /[$,%\\s]/g, returns null for '', '-', non-finite, or nullish. Shared by both calcs. |
src/calc/dates.ts | toDateKey(d) — d.toISOString().slice(0, 10). Shared. |
src/calc/since-inception.ts | computeSinceInceptionReturn + SinceInceptionDailyRow / SinceInceptionResult interfaces. Pure — no Prisma/Fastify imports. |
src/calc/premium-discount.ts | aggregatePremiumDiscountByQuarter + PremiumDiscountDailyRow / QuarterSummary interfaces. Pure. |
src/http/route-schema.ts | Shared Swagger fragments: standardErrorResponses (400/404/500 envelopes) and tickerParamSchema. |
src/http/computed-routes.ts | Hand-written Fastify routes: tickerRoute(name, handler) wrapper, the two handlers, their full schemas, and registerComputedRoutes(httpCtx). |
src/http/server.ts | Calls registerComputedRoutes(httpCtx) alongside the existing TrackedTabularDataSource / route-generator.ts framework (line ~16) — additive, replaces nothing. |
vitest.config.ts | include: ['src/**/*.test.ts']; logging silenced. |
src/calc/*.test.ts, src/http/computed-routes.test.ts | 32 tests across 5 files. |
docs/API.md | Documents both endpoints. |
tickerRoute wrapper (DRY)
computed-routes.ts factors the boilerplate into tickerRoute(routeName, handler):
- extracts
request.params.ticker, await handler(ticker),result == null→404 { error: 'No data found for the given parameters' },- thrown error →
logger.error+500 { error: 'Internal server error', message }, - otherwise
200 result.
Each handler returns the body or null (for 404). Note this means 200 [] (premium/discount with no usable rows) is distinct from 404 (no rows) — the findMany returning a non-empty array short-circuits the null check before the calc runs.
Query patterns
All DB access via dynDispatchPrismaCall('dailyEtfDs', method, args) (the reflection helper in src/util/prisma.ts). :ticker maps to the fundTicker column.
- since-inception — two
findFirstcalls, bothwhere: { fundTicker: ticker, rateDate: { not: null } }:- earliest:
orderBy: [{ rateDate: 'asc' }, { createdAt: 'desc' }] - latest:
orderBy: [{ rateDate: 'desc' }, { createdAt: 'desc' }]
- earliest:
- premium-discount/quarterly — one
findMany,where: { fundTicker: ticker },orderBy: [{ rateDate: 'asc' }, { createdAt: 'asc' }]; dedup/bucketing happens in the calc, not the query.nowpassed asnew Date().
Gotcha — Prisma optional Date typing
Prisma’s result type for the optional rateDate column is Date | null | undefined, which is not assignable to the calc interfaces’ params (rateDate: Date | null). Resolved by casting the query results to the target interfaces at the call site in computed-routes.ts:
earliest as SinceInceptionDailyRow
latest as SinceInceptionDailyRow
rows as PremiumDiscountDailyRow[]The calc functions themselves guard for falsy rateDate internally (skip the row / return the null-filled result), so the cast is sound.
Testing
- vitest 4.1.6 added — first test runner in the repo.
pnpm test=vitest run. - 32 tests / 5 files:
parse-number.test.ts,dates.test.ts,since-inception.test.ts,premium-discount.test.ts,computed-routes.test.ts. computed-routes.test.tsuses avi.hoistedmock factory +vi.mock('../util/prisma'), then drives the real Fastify app viaapp.injectto assert status codes and bodies end-to-end.- Per-task completion gate during this work was
pnpm testgreen (it replaced the usual “commit” step, since project policy forbids auto-commits).
Pre-existing unrelated TS errors
npx tsc --noEmitis clean except for pre-existing errors insrc/util/ftpclient.ts(missing@types/ssh2-sftp-client). Not introduced by this work; ignore them.
Swagger / docs
- New Swagger tag
ETF Premium/Discount(the quarterly endpoint); since-inception sits under the existingETF Performancetag. - Response schemas in
computed-routes.tsspread...standardErrorResponsesfromroute-schema.ts. docs/API.mddocuments both endpoints.
Standing project rules (reaffirmed by this work)
- No comments in code files.
- Never
git commitunless explicitly asked — all this is uncommitted. - DRY — hence
parse-number.ts/dates.ts/tickerRoute/route-schema.tsshared pieces. - Subagent-driven development.
Related
- 2026-05-11 Computed performance endpoints — the design / decisions this implements (read for the why: backend-computed not US-Bank-sourced, exact-zero excluded by design, units in percentage points, deliberately outside the
TrackedTabularDataSourceroute framework, follow-ups) - Agent Context (GSR Transformer)
- GSR Transformer
- 2026-05-04 Performance CSV ingest fix — the daily NAV CSV plumbing (
dailyEtfDs) these endpoints read from