diff options
| author | historia <[not public]> | 2026-10-05 14:22:14 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-05 14:22:14 -0400 |
| commit | 90ad8c0c33b519d5830c69d4128bdc984b5e9de8 (patch) | |
| tree | c10bc0bebfbed6fde01a7f08cf6e2d98809b91a7 /internal/cryptomarket/exchange_test.go | |
| parent | c84a9944356f590d90d819d652bdf1349ebacd40 (diff) | |
| download | thehouseoficarus-90ad8c0c33b519d5830c69d4128bdc984b5e9de8.tar.gz | |
feat: cryptocurrency exchange added to casino
Diffstat (limited to 'internal/cryptomarket/exchange_test.go')
| -rw-r--r-- | internal/cryptomarket/exchange_test.go | 339 |
1 files changed, 339 insertions, 0 deletions
diff --git a/internal/cryptomarket/exchange_test.go b/internal/cryptomarket/exchange_test.go new file mode 100644 index 0000000..89c6a82 --- /dev/null +++ b/internal/cryptomarket/exchange_test.go @@ -0,0 +1,339 @@ +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") + } + } + } + } + } +} |
