diff --git a/pkg/api/handler.go b/pkg/api/handler.go index 08198c52..39bff942 100644 --- a/pkg/api/handler.go +++ b/pkg/api/handler.go @@ -48,7 +48,7 @@ type Handler struct { limits Limits spamFilter SpamFilter - ratesSource conversionRatesSource + ratesSource ratesSource score scoreSource metaCache metadataCache tonConnect *tonconnect.Server diff --git a/pkg/api/interfaces.go b/pkg/api/interfaces.go index 338579df..71e6ab35 100644 --- a/pkg/api/interfaces.go +++ b/pkg/api/interfaces.go @@ -202,12 +202,6 @@ type ratesSource interface { GetMarketsTonPrice() ([]rates.Market, error) } -type conversionRatesSource interface { - ratesSource - GetTodayRatesWithTimestamps() (map[string]float64, map[string]int64) - GetMinuteAgoRatesWithTimestamps() (map[string]float64, map[string]int64) -} - type scoreSource interface { GetJettonScore(masterID ton.AccountID) (int32, error) } diff --git a/pkg/api/jetton_converters.go b/pkg/api/jetton_converters.go index 4e9842ba..73cfc3a3 100644 --- a/pkg/api/jetton_converters.go +++ b/pkg/api/jetton_converters.go @@ -146,7 +146,7 @@ func (h *Handler) convertJettonOperation(ctx context.Context, op core.JettonOper func (h *Handler) convertJettonBalance(ctx context.Context, wallet core.JettonWallet, currencies []string, scaledUiLt *int64, assetInfo *oas.JettonAssetInfo) (oas.JettonBalance, error) { // the latest scaled ui parameters for jetton master if scaledUiLt == nil - _, yesterdayRates, weekRates, monthRates, _ := h.getRates() + todayRates, yesterdayRates, weekRates, monthRates, _ := h.getRates() for idx, currency := range currencies { if jetton, err := tongo.ParseAddress(currency); err == nil { currency = jetton.ID.ToRaw() @@ -173,7 +173,7 @@ func (h *Handler) convertJettonBalance(ctx context.Context, wallet core.JettonWa } rates := make(map[string]oas.TokenRates) for _, currency := range currencies { - rates, err = h.convertRates(ctx, rates, wallet.JettonAddress.ToRaw(), currency, yesterdayRates, weekRates, monthRates) + rates, err = h.convertRates(ctx, rates, wallet.JettonAddress.ToRaw(), currency, todayRates, yesterdayRates, weekRates, monthRates) if err != nil { rates = make(map[string]oas.TokenRates) continue diff --git a/pkg/api/rates_handlers.go b/pkg/api/rates_handlers.go index 05caed9a..0395c459 100644 --- a/pkg/api/rates_handlers.go +++ b/pkg/api/rates_handlers.go @@ -115,7 +115,7 @@ func (h *Handler) GetRates(ctx context.Context, params oas.GetRatesParams) (*oas } } - _, yesterdayRates, weekRates, monthRates, err := h.getRates() + todayRates, yesterdayRates, weekRates, monthRates, err := h.getRates() if err != nil { return nil, toError(http.StatusInternalServerError, err) } @@ -123,7 +123,7 @@ func (h *Handler) GetRates(ctx context.Context, params oas.GetRatesParams) (*oas rates := make(map[string]oas.TokenRates) for _, token := range tokens { for _, currency := range currencies { - rates, err = h.convertRates(ctx, rates, token, currency, yesterdayRates, weekRates, monthRates) + rates, err = h.convertRates(ctx, rates, token, currency, todayRates, yesterdayRates, weekRates, monthRates) if err != nil { return nil, err } @@ -176,63 +176,11 @@ func (h *Handler) getRates() (todayRates, yesterdayRates, weekRates, monthRates return results[0], results[1], results[2], results[3], nil } -const maxGenerationSkewSeconds = 180 - -func alignConversionPrices( - token, currency string, - todayRates map[string]float64, todayTs map[string]int64, - prevRates map[string]float64, prevTs map[string]int64, -) (tokenPrice, currencyPrice float64) { - tokenPrice = todayRates[token] - currencyPrice = todayRates[currency] - - tokenTs, currencyTs := todayTs[token], todayTs[currency] - if tokenTs == 0 || currencyTs == 0 || tokenTs == currencyTs { - return tokenPrice, currencyPrice - } - gap := tokenTs - currencyTs - if gap < 0 { - gap = -gap - } - if gap > maxGenerationSkewSeconds { - return tokenPrice, currencyPrice - } - - if tokenTs < currencyTs { - // the token price is a cycle behind: take the currency price from the previous - // snapshot when its generation matches the token's better than the current one - if prev, ok := prevRates[currency]; ok && prev != 0 && closerTo(prevTs[currency], tokenTs, currencyTs) { - currencyPrice = prev - } - } else { - if prev, ok := prevRates[token]; ok && prev != 0 && closerTo(prevTs[token], currencyTs, tokenTs) { - tokenPrice = prev - } - } - return tokenPrice, currencyPrice -} - -// closerTo reports whether candidate is strictly closer to target than current is -func closerTo(candidate, target, current int64) bool { - if candidate == 0 { - return false - } - candidateDist := candidate - target - if candidateDist < 0 { - candidateDist = -candidateDist - } - currentDist := current - target - if currentDist < 0 { - currentDist = -currentDist - } - return candidateDist < currentDist -} - func (h *Handler) convertRates( ctx context.Context, rates map[string]oas.TokenRates, token, currency string, - yesterdayRates, weekRates, monthRates map[string]float64, + todayRates, yesterdayRates, weekRates, monthRates map[string]float64, ) (map[string]oas.TokenRates, error) { trust := core.TrustNone if len(token) >= minTonAddressLength { @@ -243,8 +191,6 @@ func (h *Handler) convertRates( } } - todayRates, todayTimestamps := h.ratesSource.GetTodayRatesWithTimestamps() - todayCurrencyPrice, ok := todayRates[currency] if !ok { return nil, toError(http.StatusBadRequest, fmt.Errorf("invalid currency: %v", currency)) @@ -260,12 +206,7 @@ func (h *Handler) convertRates( } } - minuteAgoRates, minuteAgoTimestamps := h.ratesSource.GetMinuteAgoRatesWithTimestamps() - tokenPrice, todayCurrencyPrice := alignConversionPrices( - token, currency, - todayRates, todayTimestamps, - minuteAgoRates, minuteAgoTimestamps, - ) + tokenPrice := todayRates[token] if trust == core.TrustBlacklist { tokenPrice = 0 } diff --git a/pkg/api/rates_handlers_test.go b/pkg/api/rates_handlers_test.go deleted file mode 100644 index 557280fd..00000000 --- a/pkg/api/rates_handlers_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package api - -import ( - "testing" -) - -func TestAlignConversionPrices(t *testing.T) { - const ( - jetton = "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe" - now = int64(1_756_000_000) - ) - - tests := []struct { - name string - todayRates map[string]float64 - todayTs map[string]int64 - prevRates map[string]float64 - prevTs map[string]int64 - wantToken, wantCur float64 - }{ - { - name: "same generation: today prices used as is", - todayRates: map[string]float64{jetton: 10, "TON": 1}, - todayTs: map[string]int64{jetton: now, "TON": now}, - prevRates: map[string]float64{jetton: 9, "TON": 1}, - prevTs: map[string]int64{jetton: now - 60, "TON": now - 60}, - wantToken: 10, wantCur: 1, - }, - { - name: "no timestamps: today prices used as is", - todayRates: map[string]float64{jetton: 10, "TON": 1}, - todayTs: map[string]int64{}, - prevRates: map[string]float64{jetton: 9, "TON": 2}, - prevTs: map[string]int64{}, - wantToken: 10, wantCur: 1, - }, - { - name: "stale token price: currency taken from previous snapshot", - // the jetton price is from the 2-minute feed (now-120), the currency from - // the 1-minute feed (now); the previous snapshot's currency (now-60... -120) - // matches the jetton's generation better - todayRates: map[string]float64{jetton: 10, "TON": 2}, - todayTs: map[string]int64{jetton: now - 120, "TON": now}, - prevRates: map[string]float64{jetton: 10, "TON": 1.8}, - prevTs: map[string]int64{jetton: now - 120, "TON": now - 120}, - wantToken: 10, wantCur: 1.8, - }, - { - name: "stale currency price: token taken from previous snapshot", - todayRates: map[string]float64{jetton: 10, "TON": 2}, - todayTs: map[string]int64{jetton: now, "TON": now - 120}, - prevRates: map[string]float64{jetton: 9, "TON": 2}, - prevTs: map[string]int64{jetton: now - 120, "TON": now - 120}, - wantToken: 9, wantCur: 2, - }, - { - name: "previous snapshot no closer: today prices kept", - // prev snapshot is even further from the token's generation than today - todayRates: map[string]float64{jetton: 10, "TON": 2}, - todayTs: map[string]int64{jetton: now - 60, "TON": now}, - prevRates: map[string]float64{jetton: 10, "TON": 1.8}, - prevTs: map[string]int64{jetton: now - 240, "TON": now - 240}, - wantToken: 10, wantCur: 2, - }, - { - name: "gap above skew bound (slow fiat): today prices kept", - // EUR was last written an hour ago; that is not feed staleness - todayRates: map[string]float64{jetton: 10, "EUR": 0.5}, - todayTs: map[string]int64{jetton: now, "EUR": now - 3600}, - prevRates: map[string]float64{jetton: 9, "EUR": 0.5}, - prevTs: map[string]int64{jetton: now - 60, "EUR": now - 3600}, - wantToken: 10, wantCur: 0.5, - }, - { - name: "previous price is zero: today price kept", - todayRates: map[string]float64{jetton: 10, "TON": 2}, - todayTs: map[string]int64{jetton: now - 120, "TON": now}, - prevRates: map[string]float64{jetton: 10, "TON": 0}, - prevTs: map[string]int64{jetton: now - 120, "TON": now - 120}, - wantToken: 10, wantCur: 2, - }, - } - - currencyOf := func(rates map[string]float64) string { - if _, ok := rates["EUR"]; ok { - return "EUR" - } - return "TON" - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - currency := currencyOf(tt.todayRates) - gotToken, gotCur := alignConversionPrices(jetton, currency, tt.todayRates, tt.todayTs, tt.prevRates, tt.prevTs) - if gotToken != tt.wantToken || gotCur != tt.wantCur { - t.Fatalf("got token=%v currency=%v, want token=%v currency=%v", gotToken, gotCur, tt.wantToken, tt.wantCur) - } - }) - } -} diff --git a/pkg/rates/calculator.go b/pkg/rates/calculator.go index 564ebc41..b93770f3 100644 --- a/pkg/rates/calculator.go +++ b/pkg/rates/calculator.go @@ -14,6 +14,9 @@ type ratesSource interface { GetMarketsTonPrice() ([]Market, error) } +// timestampedRatesSource is implemented by sources backed by the rates service's +// /v1/rates/timestamped endpoint. Only its prices are consumed; the per-token +// timestamps it reports are ignored. type timestampedRatesSource interface { GetRatesWithTimestamps(date int64) (map[string]float64, map[string]int64, error) } @@ -24,14 +27,7 @@ type calculator struct { // See the Mock description for details source ratesSource todayRates, yesterdayRates, weekRates, monthRates map[string]float64 - // todayTimestamps holds, for each token in todayRates, the unix timestamp its price - // was produced at; empty when the source cannot report timestamps - todayTimestamps map[string]int64 - // minuteAgoRates and minuteAgoTimestamps hold the today snapshot from the previous - // refresh cycle; empty until the second refresh completes (cold start) - minuteAgoRates map[string]float64 - minuteAgoTimestamps map[string]int64 - marketsTonPrice []Market + marketsTonPrice []Market } type Point struct { @@ -47,7 +43,6 @@ func InitCalculator(source ratesSource) *calculator { c := &calculator{ source: source, todayRates: map[string]float64{}, - todayTimestamps: map[string]int64{}, yesterdayRates: map[string]float64{}, weekRates: map[string]float64{}, monthRates: map[string]float64{}, @@ -74,13 +69,12 @@ func (c *calculator) refresh() { marketsTonPrice, marketErr := c.source.GetMarketsTonPrice() var todayRates map[string]float64 - var todayTimestamps map[string]int64 var err error if tsSource, ok := c.source.(timestampedRatesSource); ok { - todayRates, todayTimestamps, err = tsSource.GetRatesWithTimestamps(today.Unix()) + // prices come from the timestamped endpoint; the timestamps are ignored + todayRates, _, err = tsSource.GetRatesWithTimestamps(today.Unix()) } else { todayRates, err = c.source.GetRates(today.Unix()) - todayTimestamps = map[string]int64{} } if err != nil { slog.Error("[refresh-rates] error getting today rates", slog.String("err", err.Error())) @@ -103,10 +97,7 @@ func (c *calculator) refresh() { } c.mu.Lock() - c.minuteAgoRates = c.todayRates - c.minuteAgoTimestamps = c.todayTimestamps c.todayRates = todayRates - c.todayTimestamps = todayTimestamps c.yesterdayRates = yesterdayRates c.weekRates = weekRates c.monthRates = monthRates @@ -141,27 +132,6 @@ func (c *calculator) GetRates(date int64) (map[string]float64, error) { return nil, fmt.Errorf("invalid period") } -// GetTodayRatesWithTimestamps returns today's rates together with, for each token, the unix -// timestamp its price was produced at. The timestamps map is empty when the source does not -// implement timestampedRatesSource -func (c *calculator) GetTodayRatesWithTimestamps() (map[string]float64, map[string]int64) { - c.mu.RLock() - defer c.mu.RUnlock() - return c.todayRates, c.todayTimestamps -} - -// GetMinuteAgoRatesWithTimestamps returns the today snapshot from the previous refresh -// cycle. Until the second refresh completes (cold start) there is no previous snapshot, -// so it falls back to the current one — callers always get a usable map -func (c *calculator) GetMinuteAgoRatesWithTimestamps() (map[string]float64, map[string]int64) { - c.mu.RLock() - defer c.mu.RUnlock() - if len(c.minuteAgoRates) == 0 { - return c.todayRates, c.todayTimestamps - } - return c.minuteAgoRates, c.minuteAgoTimestamps -} - func (c *calculator) GetRatesChart(token string, currency string, pointsCount int, startDate *int64, endDate *int64) ([]Point, error) { return c.source.GetRatesChart(token, currency, pointsCount, startDate, endDate) } diff --git a/pkg/rates/calculator_test.go b/pkg/rates/calculator_test.go index c7c1b7e0..7d78e118 100644 --- a/pkg/rates/calculator_test.go +++ b/pkg/rates/calculator_test.go @@ -2,9 +2,10 @@ package rates import ( "testing" + "time" ) -// fakeTimestampedSource returns a configurable rates map with timestamps and counts fetches. +// fakeTimestampedSource serves rates through the timestamped endpoint interface. type fakeTimestampedSource struct { rates map[string]float64 timestamps map[string]int64 @@ -26,7 +27,7 @@ func (f *fakeTimestampedSource) GetMarketsTonPrice() ([]Market, error) { return []Market{}, nil } -func TestCalculator_MinuteAgoRates_ShiftAndFallback(t *testing.T) { +func TestCalculator_Refresh_UsesTimestampedSourcePrices(t *testing.T) { source := &fakeTimestampedSource{ rates: map[string]float64{"TON": 1.0}, timestamps: map[string]int64{"TON": 100}, @@ -34,38 +35,19 @@ func TestCalculator_MinuteAgoRates_ShiftAndFallback(t *testing.T) { c := &calculator{ source: source, todayRates: map[string]float64{}, - todayTimestamps: map[string]int64{}, yesterdayRates: map[string]float64{}, weekRates: map[string]float64{}, monthRates: map[string]float64{}, marketsTonPrice: []Market{}, } - // Before any refresh both snapshots are empty but usable. - rates, timestamps := c.GetMinuteAgoRatesWithTimestamps() - if rates == nil || timestamps == nil { - t.Fatal("minute-ago accessor must never return nil maps") - } - - // Cold start: after the first refresh there is no previous snapshot yet, - // so the minute-ago accessor falls back to the current one. - c.refresh() - rates, timestamps = c.GetMinuteAgoRatesWithTimestamps() - if rates["TON"] != 1.0 || timestamps["TON"] != 100 { - t.Fatalf("expected fallback to current snapshot, got rates=%v timestamps=%v", rates, timestamps) - } - - // Second refresh with new prices: the previous snapshot must shift into minute-ago. - source.rates = map[string]float64{"TON": 2.0} - source.timestamps = map[string]int64{"TON": 160} c.refresh() - rates, timestamps = c.GetMinuteAgoRatesWithTimestamps() - if rates["TON"] != 1.0 || timestamps["TON"] != 100 { - t.Fatalf("expected previous snapshot in minute-ago, got rates=%v timestamps=%v", rates, timestamps) + rates, err := c.GetRates(time.Now().UTC().Unix()) + if err != nil { + t.Fatalf("GetRates: %v", err) } - rates, timestamps = c.GetTodayRatesWithTimestamps() - if rates["TON"] != 2.0 || timestamps["TON"] != 160 { - t.Fatalf("expected fresh snapshot in today, got rates=%v timestamps=%v", rates, timestamps) + if rates["TON"] != 1.0 { + t.Fatalf("expected today rates from the timestamped source, got %v", rates) } }