Convexity is a Vite + React + TypeScript frontend talking to a Python FastAPI backend over a binary wire, with ARQ background workers feeding a SQL store from a hundred-plus feed jobs across twenty-plus named upstreams. None of those choices are interesting on their own. What is interesting is the contract that ties them together, the way data freshness travels from the database row to the pixel a user sees, and the handful of things we got wrong on the way here. This page is about that.
Engineering
Real-time is aplumbing problem.
Here is the plumbing: one request from browser to store, and the contract each hop is required to keep.
Founder pricing holds for life while the waitlist is open.
- 100+live data feeds20+ named sources · none synthetic, none mocked
- <100mswarm-read p95 targettracked on /status
- 30-40%payload reduction on the wiremsgpack vs json
- 1freshness badge, everywhereDataEnvelope[T] contract
The path of a quote
A ticker hits the search bar. Three hundred milliseconds later a Price Chart, KPI rail, peer cohort, Convexity Score, and freshness badge are on screen. Here is what happens in between.
- 1browser
React 18 + TanStack Query
A route loads via
React.lazy;useStockDetail("AAPL")fires. TanStack Query checks its in-memory cache, returns a stale-but-shown payload immediately if present, and kicks a background refetch. - 2axios + msgpack
Binary wire
The request flies as HTTP/2 to FastAPI with an
Accept: application/msgpackheader. Responses come back 30-40% smaller than JSON, decoded into typed objects without a string-parse hop. - 3FastAPI + asyncio
Async-first handler
The route handler is a thin shell over a
services/…/queries.pyfunction. It pulls from Redis first (hot reads never touch the DB), then SQLAlchemy + aiosqlite. Every I/O hop is non-blocking on a single uvicorn worker, with semaphores around upstream calls so we cannot stampede a third-party rate limit. - 4DataEnvelope[T]
One shape, every endpoint
The response is never raw data. It is
{ data, meta }wheremeta.data_as_ofis computed from the rows themselves (MAX(transaction_date)), not fromdatetime.utcnow(). Two requests five minutes apart return the samedata_as_ofif no new rows landed. - 5DataFreshnessBadge
The pixel
Every surface that shows the data renders the same shared badge component. The string "last updated…" appearing anywhere else in the frontend is a review-blocking violation. Consistency is enforced by the component, not by good intentions.
MsgPack, not JSON
JSON is a fine human-readable interchange format and a wasteful machine-to-machine one. Every payload pays for braces, quotes, and key repetition; every parse spends CPU on a string-to-tree walk that does not need to exist between two services that already agree on a schema.
Convexity speaks msgpack on the wire by default. Browsers do the binary decode in a worker, so the main thread does not block. JSON is still available behind a content-type header for anyone debugging with curl.
# JSON -- 214 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 -- 138 bytes (-36%) 82 a6 74 69 63 6b 65 72 a4 41 41 50 4c a5 70 72 69 63 65 cb 40 6b 2b ae 14 7a e1 48 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 38 51 eb 85 1e b8 …
19:58:30.412 tick AAPL 217.42 +0.85% vol=42.18M 19:58:30.488 tick MSFT 421.18 -0.12% vol=18.04M 19:58:30.512 tick NVDA 138.91 +2.31% vol=287.4M 19:58:31.044 reconnect attempt=2 backoff=1.4s jitter=0.21s 19:58:32.661 authed plan=ultimate symbols=482 19:58:32.704 replay missed=14 since=19:58:31.038 19:58:33.118 tick TSLA 251.04 -1.84% vol=98.2M 19:58:36.402 auth_fail code=401 streak=3 19:58:36.403 breaker state=open retry_in=120s 20:00:36.918 breaker state=half-open 20:00:37.221 authed plan=ultimate symbols=482 20:00:37.221 breaker state=closed
The tape, the reconnect, the breaker
The Financial Modeling Prep WebSocket is the primary live-tick channel. Symbols outside the live tape refresh on a two-minute poll, with a client-wide token-bucket rate limiter capping RPS across the FMP REST family.
Reconnects use exponential backoff with jitter and replay missed messages from the last seen sequence, so the client never carries stale state through a disconnect. Persistent auth failures trip a circuit breaker (state machine: closed → open → half-open → closed) that suppresses reconnect storms and surfaces a clean "degraded" pill to the user instead of a busy spinner.
The signal correlation engine consumes the same tape. Cluster detection across insider trades, options flow, and 8-K filings lands on the dashboard signal feed within seconds of the underlying event.
Honest freshness, all the way down
Three UIs rendering the same data must display the same value and the same freshness. Without a contract, three ad-hoc implementations drift, and a sharp user notices in seconds. The contract:
Every ARQ run writes a row to data_sync_log – including failures, sosync_status is always computable from the log instead of inferred. A typical successful run looks like this:
source sec_edgar_form4 started_at 2026-05-24 07:30:00.114 UTC completed_at 2026-05-24 07:32:47.802 UTC status success rows_added 1,284 error null
If the query returns no rows, data_as_of is null, notDate.now(). The envelope still returns. The frontend renders "No data" from the envelope, not a crash state. The whole point is that the timestamp on screen reflects when the data is from, not when the request was made.
Four numbers that did not come from a vendor
- convexity_score Convexity Score
- Capture-ratio asymmetry vs SPY over a trailing 252 days, computed nightly at 03:00 UTC weekdays. Separates names that participate in upside and reject downside from names that do the opposite. Methodology is open at /methodology/convexity-score; production code is gated by
CONVEXITY_SYNC_ENABLEDso we can kill it in one env-var flip if a sync goes sideways. - peer_cohort Peer cohort selection
- Sector peers auto-selected from FMP cohort data with a yfinance fallback when coverage is thin. Cached per-ticker with TTL; nightly refresh keeps the cohort honest as constituents shift. The cache invalidates on data refresh, not on a clock, so users never see a hot-but-stale read.
- 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 within seconds, with the underlying filing one click away.
- point_in_time Point-in-time helpers
- Backtest-safe joins and as-of lookups for any factor work. Salvaged from the Quality Score v1 attempt (more on that in a moment) and reused by every screen and study built since. If a factor cannot be evaluated point-in-time, it does not ship.
Four providers, one interface
Every AI call is recorded by surface, model, and token count in the usage ledger. Per-user monthly caps are enforced server-side; the ledger is what backs the cost-transparency claim on pricing. Routing is intent-based, not provider-loyal:
| Intent | Default model | Fallback | Notes |
|---|---|---|---|
| Chat | claude-sonnet-5 | gpt-5.6-luna | Circuit breaker shifts traffic instantly on provider degrade. |
| Synthesis | gpt-5.6-luna | claude-sonnet-5 | Luna took this lane 2026-08-10; the fallback crosses vendors in both directions. |
| Embeddings | voyage-finance-2 | voyage-3 | RAG over SEC filings and curated news. |
| Search-grounded | sonar-pro | – | Added 2026-05-04, used by the search_analyze tool only. |
| Financial sentiment | finbert | – | Domain-specific. Earnings calls, filings, market commentary. |
| Deep Research | claude-opus-4-8 | – | Pro Plus only, 20 runs/mo. Target ≤ 4.5 search calls per report. |
| Cheap lookups | gpt-5.6-luna | claude-sonnet-5 | Free tier default; prompt cache hits where possible. |
routing.py and tracked through the marketing pricing data, so a model commitment on the site is also a default in the code.By the numbers
Targets, not best-case marketing numbers. Cold reads and external-provider round-trips are slower; we are honest about the difference rather than quoting only the warm path.
| Metric | Target | How |
|---|---|---|
| Warm API read p95 | <100 ms | Redis + in-memory tiers; stale-on-refresh, not stale-on-clock. |
| Cold API read p95 | <800 ms | SQLite + canonical query function; semaphores on upstream calls. |
| Wire payload reduction | 30-40% | MsgPack default; JSON behind a content-type header for debugging. |
| HMR turnaround (dev) | <1 s | Vite, no full-page reloads. |
| Initial JS bundle | per-route | React.lazy + Suspense on every route; shared vendor chunk. |
| Quote cache TTL | 30 s | TanStack Query; fresh enough for active trading without thrash. |
| Loading state | skeletons | Content-shaped placeholders, no spinners. Layout shift = 0. |
Three things we got wrong
Every system has receipts on the things it learned the hard way. Ours, briefly:
- 2026-04-30
The cache-buster we forgot to bump
The Wave 1 envelope migration wrapped a top-level response key inside
datawithout bumpingbusterinqueryClient.ts. Persisted React Query caches hydrated the old shape against new readers and TerminalDashboard crashed on first render for every returning user, withTypeError: Cannot read properties of undefined (reading 'total_count'). Fixed ineeacde3; the rule is now in the data-contract review checklist. Bump the buster in the same commit that changes the shape, or the change is not done. - 2026-05-07
The factor that did not work
Quality Score v1 came back negative in backtest: top-minus-bottom spread of
-0.7%at four quarters,-3.1%at eight,-1.9%at twelve. We closed the pull request unmerged and shipped what was actually useful – the point-in-time helpers – in a follow-up. The lesson was not "factors are hard"; the lesson was that the cost of shipping a score that looks plausible and underperforms quietly is much higher than the cost of killing a PR. - 2026-05-01
The disk that filled up
Five retained release directories at 1.2 GB each, plus three stale partials from failed deploys that the deploy script was not pruning, took a 96 GB VPS volume to 100% on a weekday morning. Recovery was
sudo rm -rfon the failed partials first, then the old successful releases. The deploy script now prunes its own partials and we keep three releases, not five. Cheap fix. The real fix was treating the disk as a feature, not a constant.
The open-source shoulders we stand on
We did not build a programming language, an HTTP framework, or an encryption library. We built a product on top of people who did. Here is the short list of the projects that matter most, and where we plan to contribute back.
- Every async route, every middleware, every typed response.
- The binary wire format that makes the 30-40% payload reduction free.
- ORM + driver. Every
data_sync_logrow, every canonical query. - Background job runner for every sync, every cron, every backfill.
- Password hashing and field encryption. The two crypto primitives we trust.
- Frontend server-state + dev server. Every cached fetch, every sub-second HMR cycle.
- This marketing site runs on it.
asyncpg – we maintain a fork-only patch for Postgres advisory locks in the ARQ semaphore wrapper that should land in main. Once shipped, the list of patches we carry locally will be linked here with commits.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.
See open roles