Files
maker/provider_kraken.go
zeekayandHanzo Dev 1ac4c806b2 Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers; omitzero is not applied, since it changes what the JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 19:23:15 -07:00

230 lines
8.1 KiB
Go

package maker
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"maps"
"net/http"
"net/url"
"strconv"
"time"
)
// provider_kraken.go is a LIVE CEX price reference: it reads the public Kraken
// order book for a listed major (BTC/USD, ETH/USD, …) and exposes it as a
// ProviderSource (aggregator.go), exactly like provider_oma.go / provider_v3.go.
// It feeds the seeder's MidProvider so a DEX market that mirrors a CEX-listed
// asset stays pinned to the live CEX mid — the maker is the oracle-tracking LP.
//
// PUBLIC MARKET DATA ONLY. The endpoints below (/0/public/Ticker, /0/public/Depth)
// need no API key, no auth, and read prices only — the maker never trades on
// Kraken. This is the honest external reference for a listed pair, the CEX analog
// of provider_v3's native-AMM reference for a LUX/* pair.
// krakenPublicBase is the Kraken public REST root. Market-data endpoints under it
// are unauthenticated and read-only.
const krakenPublicBase = "https://api.kraken.com/0/public"
// krakenDepthCount is how many book levels to request from /Depth. A shallow book
// is enough for a mid; deeper levels feed the aggregator's phantom depth view.
const krakenDepthCount = 25
// krakenHTTP is the shared client. A tight timeout keeps a stalled CEX from
// wedging the requote loop — a missed poll just falls back to the prior mid.
var krakenHTTP = &http.Client{Timeout: 8 * time.Second}
// KrakenProviderSource builds a ProviderSource that reads live BBO and book depth
// from Kraken's public REST for each mapped symbol. pairs maps a maker symbol
// (e.g. "ETH/USD") to a Kraken query pair (e.g. "ETHUSD" / "XBTUSD"); an unmapped
// symbol yields no levels, so the aggregator skips it and the seeder falls back to
// the DEX book mid then the configured reference (graceful, never fabricated).
func KrakenProviderSource(name string, pairs map[string]string) ProviderSource {
return krakenProviderSourceWithBase(name, krakenPublicBase, pairs)
}
// krakenProviderSourceWithBase is the constructor with an injectable base URL, so a
// test can point it at an httptest server serving canned Kraken frames.
func krakenProviderSourceWithBase(name, base string, pairs map[string]string) ProviderSource {
return ProviderSource{
Name: name,
GetBBO: func(ctx context.Context, symbol string) (bid, ask, last float64, err error) {
pair, ok := pairs[symbol]
if !ok {
return 0, 0, 0, fmt.Errorf("kraken: no pair for %s", symbol)
}
t, err := krakenTickerOf(ctx, base, pair)
if err != nil {
return 0, 0, 0, err
}
return t.bid, t.ask, t.last, nil
},
GetBook: func(ctx context.Context, symbol string) (bids, asks []PriceLevel, err error) {
pair, ok := pairs[symbol]
if !ok {
return nil, nil, fmt.Errorf("kraken: no pair for %s", symbol)
}
return krakenBookOf(ctx, base, pair, name)
},
}
}
// NewKrakenFeed builds a live CEX price feed (a MidProvider the seeder tracks) for
// the given symbol→Kraken-pair map, or nil if the map is empty (no Kraken config →
// no feed, seeder keeps its prior behavior). One-liner so the maker can pin every
// listed market to the live CEX mid: seeder.UseFeed(maker.NewKrakenFeed(...)).
func NewKrakenFeed(pairs map[string]string, name string, logger *slog.Logger) MidProvider {
if len(pairs) == 0 {
return nil
}
if name == "" {
name = "kraken"
}
return NewAggregator([]ProviderSource{KrakenProviderSource(name, pairs)}, logger)
}
// --- Kraken REST codecs (the one place the public frame is decoded) ---
// krakenEnvelope is the standard Kraken response envelope: a (usually empty) error
// list and a result keyed by Kraken's normalized pair name (e.g. "XXBTZUSD" for a
// query of "XBTUSD"). We query one pair at a time, so result has exactly one entry
// and we read whichever key it is — no need to hard-code Kraken's altname map.
type krakenEnvelope struct {
Error []string `json:"error"`
Result map[string]json.RawMessage `json:"result"`
}
// krakenTick is a parsed ticker BBO. Kraken returns each field as a string array:
// a=[ask, wholeLotVol, lotVol], b=[bid, …], c=[last, lotVol].
type krakenTick struct {
bid, ask, last float64
}
type krakenTickerRaw struct {
A []string `json:"a"`
B []string `json:"b"`
C []string `json:"c"`
}
// krakenGet fetches one public endpoint for one pair and returns the single result
// payload, erroring on transport failure or a non-empty Kraken error list.
func krakenGet(ctx context.Context, base, endpoint, pair string, extra url.Values) (json.RawMessage, error) {
q := url.Values{"pair": {pair}}
maps.Copy(q, extra)
u := base + "/" + endpoint + "?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("kraken %s req: %w", endpoint, err)
}
resp, err := krakenHTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("kraken %s: %w", endpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("kraken %s read: %w", endpoint, err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("kraken %s: http %d", endpoint, resp.StatusCode)
}
var env krakenEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, fmt.Errorf("kraken %s decode: %w", endpoint, err)
}
if len(env.Error) > 0 {
return nil, fmt.Errorf("kraken %s error: %v", endpoint, env.Error)
}
for _, v := range env.Result { // exactly one entry for a single-pair query
return v, nil
}
return nil, fmt.Errorf("kraken %s: no result for %s", endpoint, pair)
}
// krakenTickerOf reads BBO + last for a pair from /Ticker.
func krakenTickerOf(ctx context.Context, base, pair string) (krakenTick, error) {
raw, err := krakenGet(ctx, base, "Ticker", pair, nil)
if err != nil {
return krakenTick{}, err
}
var tr krakenTickerRaw
if err := json.Unmarshal(raw, &tr); err != nil {
return krakenTick{}, fmt.Errorf("kraken ticker decode: %w", err)
}
ask, err := krakenFirst(tr.A)
if err != nil {
return krakenTick{}, fmt.Errorf("ticker ask: %w", err)
}
bid, err := krakenFirst(tr.B)
if err != nil {
return krakenTick{}, fmt.Errorf("ticker bid: %w", err)
}
last, err := krakenFirst(tr.C)
if err != nil {
last = (bid + ask) / 2 // last is advisory; fall back to mid
}
return krakenTick{bid: bid, ask: ask, last: last}, nil
}
// krakenBookOf reads the order book for a pair from /Depth and returns it as
// phantom price levels (external venue depth — display, not fillable on our DEX).
func krakenBookOf(ctx context.Context, base, pair, src string) (bids, asks []PriceLevel, err error) {
raw, err := krakenGet(ctx, base, "Depth", pair, url.Values{"count": {strconv.Itoa(krakenDepthCount)}})
if err != nil {
return nil, nil, err
}
var d struct {
Asks [][]json.RawMessage `json:"asks"`
Bids [][]json.RawMessage `json:"bids"`
}
if err := json.Unmarshal(raw, &d); err != nil {
return nil, nil, fmt.Errorf("kraken depth decode: %w", err)
}
asks = krakenLevels(d.Asks, src)
bids = krakenLevels(d.Bids, src)
if len(asks) == 0 || len(bids) == 0 {
return nil, nil, fmt.Errorf("kraken depth: empty book for %s", pair)
}
return bids, asks, nil
}
// krakenLevels parses Kraken depth entries [price, volume, timestamp] into phantom
// PriceLevels, skipping any malformed or non-positive row.
func krakenLevels(rows [][]json.RawMessage, src string) []PriceLevel {
out := make([]PriceLevel, 0, len(rows))
for _, r := range rows {
if len(r) < 2 {
continue
}
price, err := krakenStrFloat(r[0])
if err != nil || !(price > 0) {
continue
}
qty, err := krakenStrFloat(r[1])
if err != nil || !(qty > 0) {
continue
}
out = append(out, PriceLevel{Price: price, Qty: qty, Source: src, Phantom: true})
}
return out
}
// krakenFirst parses the first element of a Kraken string array (e.g. ask[0]).
func krakenFirst(a []string) (float64, error) {
if len(a) == 0 {
return 0, fmt.Errorf("empty array")
}
return strconv.ParseFloat(a[0], 64)
}
// krakenStrFloat parses a JSON value that Kraken encodes as a quoted decimal string.
func krakenStrFloat(m json.RawMessage) (float64, error) {
var s string
if err := json.Unmarshal(m, &s); err != nil {
return 0, err
}
return strconv.ParseFloat(s, 64)
}