Skip to content
Convexity

Engineering

How the terminal is built

Real-time is a plumbing problem.

Here is the plumbing: from the feeds to the store to your screen, and the contract each joint keeps.

  1. 01BrowserReact 18 + TanStack QueryStale times tiered by data, 15s to 1h
  2. 02APIFastAPI + msgpackStored rows ship inside a DataEnvelope
  3. 03WorkersARQ + RedisOne sync-log row per run, success or not
  4. 04StorePostgres, Alembic-only schemaFreshness derived from rows, never the clock

A React frontend, a FastAPI backend, a binary wire between them, background workers and Postgres. None of that is unusual on its own.

This page is about the contract that ties them together: how a row’s freshness travels from the database to the pixel, and what we got wrong on the way.

01: REQUEST LIFECYCLE

The path of a quote

A ticker hits the search bar, and a Price Chart, KPI rail, Convexity Score, and freshness badge come up on screen. Here is what happens in between.

  1. 1browser

    React 18 + TanStack Query

    A route loads via React.lazy and useComprehensive("AAPL") fires. TanStack Query shows a cached payload at once if it holds one, and refetches in the background.

  2. 2axios + msgpack

    Binary wire

    The request leaves the browser over HTTP/2 with an Accept: application/msgpack header. Responses come back as msgpack, decoded into typed objects without a string-parse hop.

  3. 3FastAPI + asyncio

    Async-first handler

    A thin handler calls a services/… function, which reads Postgres (SQLAlchemy, asyncpg) and then Redis for the live quote, without blocking. One token bucket in Redis paces FMP calls across prod’s four uvicorn workers.

  4. 4DataEnvelope[T]

    The envelope

    A response is { data, meta }, and meta.data_as_of is computed from the rows (MAX(transaction_date)), never from datetime.utcnow(). Two requests five minutes apart agree unless a row landed.

    The ticker page’s company payload and its quote are not wrapped.

  5. 5DataFreshnessBadge

    The pixel

    Every surface that shows enveloped data is required to render the one shared badge. “Last updated…” anywhere else in the frontend is a review-blocking violation.

02: THE WIRE

MsgPack, not JSON

JSON is fine for people and wasteful between machines: every payload repeats braces, quotes and keys, and every parse walks a string into a tree that two services sharing a schema do not need.

The terminal asks for msgpack by default. Any client that does not ask, curl included, gets JSON.

msgpack/msgpack-javascript · spec

A quote-shaped object: encoded bytes
# JSON, compact  --  198 bytes
{"ticker":"AAPL","price":217.42,"change":1.84,
 "change_pct":0.85,"volume":42180000,
 "ts":"2026-05-24T19:58:31.412Z",
 "meta":{"data_as_of":"2026-05-24T19:58:30Z",
         "sync_status":"healthy","source":"fmp"}}

# MsgPack  --  173 bytes (-13%)
87 a6 74 69 63 6b 65 72  a4 41 41 50 4c a5 70 72
69 63 65 cb 40 6b 2d 70  a3 d7 0a 3d a6 63 68 61
6e 67 65 cb 3f fd 70 a3  d7 0a 3d 71 aa 63 68 61
6e 67 65 5f 70 63 74 cb  3f eb 33 33 33 33 33 33
…
03: THE DATA CONTRACT

Honest freshness, all the way down

Three surfaces showing the same data must show the same value and the same freshness. Without a contract they drift, and a sharp user notices in seconds. The contract:

upstream
FMP, SEC EDGAR, FRED, …
100+ feeds · 20+ named sources
arq job
background worker
writes data_sync_log
sql store
canonical query fn
one per data source
api
DataEnvelope[T]
data + meta, always
ui
DataFreshnessBadge
one component, every surface

Every sync run is required to write a row to data_sync_log, failures included, so sync_status is computed from the log instead of inferred. A successful run looks like this:

data_sync_log: one sec_edgar_form4 row, 2026-09-29
source           sec_edgar_form4
started_at       2026-09-29 16:30:01.047 UTC
completed_at     2026-09-29 16:30:30.610 UTC
status           success
rows_added       67
error            null

No rows means data_as_of is null, not Date.now(), and the envelope still returns, so the frontend shows “No data” instead of a crash. The time on screen is when the data is from, not when the request was made.

04: THE MATH WE OWN

Four numbers that did not come from a vendor

convexity_score Convexity Score

A six-feature Ridge composite (momentum, insider net flow, smart-money positioning, low-vol beta, gamma, revenue-growth acceleration) forecasting the sector-neutral 180-day forward return.

It refits weekday nights at 03:00 UTC for large caps above a $25B floor. Every published vintage is immutable and scored against realized returns as its window matures.

The methodology is open at /methodology/convexity-score, and CONVEXITY_SYNC_ENABLED turns production off in one flip.

peer_cohort Peer cohort selection
Sector peers auto-selected from FMP cohort data. Cached per-ticker with TTL; nightly refresh keeps the cohort honest as constituents shift.
cluster_detection Insider + congressional clusters
Form 4 buys and STOCK Act PTRs are scored for cluster significance – multi-insider, multi-day, role-weighted. Officer-grade purchases outweigh 10b5-1 plan sales. Clusters land on the signal feed.
point_in_time Point-in-time helpers
Backtest-safe joins and as-of lookups, salvaged from the failed Quality Score v1 (below) and reused by every screen and study since. A factor that cannot be evaluated point-in-time does not ship.
05: AI ROUTING

Four providers, one interface

Every AI call goes into the usage ledger by surface, model and token count. The ledger backs the cost claims on pricing, and per-user monthly caps are enforced server-side. Routing is by intent, not by provider:

IntentDefault modelFallbackNotes
Chatclaude-sonnet-5-5gpt-6-lunaIts own lane since 2026-09-29. Circuit breaker shifts traffic instantly on provider degrade.
Synthesisgpt-6-lunaclaude-sonnet-5Luna took this lane 2026-08-10, gpt-6-luna since 2026-09-28; the fallback crosses vendors in both directions.
Embeddingsvoyage-4-large–RAG over SEC filings, call transcripts and press releases; queries embed with voyage-4-lite in the same space.
Search-groundedsonar-pro–Added 2026-05-04, used by the search_analyze tool only.
News sentimentgpt-6-lunaclaude-haiku-5-5One label per ingested headline, stored with the model that gave it. The fallback since 2026-10-07, measured level with luna on headlines.
Deep Researchclaude-opus-5-5–Pro Plus only, 20 runs/mo. Target ≤ 4.5 search calls per report.
Cheap lookupsgpt-6-lunaclaude-sonnet-5Free tier default; prompt cache hits where possible. A failed chat turn falls back to claude-haiku-5-5 instead.
Provider allow-list: Anthropic, OpenAI, Voyage, Perplexity. No Gemini. The list is enforced in routing.py and tracked through the marketing pricing data, so a model commitment on the site is also a default in the code.
06: PERFORMANCE

By the numbers

MetricTargetHow
Initial JS bundleper-routeReact.lazy + Suspense on every page except sign-in, activation and 404.
Loading stateskeletonsContent-shaped placeholders by default; some surfaces still show a spinner or plain text.
07: POSTMORTEMS

Three things we got wrong

Every system has receipts on the things it learned the hard way. Ours, briefly:

  1. 2026-04-30

    The cache-buster we forgot to bump

    The Wave 1 envelope migration wrapped a response key inside data without bumping the cache buster in queryClient.ts, so every returning user’s cache still held the old shape.

    TerminalDashboard crashed on first render with TypeError: Cannot read properties of undefined (reading 'total_count'). Fixed in eeacde3, and now a shape change and its cache bust land in one commit.

  2. 2026-05-07

    The factor that did not work

    Quality Score v1 came back negative in backtest: a top-minus-bottom spread of -0.7% at four quarters, -3.1% at eight and -1.9% at twelve. We closed the pull request unmerged and shipped the useful part, the point-in-time helpers, on its own.

    A score that looks plausible and quietly underperforms costs far more than a killed PR.

  3. 2026-05-01

    The disk that filled up

    Five retained releases at 1.2 GB each, plus three partials from failed deploys the script never pruned, took a 96 GB volume to 100% on a weekday morning. We cleared the partials first, then the old releases.

    The deploy script now prunes its own partials and keeps three releases, not five. The real fix was treating the disk as a feature, not a constant.

08: WHAT WE BUILD ON

The open-source shoulders we stand on

We did not build a language, an HTTP framework or an encryption library. We built on the people who did, and these are the projects that matter most.

If this is the kind of plumbing you like

We are looking for engineers who care about real-time systems, financial data, and shipping fast.