← Back to Blog

The Aquarium That Became a Trading Floor

It started as a screensaver joke. I publish a ranked stock board, the public output of a private engine that grades stocks every night, and one evening I fed it into an aquarium: every stock a fish, sized by its composite score (the engine's one-number grade), swimming faster the more it moved that day. Green fish drifting, red fish darting. Completely useless, and I loved it, because it made a point no table ever had: the board has body language. A calm market looks calm. A rotation looks like schools of fish changing direction.

The joke kept earning screen time, so I rebuilt it into the display I now actually leave running on a wall: Market APEX, six views over the same public board data. Three decisions ended up carrying the whole build, and they are the ones worth writing down. The app lives in a single HTML file on purpose. Nothing is fetched live, because the browser is not allowed to fetch it. And the feature that finally made it work on a phone is not a control at all. It is the URL.

Six lenses at a glance#

Lens What you are looking at When to open it
TAPE the trading year replayed as a fifteen-second race of cumulative returns to feel who actually led the year, not just who ended ahead
FIELD valuation against momentum, the cheap-and-rising corner marked as the kill zone to hunt names getting better while still unloved
PLOT any factor against any factor, sized by a third when you have a hypothesis the fixed views will not hold
WEB a force graph where stocks pull together by correlation (how much they move together) to watch sectors clump, and to spot what moves alone
GRID every factor for every name, a sortable heatmap when you want the spreadsheet after all
MAP a sector treemap colored by the year's return one glance at where the year's money went

TAPE is the one people stop for. Sixty-some lines sweep left to right as the year replays, leaders pulling away, the worst name labeled in red at the back of the pack. The quiet work is in the axis: a gold miner up 400% and a laggard down 50% have to share one readable scale, so returns pass through a signed square root before they become pixels. The race stays legible at both ends without lying about either.

All of it is one <canvas> and a requestAnimationFrame loop, under six hundred lines, no chart library. At this size a library mostly adds indirection, and the parts that make the board feel alive, the axis compression, the force layout clumping correlated names, the autopilot that dwells on one stock and narrates it, are exactly the parts I would be fighting a library to control.

One file, on purpose#

The app styles body, defines its own buttons, hides scrollbars. It behaves like it owns the page, because on the wall it does. Dropping that into a Next.js site would mean scoping hundreds of selectors or watching the board's styles bleed into the site chrome, so the site embeds it as a same-origin iframe instead. That iframe is not a shortcut I am apologizing for. It is a contract: everything inside the file is the app's business, everything outside is the site's. Three rounds of feature work later, the boundary has not leaked once.

The build left one scar worth showing. The first shipped version had a first-frame race: the animation loop started before the data arrived, the draw call threw on an empty array, and because the next frame was scheduled after the draw, one exception ended the animation forever. A black rectangle, no visible errors, perfectly good data underneath. The fix is two lines, re-register the frame first, then guard the empty state. The rule it taught generalizes: in an animation loop, schedule the next frame before you do anything that can throw, or your loop has exactly one bug's worth of lifespan.

Baked, not fetched#

The board wants a year of daily closes for every name it draws. A page running in your browser cannot simply ask a market data service for that history; browsers enforce cross-origin rules, and quote APIs refuse requests arriving from strangers' web pages. And I was not going to stand up a proxy relaying a market feed for a toy.

So nothing is fetched live. A small job runs once a day and bakes one JSON file: factor scores from the already-published board, a year of closes per symbol compressed into a 24-point sparkline, the day, week, and year moves precomputed. One static file behind the CDN, cached for fifteen minutes, reloaded quietly in the background while the board runs.

Everything else is derived on arrival. The regime banner (market breadth, meaning how many names are up on the year, plus median momentum), the hunt-list ranking, and the full pairwise correlation matrix behind WEB all get computed client-side from that one file. The matrix sounds expensive and is not: ninety names by 24 points is a few milliseconds on load. Bake what the browser cannot reach; compute what it can. The bundle stays small, and the client stays free to grow new lenses without touching the pipeline.

The URL is the feature#

On the wall, density is the point: sixty names racing, hover tooltips, six lenses a click apart. On a phone, density is the enemy. So the mobile version is not a smaller board. It is an inversion: pick first, then look. A chip picker sits above the stage, you tap in a ticker, and TAPE races just that name, with a card showing its price, moves, and factor scores. Add more and they overlay as a comparison race, each line labeled, the axis rescaled to whatever you picked.

Selection is state, and state that small belongs in the URL. /play/market-apex?symbols=NVDA,MSFT opens the board mid-comparison, and every chip you add or remove is written back with history.replaceState, so the address bar is always a shareable snapshot of exactly what you are looking at.

One implementation detail pleased me out of proportion. The app runs inside that same-origin iframe, and the query string arrives on the parent page's URL, not the iframe's. The obvious fix was to teach the site's route to forward its query string down into the iframe. The better fix was to teach the app it might be embedded: when its own URL carries no symbols it reads the parent page's (same origin, so the browser permits it), and when your selection changes it syncs both URLs. The site's route component did not change by a single character. A same-origin iframe is a styling boundary, not an information boundary, and you can lean on that.

The links compose in ways a widget never could. My private alarm engine now attaches a link to every critical push, so a stop alert on my phone opens directly into that ticker's year. A private daily digest builds a multi-ticker link the same way and gets a comparison race. The seam holding all of it together is the same one from my 3D surfaces: the public page cannot tell where its symbols came from, because symbols only ever arrive at runtime in the URL, and the published data file contains none of them. The privacy rule is not a policy I have to remember during review. There is simply no path in the data for anything private to travel.

That seam forced the last design call. The curated board is about ninety names, the engine's picks plus a watch set built from public, neutral rules (megacaps, plus the most-watched tickers by public attention data). But a deep link should resolve wider than ninety names, so the daily bake ships a second tier: every remaining name from the public attention panel, about 250 symbols in all, in the same record shape. The lenses never draw the second tier, which keeps the wall display curated, but the picker resolves from both. A name without factor coverage still resolves as a price-only card that says so, and a symbol the bundle has never heard of gets a dashed "not tracked" chip instead of silently vanishing. Honest at every layer, and neutral by construction.

The takeaway#

The aquarium made the board legible. The workstation made it navigable. The URL made it fit in a pocket. One file, because an isolation boundary you can point at is a contract you can trust. Baked data, because the browser could never fetch it and never needed to. And a query string, because the right mobile interface for a dense market board turned out not to be a smaller board. It is a link you can text to someone: bring your own tickers and race them.


Related:

Keep reading

Written by Eric Caskey. I build AI tools you can actually use. Explore the Tools or see the case studies.