Italian tax report generator for foreign investments. Modello Redditi PF.
Two-phase CLI: decaf load (broker data -> SQLite) + decaf report (SQLite -> output).
Full architecture with Mermaid diagrams: doc/ARCHITECTURE.md.
src/decaf/
cli.py CLI with load/report subcommands
parse.py IBKR FlexQuery XML -> ParsedData
schwab_parse.py Schwab 3-file orchestrator -> ParsedData
schwab_gains_pdf.py Year-End Summary PDF -> RealizedLot (per-lot gains)
schwab_vest_pdf.py Annual Withholding PDF -> vest FMV per date
statement_store.py SQLite storage (deduplicating, idempotent)
ecb_cache.py ECB rate cache (SQLite)
fx.py FX service (ECB primary, IB validation)
prices.py Year-end mark prices (yfinance)
forex.py Forex threshold analysis
forex_gains.py Forex LIFO gains per account (USD lot tracker)
quadro_rw.py IVAFE computation
quadro_rt.py Capital gains (stocks only; forex via forex_gains)
quadro_rl.py Interest + dividends + WHT
output_cli.py Rich terminal tables
output_xls/pdf/json File outputs
models.py Domain dataclasses
holidays.py Italian business days
schwab_auth.py OAuth2 (kept for future API use)
schwab_client.py Trader API client (kept for future API use)
- Stock RT uses the cost basis on the actual lot sold. Circ. AdE 165/E/1998 §2.3.2 mandates no FIFO/LIFO on partecipazioni: base imponibile = corrispettivo − costo effettivo di acquisto del lotto ceduto, documentato. Modern brokers track every lot and let the account holder choose which to sell (Schwab Tax Optimizer, IBKR matching method). Broker reports P/L on the chosen lot. This is the correct method, not a simplification. See doc/NORMATIVA.md §Metodo di determinazione del costo per le partecipazioni.
- RSU cost basis = Valore Normale, not US FMV at vest. Art. 68 c. 6 TUIR: il costo fiscalmente riconosciuto è il valore che ha concorso a formare il reddito. Per RSU l'IRPEF è stata assolta sul Valore Normale (media aritmetica dei prezzi di chiusura del mese precedente al vest, art. 9 c. 4 lett. a TUIR) — quello che appare come ITA FMV sull'Annual Withholding Statement Schwab e come reddito di lavoro dipendente sulla CU. Il cost basis del Year-End Summary è il W-2 US basis (FMV del giorno di vest), sistematicamente diverso.
schwab_parse.pysostituisce qty × ITA FMV quandolot.date_acquiredcorrisponde a un vest date (±3d). Il campobroker_pnldella RTLine resta il P/L originale Schwab come colonna di riconciliazione;gain_loss_eurè calcolato sul Valore Normale. - Forex LIFO per account: yes. Valute are fungible, specific identification isn't possible. Art. 67 c. 1-bis TUIR + circ. 165/E §2.3.2 + risposta AdE 204/2023 mandate LIFO per singolo conto. Decaf computes it (brokers don't provide forex P/L). See doc/INTERNALS.md.
- ECB rates primary. Cambio BCE per AdE. IB rates for validation only.
- Schwab API is broken for EAC accounts. Use three PDF+JSON files instead. See doc/INTERNALS.md.
- Decimal everywhere. Never float for money. Architecture tests enforce this.
- Settlement dates for IVAFE, trade dates for RT.
source .venv/bin/activate
scripts/lint.sh # ruff + pyright
scripts/test.sh # 146 tests (includes e2e against reference fixtures)
python -m decaf load # IBKR
python -m decaf load --broker schwab ... # Schwab (see README.md)
python -m decaf report --year 2025 # Generate reportPre-commit hook enforces ruff + pyright + tests on every commit.
- Bite-sized commits. One logical change per commit. Don't batch unrelated
work into a single commit, even if cli.py or a shared file hosts both.
Use
git add -pto split hunks when needed.
Package is published to PyPI as decaf-tax. Full recipe in README § Sviluppo § Rilasciare una nuova versione. Non-obvious constraint:
- README jsdelivr URLs must be pinned to the release tag (
@vX.Y.Z, not@master). jsdelivr caches the@masterref for 7 days, so a PyPI release shipped with@masterURLs will render a stale manual/cover/examples on the project page for up to a week after push. The releasesedin the README recipe does the pin automatically — never skip it.
| Doc | Language | Content |
|---|---|---|
| doc/ARCHITECTURE.md | English | Data flow, module boundaries, type system, testing |
| doc/GUIDA_FISCALE.md | Italian | How to fill the dichiarazione from decaf output |
| doc/NORMATIVA.md | Italian | Tax law references with Gazzetta Ufficiale links |
| doc/INTERNALS.md | English | Implementation gotchas, broker quirks |
| doc/QUERY_SETUP.md | English | IBKR Flex Query configuration |
| doc/BACKTEST.md | Italian | Backtesting workflow, fixture layout, prices.yaml |
Implemented in forex_gains.py. LIFO tracker keyed by account_id:
USD acquired from stock sells/dividends/interest enter their own
account's queue, disposed via EUR.USD conversions and wire transfers
consume the most-recently-acquired lot of the same account first.
Lots never cross accounts. quadro_rt.py always skips forex trades;
forex_gains_to_rt_lines() converts LIFO gains to RT lines when
threshold breached. Full details in doc/INTERNALS.md.