326 lines
11 KiB
Go
326 lines
11 KiB
Go
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
|
|
}
|