package app import ( "context" "fmt" "strings" "time" "finance-duck/internal/domain" "finance-duck/internal/quotes" ) // QuoteFailure names one instrument the price job could not value, with the // provider's already sanitized reason. It carries the ISIN as well as the ID // because the person reading a failed refresh recognises the security by its // ISIN, not by a registry identifier. type QuoteFailure struct { InstrumentID string `json:"instrument_id"` ISIN string `json:"isin"` Symbol string `json:"symbol"` Error string `json:"error"` } // QuoteResult is the outcome of one refresh. Every instrument is accounted for // exactly once, so Updated, Unchanged, Skipped and the failures add up to the // number of instruments in the journal and a partial run is visibly partial. type QuoteResult struct { Updated int `json:"updated"` Unchanged int `json:"unchanged"` Skipped int `json:"skipped"` Failures []QuoteFailure `json:"failures"` State State `json:"state"` } // quoteInterval is how often prices refresh on their own. The provider // publishes one close per day, so asking more often only spends requests. const quoteInterval = 24 * time.Hour // quotePace spaces provider calls. The chart endpoint is public and // unauthenticated, and a household portfolio of a few dozen symbols still // finishes in seconds at this rate while staying far below the burst at which // the provider starts refusing. const quotePace = 250 * time.Millisecond // quoteStartup delays the first automatic refresh past start, so a restart // never fetches while the journal is still being read and a rebuild is running. const quoteStartup = 30 * time.Second // RefreshQuotes fetches the latest close for every instrument that names a // market symbol and writes the accepted ones to the journal in a single // commit. One instrument's failure is recorded and the run continues: a // delisted or mistyped symbol must not stop the rest of the portfolio from // being valued. func (a *App) RefreshQuotes(ctx context.Context) (QuoteResult, error) { s, err := a.Snapshot(ctx) if err != nil { return QuoteResult{}, err } result := QuoteResult{Failures: []QuoteFailure{}} accepted := make(map[string]quotes.Quote) fetched := 0 for _, instrument := range s.Data.Instruments { if instrument.Symbol == "" { result.Skipped++ continue } if err = paceQuote(ctx, fetched); err != nil { return QuoteResult{}, err } fetched++ fail := func(reason string) { result.Failures = append(result.Failures, QuoteFailure{InstrumentID: instrument.ID, ISIN: instrument.ISIN, Symbol: instrument.Symbol, Error: reason}) } quote, e := a.quotes.Latest(ctx, instrument.Symbol) if e != nil { // A shutdown cancels the fetch too, and recording that as this // instrument's fault would fill the report with failures that say // nothing about the symbols. if ctx.Err() != nil { return QuoteResult{}, ctx.Err() } fail(e.Error()) continue } // One ISIN is listed on several exchanges in different currencies, and // a symbol can be resolved to the wrong listing. Storing a price in a // currency the holding is not denominated in would misstate wealth // silently, so a disagreement is a failure and never a write. if !strings.EqualFold(quote.Currency, instrument.Currency) { fail(fmt.Sprintf("quoted in %s but the instrument is held in %s", quote.Currency, instrument.Currency)) continue } units, e := quote.Price.Units() if e != nil { fail(e.Error()) continue } if units <= 0 { fail("quoted price is not positive") continue } // An empty stored quote fails to parse, which is exactly the "not the // same value" answer wanted here. if current, e := instrument.Quote.Units(); e == nil && current == units && instrument.QuotedAt == quote.Day { result.Unchanged++ continue } accepted[instrument.ID] = quote } if len(accepted) == 0 { result.State = s return result, nil } // The fetches took time, so the journal may have moved on underneath this // run; re-read it and match by instrument ID rather than by position. if s, err = a.Snapshot(ctx); err != nil { return QuoteResult{}, err } s, err = a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error { for i := range d.Instruments { quote, ok := accepted[d.Instruments[i].ID] if !ok { continue } d.Instruments[i].Quote = quote.Price d.Instruments[i].QuotedAt = quote.Day result.Updated++ } return nil }) if err != nil { return QuoteResult{}, err } result.State = s return result, nil } // paceQuote waits out the spacing between provider calls and is where a run // notices that it has been canceled: nothing has been written yet at this // point, so abandoning the run here costs only the fetches already made. func paceQuote(ctx context.Context, fetched int) error { if fetched == 0 { return ctx.Err() } select { case <-ctx.Done(): return ctx.Err() case <-time.After(quotePace): return nil } }