aboutsummaryrefslogtreecommitdiff
path: root/internal/cryptomarket/exchange.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cryptomarket/exchange.go')
-rw-r--r--internal/cryptomarket/exchange.go426
1 files changed, 426 insertions, 0 deletions
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
+}