package cryptomarket import ( "context" "encoding/json" "fmt" "io" "math/big" "net/http" "net/url" "sort" "sync" "time" ) type Instrument struct{ Symbol, Pair, Key string } var Instruments = []Instrument{ {"BTC", "XBTUSD", "XXBTZUSD"}, {"ETH", "ETHUSD", "XETHZUSD"}, {"SOL", "SOLUSD", "SOLUSD"}, {"DOGE", "DOGEUSD", "XDGUSD"}, {"SHIB", "SHIBUSD", "SHIBUSD"}, } func Find(symbol string) (Instrument, bool) { for _, i := range Instruments { if i.Symbol == symbol { return i, true } } return Instrument{}, false } type Quote struct { Bid, Ask string At time.Time } func (q Quote) Validate(now time.Time) error { b, e := Decimal(q.Bid) if e != nil { return e } a, e := Decimal(q.Ask) if e != nil { return e } if a.Cmp(b) < 0 || q.At.After(now.Add(5*time.Second)) || now.Sub(q.At) > 60*time.Second { return fmt.Errorf("stale or crossed quote") } return nil } func (q Quote) mid() *big.Rat { return mul(add(rat(q.Bid), rat(q.Ask)), rat("0.5")) } type Candle struct { Time int64 Open, High, Low, Close string } func (c Candle) Validate() error { o, e := Decimal(c.Open) if e != nil { return e } h, e := Decimal(c.High) if e != nil { return e } l, e := Decimal(c.Low) if e != nil { return e } v, e := Decimal(c.Close) if e != nil { return e } if c.Time <= 0 || c.Time%86400 != 0 || h.Cmp(l) < 0 || o.Cmp(l) < 0 || o.Cmp(h) > 0 || v.Cmp(l) < 0 || v.Cmp(h) > 0 { return fmt.Errorf("invalid daily candle") } return nil } type Snapshot struct { Quotes map[string]Quote Candles map[string][]Candle } // Expiry uses the completed UTC day's close, never today's price or the // unfinished candle. Missing history leaves a contract pending, not mispriced. func (s Snapshot) Expiry(symbol string, expiry int64) (string, bool) { for _, c := range s.Candles[symbol] { if c.Time+86400 == expiry && c.Validate() == nil { return c.Close, true } } return "", false } type Client struct { HTTP *http.Client Base string once sync.Once gate chan struct{} cache Snapshot attemptedAt map[string]time.Time historyDay int64 history map[string][]Candle retryAfter time.Time lastError error } func NewClient() *Client { return &Client{HTTP: &http.Client{Timeout: 10 * time.Second}, Base: "https://api.kraken.com/0/public"} } func (c *Client) get(ctx context.Context, path string, out any) error { req, e := http.NewRequestWithContext(ctx, "GET", c.Base+path, nil) if e != nil { return e } req.Header.Set("User-Agent", "IcarusMUD-PaperCrypto/1.0") req.Header.Set("Cache-Control", "no-cache") r, e := c.HTTP.Do(req) if e != nil { return e } defer r.Body.Close() if r.StatusCode != 200 { return fmt.Errorf("kraken HTTP %d", r.StatusCode) } var envelope struct { Error []string `json:"error"` Result json.RawMessage `json:"result"` } if e = json.NewDecoder(io.LimitReader(r.Body, 4<<20)).Decode(&envelope); e != nil { return e } if len(envelope.Error) > 0 { return fmt.Errorf("kraken rejected request") } return json.Unmarshal(envelope.Result, out) } // Fetch requests only allowlisted USD markets. Metadata prevents trading a // suspended/delisted pair. One failed instrument does not poison other pairs. func (c *Client) Fetch(ctx context.Context, symbols []string) (Snapshot, error) { s := Snapshot{Quotes: map[string]Quote{}, Candles: map[string][]Candle{}} if len(symbols) == 0 { return s, nil } for _, sym := range symbols { if _, ok := Find(sym); !ok { return s, fmt.Errorf("unsupported currency") } } // Coalesce concurrent users, bound polling, and share immutable daily history. c.once.Do(func() { c.gate = make(chan struct{}, 1) }) select { case c.gate <- struct{}{}: case <-ctx.Done(): return s, ctx.Err() } defer func() { <-c.gate }() if ctx.Err() != nil { return s, ctx.Err() } if time.Now().Before(c.retryAfter) { return s, c.lastError } if c.fresh(symbols) { return c.cache.selectSymbols(symbols), nil } result, err := c.fetch(ctx, symbols) if err != nil { // A caller's own cancellation or deadline must never become the shared // backoff error: that would show every other visitor a closed counter // for 15 seconds because one player walked away mid-fetch. if ctx.Err() != nil { return result, err } c.lastError = err c.retryAfter = time.Now().Add(15 * time.Second) return result, err } if c.attemptedAt == nil { c.attemptedAt = map[string]time.Time{} } c.cache = c.cache.merge(result) now := time.Now() for _, sym := range symbols { c.attemptedAt[sym] = now } return result, nil } // fresh reports whether every requested symbol has a recent fetch attempt. A // symbol that was attempted but produced no quote (halted/delisted, or a // missing/empty ticker) is still considered fresh: without this, every crypto // command would re-run the full request chain for a permanently-unavailable // pair. No background work runs: only an explicit interaction refreshes // anything. func (c *Client) fresh(symbols []string) bool { for _, sym := range symbols { at, ok := c.attemptedAt[sym] if !ok || time.Since(at) >= 15*time.Second { return false } } return true } func (s Snapshot) selectSymbols(symbols []string) Snapshot { out := Snapshot{Quotes: map[string]Quote{}, Candles: map[string][]Candle{}} for _, sym := range symbols { if q, ok := s.Quotes[sym]; ok { out.Quotes[sym] = q } if c, ok := s.Candles[sym]; ok { out.Candles[sym] = append([]Candle(nil), c...) } } return out } func (s Snapshot) merge(other Snapshot) Snapshot { out := s.clone() for k, v := range other.Quotes { out.Quotes[k] = v } for k, v := range other.Candles { out.Candles[k] = append([]Candle(nil), v...) } return out } func (s Snapshot) clone() Snapshot { out := Snapshot{Quotes: map[string]Quote{}, Candles: map[string][]Candle{}} for k, v := range s.Quotes { out.Quotes[k] = v } for k, v := range s.Candles { out.Candles[k] = append([]Candle(nil), v...) } return out } func (c *Client) fetch(ctx context.Context, symbols []string) (Snapshot, error) { s := Snapshot{Quotes: map[string]Quote{}, Candles: map[string][]Candle{}} pairs := "" for _, sym := range symbols { i, ok := Find(sym) if !ok { return s, fmt.Errorf("unsupported currency") } if pairs != "" { pairs += "," } pairs += i.Pair } var status struct { Status string `json:"status"` } if e := c.get(ctx, "/SystemStatus", &status); e != nil { return s, e } if status.Status != "online" { return s, fmt.Errorf("kraken is not online") } var meta map[string]struct { Status string `json:"status"` } if e := c.get(ctx, "/AssetPairs?pair="+url.QueryEscape(pairs), &meta); e != nil { return s, e } var ticks map[string]struct { Ask []string `json:"a"` Bid []string `json:"b"` } started := time.Now().UTC() if e := c.get(ctx, "/Ticker?pair="+url.QueryEscape(pairs), &ticks); e != nil { return s, e } day := started.Unix() / 86400 if c.historyDay != day { c.history = map[string][]Candle{} c.historyDay = day } for _, sym := range symbols { i, _ := Find(sym) v, ok := ticks[i.Key] if ok && meta[i.Key].Status == "online" && len(v.Ask) > 0 && len(v.Bid) > 0 { q := Quote{Bid: v.Bid[0], Ask: v.Ask[0], At: started} if q.Validate(time.Now()) == nil { s.Quotes[sym] = q } } if history, ok := c.history[sym]; ok { s.Candles[sym] = append([]Candle(nil), history...) continue } var raw map[string]json.RawMessage if e := c.get(ctx, "/OHLC?pair="+url.QueryEscape(i.Pair)+"&interval=1440", &raw); e != nil { continue } var rows [][]json.RawMessage if json.Unmarshal(raw[i.Key], &rows) != nil { continue } candles := []Candle{} valid := true var last int64 for _, r := range rows { if len(r) != 8 { valid = false break } var v Candle if json.Unmarshal(r[0], &v.Time) != nil || json.Unmarshal(r[1], &v.Open) != nil || json.Unmarshal(r[2], &v.High) != nil || json.Unmarshal(r[3], &v.Low) != nil || json.Unmarshal(r[4], &v.Close) != nil || v.Validate() != nil || v.Time <= last { valid = false break } last = v.Time if v.Time+86400 <= started.Unix() { candles = append(candles, v) } } if valid { s.Candles[sym] = candles // Retry history until the immediately preceding day is present; a // midnight publication lag must not cache missing expiry data all day. if len(candles) > 0 && candles[len(candles)-1].Time+86400 == day*86400 { c.history[sym] = append([]Candle(nil), candles...) } } } if len(s.Quotes) == 0 && len(s.Candles) == 0 { return s, fmt.Errorf("no valid live quotes") } return s, nil } func Symbols(a Account) []string { m := map[string]bool{} for _, p := range a.Positions { if p.Status == "open" { m[p.Symbol] = true } } out := []string{} for s := range m { out = append(out, s) } sort.Strings(out) return out }