Nivesh-Path
AI behavioral-finance paper-trading platform
New investors repeat the same mistakes (selling in a panic, buying after a spike, putting everything in one stock) and rarely see it in their own trades.
Designed and built solo
Paper trading with virtual cash. Educational, not financial advice.
01 / The problem
What needed solving
Nivesh-Path ("path to investment") gives every user $100k in virtual cash to paper-trade real US stocks at live prices, then holds up a mirror: every trade is checked for panic selling, FOMO buying and overconcentration, using the prices at that moment.
An AI coach explains the habit with the user's own trades, and an advisor explains the portfolio's risk. Both are educational, not financial advice.
Real market data has a hard budget: the free plan allows 60 calls a minute, and a single trade needs about 11 price lookups. So the system has to be careful about money, about data and about cost at the same time.
60 calls a minute
02 / Constraints
What the design had to respect
Money can't go negative
Even with parallel orders from two app instances and retried requests, an account must never overdraw or buy twice.
A free-tier data budget
Market data is rate-limited, so almost every lookup must be answered from a cache.
The LLM can fail
No key, an outage, a rate limit or a refusal must still leave a working page; the model may never do the arithmetic.
Live users during changes
Moving the ledger from MongoDB to PostgreSQL had to happen on the live site without losing a cent.
Cheap to run
Always-on on DigitalOcean with managed databases; AWS only for a demo stack that is created, measured and destroyed.
03 / Architecture
How it fits together
The architecture as text
Parts
- React SPA (React 18 · TypeScript): Markets, stock pages, the trade panel, the portfolio dashboard, the Behavioral Mirror and the AI Advisor. TanStack Query for server state, Zustand for UI state.
- Express API (Node.js 22 · JWT): The REST API for auth, market data, orders, portfolio and patterns, with JWT on every private route and rate limits on the paid AI endpoints.
- Quote cache (Redis TTL cache): A small TTL cache in front of every market-data lookup: quotes, profiles, key stats and news.
- Finnhub (market data API): Live quotes, company profiles, metrics and news for US tickers.
- Nightly job (EventBridge · Lambda): Every weekday at 4:30 pm New York, saves the daily close of every held stock and re-checks overconcentration for all users.
- Behavioral Mirror (pure rule functions): Checks every trade for panic selling, FOMO buying and overconcentration using the prices at that moment, and scores impulse control and diversification.
- AI service (FastAPI · LangChain): The Portfolio Advisor and the behavior coach. Computes the metrics in Python, retrieves the user's relevant trades, and asks the model for a schema-validated answer.
- Claude API (Anthropic Claude API): The LLM provider: structured outputs validated against a Pydantic schema.
- PostgreSQL ledger (Prisma · Neon): The money: accounts (NUMERIC cash) and trades. One database transaction per order.
- MongoDB (users · AI memory): Users, Behavioral Mirror events and saved analyses. The source of truth the AI memory is rebuilt from.
- ChromaDB (per-user RAG index): One vector collection per user holding their embedded trades, so the advisor can retrieve the most relevant history.
- Grafana Cloud (OpenTelemetry): Traces, metrics and logs from both services, a dashboard and email alerts.
Connections
- React SPA → Express API: REST with a JWT; orders carry an Idempotency-Key
- Express API → Quote cache: every price lookup
- Quote cache → Finnhub: only on a cache miss
- Express API → PostgreSQL ledger: one transaction per order
- Express API → Behavioral Mirror: check the trade
- Behavioral Mirror → MongoDB: save the pattern
- Express API ↔ AI service: advisor and coach requests; AI reads trades through /internal
- AI service → ChromaDB: embed and retrieve trades
- AI service → Claude API: prompt with computed metrics, structured answer
- AI service → MongoDB: cold-start rebuild, saved insights
- Nightly job → Express API: /internal jobs with a token
- AI service → Grafana Cloud: traces, metrics, logs (both services)
04 / Follow one request
Follow one trade
STEP 01
The order is placed
The trade panel posts the order with the user's JWT and a fresh Idempotency-Key. If the network drops and the browser retries, the same key returns the first result instead of buying twice.
STEP 02
The server prices it
The price comes from the server, never the browser. The quote cache answers almost every lookup; only a miss reaches the market-data API.
STEP 03
One transaction moves the money
In one PostgreSQL transaction the account row is locked (SELECT … FOR UPDATE), cash is checked and debited, and the trade is inserted. CHECK constraints refuse negative cash even if the code were wrong.
STEP 04
The Behavioral Mirror looks at it
Was it bought after a real run-up, close to the 52-week high? Is one stock now too much of the account? A detected pattern is saved and shown in the trade panel right away.
STEP 05
The AI remembers the trade
The trade is embedded into the user's own vector collection, so later questions can retrieve it. If that collection was wiped by a deploy, it's rebuilt from MongoDB.
STEP 06
The coach explains the habit
Asked for coaching, the AI service computes the numbers in Python, retrieves the relevant trades, and asks the model for a schema-validated answer that quotes the user's own trades. No model? A rule-based answer instead.
STEP 07
At 4:30 pm New York, the nightly job runs
EventBridge Scheduler starts a Step Functions workflow; a Lambda saves each held stock's close through the web service's token-only /internal API and re-checks concentration for everyone.
STEP 08
And it's all one trace
OpenTelemetry carries the trace from Node into Python, so the whole request shows up as one trace in Grafana, with alerts on latency, errors and AI health.
05 / Key decisions
The choices, and what they cost
DECISION 01
Move the money to PostgreSQL
- CHOSE
- a PostgreSQL ledger (Prisma, NUMERIC money, CHECK constraints, SELECT … FOR UPDATE on the account row, idempotency keys)
- OVER
- keeping cash and trades in MongoDB with atomic $inc updates
- BECAUSE
- the database itself then refuses an overdraft, and row locks make parallel orders safe: 20 parallel $10,000 buys on a $100k account fill exactly ten.
- TRADE-OFF
- two databases to run, and a live cutover to plan: users, Mirror events and AI memory stay in MongoDB.
DECISION 02
Cut over with a short pause, not dual writes
- CHOSE
- a maintenance pause of about five minutes, a dry run, copy and verify tool, then one switch
- OVER
- dual-writing to both databases during the move
- BECAUSE
- it's simple and verifiable for a site at this scale; the verify step proved every user's cash and holdings matched before the switch.
- TRADE-OFF
- trading is paused briefly; rollback means switching back and exporting anything written since.
DECISION 03
The model explains; code computes
- CHOSE
- computing P/L, weights and concentration in Python, sending them to the model, and overwriting the model's own diversification number with the computed one
- OVER
- letting the LLM read the holdings and do the maths
- BECAUSE
- an LLM's arithmetic isn't reliable, and live testing had already found two different diversification scores on two pages.
- TRADE-OFF
- more code in the AI service and a schema the model must follow; a rule-based fallback answers when the model can't.
DECISION 04
No NAT gateway on the AWS demo
- CHOSE
- tasks in public subnets with security groups as the inbound boundary, and the database in private subnets with no internet route
- OVER
- private subnets behind a NAT gateway
- BECAUSE
- a NAT gateway costs about $32/month even when idle, for a stack that exists a few hours per demo.
- TRADE-OFF
- no network-level egress control on the tasks; in production I'd use private subnets with a NAT or VPC endpoints.
DECISION 05
GitHub OIDC, no AWS keys
- CHOSE
- short-lived credentials from GitHub's OIDC token, with a deploy role fenced by region, service, name pattern and a permissions boundary
- OVER
- long-lived access keys stored as repository secrets
- BECAUSE
- there is no key to leak; even a tampered workflow can't create a role that does more than the boundary allows.
- TRADE-OFF
- a one-time bootstrap in CloudShell and more IAM policy to read and review.
06 / The hardest bug
Symptom to regression test
The Mirror accused me of FOMO for an ordinary buy
SYMPTOM
Testing on my own account, the Behavioral Mirror flagged index-fund (SPY) buys as FOMO, 1.0% from the 52-week high after a +1.7% move, and it would flag a sale as a panic sell even when it was taken at a profit. The impulse-control score, and the coach's advice, were built on those false flags.
HOW IT WAS FOUND
In live testing on my own account. Each flag that looked wrong was traced to the rule in code, and a written review confirmed each one before anything changed.
ROOT CAUSE
The FOMO rule only asked 'is the price near its 52-week high?' and treated the run-up as a severity bonus, not a requirement. The panic rule never compared the sale price with the average cost, and only daily data was used, so a crash on the day itself was invisible.
FIX
Rule v2, as pure functions: no FOMO flag without a run-up of at least 5%; no panic flag at or above average cost; today's move and intraday high included. A start-up migration re-graded old events and deleted the false alarms, safely repeatable.
WHAT GUARDS IT NOW
Boundary tests at every threshold (run-ups of 4.99, 5, 10 and 15%; a sale at cost versus one cent below). Each fix was broken on purpose to prove its tests fail. Tests went from 238 to 278.
07 / Results
Measured, with the file that proves each number
CI on the main branch, live from GitHub:
- 300+
tests (Jest, Supertest, pytest, Vitest, Playwright)
CI
source ↗ for tests (Jest, Supertest, pytest, Vitest, Playwright) (opens GitHub) - 20 parallel
$10,000 buys against real PostgreSQL; exactly 10 fill
integration test, two app instances
source ↗ for $10,000 buys against real PostgreSQL; exactly 10 fill (opens GitHub) - 2.3×
faster read p95 with the cache
k6 on one GitHub Actions runner (4 vCPU), fake market data, Postgres ledger
source ↗ for faster read p95 with the cache (opens GitHub) - 9.6 ms
read p95 with the Redis cache on
k6 on one GitHub Actions runner (4 vCPU), fake market data, Postgres ledger
source ↗ for read p95 with the Redis cache on (opens GitHub) - 42,000
requests across both load-test runs, 0 errors
k6 on one GitHub Actions runner (4 vCPU), fake market data, Postgres ledger
source ↗ for requests across both load-test runs, 0 errors (opens GitHub) - 99.7%
cache hit rate on the read mix
k6 on one GitHub Actions runner (4 vCPU), fake market data, Postgres ledger
source ↗ for cache hit rate on the read mix (opens GitHub) - 40+
REST endpoints, JWT on every private route
Node/Express API
source ↗ for REST endpoints, JWT on every private route (opens GitHub) - 1–3 tasks
ECS Fargate web tasks, autoscaling at 60% CPU
AWS demo stack (Terraform)
source ↗ for ECS Fargate web tasks, autoscaling at 60% CPU (opens GitHub) - 26 spans
in one live trace across the Node and Python services
live site, Grafana Cloud
source ↗ for in one live trace across the Node and Python services (opens GitHub)
08 / What I'd do differently
With hindsight
- Design the ledger in PostgreSQL from day one. Moving money between databases on a live site worked, but it cost a whole phase of tooling that a first schema would have avoided.
- Measure capacity, not just latency. The load tests are closed-loop with think time, so they show response times, not the maximum throughput; I'd add an open-model arrival-rate test on dedicated hardware.
- Write the Behavioral Mirror's thresholds as testable rules before shipping the first version, rather than finding the edge cases in live use.
09 / Tech used