Final docs link of the Étape 2 stack (#210-#217). - ADR 0015 (Accepted): holdings-per-snapshot model; aggregated-line-is-source-of-truth invariant (why non-breaking, cites ADR 0008 Modified Dietz per account); transaction-based alternative rejected (PP PR #779); latent gain vs per-security Modified Dietz (out of scope); detailed_since authoritative pivot; security-immortal-once-referenced (ON DELETE RESTRICT). Mirrors ADR 0014 structure. - Guide: new 'Détail par titre' section in docs/guide-utilisateur.md (per-security entry, detail-account wizard, latent gain) + matching docs.balance.* i18n keys (FR + EN, in-app guide surface). - architecture.md + CLAUDE.md: reconciled stale DB counters to real values — 20 tables / 24 indexes / 16 migrations (were 13/15/7 and 18/16/v9). Étape 2 delta: +2 tables (balance_securities, balance_snapshot_holdings) + 2 indexes + migrations v14/v15/v16. Backfilled v10-v16 in the migrations table, ADR table (0012 Rejected, +0013/0014/0015), new securities/detailed-save/latent-gain service surface. - CHANGELOG.md + CHANGELOG.fr.md [Unreleased]: extended with #215 (wizard), #216 (drill-down + latent gain), #211 (auto-conversion note); did not duplicate #214's per-security entry. Bilingual parity. Docs-only: no production TS/Rust logic touched. Gate green: build (tsc+vite) + 627 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
16 KiB
Markdown
122 lines
16 KiB
Markdown
# ADR 0015 — Bilan : détail par titre (holdings par snapshot)
|
||
|
||
- Status: **Accepted**
|
||
- Date: 2026-06-06
|
||
- Issues: #210 (schéma/migrations v14/v15 + types), #211 (conversion v16), #212 (service securities + save détaillé), #213 (refonte reducer + dispatch `account.kind`), #214 (UI saisie multi-titres), #215 (assistant « détailler »), #216 (drill-down + gain latent), #217 (tests), #218 (cet ADR + doc)
|
||
- Spec: [`spec-decisions-bilan-detail-titres.md`](../../spec-decisions-bilan-detail-titres.md), [`spec-plan-bilan-detail-titres.md`](../../spec-plan-bilan-detail-titres.md)
|
||
- Audit source: [docs/audit-bilan-2026-05.md](../audit-bilan-2026-05.md)
|
||
- Lève le gating « détail par titre reporté » posé par [ADR 0012](0012-balance-two-level-model.md) (Rejected) et le « Hors scope Étape 2 » de [ADR 0014](0014-balance-vehicule-attribut.md)
|
||
|
||
## Contexte
|
||
|
||
L'audit critique de la page Bilan (`docs/audit-bilan-2026-05.md`, revue CPA + UX) a défini une **trajectoire additive en trois temps**. L'Étape 0 (quick wins, #201) et l'Étape 1 (axe véhicule, [ADR 0014](0014-balance-vehicule-attribut.md) Accepted) sont livrées. L'**Étape 2 — détail par titre** — était reportée et explicitement hors scope de l'ADR 0014 (« l'Étape 2 fera l'objet d'un ADR distinct quand le besoin sera confirmé »). Ce besoin est confirmé : l'audit le qualifie d'« unique levier qui débloque le progressif pour les deux personas », et l'Étape 1 laisse une limitation connue — un compte ex-CELI/REER contenant de vraies actions reste un **montant agrégé** sous « Autres », sans détail de portefeuille.
|
||
|
||
État technique de départ : le modèle **coté** (`priced`) existe déjà, mais à raison d'**un seul titre par compte** — `balance_accounts.symbol` + `balance_snapshot_lines(quantity, unit_price, value, price_source)`, alimenté par le price-fetching maximus-api (ADR 0009/0011/0013). L'Étape 2 ne réinvente pas le `quantité × cours` : elle le **généralise de 1 titre/compte à N titres/compte**, avec un coût d'acquisition (`book_cost`) par position pour distinguer l'apport du gain latent.
|
||
|
||
### Pourquoi un ADR distinct (et non une extension de 0014)
|
||
|
||
L'ADR 0014 a posé l'axe véhicule en gardant le **grain « classe d'actif »** ; il a explicitement laissé ouverte la question du grain **« titre »**. Le détail par titre touche un autre plan du modèle (positions sous une ligne de snapshot, gain latent, bascule agrégé→détaillé, immortalité des titres référencés) qui mérite sa propre décision tracée — d'où ce 0015.
|
||
|
||
## Décision
|
||
|
||
**Le détail par titre est stocké par snapshot, sous la ligne agrégée existante, qui reste la source de vérité unique.**
|
||
|
||
### 1. Modèle holdings-par-snapshot (additif)
|
||
|
||
Trois migrations additives **post-v13** — `v14`, `v15`, `v16` — sans aucune modification des migrations `v1`–`v13` (checksum SHA-384 sqlx). `consolidated_schema.sql` (nouveaux profils) et les seeds sont synchronisés en parallèle.
|
||
|
||
- **`v14`** — deux tables :
|
||
- `balance_securities` : table normalisée et **partagée** des titres. `symbol TEXT NOT NULL UNIQUE COLLATE NOCASE` (forme canonique upper/trim), `currency TEXT NOT NULL DEFAULT 'CAD'` (préparé multi-devise, sans CHECK restrictif), `asset_type TEXT NOT NULL CHECK (asset_type IN ('stock','crypto'))`, `name?`. Un même titre est partagé entre comptes.
|
||
- `balance_snapshot_holdings` : le détail par titre d'un compte détaillé, rattaché à **sa ligne de snapshot agrégée** (`snapshot_line_id`, `security_id`, `quantity`, `unit_price`, `value`, `book_cost?`, `price_source?`, `price_fetched_at?`), `UNIQUE (snapshot_line_id, security_id)`. Deux index (`_line`, `_security`).
|
||
- **`v15`** — `balance_accounts.kind` (`'simple' | 'detailed'`, défaut `'simple'`, CHECK) + `balance_accounts.detailed_since` (DATE nullable). Backfill : un compte sous catégorie `kind='priced'` devient `'detailed'`. `balance_categories.kind` est **conservé** comme **défaut suggéré** pour les nouveaux comptes (additif, non supprimé).
|
||
- **`v16`** — convertit les comptes cotés **existants** (1 titre via `account.symbol`) en comptes `detailed` à **1 position** : création/liaison d'un `balance_securities` depuis le symbole normalisé, miroir de chaque ligne `priced` en holding, puis neutralisation de `quantity`/`unit_price` sur la ligne agrégée — **uniquement** si un holding a effectivement été créé (garde anti-perte de données pour les comptes priced à `asset_type` NULL ou sans symbole, laissés intacts). Idempotente façon v13.
|
||
|
||
### 2. Invariant central — la ligne agrégée reste la source de vérité
|
||
|
||
À chaque snapshot, **un compte = une ligne `balance_snapshot_lines`** (`UNIQUE(snapshot_id, account_id)` inchangé) :
|
||
|
||
- compte `simple` : la ligne porte `value` (saisie libre), `quantity`/`unit_price` NULL, aucun holding ;
|
||
- compte `detailed` : la ligne porte la **valeur totale** du compte — `value = SUM(holdings.value)`, chaque holding arrondi au cent puis sommé (**comparaison exacte au cent, pas de tolérance flottante**) — `quantity`/`unit_price` NULL ; le détail par titre vit dans `balance_snapshot_holdings`.
|
||
|
||
**C'est l'invariant qui rend l'Étape 2 non-breaking.** Tous les agrégateurs existants (`getSnapshotTotalsByDate` / `…ByCategoryAndDate` / `…ByVehicleAndDate`) et le **rendement Modified Dietz par compte** ([ADR 0008](0008-modified-dietz-pour-rendement.md), `compute_account_return`) continuent de lire **exactement** `balance_snapshot_lines.value` — zéro changement de chemin de lecture, zéro rebuild des lignes existantes. Les golden values des agrégations sont figées avant/après (tests de régression, #217). Le détail par titre est une **couche d'enrichissement** sous la valeur agrégée, jamais un remplacement.
|
||
|
||
### 3. `detailed_since` — pivot faisant autorité
|
||
|
||
La frontière agrégé/détaillé est portée par `balance_accounts.detailed_since` (DATE), **faisant autorité** plutôt qu'inférée du comptage de holdings : une ligne **à ou après** `detailed_since` **doit** porter des holdings ; **avant**, l'agrégé figé est toléré (historique pré-bascule en lecture seule). Un compte `detailed` peut donc cumuler des snapshots passés agrégés et des snapshots récents détaillés, mais la frontière est **explicite et requêtable**, conforme à la convention d'état du projet (`archived_at`, `is_active`). La validation est une passe séparée `validateDetailedSnapshot(account.kind, line, holdings)` ; le CHECK SQL par ligne reste inchangé ; la ligne agrégée s'écrit via le chemin « simple ».
|
||
|
||
### 4. Dispatch sur `account.kind` (et non `category_kind`)
|
||
|
||
Tout le dispatch UI/service bascule de `category_kind` vers **`account.kind`** (`BalanceAccountWithCategory.kind` propagé). `category.kind` (`simple`/`priced`) ne survit que comme **défaut suggéré** de l'`AccountForm` à la création. Un compte `detailed` sous une catégorie `simple` s'affiche donc correctement.
|
||
|
||
### 5. Gain latent vs Modified Dietz par titre
|
||
|
||
Le **gain/perte latent** d'une position = `value − book_cost`, en valeur et en pourcentage (`/ book_cost`), agrégeable par compte / classe d'actif / enveloppe, avec drill-down par titre dans le tableau des comptes (`computeUnrealizedGain`, garde-fou `book_cost = 0` ou NULL → « N/A »). `book_cost` est **saisi directement par position** (pré-rempli depuis le snapshot précédent) et historisé sur `balance_snapshot_holdings` — il n'est **pas** dérivé de transactions.
|
||
|
||
Le **rendement Modified Dietz reste au niveau compte** (inchangé). Un Modified Dietz **par titre** est **hors scope** : il exigerait des flux datés par lot et par titre (achats/ventes/RoC) — c'est-à-dire un suivi par lots, que l'app ne tient pas (modèle snapshot, pas journal de transactions). Le gain latent depuis l'acquisition est exact **sans** ces flux et répond directement au besoin (apport vs plus-value latente).
|
||
|
||
### 6. Assistant « détailler un compte agrégé » (date pivot)
|
||
|
||
Sur un compte `simple`, une action **« Détailler en titres »** (point d'entrée dans `BalanceAccountsTable`) bascule `kind='detailed'` et fixe `detailed_since = aujourd'hui`. L'historique agrégé passé reste **figé en lecture seule** ; les titres se saisissent au **prochain snapshot normal** (l'assistant ne capture pas de positions initiales). Le retour `detailed → simple` est **interdit dès qu'un holding existe** — bloqué côté UI **et** côté service (`updateBalanceAccount`, erreur typée), pour éviter les holdings orphelins.
|
||
|
||
### 7. Politique des titres : immortels une fois référencés
|
||
|
||
`balance_snapshot_holdings.security_id` → `balance_securities(id)` **`ON DELETE RESTRICT`** (miroir de `balance_account_transfers.transaction_id`, [ADR 0010](0010-fk-restrict-balance-transfers.md)) : un titre référencé par au moins un holding **ne peut pas être supprimé** — l'historique reste reproductible. La suppression de titre est donc **masquée dans l'UI** (immortalité assumée). `balance_snapshot_holdings.snapshot_line_id` → `balance_snapshot_lines(id)` **`ON DELETE CASCADE`** : supprimer une ligne (ou son snapshot) emporte ses holdings.
|
||
|
||
### Hors scope (explicitement exclu)
|
||
|
||
- **Suivi des lots d'achat / transactions** (PBR/ACB calculé, gain en capital *réalisé*, remboursement de capital, perte superficielle 61 j). `book_cost` est **saisi**, pas dérivé. Un Modified Dietz par titre en relève aussi.
|
||
- **Conversion multi-devise réelle** (taux datés, devise de référence). Seule la colonne `currency` est préparée ; affichage et agrégation restent **CAD**. L'affichage natif ≠ CAD est dé-scopé (chemin non testable tant qu'aucun titre non-CAD ne peut être créé).
|
||
- **Import CSV de cours local** (mode privacy/hors-ligne) : déféré, le price-fetching maximus-api couvre déjà l'acquisition des cours.
|
||
- **Agrégation du rendement par titre × véhicule fiscal** (`GROUP BY vehicle_type, security`).
|
||
|
||
## Alternatives considérées
|
||
|
||
### A. Modèle transaction-based (valeur dérivée) — **rejeté**
|
||
|
||
L'alternative établie dans l'industrie (cf. **[Portfolio Performance PR #779](https://github.com/portfolio-performance/portfolio/pull/779)** — « historical quotes from transactions ») : ne pas snapshoter de valeur, mais la **dériver** d'un journal de transactions (achats/ventes datés) × cours historiques. **Rejeté consciemment.**
|
||
|
||
- L'app est **snapshot-based par design** (saisie périodique manuelle d'un relevé daté), pas un journal de transactions. Tous les chemins Bilan lisent déjà des snapshots.
|
||
- Le **rendement Modified Dietz tourne déjà par compte** ([ADR 0008](0008-modified-dietz-pour-rendement.md)) à partir des snapshots + transferts taggés — sans valeurs intermédiaires. Passer au transaction-based forcerait à dériver la valeur à chaque date, soit une **refonte complète** du modèle de lecture, pour un gain (PBR exact, gain réalisé) qui est précisément le **suivi par lots hors scope**.
|
||
- Le **`book_cost` total saisi par position** colle à la référence courtier ([Wealthsimple stocke une *book value* totale, pas des lots](https://help.wealthsimple.com/hc/en-ca/articles/4409775037083-What-is-adjusted-cost-base-ACB)) ; le snapshot par titre est l'extension **additive** naturelle (cf. [Portfolio Performance — Securities/Transactions/Quotes séparés](https://help.portfolio-performance.info/en/reference/view/securities/all-securities/)).
|
||
|
||
### B. Table de positions courantes (hors snapshot) — rejeté
|
||
|
||
Maintenir une table « portefeuille courant » distincte des snapshots. Rejeté : incohérent avec le modèle snapshot (le portefeuille courant = le dernier snapshot), et la valeur agrégée ne vivrait plus sur la ligne → agrégations à dédoubler. Stocker les holdings **par snapshot** garde les agrégations existantes intactes et la migration additive sans rebuild.
|
||
|
||
### C. Tagging multi-axes / sous-comptes (`parent_id`) — déjà rejetés en 0012/0014
|
||
|
||
Ne donnent pas le grain titre et imposent un invariant somme parent = somme enfants. Voir [ADR 0012](0012-balance-two-level-model.md) (alternatives A/B) et [ADR 0014](0014-balance-vehicule-attribut.md).
|
||
|
||
## Consequences
|
||
|
||
### Positives
|
||
|
||
- **Détail de portefeuille enfin exprimable** : N titres (quantité, cours, valeur, `book_cost`) dans un même compte ; un ex-CELI/REER avec de vraies actions n'est plus un montant agrégé opaque — il se détaille via l'assistant.
|
||
- **Non-breaking par construction** : la ligne agrégée reste la source de vérité ; agrégations date/classe/enveloppe et Modified Dietz par compte **inchangés** (golden values figées, #217). Migration purement additive, idempotente, atomique (rollback testé), `v1`–`v13` intactes.
|
||
- **Gain latent exact sans suivi par lots** : `value − book_cost` répond aux findings D/E de l'audit sans le coût d'un journal de transactions.
|
||
- **Frontière agrégé/détaillé explicite** : `detailed_since` faisant autorité, pas d'état implicite ; les comptes cotés existants sont auto-convertis sans perte d'historique.
|
||
- **Reproductibilité préservée** : titres immortels une fois référencés (RESTRICT), holdings emportés par CASCADE avec leur ligne/snapshot.
|
||
|
||
### Négatives / risques actés
|
||
|
||
- **`book_cost` pré-rempli ≠ ajusté automatiquement** : la copie depuis le snapshot précédent est correcte tant qu'aucun apport/retrait n'a eu lieu sur le titre ; un achat/vente doit être ajusté manuellement, sinon gain latent (`book_cost`) et rendement (Dietz, via les transferts) peuvent diverger silencieusement. Documenté au guide.
|
||
- **Pas de gain en capital réalisé / PBR fiscal** : le `book_cost` est une *book value* estimée, pas un ACB fiscalement opposable (pas de RoC, pas de perte superficielle). Hors scope assumé.
|
||
- **Titres orphelins non nettoyables** : un titre référencé puis « abandonné » (qty 0) reste indélébile (RESTRICT). Acceptable (mêmes propriétés que `balance_account_transfers`) ; un nettoyage gardé « si aucun holding » pourra être ajouté plus tard.
|
||
- **`book_cost` NULL sur l'historique converti** : les lignes converties par v16 n'ont pas de coût rétroactif → gain latent « N/A » avant la première saisie. Attendu.
|
||
- **Pas de migration Down** : v14/v15/v16 idempotentes et conditionnelles ; toute correction passe par une `v17+` (jamais d'édition rétroactive).
|
||
|
||
### Neutre
|
||
|
||
- La colonne `currency` est prête mais inerte (CAD partout) tant qu'aucun titre non-CAD ne peut être créé — l'UX d'affichage natif s'activera avec le multi-devise.
|
||
|
||
## Liens
|
||
|
||
- [Audit Bilan 2026-05](../audit-bilan-2026-05.md) — trajectoire additive en trois temps ; Étape 2 = levier central
|
||
- [ADR 0008](0008-modified-dietz-pour-rendement.md) — Modified Dietz **par compte** (préservé tel quel ; pas de Dietz par titre)
|
||
- [ADR 0010](0010-fk-restrict-balance-transfers.md) — FK `RESTRICT` (modèle d'immortalité réutilisé pour `security_id`)
|
||
- [ADR 0012](0012-balance-two-level-model.md) — Rejected ; levait le gating « détail par titre reporté »
|
||
- [ADR 0014](0014-balance-vehicule-attribut.md) — axe véhicule (Étape 1) ; détail par titre y était explicitement hors scope
|
||
- Spec : [`spec-decisions-bilan-detail-titres.md`](../../spec-decisions-bilan-detail-titres.md) · [`spec-plan-bilan-detail-titres.md`](../../spec-plan-bilan-detail-titres.md)
|
||
- [Portfolio Performance PR #779](https://github.com/portfolio-performance/portfolio/pull/779) — modèle transaction-based **rejeté** au profit du snapshot
|
||
- [Wealthsimple — Adjusted cost base](https://help.wealthsimple.com/hc/en-ca/articles/4409775037083-What-is-adjusted-cost-base-ACB) · [AdjustedCostBase.ca](https://www.adjustedcostbase.ca/blog/how-to-calculate-adjusted-cost-base-acb-and-capital-gains/) — `book_cost` total comme modèle légitime
|
||
- Issues #210 → #218 — implémentation Étape 2
|