Command Palette

Search for a command to run...

Projects / Market (Ticker) / Overview

Market (Ticker)

A self-hosted, production-style market-data platform — one multiplexed feed, Redis-coordinated ingestion, TimescaleDB candles, computed indicators, and a live SSE dashboard.

Year
2026
Role
Solo
Focus
Backend · Infra
Status
Live
GoRedisTimescaleDBSSEDockerAWSTerraform

Problem

This portfolio needed to prove something a static site can't: that I can build and run a real distributed backend, not just talk about one. The constraint was specific — live-feeling data, sub-second updates, near-zero marginal cost, no user accounts, and honesty whenever the data behind it isn't a real market feed. A dashboard that quietly fakes numbers is worse than no dashboard at all.

Approach

  • A pluggable MarketSource interface decouples the pipeline from where ticks actually come from. v1 runs a simulated source (four regime symbols, five seeded cost-of-living cities); a real Finnhub adapter drops in later behind the same interface with no pipeline changes.
  • Ticks flow through a Redis Stream rather than an in-process channel, with a SET NX PX leader lease gating who's allowed to ingest. At v1's scale — one instance — the lease is non-load-bearing, but it's built for the horizontal-scale case on purpose, not retrofitted later.
  • An aggregator maintains OHLCV candles across five intervals at once (a configurable finest window, plus a fixed 5m/15m/1h/1d set), writing on every tick rather than on candle close so a server-rendered page always sees the live bar rather than a stale one.
  • Because a Redis Stream consumer group promises at-least-once delivery, processing is deduplicated by stream ID before anything accumulates, and candle writes are full-state upserts. High, low and close survive reprocessing on their own; volume is the one field that doesn't, which is exactly why the dedupe sits in front of it rather than relying on the upsert alone.
  • EMA and Wilder-smoothed RSI(14) are computed on read and cached in Redis, keyed per symbol, interval, and requested indicator set.
  • The browser never talks to the Go API directly. A single Next.js route proxies the SSE stream same-origin, so there's no public backend, no CORS, and no auth surface to manage.
  • On SIGTERM, the aggregator flushes every in-flight candle to Timescale and the ingestion loop releases its leader lease before the process exits, so a deploy hands over rather than dropping whatever was mid-flight.

Latency

End to end — from a tick arriving upstream to the candle repainting in the browser. Same-origin SSE keeps the network hop honest; compute is a rounding error next to it.

22 ms
tick → UI · p50
68 ms
tick → UI · p99
1,200 msg/s
ingest · peak
250
concurrent SSE clients
Sim sourceIngest · GoRedisSSE hubBrowserticknormalize + Redis lease +3 msXADD ticks +2 msasync · candle → TimescaleDBconsume + build eventevent: candle · SSE +12 msparse + paint +5 msend to end · p50 22 ms · p99 68 ms

One live tick, 1-hour sample. The SSE network hop dominates and the Timescale write forks off the live path. Illustrative pending live telemetry — these become measured numbers when the running system reports its own p50, p99 and throughput.

Results

  • A live dashboard — candlestick chart with an EMA overlay, a real RSI(14) panel, a live markets table, and a cost-of-living series with expense-basket breakdowns — all driven by the same pipeline a real feed would use.
  • Runs alongside the rest of the site on one t4g.small box: Go, Redis, TimescaleDB, Next.js, and Caddy together, effectively $0 marginal infrastructure cost beyond the box that was already there.
  • Every simulated value carries a simulated: true flag end-to-end, from the Go API through to a visible badge in the UI — it can't silently pass itself off as a real market feed.
  • Contract tests run against a real Ticker container in CI, so the frontend's assumptions about the API are checked against the API rather than against a fixture of it.

Lessons

  • The RSI Wilder-smoothing trap is real and well documented: a plain SMA-based RSI looks correct and quietly isn't. Validating against a hand-computed worked example — not just internal consistency — is what actually catches it.
  • Widening a primitive is cheaper than adding a parallel one. When the chart needed a full indicator series rather than a single latest value, the change touched seven files, including the registry contract and both indicator implementations. Nothing downstream broke, because the new shape contained the old one: the latest value became the last element of the series rather than a second thing to maintain.
  • Getting server rendering and a live stream right together is easy to get subtly wrong. Fetching live data from shared code can silently make an entire site render dynamically instead of statically — no error, no warning. It happened twice during the build, once feeding the command palette's symbol list and once the homepage's live-stat pill, and both fixes were the same shape: move the fetch into its own route, or behind an explicit revalidate window.
  • An at-least-once guarantee is something you implement, not something you're handed. A consumer group only redelivers if something claims entries that were read and never acknowledged, and the idempotent write everyone points at protects the write — not the accumulation sitting in front of it.

Go deeper