Skip to content

Latest commit

 

History

23 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

FolioHealth — Your portfolio's health check-up

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.

Architecture

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.

Run it

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 8000

Frontend (Node 20+):

cd frontend
npm install
npm run dev        # http://localhost:5173

Environment 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

Tests

cd backend
.venv/bin/python -m pytest tests/ -v
  • tests/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

Finance concepts implemented

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%.

CSV import

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.

Asset classes in a CSV

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-QUOTE pair — unambiguous, since no equity is quoted that way. A bare BTC is not rewritten unless the file declares asset_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.

Instrument names

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.

Currencies

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.

Subscription (demo)

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.

Security & compliance

  • 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/account cascades everything)

Known limitations

  • 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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages