mirror of
https://github.com/luxfi/price.git
synced 2026-10-30 15:21:24 +00:00
pkg/oracle has been an empty directory while pkg/source sat unimported, so this repo could FETCH a price and had nowhere to decide whether one may be believed. The service answers from CoinGecko alone behind an hour cache, which is right for a ticker and wrong for money: one upstream is one outage and one bad tick away from valuing everything, and an hour-old BTC quote is routinely tens of dollars stale (measured: $24.36 against the venues). Every one of these REFUSES rather than falling back — fewer than two independent sources answered, they disagree by more than 2%, or a source answered <= 0. One error for all of them, because a caller's only correct response to any is to not act and retry; distinguishing them invites handling one by acting anyway. New() refuses a duplicate venue name at construction: two entries for one exchange satisfy the COUNT while providing one opinion, which is the property MinSources is meant to buy. The median takes the LOWER middle, so with two sources it is the minimum. A venue quoting high can never pull a price above what the conservative venue said. Prices are MICRO-CENTS. XRP at $1.04295 and TON at $1.3412 become $1.04 and $1.34 as whole cents — 0.28% and 0.09% off on every use. Whole cents are precise enough for BTC and nowhere near it for a sub-dollar coin, and precision cannot depend on which asset is being priced. The float64 in source.Quote costs at most one micro-cent per whole unit (~1e-10 dollars on a realistic deposit); the bound is stated in microCents() rather than assumed, and carrying the venue's decimal string through source.Quote would remove it. Batch-shaped because source.Fetch is. An id that cannot be justified is OMITTED rather than zero — a zero price is a number, and a number is something a caller uses by accident. Tests stub the venues so the JUDGEMENT is what is asserted, plus a live probe (PRICE_LIVE=1) over bitcoin/ripple/the-open-network. The live one earned its keep immediately: its BTC sanity floor was written as $100,000 instead of $1,000 and failed against a correct price. Co-authored-by: Hanzo Dev <[email protected]>
220 lines
6.8 KiB
Go
220 lines
6.8 KiB
Go
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package oracle
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/luxfi/price/pkg/source"
|
|
)
|
|
|
|
// stub answers whatever it was told to, so what these exercise is the ORACLE's
|
|
// judgement rather than an exchange's uptime.
|
|
type stub struct {
|
|
name string
|
|
usd map[string]float64
|
|
err error
|
|
disable bool
|
|
}
|
|
|
|
func (s stub) Name() string { return s.name }
|
|
func (s stub) Enabled() bool { return !s.disable }
|
|
func (s stub) Fetch(_ context.Context, ids []string) (map[string]source.Quote, error) {
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
out := map[string]source.Quote{}
|
|
for _, id := range ids {
|
|
if p, ok := s.usd[id]; ok {
|
|
out[id] = source.Quote{ID: id, PriceUSD: p, SourceName: s.name}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func at(name string, usd float64) stub {
|
|
return stub{name: name, usd: map[string]float64{"bitcoin": usd}}
|
|
}
|
|
|
|
func price(t *testing.T, sources ...source.Source) (Price, error) {
|
|
t.Helper()
|
|
o, err := New(sources...)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
return o.Price(context.Background(), "bitcoin")
|
|
}
|
|
|
|
// --- what must be REFUSED. Each of these moves money at a wrong number.
|
|
|
|
func TestOneSourceIsNeverEnough(t *testing.T) {
|
|
if _, err := New(at("only", 65000)); err == nil {
|
|
t.Fatal("New accepted a single source — one venue could price everything alone")
|
|
}
|
|
}
|
|
|
|
func TestTwoNamesForOneVenueIsRefused(t *testing.T) {
|
|
if _, err := New(at("kraken", 65000), at("Kraken ", 65000)); err == nil {
|
|
t.Fatal("New accepted the same venue twice — the count is satisfied by one opinion")
|
|
}
|
|
}
|
|
|
|
func TestASourceThatErrorsIsNotAQuorum(t *testing.T) {
|
|
_, err := price(t, at("a", 65000), stub{name: "b", err: errors.New("timeout")})
|
|
if !errors.Is(err, ErrNoPrice) {
|
|
t.Fatalf("err = %v, want ErrNoPrice", err)
|
|
}
|
|
}
|
|
|
|
func TestADisabledSourceIsNotAQuorum(t *testing.T) {
|
|
// Enabled() is part of the interface, so a disabled venue must not be counted
|
|
// toward independence it is not providing.
|
|
_, err := price(t, at("a", 65000), stub{name: "b", usd: map[string]float64{"bitcoin": 65000}, disable: true})
|
|
if !errors.Is(err, ErrNoPrice) {
|
|
t.Fatalf("err = %v, want ErrNoPrice — a disabled source counted", err)
|
|
}
|
|
}
|
|
|
|
func TestANonPositivePriceIsNotAPrice(t *testing.T) {
|
|
// The trap: 0 must be a FAILURE, not "worth nothing". Credited as zero it
|
|
// reads as dust rather than as the outage it is.
|
|
for _, bad := range []float64{0, -1} {
|
|
if _, err := price(t, at("a", 65000), at("b", bad)); !errors.Is(err, ErrNoPrice) {
|
|
t.Errorf("price %v was accepted as a source", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDisagreementIsRefusedRatherThanResolved(t *testing.T) {
|
|
// 10% apart on a liquid pair means one is wrong and nothing here can tell
|
|
// which. Choosing is the guess this package exists to avoid.
|
|
if _, err := price(t, at("a", 65000), at("b", 71500)); !errors.Is(err, ErrNoPrice) {
|
|
t.Fatalf("err = %v, want ErrNoPrice", err)
|
|
}
|
|
}
|
|
|
|
func TestAnIdNobodyKnowsIsOmittedNotZero(t *testing.T) {
|
|
o, err := New(at("a", 65000), at("b", 65001))
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
got := o.Prices(context.Background(), []string{"bitcoin", "nonesuch"})
|
|
if _, ok := got["nonesuch"]; ok {
|
|
t.Error("an unknown id is present in the result — a zero price is a number a caller can use by accident")
|
|
}
|
|
if _, ok := got["bitcoin"]; !ok {
|
|
t.Error("one unknown id suppressed a knowable one")
|
|
}
|
|
}
|
|
|
|
// --- what must be ANSWERED, and with which number.
|
|
|
|
func TestTwoAgreeingSourcesTakeTheLower(t *testing.T) {
|
|
p, err := price(t, at("a", 65191.085), at("b", 65194.500))
|
|
if err != nil {
|
|
t.Fatalf("Price: %v", err)
|
|
}
|
|
// Downward bias, matching the truncation everywhere else: never credit value
|
|
// that was not sent.
|
|
if want := microCents(65191.085); p.MicroCents != want {
|
|
t.Errorf("micro-cents = %d, want the lower quote %d", p.MicroCents, want)
|
|
}
|
|
if len(p.Sources) != 2 {
|
|
t.Errorf("sources = %v, want both named for the audit trail", p.Sources)
|
|
}
|
|
if p.At.IsZero() {
|
|
t.Error("no timestamp — a market price must say when it was read")
|
|
}
|
|
}
|
|
|
|
func TestThreeSourcesTakeTheMiddle(t *testing.T) {
|
|
p, err := price(t, at("a", 65000), at("b", 65100), at("c", 65200))
|
|
if err != nil {
|
|
t.Fatalf("Price: %v", err)
|
|
}
|
|
if want := microCents(65100); p.MicroCents != want {
|
|
t.Errorf("micro-cents = %d, want the median %d", p.MicroCents, want)
|
|
}
|
|
}
|
|
|
|
// The precision that micro-cents exist for. XRP at $1.04295 credited as whole
|
|
// cents is $1.04 — 0.28% under, on every deposit, forever.
|
|
func TestSubDollarPrecisionSurvives(t *testing.T) {
|
|
o, err := New(
|
|
stub{name: "a", usd: map[string]float64{"ripple": 1.04295}},
|
|
stub{name: "b", usd: map[string]float64{"ripple": 1.04295}},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
p, err := o.Price(context.Background(), "ripple")
|
|
if err != nil {
|
|
t.Fatalf("Price: %v", err)
|
|
}
|
|
// 104 whole cents would be the lossy answer; the sub-cent digits must remain.
|
|
if p.MicroCents/Scale != 104 {
|
|
t.Fatalf("micro-cents = %d, want ~104 cents", p.MicroCents)
|
|
}
|
|
if p.MicroCents%Scale == 0 {
|
|
t.Errorf("micro-cents = %d has no sub-cent digits — the precision was lost", p.MicroCents)
|
|
}
|
|
}
|
|
|
|
func TestMicroCentsTruncatesDownAndRefusesNonsense(t *testing.T) {
|
|
if got, want := microCents(1.0), int64(100*Scale); got != want {
|
|
t.Errorf("microCents(1.0) = %d, want %d", got, want)
|
|
}
|
|
// Never rounds up.
|
|
if microCents(0.019999999) >= microCents(0.02) {
|
|
t.Error("a value below 0.02 priced at or above it — not truncating")
|
|
}
|
|
for _, bad := range []float64{0, -1} {
|
|
if got := microCents(bad); got != 0 {
|
|
t.Errorf("microCents(%v) = %d, want 0", bad, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- live, because a stub cannot fail the way a venue renames a field.
|
|
|
|
func TestLivePricesTheThreeNativeCoins(t *testing.T) {
|
|
if os.Getenv("PRICE_LIVE") == "" {
|
|
t.Skip("set PRICE_LIVE=1 to read the real venues")
|
|
}
|
|
o, err := New(
|
|
source.NewCoinbase("", source.DefaultSymbolMap()),
|
|
source.NewKraken("", source.DefaultKrakenPairs()),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
ids := []string{"bitcoin", "ripple", "the-open-network"}
|
|
got := o.Prices(ctx, ids)
|
|
for _, id := range ids {
|
|
p, ok := got[id]
|
|
if !ok {
|
|
t.Errorf("%s: refused — no justified price", id)
|
|
continue
|
|
}
|
|
t.Logf("%-18s $%s spread=%.4f%% %v", id, p, p.Spread*100, p.Sources)
|
|
if p.MicroCents <= 0 {
|
|
t.Errorf("%s: %d micro-cents", id, p.MicroCents)
|
|
}
|
|
}
|
|
// A floor, not a price assertion: BTC under $1,000 means a parse landed on
|
|
// the wrong field, which is the failure a stub cannot produce. One dollar is
|
|
// 100*Scale micro-cents, so the floor is 1_000 dollars written that way —
|
|
// the first cut of this line said 100_000 and failed on a correct price.
|
|
if p, ok := got["bitcoin"]; ok && p.MicroCents < 1_000*100*Scale {
|
|
t.Errorf("bitcoin priced at $%s — that is not a BTC price", p)
|
|
}
|
|
}
|