diff --git a/examples/example_test.go b/examples/example_test.go index 770ac31..860d382 100644 --- a/examples/example_test.go +++ b/examples/example_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/recomma/3commas-mock/server" + "github.com/recomma/3commas-mock/tcmock" ) // TestExampleBasicUsage demonstrates basic usage of the mock server @@ -15,38 +16,11 @@ func TestExampleBasicUsage(t *testing.T) { defer mockServer.Close() // Configure mock state - mockServer.AddBot(server.Bot{ - ID: 1, - Name: "Test Bot", - Enabled: true, - AccountID: 12345, - }) - - mockServer.AddDeal(1, server.Deal{ - ID: 101, - BotID: 1, - Pair: "USDT_BTC", - Status: "active", - ToCurrency: "BTC", - FromCurrency: "USDT", - CreatedAt: "2024-01-15T10:30:00.000Z", - UpdatedAt: "2024-01-15T10:30:00.000Z", - Events: []server.BotEvent{ - { - CreatedAt: "2024-01-15T10:30:00.000Z", - Action: "place", - Coin: "BTC", - Type: "buy", - Status: "active", - Price: "50000.0", - Size: "0.0002", - OrderType: "base", - OrderSize: 1, - OrderPosition: 1, - IsMarket: false, - }, - }, - }) + mockServer.AddBot(server.NewBot(1, "Test Bot", 12345, true)) + + deal := server.NewDeal(101, 1, "USDT_BTC", "active") + server.AddBotEvent(&deal, "Placing base order. Price: 50000.0 USDT Size: 0.0002 BTC") + mockServer.AddDeal(deal) // Test ListBots resp, err := http.Get(mockServer.URL() + "/ver1/bots?scope=enabled") @@ -55,7 +29,7 @@ func TestExampleBasicUsage(t *testing.T) { } defer resp.Body.Close() - var bots []server.Bot + var bots []tcmock.Bot if err := json.NewDecoder(resp.Body).Decode(&bots); err != nil { t.Fatalf("failed to decode bots: %v", err) } @@ -63,8 +37,8 @@ func TestExampleBasicUsage(t *testing.T) { if len(bots) != 1 { t.Fatalf("expected 1 bot, got %d", len(bots)) } - if bots[0].ID != 1 { - t.Fatalf("expected bot ID 1, got %d", bots[0].ID) + if bots[0].Id != 1 { + t.Fatalf("expected bot ID 1, got %d", bots[0].Id) } // Test GetDeal @@ -74,16 +48,16 @@ func TestExampleBasicUsage(t *testing.T) { } defer resp.Body.Close() - var deal server.Deal - if err := json.NewDecoder(resp.Body).Decode(&deal); err != nil { + var dealResp tcmock.Deal + if err := json.NewDecoder(resp.Body).Decode(&dealResp); err != nil { t.Fatalf("failed to decode deal: %v", err) } - if deal.ID != 101 { - t.Fatalf("expected deal ID 101, got %d", deal.ID) + if dealResp.Id != 101 { + t.Fatalf("expected deal ID 101, got %d", dealResp.Id) } - if len(deal.Events) != 1 { - t.Fatalf("expected 1 event, got %d", len(deal.Events)) + if len(dealResp.BotEvents) != 1 { + t.Fatalf("expected 1 event, got %d", len(dealResp.BotEvents)) } } @@ -92,36 +66,20 @@ func TestExampleEventAddition(t *testing.T) { mockServer := server.NewTestServer(t) defer mockServer.Close() - mockServer.AddBot(server.Bot{ID: 1, Name: "Test Bot", Enabled: true}) - mockServer.AddDeal(1, server.Deal{ - ID: 101, - BotID: 1, - Status: "active", - Events: []server.BotEvent{}, - }) + mockServer.AddBot(server.NewBot(1, "Test Bot", 12345, true)) + deal := server.NewDeal(101, 1, "USDT_BTC", "active") + mockServer.AddDeal(deal) // Simulate safety order being placed - mockServer.AddBotEvent(101, server.BotEvent{ - CreatedAt: "2024-01-15T10:32:00.000Z", - Action: "place", - Coin: "BTC", - Type: "buy", - Status: "active", - Price: "48750.0", - Size: "0.0004", - OrderType: "safety", - OrderSize: 2, - OrderPosition: 1, - IsMarket: false, - }) + mockServer.AddBotEventToDeal(101, "Placing safety order. Price: 48750.0 USDT Size: 0.0004 BTC") // Fetch deal and verify event was added - deal, ok := mockServer.GetDealByID(101) + dealResp, ok := mockServer.GetDealByID(101) if !ok { t.Fatal("deal not found") } - if len(deal.Events) != 1 { - t.Fatalf("expected 1 event, got %d", len(deal.Events)) + if len(dealResp.BotEvents) != 1 { + t.Fatalf("expected 1 event, got %d", len(dealResp.BotEvents)) } } @@ -161,3 +119,53 @@ func TestExampleErrorSimulation(t *testing.T) { t.Fatalf("expected status 200, got %d", resp.StatusCode) } } + +// TestExampleVCRLoading demonstrates loading real API responses from VCR cassettes +func TestExampleVCRLoading(t *testing.T) { + mockServer := server.NewTestServer(t) + defer mockServer.Close() + + // Load a VCR cassette with a real recorded deal from 3commas API + // Note: go-vcr appends .yaml automatically, so omit the extension + err := mockServer.LoadVCRCassette("../testdata/fixtures/deal_2376446537") + if err != nil { + t.Fatalf("failed to load VCR cassette: %v", err) + } + + // Now the deal is available via the API with ALL real data + resp, err := http.Get(mockServer.URL() + "/ver1/deals/2376446537/show") + if err != nil { + t.Fatalf("failed to get deal: %v", err) + } + defer resp.Body.Close() + + var deal tcmock.Deal + if err := json.NewDecoder(resp.Body).Decode(&deal); err != nil { + t.Fatalf("failed to decode deal: %v", err) + } + + // Verify we got real data from the VCR recording + if deal.Pair != "USDT_DOGE" { + t.Fatalf("expected pair USDT_DOGE, got %s", deal.Pair) + } + + // Most importantly: bot_events are preserved from the real API! + if len(deal.BotEvents) != 3 { + t.Fatalf("expected 3 bot events from VCR, got %d", len(deal.BotEvents)) + } + + // The messages are real ones from 3commas + if deal.BotEvents[0].Message == nil || + *deal.BotEvents[0].Message != "Placing averaging order (9 out of 9). Price: market Size: 25.0008 USDT (110.0 DOGE)" { + t.Fatal("bot_events not preserved correctly from VCR") + } + + // Bot was auto-created from the deal data + bot, ok := mockServer.GetBot(16511317) + if !ok { + t.Fatal("bot should have been auto-created from deal") + } + if bot.AccountName != "Demo Account 2080398" { + t.Fatalf("expected account name from VCR, got %s", bot.AccountName) + } +} diff --git a/go.mod b/go.mod index 40ab622..20483c5 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen require ( github.com/oapi-codegen/nullable v1.1.0 github.com/oapi-codegen/runtime v1.1.2 + gopkg.in/dnaeon/go-vcr.v4 v4.0.5 ) require ( @@ -15,6 +16,7 @@ require ( github.com/getkin/kin-openapi v0.133.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/uuid v1.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect diff --git a/go.sum b/go.sum index e5713fb..6cfbb9e 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -171,6 +173,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA= +gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= diff --git a/server/server.go b/server/server.go index 456ce9a..c17eb5f 100644 --- a/server/server.go +++ b/server/server.go @@ -17,8 +17,8 @@ type TestServer struct { mu sync.RWMutex // State - bots map[int]*Bot - deals map[int]*Deal + bots map[int]*tcmock.Bot + deals map[int]*tcmock.Deal // Error simulation rateLimitEnabled bool @@ -30,8 +30,8 @@ type TestServer struct { // NewTestServer creates a new mock 3Commas server for testing func NewTestServer(t *testing.T) *TestServer { ts := &TestServer{ - bots: make(map[int]*Bot), - deals: make(map[int]*Deal), + bots: make(map[int]*tcmock.Bot), + deals: make(map[int]*tcmock.Deal), botErrors: make(map[int]error), dealErrors: make(map[int]error), } @@ -58,8 +58,8 @@ func (ts *TestServer) Reset() { ts.mu.Lock() defer ts.mu.Unlock() - ts.bots = make(map[int]*Bot) - ts.deals = make(map[int]*Deal) + ts.bots = make(map[int]*tcmock.Bot) + ts.deals = make(map[int]*tcmock.Deal) ts.botErrors = make(map[int]error) ts.dealErrors = make(map[int]error) ts.rateLimitEnabled = false @@ -85,14 +85,14 @@ func (ts *TestServer) ListBots(w http.ResponseWriter, r *http.Request, params tc } // Filter bots based on scope parameter - var result []Bot + var result []tcmock.Bot for _, bot := range ts.bots { // Apply scope filter if provided if params.Scope != nil { - if *params.Scope == tcmock.Enabled && !bot.Enabled { + if *params.Scope == tcmock.Enabled && !bot.IsEnabled { continue } - if *params.Scope == tcmock.Disabled && bot.Enabled { + if *params.Scope == tcmock.Disabled && bot.IsEnabled { continue } } @@ -110,15 +110,15 @@ func (ts *TestServer) ListDeals(w http.ResponseWriter, r *http.Request, params t defer ts.mu.RUnlock() // Filter deals based on parameters - var result []Deal + var result []tcmock.Deal for _, deal := range ts.deals { // Apply bot_id filter if provided - if params.BotId != nil && deal.BotID != *params.BotId { + if params.BotId != nil && deal.BotId != *params.BotId { continue } // Apply scope filter if provided - if params.Scope != nil && string(*params.Scope) != deal.Status { + if params.Scope != nil && tcmock.DealStatus(*params.Scope) != deal.Status { continue } diff --git a/server/server_test.go b/server/server_test.go index 7ac4b6b..1f258c4 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" "testing" + + "github.com/recomma/3commas-mock/tcmock" ) func TestNewTestServer(t *testing.T) { @@ -29,7 +31,7 @@ func TestListBots_Empty(t *testing.T) { t.Fatalf("expected status 200, got %d", resp.StatusCode) } - var bots []Bot + var bots []tcmock.Bot if err := json.NewDecoder(resp.Body).Decode(&bots); err != nil { t.Fatalf("failed to decode response: %v", err) } @@ -44,18 +46,8 @@ func TestListBots_WithBots(t *testing.T) { defer ts.Close() // Add some bots - ts.AddBot(Bot{ - ID: 1, - Name: "Test Bot 1", - Enabled: true, - AccountID: 123, - }) - ts.AddBot(Bot{ - ID: 2, - Name: "Test Bot 2", - Enabled: false, - AccountID: 123, - }) + ts.AddBot(NewBot(1, "Test Bot 1", 123, true)) + ts.AddBot(NewBot(2, "Test Bot 2", 123, false)) resp, err := http.Get(ts.URL() + "/ver1/bots") if err != nil { @@ -63,7 +55,7 @@ func TestListBots_WithBots(t *testing.T) { } defer resp.Body.Close() - var bots []Bot + var bots []tcmock.Bot if err := json.NewDecoder(resp.Body).Decode(&bots); err != nil { t.Fatalf("failed to decode response: %v", err) } @@ -77,8 +69,8 @@ func TestListBots_FilterByScope(t *testing.T) { ts := NewTestServer(t) defer ts.Close() - ts.AddBot(Bot{ID: 1, Name: "Enabled Bot", Enabled: true, AccountID: 123}) - ts.AddBot(Bot{ID: 2, Name: "Disabled Bot", Enabled: false, AccountID: 123}) + ts.AddBot(NewBot(1, "Enabled Bot", 123, true)) + ts.AddBot(NewBot(2, "Disabled Bot", 123, false)) // Filter for enabled bots resp, err := http.Get(ts.URL() + "/ver1/bots?scope=enabled") @@ -87,7 +79,7 @@ func TestListBots_FilterByScope(t *testing.T) { } defer resp.Body.Close() - var bots []Bot + var bots []tcmock.Bot if err := json.NewDecoder(resp.Body).Decode(&bots); err != nil { t.Fatalf("failed to decode response: %v", err) } @@ -95,7 +87,7 @@ func TestListBots_FilterByScope(t *testing.T) { if len(bots) != 1 { t.Fatalf("expected 1 enabled bot, got %d", len(bots)) } - if !bots[0].Enabled { + if !bots[0].IsEnabled { t.Fatal("expected bot to be enabled") } } @@ -120,32 +112,12 @@ func TestGetDeal_Success(t *testing.T) { defer ts.Close() // Add bot and deal - ts.AddBot(Bot{ID: 1, Name: "Test Bot", Enabled: true, AccountID: 123}) - err := ts.AddDeal(1, Deal{ - ID: 101, - BotID: 1, - Pair: "USDT_BTC", - Status: "active", - ToCurrency: "BTC", - FromCurrency: "USDT", - CreatedAt: "2024-01-15T10:30:00.000Z", - UpdatedAt: "2024-01-15T10:30:00.000Z", - Events: []BotEvent{ - { - CreatedAt: "2024-01-15T10:30:00.000Z", - Action: "place", - Coin: "BTC", - Type: "buy", - Status: "active", - Price: "50000.0", - Size: "0.0002", - OrderType: "base", - OrderSize: 1, - OrderPosition: 1, - IsMarket: false, - }, - }, - }) + ts.AddBot(NewBot(1, "Test Bot", 123, true)) + + deal := NewDeal(101, 1, "USDT_BTC", "active") + AddBotEvent(&deal, "Placing base order. Price: 50000.0 USDT Size: 0.0002 BTC") + + err := ts.AddDeal(deal) if err != nil { t.Fatalf("failed to add deal: %v", err) } @@ -160,19 +132,22 @@ func TestGetDeal_Success(t *testing.T) { t.Fatalf("expected status 200, got %d", resp.StatusCode) } - var deal Deal - if err := json.NewDecoder(resp.Body).Decode(&deal); err != nil { + var dealResp tcmock.Deal + if err := json.NewDecoder(resp.Body).Decode(&dealResp); err != nil { t.Fatalf("failed to decode response: %v", err) } - if deal.ID != 101 { - t.Fatalf("expected deal ID 101, got %d", deal.ID) + if dealResp.Id != 101 { + t.Fatalf("expected deal ID 101, got %d", dealResp.Id) + } + if len(dealResp.BotEvents) != 1 { + t.Fatalf("expected 1 event, got %d", len(dealResp.BotEvents)) } - if len(deal.Events) != 1 { - t.Fatalf("expected 1 event, got %d", len(deal.Events)) + if dealResp.BotEvents[0].Message == nil { + t.Fatal("expected bot event to have a message") } - if deal.Events[0].Action != "place" { - t.Fatalf("expected action 'place', got '%s'", deal.Events[0].Action) + if *dealResp.BotEvents[0].Message != "Placing base order. Price: 50000.0 USDT Size: 0.0002 BTC" { + t.Fatalf("expected specific message, got '%s'", *dealResp.BotEvents[0].Message) } } @@ -180,42 +155,23 @@ func TestAddBotEvent(t *testing.T) { ts := NewTestServer(t) defer ts.Close() - ts.AddBot(Bot{ID: 1, Name: "Test Bot", Enabled: true, AccountID: 123}) - ts.AddDeal(1, Deal{ - ID: 101, - BotID: 1, - Pair: "USDT_BTC", - Status: "active", - CreatedAt: "2024-01-15T10:30:00.000Z", - UpdatedAt: "2024-01-15T10:30:00.000Z", - Events: []BotEvent{}, - }) + ts.AddBot(NewBot(1, "Test Bot", 123, true)) + deal := NewDeal(101, 1, "USDT_BTC", "active") + ts.AddDeal(deal) // Add an event - err := ts.AddBotEvent(101, BotEvent{ - CreatedAt: "2024-01-15T10:32:00.000Z", - Action: "place", - Coin: "BTC", - Type: "buy", - Status: "active", - Price: "48750.0", - Size: "0.0004", - OrderType: "safety", - OrderSize: 2, - OrderPosition: 1, - IsMarket: false, - }) + err := ts.AddBotEventToDeal(101, "Placing safety order. Price: 48750.0 USDT Size: 0.0004 BTC") if err != nil { t.Fatalf("failed to add bot event: %v", err) } // Verify event was added - deal, ok := ts.GetDealByID(101) + dealResp, ok := ts.GetDealByID(101) if !ok { t.Fatal("deal not found") } - if len(deal.Events) != 1 { - t.Fatalf("expected 1 event, got %d", len(deal.Events)) + if len(dealResp.BotEvents) != 1 { + t.Fatalf("expected 1 event, got %d", len(dealResp.BotEvents)) } } @@ -245,12 +201,12 @@ func TestListDeals_FilterByBot(t *testing.T) { ts := NewTestServer(t) defer ts.Close() - ts.AddBot(Bot{ID: 1, Name: "Bot 1", Enabled: true, AccountID: 123}) - ts.AddBot(Bot{ID: 2, Name: "Bot 2", Enabled: true, AccountID: 123}) + ts.AddBot(NewBot(1, "Bot 1", 123, true)) + ts.AddBot(NewBot(2, "Bot 2", 123, true)) - ts.AddDeal(1, Deal{ID: 101, BotID: 1, Status: "active"}) - ts.AddDeal(1, Deal{ID: 102, BotID: 1, Status: "active"}) - ts.AddDeal(2, Deal{ID: 103, BotID: 2, Status: "active"}) + ts.AddDeal(NewDeal(101, 1, "USDT_BTC", "active")) + ts.AddDeal(NewDeal(102, 1, "USDT_ETH", "active")) + ts.AddDeal(NewDeal(103, 2, "USDT_BTC", "active")) resp, err := http.Get(ts.URL() + "/ver1/deals?bot_id=1") if err != nil { @@ -258,7 +214,7 @@ func TestListDeals_FilterByBot(t *testing.T) { } defer resp.Body.Close() - var deals []Deal + var deals []tcmock.Deal if err := json.NewDecoder(resp.Body).Decode(&deals); err != nil { t.Fatalf("failed to decode response: %v", err) } @@ -272,8 +228,8 @@ func TestReset(t *testing.T) { ts := NewTestServer(t) defer ts.Close() - ts.AddBot(Bot{ID: 1, Name: "Test Bot", Enabled: true, AccountID: 123}) - ts.AddDeal(1, Deal{ID: 101, BotID: 1, Status: "active"}) + ts.AddBot(NewBot(1, "Test Bot", 123, true)) + ts.AddDeal(NewDeal(101, 1, "USDT_BTC", "active")) ts.SetRateLimitError(true, 60) ts.Reset() diff --git a/server/state.go b/server/state.go index c6c75fc..8c11dc1 100644 --- a/server/state.go +++ b/server/state.go @@ -1,31 +1,35 @@ package server -import "fmt" +import ( + "fmt" + + "github.com/recomma/3commas-mock/tcmock" +) // Bot Management // AddBot adds a bot to the mock server's state -func (ts *TestServer) AddBot(bot Bot) { +func (ts *TestServer) AddBot(bot tcmock.Bot) { ts.mu.Lock() defer ts.mu.Unlock() - ts.bots[bot.ID] = &bot + ts.bots[bot.Id] = &bot } // GetBot retrieves a bot by ID -func (ts *TestServer) GetBot(botID int) (Bot, bool) { +func (ts *TestServer) GetBot(botID int) (tcmock.Bot, bool) { ts.mu.RLock() defer ts.mu.RUnlock() bot, ok := ts.bots[botID] if !ok { - return Bot{}, false + return tcmock.Bot{}, false } return *bot, true } -// UpdateBot updates a bot's state -func (ts *TestServer) UpdateBot(botID int, updates BotUpdate) error { +// UpdateBotEnabled updates a bot's enabled state +func (ts *TestServer) UpdateBotEnabled(botID int, enabled bool) error { ts.mu.Lock() defer ts.mu.Unlock() @@ -34,13 +38,23 @@ func (ts *TestServer) UpdateBot(botID int, updates BotUpdate) error { return fmt.Errorf("bot %d not found", botID) } - if updates.Enabled != nil { - bot.Enabled = *updates.Enabled - } - if updates.Name != nil { - bot.Name = *updates.Name + bot.IsEnabled = enabled + + return nil +} + +// UpdateBotName updates a bot's name +func (ts *TestServer) UpdateBotName(botID int, name string) error { + ts.mu.Lock() + defer ts.mu.Unlock() + + bot, ok := ts.bots[botID] + if !ok { + return fmt.Errorf("bot %d not found", botID) } + bot.Name = &name + return nil } @@ -53,18 +67,18 @@ func (ts *TestServer) RemoveBot(botID int) { // Remove all deals for this bot for dealID, deal := range ts.deals { - if deal.BotID == botID { + if deal.BotId == botID { delete(ts.deals, dealID) } } } // GetAllBots returns all bots in the mock -func (ts *TestServer) GetAllBots() []Bot { +func (ts *TestServer) GetAllBots() []tcmock.Bot { ts.mu.RLock() defer ts.mu.RUnlock() - result := make([]Bot, 0, len(ts.bots)) + result := make([]tcmock.Bot, 0, len(ts.bots)) for _, bot := range ts.bots { result = append(result, *bot) } @@ -74,34 +88,33 @@ func (ts *TestServer) GetAllBots() []Bot { // Deal Management // AddDeal adds a deal to the mock server's state -func (ts *TestServer) AddDeal(botID int, deal Deal) error { +func (ts *TestServer) AddDeal(deal tcmock.Deal) error { ts.mu.Lock() defer ts.mu.Unlock() // Check if bot exists - if _, ok := ts.bots[botID]; !ok { - return fmt.Errorf("bot %d not found", botID) + if _, ok := ts.bots[deal.BotId]; !ok { + return fmt.Errorf("bot %d not found", deal.BotId) } - deal.BotID = botID - ts.deals[deal.ID] = &deal + ts.deals[deal.Id] = &deal return nil } // GetDealByID retrieves a deal by ID (state management method) -func (ts *TestServer) GetDealByID(dealID int) (Deal, bool) { +func (ts *TestServer) GetDealByID(dealID int) (tcmock.Deal, bool) { ts.mu.RLock() defer ts.mu.RUnlock() deal, ok := ts.deals[dealID] if !ok { - return Deal{}, false + return tcmock.Deal{}, false } return *deal, true } -// UpdateDeal updates a deal's state -func (ts *TestServer) UpdateDeal(dealID int, updates DealUpdate) error { +// UpdateDealStatus updates a deal's status +func (ts *TestServer) UpdateDealStatus(dealID int, status string) error { ts.mu.Lock() defer ts.mu.Unlock() @@ -110,15 +123,14 @@ func (ts *TestServer) UpdateDeal(dealID int, updates DealUpdate) error { return fmt.Errorf("deal %d not found", dealID) } - if updates.Status != nil { - deal.Status = *updates.Status - } + deal.Status = tcmock.DealStatus(status) return nil } -// AddBotEvent adds a new bot event to an existing deal -func (ts *TestServer) AddBotEvent(dealID int, event BotEvent) error { +// AddBotEventToDeal adds a new bot event to an existing deal +// message: Human-readable event description +func (ts *TestServer) AddBotEventToDeal(dealID int, message string) error { ts.mu.Lock() defer ts.mu.Unlock() @@ -127,7 +139,7 @@ func (ts *TestServer) AddBotEvent(dealID int, event BotEvent) error { return fmt.Errorf("deal %d not found", dealID) } - deal.Events = append(deal.Events, event) + AddBotEvent(deal, message) return nil } @@ -140,11 +152,11 @@ func (ts *TestServer) RemoveDeal(dealID int) { } // GetAllDeals returns all deals in the mock -func (ts *TestServer) GetAllDeals() []Deal { +func (ts *TestServer) GetAllDeals() []tcmock.Deal { ts.mu.RLock() defer ts.mu.RUnlock() - result := make([]Deal, 0, len(ts.deals)) + result := make([]tcmock.Deal, 0, len(ts.deals)) for _, deal := range ts.deals { result = append(result, *deal) } @@ -152,13 +164,13 @@ func (ts *TestServer) GetAllDeals() []Deal { } // GetBotDeals returns deals for a specific bot -func (ts *TestServer) GetBotDeals(botID int) []Deal { +func (ts *TestServer) GetBotDeals(botID int) []tcmock.Deal { ts.mu.RLock() defer ts.mu.RUnlock() - result := make([]Deal, 0) + result := make([]tcmock.Deal, 0) for _, deal := range ts.deals { - if deal.BotID == botID { + if deal.BotId == botID { result = append(result, *deal) } } diff --git a/server/types.go b/server/types.go index 25be168..690d989 100644 --- a/server/types.go +++ b/server/types.go @@ -1,50 +1,136 @@ package server -// BotEvent represents a bot event with full structured data for testing -// This overrides the simple generated type to provide rich event details -type BotEvent struct { - CreatedAt string `json:"created_at"` - Action string `json:"action"` // "place", "cancel", "cancelled", "modify" - Coin string `json:"coin"` // "BTC", "ETH", etc. - Type string `json:"type"` // "buy", "sell" - Status string `json:"status"` // "active", "filled", "cancelled" - Price string `json:"price"` // Decimal string - Size string `json:"size"` // Decimal string - OrderType string `json:"order_type"` // "base", "safety", "take_profit" - OrderSize int `json:"order_size"` // Order size category - OrderPosition int `json:"order_position"` // Safety order position - IsMarket bool `json:"is_market"` // Whether order is market order -} +import ( + "time" -// Bot represents a simplified bot for testing -type Bot struct { - ID int `json:"id"` - Name string `json:"name"` - Enabled bool `json:"is_enabled"` - AccountID int `json:"account_id"` - // Add other fields as needed -} + "github.com/oapi-codegen/nullable" + "github.com/recomma/3commas-mock/tcmock" +) + +// Helper types and functions for creating test data with the full generated types -// Deal represents a simplified deal for testing -type Deal struct { - ID int `json:"id"` - BotID int `json:"bot_id"` - Pair string `json:"pair"` - Status string `json:"status"` - ToCurrency string `json:"to_currency"` - FromCurrency string `json:"from_currency"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Events []BotEvent `json:"bot_events"` +// NewBot creates a minimal Bot with required fields populated +// This is a helper to make it easier to create test bots +func NewBot(id int, name string, accountID int, enabled bool) tcmock.Bot { + now := time.Now() + namePtr := &name + strategy := tcmock.BotStrategyLong + return tcmock.Bot{ + Id: id, + Name: namePtr, + AccountId: accountID, + AccountName: "Test Account", + IsEnabled: enabled, + CreatedAt: now, + ActiveDealsCount: 0, + FinishedDealsCount: "0", + ActiveDeals: []tcmock.Deal{}, + ActiveDealsBtcProfit: "0", + ActiveDealsUsdProfit: "0", + BtcFundsLockedInActiveDeals: "0", + FundsLockedInActiveDeals: "0", + FinishedDealsProfitUsd: "0", + Deletable: true, + Strategy: &strategy, + } } -// BotUpdate represents fields that can be updated on a bot -type BotUpdate struct { - Enabled *bool - Name *string +// NewDeal creates a minimal Deal with required fields populated +// This is a helper to make it easier to create test deals +func NewDeal(id int, botID int, pair string, status string) tcmock.Deal { + now := time.Now() + // Extract currency from pair (assumes format like "USDT_BTC") + toCurrency := "BTC" + if len(pair) > 3 { + toCurrency = pair[len(pair)-3:] + } + return tcmock.Deal{ + Id: id, + BotId: botID, + Pair: pair, + Status: tcmock.DealStatus(status), + CreatedAt: now, + UpdatedAt: now, + BotEvents: []struct { + CreatedAt *time.Time `json:"created_at,omitempty"` + Message *string `json:"message,omitempty"` + }{}, + AccountId: 1, + AccountName: "Test Account", + BotName: "Test Bot", + FromCurrency: "USDT", + ToCurrency: toCurrency, + BaseOrderVolume: "10", + BaseOrderVolumeType: "quote_currency", + BoughtAmount: "0", + BoughtAveragePrice: "0", + BoughtVolume: "0", + SoldAmount: "0", + SoldAveragePrice: "0", + SoldVolume: "0", + ActualProfitPercentage: "0", + ActualProfit: nullable.NewNullableWithValue("0"), + ActualUsdProfit: nullable.NewNullableWithValue("0"), + FinalProfit: "0", + FinalProfitPercentage: "0", + Cancellable: false, + CompletedManualSafetyOrdersCount: 0, + CompletedSafetyOrdersCount: 0, + CurrentActiveSafetyOrders: 0, + CurrentActiveSafetyOrdersCount: 0, + CurrentPrice: "0", + TakeProfitPrice: "0", + MaxSafetyOrders: 0, + ActiveManualSafetyOrders: 0, + TrailingEnabled: false, + TslEnabled: false, + StopLossPercentage: "0", + ErrorMessage: nullable.NewNullableWithValue(""), + ProfitCurrency: "quote_currency", + StopLossType: "stop_loss", + SafetyOrderStepPercentage: "0", + TakeProfitType: "total", + StopLossTimeoutEnabled: false, + StopLossTimeoutInSeconds: 0, + AddFundable: false, + SmartTradeConvertable: false, + PanicSellable: false, + MarketType: "spot", + OrderbookPriceCurrency: "USDT", + SafetyOrderVolume: "10", + SafetyOrderVolumeType: "quote_currency", + MartingaleStepCoefficient: "1.0", + MartingaleVolumeCoefficient: "1.0", + MinProfitPercentage: "0", + SafetyStrategyList: []map[string]interface{}{}, + SlToBreakevenEnabled: false, + CloseStrategyList: []map[string]interface{}{}, + TakeProfitSteps: []struct { + AmountPercentage *float32 `json:"amount_percentage,omitempty"` + Editable *bool `json:"editable,omitempty"` + ExecutionTimestamp nullable.Nullable[time.Time] `json:"execution_timestamp,omitempty"` + Id *int `json:"id,omitempty"` + InitialAmount *string `json:"initial_amount,omitempty"` + PanicSellable *bool `json:"panic_sellable,omitempty"` + Price *string `json:"price,omitempty"` + ProfitPercentage *float32 `json:"profit_percentage,omitempty"` + Status *string `json:"status,omitempty"` + TradeId *int `json:"trade_id,omitempty"` + }{}, + Type: "simple", + } } -// DealUpdate represents fields that can be updated on a deal -type DealUpdate struct { - Status *string +// AddBotEvent adds a bot event to a deal +// message: Human-readable event description +func AddBotEvent(deal *tcmock.Deal, message string) { + now := time.Now() + msg := message + deal.BotEvents = append(deal.BotEvents, struct { + CreatedAt *time.Time `json:"created_at,omitempty"` + Message *string `json:"message,omitempty"` + }{ + CreatedAt: &now, + Message: &msg, + }) } diff --git a/server/vcr.go b/server/vcr.go new file mode 100644 index 0000000..0275da4 --- /dev/null +++ b/server/vcr.go @@ -0,0 +1,194 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "strconv" + + "github.com/recomma/3commas-mock/tcmock" + "gopkg.in/dnaeon/go-vcr.v4/pkg/cassette" +) + +// URL pattern matchers +var ( + dealShowPattern = regexp.MustCompile(`/ver1/deals/(\d+)/show$`) + dealsListPattern = regexp.MustCompile(`/ver1/deals(\?.*)?$`) + botsListPattern = regexp.MustCompile(`/ver1/bots(\?.*)?$`) +) + +// LoadVCRCassette loads a VCR cassette and populates mock server state +// - Bots and Deals are loaded with ALL their data from real API responses +// - bot_events are PRESERVED exactly as recorded (this is the valuable part!) +// - Duplicate IDs will return an error +// - Non-2xx responses are skipped +func (ts *TestServer) LoadVCRCassette(cassettePath string) error { + // Load cassette from file + c, err := cassette.Load(cassettePath) + if err != nil { + return fmt.Errorf("failed to load VCR cassette %s: %w", cassettePath, err) + } + + // Process each interaction + for i, interaction := range c.Interactions { + // Skip non-successful responses + if interaction.Response.Code < 200 || interaction.Response.Code >= 300 { + continue + } + + // Only process GET requests + if interaction.Request.Method != http.MethodGet { + continue + } + + // Try to match against known patterns + if err := ts.processInteraction(interaction); err != nil { + return fmt.Errorf("failed to process interaction %d from %s: %w", i, cassettePath, err) + } + } + + return nil +} + +// LoadVCRCassettes loads multiple VCR cassettes in sequence +func (ts *TestServer) LoadVCRCassettes(cassettePaths ...string) error { + for _, path := range cassettePaths { + if err := ts.LoadVCRCassette(path); err != nil { + return err + } + } + return nil +} + +// processInteraction processes a single VCR interaction and adds entities to state +func (ts *TestServer) processInteraction(interaction *cassette.Interaction) error { + url := interaction.Request.URL + body := interaction.Response.Body + + // Match against deal show endpoint: /ver1/deals/{id}/show + if matches := dealShowPattern.FindStringSubmatch(url); matches != nil { + return ts.loadDealFromJSON(body) + } + + // Match against deals list endpoint: /ver1/deals + if dealsListPattern.MatchString(url) { + return ts.loadDealsListFromJSON(body) + } + + // Match against bots list endpoint: /ver1/bots + if botsListPattern.MatchString(url) { + return ts.loadBotsListFromJSON(body) + } + + // Unknown endpoint - skip silently + return nil +} + +// loadDealFromJSON unmarshals a single deal and adds it to state +func (ts *TestServer) loadDealFromJSON(jsonBody string) error { + var deal tcmock.Deal + if err := json.Unmarshal([]byte(jsonBody), &deal); err != nil { + return fmt.Errorf("failed to unmarshal deal: %w", err) + } + + // Check for duplicate + ts.mu.RLock() + if _, exists := ts.deals[deal.Id]; exists { + ts.mu.RUnlock() + return fmt.Errorf("duplicate deal ID %d found in VCR cassette", deal.Id) + } + ts.mu.RUnlock() + + // Check if bot exists, create a minimal one if not + ts.mu.RLock() + botExists := ts.bots[deal.BotId] != nil + ts.mu.RUnlock() + + if !botExists { + // Create a minimal bot based on deal data + bot := NewBot(deal.BotId, deal.BotName, deal.AccountId, true) + bot.AccountName = deal.AccountName + ts.AddBot(bot) + } + + // Add deal with all its data (including bot_events preserved) + ts.mu.Lock() + ts.deals[deal.Id] = &deal + ts.mu.Unlock() + + return nil +} + +// loadDealsListFromJSON unmarshals a list of deals and adds them to state +func (ts *TestServer) loadDealsListFromJSON(jsonBody string) error { + var deals []tcmock.Deal + if err := json.Unmarshal([]byte(jsonBody), &deals); err != nil { + return fmt.Errorf("failed to unmarshal deals list: %w", err) + } + + for _, deal := range deals { + // Check for duplicate + ts.mu.RLock() + if _, exists := ts.deals[deal.Id]; exists { + ts.mu.RUnlock() + return fmt.Errorf("duplicate deal ID %d found in VCR cassette", deal.Id) + } + ts.mu.RUnlock() + + // Check if bot exists, create a minimal one if not + ts.mu.RLock() + botExists := ts.bots[deal.BotId] != nil + ts.mu.RUnlock() + + if !botExists { + // Create a minimal bot based on deal data + bot := NewBot(deal.BotId, deal.BotName, deal.AccountId, true) + bot.AccountName = deal.AccountName + ts.AddBot(bot) + } + + // Add deal with all its data (including bot_events preserved) + ts.mu.Lock() + dealCopy := deal // Create a copy to get the correct pointer + ts.deals[dealCopy.Id] = &dealCopy + ts.mu.Unlock() + } + + return nil +} + +// loadBotsListFromJSON unmarshals a list of bots and adds them to state +func (ts *TestServer) loadBotsListFromJSON(jsonBody string) error { + var bots []tcmock.Bot + if err := json.Unmarshal([]byte(jsonBody), &bots); err != nil { + return fmt.Errorf("failed to unmarshal bots list: %w", err) + } + + for _, bot := range bots { + // Check for duplicate + ts.mu.RLock() + if _, exists := ts.bots[bot.Id]; exists { + ts.mu.RUnlock() + return fmt.Errorf("duplicate bot ID %d found in VCR cassette", bot.Id) + } + ts.mu.RUnlock() + + // Add bot with all its data + ts.mu.Lock() + botCopy := bot // Create a copy to get the correct pointer + ts.bots[botCopy.Id] = &botCopy + ts.mu.Unlock() + } + + return nil +} + +// ExtractDealID is a helper to extract deal ID from a deal show URL +func ExtractDealID(url string) (int, error) { + matches := dealShowPattern.FindStringSubmatch(url) + if matches == nil { + return 0, fmt.Errorf("URL does not match deal show pattern: %s", url) + } + return strconv.Atoi(matches[1]) +} diff --git a/server/vcr_test.go b/server/vcr_test.go new file mode 100644 index 0000000..c629f49 --- /dev/null +++ b/server/vcr_test.go @@ -0,0 +1,173 @@ +package server + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/recomma/3commas-mock/tcmock" +) + +func TestLoadVCRCassette_SingleDeal(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + // Load VCR cassette with a real recorded deal + // Note: go-vcr appends .yaml automatically, so omit extension + err := ts.LoadVCRCassette("../testdata/fixtures/deal_2376446537") + if err != nil { + t.Fatalf("failed to load VCR cassette: %v", err) + } + + // Verify the deal was loaded + deal, ok := ts.GetDealByID(2376446537) + if !ok { + t.Fatal("deal 2376446537 not found after loading VCR cassette") + } + + // Verify deal fields from real API + if deal.Pair != "USDT_DOGE" { + t.Errorf("expected pair USDT_DOGE, got %s", deal.Pair) + } + if deal.Status != "bought" { + t.Errorf("expected status bought, got %s", deal.Status) + } + if deal.BotId != 16511317 { + t.Errorf("expected bot_id 16511317, got %d", deal.BotId) + } + + // MOST IMPORTANT: Verify bot_events were preserved from real API + if len(deal.BotEvents) != 3 { + t.Fatalf("expected 3 bot events, got %d", len(deal.BotEvents)) + } + + // Check first event + if deal.BotEvents[0].Message == nil { + t.Fatal("expected first event to have a message") + } + expectedMsg := "Placing averaging order (9 out of 9). Price: market Size: 25.0008 USDT (110.0 DOGE)" + if *deal.BotEvents[0].Message != expectedMsg { + t.Errorf("expected first event message: %s, got: %s", expectedMsg, *deal.BotEvents[0].Message) + } + + // Verify bot was auto-created + bot, ok := ts.GetBot(16511317) + if !ok { + t.Fatal("bot 16511317 should have been auto-created") + } + if bot.AccountName != "Demo Account 2080398" { + t.Errorf("expected account name from deal, got %s", bot.AccountName) + } + + // Test that the deal is accessible via HTTP API + resp, err := http.Get(ts.URL() + "/ver1/deals/2376446537/show") + if err != nil { + t.Fatalf("failed to GET deal: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + var dealResp tcmock.Deal + if err := json.NewDecoder(resp.Body).Decode(&dealResp); err != nil { + t.Fatalf("failed to decode deal: %v", err) + } + + // Verify bot_events are returned via API + if len(dealResp.BotEvents) != 3 { + t.Fatalf("expected 3 bot events via API, got %d", len(dealResp.BotEvents)) + } +} + +func TestLoadVCRCassette_DuplicateError(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + // Load cassette first time - should succeed + err := ts.LoadVCRCassette("../testdata/fixtures/deal_2376446537") + if err != nil { + t.Fatalf("first load failed: %v", err) + } + + // Load same cassette again - should fail with duplicate error + err = ts.LoadVCRCassette("../testdata/fixtures/deal_2376446537") + if err == nil { + t.Fatal("expected error for duplicate deal ID, got nil") + } + + if err.Error() != "failed to process interaction 0 from ../testdata/fixtures/deal_2376446537: duplicate deal ID 2376446537 found in VCR cassette" { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestLoadVCRCassettes_Multiple(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + // Load multiple cassettes at once + err := ts.LoadVCRCassettes( + "../testdata/fixtures/deal_2376446537", + // Add more cassette paths here as you create them + ) + if err != nil { + t.Fatalf("failed to load cassettes: %v", err) + } + + // Verify deal from first cassette + deal, ok := ts.GetDealByID(2376446537) + if !ok { + t.Fatal("deal from first cassette not found") + } + if len(deal.BotEvents) != 3 { + t.Fatalf("expected 3 bot events, got %d", len(deal.BotEvents)) + } +} + +func TestExtractDealID(t *testing.T) { + tests := []struct { + url string + expected int + wantErr bool + }{ + { + url: "https://api.3commas.io/public/api/ver1/deals/2376446537/show", + expected: 2376446537, + wantErr: false, + }, + { + url: "/ver1/deals/123/show", + expected: 123, + wantErr: false, + }, + { + url: "/ver1/deals", + expected: 0, + wantErr: true, + }, + { + url: "/ver1/bots", + expected: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + id, err := ExtractDealID(tt.url) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for URL %s, got nil", tt.url) + } + } else { + if err != nil { + t.Errorf("unexpected error for URL %s: %v", tt.url, err) + } + if id != tt.expected { + t.Errorf("expected ID %d, got %d", tt.expected, id) + } + } + }) + } +} diff --git a/testdata/fixtures/deal_2376446537.yaml b/testdata/fixtures/deal_2376446537.yaml new file mode 100644 index 0000000..da72f4e --- /dev/null +++ b/testdata/fixtures/deal_2376446537.yaml @@ -0,0 +1,25 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.3commas.io + url: https://api.3commas.io/public/api/ver1/deals/2376446537/show + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"from_currency_id":0,"to_currency_id":0,"id":2376446537,"type":"Deal","bot_id":16511317,"max_safety_orders":9,"deal_has_error":false,"account_id":33256512,"active_safety_orders_count":0,"created_at":"2025-09-26T17:26:26.529Z","updated_at":"2025-09-28T08:27:03.877Z","closed_at":null,"finished?":false,"current_active_safety_orders_count":0,"current_active_safety_orders":0,"completed_safety_orders_count":9,"completed_manual_safety_orders_count":0,"cancellable?":true,"panic_sellable?":true,"trailing_enabled":false,"tsl_enabled":false,"stop_loss_timeout_enabled":false,"stop_loss_timeout_in_seconds":0,"active_manual_safety_orders":0,"pair":"USDT_DOGE","status":"bought","localized_status":"Active","take_profit":"2.0","take_profit_steps":[],"base_order_volume":"25.0","safety_order_volume":"25.0","safety_order_step_percentage":"0.21","safety_order_calculation_mode":"base","leverage_type":"not_specified","leverage_custom_value":null,"bought_amount":"1094.0","bought_volume":"251.20651556","bought_average_price":"0.229622043473491773308957952468007313","base_order_average_price":"0.23171148","sold_amount":"0.0","sold_volume":"0.0","sold_average_price":"0","take_profit_type":"total","final_profit":"-2.82176894","martingale_coefficient":"1.0","martingale_volume_coefficient":"1.0","martingale_step_coefficient":"1.0","stop_loss_percentage":"5.0","sl_to_breakeven_enabled":false,"sl_to_breakeven_data":null,"error_message":null,"profit_currency":"quote_currency","stop_loss_type":"stop_loss","safety_order_volume_type":"quote_currency","base_order_volume_type":"quote_currency","from_currency":"USDT","to_currency":"DOGE","final_profit_percentage":"0","usd_final_profit":"-2.82","actual_profit":"-0.62502788","actual_usd_profit":"-0.62502788","failed_message":null,"reserved_base_coin":"251.20651556","reserved_second_coin":"1094.0","trailing_deviation":"0.2","trailing_max_price":null,"tsl_max_price":null,"strategy":"long","last_known_position_info":null,"min_profit_percentage":"0.0","min_profit_type":null,"close_strategy_list":[],"safety_strategy_list":[{"options":{},"strategy":"tv_custom_signal"}],"custom_safety_steps":[],"note":null,"add_fundable":true,"smart_trade_convertable":true,"bot_name":"Bot16511317 has signal","account_name":"Demo Account 2080398","market_type":"spot","current_price":"0.22928","take_profit_price":"0.23445","stop_loss_price":"0.220125906","actual_profit_percentage":"-0.15","reserved_quote_funds":"0.0","reserved_base_funds":"0.0","orderbook_price_currency":"USDT","bot_events":[{"message":"Placing averaging order (9 out of 9). Price: market Size: 25.0008 USDT (110.0 DOGE)","created_at":"2025-09-28T08:27:03.515Z"},{"message":"Averaging order (9 out of 9) executed. Price: market Size: 25.0269019 USDT (110.0 DOGE) #lastAO 😬","created_at":"2025-09-28T08:27:03.827Z"},{"message":"Cancelling TakeProfit trade. Price: 0.23469 USDT Size: 230.93496 USDT (984.0 DOGE)","created_at":"2025-09-28T08:27:03.949Z"}]}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 1.9757445s