Files
zeekayandHanzo Dev 9864cb54a1 oracle: fill the empty slot — a justified price, or a refusal
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]>
2026-08-09 09:29:47 -07:00

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)
}
}