# EUR Fund Ledger — Design Spec (2026-07-20)

**Status:** design approved (scope + cost-basis decided with operator). Implementation plan follows separately.

**> NOT tax advice.** This is an engineering design for a bookkeeping system. The tax *treatment* it records
(Box 3 passive capital, FIFO cost basis, EUR-functional) reflects the operator's resolved structure and must be
confirmed by a Dutch belastingadviseur before it is relied on for a filing. See
`docs/superpowers/2026-07-20-fund-tax-accounting-research-brief.md` and memory
`project_fxhnt_fund_tax_accounting_research`.

## Goal

A tax-ready, append-only, double-entry **EUR ledger** for the fxhnt fund's **passive Box-3 investment book**,
living in the existing Postgres/Timescale warehouse, fed by an automated **IBKR Flex** ingestion Dagster asset.
Phase 1 books **the fund only** — the passive IBKR investment book. The tech-BV bookkeeping and the arm's-length
BV→fund service stay in the operator's existing BV administration (explicitly out of scope).

## Why this shape (context)

- The operator's structure separates **arbeid** (tech-BV, taxed there) from **kapitaal** (private fund, Box 3).
  Phase 1 books only the kapitaal side → a clean Box-3 record with no BV entanglement.
- **EUR is the functional currency**, not just presentation. IBKR DU9600528 is EUR-native; if crypto ever goes
  live it is Bybit-EU (EUR, not USDT). There is **no USD leg**, so there is **no conversion pipeline** — only a
  dormant `currency` + `fx_rate_to_eur` seam (default EUR/1.0) so a stray non-EUR line is representable and
  correct rather than silently mis-booked. A pure-EUR book has **no FX P&L** — only trading P&L.
- Reuses existing patterns: SQLAlchemy 2.0 ORM (`Base`/`Mapped`, Postgres+SQLite), a `*Repo` with `migrate()`,
  a Dagster asset alongside the nightly ETL. No new infrastructure.

## Global Constraints

1. **EUR-functional.** Every posting stored in EUR. `currency` defaults `"EUR"`, `fx_rate_to_eur` defaults
   `1.0`. Build NO conversion pipeline — the FX columns are dormant insurance only.
2. **Append-only.** `journal_entries` / `journal_postings` are immutable. Corrections are reversing entries
   (never UPDATE/DELETE of a posted row). Ingestion is idempotent (re-running a Flex pull must not double-book).
3. **Balanced in EUR.** A DB-level trigger (or repo-enforced invariant on both Postgres and SQLite) rejects any
   entry whose postings do not sum to zero in `eur_amount`.
4. **FIFO** cost basis, applied consistently. Realized gains come from lot-matching, not cash-flow deltas.
5. **Scope = fund only.** No tech-BV, dividend, or arm's-length-service postings in Phase 1.
6. **Runs on paper now.** The IBKR Flex asset must run against the *paper* account (DU9600528) — the schema and
   the EUR-sum-to-zero invariant are validated on paper data before any real capital exists.
7. **Same test discipline as the codebase:** models work on SQLite (unit tests) and Postgres (prod); full suite
   green before finishing.

## Schema (SQLAlchemy 2.0 ORM, in a new `ledger` module)

- **`accounts`** — chart of accounts. `account_id` (str PK, e.g. `IBKR-EUR-cash`, `IBKR-securities`,
  `contributed-capital`, `trading-pnl`, `fees`), `kind` (asset|liability|equity|income|expense), `venue`,
  `currency` (default EUR). Per-venue sub-ledgers each balance standalone.
- **`journal_entries`** — header. `entry_id` (str PK), `entry_date` (date), `description`, `source_venue`,
  `strategy_tag` (nullable — the Dagster attribution dimension), `source_ref` (the idempotency key: the IBKR
  Flex trade/cash id, so re-ingest is a no-op). Immutable.
- **`journal_postings`** — lines. `posting_id` (PK), `entry_id` (FK), `account_id` (FK), `side` (debit|credit),
  `amount`, `currency` (default EUR), `fx_rate_to_eur` (default 1.0), `eur_amount`. Invariant: per `entry_id`,
  Σ `eur_amount`(debit) == Σ `eur_amount`(credit).
- **`cost_basis_lots`** — `lot_id` (PK), `account_id`, `asset`, `open_date`, `qty_open`, `qty_remaining`,
  `unit_cost_eur`, and on close a link to the realizing entry. FIFO consumption.
- **`fx_rates`** — `value_date` + `pair` → `rate`. **Dormant** — populated only if a non-EUR asset appears.
  Present so the seam exists; no ingestion job in Phase 1.

## IBKR Flex ingestion (new adapter surface + Dagster asset)

The existing `IbkrBroker` is a live `ib_insync` socket connection — NOT the Flex API. Flex is a separate
**Flex Web Service**: a per-query token + query id, a 2-step `SendRequest` → `GetStatement` poll (~1 req/sec),
returning an XML statement. Add a read-only `IbkrFlexClient` (new file) that fetches and parses:
trades, cash transactions, dividends, corporate actions, positions, NAV.

- Land raw XML/rows in an append-only `raw` schema (mirrors the DB-anchor-SSOT pattern), then a transform
  normalizes into `journal_entries`/`journal_postings` (idempotent on `source_ref`).
- One Dagster asset (`fund_ledger_ingest` or similar) alongside the nightly ETL; reuses monitoring patterns.
- W-8BEN note: as a non-US person the account gets a 1042-S, never a Consolidated 1099 — irrelevant to the
  EUR ledger math, but archive the annual Activity Statement as an audit doc.

## Out of scope (Phase 1) — deliberately deferred

- Bybit ingestion (no live crypto book; Bybit-EU is EUR anyway → add later, same schema).
- The tech-BV → fund arm's-length service postings, dividend, capital-contribution accounting (stays in BV admin).
- The Box-3 *werkelijk-rendement* parallel tax-basis calc (unrealized + fee-disallow) — only needed if the
  tegenbewijsregeling is elected; add when the advisor confirms it's worth it.
- Any FX conversion pipeline (there is no non-EUR leg).

## First build step

The EUR-functional ledger core (models + repo + the EUR-sum-to-zero invariant) **plus** the IBKR Flex ingestion
asset — validated on the paper account — before anything else. This exercises every load-bearing decision:
append-only immutability, the EUR-balanced invariant, the `strategy_tag` dimension, FIFO lots.
