1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
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
}
|