mirror of
https://github.com/luxfi/price.git
synced 2026-10-30 15:21:24 +00:00
Two defects in the default maps, both invisible because pkg/source is documented
as never imported — nothing asked it a question, so nothing noticed the answers.
KRAKEN WAS DEAD FOR EVERY MULTI-ASSET QUERY. "polygon" mapped to MATICUSD;
Polygon renamed the token and Kraken delisted that pair. Kraken's Fetch sends all
pairs in ONE request, and Kraken answers a single unknown pair with
EQuery:Unknown asset pair for the WHOLE batch — so one stale symbol returned
nothing at all, for every asset, including the fourteen it knows perfectly well.
Mapped to POLUSD, which quotes. Note this contradicts the Source contract as
written ("Tokens the source does not know about are silently dropped ... they are
not an error"): for Kraken an unknown token is fatal to the entire call, so the
map is load-bearing in a way the interface does not admit.
TON WAS ABSENT FROM BOTH MAPS while every other major was present. Added as TON
(Coinbase) and TONUSD (Kraken) — no X/Z prefix, it is not a legacy major.
Tests. TestBothVenuesCoverTheSameAssets enforces the rule defaults.go already
states and nothing checked ("Keep the two maps over the SAME key set"), because a
key in one map and not the other is a venue silently dropping an asset.
TestLiveVenuesKnowEveryAsset asks BOTH venues about EVERY id, which is the only
check that goes stale loudly — it is what caught MATICUSD, and a map is only as
true as the venue still agrees with. Guarded by PRICE_LIVE=1.
Found while evaluating this package as the estate's price source for crediting
native BTC/XRP/TON deposits.
Co-authored-by: Hanzo Dev <[email protected]>
113 lines
3.3 KiB
Go
113 lines
3.3 KiB
Go
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package source
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// defaults.go states the rule this enforces: "Keep the two maps over the SAME
|
|
// key set so both venues quote the same universe."
|
|
//
|
|
// Nothing checked it. A key present in one map and absent from the other is a
|
|
// venue that silently drops that asset — Fetch treats an unknown ID as "not an
|
|
// error" and simply omits it — so the caller gets ONE quote where it asked for
|
|
// two and, if it aggregates, may fall below its own quorum without anything
|
|
// saying why.
|
|
func TestBothVenuesCoverTheSameAssets(t *testing.T) {
|
|
cb, kr := DefaultSymbolMap(), DefaultKrakenPairs()
|
|
|
|
var missingFromKraken, missingFromCoinbase []string
|
|
for id := range cb {
|
|
if _, ok := kr[id]; !ok {
|
|
missingFromKraken = append(missingFromKraken, id)
|
|
}
|
|
}
|
|
for id := range kr {
|
|
if _, ok := cb[id]; !ok {
|
|
missingFromCoinbase = append(missingFromCoinbase, id)
|
|
}
|
|
}
|
|
sort.Strings(missingFromKraken)
|
|
sort.Strings(missingFromCoinbase)
|
|
|
|
if len(missingFromKraken) > 0 {
|
|
t.Errorf("in DefaultSymbolMap but not DefaultKrakenPairs: %v", missingFromKraken)
|
|
}
|
|
if len(missingFromCoinbase) > 0 {
|
|
t.Errorf("in DefaultKrakenPairs but not DefaultSymbolMap: %v", missingFromCoinbase)
|
|
}
|
|
}
|
|
|
|
// Every value must be non-empty. An empty pair or ticker builds a URL that asks
|
|
// the venue for nothing and reads as "this asset is not listed" rather than as
|
|
// the typo it is.
|
|
func TestNoEmptyVenueSymbols(t *testing.T) {
|
|
for name, m := range map[string]map[string]string{
|
|
"DefaultSymbolMap": DefaultSymbolMap(),
|
|
"DefaultKrakenPairs": DefaultKrakenPairs(),
|
|
} {
|
|
for id, sym := range m {
|
|
if sym == "" {
|
|
t.Errorf("%s[%q] is empty", name, id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The map is only as true as the venue agrees with, and a ticker that was right
|
|
// when it was written can be delisted or renamed. This asks both venues about
|
|
// EVERY id in the maps, which is the only way that goes stale loudly.
|
|
//
|
|
// Skipped by default — it reaches two third parties. Run it deliberately:
|
|
//
|
|
// PRICE_LIVE=1 go test ./pkg/source/ -run TestLiveVenuesKnowEveryAsset -v
|
|
func TestLiveVenuesKnowEveryAsset(t *testing.T) {
|
|
if os.Getenv("PRICE_LIVE") == "" {
|
|
t.Skip("set PRICE_LIVE=1 to read the real venues")
|
|
}
|
|
ids := make([]string, 0, len(DefaultSymbolMap()))
|
|
for id := range DefaultSymbolMap() {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
|
|
for _, s := range []Source{
|
|
NewCoinbase("", DefaultSymbolMap()),
|
|
NewKraken("", DefaultKrakenPairs()),
|
|
} {
|
|
got, err := s.Fetch(ctx, ids)
|
|
if err != nil {
|
|
t.Errorf("%s: %v", s.Name(), err)
|
|
continue
|
|
}
|
|
for _, id := range ids {
|
|
q, ok := got[id]
|
|
if !ok {
|
|
// Not an error from Fetch's point of view — an unknown id is
|
|
// dropped silently — which is exactly why it is an error here.
|
|
t.Errorf("%s does not know %q (mapped to %q)", s.Name(), id, venueSymbol(s, id))
|
|
continue
|
|
}
|
|
if q.PriceUSD <= 0 {
|
|
t.Errorf("%s priced %q at %v", s.Name(), id, q.PriceUSD)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func venueSymbol(s Source, id string) string {
|
|
if s.Name() == "kraken" {
|
|
return DefaultKrakenPairs()[id]
|
|
}
|
|
return DefaultSymbolMap()[id]
|
|
}
|