Architecture
A small, boring, legible stack. One virtual machine, six containers, a static frontend, an analysis layer with no machine learning in it, and a vault contract that holds the only thing worth stealing.
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, Redis, a worker that keeps candle history current and runs backtests, and an executor that turns fired rules into trades. One machine is genuinely enough at this stage, and pretending otherwise would mean paying for idle capacity.
Three processes, because they fail differently
The alert sweep runs inside the API, once a minute, and it must not wait for anything. Backtests run in their own container at a quarter of the API's CPU weight, so a replay can never delay a live alert. Execution runs in a third, and not in either of the others: a slow RPC inside the sweep would delay every other wallet's alerts, and an exit that has to land within seconds of a bar close cannot queue behind an hour-long replay.
What the sweep does when an armed rule fires is therefore almost nothing — it writes one row and returns. The executor reads that row on its own clock, turns it into an intent, and sends exactly one transaction. Every step of that is idempotent through a unique index rather than a lock: one intent per fire, one live position per rule, one order per client id, one fill per chain id. Two executors running at once could not double a position if they tried.
Where the money is, and what we can do with it
Nowhere near us. If you use execution, your stablecoins sit in a contract you deployed and own. We hold a permission on it that can open a position and close one, and there is no code path by which it can withdraw, approve anyone else, re-route a trade or raise a cap. You revoke it in one transaction without our cooperation.
The stop, the target and the deadline are written into that contract in the same transaction that opens the position, and nothing can change them afterwards — which removes the state an executor is most dangerous in, a position that exists without a stop. It also means the exits do not depend on us: the three closing functions are permissionless and pay a small bounty from the vault, so if our server is down a stranger has a reason to close your position. Entries depend on our uptime; exits do not, and that asymmetry is the whole design.
Live and shadow are the same code path with a different venue behind it, so there is no live-only branch that shadow has never run. Shadow reads real prices and writes nothing, which is what makes a week of it a real measurement of what a strategy costs rather than a rehearsal.
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.
Anything it sees can become an alert. The alert is not written by the model either: the server follows the finding back to the detector entry it marked — that sweep, that engulfing candle, that level — and builds the rule from it, which must then pass the same validation as a rule you build by hand. It watches for the kind of thing, not the one instance: the next double bottom to confirm, not only this one. Nothing is armed until you arm it.
Backtests run in their own container at a quarter of the API's CPU weight, so a replay can never delay a live alert. A replay walks each closed bar through the same matchers the alert engine uses, with only the bars up to that point in view, and records every candidate before filtering; that tape is cached, so trying many filter and exit combinations costs seconds rather than a fresh replay each time. Tuning only ever sees history up to the seen/unseen boundary. The single setting it picks is then measured once on the unseen remainder, and the report always says how many settings were tried.
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
895 tests across the backend, the frontend and the vault contract. 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.