account/internal/snapshot/engine.go
GnomeZworc 0f2d7126eb
ajoute snapshot
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-13 22:52:13 +02:00

212 lines
6 KiB
Go

package snapshot
import (
"context"
"fmt"
"log/slog"
"time"
"git.g3e.fr/H6N/account/internal/store"
)
type Engine struct {
store *store.Store
logger *slog.Logger
horizonDays int
}
func New(st *store.Store, logger *slog.Logger, horizonDays int) *Engine {
return &Engine{store: st, logger: logger, horizonDays: horizonDays}
}
func (e *Engine) Store() *store.Store { return e.store }
// RecomputeDay calcule les snapshots de tous les comptes pour une date donnée.
// Toutes les transactions (validées ou non) sont incluses.
func (e *Engine) RecomputeDay(ctx context.Context, date time.Time) error {
date = date.Truncate(24 * time.Hour)
accountIDs, err := e.store.ListAllAccountIDs(ctx)
if err != nil {
return fmt.Errorf("list accounts: %w", err)
}
for _, accountID := range accountIDs {
if err := e.recomputeAccount(ctx, accountID, date); err != nil {
e.logger.Error("snapshot: account failed", "account_id", accountID, "error", err)
}
}
e.logger.Info("snapshot: day computed", "date", date.Format("2006-01-02"), "accounts", len(accountIDs))
return nil
}
// RecomputeAccount recalcule les snapshots d'un compte depuis son invalidation
// jusqu'à aujourd'hui + horizon. Efface l'invalidation une fois terminé.
// Retourne (zero, zero, nil) si aucune invalidation n'est en attente.
func (e *Engine) RecomputeAccount(ctx context.Context, accountID int32) (from, to time.Time, err error) {
inv, found, err := e.store.GetInvalidation(ctx, accountID)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("get invalidation: %w", err)
}
if !found {
return time.Time{}, time.Time{}, nil
}
today := time.Now().Truncate(24 * time.Hour)
horizon := today.AddDate(0, 0, e.horizonDays)
if err := e.backfillAccount(ctx, accountID, inv.RecomputeFrom, horizon); err != nil {
return time.Time{}, time.Time{}, err
}
if err := e.store.ClearInvalidation(ctx, accountID); err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("clear invalidation: %w", err)
}
return inv.RecomputeFrom, horizon, nil
}
// DailySnapshot :
// 1. Traite les invalidations en attente (jusqu'à today + horizon)
// 2. Recalcule hier (consolide les transactions de la veille)
// 3. Calcule le nouveau jour entrant dans la fenêtre (today + horizonDays)
func (e *Engine) DailySnapshot(ctx context.Context) error {
today := time.Now().Truncate(24 * time.Hour)
yesterday := today.AddDate(0, 0, -1)
newHorizonDay := today.AddDate(0, 0, e.horizonDays)
// 1. Invalidations
if err := e.processInvalidations(ctx, newHorizonDay); err != nil {
e.logger.Error("snapshot: invalidation processing failed", "error", err)
}
// 2. Hier
if err := e.RecomputeDay(ctx, yesterday); err != nil {
e.logger.Error("snapshot: yesterday recompute failed", "error", err)
}
// 3. Nouveau jour entrant dans la fenêtre
if err := e.RecomputeDay(ctx, newHorizonDay); err != nil {
e.logger.Error("snapshot: horizon day failed", "error", err)
}
return nil
}
func (e *Engine) processInvalidations(ctx context.Context, until time.Time) error {
invalidations, err := e.store.GetInvalidations(ctx)
if err != nil {
return fmt.Errorf("get invalidations: %w", err)
}
if len(invalidations) == 0 {
return nil
}
e.logger.Info("snapshot: processing invalidations", "count", len(invalidations))
for _, inv := range invalidations {
if err := e.backfillAccount(ctx, inv.AccountID, inv.RecomputeFrom, until); err != nil {
e.logger.Error("snapshot: backfill failed", "account_id", inv.AccountID, "error", err)
continue
}
if err := e.store.ClearInvalidation(ctx, inv.AccountID); err != nil {
e.logger.Error("snapshot: clear invalidation failed", "account_id", inv.AccountID, "error", err)
}
}
return nil
}
func (e *Engine) backfillAccount(ctx context.Context, accountID int32, from, to time.Time) error {
from = from.Truncate(24 * time.Hour)
to = to.Truncate(24 * time.Hour)
if from.After(to) {
return fmt.Errorf("from (%s) after to (%s)", from.Format("2006-01-02"), to.Format("2006-01-02"))
}
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
if err := e.recomputeAccount(ctx, accountID, d); err != nil {
return fmt.Errorf("recompute %s: %w", d.Format("2006-01-02"), err)
}
}
return nil
}
func (e *Engine) recomputeAccount(ctx context.Context, accountID int32, date time.Time) error {
baseDate, hasBase, err := e.store.GetLatestSnapshotDate(ctx, accountID, date)
if err != nil {
return fmt.Errorf("get latest snapshot date: %w", err)
}
positions := map[int32]store.PositionRow{}
if hasBase {
base, err := e.store.GetBasePositions(ctx, accountID, baseDate)
if err != nil {
return fmt.Errorf("get base positions: %w", err)
}
for _, p := range base {
positions[p.InstrumentID] = p
}
}
deltas, err := e.store.ComputeDeltas(ctx, accountID, baseDate, date)
if err != nil {
return fmt.Errorf("compute deltas: %w", err)
}
for _, d := range deltas {
p := positions[d.InstrumentID]
p.InstrumentID = d.InstrumentID
p.InstrumentType = d.InstrumentType
p.Quantite += d.Quantite
positions[d.InstrumentID] = p
}
var totalValeur float64
for _, pos := range positions {
if pos.Quantite <= 0 {
continue
}
prix, found, err := e.store.GetPriceAt(ctx, pos.InstrumentID, date)
if err != nil {
return fmt.Errorf("get price instrument %d: %w", pos.InstrumentID, err)
}
if !found {
if pos.InstrumentType == "devise" {
prix = 1.0
} else {
e.logger.Warn("snapshot: no price, position skipped",
"account_id", accountID,
"instrument_id", pos.InstrumentID,
"date", date.Format("2006-01-02"),
)
continue
}
}
valeur := pos.Quantite * prix
totalValeur += valeur
snap := store.PositionSnapshot{
Date: date,
AccountID: accountID,
InstrumentID: pos.InstrumentID,
Quantite: pos.Quantite,
PrixCloture: &prix,
Valeur: &valeur,
}
if err := e.store.UpsertPositionSnapshot(ctx, snap); err != nil {
return fmt.Errorf("upsert position snapshot: %w", err)
}
}
return e.store.UpsertAccountSnapshot(ctx, store.AccountSnapshot{
Date: date,
AccountID: accountID,
Valeur: totalValeur,
})
}