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") } } } } } }