From a3d8831b36b63daeab6952ab596f0076ce71f720 Mon Sep 17 00:00:00 2001 From: Dean Date: Tue, 14 Apr 2026 17:42:05 +0800 Subject: [PATCH 1/7] feat(trader): implement margin mode handling for order and leverage settings --- trader/auto_trader_grid.go | 11 ++ trader/okx/trader.go | 13 ++- trader/okx/trader_account.go | 12 +- trader/okx/trader_margin_mode_test.go | 162 ++++++++++++++++++++++++++ trader/okx/trader_orders.go | 20 +++- 5 files changed, 204 insertions(+), 14 deletions(-) create mode 100644 trader/okx/trader_margin_mode_test.go diff --git a/trader/auto_trader_grid.go b/trader/auto_trader_grid.go index 6151dbbb..c16a5d8d 100644 --- a/trader/auto_trader_grid.go +++ b/trader/auto_trader_grid.go @@ -324,6 +324,17 @@ func (at *AutoTrader) InitializeGrid() error { at.gridState.IsInitialized = true + // Keep grid orders aligned with the trader's configured cross/isolated mode. + if err := at.trader.SetMarginMode(gridConfig.Symbol, at.config.IsCrossMargin); err != nil { + logger.Warnf("[Grid] Failed to set margin mode for %s: %v", gridConfig.Symbol, err) + } else { + marginMode := "cross" + if !at.config.IsCrossMargin { + marginMode = "isolated" + } + logger.Infof("[Grid] Margin mode set to %s for %s", marginMode, gridConfig.Symbol) + } + // CRITICAL: Set leverage on exchange before trading if err := at.trader.SetLeverage(gridConfig.Symbol, gridConfig.Leverage); err != nil { logger.Warnf("[Grid] Failed to set leverage %dx on exchange: %v", gridConfig.Leverage, err) diff --git a/trader/okx/trader.go b/trader/okx/trader.go index b46f23c4..41e4a45f 100644 --- a/trader/okx/trader.go +++ b/trader/okx/trader.go @@ -41,7 +41,7 @@ type OKXTrader struct { secretKey string passphrase string - // Margin mode setting + // Margin mode setting used for new orders and leverage changes. isCrossMargin bool // Position mode: "long_short_mode" (hedge) or "net_mode" (one-way) @@ -121,6 +121,7 @@ func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader { apiKey: apiKey, secretKey: secretKey, passphrase: passphrase, + isCrossMargin: true, httpClient: httpClient, cacheDuration: 15 * time.Second, instrumentsCache: make(map[string]*OKXInstrument), @@ -139,10 +140,18 @@ func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader { } } - logger.Infof("✓ OKX trader initialized with position mode: %s", trader.positionMode) + logger.Infof("✓ OKX trader initialized with position mode: %s, default margin mode: %s", + trader.positionMode, trader.marginMode()) return trader } +func (t *OKXTrader) marginMode() string { + if t.isCrossMargin { + return "cross" + } + return "isolated" +} + // detectPositionMode gets current position mode from account config func (t *OKXTrader) detectPositionMode() error { data, err := t.doRequest("GET", okxAccountConfigPath, nil) diff --git a/trader/okx/trader_account.go b/trader/okx/trader_account.go index e10fadf0..53fa0550 100644 --- a/trader/okx/trader_account.go +++ b/trader/okx/trader_account.go @@ -83,11 +83,8 @@ func (t *OKXTrader) GetBalance() (map[string]interface{}, error) { // SetMarginMode sets margin mode func (t *OKXTrader) SetMarginMode(symbol string, isCrossMargin bool) error { instId := t.convertSymbol(symbol) - - mgnMode := "isolated" - if isCrossMargin { - mgnMode = "cross" - } + t.isCrossMargin = isCrossMargin + mgnMode := t.marginMode() body := map[string]interface{}{ "instId": instId, @@ -116,13 +113,14 @@ func (t *OKXTrader) SetMarginMode(symbol string, isCrossMargin bool) error { // SetLeverage sets leverage func (t *OKXTrader) SetLeverage(symbol string, leverage int) error { instId := t.convertSymbol(symbol) + marginMode := t.marginMode() // Set leverage for both long and short for _, posSide := range []string{"long", "short"} { body := map[string]interface{}{ "instId": instId, "lever": strconv.Itoa(leverage), - "mgnMode": "cross", + "mgnMode": marginMode, "posSide": posSide, } @@ -136,7 +134,7 @@ func (t *OKXTrader) SetLeverage(symbol string, leverage int) error { } } - logger.Infof(" ✓ %s leverage set to %dx", symbol, leverage) + logger.Infof(" ✓ %s leverage set to %dx (%s)", symbol, leverage, marginMode) return nil } diff --git a/trader/okx/trader_margin_mode_test.go b/trader/okx/trader_margin_mode_test.go new file mode 100644 index 00000000..b0a78305 --- /dev/null +++ b/trader/okx/trader_margin_mode_test.go @@ -0,0 +1,162 @@ +package okx + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "nofx/trader/types" +) + +type capturedRequest struct { + Method string + Path string + Body map[string]interface{} +} + +type recordingTransport struct { + requests []capturedRequest +} + +func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + var body map[string]interface{} + if req.Body != nil { + data, _ := io.ReadAll(req.Body) + if len(data) > 0 && strings.HasPrefix(strings.TrimSpace(string(data)), "{") { + _ = json.Unmarshal(data, &body) + } + } + + rt.requests = append(rt.requests, capturedRequest{ + Method: req.Method, + Path: req.URL.Path, + Body: body, + }) + + response := `{"code":"0","msg":"","data":[]}` + switch req.URL.Path { + case okxInstrumentsPath: + response = `{"code":"0","msg":"","data":[{"instId":"BTC-USDT-SWAP","ctVal":"0.01","ctMult":"1","lotSz":"1","minSz":"1","maxMktSz":"100000","tickSz":"0.1","ctType":"linear"}]}` + case okxOrderPath: + response = `{"code":"0","msg":"","data":[{"ordId":"123","clOrdId":"abc","sCode":"0","sMsg":""}]}` + } + + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewBufferString(response)), + }, nil +} + +func (rt *recordingTransport) requestsForPath(path string) []capturedRequest { + var matches []capturedRequest + for _, req := range rt.requests { + if req.Path == path { + matches = append(matches, req) + } + } + return matches +} + +func newTestOKXTrader(rt *recordingTransport, isCrossMargin bool) *OKXTrader { + return &OKXTrader{ + apiKey: "key", + secretKey: "secret", + passphrase: "pass", + isCrossMargin: isCrossMargin, + positionMode: "long_short_mode", + httpClient: &http.Client{ + Transport: rt, + }, + cacheDuration: 15 * time.Second, + instrumentsCache: make(map[string]*OKXInstrument), + instrumentsCacheTime: time.Now(), + } +} + +func TestOKXSetLeverageUsesConfiguredMarginMode(t *testing.T) { + rt := &recordingTransport{} + trader := newTestOKXTrader(rt, false) + + if err := trader.SetLeverage("BTCUSDT", 5); err != nil { + t.Fatalf("SetLeverage failed: %v", err) + } + + leverageRequests := rt.requestsForPath(okxLeveragePath) + if len(leverageRequests) != 2 { + t.Fatalf("expected 2 leverage requests, got %d", len(leverageRequests)) + } + + for _, req := range leverageRequests { + if req.Body["mgnMode"] != "isolated" { + t.Fatalf("expected isolated leverage mode, got %#v", req.Body["mgnMode"]) + } + } +} + +func TestOKXOpenLongUsesConfiguredMarginMode(t *testing.T) { + rt := &recordingTransport{} + trader := newTestOKXTrader(rt, false) + + if _, err := trader.OpenLong("BTCUSDT", 0.1, 5); err != nil { + t.Fatalf("OpenLong failed: %v", err) + } + + orderRequests := rt.requestsForPath(okxOrderPath) + if len(orderRequests) == 0 { + t.Fatal("expected at least one order request") + } + + lastOrder := orderRequests[len(orderRequests)-1] + if lastOrder.Body["tdMode"] != "isolated" { + t.Fatalf("expected isolated tdMode, got %#v", lastOrder.Body["tdMode"]) + } +} + +func TestOKXSetStopLossUsesConfiguredMarginMode(t *testing.T) { + rt := &recordingTransport{} + trader := newTestOKXTrader(rt, false) + + if err := trader.SetStopLoss("BTCUSDT", "LONG", 0.1, 90000); err != nil { + t.Fatalf("SetStopLoss failed: %v", err) + } + + algoRequests := rt.requestsForPath(okxAlgoOrderPath) + if len(algoRequests) != 1 { + t.Fatalf("expected 1 algo order request, got %d", len(algoRequests)) + } + + if algoRequests[0].Body["tdMode"] != "isolated" { + t.Fatalf("expected isolated tdMode, got %#v", algoRequests[0].Body["tdMode"]) + } +} + +func TestOKXPlaceLimitOrderUsesConfiguredMarginMode(t *testing.T) { + rt := &recordingTransport{} + trader := newTestOKXTrader(rt, false) + + _, err := trader.PlaceLimitOrder(&types.LimitOrderRequest{ + Symbol: "BTCUSDT", + Side: "BUY", + PositionSide: "LONG", + Price: 95000, + Quantity: 0.1, + Leverage: 3, + }) + if err != nil { + t.Fatalf("PlaceLimitOrder failed: %v", err) + } + + orderRequests := rt.requestsForPath(okxOrderPath) + if len(orderRequests) != 1 { + t.Fatalf("expected 1 limit order request, got %d", len(orderRequests)) + } + + if orderRequests[0].Body["tdMode"] != "isolated" { + t.Fatalf("expected isolated tdMode, got %#v", orderRequests[0].Body["tdMode"]) + } +} diff --git a/trader/okx/trader_orders.go b/trader/okx/trader_orders.go index 33697acc..2676db3f 100644 --- a/trader/okx/trader_orders.go +++ b/trader/okx/trader_orders.go @@ -41,9 +41,11 @@ func (t *OKXTrader) OpenLong(symbol string, quantity float64, leverage int) (map szStr = t.formatSize(sz, inst) } + marginMode := t.marginMode() + body := map[string]interface{}{ "instId": instId, - "tdMode": "cross", + "tdMode": marginMode, "side": "buy", "posSide": "long", "ordType": "market", @@ -118,9 +120,11 @@ func (t *OKXTrader) OpenShort(symbol string, quantity float64, leverage int) (ma szStr = t.formatSize(sz, inst) } + marginMode := t.marginMode() + body := map[string]interface{}{ "instId": instId, - "tdMode": "cross", + "tdMode": marginMode, "side": "sell", "posSide": "short", "ordType": "market", @@ -410,9 +414,11 @@ func (t *OKXTrader) SetStopLoss(symbol string, positionSide string, quantity, st posSide = "short" } + marginMode := t.marginMode() + body := map[string]interface{}{ "instId": instId, - "tdMode": "cross", + "tdMode": marginMode, "side": side, "posSide": posSide, "ordType": "conditional", @@ -453,9 +459,11 @@ func (t *OKXTrader) SetTakeProfit(symbol string, positionSide string, quantity, posSide = "short" } + marginMode := t.marginMode() + body := map[string]interface{}{ "instId": instId, - "tdMode": "cross", + "tdMode": marginMode, "side": side, "posSide": posSide, "ordType": "conditional", @@ -815,9 +823,11 @@ func (t *OKXTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitO posSide = "short" } + marginMode := t.marginMode() + body := map[string]interface{}{ "instId": instId, - "tdMode": "cross", + "tdMode": marginMode, "side": side, "posSide": posSide, "ordType": "limit", From c2fc80e26955c512a3b48f1cf5c6df276203da60 Mon Sep 17 00:00:00 2001 From: Dean Date: Tue, 14 Apr 2026 23:34:35 +0800 Subject: [PATCH 2/7] refactor(trader): update SetMarginMode to avoid legacy endpoint and improve logging --- trader/okx/trader_account.go | 27 +++++--------------------- trader/okx/trader_margin_mode_test.go | 28 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/trader/okx/trader_account.go b/trader/okx/trader_account.go index 53fa0550..416a109d 100644 --- a/trader/okx/trader_account.go +++ b/trader/okx/trader_account.go @@ -82,31 +82,14 @@ func (t *OKXTrader) GetBalance() (map[string]interface{}, error) { // SetMarginMode sets margin mode func (t *OKXTrader) SetMarginMode(symbol string, isCrossMargin bool) error { - instId := t.convertSymbol(symbol) t.isCrossMargin = isCrossMargin mgnMode := t.marginMode() - body := map[string]interface{}{ - "instId": instId, - "mgnMode": mgnMode, - } - - _, err := t.doRequest("POST", "/api/v5/account/set-isolated-mode", body) - if err != nil { - // Ignore error if already in target mode - if strings.Contains(err.Error(), "already") { - logger.Infof(" ✓ %s margin mode is already %s", symbol, mgnMode) - return nil - } - // Cannot change when there are positions - if strings.Contains(err.Error(), "position") { - logger.Infof(" ⚠️ %s has positions, cannot change margin mode", symbol) - return nil - } - return err - } - - logger.Infof(" ✓ %s margin mode set to %s", symbol, mgnMode) + // OKX V5 unified account applies cross/isolated per order via tdMode, + // while leverage uses mgnMode on /account/set-leverage. + // Persist the configured mode locally so subsequent leverage/order calls use it, + // instead of calling the legacy isolated-mode endpoint that returns 51000 errors. + logger.Infof(" ✓ %s margin mode configured as %s (applied via tdMode/mgnMode on subsequent requests)", symbol, mgnMode) return nil } diff --git a/trader/okx/trader_margin_mode_test.go b/trader/okx/trader_margin_mode_test.go index b0a78305..b633a364 100644 --- a/trader/okx/trader_margin_mode_test.go +++ b/trader/okx/trader_margin_mode_test.go @@ -98,6 +98,34 @@ func TestOKXSetLeverageUsesConfiguredMarginMode(t *testing.T) { } } +func TestOKXSetMarginModeUpdatesFutureRequestsWithoutAPIError(t *testing.T) { + rt := &recordingTransport{} + trader := newTestOKXTrader(rt, true) + + if err := trader.SetMarginMode("BTCUSDT", false); err != nil { + t.Fatalf("SetMarginMode failed: %v", err) + } + + if len(rt.requestsForPath("/api/v5/account/set-isolated-mode")) != 0 { + t.Fatal("expected SetMarginMode not to call legacy isolated-mode endpoint") + } + + if err := trader.SetLeverage("BTCUSDT", 5); err != nil { + t.Fatalf("SetLeverage failed: %v", err) + } + + leverageRequests := rt.requestsForPath(okxLeveragePath) + if len(leverageRequests) != 2 { + t.Fatalf("expected 2 leverage requests, got %d", len(leverageRequests)) + } + + for _, req := range leverageRequests { + if req.Body["mgnMode"] != "isolated" { + t.Fatalf("expected isolated leverage mode after SetMarginMode(false), got %#v", req.Body["mgnMode"]) + } + } +} + func TestOKXOpenLongUsesConfiguredMarginMode(t *testing.T) { rt := &recordingTransport{} trader := newTestOKXTrader(rt, false) From 1464cedeff0ef86982504fe87d7b0e3d13b9e775 Mon Sep 17 00:00:00 2001 From: Dean Date: Wed, 15 Apr 2026 18:15:32 +0800 Subject: [PATCH 3/7] feat(api): enhance strategy handling by integrating claw402 wallet key validation Added validation for the claw402 model's wallet key during strategy test runs. If the selected AI model is claw402, the server now checks for a valid wallet key and returns appropriate error messages if it's missing or if the model fails to load. This ensures better error handling and user feedback when working with AI models. --- api/strategy.go | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/api/strategy.go b/api/strategy.go index 1985c384..07e20987 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -516,8 +516,31 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) { req.PromptVariant = "balanced" } + claw402WalletKey := "" + if req.AIModelID != "" { + model, err := s.store.AIModel().Get(userID, req.AIModelID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Failed to load selected AI model", + "ai_response": "", + }) + return + } + + if model.Provider == "claw402" { + claw402WalletKey = string(model.APIKey) + if claw402WalletKey == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Selected claw402 model is missing wallet private key", + "ai_response": "", + }) + return + } + } + } + // Create strategy engine to build prompt - engine := kernel.NewStrategyEngine(&req.Config) + engine := kernel.NewStrategyEngine(&req.Config, claw402WalletKey) // Get candidate coins candidates, err := engine.GetCandidateCoins() From 0d74c27be27ea39536d73515be88fc89783dd8d4 Mon Sep 17 00:00:00 2001 From: Dean Date: Wed, 15 Apr 2026 18:34:20 +0800 Subject: [PATCH 4/7] refactor(api): streamline claw402 wallet key retrieval and error handling Refactored the strategy handling logic to encapsulate claw402 wallet key retrieval in a new method, `resolveStrategyDataWalletKey`. This improves code readability and maintains consistent error handling for missing or invalid wallet keys during strategy test runs. The changes enhance the overall robustness of the AI model integration. --- api/strategy.go | 63 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/api/strategy.go b/api/strategy.go index 07e20987..f2813efb 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -516,27 +516,13 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) { req.PromptVariant = "balanced" } - claw402WalletKey := "" - if req.AIModelID != "" { - model, err := s.store.AIModel().Get(userID, req.AIModelID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Failed to load selected AI model", - "ai_response": "", - }) - return - } - - if model.Provider == "claw402" { - claw402WalletKey = string(model.APIKey) - if claw402WalletKey == "" { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Selected claw402 model is missing wallet private key", - "ai_response": "", - }) - return - } - } + claw402WalletKey, err := s.resolveStrategyDataWalletKey(userID, req.AIModelID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + "ai_response": "", + }) + return } // Create strategy engine to build prompt @@ -720,3 +706,38 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string) return response, nil } + +func (s *Server) resolveStrategyDataWalletKey(userID, selectedModelID string) (string, error) { + if selectedModelID != "" { + model, err := s.store.AIModel().Get(userID, selectedModelID) + if err != nil { + return "", fmt.Errorf("failed to load selected AI model") + } + + if model.Provider == "claw402" { + walletKey := string(model.APIKey) + if walletKey == "" { + return "", fmt.Errorf("selected claw402 model is missing wallet private key") + } + return walletKey, nil + } + } + + models, err := s.store.AIModel().List(userID) + if err != nil { + return "", fmt.Errorf("failed to load AI models") + } + + for _, model := range models { + if model == nil || model.Provider != "claw402" { + continue + } + + walletKey := string(model.APIKey) + if walletKey != "" { + return walletKey, nil + } + } + + return "", nil +} From b9b0a521375dce56a9922cda86a198870a280028 Mon Sep 17 00:00:00 2001 From: Dean Date: Wed, 15 Apr 2026 18:50:31 +0800 Subject: [PATCH 5/7] feat(trader): add claw402 wallet key resolution for trader configuration Implemented a new method, `resolveTraderDataWalletKey`, to retrieve the claw402 wallet key based on the selected AI model and user ID. This enhancement allows for better integration of the claw402 model within the trader configuration, ensuring that the correct wallet key is used for trading operations. The `AutoTraderConfig` struct has been updated to include the new `Claw402WalletKey` field, improving the overall handling of wallet keys in the trading process. --- manager/trader_manager.go | 30 ++++++++++++++++++++++++++++++ trader/auto_trader.go | 21 +++++++++++---------- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/manager/trader_manager.go b/manager/trader_manager.go index ab4b6583..8c65941d 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -703,6 +703,8 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg traderConfig.CustomAPIKey = string(aiModelCfg.APIKey) } + traderConfig.Claw402WalletKey = resolveTraderDataWalletKey(st, traderCfg.UserID, aiModelCfg) + // Create trader instance at, err := trader.NewAutoTrader(traderConfig, st, traderCfg.UserID) if err != nil { @@ -741,3 +743,31 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg return nil } +func resolveTraderDataWalletKey(st *store.Store, userID string, selectedModel *store.AIModel) string { + if selectedModel != nil && selectedModel.Provider == "claw402" { + if walletKey := string(selectedModel.APIKey); walletKey != "" { + return walletKey + } + } + + if st == nil { + return "" + } + + models, err := st.AIModel().List(userID) + if err != nil { + logger.Warnf("⚠️ Failed to load claw402 wallet for trader data routing: %v", err) + return "" + } + + for _, model := range models { + if model == nil || model.Provider != "claw402" { + continue + } + if walletKey := string(model.APIKey); walletKey != "" { + return walletKey + } + } + + return "" +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 42d6a13f..0a0a6786 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -2,14 +2,13 @@ package trader import ( "fmt" + "github.com/ethereum/go-ethereum/crypto" "nofx/kernel" "nofx/logger" "nofx/mcp" _ "nofx/mcp/payment" _ "nofx/mcp/provider" "nofx/store" - "nofx/wallet" - "github.com/ethereum/go-ethereum/crypto" "nofx/trader/aster" "nofx/trader/binance" "nofx/trader/bitget" @@ -20,6 +19,7 @@ import ( "nofx/trader/kucoin" "nofx/trader/lighter" "nofx/trader/okx" + "nofx/wallet" "sync" "time" ) @@ -90,9 +90,10 @@ type AutoTraderConfig struct { QwenKey string // Custom AI API configuration - CustomAPIURL string - CustomAPIKey string - CustomModelName string + CustomAPIURL string + CustomAPIKey string + CustomModelName string + Claw402WalletKey string // Scan configuration ScanInterval time.Duration // Scan interval (recommended 3 minutes) @@ -148,9 +149,9 @@ type AutoTrader struct { userID string // User ID gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading") claw402WalletAddr string // Claw402 wallet address (derived from private key at start) - consecutiveAIFailures int // Consecutive AI call failures - safeMode bool // Safe mode: no new positions, protect existing ones - safeModeReason string // Why safe mode was activated + consecutiveAIFailures int // Consecutive AI call failures + safeMode bool // Safe mode: no new positions, protect existing ones + safeModeReason string // Why safe mode was activated } // NewAutoTrader creates an automatic trader @@ -335,8 +336,8 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au } // Pass claw402 wallet key to strategy engine so nofxos data requests // are routed through claw402 (reuses the same wallet as AI calls) - var claw402Key string - if config.AIModel == "claw402" && config.CustomAPIKey != "" { + claw402Key := config.Claw402WalletKey + if claw402Key == "" && config.AIModel == "claw402" && config.CustomAPIKey != "" { claw402Key = config.CustomAPIKey } strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig, claw402Key) From 802590c2b94ded9a78b3a9d9d1289a35418aefd6 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Apr 2026 10:57:42 +0800 Subject: [PATCH 6/7] refactor: extract ResolveClaw402WalletKey to store layer and expand OKX margin mode tests - Move duplicated claw402 wallet resolution logic into store.AIModelStore.ResolveClaw402WalletKey - api/strategy.go and manager/trader_manager.go now delegate to the shared method - Add detailed doc comment on OKX SetMarginMode explaining the local-state-only approach and why the legacy /api/v5/account/set-isolated-mode endpoint is not called - Add 3 new test cases: cross mode leverage, OpenShort tdMode, SetTakeProfit tdMode --- api/strategy.go | 33 +--------------- manager/trader_manager.go | 19 ++++----- store/ai_model.go | 37 +++++++++++++++++ trader/okx/trader_account.go | 14 ++++++- trader/okx/trader_margin_mode_test.go | 57 +++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 45 deletions(-) diff --git a/api/strategy.go b/api/strategy.go index f2813efb..64105fa5 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -708,36 +708,5 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string) } func (s *Server) resolveStrategyDataWalletKey(userID, selectedModelID string) (string, error) { - if selectedModelID != "" { - model, err := s.store.AIModel().Get(userID, selectedModelID) - if err != nil { - return "", fmt.Errorf("failed to load selected AI model") - } - - if model.Provider == "claw402" { - walletKey := string(model.APIKey) - if walletKey == "" { - return "", fmt.Errorf("selected claw402 model is missing wallet private key") - } - return walletKey, nil - } - } - - models, err := s.store.AIModel().List(userID) - if err != nil { - return "", fmt.Errorf("failed to load AI models") - } - - for _, model := range models { - if model == nil || model.Provider != "claw402" { - continue - } - - walletKey := string(model.APIKey) - if walletKey != "" { - return walletKey, nil - } - } - - return "", nil + return s.store.AIModel().ResolveClaw402WalletKey(userID, selectedModelID) } diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 8c65941d..36b745b8 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -744,6 +744,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg } func resolveTraderDataWalletKey(st *store.Store, userID string, selectedModel *store.AIModel) string { + // Fast path: selected model is itself a claw402 model. if selectedModel != nil && selectedModel.Provider == "claw402" { if walletKey := string(selectedModel.APIKey); walletKey != "" { return walletKey @@ -754,20 +755,14 @@ func resolveTraderDataWalletKey(st *store.Store, userID string, selectedModel *s return "" } - models, err := st.AIModel().List(userID) + // Fallback: find any configured claw402 model for this user so that paid + // NofxAI data sources work even when a non-claw402 model (e.g. deepseek) is + // selected as the AI brain. + preferredID := "" + walletKey, err := st.AIModel().ResolveClaw402WalletKey(userID, preferredID) if err != nil { logger.Warnf("⚠️ Failed to load claw402 wallet for trader data routing: %v", err) return "" } - - for _, model := range models { - if model == nil || model.Provider != "claw402" { - continue - } - if walletKey := string(model.APIKey); walletKey != "" { - return walletKey - } - } - - return "" + return walletKey } diff --git a/store/ai_model.go b/store/ai_model.go index e1af9d0f..ef577cc1 100644 --- a/store/ai_model.go +++ b/store/ai_model.go @@ -253,6 +253,43 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI } // Create creates an AI model +// ResolveClaw402WalletKey returns the claw402 wallet private key for a user. +// If preferredModelID is non-empty and points to a claw402 model, its key is returned first. +// Otherwise the first enabled claw402 model in the user's model list is used. +// Returns ("", nil) when no claw402 model is configured — callers should treat this as +// "no paid data routing" rather than an error. +func (s *AIModelStore) ResolveClaw402WalletKey(userID, preferredModelID string) (string, error) { + if preferredModelID != "" { + model, err := s.Get(userID, preferredModelID) + if err != nil { + return "", fmt.Errorf("failed to load selected AI model") + } + if model.Provider == "claw402" { + walletKey := string(model.APIKey) + if walletKey == "" { + return "", fmt.Errorf("selected claw402 model is missing wallet private key") + } + return walletKey, nil + } + } + + models, err := s.List(userID) + if err != nil { + return "", fmt.Errorf("failed to load AI models") + } + + for _, model := range models { + if model == nil || model.Provider != "claw402" { + continue + } + if walletKey := string(model.APIKey); walletKey != "" { + return walletKey, nil + } + } + + return "", nil +} + func (s *AIModelStore) Create(userID, id, name, provider string, enabled bool, apiKey, customAPIURL string) error { model := &AIModel{ ID: id, diff --git a/trader/okx/trader_account.go b/trader/okx/trader_account.go index 416a109d..9220b3d8 100644 --- a/trader/okx/trader_account.go +++ b/trader/okx/trader_account.go @@ -80,7 +80,19 @@ func (t *OKXTrader) GetBalance() (map[string]interface{}, error) { return result, nil } -// SetMarginMode sets margin mode +// SetMarginMode configures the margin mode (cross/isolated) that will be applied +// to all subsequent leverage and order requests for this trader instance. +// +// OKX V5 unified accounts do not expose a per-symbol mode-switch endpoint that +// works reliably — the legacy /api/v5/account/set-isolated-mode endpoint returns +// error 51000 ("Parameter isoMode error") when called on a unified account. +// Instead, OKX applies the mode per-request via the mgnMode field on +// /api/v5/account/set-leverage and via the tdMode field on order placement. +// +// This implementation therefore stores the configured mode locally and injects it +// into each subsequent API request, rather than making an API call here. +// NOTE: unlike Binance/Bybit implementations of this interface, no network call +// is made — the method only updates local state. func (t *OKXTrader) SetMarginMode(symbol string, isCrossMargin bool) error { t.isCrossMargin = isCrossMargin mgnMode := t.marginMode() diff --git a/trader/okx/trader_margin_mode_test.go b/trader/okx/trader_margin_mode_test.go index b633a364..e4bbb694 100644 --- a/trader/okx/trader_margin_mode_test.go +++ b/trader/okx/trader_margin_mode_test.go @@ -188,3 +188,60 @@ func TestOKXPlaceLimitOrderUsesConfiguredMarginMode(t *testing.T) { t.Fatalf("expected isolated tdMode, got %#v", orderRequests[0].Body["tdMode"]) } } + +func TestOKXCrossMarginModeUsedInLeverage(t *testing.T) { + rt := &recordingTransport{} + trader := newTestOKXTrader(rt, true) // cross margin + + if err := trader.SetLeverage("BTCUSDT", 10); err != nil { + t.Fatalf("SetLeverage failed: %v", err) + } + + leverageRequests := rt.requestsForPath(okxLeveragePath) + if len(leverageRequests) != 2 { + t.Fatalf("expected 2 leverage requests, got %d", len(leverageRequests)) + } + + for _, req := range leverageRequests { + if req.Body["mgnMode"] != "cross" { + t.Fatalf("expected cross leverage mode, got %#v", req.Body["mgnMode"]) + } + } +} + +func TestOKXOpenShortUsesConfiguredMarginMode(t *testing.T) { + rt := &recordingTransport{} + trader := newTestOKXTrader(rt, false) // isolated + + if _, err := trader.OpenShort("BTCUSDT", 0.1, 5); err != nil { + t.Fatalf("OpenShort failed: %v", err) + } + + orderRequests := rt.requestsForPath(okxOrderPath) + if len(orderRequests) == 0 { + t.Fatal("expected at least one order request") + } + + lastOrder := orderRequests[len(orderRequests)-1] + if lastOrder.Body["tdMode"] != "isolated" { + t.Fatalf("expected isolated tdMode for OpenShort, got %#v", lastOrder.Body["tdMode"]) + } +} + +func TestOKXSetTakeProfitUsesConfiguredMarginMode(t *testing.T) { + rt := &recordingTransport{} + trader := newTestOKXTrader(rt, false) // isolated + + if err := trader.SetTakeProfit("BTCUSDT", "LONG", 0.1, 100000); err != nil { + t.Fatalf("SetTakeProfit failed: %v", err) + } + + algoRequests := rt.requestsForPath(okxAlgoOrderPath) + if len(algoRequests) != 1 { + t.Fatalf("expected 1 algo order request, got %d", len(algoRequests)) + } + + if algoRequests[0].Body["tdMode"] != "isolated" { + t.Fatalf("expected isolated tdMode for SetTakeProfit, got %#v", algoRequests[0].Body["tdMode"]) + } +} From 0a1a2923dcf670087fb894a2b9168a66edbf9ad1 Mon Sep 17 00:00:00 2001 From: Lance Date: Fri, 17 Apr 2026 11:17:17 +0800 Subject: [PATCH 7/7] fix(auth): prevent SetupPage remount from wiping freshly-set auth token (#1481) After #1470 moved routing into react-router, SetupPage is rendered at two different tree positions (top-level guard + /setup Route). When register success flushSync-sets `user`, the top-level guard stops matching and the Route-level SetupPage mounts as a new instance, re-running its cleanup useEffect and removing the auth_token that handlePostAuthSuccess just wrote. Subsequent requests 401 and bounce the user back to /login. Redirect /setup to /welcome when user is already set so SetupPage is never re-mounted during the auth transition. --- web/src/router/AppRoutes.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/router/AppRoutes.tsx b/web/src/router/AppRoutes.tsx index 6469643e..7ee5d2e8 100644 --- a/web/src/router/AppRoutes.tsx +++ b/web/src/router/AppRoutes.tsx @@ -439,7 +439,9 @@ export function AppRoutes() { + ) : systemConfig?.initialized ? ( ) : (