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: never git commit unless 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 in docs/API.md and exposed in Swagger. No DB schema change, no new ingest job, no US Bank feed change — pure read-side computation over already-ingested dailyEtfDs daily 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 / marketPriceReturn are percentages, 2 dp (Math.round((end/start - 1) * 10000) / 100); null when either value is unparseable or start <= 0.
    • asOfDate = date key of the latest data row (null if no usable row).
    • baselineDate is only present when the earliest data row’s rateDate postdates the configured inception (incomplete feed). When present, a logger.warn is emitted server-side.
  • 404 — no daily rows for the ticker (earliest is null).
  • Calc lives in src/calc/since-inception.tscomputeSinceInceptionReturn(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).
    • quarter string format: "Q2 2026".
    • Quarter bucketing is UTC: quarter = floor(getUTCMonth() / 3) + 1, year = getUTCFullYear().
    • Bucket assignment: value > 0 ⇒ premium, value < 0 ⇒ discount, exactly 0 ⇒ neither bucket (the two pct fields can sum to < 100 — by design).
    • pctDaysAtPremium / pctDaysAtDiscount = round2((count / tradingDays) * 100).
    • isCurrentQuarter = bucket matches quarterOf(now).
  • 200 [] — rows exist for the ticker but none are usable (no rateDate, or unparseable premiumdiscountPercentage).
  • 404 — no rows at all for the ticker.
  • Dedup: one entry per date key (toISOString().slice(0,10)), keeping the row with the latest createdAt (rows lacking createdAt lose to any row that has one).
  • Calc lives in src/calc/premium-discount.tsaggregatePremiumDiscountByQuarter(rows, now).

File layout (as built)

FileRole
src/calc/parse-number.tsparseLooseNumber(raw) — strips $ , % and whitespace via /[$,%\\s]/g, returns null for '', '-', non-finite, or nullish. Shared by both calcs.
src/calc/dates.tstoDateKey(d)d.toISOString().slice(0, 10). Shared.
src/calc/since-inception.tscomputeSinceInceptionReturn + SinceInceptionDailyRow / SinceInceptionResult interfaces. Pure — no Prisma/Fastify imports.
src/calc/premium-discount.tsaggregatePremiumDiscountByQuarter + PremiumDiscountDailyRow / QuarterSummary interfaces. Pure.
src/http/route-schema.tsShared Swagger fragments: standardErrorResponses (400/404/500 envelopes) and tickerParamSchema.
src/http/computed-routes.tsHand-written Fastify routes: tickerRoute(name, handler) wrapper, the two handlers, their full schemas, and registerComputedRoutes(httpCtx).
src/http/server.tsCalls registerComputedRoutes(httpCtx) alongside the existing TrackedTabularDataSource / route-generator.ts framework (line ~16) — additive, replaces nothing.
vitest.config.tsinclude: ['src/**/*.test.ts']; logging silenced.
src/calc/*.test.ts, src/http/computed-routes.test.ts32 tests across 5 files.
docs/API.mdDocuments both endpoints.

tickerRoute wrapper (DRY)

computed-routes.ts factors the boilerplate into tickerRoute(routeName, handler):

  • extracts request.params.ticker,
  • await handler(ticker),
  • result == null404 { 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 findFirst calls, both where: { fundTicker: ticker, rateDate: { not: null } }:
    • earliest: orderBy: [{ rateDate: 'asc' }, { createdAt: 'desc' }]
    • latest: orderBy: [{ rateDate: 'desc' }, { createdAt: 'desc' }]
  • premium-discount/quarterly — one findMany, where: { fundTicker: ticker }, orderBy: [{ rateDate: 'asc' }, { createdAt: 'asc' }]; dedup/bucketing happens in the calc, not the query. now passed as new 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.ts uses a vi.hoisted mock factory + vi.mock('../util/prisma'), then drives the real Fastify app via app.inject to assert status codes and bodies end-to-end.
  • Per-task completion gate during this work was pnpm test green (it replaced the usual “commit” step, since project policy forbids auto-commits).

Pre-existing unrelated TS errors

npx tsc --noEmit is clean except for pre-existing errors in src/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 existing ETF Performance tag.
  • Response schemas in computed-routes.ts spread ...standardErrorResponses from route-schema.ts.
  • docs/API.md documents both endpoints.

Standing project rules (reaffirmed by this work)

  • No comments in code files.
  • Never git commit unless explicitly asked — all this is uncommitted.
  • DRY — hence parse-number.ts / dates.ts / tickerRoute / route-schema.ts shared pieces.
  • Subagent-driven development.