259 lines
7.8 KiB
Go
259 lines
7.8 KiB
Go
package snapshot
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"git.g3e.fr/H6N/account/internal/store"
|
|
)
|
|
|
|
// PriceBackfiller est implémenté par le pipeline pour récupérer l'historique de prix manquant.
|
|
type PriceBackfiller interface {
|
|
BackfillInstrument(ctx context.Context, instrumentID int32, from, to time.Time) error
|
|
}
|
|
|
|
type Engine struct {
|
|
store *store.Store
|
|
logger *slog.Logger
|
|
horizonDays int
|
|
backfiller PriceBackfiller
|
|
}
|
|
|
|
func New(st *store.Store, logger *slog.Logger, horizonDays int) *Engine {
|
|
return &Engine{store: st, logger: logger, horizonDays: horizonDays}
|
|
}
|
|
|
|
// WithBackfiller active le backfill automatique des prix historiques manquants.
|
|
func (e *Engine) WithBackfiller(b PriceBackfiller) *Engine {
|
|
e.backfiller = b
|
|
return e
|
|
}
|
|
|
|
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"))
|
|
}
|
|
|
|
// backfilledInstruments évite de re-fetcher le même instrument plusieurs fois dans ce run.
|
|
backfilledInstruments := make(map[int32]bool)
|
|
|
|
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
|
if err := e.recomputeAccountWithBackfill(ctx, accountID, d, backfilledInstruments); 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 {
|
|
return e.recomputeAccountWithBackfill(ctx, accountID, date, nil)
|
|
}
|
|
|
|
func (e *Engine) recomputeAccountWithBackfill(ctx context.Context, accountID int32, date time.Time, backfilled map[int32]bool) error {
|
|
baseDate, hasBase, err := e.store.GetLatestSnapshotDate(ctx, accountID, date)
|
|
if err != nil {
|
|
return fmt.Errorf("get latest snapshot date: %w", err)
|
|
}
|
|
|
|
prus, err := e.store.ComputePRUs(ctx, accountID, date)
|
|
if err != nil {
|
|
return fmt.Errorf("compute PRUs: %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 && pos.InstrumentType == "devise" {
|
|
prix = 1.0
|
|
found = true
|
|
}
|
|
|
|
// Backfill automatique si prix manquant et backfiller disponible.
|
|
// from = date - 1 jour (marge timezone), to = maintenant (inclut aujourd'hui).
|
|
if !found && e.backfiller != nil && (backfilled == nil || !backfilled[pos.InstrumentID]) {
|
|
from := date.AddDate(0, 0, -1)
|
|
now := time.Now().UTC()
|
|
if err := e.backfiller.BackfillInstrument(ctx, pos.InstrumentID, from, now); err != nil {
|
|
e.logger.Warn("snapshot: backfill échoué",
|
|
"instrument_id", pos.InstrumentID, "error", err)
|
|
} else {
|
|
if backfilled != nil {
|
|
backfilled[pos.InstrumentID] = true
|
|
}
|
|
// Retry après backfill
|
|
prix, found, err = e.store.GetPriceAt(ctx, pos.InstrumentID, date)
|
|
if err != nil {
|
|
return fmt.Errorf("get price instrument %d (post-backfill): %w", pos.InstrumentID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
snap := store.PositionSnapshot{
|
|
Date: date,
|
|
AccountID: accountID,
|
|
InstrumentID: pos.InstrumentID,
|
|
Quantite: pos.Quantite,
|
|
}
|
|
if pru, ok := prus[pos.InstrumentID]; ok {
|
|
snap.PRU = &pru
|
|
}
|
|
if found {
|
|
valeur := pos.Quantite * prix
|
|
snap.PrixCloture = &prix
|
|
snap.Valeur = &valeur
|
|
totalValeur += valeur
|
|
} else {
|
|
e.logger.Warn("snapshot: no price, valeur non calculée",
|
|
"account_id", accountID,
|
|
"instrument_id", pos.InstrumentID,
|
|
"date", date.Format("2006-01-02"),
|
|
)
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|