Engineering Deep-Dive
How Acrue is built — rate limiting, distributed scheduling, NLP pipelines, portfolio optimization, and real-time delivery. Every tradeoff documented.
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)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)
The scheduler runs as a two-timer background loop inside the Next.js process — no separate worker service needed.
The autocomplete layer calls Finnhub /search, then re-ranks candidates using a tag-based recommender before returning results.
Results capped at 10, sorted by composite score. No vector DB, no ML model — pure deterministic re-ranking.
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.
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.
Composite score from four components, each normalised to [0, 100]:
No candle history required — 52-week high/low used as annual volatility proxy; PEG derived from analyst price target upside.
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.
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).
PostgreSQL via Prisma ORM. UUID primary keys throughout. Key schema decisions:
startPrice locked at add time; live P&L computed from Redis quote cache.Three strictly separated layers:
Dashboard fetches 4 endpoints in parallel with Promise.allSettled. WebSocket updates patch local state directly — no refetch on every price tick.
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.
| Decision | Chosen | Alternative | Why |
|---|---|---|---|
| Market data | Finnhub REST | yahoo-finance2, Polygon | Stable API; 60 req/min free; WebSocket included |
| Rate limiting | Token bucket + priority queue | Simple delay loop | Maximises data freshness within budget; watchlist tickers get priority |
| Background jobs | In-process two-timer loop | BullMQ, SQS | No extra infra; single-process Node; sufficient at this scale |
| Portfolio opt. | Gradient ascent on U=μ−Aσ² | Closed-form MVO | Stable for singular covariance matrices; works for any portfolio size |
| NLP | AFINN (Node) | OpenAI embeddings, HuggingFace | No API cost; no latency; runs in-process; sufficient for sentiment signal |
| WebSocket auth | Decode JWT from cookie on upgrade | Separate HTTP auth endpoint | Zero round-trip; session token already present in upgrade cookies |
| Read tracking | DB join table (UserNewsRead) | Client-side Set | Persists across sessions; survives reload; enables analytics |
| Candle-free detection | quote.changePct + intraday range | OHLCV 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 →Built to Accrue