Architecture
Felix is an advisory-only wealth-management copilot: every morning it tells you what happened to your portfolio, whether you're on track for retirement, and exactly which actions to consider — rebalancing drift, tax-loss harvesting, deploying cash, restoring liquidity. It proposes; you execute; it verifies you actually did. Built for one user first, shaped for multi-user — and as of phase 8, multi-user is enabled for real. This page is the whole system in one narrative; the numbered docs below drill into each piece.
Status: built. All eight roadmap phases shipped in the felix-app monorepo (the architecture in docs 01–05 is fully implemented); each component doc carries inline as built notes where the implementation refined the design, and every open question is resolved with the shipped answer.
Core principle: deterministic facts, generated words
Every number is computed by tested code — drift vs model, TLH candidates, wash-sale windows, liquidity breaches, retirement projections. The LLM never does math. It receives a compact fact pack, decides what's material, narrates it, and emits schema-validated structured actions that cite the fact IDs they came from — and validators enforce that action quantities match the deterministic plans they cite. That is what makes the AI semi-deterministic, auditable (the "based on" chips in the UI), and cheap: small contexts, low temperature.
System overview
The shape of the whole system: three input feeds, one canonical store, two processing paths, an AI layer, and the app — with a nightly cron driving the data flow.
How to read it: the nightly chain runs top to bottom. Shared pipelines refresh the lake, then per user — import-if-any, then the analytics engine writes typed facts, then the daily agent narrates them into the briefing, insights, and proposed actions (including the personalized "markets for you" pass over the shared recap). The verifier fires on every import commit to match accepted actions against real transactions. Storage authority is split: the lake owns history (prices, snapshots, article archive), Postgres owns interactive state (portfolio, facts, AI artifacts, chat) — dual-homed tables have one declared owner. All LLM traffic flows through the gateway: shared jobs on the house key, per-user work on that user's virtual key with budgets, forwarding their own provider key when BYOK is set.
Three non-obvious choices sit underneath it:
- The canonical store is the real product. Upstream is messy and source-specific; downstream sees one clean truth. Its job is to reconcile a user's many sources within their single jurisdiction — each user is pinned to one country, US or India, never both. ISIN is the natural instrument key, so cross-listing and cross-currency reconciliation don't arise.
- Analytics is deterministic and AI-free. XIRR, drift, exposures are plain, tested code. LLMs are bad at arithmetic; the AI layer reads these numbers, never computes them.
- Orchestration is just cron. No streaming, no queues-at-scale. Nightly is enough at this horizon — the slowest window is days, users number in the tens.
Constraints & assumptions
Five facts shape every decision here. They are load-bearing — each choice above and below traces back to one of them:
- Advisory-only is a regulatory firewall, not just a feature. Nothing executes and there are no fees at the friends-and-family level, so Felix stays outside the heavy regimes in both countries. Architecturally, the system is never provisioned with any write or trade scope at any broker — the inability to execute is enforced by which credentials the system holds, not by a prompt.
- Slow horizon plus tiny scale equals boring, cheap infrastructure. The shortest window is days; users number in the tens. So: batch and scheduled refresh, EOD market data, no streaming, no horizontal scale. Spend the complexity budget on data normalization and the AI layer.
- Each user lives in a single jurisdiction — US or India, never both. This isn't "normalize across two countries"; it's one codebase parameterized by country, every user pinned to one. It removes cross-currency portfolio math and cross-listing reconciliation.
- "Daily-updated" is achieved by re-pricing, not by daily account pulls. Holdings change slowly (only on a trade); value changes daily (prices). The nightly job re-marks a slowly-synced holdings set to today's EOD prices — a fresh dashboard every night without hitting the broker daily.
- AI cost has the same shape as data cost: a fixed shared component plus near-zero marginal cost per user. The expensive AI work — digesting news, scoring, summarizing — is a function of the content, not the reader, so it runs once and fans out.
Data connectivity & the daily-refresh model
This is where US and India diverge, and where "update every day" actually gets resolved. Daily account pulls aren't needed for a daily-updated portfolio: holdings change slowly (only when someone trades); value changes daily, but that's price-driven, and prices come from the market-data feed.
The reframe — Fetch holdings periodically, then re-mark them to today's EOD prices every night. The dashboard updates daily; the broker is never hit daily. A trade made since the last holdings sync is caught at the next sync or logged manually — a non-issue on a multi-day horizon.
- US — SnapTrade only. Scope is equities and ETFs, read-only, and a brokerage connection already includes the cash inside it, so no separate bank rail is needed. SnapTrade is brokerage-native, returns exactly the needed data (positions, orders, transactions, balances), and is cheap and transparent (5 free connections, then about $1.50 per connected user per month). Plaid's edge is bank and cash breadth this scope doesn't need; defer it until someone wants external checking or savings in net worth.
- India — CAS as the no-login backbone. Order-capable APIs (Kite, Upstox, Angel, Dhan) require daily auth — a cost that buys execution Felix doesn't use. So read through rails that aren't order-capable: the NSDL/CDSL Consolidated Account Statement (PAN-based, covers all demat and MF across every AMC, ISIN-wise with current value, requestable on demand with zero login) is the backbone. An optional broker API serves anyone who wants fresher syncs and doesn't mind logging in; manual upload covers EPF/PPF, FDs, gold, real estate, and ESOPs.
| United States | India | |
|---|---|---|
| Brokerage / equities | SnapTrade (read-only) | CDSL/NSDL CAS (backbone) + broker API on-login |
| Cash | included in brokerage | broker funds API / CAS / manual |
| Funds | via brokerage | CAS / MF Central / CAMS-KFintech |
| Retirement | 401k/IRA via aggregator (partial) | NPS via CAS; EPF/PPF manual |
| The long tail | manual / CSV | manual / CSV |
| Daily-fresh value | re-price nightly | re-price nightly |
| "Right" persistent rail | open banking (Plaid) | Account Aggregator (needs a regulated entity) |
Design implication: wrap each source behind a common SourceAdapter.
Adapters are grouped by jurisdiction; a user only ever touches their own
jurisdiction's set, so normalization is within a country, never across.
Swapping CAS-parsing for the Account Aggregator later is a localized change.
The two feeds: market data vs news
These are different kinds of data serving different kinds of consumer, even though they share the enrichment tier. Keep the wall clean:
- Market data is structured, and feeds the deterministic analytics. Prices, FX, and fundamentals go in as numbers and come out as valuation, drift, and performance. Stored as time series. The LLM never touches this math.
- News is unstructured, and feeds the AI layer. Headlines, events, and sentiment go in as text and come out as reasoning. Stored as documents plus embeddings in pgvector, tagged to instruments — consumed reactively (news filtered to the user's holdings) and proactively (broader market and sector news feeding the trade-ideas engine).
Corporate actions straddle both layers: a dividend, split, bonus, or buyback is simultaneously a news event and structured data that must update quantities, cost basis, and valuation. Route them to the structured side, or a 2-for-1 split shows up as a headline while the position count silently goes wrong.
The AI layer
Felix's agents are Pydantic AI — typed outputs with validation and
auto-retry, tools, streaming, and OpenTelemetry tracing — running behind a
self-hosted LiteLLM proxy. Model-tier aliases (embed, cheap, mid,
best) make a model swap a config change, and the gateway is where virtual
keys, per-user budgets, and BYOK forwarding live (see
Tenancy, BYOK & ops). The
entire AI loop runs server-side: keys, tool execution, and retrieval all
live on the backend — the browser only ever streams the result.
Money is involved and the output is advice, so the shape is deterministic pipelines with LLM reasoning steps, not a free-roaming agent:
gather facts (tool calls into Felix's own analytics and news APIs) → reason (LLM) → emit a structured, schema-validated recommendation → human reviews.
The chat is a first-class surface. Each turn runs hybrid retrieval: the
user's current portfolio snapshot and computed metrics are pre-loaded into
context every turn (always needed, cheap, deterministic), while news and search
are exposed as tools the model calls only when a question warrants digging into
NewsItems in pgvector. The whole loop runs in a server route handler, and tool
queries execute under the user's JWT so RLS stays the backstop even inside a
tool call. An incognito mode (no_persist threaded through the pipeline)
suppresses every DB write for a session — no history, no embeddings, no audit
event — so sensitive questions retain nothing while normal-path
auditability is preserved.
Guardrails are baked into the architecture, not just the prompts:
- The LLM never produces final numbers — the compute layer does.
- Every recommendation carries its inputs, prices and date, and confidence, so it is fully reconstructable.
- Output is structured, validated JSON, so a recommendation is data, not prose.
- Tool calls run server-side and are arg-validated against a schema before they execute; retrieved news is treated as data, never instructions (prompt- injection containment, since news is untrusted third-party text).
- No execution path exists at all — the system holds no trade scope at any broker. This is the advisory-only firewall, by construction.
Compute economics — once for all, then cheap per user. The expensive work
(article summary, sentiment, entity tagging, embedding, per-instrument digests)
is a function of the news item and instrument, not the reader, so it runs once
in the nightly batch on the house key and is cached on the enriched NewsItem.
Per-user work (commentary, rebalancing and tax suggestions, chat) reasons over
that pre-digested cache and never re-reads raw articles. Dedupe enrichment by
article hash and cache per-instrument digests with a freshness window, and even
the BYOK AI bill becomes a fixed shared component plus near-zero marginal cost
per user.
Canonical data model
Jurisdiction sits on the User, so everything below is single-jurisdiction per user:
- User —
jurisdiction(US/IN),home_currency, optional Household grouping; inherited by all accounts. - Account —
provider,type(brokerage / demat / bank / retirement / manual). - Instrument —
ISIN(PK),ticker,asset_class,sector,currency; one jurisdiction's universe, no cross-listing reconciliation. - Holding / Position —
account,instrument,quantity,cost_basis,lots; slow-changing, re-marked to daily prices. - Transaction — buy / sell / dividend / interest / fee.
- CorporateAction —
instrument,type,ratio,ex_date; structured, updates quantities and cost basis. - Price and FXRate — time series (FX only if a user holds foreign-listed securities).
- NewsItem —
instrument(s),source,url,published_at,sentiment,snippet,embedding; enriched once, fanned out. - TargetAllocation / Goal — the policy the rebalancer and planner reason against.
- ChatSession / ChatMessage — persisted chat history; incognito sessions create no rows.
- Recommendation and Action — the advice artifact and its executed or dismissed outcome; incognito recommendations are display-only.
- AuditEvent — what data and prompt produced what advice, when; skipped for incognito.
Stack
- Monorepo:
platform/web-app(Next.js App Router + Supabase Auth via@supabase/ssr),platform/brain(Python: FastAPI, pipelines, analytics, agents),platform/packages/schemas(shared types),platform/packages/db(Drizzle TS schema — the single DDL source of truth;drizzle-kit generateemits the plain-SQL migrations applied against Supabase, and the Python side stays aligned via generated types plus an enum tripwire test), andinfra/(docker-compose for LiteLLM and MinIO; the Supabase CLI provides the local stack). - Lake: Parquet on object storage (R2/S3, MinIO in dev), raw and curated
zones, a
market/prefix shared vsusers/{id}/per user, queried via DuckDB through a single lake module — the only code that knows paths and providers, and the seam for later upgrades. - App DB: Supabase (managed Postgres + pgvector).
user_idon every row from day 1; RLS enabled from day 1 (anon key plusauth.uid()policies); BYOK keys in Supabase Vault. - AI: Pydantic AI agents behind a self-hosted LiteLLM proxy; model-tier aliases; pgvector retrieval; streaming for the chat — all server-side.
- Scheduling: a cron-driven
brainCLI with a run-status table for the MVP; graduate to Dagster when pipelines multiply. - Connectivity: SnapTrade (US); CAS parser plus optional Kite/Upstox/Angel
(India); a generic CSV/manual importer. As built (v1): the CSV importer is
what shipped, behind the
BrokerConnectorcontract SnapTrade/CAS will implement later. - Market data & news: as built (v1, US): prices on yfinance behind the
MarketDataProviderinterface (Tiingo is the paid upgrade path); news on the Finnhub free tier behindNewsSource. The candidates originally listed here — Polygon or EODHD (which also covers India), NSE/BSE bhavcopy for India EOD, Alpha Vantage/Marketaux plus filings and RSS — remain the menu those interfaces were built for.
Tenancy & cost model
- Shared, house key: market-data ingest, news clustering and summaries, the generic market recap, reference data — none of it touches user data.
- Per-user, BYOK-capable: portfolio import, analytics (zero LLM cost), the daily agent, recap personalization, chat, and verification. Each user's LiteLLM virtual key maps to their own provider key with budget caps; spend lands in a usage ledger. As built (phase 8): virtual keys provision lazily on first metered call; non-owner chat requires BYOK (friendly refusal), while the daily briefing falls back to the house key and says so in the published text; the funding kind is stamped on every run at call time.
- Rule of thumb: any prompt containing user data runs on the user's key; anything user-agnostic runs once on the house key.
- Budgets (settled, phase 8): $20/mo house cap · $10/mo per-user virtual key · $2/day chat · alert at 80% — enforced by the gateway with a nightly spend reconciliation that alerts on >5% drift.
- Isolation: Supabase RLS from day 1; the Python brain uses the service-role
connection with tenancy enforced in code (repositories built from a user
context, cross-tenant CI tests from phase 1); lake-prefix separation; BYOK keys
at rest in Vault (custody proven live — a pasted secret exists nowhere but
Vault); session-derived identity everywhere; a CI lint fails any migration
that adds a
user_idtable without a policy.
At the current defaults the shared layer is roughly $1–3/month, a per-user month is about $20 (chat-dominated), and a solo deployment totals roughly $85–95/month at steady state — the running as-built system is cheaper today (yfinance + Finnhub free, no paid data tier; OTel wiring with no hosted observability backend; backups are Supabase Pro's own daily backups). The full breakdown is in the cost model.
Components
The build is decomposed into five design docs, each independently useful:
- Data pipelines & the lake —
universe-driven prices, news to deduped and clustered stories, and portfolio
ingestion behind a
BrokerConnector(CSV first). - Analytics & the daily AI — the deterministic fact catalog (drift, TLH, cash, liquidity, retirement MC), fact packs, the daily agent (action params code-built from cited facts — the model emits no numbers), and the action lifecycle and transaction-matching verifier.
- App DB & web — the SQL-first schema catalog, the two-plane API (Next CRUD / brain compute), Supabase Auth, and page-by-page UX from Today to Settings.
- Chat agent — the grounded toolbelt (what-if and retirement-override tools reuse the engine), the SSE event contract, and drafting actions from conversation.
- Tenancy, BYOK & ops — the gateway key hierarchy, Vault-backed BYOK with request-scoped forwarding, the usage ledger, isolation, observability, and evals-as-CI.
Build roadmap — all phases shipped
Ordered to make the app useful early: holdings visible, then facts computed, then the daily briefing end-to-end, then verification, then news and markets, then chat. Every phase below has shipped in felix-app — the list stands as the record of the build order, with what each phase actually delivered noted in the component docs' inline as-built notes.
- Phase 0 — Scaffold ✅: monorepo layout,
supabase initlocal stack, docker-compose (LiteLLM, MinIO), Next.js shell + Supabase Auth, Python brain skeleton. - Phase 1 — App DB schema + portfolio CSV import ✅ (BrokerConnector):
holdings visible in the app (CLI-first import;
genericmapping profile). - Phase 2 — Data-lake layout + price pipeline ✅ (EOD OHLCV, corporate actions, yfinance provider): valuations; the snapshot series behind charts.
- Phase 3 — Deterministic analytics engine ✅: drift, TLH scanner, cash deployment, liquidity, retirement projection — 17 typed fact kinds with golden-fixture tests.
- Phase 4 — Daily AI pipeline ✅: fact pack, Pydantic AI agent (reference-based actions, params code-built), briefing + insights + structured actions with evidence, actions inbox UI, the eval harness.
- Phase 5 — Action verification loop ✅: transaction matching, near-miss candidates, nudge tiers, expiry/invalidation, notifications v1, the DB-trigger state machine.
- Phase 6 — News pipeline ✅ (Finnhub → dedupe, embed, cluster, synthesize)
- Markets and Ticker pages with the personalized markets pass and the validator-enforced story-number boundary.
- Phase 7 — Chat agent ✅: 16-tool grounded toolbelt with deterministic what-ifs, SSE streaming UI, click-to-chat on artifacts, draft actions from conversation, the cached Ticker AI take.
- Phase 8 — Vault-backed BYOK ✅ + LiteLLM virtual keys and budgets ($20/$10/$2), RLS review + lint (enabled since phase 0), usage ledger with spend reconciliation, evals as a CI gate, OTel wiring, the EC2 runbook — and multi-user enabled for real.
What remains is operator work, not architecture: applying the hosted-Supabase migration queue, enabling Supabase Pro backups, and the EC2 stand-up session with the committed runbook.