From 90ad8c0c33b519d5830c69d4128bdc984b5e9de8 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 5 Oct 2026 14:22:14 -0400 Subject: feat: cryptocurrency exchange added to casino --- internal/cryptomarket/exchange.go | 426 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 internal/cryptomarket/exchange.go (limited to 'internal/cryptomarket/exchange.go') 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 +} -- cgit v1.2.3