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 }