package store import ( "context" "errors" "time" "github.com/jackc/pgx/v5" ) type PositionRow struct { InstrumentID int32 `json:"instrument_id"` InstrumentType string `json:"instrument_type"` Quantite float64 `json:"quantite"` } type PositionSnapshot struct { Date time.Time AccountID int32 InstrumentID int32 Quantite float64 PRU *float64 PrixCloture *float64 Valeur *float64 } type AccountSnapshot struct { Date time.Time AccountID int32 Valeur float64 } // ListAllAccountIDs retourne tous les comptes (maîtres + enveloppes). func (s *Store) ListAllAccountIDs(ctx context.Context) ([]int32, error) { rows, err := s.pool.Query(ctx, `SELECT id FROM account ORDER BY id`) if err != nil { return nil, err } defer rows.Close() var ids []int32 for rows.Next() { var id int32 if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } return ids, rows.Err() } // GetLatestSnapshotDate retourne la date du snapshot le plus récent strictement // antérieur à before. Retourne (zero, false, nil) si aucun snapshot n'existe. // On utilise account_snapshot comme marqueur de "jour déjà calculé" car il // contient toujours une ligne même pour un compte à solde nul. func (s *Store) GetLatestSnapshotDate(ctx context.Context, accountID int32, before time.Time) (time.Time, bool, error) { var t *time.Time err := s.pool.QueryRow(ctx, ` SELECT MAX(date) FROM account_snapshot WHERE account_id = $1 AND date < $2::date `, accountID, before).Scan(&t) if err != nil || t == nil { return time.Time{}, false, err } return *t, true, nil } // GetBasePositions retourne les positions d'un compte à une date de snapshot // existante, enrichies du type d'instrument pour le fallback de prix. func (s *Store) GetBasePositions(ctx context.Context, accountID int32, date time.Time) ([]PositionRow, error) { rows, err := s.pool.Query(ctx, ` SELECT ps.instrument_id, i.type AS instrument_type, ps.quantite FROM position_snapshot ps JOIN instrument i ON i.id = ps.instrument_id WHERE ps.account_id = $1 AND ps.date = $2::date `, accountID, date) if err != nil { return nil, err } return pgx.CollectRows(rows, pgx.RowToStructByName[PositionRow]) } // ComputeDeltas calcule les variations nettes par instrument pour un compte // entre fromExclusive (exclu) et toInclusive (inclus). // Toutes les transactions sont comptées quelle que soit leur validation : // le solde reflète l'état envisagé complet (passé confirmé + prévisionnel). // Passer time.Time{} comme fromExclusive couvre toutes les transactions. func (s *Store) ComputeDeltas(ctx context.Context, accountID int32, fromExclusive, toInclusive time.Time) ([]PositionRow, error) { from := fromExclusive if from.IsZero() { from = time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC) } rows, err := s.pool.Query(ctx, ` WITH flows AS ( SELECT instrument_dest_id AS instrument_id, quantite_dest AS delta FROM transaction WHERE account_dest_id = $1 AND date > $2::date AND date <= $3::date UNION ALL SELECT instrument_source_id, -quantite_source FROM transaction WHERE account_source_id = $1 AND date > $2::date AND date <= $3::date ) SELECT f.instrument_id, i.type AS instrument_type, SUM(f.delta) AS quantite FROM flows f JOIN instrument i ON i.id = f.instrument_id GROUP BY f.instrument_id, i.type `, accountID, from, toInclusive) if err != nil { return nil, err } return pgx.CollectRows(rows, pgx.RowToStructByName[PositionRow]) } // AccountSnapshotRow représente une ligne de account_snapshot pour la restitution. type AccountSnapshotRow struct { Date string `json:"date"` Valeur float64 `json:"valeur"` } // PositionSnapshotRow représente une ligne de position_snapshot pour la restitution. type PositionSnapshotRow struct { Date string `json:"date"` InstrumentID int32 `json:"instrument_id"` Quantite float64 `json:"quantite"` PRU *float64 `json:"pru,omitempty"` PrixCloture *float64 `json:"prix_cloture,omitempty"` Valeur *float64 `json:"valeur,omitempty"` } // ListAccountSnapshots retourne les snapshots agrégés d'un compte entre from et to. // Si from est zero, pas de borne inférieure. Si to est zero, pas de borne supérieure. func (s *Store) ListAccountSnapshots(ctx context.Context, accountID int32, from, to time.Time) ([]AccountSnapshotRow, error) { rows, err := s.pool.Query(ctx, ` SELECT date::text, valeur FROM account_snapshot WHERE account_id = $1 AND ($2::date IS NULL OR date >= $2::date) AND ($3::date IS NULL OR date <= $3::date) ORDER BY date `, accountID, nullableDate(from), nullableDate(to)) if err != nil { return nil, err } return pgx.CollectRows(rows, pgx.RowToStructByName[AccountSnapshotRow]) } // ListPositionSnapshots retourne les positions détaillées d'un compte entre from et to. // Si from est zero, pas de borne inférieure. Si to est zero, pas de borne supérieure. func (s *Store) ListPositionSnapshots(ctx context.Context, accountID int32, from, to time.Time) ([]PositionSnapshotRow, error) { rows, err := s.pool.Query(ctx, ` SELECT date::text, instrument_id, quantite, pru, prix_cloture, valeur FROM position_snapshot WHERE account_id = $1 AND ($2::date IS NULL OR date >= $2::date) AND ($3::date IS NULL OR date <= $3::date) ORDER BY date, instrument_id `, accountID, nullableDate(from), nullableDate(to)) if err != nil { return nil, err } return pgx.CollectRows(rows, pgx.RowToStructByName[PositionSnapshotRow]) } // nullableDate convertit time.Time{} en nil pour les paramètres SQL optionnels. func nullableDate(t time.Time) any { if t.IsZero() { return nil } return t } // ComputePRUs retourne le PRU (coût moyen pondéré) par instrument pour un compte, // calculé sur toutes les transactions d'achat (account_dest_id) jusqu'à asOf inclus. // PRU = sum(quantite_source) / sum(quantite_dest) // Retourne uniquement les instruments qui ont des achats. func (s *Store) ComputePRUs(ctx context.Context, accountID int32, asOf time.Time) (map[int32]float64, error) { rows, err := s.pool.Query(ctx, ` SELECT instrument_dest_id, SUM(quantite_source) / NULLIF(SUM(quantite_dest), 0) AS pru FROM transaction WHERE account_dest_id = $1 AND date <= $2::date AND quantite_source IS NOT NULL GROUP BY instrument_dest_id `, accountID, asOf) if err != nil { return nil, err } defer rows.Close() result := make(map[int32]float64) for rows.Next() { var instrumentID int32 var pru float64 if err := rows.Scan(&instrumentID, &pru); err != nil { return nil, err } result[instrumentID] = pru } return result, rows.Err() } // GetPriceAt retourne le dernier prix connu pour un instrument à une date donnée. // Retourne (0, false, nil) si aucun prix n'est trouvé. func (s *Store) GetPriceAt(ctx context.Context, instrumentID int32, date time.Time) (float64, bool, error) { var prix float64 err := s.pool.QueryRow(ctx, ` SELECT prix FROM price_history WHERE instrument_id = $1 AND fetched_at::date <= $2::date ORDER BY fetched_at DESC LIMIT 1 `, instrumentID, date).Scan(&prix) if errors.Is(err, pgx.ErrNoRows) { return 0, false, nil } if err != nil { return 0, false, err } return prix, true, nil } func (s *Store) UpsertPositionSnapshot(ctx context.Context, snap PositionSnapshot) error { _, err := s.pool.Exec(ctx, ` INSERT INTO position_snapshot (date, account_id, instrument_id, quantite, pru, prix_cloture, valeur) VALUES ($1::date, $2, $3, $4, $5, $6, $7) ON CONFLICT (date, account_id, instrument_id) DO UPDATE SET quantite = EXCLUDED.quantite, pru = EXCLUDED.pru, prix_cloture = EXCLUDED.prix_cloture, valeur = EXCLUDED.valeur `, snap.Date, snap.AccountID, snap.InstrumentID, snap.Quantite, snap.PRU, snap.PrixCloture, snap.Valeur) return err } func (s *Store) UpsertAccountSnapshot(ctx context.Context, snap AccountSnapshot) error { _, err := s.pool.Exec(ctx, ` INSERT INTO account_snapshot (date, account_id, valeur) VALUES ($1::date, $2, $3) ON CONFLICT (date, account_id) DO UPDATE SET valeur = EXCLUDED.valeur `, snap.Date, snap.AccountID, snap.Valeur) return err } // ── Invalidation ───────────────────────────────────────────────────────────── type Invalidation struct { AccountID int32 RecomputeFrom time.Time } // InvalidateSnapshot marque un compte comme nécessitant un recalcul depuis date. // Conserve le MIN si une invalidation antérieure existe déjà. func (s *Store) InvalidateSnapshot(ctx context.Context, accountID int32, from time.Time) error { _, err := s.pool.Exec(ctx, ` INSERT INTO account_snapshot_invalidation (account_id, recompute_from) VALUES ($1, $2::date) ON CONFLICT (account_id) DO UPDATE SET recompute_from = LEAST(account_snapshot_invalidation.recompute_from, EXCLUDED.recompute_from) `, accountID, from) return err } // GetInvalidations retourne tous les comptes en attente de recalcul. func (s *Store) GetInvalidations(ctx context.Context) ([]Invalidation, error) { rows, err := s.pool.Query(ctx, `SELECT account_id, recompute_from FROM account_snapshot_invalidation ORDER BY account_id`) if err != nil { return nil, err } defer rows.Close() var out []Invalidation for rows.Next() { var inv Invalidation if err := rows.Scan(&inv.AccountID, &inv.RecomputeFrom); err != nil { return nil, err } out = append(out, inv) } return out, rows.Err() } // GetInvalidationsForOwner retourne les invalidations des comptes appartenant à ownerID. func (s *Store) GetInvalidationsForOwner(ctx context.Context, ownerID int32) ([]Invalidation, error) { rows, err := s.pool.Query(ctx, ` SELECT ai.account_id, ai.recompute_from FROM account_snapshot_invalidation ai JOIN account a ON a.id = ai.account_id WHERE a.owner_id = $1 ORDER BY ai.account_id `, ownerID) if err != nil { return nil, err } defer rows.Close() var out []Invalidation for rows.Next() { var inv Invalidation if err := rows.Scan(&inv.AccountID, &inv.RecomputeFrom); err != nil { return nil, err } out = append(out, inv) } return out, rows.Err() } // ClearInvalidation supprime le flag d'invalidation une fois le recalcul terminé. func (s *Store) ClearInvalidation(ctx context.Context, accountID int32) error { _, err := s.pool.Exec(ctx, `DELETE FROM account_snapshot_invalidation WHERE account_id = $1`, accountID) return err } // GetInvalidation retourne l'invalidation d'un compte spécifique, si elle existe. func (s *Store) GetInvalidation(ctx context.Context, accountID int32) (Invalidation, bool, error) { var inv Invalidation err := s.pool.QueryRow(ctx, `SELECT account_id, recompute_from FROM account_snapshot_invalidation WHERE account_id = $1`, accountID).Scan(&inv.AccountID, &inv.RecomputeFrom) if errors.Is(err, pgx.ErrNoRows) { return Invalidation{}, false, nil } return inv, err == nil, err }