← Back to selected work
Case study

MAET: an honest paper-trading and backtesting workspace for NSE equities

A research terminal for Indian equities with a screener, charts, a strategy lab and a paper-trading engine. The interesting work is in the parts that decide whether a fill or a backtest result is believable.

typescriptreact 19tanstack startnitropostgresqldrizzleredisangel one smartapi
MAET landing page with an NSE market watch table and index tiles, marked market closed with last available quotes.
Public landing page, captured 10 Nov 2026 outside market hours. The page labels the quotes as the last available ones and shows their timestamps.
MAET stock screener listing NSE companies with price, change, volume and ISIN, with a notice that the database is offline.
Screener, same day. The page itself reported “Database offline: NSE company list with live quotes only”: the documented fallback when Postgres is unreachable, not a mock-up.

Problem

Retail research tools tend to show a number without saying where it came from, and backtests that quietly peek at the future. I wanted a workspace where every quote carries its provenance and where a paper fill or a backtest trade has to earn its price.

MAET is that workspace for NSE and BSE cash equities: screener, charts, a strategy lab with backtests, parameter sweeps and walk-forward runs, and a paper-trading engine. It is research and execution practice. No code path sends orders to a broker.

Architecture

  • Frontend: React 19 with TanStack Start, built with Vite and served from Vercel.
  • Backend: one Nitro server on a single Render free-plan web service. A health check hits /api/health; no worker services are declared.
  • State: PostgreSQL through Drizzle (pool of 5, transaction-pooler friendly). Redis holds the candle cache, the daily-processor lock and paper-notification pub/sub. It is not in the quote path.
  • Market data: Angel One REST and a WebSocket feed for live NSE ticks, with Yahoo Finance as a delayed fallback and the source of historical candles.
  • One process runs the orchestrator: an in-memory quote store, a tick bus, the paper order matcher and 2-second margin monitor, an alert evaluator and a daily processor that runs after 18:30 IST on trading days.
  • Quote path: a fresh streamed tick first, then Angel One REST, then Yahoo. A quote cached during the session is relabelled “delayed” once the market is no longer open, so the UI never presents it as live.
  • The paper-trading arithmetic (price, charges, position reconciliation, margin) is pure code in shared/. The server layer adds locking, persistence and quote sourcing, which is what lets the arithmetic be unit-tested without a database.

Constraints and tradeoffs

  • Free-plan hosting means everything that would normally be a worker runs inside the one web process. The cost: an idle service sleeps, queued backtest jobs wait until it wakes, and CPU-heavy work shares an event loop with tick matching.
  • It is a margin account, not a cash account. A fill never debits the trade’s notional; cash moves only by realised P&L and fees, and margin limits buying power. Short selling is allowed.
  • A paper fill is refused unless the quote is fresh (15 s by default), not stale, not synthetic and not delayed. So when only Yahoo is available, MARKET orders are refused rather than filled at a price nobody could have traded at. Delayed fills can be enabled by config.
  • Resting orders fill at most 10% of the volume traded since the previous tick, with a one-share floor, so a huge limit order cannot fill entirely on a single quote. Market-order slippage is tiered: 0.05%, 0.15% and 0.30% of price by order value.
  • Backtests take the conservative side: when a bar touches both the stop and the target, the stop wins.

Hard parts

  • Look-ahead in backtests. Rules are evaluated at bar i−1 and filled at bar i’s open, a position closed on a bar cannot re-enter on it, and position sizing uses ATR at the signal bar. Because the indicator cache is computed over the whole series, correctness depends on every indicator being causal, which the docs call out as an assumption rather than a guarantee.
  • Concurrent money-moving work. Every write path locks the account row first, then the order, positions and bracket relatives. Transactions are SERIALIZABLE and retry on serialization failure or deadlock, up to three attempts. The quote is loaded before the transaction opens so no network I/O happens while locks are held.
  • Racing the matcher. The tick matcher reads its candidate orders without locks, then re-reads each one under lock and re-checks status, generation and fingerprint, because a cancel, fill or reset may have committed in between.
  • Reset without rewriting history. Resetting an account bumps a generation counter and writes a new initial-cash ledger row. Positions, fills, ledger rows and idempotency keys are scoped by generation, so old rows stay as history. Fills and ledger entries are append-only by database trigger.

Testing

  • Pure paper-trading arithmetic is unit-tested without a database (financial-invariants.test.ts). Integrity properties are tested against real PostgreSQL in the engine-integrity, concurrency and risk-liquidation integration suites, including “cancel racing a fill never leaves fills behind a cancellation”.
  • For look-ahead, truncated-history.test.ts checks that a run which never saw bars after time T agrees with the full-history run on every trade and equity point before T. fill-bar-look-ahead.test.ts perturbs the fill bar’s high, low, close and volume and checks that decisions made at its open do not change.
  • CI runs typecheck, lint, a test-wiring check and the unit tests, plus three jobs against a postgres:16 service for the paper-trading integration, concurrency and advanced-feature suites. A separate workflow polls production /api/health until it reports the pushed commit.

Limitations and what I’d do next

  • Every fill is charged at delivery rates because orders have no CNC/MIS product field. The docs’ own example: an intraday round trip of ₹1,00,000 costs ₹222.24 here against ₹35.24 at intraday rates. Adding a product field is the first fix.
  • The live feed covers NSE cash only. BSE orders get no executable quote, so BSE MARKET orders are refused and BSE resting orders never match.
  • An account in LIQUIDATION_PENDING can stay stuck when a position has no executable quote; the only user-side exit is a reset.
  • Research jobs and synchronous backtests run inside the web process. The portfolio engine, the spread and market-impact execution model, point-in-time fundamentals and corporate-action adjustment exist in the repository but are not used by production backtests yet.
  • State such as the quote cache and rate-limit buckets lives in process memory, so a second instance would split it. There is no metrics exporter or tracing, only logs and health endpoints.
  • Next, in order: the product field, a separate worker for research jobs, and wiring corporate actions into backtests.

Source: the repository’s docs/ARCHITECTURE.md, docs/PAPER_TRADING_ENGINE.md and docs/RESEARCH_ENGINE.md, which describe the code at commit ca8b1bb.

← Back to selected work