Simpl-Resultat/docs/adr/0015-balance-detail-par-titre.md
le king fu d41ccbd618 docs(balance): ADR 0015 + guide + architecture + CHANGELOG for per-security detail (#218)
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>
2026-06-06 14:17:03 -04:00

16 KiB
Raw Blame History

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-plan-bilan-detail-titres.md
  • Audit source: docs/audit-bilan-2026-05.md
  • Lève le gating « détail par titre reporté » posé par ADR 0012 (Rejected) et le « Hors scope Étape 2 » de ADR 0014

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 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 comptebalance_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-v13v14, v15, v16 — sans aucune modification des migrations v1v13 (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).
  • v15balance_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, 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_idbalance_securities(id) ON DELETE RESTRICT (miroir de balance_account_transfers.transaction_id, ADR 0010) : 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_idbalance_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 — « 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) à 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) ; le snapshot par titre est l'extension additive naturelle (cf. Portfolio Performance — Securities/Transactions/Quotes séparés).

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 (alternatives A/B) et ADR 0014.

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é), v1v13 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