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, }) }