Benjamin ThaiSoftware Engineer · ML
← Projects
AI / ML2026Complete

AI Trading Workstation

A trading terminal with streaming prices, a simulated portfolio, and an AI copilot that executes trades through structured outputs instead of just describing them.

Next.js 16FastAPIPythonSSELiteLLM
01THE PROBLEM

A chat box bolted onto a dashboard can describe your portfolio. It can't change it.

Trading interfaces are dense because they have to be, with a watchlist, a chart, positions, and an order ticket all live at once. The usual way to add AI to one is a sidebar that answers questions about whatever you're looking at. It reads the screen and explains it. Every suggestion still ends with the user finding the right field and typing the numbers in themselves.

This project was built around the opposite premise: give the assistant the same powers the interface has. “Sell my NVDA” should move shares, not explain which button moves them. That changes what the model is allowed to return, since prose can't be executed, and it raises the question the rest of the build is really about, which is what happens when a language model's output is wired directly to something that spends money.

02SCOPE

What the workstation has to get right.

Live

Prices move on their own, and everything derived from them (sparklines, positions, the heatmap, total portfolio value) moves with them off a single stream rather than each panel polling for itself.

Executable

The assistant returns a typed schema, not a paragraph. Trades and watchlist edits come back as structured actions the backend can validate and run without parsing English for intent.

Safe

An auto-executed trade goes through the identical validation as one placed by hand, whether that is an unknown ticker, not enough cash, or not enough shares. A rejection surfaces in the transcript as a failed action instead of a silent no-op.

Everything in the demo is mock data. Prices come from a built-in geometric Brownian motion simulator seeded with realistic starting values, and the portfolio is $10,000 of virtual cash. No broker, no real money, no live quotes anywhere on screen. Real market data is a supported mode rather than a missing one: set a MASSIVE_API_KEY (Polygon.io) and the backend swaps the simulator for a REST poller behind the same interface, and nothing else in the system changes.

03THE APPROACH

One price cache, and one path to a filled order.

Whichever source is active writes into a single in-memory price cache, and everything downstream reads from it: the SSE stream feeding the browser, portfolio valuation, and trade execution. Because the cache is the only thing the rest of the system knows about, swapping simulated prices for real ones is a factory decision made once at startup, and no consumer is aware it happened.

The copilot is deliberately not a parallel implementation. It calls the same portfolio and watchlist services the buttons call, so there is exactly one place where a trade is validated and filled, and the assistant inherits every rule that path already enforces rather than reimplementing them in a prompt.

It's the capstone for an agentic AI coding course, so the build ran the same way it reads: specialized coding agents working against a shared planning directory, with the interfaces above written down as contracts before the code that filled them in.

04HOW IT WORKS

Three layers, one shared cache between them.

01 · STREAM

Prices into a versioned cache

The active source (the GBM simulator by default, a Massive/Polygon poller when a key is set) writes ticks into a thread-safe cache that bumps a version counter on every update.

02 · SERVE

SSE to every panel

One endpoint pushes the whole price map to the browser whenever the version changes, with a retry directive so EventSource reconnects itself. Sparklines accumulate client-side from the stream.

03 · ACT

Orders through one service

Manual tickets and copilot-declared trades both call the same execute_trade, filled at the cached price against the same cash and share checks.

05INSIDE A COPILOT TURN

From a sentence to a filled order.

Every message the user sends runs this pass. The interesting part is that steps three onward are indistinguishable from a click on the buy button.

01

Assemble the turn's context

The current portfolio snapshot, the watchlist, and the last twenty messages of history are gathered and built into the prompt. The model is told what the user actually holds rather than being asked to remember it, so “sell half my tech” resolves against real positions.

02

Constrain the model to a schema

The LLM is bound to a Pydantic shape: a message plus a list of trades and a list of watchlist changes. The response is parsed straight into it. Sides are restricted to buy or sell, quantities must be positive, and tickers are normalized on the way in. Anything that doesn't fit the schema fails at the boundary rather than deeper in.

03

Execute each declared action through the service layer

Every trade in the list is run through the same portfolio service the order ticket uses, and each watchlist change through the same watchlist service. There is no separate AI code path to keep in sync, which means the assistant cannot buy something a human couldn't.

04

Report per-action outcomes, not a per-turn verdict

Each action comes back individually as executed or failed with its own reason and fill price, because a batch is rarely all-or-nothing. Ten fills and one rejection for insufficient cash is a normal turn. The whole set is persisted alongside the assistant's message, so reopening the chat shows what actually happened rather than what was proposed.

05

Let the stream repaint the screen

Nothing refetches. The trade changed the portfolio, the next SSE frame carries the prices, and positions, the heatmap, and the value chart re-derive themselves, so a trade placed by the copilot animates exactly like one placed by hand.

06INTERFACE DECISIONS

Choices behind the terminal.

Simulated prices are the default, real ones are an env var
Anyone who clones the repo gets a moving market with no API key, no rate limit, and no market-hours dependency. Real data lives behind the same abstract interface, so choosing it is a factory branch at startup rather than a second implementation of everything downstream.
Correlated random walks, not independent noise
Independently wandering tickers look obviously fake, because a real market moves together. Prices are drawn through a Cholesky decomposition of a sector correlation matrix (tech at 0.6, finance at 0.5, cross-sector at 0.3) plus a small chance per tick of a 2–5% shock, so the charts have events instead of drift.
SSE rather than WebSockets
Price data only travels one way. Server-Sent Events give browser-native auto-reconnect and survive proxies with no handshake to manage, and the client sends its intentions over ordinary REST calls.
A version counter on the price cache
The stream compares the cache's version to what it last sent and skips the frame when nothing moved, so an idle market costs an integer comparison instead of a serialized payload every 500ms.
07SCREENS
▶aiworkstation.mp4
The workstation: watchlist, live chart, heatmap, positions, and the copilot down the right. Every price on screen is simulated. Press play for the walkthrough.
“Buy all the stocks on my watchlist” — one sentence, eleven validated fills, and the positions table and heatmap repaint off the same price stream
“Buy all the stocks on my watchlist” — one sentence, eleven validated fills, and the positions table and heatmap repaint off the same price stream
The manual path: a market order off the ticket, filled at the cached price
The manual path: a market order off the ticket, filled at the cached price
The copilot closing a position and adding a ticker, each action reported with its outcome
The copilot closing a position and adding a ticker, each action reported with its outcome
08TRADEOFFS

What I chose, and what it cost.

Chose

A simulator as the default data source

Cost

Nothing on screen is a real quote. The demo trades a plausible market, not the market. Switching to Massive/Polygon is a key and a restart, but the free tier allows five requests a minute, so real numbers arrive every 15 seconds instead of twice a second. Live prices and the live-terminal feel are, at that tier, mutually exclusive.

Chose

Letting the model execute rather than suggest

Cost

A misread instruction becomes a filled order instead of a bad sentence. That's survivable here only because the portfolio is virtual. With real money the same architecture would need a confirmation step between the schema and the service call, and it doesn't have one.

The hard part was making failure legible

The first version treated a chat turn as one unit of work: the model spoke, the trades ran, and the reply came back as a single success or error. That falls apart the moment a request expands into a batch. “Buy all the stocks on my watchlist” is eleven separate orders, and the interesting case is ten of them filling while the eleventh runs the account out of cash, a turn that is neither a success nor a failure. Collapsing it into either one produces a UI that lies. The fix was to stop treating the turn as the unit and make every declared action carry its own outcome through to the transcript: executed with the price it filled at, or failed with the specific reason it was rejected. The service layer already raised the right distinct errors for insufficient cash, insufficient shares, and unknown tickers; what was missing was refusing to flatten them on the way out.

09WHAT'S NEXT

Where I'd take it.

→

A confirmation step for copilot trades above a size threshold. The structured-output boundary is the natural place to hold an action pending approval, and it's the one thing standing between this design and one that could touch real money.

→

Market-hours awareness. The simulator ticks around the clock, which is fine until real data is switched on and the poller starts returning a flat market every evening; the UI currently has no concept of a closed session.

→

Order types past market IOC. Limit and stop orders are where a simulated book gets genuinely interesting, since they require the engine to hold resting orders against the price stream rather than filling instantly at the cached price.