Engineering Deep-Dive

Architecture

How Acrue is built — rate limiting, distributed scheduling, NLP pipelines, portfolio optimization, and real-time delivery. Every tradeoff documented.

System Overview

Browser / Client
        │
        ├── REST (fetch)  ──────────────────────────────────────────────────┐
        │                                                                   │
        └── WebSocket (/ws)  ─────────────────────────────────────────────┐ │
                                                                          │ │
                            Next.js App (single process)                  │ │
┌─────────────────────────────────────────────────────────────────────┐  │ │
│                                                                     │  │ │
│  app/api/v1/*  (thin controllers — validate → call service → JSON) ◄───┘ │
│         │                                                           │    │
│         ▼                                                           │    │
│  services/  (business logic)                                        │    │
│    ├── auth.ts          JWT + bcrypt                                │    │
│    ├── marketData.ts    quotes, candles, profiles                   │    │
│    ├── alerts.ts        anomaly detection on live quotes            │    │
│    ├── news.ts          RSS ingest + AFINN sentiment                │    │
│    ├── signals.ts       composite score (momentum/analyst/NLP)      │    │
│    ├── portfolio.ts     MPT gradient ascent on U=μ−Aσ²             │    │
│    ├── simulate.ts      paper trading, live P&L                     │    │
│    ├── notifications.ts alert detection wiring + push (VAPID)       │    │
│    └── ws.ts            WebSocket broadcast ◄───────────────────────────┘
│         │                                                           │
│         ▼                                                           │
│  lib/  (infrastructure)                                             │
│    ├── db.ts            Prisma → PostgreSQL                         │
│    ├── cache.ts         ioredis → Redis                             │
│    ├── rateLimiter.ts   token bucket (55 req/min)                   │
│    └── finnhub/         quote, chart, search, summary, screener     │
│         │                                                           │
│         ▼                                                           │
│  tickerScheduler.ts  (two-timer background loop)                    │
│    ├── 60s  → rebuild priority queue (score all tickers)            │
│    └── ~1091ms → drain: pop ticker, fetch quote, cache, detect      │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
         │                          │                    │
         ▼                          ▼                    ▼
    PostgreSQL                    Redis              Finnhub REST
  (persistent state)          (quote cache,        (60 req/min free)
                              rate limiter)

External feeds:
  RSS (Reuters, CNBC, MarketWatch, Yahoo Finance, Seeking Alpha) → news.ts
  Web Push VAPID → service worker → browser (high-severity alerts only)

Priority Queue & Rate Limiting

Finnhub free tier caps at 60 req/min. With 50+ watchlist tickers each needing a fresh quote every 60s, naive round-robin wastes the budget on low-priority tickers while high-volatility ones go stale.

Solution: a float-score priority queue. Each ticker is scored by recency, watchlist count, and pending alert rules. The token bucket refills at 55 tokens/min — 5 held in reserve for on-demand user calls.

score = (staleness_weight × Δt) + (watchlist_count × 0.4) + (alert_rules × 0.3)

Ticker Scheduler — Distributed Work Queue

The scheduler runs as a two-timer background loop inside the Next.js process — no separate worker service needed.

  • 60s rebuild Rescores all tickers, adds new watchlist entries, removes delisted ones.
  • ~1091ms drain Pops the top-scored ticker, fetches a fresh quote, writes to Redis, fires alert detection. 1091ms × 55 = 60,005ms — exactly fills the rate budget per minute.

Alert Detection

Alert detection runs on every quote fetch via afterFetchListener — up to 55 checks/minute, always on freshly cached data, with no separate polling loop.

Originally designed on OHLCV candles (RSI, EMA cross). Finnhub free tier returns 403 on /stock/candle — rewrote detection to use quote.changePct and intraday range (dayHigh − dayLow) / prevClose only.

Per-rule cooldowns in Redis prevent alert spam after a threshold is breached.

News Pipeline & NLP

Macro news from 5 RSS feeds (Reuters, CNBC, MarketWatch, Yahoo Finance, Seeking Alpha) — free, no API key, ingested 4× daily. Company news from Finnhub /company-news — no extra quota consumed.

Sentiment scored with AFINN lexicon, normalised to [0, 1] by dividing raw sum by wordCount × 5 to remove article-length bias.

Ticker extraction uses word-boundary regex with a 2-char minimum — single-letter tickers (F, S, M) produce false positives in every financial sentence.

Signal Scoring

Composite score from four components, each normalised to [0, 100]:

Momentum
35%
Analyst
25%
Valuation
20%
News NLP
20%

No candle history required — 52-week high/low used as annual volatility proxy; PEG derived from analyst price target upside.

Portfolio Optimization — MPT

Standard closed-form MVO (mean-variance optimisation) requires inverting the covariance matrix — which is singular when fewer than ~30 price observations exist per ticker. Acrue uses projected gradient ascent on a utility function instead:

U = μ − A·σ² (maximise)
subject to: weights ∈ Δⁿ (simplex) via Duchi 2008

Duchi simplex projection keeps weights ≥ 0 and summing to 1 after every gradient step — O(n log n), stable for any portfolio size.

WebSocket Server

Default Next.js { server } mode intercepts all WebSocket upgrades — including /_next/webpack-hmr, breaking hot reload in development.

Solution: noServer: true — manually route upgrade events so only /ws goes to the WS server; all other paths pass to Next.js.

Auth decodes the next-auth JWT cookie directly on the upgrade request — no extra HTTP round-trip. Falls back to 60s polling on Vercel (serverless, no persistent process).

Database Design

PostgreSQL via Prisma ORM. UUID primary keys throughout. Key schema decisions:

  • assets Populated lazily on watchlist add — no bulk import. Profile fetched from Finnhub on first add.
  • alertRules Separate table (not JSONB) — enables per-user per-ticker customisation and cooldown queries.
  • UserNewsRead Compound PK join table — persistent per-user read state with cascade delete.
  • simPortfolios startPrice locked at add time; live P&L computed from Redis quote cache.

Frontend Architecture

Three strictly separated layers:

  • app/(pages)/*/page.tsx Layout only — compose stateful components, no business logic.
  • components/stateful/ Own data fetching and local state. Each maps to one use case.
  • components/ui/ Stateless presentational — props only, no fetch, no side effects.

Dashboard fetches 4 endpoints in parallel with Promise.allSettled. WebSocket updates patch local state directly — no refetch on every price tick.

Deployment

Vercel (serverless) for the Next.js app. PostgreSQL and Redis on Railway (persistent Node processes). WebSocket server requires a persistent process — falls back to 60s polling on Vercel automatically.

postinstall runs prisma generate before next build on every deploy. tsx in dependencies (not devDependencies) — Railway's production install skips dev deps.

Key Tradeoffs

DecisionChosenAlternativeWhy
Market dataFinnhub RESTyahoo-finance2, PolygonStable API; 60 req/min free; WebSocket included
Rate limitingToken bucket + priority queueSimple delay loopMaximises data freshness within budget; watchlist tickers get priority
Background jobsIn-process two-timer loopBullMQ, SQSNo extra infra; single-process Node; sufficient at this scale
Portfolio opt.Gradient ascent on U=μ−Aσ²Closed-form MVOStable for singular covariance matrices; works for any portfolio size
NLPAFINN (Node)OpenAI embeddings, HuggingFaceNo API cost; no latency; runs in-process; sufficient for sentiment signal
WebSocket authDecode JWT from cookie on upgradeSeparate HTTP auth endpointZero round-trip; session token already present in upgrade cookies
Read trackingDB join table (UserNewsRead)Client-side SetPersists across sessions; survives reload; enables analytics
Candle-free detectionquote.changePct + intraday rangeOHLCV candles (RSI, EMA)Finnhub free tier returns 403 on /stock/candle; quote fields are free

Ready to see it in action?

Get started with Acrue →
Acrue

Built to Accrue