aboutsummaryrefslogtreecommitdiff
path: root/internal/cryptomarket/client.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-05 14:22:14 -0400
committerhistoria <[not public]>2026-10-05 14:22:14 -0400
commit90ad8c0c33b519d5830c69d4128bdc984b5e9de8 (patch)
treec10bc0bebfbed6fde01a7f08cf6e2d98809b91a7 /internal/cryptomarket/client.go
parentc84a9944356f590d90d819d652bdf1349ebacd40 (diff)
downloadthehouseoficarus-90ad8c0c33b519d5830c69d4128bdc984b5e9de8.tar.gz
feat: cryptocurrency exchange added to casino
Diffstat (limited to 'internal/cryptomarket/client.go')
-rw-r--r--internal/cryptomarket/client.go347
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
+}