App architecture · expert
Reference architecture II: Tape, a real-time trading app
The "build us an HFT app" brief, made honest. A second capstone where the data arrives faster than the screen can draw it. Conflation, sequence gaps, an order state machine, pre-trade risk checks, and why Ledger's offline outbox would be a bug here.
By the end you will be able to
- Say precisely what a phone can and cannot promise in a high-frequency trading system
- Design a market-data path that survives 4,000 price updates a second — conflation, sequence integrity, resnapshot
- Model an order's life as a state machine driven only by messages from the venue
- Place pre-trade risk checks in a framework-free core, and duplicate protection at the gateway
- Argue why the offline-outbox pattern is wrong for orders — and what replaces it
"We're a trading firm. Build us the app. Quotes must feel instant, and an order must never, ever execute twice."
That brief lands on iOS teams at brokers, exchanges and fintech companies. The job spec usually has the letters HFT in it somewhere. HFT is high-frequency trading: buying and selling in fractions of a second, far faster than a person could react. Hold that word loosely for now — the next section is about how much of it a phone can honestly do.
Ledger, the capstone in D1-06, taught you to assemble a money-movement app. This second capstone builds its mirror image. In Ledger the hard problem was getting the data at all. Here the data arrives faster than the screen can draw it, and the hard problem is surviving that rate.
The vocabulary stays the same as Ledger's: packages, ports, use cases, gateways. The failure modes are opposite. Ledger protects writes by being patient — queue the transfer, retry it, reconcile afterwards. This app protects reads by being impatient. An old price is worse than no price, because a user who acts on an old price loses money.
The app is called Tape, and it has three screens. The watchlist is a list of symbols the user follows, each showing its current price. The order ticket is the form where the user enters a buy or a sell. The blotter is the list of orders already placed, and what has happened to each one.
The name is trader slang from the age of paper ticker tape, when machines printed prices onto a ribbon of paper as they arrived and traders "read the tape". The tape is the stream of prices itself. The paper went away; the word stayed.
Two words are used constantly from here on. A tick is one price update for one symbol — in this app, a small value carrying a symbol, a price and a sequence number. A market data feed is the network connection that delivers ticks, normally a WebSocket held open for the whole session.
The feed delivers ~4,000 price updates a second across the 60 symbols on screen. The display refreshes at most 120 times a second. What does the market-data path do with the difference?
The map
The shape is Ledger's — screens on top, infrastructure below them, and at the bottom a core package that imports no framework at all. One thing is deliberately different. Ledger kept reads and writes in the same package, LedgerData, because both were ordinary request-and-response calls to the same API. In Tape, reading and writing follow opposite rules, so they get separate packages:
| Package | Owns | Discipline |
|---|---|---|
TapeCore | instruments, Money (whole numbers of cents, S4-04), risk rules, the order state machine | pure Swift, imports nothing |
TapeMarket | feed client, QuoteBook conflation actor, gap detection | lossy-latest: dropping data is correct |
TapeTrade | order gateway, client order IDs, duplicate rejection | exactly-once: dropping or repeating is a bug |
TapeFeatures | watchlist, ticket, blotter — screens, view models, routes, all in D1-04's shapes | binds the two sides to pixels |
Two words in that table need unpacking before they are used again. An instrument is something you can trade: a share in Apple, a euro-dollar currency pair, a futures contract. A venue is wherever an order is actually matched — an exchange, or a broker's own internal system. The word appears throughout the rest of this lesson.
Now read the last column, because it carries the whole thesis. "Reliability" is not one property. The read side is allowed to lose ticks, and in fact must lose them. The write side may not lose or repeat a single message. Those are two different rules, and when both live in one module somebody eventually applies the wrong one to the wrong path — usually the path where it costs money.
The firehose
"Firehose" is the usual name for a data feed you cannot ask to slow down. The watchlist reads from one.
Its data source is a WebSocket, held open for the whole session, delivering ticks. Each tick carries three fields: the symbol, a sequence number, and the price.
The obvious architecture is to let every tick write straight into @Observable state. Here is what that does in production. Follow the sequence:
- Four thousand ticks arrive in one second. Each one writes to observed state, and each write invalidates every view that read it.
- SwiftUI runs four thousand rounds of body evaluation and diffing, on the main thread, in that same second.
- The main thread now has no time left for anything else. Scrolling stutters, taps feel late, and the battery drains.
- The work backs up. The paint that finally reaches the screen was built from a tick that arrived a long time ago, so the displayed price falls further and further behind the real one.
Step 4 is the important one. This design is not merely slow. It is slowest exactly when the market is moving fastest, which is exactly when the price on screen matters most.
Two pieces fix this, and both live in TapeMarket, behind a port — a protocol declared by the code that needs the service rather than by the code that provides it, which is the Dependency Inversion move from D1-02 and the shape D1-05 builds its whole domain layer out of. That is the same place Ledger put its cache.
Read the numbered arrows:
- Ticks pour into the
QuoteBookactor. Applying a tick is a single dictionary write: store this tick as the latest one for its symbol. Nothing is appended and nothing grows, so the actor absorbs whatever rate the feed runs at. - The screen samples the book at its own cadence. Nothing pushes ticks at SwiftUI. A loop running at display cadence pulls the book's current state, at most 120 times a second. The mismatch between 4,000 and 120 disappears, because nothing ever tries to carry every tick across.
- A gap in the sequence numbers makes the book ask for fresh state. Each tick carries a sequence number: a counter the feed increases by one for every tick it sends for that symbol. If the numbers jump from 2 to 5, ticks 3 and 4 were lost on the way. That is a sequence gap. You cannot reconstruct a price you never received, so the book does not guess — it asks the feed to re-send the current state for that symbol. That request is called a resnapshot.
One warning about vocabulary, because the same word does two jobs here. The book's own snapshot() method, below, returns what the app currently holds; it touches no network. A resnapshot is a request over the network for a fresh copy of the truth. Same root word, very different cost.
Watch the sampling behave — six ticks arrive on a producer task while the display samples twice:
One task applies six ticks, ten milliseconds apart. A second task reads the book twice, twenty-seven milliseconds apart — each read stands for one repaint of the screen, which is why it prints paint:. What does this print?
actor Book {
var latest = 0
var applied = 0
func apply(_ cents: Int) {
latest = cents
applied += 1
}
func read() -> Int { latest }
func count() -> Int { applied }
}
func main() async {
let book = Book()
let producer = Task {
for price in [18901, 18904, 18899, 18910, 18907, 18922] {
await book.apply(price)
try? await Task.sleep(nanoseconds: 10_000_000)
}
}
let display = Task {
for _ in 1...2 {
try? await Task.sleep(nanoseconds: 27_000_000)
let price = await book.read()
print("paint:", price)
}
}
await producer.value
await display.value
print("ticks applied:", await book.count())
}
await main()The real book does two things: it keeps the latest tick per symbol, and it refuses any tick whose sequence number is not newer than the one it already holds. A tick that turns up late is dropped, never applied.
Look at the order of those four apply calls. AAPL's seq: 4 arrives before its seq: 3. That is ordinary: a network can deliver packets in a different order from the one they were sent in. Without the sequence check, applying tick 3 second would paint 18910 over 18922 and show the user an older price as if it were the newest one. With the check, each symbol's price can only move forward.
This is C2-01's actor doing precisely the job actors exist for. Many tasks write, many tasks read, one mutable dictionary is isolated inside so that only one of them runs at a time, and no lock is written anywhere.
In a real app the feed is a URLSessionWebSocketTask wrapped into an AsyncStream, so everything downstream can consume ticks with a plain for await loop. That code cannot run on this page — the browser runtime that executes this lesson's examples has no network sockets — so it appears in a plain fenced block instead. C1-02 is the lesson that builds AsyncStream bridges like this one, and C1-04 throttles a quote stream with them.
final class FeedClient {
private let task: URLSessionWebSocketTask
init(url: URL) {
task = URLSession.shared.webSocketTask(with: url)
task.resume()
}
var ticks: AsyncStream<Tick> {
AsyncStream { [task] continuation in // capture the task, not self
func receiveNext() {
task.receive { result in
if case .success(let message) = result {
// Tick(_:) is a failable initialiser you write: parse
// one frame, or return nil if the frame isn't a tick.
if let tick = Tick(message) { continuation.yield(tick) }
receiveNext() // ask for the next frame
} else {
continuation.finish() // socket died → stream ends
}
}
}
receiveNext()
}
}
}
// TapeMarket's ingestion loop — the entire read side:
for await tick in feed.ticks {
await book.apply(tick)
}
// Reaching this line means the stream finished, which means the socket died.
// That is the disconnect signal: mark every quote stale, start reconnecting.
Notice what the end of that loop means. When the socket dies, the stream finishes and for await simply returns. There is no error to catch and no callback to wire up; the loop ending is the notification.
What happens next is the part that matters to the user. Staleness is something shown on screen, not something the app merely knows internally. The moment the loop exits, every price changes appearance — dimmed, and stamped with the time it was last updated. A cockpit never shows a frozen instrument as though it were live.
Orders: a state machine the venue drives
The write side is Ledger's problem turned upside down.
Ledger's SendMoney use case succeeded by being patient. It wrote the user's intent into an outbox — a local table of pending writes — and a background loop delivered each row whenever the network came back. A transfer that lands an hour late is still the right transfer, so patience costs nothing there.
A market order is an instruction to buy or sell immediately, at whatever price the market is currently offering. Deliver one an hour late and it executes at an hour-late price, which is a price the user never saw. Here, patience is not a virtue. It is the bug.
The user taps Buy 100 @ market in a subway tunnel — the feed disconnected four seconds ago. Ledger's answer was the outbox: queue the write, drain it when online. Here?
What the app can promise is that an order, once accepted, is tracked truthfully and never duplicated. Two mechanisms do that, both borrowed from Ledger and re-tuned.
The client order ID is Ledger's idempotency key under a different name. It is a unique string the phone generates the moment the user confirms the ticket, and it is attached to every attempt to send that order.
Here is the situation it exists for. The phone sends the order. The venue accepts it and sends back an ack — an acknowledgement message meaning "received". The phone loses signal before the ack arrives. All the phone knows is that it heard nothing, and hearing nothing is not the same as knowing nothing happened. So it sends the order again with the same client order ID, and the gateway recognises an ID it has already accepted. One order exists, not two. The same mechanism absorbs a user double-tapping the Buy button.
The order state machine is the second mechanism. A state machine is a small set of named states plus rules for which state may follow which. You built one in D1-04, where a screen moved between idle, loading, loaded, empty and failed. An order gets the same treatment, plus one rule LoadState never needed: an order's state changes only when a message from the venue says it changed. Never because the app feels optimistic about a tap.
Walk the arrows one at a time.
- draft → submitted, on submit. The order leaves the phone, and the client order ID is now spent — it belongs to this order and to no other.
- submitted → working, on the venue's ack. "Working" means the order is live at the venue, waiting to be filled.
- working → working, on a partial fill. A fill is the venue reporting that some of your quantity has traded. Buy 100 and you may get 40 now and 60 a moment later; each fill adds to the quantity already done.
- working → filled, once the fills add up to the whole order.
- submitted → rejected, when the venue refuses the order outright — an unknown symbol, or not enough buying power in the account.
- working → cancelled, on a cancel ack. Read that carefully: the ack, not the request. Asking to cancel changes nothing on its own. The order is cancelled when the venue says it is.
filled, rejected and cancelled are terminal, and nothing leaves them. An illegal transition — a fill arriving for an order the app believes is cancelled — is dropped and raised as an alert, never applied. When the venue and the app disagree about an order, the app's job is to make the disagreement visible, not to smooth it over.
That keeps the blotter screen simple: it renders this machine and nothing else. Every trading-app horror story that begins "the app said cancelled but it had actually filled" is a state machine that let the UI, rather than the venue, drive a transition.
Risk lives in the core
Before an order reaches the gateway, TapeCore gets to veto it. Checks that run before an order is sent are called pre-trade risk checks, and three kinds are common:
- Fat-finger guards. "Fat finger" is the industry's name for a typing mistake: an extra zero on the quantity, or a decimal point in the wrong place on the price. An order far larger than the user's usual size, or priced far away from the last traded price, is almost certainly a typo rather than an intention.
- Buying-power ceilings. Do not send an order the account cannot pay for.
- Position limits. Do not let a holding in one symbol grow past an agreed size.
All three are arithmetic over numbers the app already holds, so they belong in the framework-free core for Ledger's exact reason: the phone app, the widget, the watch app and next year's iPad layout must all refuse the same bad order in the same way, and a rule that lives inside a screen cannot do that.
The server runs these checks too, and the server's verdict is the one that counts. The client copy exists for speed and for wording. The mistake dies on the phone in a few milliseconds instead of after a round trip, and the ticket can immediately show a sentence explaining what was wrong with the order. You build the checker in exercise 3.
One rule from S1-01 and S4-04 carries extra weight here: prices are held as integer minor units — whole numbers of cents — from the feed all the way to the screen, and never as Double. A watchlist that drifts by a floating-point cent is embarrassing. A risk check that drifts is a trading incident.
A caution about one word, because it does two jobs in this industry. The market-data sense of tick used throughout this lesson is one price update. Tick size is something else: the smallest increment a price is allowed to move in at a given venue, often one cent for shares. Both meanings are standard, and you will hear both in the same meeting.
Painting the storm
The last step is SwiftUI, and the firehose section already decided the rule: no view ever observes the feed. The watchlist's view model reads the book at display cadence and publishes what it read. Each row is its own small view holding one quote, so a price change in TSLA invalidates the TSLA row and nothing else. That is P1-04's dependency-based invalidation, under full pressure.
Three production details ride on top. All three are real APIs, and you wire them up in the Xcode task below.
.monospacedDigit()gives every digit the same width. Without it, a price flicking between189.22and189.11changes width, and the row jiggles sideways on every update..contentTransition(.numericText())makes a changing number roll from the old value to the new one instead of blinking.TimelineViewis for anything that must redraw on a clock rather than on new data — a session timer, a chart's moving edge. It asks the system for display-cadence callbacks, which is cheaper and better behaved than running aTimerfast enough to look smooth.
Now the counterweight, because honest architecture needs one. If the product is a casual portfolio app that updates once a minute, none of this earns its keep. Conflation actors and cadence sampling are paid for in complexity, and what justifies buying them is a measured main thread running out of time — not a diagram that looks impressive.
Testing: replay the tape
Streaming systems hand you one testing advantage a request-and-response app never gets: the input is a recording. A feed session is nothing more than an ordered list of ticks. Record one, and any bug report becomes a segment of tape you can replay through the real QuoteBook as often as you like.
These replays cannot flake, because there is nothing in them to flake. The array of ticks is fixed, the QuoteBook contains no timer and no network, and the assertion runs after the last tick has been applied. Same input, same output, every time.
| Layer | What you assert | Harness |
|---|---|---|
TapeCore | state machine legality, risk vetoes, money arithmetic | plain structs, no simulator — most tests live here |
TapeMarket | the conflation rule, gap → resnapshot, dropping stale ticks | recorded tick arrays replayed through the actor |
TapeTrade | client-ID duplicate rejection, cancel-on-disconnect bookkeeping | scripted stand-ins for the gateway |
TapeFeatures | blotter rows mirror the machine, ticket disables on stale quotes | D1-04's scripted-server pattern |
One rule is worth writing as a test before any other: after any replay, every symbol's displayed price equals the highest-sequence tick for that symbol in the recording. It is about five lines against the book's snapshot, and it catches ordering bugs, conflation bugs and gap-handling bugs together.
Your interviewer follows up: "Same firm, but now the watch app — glanceable positions and one-tap close, on a device that's offline half the time with a tiny battery." In six to eight sentences, design it: which Tape packages the watch target links, what changes about the market-data cadence and the staleness display, and what your order-entry policy is on a device that cannot show a live tape. Defend each choice against the cockpit-not-engine rule.
You can now:
- Open a trading-system design with the honest latency story — cockpit, not engine
- Draw the four-package map and defend the read/write split as two different reliability rules
- Build the conflation actor and explain why dropping ticks is correctness, not loss
- Guard a feed with sequence numbers: accept, drop as stale, or report a gap and resnapshot
- Drive a blotter from a venue-message state machine whose terminal states stay terminal
- Veto fat-finger orders in a framework-free core, in integer arithmetic
Two capstones now bracket the architecture arc. Ledger, where writes are patient promises, and Tape, where reads race a clock: one vocabulary, two failure modes, and the judgment to tell them apart. Next up: the P-track — testing, the practice all this structure was buying.