The system design question
Users set price rules, like "tell me when this stock goes above a level" or "tell me if it moves 5% in 10 minutes". Design the system that checks every price tick against those rules and notifies them.
Agree on the scope (5 minutes)
Say up front what you're covering.
| We'll design | We'll skip |
|---|---|
| Creating and deleting rules | Collecting and cleaning exchange feeds |
| Level rules: above or below a price | Storing years of price history |
| Move rules: up or down X% in Y minutes | Charts and the trading UI |
| Checking every tick against the rules | The push and email providers themselves |
| Sending the notification |
Then agree on what "good" means:
- Fast: a notification should reach the user within a second or two of the tick that set it off.
- Once: a rule fires one time when it's hit, not again on every tick after.
- Small memory: a 10 minute rule can't mean keeping 10 minutes of every tick for every stock.
- Keeps up: the busiest minute of the trading day can't make us fall behind.
Rough numbers (5 minutes)
Made up, but reasonable: about 100,000 tickers, around 500,000 price ticks a second on average during market hours and 5 times that at the open and close. 10 million users with 50 million rules between them, and about 1 in 10 is a move rule. Move rules pick from a short menu of windows, up to 60 minutes.
- ~500K
- ticks per second, on average
- ~2.5M
- ticks per second at a peak
- 50M
- active rules
- ~5M
- of them move rules
| Question | Rough math | What it means |
|---|---|---|
| Can the rules fit in memory? | 50M rules × about 100 bytes | About 5 GB. Fits in memory, split across a few machines |
| What does a rolling window cost? | 100K tickers × 3,600 one-second slots × 16 bytes for a low and a high | About 6 GB for a full hour on every ticker. Fine when split, and only tickers with move rules need it |
| How many rules fire? | Say 1% of rules trigger on a busy day | 500K notifications a day, but they bunch up when the market jumps |
The overall design (10 minutes)
- We assume a clean price stream already exists: one format, one tick per price change, with a ticker, price and timestamp. That's its own design problem, so say so and move on.
- The stream is split by ticker, so every tick for one ticker lands on the same matcher, in order.
- Each matcher owns a slice of tickers. For each one it keeps the level rules sorted by price, the move rules grouped by window, and a small rolling summary of recent prices.
- When a rule fires, the matcher writes a 'rule fired' event to a queue. A notifier reads it and sends the push, email or in-app message.
- Rules are created through a normal API that writes to a database first, then tells the right matcher.
What the apps need to do
| Action | Who does it | What's sent |
|---|---|---|
| Create a rule | User | Ticker, the rule (above or below a price, or up or down X% in Y minutes), one-time or repeating, and how to notify |
| List or delete rules | User | Rule IDs |
| Rule changed event | Rule API, to the matcher | Rule ID, ticker, the rule, and whether it was added or removed |
| Rule fired event | Matcher, to the notifier | Rule ID, user ID, the tick that set it off, and the move it saw |
What we store and where
| Data | Where it goes | Why |
|---|---|---|
| Rules | Relational database, split by user | The source of truth for what users set up |
| Level rules by ticker | In each matcher's memory, sorted by price | So a tick only looks at rules near its price |
| Move rules by ticker and window | In each matcher's memory, sorted by percent | So one calculation per window covers every rule on it |
| Rolling price summary | In each matcher's memory, a low and high per second | Answers 'how far has it moved' without keeping every tick |
| Ticks | The stream, kept a few days | So a matcher can replay and rebuild its windows after a crash |
| Fired rules | Database with a unique key on rule ID and firing number | So the same firing can't be sent twice |
Step 1: Checking level rules quickly (10 minutes)
The naive way checks every rule on every tick. That's 2.5 million ticks times 50 million rules, which is never going to happen. Instead group rules by ticker, and within a ticker, sort them by price.
For one ticker, keep two sorted lists: 'above X' rules and 'below X' rules. When the price goes from 101 to 105, only the 'above' rules between 101 and 105 fire. Find that range with a quick binary search, fire those, and take them out. Most ticks fire nothing and cost almost nothing to check.
Split tickers across matchers using the same key as the price stream, which is sharding by ticker. A few tickers are much busier than the rest, so give those their own matchers instead of hashing them in with everyone else.
New rules go to the database first, then to the right matcher. When one arrives, check it against the current price right away, in case the price is already past it.
Step 2: Move rules without keeping the whole stream (12 minutes)
'Up 5% in 10 minutes' means: is the price now at least 5% above the lowest price in the last 10 minutes? 'Down 5%' means at least 5% below the highest. So for each window, all you need is the low and the high over that window. You don't need every tick.
Keep one slot per second for each ticker, holding that second's low and high, in a ring that's as long as the longest window on the menu, say an hour. Each tick only updates the current slot. When a second passes, the oldest slot drops off. That's a window over the stream, and its size stays fixed no matter how many ticks come in.
To get the low over the last 10 minutes, you could scan 600 slots, but doing that on every tick for busy stocks adds up. Instead keep a running low and high per window length, using the trick of keeping only slots that could still be the answer. It's a short list that's cheap to update. Since windows come from a fixed menu, that's at most a handful per ticker.
Now group the move rules by ticker and window, and sort them by percent. On a tick, work out the current move once for that window, like 'up 6.2% from the low'. Every 'up' rule on that window with a percent of 6.2 or less fires. It's the same sorted-range trick as level rules.
Why one-second slots are fine
Using a second as the smallest step means the window edge can be off by up to a second. For a 10 minute rule nobody notices. Say it out loud as a trade-off, and offer smaller slots for short windows if the interviewer pushes.
Step 3: Firing each rule once, and not spamming (8 minutes)
Matchers can crash. When one comes back, it reloads its rules, replays the last hour of ticks from the stream to rebuild its windows, and carries on from its last checkpoint. That means it might fire the same rule twice.
So the notifier only sends after inserting the rule ID plus a firing number into a table with a unique key. The second insert fails and the duplicate is dropped. That's idempotency: doing it twice has the same effect as doing it once.
Repeating rules need care. A level rule should re-arm only after the price moves back past a small buffer, so a price bouncing around 100.00 doesn't send fifty pushes in a minute. A move rule stays true for a while once it's hit, so give it a cooldown, like not again until the window has fully passed.
Choices you should be able to defend
| We chose | Instead of | Because | We'd reconsider if |
|---|---|---|---|
| Rules held in memory per ticker | Querying a database for every tick | Millions of database lookups a second would be slow and expensive | Rules grew far past what memory can hold |
| Per-second low and high | Keeping every tick in the window | Fixed, small memory per ticker, and a second of slack is fine for alerts | Users wanted exact windows down to the millisecond |
| A fixed menu of window lengths | Any window the user types | Each ticker keeps a few running windows, shared by every rule on them | Users really needed odd windows, then round to the nearest second and accept more work |
| At least once, with a unique firing ID | Trying to guarantee exactly once end to end | Much simpler, and the unique key gives the same result | Never |
What could go wrong (5 minutes)
| What happens | What we do about it |
|---|---|
| A matcher crashes | A new one loads its rules, replays the last hour of ticks to rebuild the windows, and duplicates get dropped by the unique key |
| A bad tick shows a 30% drop | Every move rule on that ticker fires. The price stream should hold back wild ticks, and the matcher can wait for a second tick that agrees before firing big moves |
| The market jumps and a million rules fire at once | Fired events queue up and the notifier works through them. Matching never waits on sending |
| The push provider is slow | Retry with growing gaps, and fall back to in-app messages |
| The price stream stalls | The windows stop moving. Don't fire move rules on the first tick after a long gap, since the 'move' might be hours old |
Follow-up questions (5 minutes)
What about 'up 5% today'?
Turn it into a level rule when the day starts, since the opening price is known.
What about rules on volume, like 'twice the usual volume in 5 minutes'?
Same slots, but keep a sum of volume per second instead of a low and high. Sums are even easier to roll.
How do you show it was fast enough?
Track the gap between the tick's timestamp and when the push went out, and alert on the slowest 1% of sends.
How the 60 minutes add up
| Minutes | What you're doing |
|---|---|
| 0–5 | Agree on what you're building |
| 5–10 | Rough numbers |
| 10–20 | Draw the overall design |
| 20–30 | Checking level rules quickly |
| 30–42 | Move rules without keeping the whole stream |
| 42–50 | Firing each rule once, and not spamming |
| 50–55 | What could go wrong |
| 55–60 | Follow-up questions |
Key concepts used here
- Stream processing and windowingCount things in data that never stops coming in.
- Message queues and event streamsKeep services from depending on each other, and handle traffic spikes.
- Idempotency and retriesMake sure a retried request doesn't happen twice.
- Sharding and consistent hashingSplit data across machines and keep it balanced.
- Binary searchCut the search in half, even when you're searching for an answer.
- Real-time deliveryPush updates to users the moment they happen, with WebSockets and friends.
Further reading
We wrote this sample answer ourselves.
Practice Loop is independent and isn't affiliated with or endorsed by Bloomberg.