Architecture
A small, boring, legible stack. One virtual machine, four containers, a static frontend, and an analysis layer with no machine learning in it.
What runs where
The frontend is a Next.js 15 App Router project on Vercel — mostly static, with the chart rendered client-side by Lightweight Charts and pattern geometry drawn in an SVG layer positioned through the chart's own coordinate functions.
Everything else runs in Docker Compose on a single Google Compute Engine e2-small in asia-south1-a: Caddy terminating TLS, a FastAPI service, TimescaleDB, and Redis. One machine is genuinely enough at this stage, and pretending otherwise would mean paying for idle capacity.
Why TimescaleDB
Candles are time-series data and the access pattern is always "this symbol, this timeframe, this window". TimescaleDB gives us PostgreSQL — ordinary SQL, ordinary tooling — with hypertables that partition on time underneath. Redis sits in front for the hot path, since the same recent window is requested repeatedly as users pan around a chart.
The analysis layer has no model in it
This is the part people assume is machine learning, and it is not. Level clustering and W/M detection are deterministic Python: find swing pivots, compare them against thresholds expressed in ATR, score the geometry. Given the same candles it returns the same answer, and every number it produces can be traced to a rule.
Thresholds are in ATR rather than percentages for a reason we learned by getting it wrong. A fixed percentage is calibrated for one timeframe and collapses on the others — the shoulder tolerance used by widely-copied Pine scripts works out to 0.9 ATR on a daily chart but around 65 ATR on a 1-minute chart, where it treats any two lows as a match. ATR-relative thresholds behave identically across timeframes.
The language model is used only for the conversational interface. It never computes a level or a pattern, so a bad generation cannot corrupt the analysis — the worst it can do is describe it clumsily.
It has one other job: reading a sentence like "alert me when a doji forms and RSI(14) crosses above 30" into a rule draft. Even there it only proposes. The draft is validated by the same schema the API enforces, so an invented indicator is refused rather than armed, and nothing is armed until the user confirms the card. The engine that then evaluates the rule every minute is deterministic and never calls the model.
The same discipline runs the chart conversation. When you ask "do you see support here?", the model is not shown the chart. It is shown a scene: the detectors' output for exactly the candles on your screen — every level, pattern, candle shape and indicator cross, each with a price and a bar time. It narrates from that and may ask for marks, and a guard then drops any mark whose price or bar the detectors did not report. What gets drawn is detector fact; the model chose which facts answered your question. You can see precisely what it saw at POST /api/scene.
Who owns a rule
Strategy rules are private to one person, which is the only part of the product that needs an identity. There is no user table and no password: the server mints an EIP-4361 message, the wallet signs it, and the server recovers the signer and issues a seven-day bearer token naming that address.
The server builds the message it will later verify, rather than parsing one the client sends. Because the exact string is kept server-side for the five minutes the challenge is valid, verification is one string comparison and one signature recovery — there is no parser to get wrong. The nonce is deleted the moment it is used, and since DEL is atomic, two requests racing the same challenge cannot both succeed.
Fired alerts are delivered on a socket keyed by owner rather than by symbol. That was a correctness fix, not a design flourish: signals previously travelled on the public per-symbol market stream, which delivered one user's fired rules to every browser watching that pair.
Analysis follows the viewport
Every analysis endpoint takes the same window — {symbol, timeframe, from, to} — so the chart can ask about exactly the candles it is displaying. Pan or zoom and the request is re-issued for the new range, debounced, with the previous request aborted. What you are looking at is what gets analysed, rather than a fixed lookback that happens not to match the screen.
Scale, and why it is its own control
A single pivot window cannot see both kinds of pattern traders care about. A wide window finds clean multi-day swings and is structurally blind to the tight double bottoms that form inside a range — the second low of one is simply not the lowest bar for four bars either side. So the detector searches several widths and merges the results, with a guard that stops a large pattern from swallowing a small one nested inside it.
Scale and strictness are deliberately separate axes. Bundled together, "look for smaller structures" and "accept worse examples" could only be asked for at once, which made the one combination a scalper actually wants — small but precise — impossible to express.
Testing
286 tests. Pattern fixtures are built from line segments so the geometry is known exactly and assertions can be made on prices rather than on "something was found". A good number of those tests exist because a real chart disagreed with the detector and the disagreement turned out to be our bug — each one carries a docstring explaining what broke.
What we would change with more traffic
Honestly, not much yet — a single e2-small serves the current load with sub-250ms analysis responses. The first things to move would be read replicas for TimescaleDB and pushing candle fetching onto a scheduled worker rather than doing it inline on cache misses. We would rather add that when it is needed than build it speculatively.