mirror of
https://github.com/luxfi/maker.git
synced 2026-10-30 16:20:04 +00:00
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]>
230 lines
8.1 KiB
Go
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)
|
|
}
|