diff options
| author | historia <[not public]> | 2026-10-05 14:22:14 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-05 14:22:14 -0400 |
| commit | 90ad8c0c33b519d5830c69d4128bdc984b5e9de8 (patch) | |
| tree | c10bc0bebfbed6fde01a7f08cf6e2d98809b91a7 /internal/cryptomarket/client.go | |
| parent | c84a9944356f590d90d819d652bdf1349ebacd40 (diff) | |
| download | thehouseoficarus-90ad8c0c33b519d5830c69d4128bdc984b5e9de8.tar.gz | |
feat: cryptocurrency exchange added to casino
Diffstat (limited to 'internal/cryptomarket/client.go')
| -rw-r--r-- | internal/cryptomarket/client.go | 347 |
1 files changed, 347 insertions, 0 deletions
diff --git a/internal/cryptomarket/client.go b/internal/cryptomarket/client.go new file mode 100644 index 0000000..4d6d0a8 --- /dev/null +++ b/internal/cryptomarket/client.go @@ -0,0 +1,347 @@ +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 + fetchedAt 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.fetchedAt == nil { + c.fetchedAt = map[string]time.Time{} + } + c.cache = c.cache.merge(result) + now := time.Now() + for sym := range result.Quotes { + c.fetchedAt[sym] = now + } + return result, nil +} + +// fresh reports whether every requested symbol has a recent quote. Each +// symbol ages independently, so another currency's fetch cannot make a stale +// quote look usable. No background work runs: only an explicit interaction +// refreshes anything. +func (c *Client) fresh(symbols []string) bool { + for _, sym := range symbols { + if _, ok := c.cache.Quotes[sym]; !ok { + return false + } + if at, ok := c.fetchedAt[sym]; !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 +} |
