diff --git a/api/strategy.go b/api/strategy.go index 1985c384..64105fa5 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -516,8 +516,17 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) { req.PromptVariant = "balanced" } + 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 - engine := kernel.NewStrategyEngine(&req.Config) + engine := kernel.NewStrategyEngine(&req.Config, claw402WalletKey) // Get candidate coins candidates, err := engine.GetCandidateCoins() @@ -697,3 +706,7 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string) return response, nil } + +func (s *Server) resolveStrategyDataWalletKey(userID, selectedModelID string) (string, error) { + return s.store.AIModel().ResolveClaw402WalletKey(userID, selectedModelID) +} diff --git a/manager/trader_manager.go b/manager/trader_manager.go index ab4b6583..36b745b8 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,26 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg return nil } +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 + } + } + + if st == nil { + return "" + } + + // 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 "" + } + 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/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) 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..9220b3d8 100644 --- a/trader/okx/trader_account.go +++ b/trader/okx/trader_account.go @@ -80,49 +80,42 @@ 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 { - instId := t.convertSymbol(symbol) + t.isCrossMargin = isCrossMargin + mgnMode := t.marginMode() - mgnMode := "isolated" - if isCrossMargin { - mgnMode = "cross" - } - - 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 } // 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 +129,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..e4bbb694 --- /dev/null +++ b/trader/okx/trader_margin_mode_test.go @@ -0,0 +1,247 @@ +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 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) + + 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"]) + } +} + +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"]) + } +} 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", 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 ? ( ) : (