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.
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 Open the Admin portal → Stock Providers → click "Switch to Alpaca."
- 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.
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.
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:
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.
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:
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:
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.