FinTech capstone project (EPITA Summer School 2026). A portfolio dashboard that works like a doctor, not a spreadsheet: consolidated valuation, P&L, risk metrics, and a Portfolio Health Score (0–100) with plain-language recommendations.
React UI (Vite, port 5173)
│ /api proxy
FastAPI backend (port 8000)
├── SQLite (users, positions, realized trades, price cache, audit log)
├── Finance engine — pure Python, no I/O (app/services/finance.py)
└── Market data (app/services/prices.py):
yfinance (live) → DB price cache → offline mock JSON
The three-level price strategy means the demo works 100% offline: every
successful yfinance fetch is cached in SQLite, and 33 instruments — stocks,
ETFs (equity, bond, gold, real estate, emerging), crypto (BTC/ETH/SOL) and FX
pairs — have deterministic mock histories as last resort
(backend/data/mock_prices.json). Individual bonds never hit a provider at all:
they are repriced from the Treasury yield curve (app/services/bonds.py), whose
own fallback chain ends in backend/data/mock_treasury.json.
Backend (Python 3.12+):
cd backend
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python -m uvicorn app.main:app --port 8000Frontend (Node 20+):
cd frontend
npm install
npm run dev # http://localhost:5173Environment variables (all optional):
| Variable | Effect |
|---|---|
FOLIO_OFFLINE=1 |
skip yfinance entirely — demo/rehearsal mode |
FOLIO_DB=/path/to.db |
SQLite location (default backend/folio.db) |
FOLIO_JWT_SECRET=... |
JWT signing secret (set it in production) |
API docs (Swagger): http://localhost:8000/docs
cd backend
.venv/bin/python -m pytest tests/ -vtests/test_finance.py— every calculation validated against the hand-computed reference portfolio (docs/reference_portfolio.md)tests/test_api.py— authentication, input validation, and authorization (user A can never see user B's portfolio → 403), GDPR account deletion
| Concept | Where |
|---|---|
| Mark-to-market valuation | finance.total_value |
| Realized / unrealized P&L | finance.unrealized_pnl, RealizedTrade model |
| Portfolio return | finance.portfolio_return |
| Allocation & Herfindahl concentration index | finance.weights, finance.herfindahl |
| Annualized volatility (sample stdev × √252) | finance.annualized_volatility |
| Sharpe ratio (rf = 3%) | finance.sharpe_ratio |
| Maximum drawdown | finance.max_drawdown |
| Health Score = 0.4·diversification + 0.3·risk-fit + 0.3·drawdown | finance.health_score |
Risk-fit compares portfolio volatility to the target of the user's declared profile: conservative 12%, balanced 20%, aggressive 30%.
Positions can be imported from a broker CSV export (POST /api/positions/import,
"Import CSV" button on the dashboard). Two shapes are detected automatically:
- snapshot — one row per holding (
ticker, quantity, buy_price, buy_date). Sample file:docs/sample_import.csv. - ledger — one row per transaction, which is what brokers actually export.
Sample file:
docs/sample_ledger.csv.
A ledger is replayed in execution order and netted into holdings: buys and sells of the same instrument collapse into one position at weighted-average cost with fees included, and an instrument that ends up fully sold becomes a realized trade rather than an open position. Rows that are not trades (dividends, cash transfers, card payments) are skipped rather than reported as errors.
The parser is deliberately tolerant: , or ; delimiters, decimal commas
(150,50), several date formats (ISO, DD/MM/YYYY…), and common header
aliases. Header aliases are matched in a fixed priority order — a ledger
carries both a shares column (units) and an amount column (cash), and the
two must never be confused. Invalid rows are reported with their line number;
valid rows are always created.
A file can mix all four classes. The optional asset_class column
(aliases: type, category, instrument_type) says which is which; without
it, every row is read the way it looks.
| Class | Recognised by | Extra columns |
|---|---|---|
equity |
a single company, per the market-data provider | — |
etf |
a fund or ETF, per the market-data provider | — |
bond |
asset_class=bond, or simply a maturity date |
face_value, coupon_rate (in %), coupon_frequency, maturity_date, credit_spread_bps |
crypto |
asset_class=crypto, or a BASE-QUOTE symbol (BTC-USD, ETH-EUR) |
— |
A share and an ETF are told apart by asking, not by guessing. Nothing in the
string SPY says "basket" and nothing in AAPL says "single company", so the
class is taken from the provider's own instrument type (quoteType), cached in
instrument_meta next to the currency and the name, and served offline from
backend/data/mock_meta.json. A file that declares a class always overrules
the provider — it is the user's statement about their own holding. The same
lookup runs when a position is added by hand, so an instrument cannot end up
with one class when imported and another when typed in.
Two further rules exist because the alternative is a silently mispriced position rather than a visible error:
- A bond row states the price paid per bond in currency (
985.50), while everything downstream stores a quote in % of par; the import converts it. Its coupon is a percentage (4.250= 4.25%), and an ambiguous decimal is rejected with both spellings offered rather than guessed at. - A crypto row is only auto-detected from a
BASE-QUOTEpair — unambiguous, since no equity is quoted that way. A bareBTCis not rewritten unless the file declaresasset_class=crypto, in which case the import completes it to the symbol the provider knows (BTC-USD). Guessing on bare symbols would eventually turn someone's small-cap equity into a coin.
Crypto is then valued, charted and risk-scored exactly like an equity — it is
quoted by the same provider. Only its label differs, and the dashboard uses it
to report a weight per asset class (allocation_by_class) alongside the
per-ticker weights.
A symbol is not always readable: a broker export identifies holdings by ISIN, and
FR001400U5Q4 tells the user nothing. Names are resolved from the market-data
provider and cached in instrument_meta alongside the currency, then shown in the
positions table, the allocation legend and the concentration warning, with the
symbol kept as a secondary line. When no source can name a ticker, the symbol is
displayed instead — a name is never required for the UI to render.
Holdings can be quoted in different currencies — a US stock in USD, a European
ETF in EUR — so every figure is converted into the user's display_currency
(default EUR, changeable in Account) before anything is summed. An instrument's
quote currency comes from the market-data provider and is cached in
instrument_meta; a CSV that names its own currency overrides it for the cost
basis, since that is what was actually paid.
Rates are fetched as ordinary price series (yfinance spells a pair USDEUR=X),
so FX inherits the same live → DB cache → mock fallback chain. Price histories
are converted at each day's own rate, not today's: for a euro investor holding a
US stock, the currency swing is part of the risk the dashboard reports.
After signup, users pick a plan on a pricing page: monthly €9.99 or
yearly €99.90 (the price of 10 months — 2 months free). A promo code field
sits above the plans; code BTS grants free lifetime access. Checkout is
simulated — no real payment is ever processed. Plans are stored on the user
(users.plan) via POST /api/auth/subscribe, and promo codes are validated
server-side only.
- bcrypt password hashing, JWT auth (12h expiry)
- Pydantic input validation on every endpoint (ticker charset, positive quantities, no future buy dates)
- Per-user authorization on every position operation (403 otherwise)
- Audit trail table for signup/login/positions/deletion events
- GDPR: data minimization (email + positions only) and right to erasure
(
DELETE /api/auth/accountcascades everything)
- No broker integration — positions are entered manually
- Prices are ~15-minute delayed (yfinance) or simulated (mock fallback)
- Portfolio history is reconstructed with current quantities (ignores when each lot was bought); historical volatility is not a prediction of future risk