Files
nofx/trader/auto_trader.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

666 lines
23 KiB
Go

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/trader/aster"
"nofx/trader/binance"
"nofx/trader/bitget"
"nofx/trader/bybit"
"nofx/trader/gate"
"nofx/trader/hyperliquid"
"nofx/trader/indodax"
"nofx/trader/kucoin"
"nofx/trader/lighter"
"nofx/trader/okx"
"nofx/wallet"
"sync"
"time"
)
// AutoTraderConfig auto trading configuration (simplified version - AI makes all decisions)
type AutoTraderConfig struct {
// Trader identification
ID string // Trader unique identifier (for log directory, etc.)
Name string // Trader display name
AIModel string // AI model: "qwen" or "deepseek"
// Trading platform selection
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "gate", "hyperliquid", "aster" or "lighter"
ExchangeID string // Exchange account UUID (for multi-account support)
// Binance API configuration
BinanceAPIKey string
BinanceSecretKey string
// Bybit API configuration
BybitAPIKey string
BybitSecretKey string
// OKX API configuration
OKXAPIKey string
OKXSecretKey string
OKXPassphrase string
// Bitget API configuration
BitgetAPIKey string
BitgetSecretKey string
BitgetPassphrase string
// Gate API configuration
GateAPIKey string
GateSecretKey string
// KuCoin API configuration
KuCoinAPIKey string
KuCoinSecretKey string
KuCoinPassphrase string
// Indodax API configuration
IndodaxAPIKey string
IndodaxSecretKey string
// Hyperliquid configuration
HyperliquidPrivateKey string
HyperliquidWalletAddr string
HyperliquidTestnet bool
HyperliquidUnifiedAcct bool // Unified Account mode: Spot USDC as Perp collateral
// Aster configuration
AsterUser string // Aster main wallet address
AsterSigner string // Aster API wallet address
AsterPrivateKey string // Aster API wallet private key
// LIGHTER configuration
LighterWalletAddr string // LIGHTER wallet address (L1 wallet)
LighterPrivateKey string // LIGHTER L1 private key (for account identification)
LighterAPIKeyPrivateKey string // LIGHTER API Key private key (40 bytes, for transaction signing)
LighterAPIKeyIndex int // LIGHTER API Key index (0-255)
LighterTestnet bool // Whether to use testnet
// AI configuration
UseQwen bool
DeepSeekKey string
QwenKey string
// Custom AI API configuration
CustomAPIURL string
CustomAPIKey string
CustomModelName string
Claw402WalletKey string
// Scan configuration
ScanInterval time.Duration // Scan interval (recommended 3 minutes)
// Account configuration
InitialBalance float64 // Initial balance (for P&L calculation, must be set manually)
// Risk control (only as hints, AI can make autonomous decisions)
MaxDailyLoss float64 // Maximum daily loss percentage (hint)
MaxDrawdown float64 // Maximum drawdown percentage (hint)
StopTradingTime time.Duration // Pause duration after risk control triggers
// Position mode
IsCrossMargin bool // true=cross margin mode, false=isolated margin mode
// Competition visibility
ShowInCompetition bool // Whether to show in competition page
// Strategy configuration (use complete strategy config)
StrategyConfig *store.StrategyConfig // Strategy configuration (includes coin sources, indicators, risk control, prompts, etc.)
}
// AutoTrader automatic trader
type AutoTrader struct {
id string // Trader unique identifier
name string // Trader display name
aiModel string // AI model name
exchange string // Trading platform type (binance/bybit/etc)
exchangeID string // Exchange account UUID
showInCompetition bool // Whether to show in competition page
config AutoTraderConfig
trader Trader // Use Trader interface (supports multiple platforms)
mcpClient mcp.AIClient
store *store.Store // Data storage (decision records, etc.)
strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration)
cycleNumber int // Current cycle number
initialBalance float64
dailyPnL float64
customPrompt string // Custom trading strategy prompt
overrideBasePrompt bool // Whether to override base prompt
lastResetTime time.Time
stopUntil time.Time
isRunning bool
isRunningMutex sync.RWMutex // Mutex to protect isRunning flag
startTime time.Time // System start time
callCount int // AI call count
positionFirstSeenTime map[string]int64 // Position first seen time (symbol_side -> timestamp in milliseconds)
stopMonitorCh chan struct{} // Used to stop monitoring goroutine
monitorWg sync.WaitGroup // Used to wait for monitoring goroutine to finish
peakPnLCache map[string]float64 // Peak profit cache (symbol -> peak P&L percentage)
peakPnLCacheMutex sync.RWMutex // Cache read-write lock
lastBalanceSyncTime time.Time // Last balance sync time
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
}
// NewAutoTrader creates an automatic trader
// st parameter is used to store decision records to database
func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*AutoTrader, error) {
// Set default values
if config.ID == "" {
config.ID = "default_trader"
}
if config.Name == "" {
config.Name = "Default Trader"
}
if config.AIModel == "" {
if config.UseQwen {
config.AIModel = "qwen"
} else {
config.AIModel = "deepseek"
}
}
// Initialize AI client based on provider
var mcpClient mcp.AIClient
aiModel := config.AIModel
if config.UseQwen && aiModel == "" {
aiModel = "qwen"
}
// Resolve API key (provider-specific overrides)
apiKey := config.CustomAPIKey
customURL := config.CustomAPIURL
switch aiModel {
case "qwen":
if config.QwenKey != "" {
apiKey = config.QwenKey
}
case "deepseek", "":
if config.DeepSeekKey != "" {
apiKey = config.DeepSeekKey
}
}
// Create client via registry (covers all registered providers)
if aiModel == "custom" {
mcpClient = mcp.New()
} else if aiModel == "" {
aiModel = "deepseek"
mcpClient = mcp.NewAIClientByProvider(aiModel)
} else {
mcpClient = mcp.NewAIClientByProvider(aiModel)
}
if mcpClient == nil {
mcpClient = mcp.New()
}
// Payment providers (claw402) ignore customURL
switch aiModel {
case "claw402":
mcpClient.SetAPIKey(apiKey, "", config.CustomModelName)
default:
mcpClient.SetAPIKey(apiKey, customURL, config.CustomModelName)
}
logger.Infof("🤖 [%s] Using %s AI", config.Name, aiModel)
if config.CustomAPIURL != "" || config.CustomModelName != "" {
logger.Infof("🔧 [%s] Custom config - URL: %s, Model: %s", config.Name, config.CustomAPIURL, config.CustomModelName)
}
// Set default trading platform
if config.Exchange == "" {
config.Exchange = "binance"
}
// Create corresponding trader based on configuration
var trader Trader
var err error
// Record position mode (general)
marginModeStr := "Cross Margin"
if !config.IsCrossMargin {
marginModeStr = "Isolated Margin"
}
logger.Infof("📊 [%s] Position mode: %s", config.Name, marginModeStr)
switch config.Exchange {
case "binance":
logger.Infof("🏦 [%s] Using Binance Futures trading", config.Name)
trader = binance.NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
case "bybit":
logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name)
trader = bybit.NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
case "okx":
logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name)
trader = okx.NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
case "bitget":
logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name)
trader = bitget.NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase)
case "gate":
logger.Infof("🏦 [%s] Using Gate.io Futures trading", config.Name)
trader = gate.NewGateTrader(config.GateAPIKey, config.GateSecretKey)
case "kucoin":
logger.Infof("🏦 [%s] Using KuCoin Futures trading", config.Name)
trader = kucoin.NewKuCoinTrader(config.KuCoinAPIKey, config.KuCoinSecretKey, config.KuCoinPassphrase)
case "hyperliquid":
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name)
trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet, config.HyperliquidUnifiedAcct)
if err != nil {
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
}
case "aster":
logger.Infof("🏦 [%s] Using Aster trading", config.Name)
trader, err = aster.NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize Aster trader: %w", err)
}
case "lighter":
logger.Infof("🏦 [%s] Using LIGHTER trading", config.Name)
if config.LighterWalletAddr == "" || config.LighterAPIKeyPrivateKey == "" {
return nil, fmt.Errorf("Lighter requires wallet address and API Key private key")
}
// Lighter only supports mainnet (testnet disabled)
trader, err = lighter.NewLighterTraderV2(
config.LighterWalletAddr,
config.LighterAPIKeyPrivateKey,
config.LighterAPIKeyIndex,
false, // Always use mainnet for Lighter
)
if err != nil {
return nil, fmt.Errorf("failed to initialize LIGHTER trader: %w", err)
}
logger.Infof("✓ LIGHTER trader initialized successfully")
case "indodax":
logger.Infof("🏦 [%s] Using Indodax Spot trading", config.Name)
trader = indodax.NewIndodaxTrader(config.IndodaxAPIKey, config.IndodaxSecretKey)
default:
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
}
// Validate initial balance configuration, auto-fetch from exchange if 0
if config.InitialBalance <= 0 {
logger.Infof("📊 [%s] Initial balance not set, attempting to fetch current balance from exchange...", config.Name)
account, err := trader.GetBalance()
if err != nil {
return nil, fmt.Errorf("initial balance not set and unable to fetch balance from exchange: %w", err)
}
// Try multiple balance field names (different exchanges return different formats)
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
var foundBalance float64
for _, key := range balanceKeys {
if balance, ok := account[key].(float64); ok && balance > 0 {
foundBalance = balance
break
}
}
if foundBalance > 0 {
config.InitialBalance = foundBalance
logger.Infof("✓ [%s] Auto-fetched initial balance: %.2f USDT", config.Name, foundBalance)
// Save to database so it persists across restarts
if st != nil {
if err := st.Trader().UpdateInitialBalance(userID, config.ID, foundBalance); err != nil {
logger.Infof("⚠️ [%s] Failed to save initial balance to database: %v", config.Name, err)
} else {
logger.Infof("✓ [%s] Initial balance saved to database", config.Name)
}
}
} else {
return nil, fmt.Errorf("initial balance must be greater than 0, please set InitialBalance in config or ensure exchange account has balance")
}
}
// Get last cycle number (for recovery)
var cycleNumber int
if st != nil {
cycleNumber, _ = st.Decision().GetLastCycleNumber(config.ID)
logger.Infof("📊 [%s] Decision records will be stored to database", config.Name)
}
// Create strategy engine (must have strategy config)
if config.StrategyConfig == nil {
return nil, fmt.Errorf("[%s] strategy not configured", config.Name)
}
// Pass claw402 wallet key to strategy engine so nofxos data requests
// are routed through claw402 (reuses the same wallet as AI calls)
claw402Key := config.Claw402WalletKey
if claw402Key == "" && config.AIModel == "claw402" && config.CustomAPIKey != "" {
claw402Key = config.CustomAPIKey
}
strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig, claw402Key)
logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name)
return &AutoTrader{
id: config.ID,
name: config.Name,
aiModel: config.AIModel,
exchange: config.Exchange,
exchangeID: config.ExchangeID,
showInCompetition: config.ShowInCompetition,
config: config,
trader: trader,
mcpClient: mcpClient,
store: st,
strategyEngine: strategyEngine,
cycleNumber: cycleNumber,
initialBalance: config.InitialBalance,
lastResetTime: time.Now(),
startTime: time.Now(),
callCount: 0,
isRunning: false,
positionFirstSeenTime: make(map[string]int64),
stopMonitorCh: make(chan struct{}),
monitorWg: sync.WaitGroup{},
peakPnLCache: make(map[string]float64),
peakPnLCacheMutex: sync.RWMutex{},
lastBalanceSyncTime: time.Now(),
userID: userID,
}, nil
}
// Run runs the automatic trading main loop
func (at *AutoTrader) Run() error {
at.isRunningMutex.Lock()
at.isRunning = true
at.isRunningMutex.Unlock()
at.stopMonitorCh = make(chan struct{})
at.startTime = time.Now()
logger.Info("🚀 AI-driven automatic trading system started")
logger.Infof("💰 Initial balance: %.2f USDT", at.initialBalance)
logger.Infof("⚙️ Scan interval: %v", at.config.ScanInterval)
logger.Info("🤖 AI will make full decisions on leverage, position size, stop loss/take profit, etc.")
// Pre-launch checks for claw402 users
at.runPreLaunchChecks()
at.monitorWg.Add(1)
defer at.monitorWg.Done()
// Start drawdown monitoring
at.startDrawdownMonitor()
// Start Lighter order sync if using Lighter exchange
if at.exchange == "lighter" {
if lighterTrader, ok := at.trader.(*lighter.LighterTraderV2); ok && at.store != nil {
lighterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Lighter order+position sync enabled (every 30s)", at.name)
}
}
// Start Hyperliquid order sync if using Hyperliquid exchange
if at.exchange == "hyperliquid" {
if hyperliquidTrader, ok := at.trader.(*hyperliquid.HyperliquidTrader); ok && at.store != nil {
hyperliquidTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Hyperliquid order+position sync enabled (every 30s)", at.name)
}
}
// Start Bybit order sync if using Bybit exchange
if at.exchange == "bybit" {
if bybitTrader, ok := at.trader.(*bybit.BybitTrader); ok && at.store != nil {
bybitTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Bybit order+position sync enabled (every 30s)", at.name)
}
}
// Start OKX order sync if using OKX exchange
if at.exchange == "okx" {
if okxTrader, ok := at.trader.(*okx.OKXTrader); ok && at.store != nil {
okxTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] OKX order+position sync enabled (every 30s)", at.name)
}
}
// Start Bitget order sync if using Bitget exchange
if at.exchange == "bitget" {
if bitgetTrader, ok := at.trader.(*bitget.BitgetTrader); ok && at.store != nil {
bitgetTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Bitget order+position sync enabled (every 30s)", at.name)
}
}
// Start Aster order sync if using Aster exchange
if at.exchange == "aster" {
if asterTrader, ok := at.trader.(*aster.AsterTrader); ok && at.store != nil {
asterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Aster order+position sync enabled (every 30s)", at.name)
}
}
// Start Binance order sync if using Binance exchange
if at.exchange == "binance" {
if binanceTrader, ok := at.trader.(*binance.FuturesTrader); ok && at.store != nil {
binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Binance order+position sync enabled (every 30s)", at.name)
}
}
// Start Gate order sync if using Gate exchange
if at.exchange == "gate" {
if gateTrader, ok := at.trader.(*gate.GateTrader); ok && at.store != nil {
gateTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Gate order+position sync enabled (every 30s)", at.name)
}
}
// Start KuCoin order sync if using KuCoin exchange
if at.exchange == "kucoin" {
if kucoinTrader, ok := at.trader.(*kucoin.KuCoinTrader); ok && at.store != nil {
kucoinTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] KuCoin order+position sync enabled (every 30s)", at.name)
}
}
ticker := time.NewTicker(at.config.ScanInterval)
defer ticker.Stop()
// Check if this is a grid trading strategy
isGridStrategy := at.IsGridStrategy()
if isGridStrategy {
logger.Infof("🔲 [%s] Grid trading strategy detected, initializing grid...", at.name)
if err := at.InitializeGrid(); err != nil {
logger.Errorf("❌ [%s] Failed to initialize grid: %v", at.name, err)
return fmt.Errorf("grid initialization failed: %w", err)
}
}
// Execute immediately on first run
if isGridStrategy {
if err := at.RunGridCycle(); err != nil {
logger.Infof("❌ Grid execution failed: %v", err)
}
} else {
if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err)
}
}
for {
at.isRunningMutex.RLock()
running := at.isRunning
at.isRunningMutex.RUnlock()
if !running {
break
}
select {
case <-ticker.C:
if isGridStrategy {
if err := at.RunGridCycle(); err != nil {
logger.Infof("❌ Grid execution failed: %v", err)
}
} else {
if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err)
}
}
case <-at.stopMonitorCh:
logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name)
return nil
}
}
return nil
}
// Stop stops the automatic trading
func (at *AutoTrader) Stop() {
at.isRunningMutex.Lock()
if !at.isRunning {
at.isRunningMutex.Unlock()
return
}
at.isRunning = false
at.isRunningMutex.Unlock()
close(at.stopMonitorCh) // Notify monitoring goroutine to stop
at.monitorWg.Wait() // Wait for monitoring goroutine to finish
logger.Info("⏹ Automatic trading system stopped")
}
// GetID gets trader ID
func (at *AutoTrader) GetID() string {
return at.id
}
// GetUnderlyingTrader returns the underlying Trader interface implementation
// This is used by grid trading and other components that need direct exchange access
func (at *AutoTrader) GetUnderlyingTrader() Trader {
return at.trader
}
// GetName gets trader name
func (at *AutoTrader) GetName() string {
return at.name
}
// GetAIModel gets AI model
func (at *AutoTrader) GetAIModel() string {
return at.aiModel
}
// GetExchange gets exchange
func (at *AutoTrader) GetExchange() string {
return at.exchange
}
// GetShowInCompetition returns whether trader should be shown in competition
func (at *AutoTrader) GetShowInCompetition() bool {
return at.showInCompetition
}
// SetShowInCompetition sets whether trader should be shown in competition
func (at *AutoTrader) SetShowInCompetition(show bool) {
at.showInCompetition = show
}
// SetCustomPrompt sets custom trading strategy prompt
func (at *AutoTrader) SetCustomPrompt(prompt string) {
at.customPrompt = prompt
}
// SetOverrideBasePrompt sets whether to override base prompt
func (at *AutoTrader) SetOverrideBasePrompt(override bool) {
at.overrideBasePrompt = override
}
// GetSystemPromptTemplate gets current system prompt template name (from strategy config)
func (at *AutoTrader) GetSystemPromptTemplate() string {
if at.strategyEngine != nil {
config := at.strategyEngine.GetConfig()
if config.CustomPrompt != "" {
return "custom"
}
}
return "strategy"
}
// GetStore gets data store (for external access to decision records, etc.)
func (at *AutoTrader) GetStore() *store.Store {
return at.store
}
// calculatePnLPercentage calculates P&L percentage (based on margin, automatically considers leverage)
// Return rate = Unrealized P&L / Margin x 100%
func calculatePnLPercentage(unrealizedPnl, marginUsed float64) float64 {
if marginUsed > 0 {
return (unrealizedPnl / marginUsed) * 100
}
return 0.0
}
// runPreLaunchChecks performs pre-launch checks for claw402 users (wallet balance, runway estimate)
func (at *AutoTrader) runPreLaunchChecks() {
if !store.IsClaw402Config(at.config.AIModel) {
return
}
logger.Info("🔍 Running pre-launch checks (claw402)...")
// Derive wallet address from CustomAPIKey (which is the private key for claw402)
if at.config.CustomAPIKey != "" {
// Try to derive address using go-ethereum
addr := deriveWalletAddress(at.config.CustomAPIKey)
if addr != "" {
at.claw402WalletAddr = addr
logger.Infof("💳 [%s] Claw402 wallet: %s", at.name, addr)
// Query USDC balance
balance, err := wallet.QueryUSDCBalance(addr)
if err != nil {
logger.Warnf("⚠️ [%s] Could not query USDC balance: %v", at.name, err)
} else {
// Estimate runway
scanMinutes := int(at.config.ScanInterval.Minutes())
modelName := at.config.CustomModelName
if modelName == "" {
modelName = "deepseek"
}
dailyCost, runway := store.EstimateRunway(balance, modelName, scanMinutes)
logger.Infof("💰 [%s] USDC Balance: $%.2f | Daily AI cost: ~$%.2f | Runway: ~%.1f days",
at.name, balance, dailyCost, runway)
if balance < 1.0 {
logger.Warnf("⚠️ [%s] Low USDC balance! Consider topping up.", at.name)
}
if balance <= 0 {
logger.Errorf("🚨 [%s] USDC balance is ZERO — AI calls will fail!", at.name)
}
}
}
}
logger.Info("✅ Pre-launch checks complete")
}
// deriveWalletAddress derives an Ethereum address from a hex private key
func deriveWalletAddress(privateKeyHex string) string {
// Remove 0x prefix if present
if len(privateKeyHex) > 2 && privateKeyHex[:2] == "0x" {
privateKeyHex = privateKeyHex[2:]
}
privateKey, err := crypto.HexToECDSA(privateKeyHex)
if err != nil {
return ""
}
address := crypto.PubkeyToAddress(privateKey.PublicKey)
return address.Hex()
}