Files
nofx/manager/trader_manager.go
T
Lance 7ae5bf8247 release: merge dev into main (2026-04-17) (#1484)
* feat(store): prevent deletion of active strategies and update translations (#1461)

Co-authored-by: Dean <afei.wuhao@gmail.com>

* fix: allow model switching without re-entering wallet key

Users with existing wallets could not switch AI models because the
"Start Trading" button required a valid private key even when one was
already configured. Now the button is enabled when hasExistingWallet
is true, and handleSubmit passes an empty key so the backend preserves
the existing key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: replace window.location with useNavigate for routing in auth components (#1470)

Co-authored-by: Dean <afei.wuhao@gmail.com>

* feat(trader): implement margin mode handling for order and leverage settings

* refactor(trader): update SetMarginMode to avoid legacy endpoint and improve logging

* 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.

* 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.

* 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.

* feat(claw402): preflight USDC balance before AI calls (#1479)

* chore: ignore nofx-server build artifact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(claw402): preflight USDC balance before AI calls

Short-circuit claw402 Call/CallWithRequestFull when the wallet balance
can't cover the estimated cost of the call, surfacing ErrInsufficientFunds
instead of letting x402 fail mid-flight after the sign step.

- wallet: cached balance lookup (30s TTL, per-address mutex) to avoid
  hammering the Base RPC; separate error-returning and display-only APIs
  so callers can distinguish zero balance from an unreachable RPC.
- claw402: 1.5× safety multiplier on the flat per-call estimate, 4.0×
  for reasoner models whose chain-of-thought cost can blow past the
  flat rate. Fail-open on RPC errors — x402 still gates actually-empty
  wallets, and we prefer availability over extra strictness.
- shortAddr redacts the wallet in error strings to avoid leaking the
  full address into telemetry bundles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(telemetry): report token usage for SSE streaming paths (#1475)

* fix(telemetry): report token usage for SSE streaming paths

ParseSSEStream already parsed the usage block from SSE chunks but only
printed it, so claw402 streaming calls (and native streaming) never
fired TokenUsageCallback. GA4 therefore undercounted AI usage on the
streaming path.

Return the parsed usage from ParseSSEStream and have both callers fire
the callback with their own Provider/Model.

* chore: drop leftover debug Printf in ParseSSEStream

Telemetry is now wired via TokenUsageCallback, so the Printf is
redundant noise in the stream path.

* fix(gemini): update default model to gemini-3.1-pro

Google discontinued gemini-3-pro-preview on 2026-03-26 and directs all
callers to gemini-3.1-pro / gemini-3.1-pro-preview. Users on their own
API key were getting errors from the native Gemini endpoint because the
provider default pointed at the retired ID. Claw402 was unaffected
because its route map already used gemini-3.1-pro.

Align both the native provider default and the handler's preset list
with gemini-3.1-pro so every code path sends a live model ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 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

* 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.

* fix(wallet): handle JSON-RPC null error field in balance query

Some RPC implementations return explicit "error": null on success.
json.RawMessage deserializes this as the 4-byte literal "null", so
len() > 0 was true, causing every balance query to fail with
"rpc error: null". Skip the null literal to avoid false positives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: deanokk <wuhao@vergex.trade>
Co-authored-by: Dean <afei.wuhao@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: root <root@localhost.localdomain>
2026-04-17 19:13:35 +08:00

769 lines
24 KiB
Go

package manager
import (
"context"
"fmt"
"nofx/logger"
"nofx/store"
"nofx/trader"
"sort"
"sync"
"time"
)
// CompetitionCache competition data cache
type CompetitionCache struct {
data map[string]interface{}
timestamp time.Time
mu sync.RWMutex
}
// TraderManager manages multiple trader instances
type TraderManager struct {
traders map[string]*trader.AutoTrader // key: trader ID
loadErrors map[string]error // key: trader ID, stores last load error
competitionCache *CompetitionCache
mu sync.RWMutex
}
// NewTraderManager creates a trader manager
func NewTraderManager() *TraderManager {
return &TraderManager{
traders: make(map[string]*trader.AutoTrader),
loadErrors: make(map[string]error),
competitionCache: &CompetitionCache{
data: make(map[string]interface{}),
},
}
}
// GetLoadError returns the last load error for a trader
func (tm *TraderManager) GetLoadError(traderID string) error {
tm.mu.RLock()
defer tm.mu.RUnlock()
return tm.loadErrors[traderID]
}
// GetTrader retrieves a trader by ID
func (tm *TraderManager) GetTrader(id string) (*trader.AutoTrader, error) {
tm.mu.RLock()
defer tm.mu.RUnlock()
t, exists := tm.traders[id]
if !exists {
return nil, fmt.Errorf("trader ID '%s' does not exist", id)
}
return t, nil
}
// GetAllTraders retrieves all traders
func (tm *TraderManager) GetAllTraders() map[string]*trader.AutoTrader {
tm.mu.RLock()
defer tm.mu.RUnlock()
result := make(map[string]*trader.AutoTrader)
for id, t := range tm.traders {
result[id] = t
}
return result
}
// GetTraderIDs retrieves all trader IDs
func (tm *TraderManager) GetTraderIDs() []string {
tm.mu.RLock()
defer tm.mu.RUnlock()
ids := make([]string, 0, len(tm.traders))
for id := range tm.traders {
ids = append(ids, id)
}
return ids
}
// StartAll starts all traders
func (tm *TraderManager) StartAll() {
tm.mu.RLock()
defer tm.mu.RUnlock()
logger.Info("🚀 Starting all traders...")
for id, t := range tm.traders {
go func(traderID string, at *trader.AutoTrader) {
logger.Infof("▶️ Starting %s...", at.GetName())
if err := at.Run(); err != nil {
logger.Infof("❌ %s runtime error: %v", at.GetName(), err)
}
}(id, t)
}
}
// StopAll stops all traders
func (tm *TraderManager) StopAll() {
tm.mu.RLock()
defer tm.mu.RUnlock()
logger.Info("⏹ Stopping all traders...")
for _, t := range tm.traders {
t.Stop()
}
}
// AutoStartRunningTraders automatically starts traders marked as running in the database
func (tm *TraderManager) AutoStartRunningTraders(st *store.Store) {
// Get all trader configurations (single query)
traderList, err := st.Trader().ListAll()
if err != nil {
logger.Infof("⚠️ Failed to get trader list: %v", err)
return
}
// Build set of running trader IDs
runningTraderIDs := make(map[string]bool)
for _, traderCfg := range traderList {
if traderCfg.IsRunning {
runningTraderIDs[traderCfg.ID] = true
}
}
if len(runningTraderIDs) == 0 {
logger.Info("📋 No traders to auto-restore")
return
}
tm.mu.RLock()
defer tm.mu.RUnlock()
startedCount := 0
for id, t := range tm.traders {
if runningTraderIDs[id] {
go func(traderID string, at *trader.AutoTrader) {
logger.Infof("▶️ Auto-restoring %s...", at.GetName())
if err := at.Run(); err != nil {
logger.Infof("❌ %s runtime error: %v", at.GetName(), err)
}
}(id, t)
startedCount++
}
}
if startedCount > 0 {
logger.Infof("✓ Auto-restored %d traders", startedCount)
}
}
// GetComparisonData retrieves comparison data
func (tm *TraderManager) GetComparisonData() (map[string]interface{}, error) {
tm.mu.RLock()
defer tm.mu.RUnlock()
comparison := make(map[string]interface{})
traders := make([]map[string]interface{}, 0, len(tm.traders))
for _, t := range tm.traders {
account, err := t.GetAccountInfo()
if err != nil {
continue
}
status := t.GetStatus()
traders = append(traders, map[string]interface{}{
"trader_id": t.GetID(),
"trader_name": t.GetName(),
"ai_model": t.GetAIModel(),
"exchange": t.GetExchange(),
"total_equity": account["total_equity"],
"total_pnl": account["total_pnl"],
"total_pnl_pct": account["total_pnl_pct"],
"position_count": account["position_count"],
"margin_used_pct": account["margin_used_pct"],
"call_count": status["call_count"],
"is_running": status["is_running"],
})
}
comparison["traders"] = traders
comparison["count"] = len(traders)
return comparison, nil
}
// GetCompetitionData retrieves competition data (all traders across platform)
func (tm *TraderManager) GetCompetitionData() (map[string]interface{}, error) {
// Check if cache is valid (within 30 seconds)
tm.competitionCache.mu.RLock()
if time.Since(tm.competitionCache.timestamp) < 30*time.Second && len(tm.competitionCache.data) > 0 {
// Return cached data
cachedData := make(map[string]interface{})
for k, v := range tm.competitionCache.data {
cachedData[k] = v
}
tm.competitionCache.mu.RUnlock()
logger.Infof("📋 Returning competition data cache (cache age: %.1fs)", time.Since(tm.competitionCache.timestamp).Seconds())
return cachedData, nil
}
tm.competitionCache.mu.RUnlock()
tm.mu.RLock()
// Get all trader list (only those with ShowInCompetition = true)
allTraders := make([]*trader.AutoTrader, 0, len(tm.traders))
for id, t := range tm.traders {
if t.GetShowInCompetition() {
allTraders = append(allTraders, t)
logger.Infof("📋 Competition data includes trader: %s (%s)", t.GetName(), id)
} else {
logger.Infof("📋 Competition data excludes trader (hidden): %s (%s)", t.GetName(), id)
}
}
tm.mu.RUnlock()
logger.Infof("🔄 Refreshing competition data, trader count: %d", len(allTraders))
// Concurrently fetch trader data
traders := tm.getConcurrentTraderData(allTraders)
// Sort by profit rate (descending)
sort.Slice(traders, func(i, j int) bool {
pnlPctI, okI := traders[i]["total_pnl_pct"].(float64)
pnlPctJ, okJ := traders[j]["total_pnl_pct"].(float64)
if !okI {
pnlPctI = 0
}
if !okJ {
pnlPctJ = 0
}
return pnlPctI > pnlPctJ
})
// Limit to top 50
totalCount := len(traders)
limit := 50
if len(traders) > limit {
traders = traders[:limit]
}
comparison := make(map[string]interface{})
comparison["traders"] = traders
comparison["count"] = len(traders)
comparison["total_count"] = totalCount // Total number of traders
// Update cache
tm.competitionCache.mu.Lock()
tm.competitionCache.data = comparison
tm.competitionCache.timestamp = time.Now()
tm.competitionCache.mu.Unlock()
return comparison, nil
}
// getConcurrentTraderData concurrently fetches data for multiple traders
func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) []map[string]interface{} {
type traderResult struct {
index int
data map[string]interface{}
}
// Create result channel
resultChan := make(chan traderResult, len(traders))
// Concurrently fetch data for each trader
for i, t := range traders {
go func(index int, trader *trader.AutoTrader) {
// Set timeout to 10 seconds for single trader (increased from 3s for DEX reliability)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Use channel for timeout control
accountChan := make(chan map[string]interface{}, 1)
errorChan := make(chan error, 1)
go func() {
account, err := trader.GetAccountInfo()
if err != nil {
errorChan <- err
} else {
accountChan <- account
}
}()
status := trader.GetStatus()
var traderData map[string]interface{}
select {
case account := <-accountChan:
// Successfully got account info
traderData = map[string]interface{}{
"trader_id": trader.GetID(),
"trader_name": trader.GetName(),
"ai_model": trader.GetAIModel(),
"exchange": trader.GetExchange(),
"total_equity": account["total_equity"],
"total_pnl": account["total_pnl"],
"total_pnl_pct": account["total_pnl_pct"],
"position_count": account["position_count"],
"margin_used_pct": account["margin_used_pct"],
"is_running": status["is_running"],
"system_prompt_template": trader.GetSystemPromptTemplate(),
}
case err := <-errorChan:
// Failed to get account info
logger.Infof("⚠️ Failed to get account info for trader %s (%s/%s): %v", trader.GetName(), trader.GetID(), trader.GetExchange(), err)
traderData = map[string]interface{}{
"trader_id": trader.GetID(),
"trader_name": trader.GetName(),
"ai_model": trader.GetAIModel(),
"exchange": trader.GetExchange(),
"total_equity": 0.0,
"total_pnl": 0.0,
"total_pnl_pct": 0.0,
"position_count": 0,
"margin_used_pct": 0.0,
"is_running": status["is_running"],
"system_prompt_template": trader.GetSystemPromptTemplate(),
"error": "Failed to get account data",
}
case <-ctx.Done():
// Timeout
logger.Infof("⏰ Timeout (10s) getting account info for trader %s (%s/%s)", trader.GetName(), trader.GetID(), trader.GetExchange())
traderData = map[string]interface{}{
"trader_id": trader.GetID(),
"trader_name": trader.GetName(),
"ai_model": trader.GetAIModel(),
"exchange": trader.GetExchange(),
"total_equity": 0.0,
"total_pnl": 0.0,
"total_pnl_pct": 0.0,
"position_count": 0,
"margin_used_pct": 0.0,
"is_running": status["is_running"],
"system_prompt_template": trader.GetSystemPromptTemplate(),
"error": "Request timeout",
}
}
resultChan <- traderResult{index: index, data: traderData}
}(i, t)
}
// Collect all results
results := make([]map[string]interface{}, len(traders))
for i := 0; i < len(traders); i++ {
result := <-resultChan
results[result.index] = result.data
}
return results
}
// GetTopTradersData retrieves top 5 traders data (for performance comparison)
func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) {
// Reuse competition data cache, as top 5 is filtered from all data
competitionData, err := tm.GetCompetitionData()
if err != nil {
return nil, err
}
// Extract top 5 from competition data
allTraders, ok := competitionData["traders"].([]map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid competition data format")
}
// Limit to top 5
limit := 5
topTraders := allTraders
if len(allTraders) > limit {
topTraders = allTraders[:limit]
}
result := map[string]interface{}{
"traders": topTraders,
"count": len(topTraders),
}
return result, nil
}
// RemoveTrader removes a trader from memory (does not affect database)
// Used to force reload when updating trader configuration
// If the trader is running, it will be stopped first
func (tm *TraderManager) RemoveTrader(traderID string) {
tm.mu.Lock()
defer tm.mu.Unlock()
if t, exists := tm.traders[traderID]; exists {
// Stop the trader if it's running (this ensures the goroutine exits)
status := t.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
logger.Infof("⏹ Stopping trader %s before removing from memory...", traderID)
t.Stop()
}
delete(tm.traders, traderID)
logger.Infof("✓ Trader %s removed from memory", traderID)
}
}
// LoadUserTradersFromStore loads traders from store for a specific user to memory
func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string) error {
tm.mu.Lock()
defer tm.mu.Unlock()
// Get all traders for the specified user
traders, err := st.Trader().List(userID)
if err != nil {
return fmt.Errorf("failed to get trader list for user %s: %w", userID, err)
}
logger.Infof("📋 Loading trader configurations for user %s: %d traders", userID, len(traders))
// Get AI model and exchange lists (query only once outside loop)
aiModels, err := st.AIModel().List(userID)
if err != nil {
logger.Infof("⚠️ Failed to get AI model config for user %s: %v", userID, err)
return fmt.Errorf("failed to get AI model config: %w", err)
}
exchanges, err := st.Exchange().List(userID)
if err != nil {
logger.Infof("⚠️ Failed to get exchange config for user %s: %v", userID, err)
return fmt.Errorf("failed to get exchange config: %w", err)
}
// Load configuration for each trader
for _, traderCfg := range traders {
// Check if this trader is already loaded
if _, exists := tm.traders[traderCfg.ID]; exists {
// Trader already loaded - this is normal, no need to log
continue
}
// Find AI model config from already queried list
var aiModelCfg *store.AIModel
for _, model := range aiModels {
if model.ID == traderCfg.AIModelID {
aiModelCfg = model
break
}
}
if aiModelCfg == nil {
for _, model := range aiModels {
if model.Provider == traderCfg.AIModelID {
aiModelCfg = model
break
}
}
}
if aiModelCfg == nil {
logger.Infof("⚠️ AI model %s for trader %s does not exist, skipping", traderCfg.AIModelID, traderCfg.Name)
continue
}
if !aiModelCfg.Enabled {
logger.Infof("⚠️ AI model %s for trader %s is not enabled, skipping", traderCfg.AIModelID, traderCfg.Name)
continue
}
// Find exchange config from already queried list
var exchangeCfg *store.Exchange
for _, exchange := range exchanges {
if exchange.ID == traderCfg.ExchangeID {
exchangeCfg = exchange
break
}
}
if exchangeCfg == nil {
logger.Infof("⚠️ Exchange %s for trader %s does not exist, skipping", traderCfg.ExchangeID, traderCfg.Name)
continue
}
if !exchangeCfg.Enabled {
logger.Infof("⚠️ Exchange %s for trader %s is not enabled, skipping", traderCfg.ExchangeID, traderCfg.Name)
continue
}
// Use existing method to load trader
logger.Infof("📦 Loading trader %s (AI Model: %s, Exchange: %s/%s, Strategy ID: %s)", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ExchangeType, exchangeCfg.AccountName, traderCfg.StrategyID)
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
if err != nil {
logger.Infof("❌ Failed to load trader %s: %v", traderCfg.Name, err)
// Save error for later retrieval
tm.loadErrors[traderCfg.ID] = err
} else {
// Clear any previous error on success
delete(tm.loadErrors, traderCfg.ID)
}
}
return nil
}
// LoadTradersFromStore loads all traders from store to memory (new API)
func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
tm.mu.Lock()
defer tm.mu.Unlock()
// Get all users
userIDs, err := st.User().GetAllIDs()
if err != nil {
return fmt.Errorf("failed to get user list: %w", err)
}
logger.Infof("📋 Found %d users, loading all trader configurations...", len(userIDs))
var allTraders []*store.Trader
for _, userID := range userIDs {
// Get traders for each user
traders, err := st.Trader().List(userID)
if err != nil {
logger.Infof("⚠️ Failed to get traders for user %s: %v", userID, err)
continue
}
logger.Infof("📋 User %s: %d traders", userID, len(traders))
allTraders = append(allTraders, traders...)
}
logger.Infof("📋 Total loaded trader configurations: %d", len(allTraders))
// Get AI model and exchange configs for each trader
for _, traderCfg := range allTraders {
// Get AI model config
aiModels, err := st.AIModel().List(traderCfg.UserID)
if err != nil {
logger.Infof("⚠️ Failed to get AI model config: %v", err)
continue
}
var aiModelCfg *store.AIModel
// Prioritize exact match on model.ID
for _, model := range aiModels {
if model.ID == traderCfg.AIModelID {
aiModelCfg = model
break
}
}
// If no exact match, try matching provider (for backward compatibility)
if aiModelCfg == nil {
for _, model := range aiModels {
if model.Provider == traderCfg.AIModelID {
aiModelCfg = model
logger.Infof("⚠️ Trader %s using legacy provider match: %s -> %s", traderCfg.Name, traderCfg.AIModelID, model.ID)
break
}
}
}
if aiModelCfg == nil {
logger.Infof("⚠️ AI model %s for trader %s does not exist, skipping", traderCfg.AIModelID, traderCfg.Name)
continue
}
if !aiModelCfg.Enabled {
logger.Infof("⚠️ AI model %s for trader %s is not enabled, skipping", traderCfg.AIModelID, traderCfg.Name)
continue
}
// Get exchange config
exchanges, err := st.Exchange().List(traderCfg.UserID)
if err != nil {
logger.Infof("⚠️ Failed to get exchange config: %v", err)
continue
}
var exchangeCfg *store.Exchange
for _, exchange := range exchanges {
if exchange.ID == traderCfg.ExchangeID {
exchangeCfg = exchange
break
}
}
if exchangeCfg == nil {
logger.Infof("⚠️ Exchange %s for trader %s does not exist, skipping", traderCfg.ExchangeID, traderCfg.Name)
continue
}
if !exchangeCfg.Enabled {
logger.Infof("⚠️ Exchange %s for trader %s is not enabled, skipping", traderCfg.ExchangeID, traderCfg.Name)
continue
}
// Add to TraderManager (ai500APIURL/oiTopAPIURL already obtained from strategy config)
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
if err != nil {
logger.Infof("❌ Failed to add trader %s: %v", traderCfg.Name, err)
continue
}
}
logger.Infof("✓ Successfully loaded %d traders to memory", len(tm.traders))
return nil
}
// addTraderFromStore internal method: adds trader from store configuration
func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg *store.AIModel, exchangeCfg *store.Exchange, st *store.Store) error {
if _, exists := tm.traders[traderCfg.ID]; exists {
return fmt.Errorf("trader ID '%s' already exists", traderCfg.ID)
}
// Load strategy config (must have strategy)
var strategyConfig *store.StrategyConfig
if traderCfg.StrategyID != "" {
strategy, err := st.Strategy().Get(traderCfg.UserID, traderCfg.StrategyID)
if err != nil {
return fmt.Errorf("failed to load strategy %s for trader %s: %w", traderCfg.StrategyID, traderCfg.Name, err)
}
// Parse JSON config
strategyConfig, err = strategy.ParseConfig()
if err != nil {
return fmt.Errorf("failed to parse strategy config for trader %s: %w", traderCfg.Name, err)
}
logger.Infof("✓ Trader %s loaded strategy config: %s", traderCfg.Name, strategy.Name)
} else {
return fmt.Errorf("trader %s has no strategy configured", traderCfg.Name)
}
// Build AutoTraderConfig (ai500APIURL/oiTopAPIURL obtained from strategy config, used in StrategyEngine)
traderConfig := trader.AutoTraderConfig{
ID: traderCfg.ID,
Name: traderCfg.Name,
AIModel: aiModelCfg.Provider,
Exchange: exchangeCfg.ExchangeType, // Exchange type: binance/bybit/okx/etc
ExchangeID: exchangeCfg.ID, // Exchange account UUID (for multi-account)
BinanceAPIKey: "",
BinanceSecretKey: "",
HyperliquidPrivateKey: "",
HyperliquidTestnet: exchangeCfg.Testnet,
UseQwen: aiModelCfg.Provider == "qwen",
DeepSeekKey: "",
QwenKey: "",
CustomAPIURL: aiModelCfg.CustomAPIURL,
CustomModelName: aiModelCfg.CustomModelName,
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
InitialBalance: traderCfg.InitialBalance,
IsCrossMargin: traderCfg.IsCrossMargin,
ShowInCompetition: traderCfg.ShowInCompetition,
StrategyConfig: strategyConfig,
}
logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v",
traderCfg.Name, traderCfg.ScanIntervalMinutes, traderConfig.ScanInterval)
// Set API keys based on exchange type (convert EncryptedString to string)
switch exchangeCfg.ExchangeType {
case "binance":
traderConfig.BinanceAPIKey = string(exchangeCfg.APIKey)
traderConfig.BinanceSecretKey = string(exchangeCfg.SecretKey)
case "bybit":
traderConfig.BybitAPIKey = string(exchangeCfg.APIKey)
traderConfig.BybitSecretKey = string(exchangeCfg.SecretKey)
case "okx":
traderConfig.OKXAPIKey = string(exchangeCfg.APIKey)
traderConfig.OKXSecretKey = string(exchangeCfg.SecretKey)
traderConfig.OKXPassphrase = string(exchangeCfg.Passphrase)
case "bitget":
traderConfig.BitgetAPIKey = string(exchangeCfg.APIKey)
traderConfig.BitgetSecretKey = string(exchangeCfg.SecretKey)
traderConfig.BitgetPassphrase = string(exchangeCfg.Passphrase)
case "gate":
traderConfig.GateAPIKey = string(exchangeCfg.APIKey)
traderConfig.GateSecretKey = string(exchangeCfg.SecretKey)
case "kucoin":
traderConfig.KuCoinAPIKey = string(exchangeCfg.APIKey)
traderConfig.KuCoinSecretKey = string(exchangeCfg.SecretKey)
traderConfig.KuCoinPassphrase = string(exchangeCfg.Passphrase)
case "hyperliquid":
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
traderConfig.HyperliquidUnifiedAcct = exchangeCfg.HyperliquidUnifiedAcct
case "aster":
traderConfig.AsterUser = exchangeCfg.AsterUser
traderConfig.AsterSigner = exchangeCfg.AsterSigner
traderConfig.AsterPrivateKey = string(exchangeCfg.AsterPrivateKey)
case "lighter":
traderConfig.LighterPrivateKey = string(exchangeCfg.LighterPrivateKey)
traderConfig.LighterWalletAddr = exchangeCfg.LighterWalletAddr
traderConfig.LighterAPIKeyPrivateKey = string(exchangeCfg.LighterAPIKeyPrivateKey)
traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex
traderConfig.LighterTestnet = exchangeCfg.Testnet
case "indodax":
traderConfig.IndodaxAPIKey = string(exchangeCfg.APIKey)
traderConfig.IndodaxSecretKey = string(exchangeCfg.SecretKey)
}
// Set API keys based on AI model (convert EncryptedString to string)
switch aiModelCfg.Provider {
case "qwen":
traderConfig.QwenKey = string(aiModelCfg.APIKey)
case "deepseek":
traderConfig.DeepSeekKey = string(aiModelCfg.APIKey)
default:
// For other providers (grok, openai, claude, gemini, kimi, etc.), use CustomAPIKey
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 {
return fmt.Errorf("failed to create trader: %w", err)
}
// Set custom prompt (if exists)
if traderCfg.CustomPrompt != "" {
at.SetCustomPrompt(traderCfg.CustomPrompt)
at.SetOverrideBasePrompt(traderCfg.OverrideBasePrompt)
if traderCfg.OverrideBasePrompt {
logger.Infof("✓ Set custom trading strategy prompt (overriding base prompt)")
} else {
logger.Infof("✓ Set custom trading strategy prompt (supplementing base prompt)")
}
}
tm.traders[traderCfg.ID] = at
logger.Infof("✓ Trader '%s' (%s + %s/%s) loaded to memory", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ExchangeType, exchangeCfg.AccountName)
// Auto-start if trader was running before shutdown
if traderCfg.IsRunning {
logger.Infof("🔄 Auto-starting trader '%s' (was running before shutdown)...", traderCfg.Name)
go func(trader *trader.AutoTrader, traderName, traderID, userID string) {
if err := trader.Run(); err != nil {
logger.Warnf("⚠️ Trader '%s' stopped with error: %v", traderName, err)
// Update database to reflect stopped state
if st != nil {
_ = st.Trader().UpdateStatus(userID, traderID, false)
}
}
}(at, traderCfg.Name, traderCfg.ID, traderCfg.UserID)
logger.Infof("✅ Trader '%s' auto-started successfully", traderCfg.Name)
}
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
}