Merge branch 'dev' of https://github.com/NoFxAiOS/nofx into dev

This commit is contained in:
shinchan-zhai
2026-04-17 11:17:24 +08:00
10 changed files with 397 additions and 49 deletions

View File

@@ -516,8 +516,17 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
req.PromptVariant = "balanced" 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 // Create strategy engine to build prompt
engine := kernel.NewStrategyEngine(&req.Config) engine := kernel.NewStrategyEngine(&req.Config, claw402WalletKey)
// Get candidate coins // Get candidate coins
candidates, err := engine.GetCandidateCoins() candidates, err := engine.GetCandidateCoins()
@@ -697,3 +706,7 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
return response, nil return response, nil
} }
func (s *Server) resolveStrategyDataWalletKey(userID, selectedModelID string) (string, error) {
return s.store.AIModel().ResolveClaw402WalletKey(userID, selectedModelID)
}

View File

@@ -703,6 +703,8 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
traderConfig.CustomAPIKey = string(aiModelCfg.APIKey) traderConfig.CustomAPIKey = string(aiModelCfg.APIKey)
} }
traderConfig.Claw402WalletKey = resolveTraderDataWalletKey(st, traderCfg.UserID, aiModelCfg)
// Create trader instance // Create trader instance
at, err := trader.NewAutoTrader(traderConfig, st, traderCfg.UserID) at, err := trader.NewAutoTrader(traderConfig, st, traderCfg.UserID)
if err != nil { if err != nil {
@@ -741,3 +743,26 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
return nil 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
}

View File

@@ -253,6 +253,43 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
} }
// Create creates an AI model // 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 { func (s *AIModelStore) Create(userID, id, name, provider string, enabled bool, apiKey, customAPIURL string) error {
model := &AIModel{ model := &AIModel{
ID: id, ID: id,

View File

@@ -2,14 +2,13 @@ package trader
import ( import (
"fmt" "fmt"
"github.com/ethereum/go-ethereum/crypto"
"nofx/kernel" "nofx/kernel"
"nofx/logger" "nofx/logger"
"nofx/mcp" "nofx/mcp"
_ "nofx/mcp/payment" _ "nofx/mcp/payment"
_ "nofx/mcp/provider" _ "nofx/mcp/provider"
"nofx/store" "nofx/store"
"nofx/wallet"
"github.com/ethereum/go-ethereum/crypto"
"nofx/trader/aster" "nofx/trader/aster"
"nofx/trader/binance" "nofx/trader/binance"
"nofx/trader/bitget" "nofx/trader/bitget"
@@ -20,6 +19,7 @@ import (
"nofx/trader/kucoin" "nofx/trader/kucoin"
"nofx/trader/lighter" "nofx/trader/lighter"
"nofx/trader/okx" "nofx/trader/okx"
"nofx/wallet"
"sync" "sync"
"time" "time"
) )
@@ -90,9 +90,10 @@ type AutoTraderConfig struct {
QwenKey string QwenKey string
// Custom AI API configuration // Custom AI API configuration
CustomAPIURL string CustomAPIURL string
CustomAPIKey string CustomAPIKey string
CustomModelName string CustomModelName string
Claw402WalletKey string
// Scan configuration // Scan configuration
ScanInterval time.Duration // Scan interval (recommended 3 minutes) ScanInterval time.Duration // Scan interval (recommended 3 minutes)
@@ -148,9 +149,9 @@ type AutoTrader struct {
userID string // User ID userID string // User ID
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading") gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
claw402WalletAddr string // Claw402 wallet address (derived from private key at start) claw402WalletAddr string // Claw402 wallet address (derived from private key at start)
consecutiveAIFailures int // Consecutive AI call failures consecutiveAIFailures int // Consecutive AI call failures
safeMode bool // Safe mode: no new positions, protect existing ones safeMode bool // Safe mode: no new positions, protect existing ones
safeModeReason string // Why safe mode was activated safeModeReason string // Why safe mode was activated
} }
// NewAutoTrader creates an automatic trader // 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 // Pass claw402 wallet key to strategy engine so nofxos data requests
// are routed through claw402 (reuses the same wallet as AI calls) // are routed through claw402 (reuses the same wallet as AI calls)
var claw402Key string claw402Key := config.Claw402WalletKey
if config.AIModel == "claw402" && config.CustomAPIKey != "" { if claw402Key == "" && config.AIModel == "claw402" && config.CustomAPIKey != "" {
claw402Key = config.CustomAPIKey claw402Key = config.CustomAPIKey
} }
strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig, claw402Key) strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig, claw402Key)

View File

@@ -324,6 +324,17 @@ func (at *AutoTrader) InitializeGrid() error {
at.gridState.IsInitialized = true 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 // CRITICAL: Set leverage on exchange before trading
if err := at.trader.SetLeverage(gridConfig.Symbol, gridConfig.Leverage); err != nil { 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) logger.Warnf("[Grid] Failed to set leverage %dx on exchange: %v", gridConfig.Leverage, err)

View File

@@ -41,7 +41,7 @@ type OKXTrader struct {
secretKey string secretKey string
passphrase string passphrase string
// Margin mode setting // Margin mode setting used for new orders and leverage changes.
isCrossMargin bool isCrossMargin bool
// Position mode: "long_short_mode" (hedge) or "net_mode" (one-way) // Position mode: "long_short_mode" (hedge) or "net_mode" (one-way)
@@ -121,6 +121,7 @@ func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader {
apiKey: apiKey, apiKey: apiKey,
secretKey: secretKey, secretKey: secretKey,
passphrase: passphrase, passphrase: passphrase,
isCrossMargin: true,
httpClient: httpClient, httpClient: httpClient,
cacheDuration: 15 * time.Second, cacheDuration: 15 * time.Second,
instrumentsCache: make(map[string]*OKXInstrument), 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 return trader
} }
func (t *OKXTrader) marginMode() string {
if t.isCrossMargin {
return "cross"
}
return "isolated"
}
// detectPositionMode gets current position mode from account config // detectPositionMode gets current position mode from account config
func (t *OKXTrader) detectPositionMode() error { func (t *OKXTrader) detectPositionMode() error {
data, err := t.doRequest("GET", okxAccountConfigPath, nil) data, err := t.doRequest("GET", okxAccountConfigPath, nil)

View File

@@ -80,49 +80,42 @@ func (t *OKXTrader) GetBalance() (map[string]interface{}, error) {
return result, nil 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 { func (t *OKXTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
instId := t.convertSymbol(symbol) t.isCrossMargin = isCrossMargin
mgnMode := t.marginMode()
mgnMode := "isolated" // OKX V5 unified account applies cross/isolated per order via tdMode,
if isCrossMargin { // while leverage uses mgnMode on /account/set-leverage.
mgnMode = "cross" // 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)
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)
return nil return nil
} }
// SetLeverage sets leverage // SetLeverage sets leverage
func (t *OKXTrader) SetLeverage(symbol string, leverage int) error { func (t *OKXTrader) SetLeverage(symbol string, leverage int) error {
instId := t.convertSymbol(symbol) instId := t.convertSymbol(symbol)
marginMode := t.marginMode()
// Set leverage for both long and short // Set leverage for both long and short
for _, posSide := range []string{"long", "short"} { for _, posSide := range []string{"long", "short"} {
body := map[string]interface{}{ body := map[string]interface{}{
"instId": instId, "instId": instId,
"lever": strconv.Itoa(leverage), "lever": strconv.Itoa(leverage),
"mgnMode": "cross", "mgnMode": marginMode,
"posSide": posSide, "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 return nil
} }

View File

@@ -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"])
}
}

View File

@@ -41,9 +41,11 @@ func (t *OKXTrader) OpenLong(symbol string, quantity float64, leverage int) (map
szStr = t.formatSize(sz, inst) szStr = t.formatSize(sz, inst)
} }
marginMode := t.marginMode()
body := map[string]interface{}{ body := map[string]interface{}{
"instId": instId, "instId": instId,
"tdMode": "cross", "tdMode": marginMode,
"side": "buy", "side": "buy",
"posSide": "long", "posSide": "long",
"ordType": "market", "ordType": "market",
@@ -118,9 +120,11 @@ func (t *OKXTrader) OpenShort(symbol string, quantity float64, leverage int) (ma
szStr = t.formatSize(sz, inst) szStr = t.formatSize(sz, inst)
} }
marginMode := t.marginMode()
body := map[string]interface{}{ body := map[string]interface{}{
"instId": instId, "instId": instId,
"tdMode": "cross", "tdMode": marginMode,
"side": "sell", "side": "sell",
"posSide": "short", "posSide": "short",
"ordType": "market", "ordType": "market",
@@ -410,9 +414,11 @@ func (t *OKXTrader) SetStopLoss(symbol string, positionSide string, quantity, st
posSide = "short" posSide = "short"
} }
marginMode := t.marginMode()
body := map[string]interface{}{ body := map[string]interface{}{
"instId": instId, "instId": instId,
"tdMode": "cross", "tdMode": marginMode,
"side": side, "side": side,
"posSide": posSide, "posSide": posSide,
"ordType": "conditional", "ordType": "conditional",
@@ -453,9 +459,11 @@ func (t *OKXTrader) SetTakeProfit(symbol string, positionSide string, quantity,
posSide = "short" posSide = "short"
} }
marginMode := t.marginMode()
body := map[string]interface{}{ body := map[string]interface{}{
"instId": instId, "instId": instId,
"tdMode": "cross", "tdMode": marginMode,
"side": side, "side": side,
"posSide": posSide, "posSide": posSide,
"ordType": "conditional", "ordType": "conditional",
@@ -815,9 +823,11 @@ func (t *OKXTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitO
posSide = "short" posSide = "short"
} }
marginMode := t.marginMode()
body := map[string]interface{}{ body := map[string]interface{}{
"instId": instId, "instId": instId,
"tdMode": "cross", "tdMode": marginMode,
"side": side, "side": side,
"posSide": posSide, "posSide": posSide,
"ordType": "limit", "ordType": "limit",

View File

@@ -439,7 +439,9 @@ export function AppRoutes() {
<Route <Route
path={ROUTES.setup} path={ROUTES.setup}
element={ element={
systemConfig?.initialized ? ( user ? (
<Navigate to={ROUTES.welcome} replace />
) : systemConfig?.initialized ? (
<Navigate to={ROUTES.login} replace /> <Navigate to={ROUTES.login} replace />
) : ( ) : (
<SetupPage /> <SetupPage />