A personal, open-source, local-first investment platform. Self-hosted: your data never leaves your machine.
Quoin tracks a buy-and-hold portfolio (broad-index ETFs plus a few single names) and, later, a small trading sleeve — with allocation analysis, true look-through exposure, and support for the Bizkaia foral tax regime that off-the-shelf trackers ignore. The goal isn't to trade: it's to understand a portfolio, and to double as a learning project.
Status: v0.3.0, actively built. The immutable ledger, core domain (Money, event types,
computePositionswith average cost), CSV and.xlsxingestion (Trade Republic + Kraken), a Yahoo price provider with daily history, the app shell and the summary / holdings / movements / asset-detail / instruments / allocation / realised / target screens are in place. Look-through works end to end: fund compositions are imported from whatever the issuer publishes, and holdings are matched across issuers by canonical identity. Returns are reported both time-weighted and money-weighted, per instrument and for the portfolio, and can be read in today's purchasing power against INE's consumer price index. The tax module is next — see the roadmap.
Existing trackers do charts and dividends well, but none of them:
- model a jurisdiction-pluggable tax engine (Bizkaia foral rules as a first implementation);
- compute true look-through exposure — your real single-name weight counting holdings inside each ETF, not just direct positions;
- stay local-first and self-hosted with zero recurring cost, so the data you own actually stays yours and survives a broker or country change.
Quoin is built around an immutable ledger as the single source of truth: positions, cost basis, P&L, allocation and look-through are all pure projections derived from it.
React Router 8 (SSR) · React 19 · TypeScript (strict, with noUncheckedIndexedAccess) · Tailwind v4 · Lucide · Zod · Recharts · Papa Parse · Prisma 7 + SQLite (better-sqlite3 driver adapter) · decimal.js · Vitest.
Requirements: Node.js 24+ and pnpm.
cp .env.example .env # DATABASE_URL -> data/quoin.sqlite
pnpm install # builds better-sqlite3 + generates the Prisma client
pnpm run db:migrate # creates the database and the first migration
pnpm run dev # http://localhost:5173Import your data and fetch prices:
pnpm ingest --broker=trade-republic path/to/export.csv # CSV -> ledger (idempotent)
pnpm ingest --broker=kraken path/to/ledgers.csv
pnpm prices:map <ISIN> <SYMBOL> # map an instrument to a Yahoo symbol, e.g. VWCE.DE
pnpm prices:sync # fetch quotes for mapped instruments -> price snapshots
pnpm prices:backfill [ISIN] [1y|2y|5y|10y|max] # daily price history (default 5y)
pnpm exposure:map # list how every instrument resolves for look-through
pnpm exposure:map <ISIN> <KIND> [LEAF] # e.g. XS2183935274 COMMODITY XAU
pnpm identity:resolve # give holdings a canonical id so duplicates merge
pnpm identity:resolve --report # what merged, and what is still ambiguous
pnpm ipc:sync # INE consumer price index (national + Bizkaia)
pnpm target:set [<file>] # show, or record a version of, the savings planFund compositions are imported from the Instrumentos screen: drop the issuer's holdings CSV onto the fund's row. One parser handles every issuer — the weight column is found by being the one that adds up to about 100, so it survives a different language, layout or number format without a rule per provider. There is no command for it on purpose.
Quote symbols and exposure classifications live only in your local database (never in
the repo), so a public clone never discloses your holdings. Prefer EUR venues (.DE,
.AS, .MC) to avoid FX for now.
Two databases, on purpose. data/quoin.sqlite is the ledger — real trades, and the only
thing here that cannot be regenerated. data/dev.sqlite is scratch: point DATABASE_URL
at it, run pnpm db:seed for a synthetic portfolio, and develop against that. Commands
that destroy data refuse to run against the ledger, and refuse just as firmly when they
cannot tell which database they have been handed. Take a snapshot with pnpm db:backup
before anything you have not done before.
exposure:map exists because brokers do not report what a fund actually is: Trade
Republic labels both equity ETFs and physical-gold ETCs as FUND, so an ETC arrives
indistinguishable from an index fund. Stocks and crypto resolve from their type
automatically; ETCs and bond funds need one command each, once.
identity:resolve exists because issuers disagree on what to publish. Some list an
ISIN, others only a ticker, and they hold hundreds of the same companies — so the same
business arrives as US67066G1040 from one fund and NVDA.US from another and counts
twice. Both are mapped to a share-class FIGI via OpenFIGI,
which links one share class across countries. Set OPENFIGI_API_KEY in .env for a
free and far higher rate limit; without one it still works, just slower. Anything that
cannot be resolved keeps its raw identity: it will not merge with its twin, but it
still appears with the right value.
Share classes are deliberately not merged. GOOG and GOOGL, or Berkshire A and
B, are separate securities with separate ISINs and separate prices; reporting them
apart is more correct than tidying them together.
Other scripts:
pnpm run typecheck # react-router typegen + tsc
pnpm run lint # eslint (includes the layer boundaries)
pnpm run build # production build
pnpm run db:generate # regenerate the Prisma client after a schema change
pnpm run db:studio # Prisma Studio (GUI to inspect the data)
pnpm run db:backup # VACUUM INTO data/backups/, keeping the last 30
pnpm run db:seed # synthetic portfolio -> the scratch database
pnpm twr:explain # audit the portfolio TWR chain link by link, worst first
pnpm test # Vitest (pure domain / projection / mapper tests)
pnpm run test:integration # migrations against a temporary SQLite databaseSee docs/ARCHITECTURE.md. In short: a single React Router app
with app -> adapters -> core layers enforced by lint; money is always a string operated
on with decimal.js; data and secrets are never committed.
- Skeleton: layered structure, design tokens, lint boundaries
- Persistence: Prisma ledger schema (Instrument + LedgerEntry) and first migration
- Core domain:
Money, ledger event types, repository ports,computePositions(average cost) - CSV import: Trade Republic + Kraken adapters (filter card spending, dedup by transaction id)
- Holdings screen (sortable table, expandable per-position detail)
- Market data: Yahoo price provider,
PriceSnapshotpersistence,prices:sync,prices:backfill - Surface market columns (value, unrealized P&L, weight) + price freshness in the UI
- Asset-detail view (price chart, invested-vs-value, TWR/MWR)
- App shell (sidebar / bottom nav, theme toggle) and summary screen
- Movements screen (full ledger, URL-driven pagination)
- Exposure model: leaves, intrinsic resolution,
exposure:map - Instruments screen: classify exposure, import fund compositions by dropping a CSV
- Generic holdings parser: one parser for every issuer, no per-provider rules
- Allocation screen: true look-through, direct vs via-fund attribution, concentration threshold
- Canonical identity via OpenFIGI, so a company held directly and inside a fund counts once
- Read
.xlsxholdings directly (most issuers publish Excel, not CSV) - Ledger safety: scratch database, synthetic seed, verified
VACUUM INTObackups - Realised P&L broken down by sale (AVCO at the instant of each sale)
- Portfolio target: the savings plan, versioned by the date it took effect
- Real returns: a nominal/real switch backed by INE's monthly consumer price index
- Portfolio-level TWR and MWR, with an XIRR solver that refuses rather than guesses
- Trading sleeve, watchlist and trade journal
- Bizkaia foral tax module (FIFO lots)
- DCF valuation module
© 2026 Aritz Martínez. Licensed under AGPL-3.0-only — you may use, fork and even run it as a service, but any derivative (including a hosted service) must remain open under the same license.
