aboutsummaryrefslogtreecommitdiff
path: root/internal/cryptomarket
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
parentc84a9944356f590d90d819d652bdf1349ebacd40 (diff)
downloadthehouseoficarus-90ad8c0c33b519d5830c69d4128bdc984b5e9de8.tar.gz
feat: cryptocurrency exchange added to casino
Diffstat (limited to 'internal/cryptomarket')
-rw-r--r--internal/cryptomarket/chart.go78
-rw-r--r--internal/cryptomarket/client.go347
-rw-r--r--internal/cryptomarket/client_test.go216
-rw-r--r--internal/cryptomarket/exchange.go426
-rw-r--r--internal/cryptomarket/exchange_test.go339
-rw-r--r--internal/cryptomarket/money.go72
6 files changed, 1478 insertions, 0 deletions
diff --git a/internal/cryptomarket/chart.go b/internal/cryptomarket/chart.go
new file mode 100644
index 0000000..c1fef29
--- /dev/null
+++ b/internal/cryptomarket/chart.go
@@ -0,0 +1,78 @@
+package cryptomarket
+
+import (
+ "fmt"
+ "math"
+ "strings"
+ "time"
+)
+
+// Chart uses one column per completed daily candle, auto-scales for BTC and
+// tiny tokens alike, and delegates color to the player's configured palette.
+func Chart(candles []Candle, width int, unicode bool, paint func(string, bool) string) []string {
+ width = max(12, min(80, width))
+ label := 12
+ count := width - label - 2
+ if count < 2 || len(candles) < 2 {
+ return []string{"Not enough completed daily candles."}
+ }
+ if len(candles) > count {
+ candles = candles[len(candles)-count:]
+ }
+ type bar struct{ o, h, l, c float64 }
+ bars := []bar{}
+ low, high := math.Inf(1), math.Inf(-1)
+ for _, c := range candles {
+ if c.Validate() != nil {
+ return []string{"Invalid candle history."}
+ }
+ o, _ := rat(c.Open).Float64()
+ h, _ := rat(c.High).Float64()
+ l, _ := rat(c.Low).Float64()
+ v, _ := rat(c.Close).Float64()
+ bars = append(bars, bar{o, h, l, v})
+ low = math.Min(low, l)
+ high = math.Max(high, h)
+ }
+ if high == low {
+ padding := math.Max(high*0.01, 1e-18)
+ low -= padding
+ high += padding
+ }
+ const height = 10
+ row := func(p float64) int { return max(0, min(height-1, int(math.Round((high-p)/(high-low)*(height-1))))) }
+ grid := make([][]string, height)
+ for y := range grid {
+ grid[y] = make([]string, len(bars))
+ for x := range grid[y] {
+ grid[y][x] = " "
+ }
+ }
+ wick, body, doji, axis := "|", "#", "-", "|"
+ if unicode {
+ wick, body, doji, axis = "│", "█", "━", "│"
+ }
+ for x, b := range bars {
+ top, bottom := min(row(b.o), row(b.c)), max(row(b.o), row(b.c))
+ for y := row(b.h); y <= row(b.l); y++ {
+ ch := wick
+ if y >= top && y <= bottom {
+ ch = body
+ if top == bottom {
+ ch = doji
+ }
+ }
+ if paint != nil {
+ ch = paint(ch, b.c >= b.o)
+ }
+ grid[y][x] = ch
+ }
+ }
+ out := []string{"USD / credits; daily candles (UTC). Up/down colored; body=open-close, wick=range."}
+ for y := range grid {
+ v := high - float64(y)*(high-low)/(height-1)
+ out = append(out, fmt.Sprintf("%11.5g %s%s", v, axis, strings.Join(grid[y], "")))
+ }
+ out = append(out, fmt.Sprintf("%s to %s", time.Unix(candles[0].Time, 0).UTC().Format("2006-01-02"), time.Unix(candles[len(candles)-1].Time, 0).UTC().Format("2006-01-02")))
+ return out
+}
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
+}
diff --git a/internal/cryptomarket/client_test.go b/internal/cryptomarket/client_test.go
new file mode 100644
index 0000000..39367fc
--- /dev/null
+++ b/internal/cryptomarket/client_test.go
@@ -0,0 +1,216 @@
+package cryptomarket
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func mockKraken(t *testing.T, mode string) (*Client, *atomic.Int32) {
+ t.Helper()
+ calls := &atomic.Int32{}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ if mode == "http" {
+ w.WriteHeader(429)
+ return
+ }
+ if mode == "error" {
+ w.Write([]byte(`{"error":["EAPI:Rate limit exceeded"]}`))
+ return
+ }
+ result := any(nil)
+ switch r.URL.Path {
+ case "/SystemStatus":
+ status := "online"
+ if mode == "maintenance" {
+ status = "maintenance"
+ }
+ result = map[string]string{"status": status}
+ case "/AssetPairs":
+ m := map[string]any{}
+ for _, i := range Instruments {
+ status := "online"
+ if mode == "halted" && i.Symbol == "BTC" {
+ status = "cancel_only"
+ }
+ m[i.Key] = map[string]string{"status": status}
+ }
+ result = m
+ case "/Ticker":
+ m := map[string]any{}
+ for _, i := range Instruments {
+ bid, ask := "100", "101"
+ if mode == "crossed" && i.Symbol == "BTC" {
+ bid = "102"
+ }
+ m[i.Key] = map[string]any{"a": []string{ask}, "b": []string{bid}}
+ }
+ result = m
+ case "/OHLC":
+ key := ""
+ for _, i := range Instruments {
+ if i.Pair == r.URL.Query().Get("pair") {
+ key = i.Key
+ }
+ }
+ now := time.Now().UTC().Truncate(24 * time.Hour).Unix()
+ high := "110"
+ if mode == "bad-candle" {
+ high = "99"
+ }
+ result = map[string]any{key: [][]any{{now - 86400, "100", high, "90", "101", "100", "1", 1}, {now, "100", "110", "90", "102", "100", "1", 1}}, "last": now}
+ default:
+ t.Errorf("unexpected URL %s", r.URL)
+ }
+ json.NewEncoder(w).Encode(map[string]any{"error": []string{}, "result": result})
+ }))
+ t.Cleanup(srv.Close)
+ c := NewClient()
+ c.Base = srv.URL
+ return c, calls
+}
+func TestClientValidatesQuotesAndCompletedCandles(t *testing.T) {
+ for _, mode := range []string{"ok", "halted", "crossed", "bad-candle"} {
+ t.Run(mode, func(t *testing.T) {
+ c, _ := mockKraken(t, mode)
+ s, e := c.Fetch(context.Background(), []string{"BTC"})
+ if e != nil {
+ t.Fatal(e)
+ }
+ _, quoted := s.Quotes["BTC"]
+ if quoted != (mode != "halted" && mode != "crossed") {
+ t.Fatal(s.Quotes)
+ }
+ n := len(s.Candles["BTC"])
+ if mode == "bad-candle" {
+ if n != 0 {
+ t.Fatal("invalid candle")
+ }
+ } else if n != 1 {
+ t.Fatal("unfinished candle included", n)
+ }
+ })
+ }
+}
+func TestClientErrorsBackoffAndCancellation(t *testing.T) {
+ for _, mode := range []string{"http", "error", "maintenance"} {
+ c, calls := mockKraken(t, mode)
+ for i := 0; i < 2; i++ {
+ if _, e := c.Fetch(context.Background(), []string{"BTC"}); e == nil {
+ t.Fatal(mode)
+ }
+ }
+ if calls.Load() != 1 {
+ t.Fatal("no backoff")
+ }
+ }
+ c, _ := mockKraken(t, "ok")
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, e := c.Fetch(ctx, []string{"BTC"}); e == nil {
+ t.Fatal("ignored cancellation")
+ }
+ if _, e := c.Fetch(context.Background(), []string{"BTC/../../"}); e == nil {
+ t.Fatal("symbol injection")
+ }
+}
+func TestClientCoalescesAndDoesNotShareMutableCache(t *testing.T) {
+ c, calls := mockKraken(t, "ok")
+ var wg sync.WaitGroup
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ s, e := c.Fetch(context.Background(), []string{"BTC"})
+ if e != nil {
+ t.Error(e)
+ return
+ }
+ delete(s.Quotes, "BTC")
+ s.Candles["BTC"][0].Close = "1"
+ }()
+ }
+ wg.Wait()
+ // One explicit interaction fetches only the requested pair: status,
+ // metadata, ticker and daily history. The rest are cache hits.
+ if calls.Load() != 4 {
+ t.Fatalf("requests %d want 4", calls.Load())
+ }
+ s, e := c.Fetch(context.Background(), []string{"BTC"})
+ if e != nil || s.Quotes["BTC"].Bid != "100" || s.Candles["BTC"][0].Close != "101" {
+ t.Fatal(s, e)
+ }
+}
+func TestKrakenLive(t *testing.T) {
+ if os.Getenv("TEST_KRAKEN_LIVE") != "1" {
+ t.Skip("set TEST_KRAKEN_LIVE=1 for public-API smoke test")
+ }
+ c := NewClient()
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
+ defer cancel()
+ s, e := c.Fetch(ctx, []string{"BTC", "SHIB"})
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, symbol := range []string{"BTC", "SHIB"} {
+ q, ok := s.Quotes[symbol]
+ if !ok || q.Validate(time.Now()) != nil || len(s.Candles[symbol]) < 2 {
+ t.Fatalf("%s missing quotes/candles", symbol)
+ }
+ t.Log(symbol, q.Bid, q.Ask, strings.Join(Chart(s.Candles[symbol], 30, false, nil), "\n"))
+ }
+}
+
+func TestClientCancellationDoesNotPoisonSharedBackoff(t *testing.T) {
+ started := make(chan struct{})
+ release := make(chan struct{})
+ var once sync.Once
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Block the first request so the caller can cancel it mid-fetch.
+ once.Do(func() { close(started); <-release })
+ result := any(nil)
+ switch r.URL.Path {
+ case "/SystemStatus":
+ result = map[string]string{"status": "online"}
+ case "/AssetPairs":
+ m := map[string]any{}
+ for _, i := range Instruments {
+ m[i.Key] = map[string]string{"status": "online"}
+ }
+ result = m
+ case "/Ticker":
+ m := map[string]any{}
+ for _, i := range Instruments {
+ m[i.Key] = map[string]any{"a": []string{"100"}, "b": []string{"99"}}
+ }
+ result = m
+ case "/OHLC":
+ result = map[string]any{}
+ default:
+ t.Errorf("unexpected URL %s", r.URL)
+ }
+ json.NewEncoder(w).Encode(map[string]any{"error": []string{}, "result": result})
+ }))
+ defer srv.Close()
+ c := NewClient()
+ c.Base = srv.URL
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() { _, _ = c.Fetch(ctx, []string{"BTC"}); close(done) }()
+ <-started
+ cancel()
+ <-done
+ close(release)
+ // A later caller must not inherit the cancelled caller's backoff.
+ if _, e := c.Fetch(context.Background(), []string{"BTC"}); e != nil {
+ t.Fatalf("cancelled fetch poisoned the shared client: %v", e)
+ }
+}
diff --git a/internal/cryptomarket/exchange.go b/internal/cryptomarket/exchange.go
new file mode 100644
index 0000000..2e65d5c
--- /dev/null
+++ b/internal/cryptomarket/exchange.go
@@ -0,0 +1,426 @@
+package cryptomarket
+
+import (
+ "fmt"
+ "math"
+ "math/big"
+ "time"
+)
+
+// Account is the exchange ledger: Balance is available cash, negative for
+// recourse debt. Collateral and written option premiums remain locked in each
+// position, never spendable proceeds.
+type Account struct {
+ Balance int64 `yaml:"balance"`
+ NextID int64 `yaml:"next_id"`
+ Positions []Position `yaml:"positions,omitempty"`
+}
+type Position struct {
+ ID int64 `yaml:"id"`
+ Symbol string `yaml:"symbol"`
+ Kind string `yaml:"kind"` // spot, short, call, put, write-call, write-put
+ Quantity string `yaml:"quantity"`
+ Entry string `yaml:"entry"`
+ Strike string `yaml:"strike,omitempty"`
+ Expiry int64 `yaml:"expiry,omitempty"`
+ Opened int64 `yaml:"opened"`
+ Collateral int64 `yaml:"collateral"`
+ Premium int64 `yaml:"premium"`
+ Cost int64 `yaml:"cost"`
+ Status string `yaml:"status"`
+ Closed int64 `yaml:"closed,omitempty"`
+ Proceeds int64 `yaml:"proceeds,omitempty"`
+}
+
+func (a Account) Clone() Account { a.Positions = append([]Position(nil), a.Positions...); return a }
+func (p Position) Risky() bool {
+ return p.Kind == "short" || p.Kind == "write-call" || p.Kind == "write-put"
+}
+func (p Position) Option() bool {
+ return p.Kind == "call" || p.Kind == "put" || p.Kind == "write-call" || p.Kind == "write-put"
+}
+func (p Position) Validate() error {
+ if _, ok := Find(p.Symbol); !ok {
+ return fmt.Errorf("unknown saved currency")
+ }
+ if _, e := Decimal(p.Quantity); e != nil {
+ return e
+ }
+ if _, e := Decimal(p.Entry); e != nil {
+ return e
+ }
+ if p.ID <= 0 || p.Opened <= 0 || p.Collateral < 0 || p.Collateral > MaxMoney || p.Premium < 0 || p.Premium > MaxMoney || p.Cost < 0 || p.Cost > MaxMoney {
+ return fmt.Errorf("invalid saved position")
+ }
+ if p.Option() {
+ if _, e := Decimal(p.Strike); e != nil {
+ return e
+ }
+ if p.Expiry <= p.Opened || p.Expiry%86400 != 0 {
+ return fmt.Errorf("invalid expiry")
+ }
+ }
+ if p.Kind != "spot" && p.Kind != "short" && !p.Option() {
+ return fmt.Errorf("invalid position kind")
+ }
+ if p.Proceeds < -MaxMoney || p.Proceeds > MaxMoney {
+ return fmt.Errorf("invalid saved cash amount")
+ }
+ if p.Risky() && p.Collateral != p.Cost {
+ return fmt.Errorf("invalid saved collateral")
+ }
+ if !p.Risky() && p.Collateral != 0 {
+ return fmt.Errorf("unexpected collateral")
+ }
+ if p.Option() {
+ if !p.Risky() && p.Premium != p.Cost {
+ return fmt.Errorf("invalid saved premium")
+ }
+ } else if p.Premium != 0 || p.Expiry != 0 || p.Strike != "" {
+ return fmt.Errorf("unexpected option fields")
+ }
+ switch p.Status {
+ case "open":
+ if p.Closed != 0 || p.Proceeds != 0 {
+ return fmt.Errorf("invalid open receipt")
+ }
+ case "closed", "liquidated", "expired":
+ if p.Closed < p.Opened {
+ return fmt.Errorf("invalid closed receipt")
+ }
+ default:
+ return fmt.Errorf("invalid position status")
+ }
+ return nil
+}
+func (a Account) Validate() error {
+ if a.Balance < -MaxMoney || a.Balance > MaxMoney || a.NextID < 0 || a.NextID > MaxMoney {
+ return fmt.Errorf("invalid exchange ledger")
+ }
+ seen := map[int64]bool{}
+ open := 0
+ for _, p := range a.Positions {
+ if e := p.Validate(); e != nil {
+ return e
+ }
+ if seen[p.ID] || p.ID > a.NextID {
+ return fmt.Errorf("invalid position identity")
+ }
+ seen[p.ID] = true
+ if p.Status == "open" {
+ open++
+ }
+ }
+ if open > MaxPositions {
+ return fmt.Errorf("too many positions")
+ }
+ return nil
+}
+
+// OptionPrice values a house option: European, cash-settled, zero interest,
+// fixed 100% annualized volatility. These are model quotes, NOT Kraken's
+// listed option prices.
+func OptionPrice(kind string, spot, strike *big.Rat, remaining time.Duration) (*big.Rat, error) {
+ s, _ := spot.Float64()
+ k, _ := strike.Float64()
+ t := remaining.Hours() / (365 * 24)
+ call := kind == "call" || kind == "write-call"
+ intrinsic := math.Max(0, s-k)
+ if !call {
+ intrinsic = math.Max(0, k-s)
+ }
+ if t <= 0 {
+ return floatRat(intrinsic)
+ }
+ root := math.Sqrt(t)
+ d1 := (math.Log(s/k) + 0.5*t) / root
+ d2 := d1 - root
+ normal := func(x float64) float64 { return 0.5 * (1 + math.Erf(x/math.Sqrt2)) }
+ v := s*normal(d1) - k*normal(d2)
+ if !call {
+ v = k*normal(-d2) - s*normal(-d1)
+ }
+ return floatRat(math.Max(intrinsic, v))
+}
+func intrinsic(p Position, price string) *big.Rat {
+ v := sub(rat(price), rat(p.Strike))
+ if p.Kind == "put" || p.Kind == "write-put" {
+ v.Neg(v)
+ }
+ if v.Sign() < 0 {
+ return integer(0)
+ }
+ return mul(v, rat(p.Quantity))
+}
+
+// Open mutates only on success. Full-lot closes prevent repeated fractional
+// cash rounding from manufacturing credits. Quantity is underlying coins, not
+// exchange contract lots (there is no hidden 100x multiplier).
+func (a *Account) Open(symbol, kind, quantity, strike string, days int, q Quote, now time.Time) (Position, error) {
+ fail := func(e string) (Position, error) { return Position{}, fmt.Errorf("%s", e) }
+ if e := a.Validate(); e != nil {
+ return Position{}, e
+ }
+ if a.Balance < 0 {
+ return fail("pay the exchange debt before opening positions")
+ }
+ if e := q.Validate(now); e != nil {
+ return Position{}, e
+ }
+ if _, ok := Find(symbol); !ok {
+ return fail("unsupported currency")
+ }
+ quant, e := Decimal(quantity)
+ if e != nil {
+ return Position{}, e
+ }
+ n := 0
+ for _, p := range a.Positions {
+ if p.Status == "open" {
+ n++
+ }
+ }
+ if n >= MaxPositions || a.NextID >= MaxMoney {
+ return fail("portfolio is full")
+ }
+ p := Position{ID: a.NextID + 1, Symbol: symbol, Kind: kind, Quantity: quantity, Opened: now.Unix(), Status: "open", Entry: q.mid().FloatString(18)}
+ notional := mul(q.mid(), quant)
+ var cash *big.Rat
+ switch kind {
+ case "spot":
+ p.Entry = q.Ask
+ cash = mul(rat(q.Ask), quant)
+ case "short":
+ p.Entry = q.Bid
+ p.Collateral, e = Round(notional)
+ cash = integer(p.Collateral)
+ case "call", "put", "write-call", "write-put":
+ k, err := Decimal(strike)
+ if err != nil {
+ return Position{}, err
+ }
+ if days != 1 && days != 7 && days != 30 {
+ return fail("expiry must be 1, 7 or 30 UTC days")
+ }
+ if k.Cmp(mul(q.mid(), rat("0.5"))) < 0 || k.Cmp(mul(q.mid(), integer(2))) > 0 {
+ return fail("strike must be between 50% and 200% of spot")
+ }
+ p.Strike = strike
+ expiry := now.UTC().Add(time.Duration(days) * 24 * time.Hour)
+ midnight := expiry.Truncate(24 * time.Hour)
+ if expiry.After(midnight) {
+ midnight = midnight.Add(24 * time.Hour)
+ }
+ p.Expiry = midnight.Unix()
+ price, err := OptionPrice(kind, q.mid(), k, time.Unix(p.Expiry, 0).Sub(now))
+ if err != nil {
+ return Position{}, err
+ }
+ premium := mul(price, quant)
+ p.Premium, e = Round(premium)
+ if e != nil {
+ return Position{}, e
+ }
+ if p.Risky() {
+ margin := notional
+ if v := mul(k, quant); v.Cmp(margin) > 0 {
+ margin = v
+ }
+ p.Collateral, e = Round(margin)
+ cash = integer(p.Collateral)
+ } else {
+ cash = integer(p.Premium)
+ }
+ default:
+ return fail("choose spot, short, call, put, write-call or write-put")
+ }
+ if e != nil {
+ return Position{}, e
+ }
+ cost, e := Round(cash)
+ if e != nil {
+ return Position{}, e
+ }
+ if a.Balance < cost {
+ return fail("insufficient exchange funds; use fund CREDITS")
+ }
+ p.Cost = cost
+ if e = p.Validate(); e != nil {
+ return Position{}, e
+ }
+ a.Balance -= cost
+ a.NextID = p.ID
+ a.Positions = append(a.Positions, p)
+ a.prune()
+ return p, nil
+}
+func (a *Account) prune() {
+ // Retain all open lots and the latest 100 closed receipts. IDs never reuse.
+ closed := 0
+ for _, p := range a.Positions {
+ if p.Status != "open" {
+ closed++
+ }
+ }
+ if closed <= 100 {
+ return
+ }
+ out := make([]Position, 0, len(a.Positions))
+ for _, p := range a.Positions {
+ if p.Status != "open" && closed > 100 {
+ closed--
+ continue
+ }
+ out = append(out, p)
+ }
+ a.Positions = out
+}
+
+// Value is the total cash released at close (including locked collateral).
+func Value(p Position, q Quote, now time.Time, expiryPrice string) (int64, error) {
+ if e := p.Validate(); e != nil {
+ return 0, e
+ }
+ if now.Unix() < p.Opened {
+ return 0, fmt.Errorf("clock precedes position opening")
+ }
+ var v *big.Rat
+ if p.Option() && now.Unix() >= p.Expiry {
+ if _, e := Decimal(expiryPrice); e != nil {
+ return 0, fmt.Errorf("awaiting exact expiry candle")
+ }
+ v = intrinsic(p, expiryPrice)
+ } else {
+ if e := q.Validate(now); e != nil {
+ return 0, e
+ }
+ switch p.Kind {
+ case "spot":
+ v = mul(rat(q.Bid), rat(p.Quantity))
+ case "short":
+ v = mul(sub(rat(p.Entry), rat(q.Ask)), rat(p.Quantity))
+ default:
+ model, e := OptionPrice(p.Kind, q.mid(), rat(p.Strike), time.Unix(p.Expiry, 0).Sub(now))
+ if e != nil {
+ return 0, e
+ }
+ v = mul(model, rat(p.Quantity))
+ }
+ }
+ if p.Kind == "short" {
+ v.Add(v, integer(p.Collateral))
+ } else if p.Risky() {
+ // The whole option liability rounds exactly like a bought option's
+ // payout. Rounding the net refund instead would mint a credit on
+ // half-credit write/close round trips without a spread.
+ liability, err := Round(v)
+ if err != nil {
+ return 0, err
+ }
+ v.Sub(integer(p.Collateral+p.Premium), integer(liability))
+ }
+ return Round(v)
+}
+func (a *Account) Close(id int64, s Snapshot, now time.Time, reason string) (Position, error) {
+ if reason != "closed" && reason != "expired" && reason != "liquidated" {
+ return Position{}, fmt.Errorf("invalid close reason")
+ }
+ if e := a.Validate(); e != nil {
+ return Position{}, e
+ }
+ for i, p := range a.Positions {
+ if p.ID != id {
+ continue
+ }
+ if p.Status != "open" {
+ return Position{}, fmt.Errorf("position is already closed")
+ }
+ expiry, _ := s.Expiry(p.Symbol, p.Expiry)
+ v, e := Value(p, s.Quotes[p.Symbol], now, expiry)
+ if e != nil {
+ return Position{}, e
+ }
+ balance, e := Sum(a.Balance, v)
+ if e != nil {
+ return Position{}, e
+ }
+ p.Status = reason
+ p.Closed = now.Unix()
+ p.Proceeds = v
+ a.Positions[i] = p
+ a.Balance = balance
+ a.prune()
+ return p, nil
+ }
+ return Position{}, fmt.Errorf("unknown position number")
+}
+
+// Reconcile expires contracts and liquidates undercollateralized positions at
+// observed quotes when the player uses the exchange. No retroactive imaginary fills
+// or guaranteed stop price: a market gap can create recourse debt.
+func (a *Account) Reconcile(s Snapshot, now time.Time) ([]string, error) {
+ if e := a.Validate(); e != nil {
+ return nil, e
+ }
+ work := a.Clone()
+ messages := []string{}
+ ids := append([]Position(nil), work.Positions...)
+ for _, p := range ids {
+ if p.Status != "open" {
+ continue
+ }
+ expiry, _ := s.Expiry(p.Symbol, p.Expiry)
+ value, e := Value(p, s.Quotes[p.Symbol], now, expiry)
+ if e != nil {
+ continue
+ }
+ reason := ""
+ if p.Option() && now.Unix() >= p.Expiry {
+ reason = "expired"
+ } else if p.Risky() && value <= p.Collateral/4 {
+ reason = "liquidated"
+ }
+ if reason == "" {
+ continue
+ }
+ // Keep an overflowing positive payout pending until cash is withdrawn.
+ // Never discard the claim or permanently prevent making room for it.
+ if value > 0 && work.Balance > MaxMoney-value {
+ continue
+ }
+ closed, e := work.Close(p.ID, s, now, reason)
+ if e != nil {
+ return nil, e
+ }
+ messages = append(messages, fmt.Sprintf("Crypto #%d %s: %+d credits released.", p.ID, reason, closed.Proceeds))
+ }
+ if work.Balance < 0 { // A bankrupt account may reduce, but never enlarge, risk.
+ for _, p := range append([]Position(nil), work.Positions...) {
+ // Stop as soon as the available cash covers the debt; the remaining
+ // positions are solvent and keep their normal liquidation rules.
+ if work.Balance >= 0 {
+ break
+ }
+ if p.Status == "open" && p.Risky() {
+ v, e := work.Close(p.ID, s, now, "liquidated")
+ if e == nil {
+ messages = append(messages, fmt.Sprintf("Crypto #%d liquidated for account debt: %+d credits.", p.ID, v.Proceeds))
+ }
+ }
+ }
+ }
+ *a = work
+ return messages, nil
+}
+
+// SweepBank services debt without touching carried credits. Call in the same
+// atomic save as the event that creates debt or deposits money into the bank.
+func (a *Account) SweepBank(bank *int) int64 {
+ if a.Balance >= 0 || *bank <= 0 {
+ return 0
+ }
+ paid := min(-a.Balance, int64(*bank))
+ *bank -= int(paid)
+ a.Balance += paid
+ return paid
+}
diff --git a/internal/cryptomarket/exchange_test.go b/internal/cryptomarket/exchange_test.go
new file mode 100644
index 0000000..89c6a82
--- /dev/null
+++ b/internal/cryptomarket/exchange_test.go
@@ -0,0 +1,339 @@
+package cryptomarket
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+)
+
+var testNow = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
+
+func quote(price string) Quote { return Quote{Bid: price, Ask: price, At: testNow} }
+func snapshot(symbol, price string, now time.Time) Snapshot {
+ q := quote(price)
+ q.At = now
+ return Snapshot{Quotes: map[string]Quote{symbol: q}, Candles: map[string][]Candle{}}
+}
+func mustOpen(t *testing.T, a *Account, symbol, kind, qty, strike string, days int, price string) Position {
+ t.Helper()
+ p, e := a.Open(symbol, kind, qty, strike, days, quote(price), testNow)
+ if e != nil {
+ t.Fatal(e)
+ }
+ return p
+}
+func TestDecimalAndNearestRounding(t *testing.T) {
+ for _, s := range []string{"NaN", "Inf", "-1", "0", "0.0", "1e6", "1/2", " 1", "+1", "1.", ".1", "1;quit", "1000000000000000", "0.0000000000000000001"} {
+ if _, e := Decimal(s); e == nil {
+ t.Errorf("accepted %q", s)
+ }
+ }
+ for s, want := range map[string]int64{"0.49": 0, "0.5": 1, "42.5": 43, "-42.5": -43, "-0.49": 0, "999999999999.49": 999999999999} {
+ got, e := Round(rat(s))
+ if e != nil || got != want {
+ t.Fatalf("round %s=%d/%v want %d", s, got, e, want)
+ }
+ }
+ for _, s := range []string{"1000000000000.5", "-1000000000000.5"} {
+ if _, e := Round(rat(s)); e == nil {
+ t.Fatal("accepted overflow")
+ }
+ }
+}
+func TestFractionalBTCAndHugeSHIB(t *testing.T) {
+ for _, tc := range []struct {
+ sym, qty, price string
+ cost, sale int64
+ }{{"BTC", "0.001", "85000", 85, 85}, {"SHIB", "10000000", "0.000006", 60, 60}} {
+ a := Account{Balance: 1000}
+ p := mustOpen(t, &a, tc.sym, "spot", tc.qty, "", 0, tc.price)
+ if p.Cost != tc.cost {
+ t.Fatalf("cost %d", p.Cost)
+ }
+ closed, e := a.Close(p.ID, snapshot(tc.sym, tc.price, testNow), testNow, "closed")
+ if e != nil || closed.Proceeds != tc.sale {
+ t.Fatalf("close %+v %v", closed, e)
+ }
+ if a.Balance != 1000 {
+ t.Fatal(a.Balance)
+ }
+ before := a.Clone()
+ if _, e = a.Close(p.ID, snapshot(tc.sym, tc.price, testNow), testNow, "closed"); e == nil || !reflect.DeepEqual(before, a) {
+ t.Fatal("double payment")
+ }
+ }
+}
+func TestShortGapDebtAndBankSweep(t *testing.T) {
+ a := Account{Balance: 100}
+ p := mustOpen(t, &a, "BTC", "short", "1", "", 0, "100")
+ if a.Balance != 0 || p.Collateral != 100 {
+ t.Fatal(a)
+ }
+ messages, e := a.Reconcile(snapshot("BTC", "300", testNow), testNow)
+ if e != nil || len(messages) != 1 {
+ t.Fatalf("%v %v", messages, e)
+ }
+ if a.Balance != -100 || a.Positions[0].Status != "liquidated" {
+ t.Fatal(a)
+ }
+ bank := 50
+ if paid := a.SweepBank(&bank); paid != 50 || bank != 0 || a.Balance != -50 {
+ t.Fatal(a, bank)
+ }
+ bank = 100
+ a.SweepBank(&bank)
+ if bank != 50 || a.Balance != 0 {
+ t.Fatal(a, bank)
+ }
+ if m, e := a.Reconcile(snapshot("BTC", "300", testNow), testNow); e != nil || len(m) != 0 {
+ t.Fatal("duplicate liquidation", m, e)
+ }
+}
+func TestOptionExpiryExactCandleAndLimitedLongRisk(t *testing.T) {
+ for _, kind := range []string{"call", "put", "write-call", "write-put"} {
+ t.Run(kind, func(t *testing.T) {
+ a := Account{Balance: 20000}
+ p := mustOpen(t, &a, "BTC", kind, "1", "10000", 1, "10000")
+ if p.Expiry != time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC).Unix() {
+ t.Fatal("expiry", p)
+ }
+ now := time.Unix(p.Expiry, 0)
+ s := snapshot("BTC", "99999", now)
+ before := a.Clone()
+ messages, e := a.Reconcile(s, now)
+ if e != nil || len(messages) != 0 || !reflect.DeepEqual(a, before) {
+ t.Fatal("settled at wrong price")
+ }
+ s.Candles["BTC"] = []Candle{{Time: p.Expiry - 86400, Open: "10000", High: "12000", Low: "10000", Close: "12000"}}
+ m, e := a.Reconcile(s, now)
+ if e != nil || len(m) != 1 {
+ t.Fatal(m, e)
+ }
+ want := int64(0)
+ if kind == "call" {
+ want = 2000
+ }
+ if kind == "write-call" {
+ want = p.Collateral + p.Premium - 2000
+ }
+ if kind == "write-put" {
+ want = p.Collateral + p.Premium
+ }
+ if a.Positions[0].Proceeds != want {
+ t.Fatalf("got %d want %d", a.Positions[0].Proceeds, want)
+ }
+ })
+ }
+}
+func TestWrittenCallCanCreateDebt(t *testing.T) {
+ a := Account{Balance: 10000}
+ p := mustOpen(t, &a, "BTC", "write-call", "1", "10000", 7, "10000")
+ if a.Balance != 0 {
+ t.Fatal("premium was withdrawable")
+ }
+ now := time.Unix(p.Expiry, 0)
+ s := snapshot("BTC", "1", now)
+ s.Candles["BTC"] = []Candle{{Time: p.Expiry - 86400, Open: "40000", High: "40000", Low: "40000", Close: "40000"}}
+ _, e := a.Reconcile(s, now)
+ if e != nil || a.Balance >= 0 {
+ t.Fatal(a, e)
+ }
+ want := p.Collateral + p.Premium - 30000
+ if a.Balance != want {
+ t.Fatal(a.Balance, want)
+ }
+}
+func TestOpenValidationDoesNotMutate(t *testing.T) {
+ cases := []struct {
+ kind, qty, strike string
+ days int
+ q Quote
+ }{
+ {"spot", "10001", "", 0, quote("100")}, // insufficient funds, not an exposure cap
+ {"spot", "NaN", "", 0, quote("100")}, {"call", "1", "100", 2, quote("100")},
+ {"call", "1", "1000", 7, quote("100")},
+ {"fake", "1", "", 0, quote("100")}, {"spot", "1", "", 0, Quote{Bid: "101", Ask: "100", At: testNow}},
+ {"spot", "1", "", 0, Quote{Bid: "100", Ask: "100", At: testNow.Add(-61 * time.Second)}},
+ }
+ for _, tc := range cases {
+ a := Account{Balance: 1000000}
+ before := a.Clone()
+ _, e := a.Open("BTC", tc.kind, tc.qty, tc.strike, tc.days, tc.q, testNow)
+ if e == nil || !reflect.DeepEqual(a, before) {
+ t.Fatal(tc, e, a)
+ }
+ }
+ a := Account{Balance: -1}
+ if _, e := a.Open("BTC", "spot", "1", "", 0, quote("100"), testNow); e == nil {
+ t.Fatal("opened in debt")
+ }
+}
+func TestNoStationaryRoundTripProfit(t *testing.T) {
+ for _, kind := range []string{"spot", "short", "call", "put", "write-call", "write-put"} {
+ for n := 10; n < 100; n++ {
+ a := Account{Balance: 100000}
+ qty := fmt.Sprintf("%d.123456", n)
+ p, e := a.Open("BTC", kind, qty, "100", 7, quote("100"), testNow)
+ if e != nil {
+ t.Fatal(e)
+ }
+ _, e = a.Close(p.ID, snapshot("BTC", "100", testNow), testNow, "closed")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if a.Balance != 100000 {
+ t.Fatalf("fee or rounding arbitrage %s %s -> %d", kind, qty, a.Balance)
+ }
+ }
+ }
+}
+func TestNoMinimumOrExposureLimit(t *testing.T) {
+ for _, kind := range []string{"spot", "short", "call", "put", "write-call", "write-put"} {
+ a := Account{Balance: 10}
+ p := mustOpen(t, &a, "BTC", kind, "0.00001", "100", 1, "100")
+ if p.Cost != 0 {
+ t.Fatalf("tiny %s costs %d", kind, p.Cost)
+ }
+ if _, e := a.Close(p.ID, snapshot("BTC", "100", testNow), testNow, "closed"); e != nil || a.Balance != 10 {
+ t.Fatal(a, e)
+ }
+ }
+ a := Account{Balance: 10000000}
+ for i := 0; i < 2; i++ {
+ mustOpen(t, &a, "BTC", "spot", "30000", "", 0, "100")
+ }
+ if a.Balance != 4000000 {
+ t.Fatal(a)
+ }
+}
+func TestWrittenOptionHalfCreditRoundTrip(t *testing.T) {
+ for _, tc := range []struct{ kind, strike, qty string }{{"write-call", "50", "0.01"}, {"write-put", "200", "0.005"}} {
+ a := Account{Balance: 100}
+ p := mustOpen(t, &a, "BTC", tc.kind, tc.qty, tc.strike, 1, "100")
+ if p.Premium != 1 {
+ t.Fatal("expected half-credit premium rounded to one", p)
+ }
+ if _, e := a.Close(p.ID, snapshot("BTC", "100", testNow), testNow, "closed"); e != nil || a.Balance != 100 {
+ t.Fatal("half-credit arbitrage", a, e)
+ }
+ }
+}
+func TestSpotUsesVenueBidAskWithoutMarkup(t *testing.T) {
+ a := Account{Balance: 10000}
+ q := Quote{Bid: "99", Ask: "101", At: testNow}
+ p, e := a.Open("BTC", "spot", "10", "", 0, q, testNow)
+ if e != nil || p.Cost != 1010 {
+ t.Fatal(p, e)
+ }
+ s := Snapshot{Quotes: map[string]Quote{"BTC": q}}
+ p, e = a.Close(p.ID, s, testNow, "closed")
+ if e != nil || p.Proceeds != 990 {
+ t.Fatal(p, e)
+ }
+}
+func TestOutageAndOverflowDoNotLosePositions(t *testing.T) {
+ a := Account{Balance: 100}
+ p := mustOpen(t, &a, "BTC", "spot", "1", "", 0, "99")
+ before := a.Clone()
+ if _, e := a.Close(p.ID, Snapshot{}, testNow, "closed"); e == nil || !reflect.DeepEqual(a, before) {
+ t.Fatal("closed without data")
+ }
+ a.Balance = MaxMoney
+ before = a.Clone()
+ if _, e := a.Close(p.ID, snapshot("BTC", "100", testNow), testNow, "closed"); e == nil || !reflect.DeepEqual(a, before) {
+ t.Fatal("overflow mutated ledger")
+ }
+}
+func TestPositionLimitHistoryAndCorruptSave(t *testing.T) {
+ a := Account{Balance: 10000}
+ for i := 0; i < MaxPositions; i++ {
+ mustOpen(t, &a, "BTC", "spot", "1", "", 0, "10")
+ }
+ if _, e := a.Open("BTC", "spot", "1", "", 0, quote("10"), testNow); e == nil {
+ t.Fatal("position cap")
+ }
+ a.Positions[0].Quantity = "NaN"
+ if _, e := a.Reconcile(snapshot("BTC", "10", testNow), testNow); e == nil {
+ t.Fatal("corrupt position")
+ }
+ a = Account{Balance: 10000}
+ for i := 0; i < 110; i++ {
+ p := mustOpen(t, &a, "BTC", "spot", "1", "", 0, "10")
+ if _, e := a.Close(p.ID, snapshot("BTC", "10", testNow), testNow, "closed"); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if len(a.Positions) != 100 || a.NextID != 110 {
+ t.Fatal(a.NextID, len(a.Positions))
+ }
+}
+func TestExpiryAtMidnightAndBalanceCapPending(t *testing.T) {
+ now := testNow.Truncate(24 * time.Hour)
+ a := Account{Balance: 20000}
+ q := quote("10000")
+ q.At = now
+ p, e := a.Open("BTC", "call", "1", "10000", 1, q, now)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if p.Expiry != now.Add(24*time.Hour).Unix() {
+ t.Fatal("midnight gained extra day", p.Expiry)
+ }
+ a.Balance = MaxMoney
+ expired := time.Unix(p.Expiry, 0)
+ s := snapshot("BTC", "30000", expired)
+ s.Candles["BTC"] = []Candle{{Time: p.Expiry - 86400, Open: "12000", High: "12000", Low: "12000", Close: "12000"}}
+ if _, e = a.Reconcile(s, expired); e != nil || a.Positions[0].Status != "open" {
+ t.Fatal("overflow lost claim", a, e)
+ }
+ a.Balance -= 2000
+ if _, e = a.Reconcile(s, expired); e != nil || a.Balance != MaxMoney || a.Positions[0].Status != "expired" {
+ t.Fatal("payout did not resume", a, e)
+ }
+}
+func TestBankruptAccountStopsLiquidatingOnceSolvent(t *testing.T) {
+ a := Account{Balance: 300}
+ // Two healthy shorts; each returns its collateral when closed at entry.
+ mustOpen(t, &a, "BTC", "short", "1", "", 0, "100")
+ mustOpen(t, &a, "BTC", "short", "1", "", 0, "100")
+ // A small debt that one close more than covers.
+ a.Balance = -10
+ messages, e := a.Reconcile(snapshot("BTC", "100", testNow), testNow)
+ if e != nil {
+ t.Fatal(e)
+ }
+ open := 0
+ for _, p := range a.Positions {
+ if p.Status == "open" {
+ open++
+ }
+ }
+ if a.Balance < 0 || open != 1 {
+ t.Fatalf("over-liquidated: balance %d, open %d (%v)", a.Balance, open, messages)
+ }
+}
+func TestCandlestickModesFlatAndTiny(t *testing.T) {
+ for _, price := range []string{"85000", "0.000006"} {
+ candles := []Candle{{Time: 86400, Open: price, High: price, Low: price, Close: price}, {Time: 172800, Open: price, High: price, Low: price, Close: price}}
+ for _, unicode := range []bool{false, true} {
+ calls := 0
+ lines := Chart(candles, 40, unicode, func(s string, up bool) string { calls++; return s })
+ joined := strings.Join(lines, "\n")
+ if calls == 0 || strings.Contains(joined, "NaN") {
+ t.Fatal(lines)
+ }
+ if unicode && !strings.Contains(joined, "━") {
+ t.Fatal("no unicode body")
+ }
+ if !unicode {
+ for _, r := range joined {
+ if r > 127 {
+ t.Fatal("non-ascii")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/internal/cryptomarket/money.go b/internal/cryptomarket/money.go
new file mode 100644
index 0000000..8154623
--- /dev/null
+++ b/internal/cryptomarket/money.go
@@ -0,0 +1,72 @@
+// Package cryptomarket implements a fictional-credit exchange, not real orders.
+package cryptomarket
+
+import (
+ "fmt"
+ "math"
+ "math/big"
+ "regexp"
+ "strconv"
+)
+
+const MaxMoney int64 = 1_000_000_000_000
+const MaxPositions = 50
+
+// Decimal strings survive YAML/JSON without a float conversion. In particular,
+// neither SHIB prices nor fractional BTC quantities are rounded to credits.
+var decimalPattern = regexp.MustCompile(`^(0|[1-9][0-9]{0,14})(\.[0-9]{1,18})?$`)
+
+func Decimal(s string) (*big.Rat, error) {
+ if !decimalPattern.MatchString(s) {
+ return nil, fmt.Errorf("use a positive decimal (no exponent), at most 15 integer and 18 fractional digits")
+ }
+ r, ok := new(big.Rat).SetString(s)
+ if !ok || r.Sign() <= 0 {
+ return nil, fmt.Errorf("amount must be positive")
+ }
+ return r, nil
+}
+
+func rat(s string) *big.Rat { r, _ := new(big.Rat).SetString(s); return r }
+func mul(a, b *big.Rat) *big.Rat { return new(big.Rat).Mul(a, b) }
+func add(a, b *big.Rat) *big.Rat { return new(big.Rat).Add(a, b) }
+func sub(a, b *big.Rat) *big.Rat { return new(big.Rat).Sub(a, b) }
+func integer(n int64) *big.Rat { return new(big.Rat).SetInt64(n) }
+
+// Round rounds the complete cash amount once, nearest credit, ties away from
+// zero. Never round per coin, and never silently clamp an overflow.
+func Round(r *big.Rat) (int64, error) {
+ if r == nil {
+ return 0, fmt.Errorf("invalid amount")
+ }
+ a := new(big.Rat).Abs(r)
+ n, rem := new(big.Int), new(big.Int)
+ n.QuoRem(a.Num(), a.Denom(), rem)
+ if rem.Lsh(rem, 1).Cmp(a.Denom()) >= 0 {
+ n.Add(n, big.NewInt(1))
+ }
+ if !n.IsInt64() || n.Int64() > MaxMoney {
+ return 0, fmt.Errorf("amount exceeds exchange accounting limit")
+ }
+ v := n.Int64()
+ if r.Sign() < 0 {
+ v = -v
+ }
+ return v, nil
+}
+func Sum(a, b int64) (int64, error) {
+ if a < -MaxMoney || a > MaxMoney || b < -MaxMoney || b > MaxMoney || a+b < -MaxMoney || a+b > MaxMoney {
+ return 0, fmt.Errorf("exchange balance limit exceeded")
+ }
+ return a + b, nil
+}
+func floatRat(f float64) (*big.Rat, error) {
+ if math.IsNaN(f) || math.IsInf(f, 0) || f < 0 {
+ return nil, fmt.Errorf("invalid model value")
+ }
+ r, ok := new(big.Rat).SetString(strconv.FormatFloat(f, 'f', 18, 64))
+ if !ok {
+ return nil, fmt.Errorf("invalid model value")
+ }
+ return r, nil
+}