Case Studies

Building DayTrader: What We Learned

Lead Engineer, Quantums
9 min read
March 2026
8 weeks
Build time
116 commits
Version history
67,000 lines
C# across 417 files
4 services
Deployable containers
.NET 9 + ONNX
Core stack

When we started DayTrader in late March, the brief we set ourselves was simple, but the constraints were not: build a trading platform that a real person could trust with real money. Not a tutorial app. Not a demo with three hardcoded tickers. A platform with proper authentication, real broker integration, machine-learning-driven signals, paper and live execution lanes, a hot-swappable model registry, and a UI good enough that a non-engineer could place an order without reading docs.

Eight weeks and 116 commits later, the system spans four deployable .NET 9 services, an Android client, an ML training pipeline, and about 67,000 lines of C# across 417 source files. This is the story of what we chose, what we'd do differently, and the engineering decisions that turned out to matter most.

The Problem with Most Trading Apps

Most retail trading tools fall into one of two buckets. On one end are the broker-supplied apps — polished, but locked to a single venue and barely scriptable. On the other end are open-source bots — flexible, but fragile, terminal-only, and one bad assumption away from torching your account.

We wanted the surface area of the first and the configurability of the second. The non-negotiables looked like this:

  • Provider-agnostic. We should be able to swap the market data source — Alpaca today, Polygon tomorrow, IBKR next month — without redeploying.
  • Paper and live, side by side. Every account starts in paper mode. Going live should be a deliberate, audited action, not a checkbox someone clicks at 2 a.m.
  • Real ML, not vibes. The "AI" had to be a model we trained, evaluated, and could roll back — not a wrapper around someone else's chat API.
  • Three clients, one truth. A web client, an admin portal, and a mobile app — all reading from the same authoritative API, all updating in real time.

That set the shape of the system before we'd written a line of code.

The Architecture in One Picture

The deployable footprint ended up as five containers:

Container What it does
dt-api port 5000 ASP.NET Core 9 — auth, trading, market data, AI signals, SignalR hubs
dt-web port 5001 Blazor WebAssembly — the trading client (charts, portfolio, orders)
dt-admin port 5002 Blazor Server — user management, provider switching, ML model promotion
dt-postgres All persistent state, including every OHLCV candle we've ever fetched
dt-redis Refresh tokens, cache, pub/sub fan-out for SignalR

Plus an Android Kotlin Compose app that talks to the API directly, and a standalone DayTrader.ML.Trainer console app that produces ONNX models offline.

The code itself is laid out as a clean domain split: Core holds entities, interfaces, and DTOs with no infrastructure dependencies; Infrastructure holds the EF Core context, Redis client, provider adapters, JWT and TOTP services; AI holds the strategies, consensus engine, backtester, and ML inference. The deployable apps (API, Web, Admin) are thin shells that compose those libraries.

That split paid for itself many times over. When we needed integration tests that hit a real Postgres but a mocked provider, we didn't have to fight the architecture — we just registered a different IStockDataProvider.

Decision 1

The Provider Abstraction

The first interface we wrote was IStockDataProvider. Everything that touches market data — quotes, candles, search, subscriptions — goes through it. The active provider is resolved at runtime by StockProviderFactory, which reads from a settings service the admin portal can update on the fly.

In practice that means:

  1. 1 Open the Admin portal → Stock Providers → click "Switch to Alpaca."
  2. 2 The next quote request uses Alpaca. No restart. No deploy.

The shape of that interface is the single most important design decision in the whole codebase, because it cascades into everything else. Backtesting works because candles can come from a LocalDatabaseProvider. Paper trading is honest because MockStockProvider returns real last-known prices from the same store. Adding Polygon later is a three-step process — implement the interface, register it in DI, add the name to the factory.

If we were starting again: the only thing we'd change is to make the provider switch event-sourced rather than overwriting an in-memory setting. Right now, a switch is silent in the audit log. For a real money platform, that's a gap we want to close.
Decision 2

Paper Mode Is the Default

Every new account is created in TradingMode.Paper. Paper mode uses the mock provider by default — no API keys, no network calls, no risk. Orders fill instantly at the last known price. Positions and P&L are tracked exactly as in live mode, against the same database, with the same UI.

Going live is an explicit admin action on a specific account. Two layers of gating, not one.

This came directly from a failure mode we've seen in other systems: a single global "test mode" toggle that someone flips for debugging and forgets. By scoping mode to the account and making Paper the lowest-friction option, the unsafe path is always the longer one.

Decision 3

Real-Time Without WebSocket Spaghetti

The web client needs live quote ticks. The mobile client needs them too. The admin portal needs live system health. Wiring each consumer to its own WebSocket connection against a provider would be expensive and brittle. The answer was four SignalR hubs sitting in front of a Redis pub/sub channel:

Alpaca WebSocket
▼ AlpacaStockProvider.SubscribeAsync()
Redis Pub/Sub channel
SignalR MarketDataHub
├─────────────────┤
Web (Blazor)
Mobile (Kotlin)
Admin (Blazor)

Redis in the middle is what makes the system horizontally scalable. If we run two API pods, both subscribe to the same Redis channel, both push to their connected SignalR clients. No leader election, no sticky sessions.

Four hubs, not one: market (quotes and candles), trading (order fills and position updates), alerts (price trigger fires), and settings (live config sync). Splitting them by topic keeps subscriptions narrow and lets clients reconnect to just the streams they care about.

Decision 4

Persist Every Candle You Ever Fetch

The first time a client requests AAPL minute bars for last Tuesday, we go to the provider. Every subsequent request — for that user, for any user, forever — is served from Postgres via CandleRepository.BulkInsertAsync.

The consequences are bigger than they look:

No API quota drained by re-renders. Backtests, chart redraws, and model training all hit the database, not the provider.
Backtests are fully offline. Disconnect the API key, the backtester still runs.
The training set grows automatically with normal use. Every chart someone opens contributes to the next model.

The cost is storage. Minute bars across a few hundred symbols add up fast. The next iteration will probably move OHLCV into a time-series store — InfluxDB or Timescale — because Postgres handles the volume but loses some efficiency on the write side. For now, the simplicity has been worth it.

The ML Pipeline: Honest, Not Magic

This is the part we're proudest of, because it would have been very easy to cheat.

The DayTrader.AI library implements nine concrete trade strategies — Momentum, Mean Reversion, MACD, VWAP, Bollinger Breakout, News Signal, Options Flow Signal, ML Scoring, and ML Inference. The first seven are deterministic technical strategies. The last two are powered by a classification model.

The model expects exactly 57 input features in a fixed schema — price returns, volume ratios, momentum, trend, volatility, MACD, VWAP, market structure, time-of-day. It outputs three probabilities: SELL, HOLD, BUY. If the winning class exceeds a confidence threshold encoded in the model's manifest, the AI engine acts on it; otherwise it stays out.

Training invocation

dotnet run -- --symbols AAPL,MSFT,TSLA,SPY,QQQ \
              --from 2024-01-01 --to 2026-04-30 \
              --out models/

The pipeline pulls candles from Postgres, builds the feature matrix, generates labels by looking forward a fixed number of bars (BUY if price rises ≥0.5% in the next 30 minutes, SELL if it drops ≥0.5%, HOLD otherwise), trains a LightGBM multiclass model via ML.NET, evaluates with per-class F1 and a held-out 20% set, and emits two files: a .onnx model and a model_manifest_*.json with all the metadata.

The admin portal has an upload form. Pick the two files, decide whether to auto-promote, and the model goes into the MLModelRegistry — a thread-safe in-memory ONNX session guarded by a reader-writer lock. Hot-swaps don't interrupt an in-flight inference. The full version history is in the database with F1 and accuracy badges (green ≥ 0.70, amber ≥ 0.55, red below). Rollback is one click.

The 57-feature schema is hardcoded.

Models with the wrong feature count are rejected at upload time. That's a constraint we'd loosen eventually — adding a ModelArchitecture field and an InputAdapter layer would let us import PyTorch LSTMs or external Hugging Face models — but for v1, a strict contract eliminated an entire category of "why is this model producing garbage" debugging.

Survivorship bias is real.

If you train only on stocks currently in the S&P 500, you're learning patterns from companies that survived. Every bankrupt and delisted ticker is a counterfactual the model never sees. We've been training on Alpaca's free feed for prototyping and budgeting a Polygon subscription for the production model. Document the bias — don't pretend it isn't there.

The signal hands off to a ConsensusEngine that weighs the ML output against the technical strategies, then to a RiskManager that enforces per-account caps, then to a PositionSizer that turns "BUY AAPL" into "buy 14 shares at the bid + 0.01." Three layers between "the model thinks up" and "an order goes out." Each one can veto.

Authentication: Boring on Purpose

The auth stack is intentionally unflashy:

🔑 BCrypt, work factor 12

Standard password hashing — nothing exotic.

🎫 JWT 15 min / Refresh 30 days

Refresh tokens in Redis for instant revocation.

📱 TOTP 2FA via OtpNet

Google Authenticator, Authy, 1Password — no SMS, ever.

👆 Android biometrics

Fingerprint/face via BiometricManager, stores a signed challenge — not the password.

🔒 TLS 1.3 minimum

HSTS, the usual headers. Nothing skipped for convenience.

🛟 Recovery via admin SQL

Deliberate. No self-service. The cost of a bad recovery flow on a trading account is "your money is gone."

The only piece worth specific attention is the recovery path. If someone loses their authenticator, the documented recovery is a direct SQL update from the admin. That's deliberate. There's no self-service recovery flow, because the cost of a bad recovery flow on a trading account is "your money is gone." Recovery requires a human.

What Two Months Taught Us

The provider interface is worth designing before you write a single feature.

Every shortcut we took early — hardcoded symbols, assumed minute bars, inlined Alpaca calls — cost us twice when we had to undo it. The interface is annoying to write before you have a second provider. Write it anyway.

Paper mode pays for itself in the first week.

Not because of bugs caught — though there were many — but because it changes how you think. When the default lane is safe, you experiment more aggressively. The cost of being wrong drops to zero. We'd estimate paper mode tripled our iteration speed on the AI engine.

Persisting every candle changed what features were even possible.

Backtesting an arbitrary time window against any strategy went from "request the data, hope the quota holds" to a single SQL query. The training set for the ML model is literally the byproduct of normal use.

Hot-swap the model, not the deployment.

Tying model promotion to a deploy was the path of least resistance. It was also the wrong call. Once promotion was decoupled — upload a file, click promote, the running process picks it up — model iteration went from a weekly cadence to several per day. The same is true for the provider switch.

Three clients, one API is harder than four monoliths.

The Web app, the Admin portal, and the Android app all share the same API surface. That's strictly more work upfront — you can't lean on tight coupling between a controller and a Razor page when a Kotlin client also depends on the response shape. But it's the only design that doesn't punish you on commit 200. Every behaviour is defined in one place. Every bug is fixed in one place. Every new feature ships to all three clients the moment the API endpoint lands.

What's Next

The next quarter's work is already scoped:

Polygon.io provider integration
Trailing-stop + risk circuit-breaker (halts on max daily loss)
FCM push notifications hooked into the alerts hub
Order book L2 visualisation via Alpaca streaming endpoint
Sequence-aware LSTM exported through the same ONNX path

If there's one takeaway from two months of building this, it's that the boring decisions are the ones that compound. Clean interfaces. A paper lane by default. Persistence on the hot path. Hot-swap over redeploy. None of it is novel. All of it is what separates a demo from something you'd actually run with money on the line.

Need something built to this standard?

We apply the same rigour to every client engagement — from architecture to deployment.