aboutsummaryrefslogtreecommitdiff
path: root/internal/cryptomarket/exchange_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-05 14:22:14 -0400
committerhistoria <[not public]>2026-10-05 14:22:14 -0400
commit90ad8c0c33b519d5830c69d4128bdc984b5e9de8 (patch)
treec10bc0bebfbed6fde01a7f08cf6e2d98809b91a7 /internal/cryptomarket/exchange_test.go
parentc84a9944356f590d90d819d652bdf1349ebacd40 (diff)
downloadthehouseoficarus-90ad8c0c33b519d5830c69d4128bdc984b5e9de8.tar.gz
feat: cryptocurrency exchange added to casino
Diffstat (limited to 'internal/cryptomarket/exchange_test.go')
-rw-r--r--internal/cryptomarket/exchange_test.go339
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")
+ }
+ }
+ }
+ }
+ }
+}