Files
nofx/agent/tools.go
T
lky-spec 3ca95b294d feat: port NOFXi agent module onto latest dev base (#1485)
* feat: integrate NOFXi agent into dev

* Enhance NOFXi agent workflow and diagnostics
2026-04-21 23:47:55 +08:00

2243 lines
74 KiB
Go

package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"nofx/kernel"
"nofx/mcp"
"nofx/safe"
"nofx/security"
"nofx/store"
)
// cachedTools holds the static tool definitions (built once, reused per message).
var cachedTools = buildAgentTools()
// agentTools returns the tools available to the LLM for autonomous action.
func agentTools() []mcp.Tool { return cachedTools }
func buildAgentTools() []mcp.Tool {
return []mcp.Tool{
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_preferences",
Description: "Get all persistent user preferences that the agent should remember long-term.",
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "manage_preferences",
Description: "Add, update, or delete a persistent user preference. Use this when the user asks to remember something long-term, change an existing long-term preference, or remove one.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"add", "update", "delete"},
"description": "What to do with the persistent preference.",
},
"text": map[string]any{
"type": "string",
"description": "The new preference text. Required for add and update.",
},
"match": map[string]any{
"type": "string",
"description": "How to find the existing preference to update or delete. Can be an id or distinctive text like '每天8点'.",
},
},
"required": []string{"action"},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_backend_logs",
Description: "Get recent backend log lines for a trader diagnosis. Prefer this when the user asks why a specific trader failed, stopped, or behaved unexpectedly. Returns recent matching log lines for the authenticated user's trader.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"trader_id": map[string]any{
"type": "string",
"description": "Trader id to diagnose. The backend verifies that this trader belongs to the authenticated user before returning logs.",
},
"limit": map[string]any{"type": "number", "description": "Maximum number of recent log lines to return. Default 30."},
"errors_only": map[string]any{"type": "boolean", "description": "When true, only return error-like log lines. Default true."},
},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_exchange_configs",
Description: "Get the user's current exchange account bindings. Returns safe metadata only and whether credentials are already stored.",
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "manage_exchange_config",
Description: "Create, update, or delete an exchange account binding. Use this when the user asks to add/edit/remove an exchange account, API key, secret, passphrase, wallet address, or account name. Sensitive fields are stored securely and are never returned in full.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"create", "update", "delete"},
},
"exchange_id": map[string]any{
"type": "string",
"description": "Existing exchange account id. Required for update and delete.",
},
"exchange_type": map[string]any{
"type": "string",
"description": "Exchange type for a new binding, such as binance, bybit, okx, hyperliquid, aster, lighter, gate, kucoin, alpaca, forex, or metals.",
},
"account_name": map[string]any{
"type": "string",
"description": "User-visible account name like Main, Testnet, or Mom Account.",
},
"enabled": map[string]any{
"type": "boolean",
"description": "Whether this exchange binding should be enabled.",
},
"api_key": map[string]any{"type": "string"},
"secret_key": map[string]any{"type": "string"},
"passphrase": map[string]any{"type": "string"},
"testnet": map[string]any{"type": "boolean"},
"hyperliquid_wallet_addr": map[string]any{"type": "string"},
"hyperliquid_unified_account": map[string]any{"type": "boolean"},
"aster_user": map[string]any{"type": "string"},
"aster_signer": map[string]any{"type": "string"},
"aster_private_key": map[string]any{"type": "string"},
"lighter_wallet_addr": map[string]any{"type": "string"},
"lighter_private_key": map[string]any{"type": "string"},
"lighter_api_key_private_key": map[string]any{"type": "string"},
"lighter_api_key_index": map[string]any{"type": "number"},
},
"required": []string{"action"},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_model_configs",
Description: "Get the user's current AI model bindings. Returns safe metadata only and whether an API key is already stored.",
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "manage_model_config",
Description: "Create, update, or delete an AI model binding. Use this when the user asks to add/edit/remove a model provider, API key, custom API URL, or custom model name. Sensitive fields are stored securely and are never returned in full.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"create", "update", "delete"},
},
"model_id": map[string]any{
"type": "string",
"description": "Existing model id for update/delete, or the desired id for create.",
},
"provider": map[string]any{
"type": "string",
"description": "Provider slug such as openai, claude, gemini, deepseek, qwen, kimi, grok, minimax, claw402, or blockrun-base.",
},
"name": map[string]any{
"type": "string",
"description": "Display name for a newly created model binding.",
},
"enabled": map[string]any{"type": "boolean"},
"api_key": map[string]any{"type": "string"},
"custom_api_url": map[string]any{"type": "string"},
"custom_model_name": map[string]any{"type": "string"},
},
"required": []string{"action"},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_strategies",
Description: "Get the user's current strategy templates, including system default strategies available to that user.",
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "manage_strategy",
Description: "List, create, update, delete, activate, duplicate strategies, or get the default strategy config template. Use this when the user asks to create or edit a strategy template. Strategy templates are independent assets and do not require exchange/model bindings unless the user asks to run them via a trader.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"list", "create", "update", "delete", "activate", "duplicate", "get_default_config"},
},
"strategy_id": map[string]any{"type": "string"},
"name": map[string]any{"type": "string"},
"description": map[string]any{"type": "string"},
"lang": map[string]any{"type": "string", "enum": []string{"zh", "en"}},
"is_public": map[string]any{"type": "boolean"},
"config_visible": map[string]any{"type": "boolean"},
"config": map[string]any{"type": "object", "description": "Full or partial strategy config JSON object, depending on action."},
},
"required": []string{"action"},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "manage_trader",
Description: "List, create, update, delete, start, or stop traders. Use this when the user asks to create a trader, rename one, switch its exchange/model/strategy, delete it, or control its running state.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"list", "create", "update", "delete", "start", "stop"},
},
"trader_id": map[string]any{
"type": "string",
"description": "Required for update, delete, start, and stop.",
},
"name": map[string]any{"type": "string"},
"ai_model_id": map[string]any{"type": "string"},
"exchange_id": map[string]any{"type": "string"},
"strategy_id": map[string]any{"type": "string"},
"initial_balance": map[string]any{"type": "number"},
"scan_interval_minutes": map[string]any{"type": "number"},
"is_cross_margin": map[string]any{"type": "boolean"},
"show_in_competition": map[string]any{"type": "boolean"},
"btc_eth_leverage": map[string]any{"type": "number"},
"altcoin_leverage": map[string]any{"type": "number"},
"trading_symbols": map[string]any{"type": "string"},
"custom_prompt": map[string]any{"type": "string"},
"override_base_prompt": map[string]any{"type": "boolean"},
"system_prompt_template": map[string]any{"type": "string"},
"use_ai500": map[string]any{"type": "boolean"},
"use_oi_top": map[string]any{"type": "boolean"},
},
"required": []string{"action"},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "search_stock",
Description: "Search for a stock by name, ticker symbol, or keyword. Searches across A-share (沪深), Hong Kong, and US markets. Returns a list of matching stocks with their codes. Use this when the user asks about a stock not in your known list, or when you need to find the exact code for a stock.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"keyword": map[string]any{
"type": "string",
"description": "Search keyword: stock name (e.g. '宁德时代', '腾讯'), ticker (e.g. 'TSLA', 'AAPL'), or stock code (e.g. '300750')",
},
},
"required": []string{"keyword"},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "execute_trade",
Description: "Execute a trade order (crypto or US stocks). Use this when the user explicitly asks to open/close a position. For stocks (e.g. AAPL, TSLA), use open_long to buy and close_long to sell. This creates a pending trade that requires user confirmation.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"open_long", "open_short", "close_long", "close_short"},
"description": "Trade action: open_long (做多/buy), open_short (做空/sell), close_long (平多), close_short (平空)",
},
"symbol": map[string]any{
"type": "string",
"description": "Trading symbol. For crypto: BTCUSDT, ETHUSDT. For US stocks: AAPL, TSLA, NVDA (no suffix needed).",
},
"quantity": map[string]any{
"type": "number",
"description": "Trade quantity/amount. Required for opening positions. Use 0 to close entire position.",
},
"leverage": map[string]any{
"type": "number",
"description": "Leverage multiplier (e.g. 5, 10, 20). Optional, defaults to trader's current setting.",
},
},
"required": []string{"action", "symbol", "quantity"},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_positions",
Description: "Get all current open positions across all traders. Returns symbol, side, size, entry price, mark price, and unrealized PnL.",
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_balance",
Description: "Get account balance and equity across all traders.",
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_market_price",
Description: "Get the current market price for a crypto or stock symbol.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"symbol": map[string]any{
"type": "string",
"description": "Trading symbol, e.g. BTCUSDT for crypto, AAPL for stocks",
},
},
"required": []string{"symbol"},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_trade_history",
Description: "Get recent closed trade history with PnL. Use when user asks about past trades, performance, or trade results. Returns the most recent closed positions.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"limit": map[string]any{
"type": "number",
"description": "Number of recent trades to return (default 10, max 50)",
},
},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_candidate_coins",
Description: "Get the current candidate coin list for a trader or strategy, including AI500 coin-source settings and the selected symbols.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"trader_id": map[string]any{
"type": "string",
"description": "Optional trader id. Prefer this when asking about a running trader.",
},
"strategy_id": map[string]any{
"type": "string",
"description": "Optional strategy id. Use this when asking about a strategy template directly.",
},
},
},
},
},
}
}
// handleToolCall processes a single tool call from the LLM and returns the result.
func (a *Agent) handleToolCall(ctx context.Context, storeUserID string, userID int64, lang string, tc mcp.ToolCall) string {
switch tc.Function.Name {
case "get_preferences":
return a.toolGetPreferences(userID)
case "manage_preferences":
return a.toolManagePreferences(userID, tc.Function.Arguments)
case "get_backend_logs":
return a.toolGetBackendLogs(storeUserID, tc.Function.Arguments)
case "get_exchange_configs":
return a.toolGetExchangeConfigs(storeUserID)
case "manage_exchange_config":
return a.toolManageExchangeConfig(storeUserID, tc.Function.Arguments)
case "get_model_configs":
return a.toolGetModelConfigs(storeUserID)
case "manage_model_config":
return a.toolManageModelConfig(storeUserID, tc.Function.Arguments)
case "get_strategies":
return a.toolGetStrategies(storeUserID)
case "manage_strategy":
return a.toolManageStrategy(storeUserID, tc.Function.Arguments)
case "manage_trader":
return a.toolManageTrader(storeUserID, tc.Function.Arguments)
case "search_stock":
return a.toolSearchStock(tc.Function.Arguments)
case "execute_trade":
return a.toolExecuteTrade(ctx, userID, lang, tc.Function.Arguments)
case "get_positions":
return a.toolGetPositions()
case "get_balance":
return a.toolGetBalance()
case "get_market_price":
return a.toolGetMarketPrice(tc.Function.Arguments)
case "get_trade_history":
return a.toolGetTradeHistory(tc.Function.Arguments)
case "get_candidate_coins":
return a.toolGetCandidateCoins(storeUserID, userID, tc.Function.Arguments)
default:
return fmt.Sprintf(`{"error": "unknown tool: %s"}`, tc.Function.Name)
}
}
type safeExchangeToolConfig struct {
ID string `json:"id"`
ExchangeType string `json:"exchange_type"`
AccountName string `json:"account_name"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
HasAPIKey bool `json:"has_api_key"`
HasSecretKey bool `json:"has_secret_key"`
HasPassphrase bool `json:"has_passphrase"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr,omitempty"`
HasAsterPrivateKey bool `json:"has_aster_private_key"`
AsterUser string `json:"aster_user,omitempty"`
AsterSigner string `json:"aster_signer,omitempty"`
LighterWalletAddr string `json:"lighter_wallet_addr,omitempty"`
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"`
}
type safeModelToolConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
HasAPIKey bool `json:"has_api_key"`
CustomAPIURL string `json:"custom_api_url,omitempty"`
CustomModelName string `json:"custom_model_name,omitempty"`
}
type safeTraderToolConfig struct {
ID string `json:"id"`
Name string `json:"name"`
AIModelID string `json:"ai_model_id"`
ExchangeID string `json:"exchange_id"`
StrategyID string `json:"strategy_id,omitempty"`
InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsRunning bool `json:"is_running"`
IsCrossMargin bool `json:"is_cross_margin"`
ShowInCompetition bool `json:"show_in_competition"`
BTCETHLeverage int `json:"btc_eth_leverage,omitempty"`
AltcoinLeverage int `json:"altcoin_leverage,omitempty"`
TradingSymbols string `json:"trading_symbols,omitempty"`
CustomPrompt string `json:"custom_prompt,omitempty"`
SystemPromptTemplate string `json:"system_prompt_template,omitempty"`
}
type safeStrategyToolConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsActive bool `json:"is_active"`
IsDefault bool `json:"is_default"`
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
Config map[string]any `json:"config,omitempty"`
HasConfig bool `json:"has_config"`
}
type manageTraderArgs struct {
Action string `json:"action"`
TraderID string `json:"trader_id"`
Name string `json:"name"`
AIModelID string `json:"ai_model_id"`
ExchangeID string `json:"exchange_id"`
StrategyID string `json:"strategy_id"`
InitialBalance *float64 `json:"initial_balance"`
ScanIntervalMinutes *int `json:"scan_interval_minutes"`
IsCrossMargin *bool `json:"is_cross_margin"`
ShowInCompetition *bool `json:"show_in_competition"`
BTCETHLeverage *int `json:"btc_eth_leverage"`
AltcoinLeverage *int `json:"altcoin_leverage"`
TradingSymbols string `json:"trading_symbols"`
CustomPrompt string `json:"custom_prompt"`
OverrideBasePrompt *bool `json:"override_base_prompt"`
SystemPromptTemplate string `json:"system_prompt_template"`
UseAI500 *bool `json:"use_ai500"`
UseOITop *bool `json:"use_oi_top"`
}
func safeExchangeForTool(ex *store.Exchange) safeExchangeToolConfig {
return safeExchangeToolConfig{
ID: ex.ID,
ExchangeType: ex.ExchangeType,
AccountName: ex.AccountName,
Name: ex.Name,
Type: ex.Type,
Enabled: ex.Enabled,
HasAPIKey: ex.APIKey != "",
HasSecretKey: ex.SecretKey != "",
HasPassphrase: ex.Passphrase != "",
Testnet: ex.Testnet,
HyperliquidWalletAddr: ex.HyperliquidWalletAddr,
HasAsterPrivateKey: ex.AsterPrivateKey != "",
AsterUser: ex.AsterUser,
AsterSigner: ex.AsterSigner,
LighterWalletAddr: ex.LighterWalletAddr,
HasLighterPrivateKey: ex.LighterPrivateKey != "",
HasLighterAPIKey: ex.LighterAPIKeyPrivateKey != "",
}
}
func safeModelForTool(model *store.AIModel) safeModelToolConfig {
return safeModelToolConfig{
ID: model.ID,
Name: model.Name,
Provider: model.Provider,
Enabled: model.Enabled,
HasAPIKey: model.APIKey != "",
CustomAPIURL: model.CustomAPIURL,
CustomModelName: model.CustomModelName,
}
}
func modelConfigUsable(provider, modelID, apiKey, customAPIURL, customModelName string) bool {
if strings.TrimSpace(apiKey) == "" {
return false
}
resolvedURL, resolvedModel := resolveModelRuntimeConfig(provider, customAPIURL, customModelName, modelID)
return strings.TrimSpace(resolvedURL) != "" && strings.TrimSpace(resolvedModel) != ""
}
func safeTraderForTool(trader *store.Trader, isRunning bool) safeTraderToolConfig {
return safeTraderToolConfig{
ID: trader.ID,
Name: trader.Name,
AIModelID: trader.AIModelID,
ExchangeID: trader.ExchangeID,
StrategyID: trader.StrategyID,
InitialBalance: trader.InitialBalance,
ScanIntervalMinutes: trader.ScanIntervalMinutes,
IsRunning: isRunning,
IsCrossMargin: trader.IsCrossMargin,
ShowInCompetition: trader.ShowInCompetition,
BTCETHLeverage: trader.BTCETHLeverage,
AltcoinLeverage: trader.AltcoinLeverage,
TradingSymbols: trader.TradingSymbols,
CustomPrompt: trader.CustomPrompt,
SystemPromptTemplate: trader.SystemPromptTemplate,
}
}
func safeStrategyForTool(strategy *store.Strategy) safeStrategyToolConfig {
out := safeStrategyToolConfig{
ID: strategy.ID,
Name: strategy.Name,
Description: strategy.Description,
IsActive: strategy.IsActive,
IsDefault: strategy.IsDefault,
IsPublic: strategy.IsPublic,
ConfigVisible: strategy.ConfigVisible,
HasConfig: strings.TrimSpace(strategy.Config) != "",
}
if out.HasConfig {
var cfg map[string]any
if err := json.Unmarshal([]byte(strategy.Config), &cfg); err == nil {
out.Config = cfg
}
}
return out
}
func (a *Agent) toolGetExchangeConfigs(storeUserID string) string {
if a.store == nil {
return `{"error":"store unavailable"}`
}
exchanges, err := a.store.Exchange().List(storeUserID)
if err != nil {
return fmt.Sprintf(`{"error":"failed to load exchange configs: %s"}`, err)
}
safe := make([]safeExchangeToolConfig, 0, len(exchanges))
for _, ex := range exchanges {
safe = append(safe, safeExchangeForTool(ex))
}
result, _ := json.Marshal(map[string]any{
"exchange_configs": safe,
"count": len(safe),
})
return string(result)
}
func latestBackendLogFilePath() string {
matches, err := filepath.Glob(filepath.Join("data", "nofx_*.log"))
if err != nil || len(matches) == 0 {
return ""
}
sort.Strings(matches)
return matches[len(matches)-1]
}
func isBackendErrorLikeLogLine(line string) bool {
lower := strings.ToLower(strings.TrimSpace(line))
if lower == "" {
return false
}
return strings.Contains(lower, "[erro]") ||
strings.Contains(lower, " panic") ||
strings.Contains(lower, "🔥") ||
strings.Contains(lower, "❌") ||
strings.Contains(lower, " failed") ||
strings.Contains(lower, " error") ||
strings.Contains(lower, "invalid ")
}
func readBackendLogEntries(limit int, contains string, errorsOnly bool) (string, []string, error) {
path := latestBackendLogFilePath()
if path == "" {
return "", nil, fmt.Errorf("backend log file not found")
}
file, err := os.Open(path)
if err != nil {
return path, nil, err
}
defer file.Close()
filter := strings.ToLower(strings.TrimSpace(contains))
matches := make([]string, 0, max(limit, 1))
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if errorsOnly && !isBackendErrorLikeLogLine(line) {
continue
}
if filter != "" && !strings.Contains(strings.ToLower(line), filter) {
continue
}
matches = append(matches, line)
}
if err := scanner.Err(); err != nil {
return path, nil, err
}
if limit <= 0 {
limit = 30
}
if len(matches) > limit {
matches = matches[len(matches)-limit:]
}
return path, matches, nil
}
func (a *Agent) toolGetBackendLogs(storeUserID, argsJSON string) string {
var args struct {
TraderID string `json:"trader_id"`
Limit int `json:"limit"`
ErrorsOnly *bool `json:"errors_only"`
}
if strings.TrimSpace(argsJSON) != "" {
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err)
}
}
errorsOnly := true
if args.ErrorsOnly != nil {
errorsOnly = *args.ErrorsOnly
}
traderID := strings.TrimSpace(args.TraderID)
if traderID == "" {
return `{"error":"trader_id is required"}`
}
if a.store == nil {
return `{"error":"store unavailable"}`
}
trader, err := a.store.Trader().GetByID(traderID)
if err != nil {
return fmt.Sprintf(`{"error":"failed to load trader: %s"}`, err)
}
if trader.UserID != storeUserID {
return `{"error":"trader not found for current user"}`
}
path, entries, err := readBackendLogEntries(args.Limit, traderID, errorsOnly)
if err != nil {
return fmt.Sprintf(`{"error":"failed to read backend logs: %s"}`, err)
}
result, _ := json.Marshal(map[string]any{
"trader_id": traderID,
"log_file": path,
"entries": entries,
"count": len(entries),
"errors_only": errorsOnly,
})
return string(result)
}
func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string {
if a.store == nil {
return `{"error":"store unavailable"}`
}
var args struct {
Action string `json:"action"`
ExchangeID string `json:"exchange_id"`
ExchangeType string `json:"exchange_type"`
AccountName string `json:"account_name"`
Enabled *bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Passphrase string `json:"passphrase"`
Testnet *bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAccount *bool `json:"hyperliquid_unified_account"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
LighterAPIKeyIndex *int `json:"lighter_api_key_index"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err)
}
action := strings.TrimSpace(args.Action)
switch action {
case "create":
if strings.TrimSpace(args.ExchangeType) == "" {
return `{"error":"exchange_type is required for create"}`
}
enabled := false
if args.Enabled != nil {
enabled = *args.Enabled
}
testnet := false
if args.Testnet != nil {
testnet = *args.Testnet
}
unified := true
if args.HyperliquidUnifiedAccount != nil {
unified = *args.HyperliquidUnifiedAccount
}
lighterIndex := 0
if args.LighterAPIKeyIndex != nil {
lighterIndex = *args.LighterAPIKeyIndex
}
id, err := a.store.Exchange().Create(
storeUserID,
strings.TrimSpace(args.ExchangeType),
strings.TrimSpace(args.AccountName),
enabled,
strings.TrimSpace(args.APIKey),
strings.TrimSpace(args.SecretKey),
strings.TrimSpace(args.Passphrase),
testnet,
strings.TrimSpace(args.HyperliquidWalletAddr),
unified,
strings.TrimSpace(args.AsterUser),
strings.TrimSpace(args.AsterSigner),
strings.TrimSpace(args.AsterPrivateKey),
strings.TrimSpace(args.LighterWalletAddr),
strings.TrimSpace(args.LighterPrivateKey),
strings.TrimSpace(args.LighterAPIKeyPrivateKey),
lighterIndex,
)
if err != nil {
return fmt.Sprintf(`{"error":"failed to create exchange config: %s"}`, err)
}
created, err := a.store.Exchange().GetByID(storeUserID, id)
if err != nil {
return fmt.Sprintf(`{"error":"exchange created but failed to reload: %s"}`, err)
}
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "create",
"exchange": safeExchangeForTool(created),
})
return string(result)
case "update":
if strings.TrimSpace(args.ExchangeID) == "" {
return `{"error":"exchange_id is required for update"}`
}
existing, err := a.store.Exchange().GetByID(storeUserID, strings.TrimSpace(args.ExchangeID))
if err != nil {
return fmt.Sprintf(`{"error":"failed to load exchange config: %s"}`, err)
}
enabled := existing.Enabled
if args.Enabled != nil {
enabled = *args.Enabled
}
testnet := existing.Testnet
if args.Testnet != nil {
testnet = *args.Testnet
}
unified := existing.HyperliquidUnifiedAcct
if args.HyperliquidUnifiedAccount != nil {
unified = *args.HyperliquidUnifiedAccount
}
lighterIndex := existing.LighterAPIKeyIndex
if args.LighterAPIKeyIndex != nil {
lighterIndex = *args.LighterAPIKeyIndex
}
hyperWallet := existing.HyperliquidWalletAddr
if strings.TrimSpace(args.HyperliquidWalletAddr) != "" {
hyperWallet = strings.TrimSpace(args.HyperliquidWalletAddr)
}
asterUser := existing.AsterUser
if strings.TrimSpace(args.AsterUser) != "" {
asterUser = strings.TrimSpace(args.AsterUser)
}
asterSigner := existing.AsterSigner
if strings.TrimSpace(args.AsterSigner) != "" {
asterSigner = strings.TrimSpace(args.AsterSigner)
}
lighterWallet := existing.LighterWalletAddr
if strings.TrimSpace(args.LighterWalletAddr) != "" {
lighterWallet = strings.TrimSpace(args.LighterWalletAddr)
}
if err := a.store.Exchange().Update(
storeUserID,
existing.ID,
enabled,
strings.TrimSpace(args.APIKey),
strings.TrimSpace(args.SecretKey),
strings.TrimSpace(args.Passphrase),
testnet,
hyperWallet,
unified,
asterUser,
asterSigner,
strings.TrimSpace(args.AsterPrivateKey),
lighterWallet,
strings.TrimSpace(args.LighterPrivateKey),
strings.TrimSpace(args.LighterAPIKeyPrivateKey),
lighterIndex,
); err != nil {
return fmt.Sprintf(`{"error":"failed to update exchange config: %s"}`, err)
}
if trimmed := strings.TrimSpace(args.AccountName); trimmed != "" && trimmed != existing.AccountName {
if err := a.store.Exchange().UpdateAccountName(storeUserID, existing.ID, trimmed); err != nil {
return fmt.Sprintf(`{"error":"exchange updated but failed to rename account: %s"}`, err)
}
}
updated, err := a.store.Exchange().GetByID(storeUserID, existing.ID)
if err != nil {
return fmt.Sprintf(`{"error":"exchange updated but failed to reload: %s"}`, err)
}
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "update",
"exchange": safeExchangeForTool(updated),
})
return string(result)
case "delete":
if strings.TrimSpace(args.ExchangeID) == "" {
return `{"error":"exchange_id is required for delete"}`
}
if err := a.store.Exchange().Delete(storeUserID, strings.TrimSpace(args.ExchangeID)); err != nil {
return fmt.Sprintf(`{"error":"failed to delete exchange config: %s"}`, err)
}
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "delete",
"exchange_id": strings.TrimSpace(args.ExchangeID),
})
return string(result)
default:
return `{"error":"invalid action"}`
}
}
func (a *Agent) toolGetModelConfigs(storeUserID string) string {
if a.store == nil {
return `{"error":"store unavailable"}`
}
models, err := a.store.AIModel().List(storeUserID)
if err != nil {
return fmt.Sprintf(`{"error":"failed to load model configs: %s"}`, err)
}
safe := make([]safeModelToolConfig, 0, len(models))
for _, model := range models {
safe = append(safe, safeModelForTool(model))
}
result, _ := json.Marshal(map[string]any{
"model_configs": safe,
"count": len(safe),
})
return string(result)
}
func (a *Agent) toolManageModelConfig(storeUserID, argsJSON string) string {
if a.store == nil {
return `{"error":"store unavailable"}`
}
var args struct {
Action string `json:"action"`
ModelID string `json:"model_id"`
Provider string `json:"provider"`
Name string `json:"name"`
Enabled *bool `json:"enabled"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err)
}
if trimmed := strings.TrimSpace(args.CustomAPIURL); trimmed != "" {
if err := security.ValidateURL(strings.TrimSuffix(trimmed, "#")); err != nil {
return fmt.Sprintf(`{"error":"invalid custom_api_url: %s"}`, err)
}
}
action := strings.TrimSpace(args.Action)
switch action {
case "create":
provider := strings.TrimSpace(args.Provider)
if provider == "" {
return `{"error":"provider is required for create"}`
}
modelID := strings.TrimSpace(args.ModelID)
if modelID == "" {
modelID = provider
}
enabled := false
if args.Enabled != nil {
enabled = *args.Enabled
}
if err := a.store.AIModel().Update(storeUserID, modelID, enabled, strings.TrimSpace(args.APIKey), strings.TrimSpace(args.CustomAPIURL), strings.TrimSpace(args.CustomModelName)); err != nil {
return fmt.Sprintf(`{"error":"failed to create model config: %s"}`, err)
}
createdID := modelID
if modelID == provider {
createdID = fmt.Sprintf("%s_%s", storeUserID, provider)
}
model, err := a.store.AIModel().Get(storeUserID, createdID)
if err != nil {
model, err = a.store.AIModel().Get(storeUserID, modelID)
}
if err != nil {
return fmt.Sprintf(`{"error":"model created but failed to reload: %s"}`, err)
}
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "create",
"model": safeModelForTool(model),
})
return string(result)
case "update":
modelID := strings.TrimSpace(args.ModelID)
if modelID == "" {
return `{"error":"model_id is required for update"}`
}
existing, err := a.store.AIModel().Get(storeUserID, modelID)
if err != nil {
return fmt.Sprintf(`{"error":"failed to load model config: %s"}`, err)
}
enabled := existing.Enabled
if args.Enabled != nil {
enabled = *args.Enabled
}
customAPIURL := existing.CustomAPIURL
if strings.TrimSpace(args.CustomAPIURL) != "" {
customAPIURL = strings.TrimSpace(args.CustomAPIURL)
}
customModelName := existing.CustomModelName
if strings.TrimSpace(args.CustomModelName) != "" {
customModelName = strings.TrimSpace(args.CustomModelName)
}
apiKey := strings.TrimSpace(args.APIKey)
effectiveAPIKey := string(existing.APIKey)
if apiKey != "" {
effectiveAPIKey = apiKey
}
if enabled && !modelConfigUsable(existing.Provider, existing.ID, effectiveAPIKey, customAPIURL, customModelName) {
return `{"error":"cannot enable model config before API key is configured"}`
}
if err := a.store.AIModel().Update(storeUserID, existing.ID, enabled, apiKey, customAPIURL, customModelName); err != nil {
return fmt.Sprintf(`{"error":"failed to update model config: %s"}`, err)
}
updated, err := a.store.AIModel().Get(storeUserID, existing.ID)
if err != nil {
return fmt.Sprintf(`{"error":"model updated but failed to reload: %s"}`, err)
}
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "update",
"model": safeModelForTool(updated),
})
return string(result)
case "delete":
modelID := strings.TrimSpace(args.ModelID)
if modelID == "" {
return `{"error":"model_id is required for delete"}`
}
if err := a.store.AIModel().Delete(storeUserID, modelID); err != nil {
return fmt.Sprintf(`{"error":"failed to delete model config: %s"}`, err)
}
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "delete",
"model_id": modelID,
})
return string(result)
default:
return `{"error":"invalid action"}`
}
}
func (a *Agent) toolGetStrategies(storeUserID string) string {
if a.store == nil {
return `{"error":"store unavailable"}`
}
strategies, err := a.store.Strategy().List(storeUserID)
if err != nil {
return fmt.Sprintf(`{"error":"failed to load strategies: %s"}`, err)
}
safeStrategies := make([]safeStrategyToolConfig, 0, len(strategies))
for _, strategy := range strategies {
safeStrategies = append(safeStrategies, safeStrategyForTool(strategy))
}
result, _ := json.Marshal(map[string]any{
"strategies": safeStrategies,
"count": len(safeStrategies),
})
return string(result)
}
func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string {
if a.store == nil {
return `{"error":"store unavailable"}`
}
var args struct {
Action string `json:"action"`
StrategyID string `json:"strategy_id"`
Name string `json:"name"`
Description string `json:"description"`
Lang string `json:"lang"`
IsPublic *bool `json:"is_public"`
ConfigVisible *bool `json:"config_visible"`
Config map[string]any `json:"config"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err)
}
switch strings.TrimSpace(args.Action) {
case "list":
return a.toolGetStrategies(storeUserID)
case "get_default_config":
lang := strings.TrimSpace(args.Lang)
if lang != "zh" {
lang = "en"
}
cfg := store.GetDefaultStrategyConfig(lang)
payload, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "get_default_config",
"config": cfg,
})
return string(payload)
case "create":
name := strings.TrimSpace(args.Name)
if name == "" {
return `{"error":"name is required for create"}`
}
var cfg any = store.GetDefaultStrategyConfig(strings.TrimSpace(args.Lang))
if len(args.Config) > 0 {
cfg = args.Config
}
configJSON, err := json.Marshal(cfg)
if err != nil {
return fmt.Sprintf(`{"error":"failed to serialize strategy config: %s"}`, err)
}
record := &store.Strategy{
ID: fmt.Sprintf("strategy_%d", time.Now().UnixNano()),
UserID: storeUserID,
Name: name,
Description: strings.TrimSpace(args.Description),
IsActive: false,
IsDefault: false,
IsPublic: args.IsPublic != nil && *args.IsPublic,
ConfigVisible: args.ConfigVisible == nil || *args.ConfigVisible,
Config: string(configJSON),
}
if err := a.store.Strategy().Create(record); err != nil {
return fmt.Sprintf(`{"error":"failed to create strategy: %s"}`, err)
}
payload, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "create",
"strategy": safeStrategyForTool(record),
})
return string(payload)
case "update":
strategyID := strings.TrimSpace(args.StrategyID)
if strategyID == "" {
return `{"error":"strategy_id is required for update"}`
}
existing, err := a.store.Strategy().Get(storeUserID, strategyID)
if err != nil {
return fmt.Sprintf(`{"error":"failed to load strategy: %s"}`, err)
}
if existing.IsDefault {
return `{"error":"cannot modify system default strategy"}`
}
name := existing.Name
if trimmed := strings.TrimSpace(args.Name); trimmed != "" {
name = trimmed
}
description := existing.Description
if trimmed := strings.TrimSpace(args.Description); trimmed != "" {
description = trimmed
}
isPublic := existing.IsPublic
if args.IsPublic != nil {
isPublic = *args.IsPublic
}
configVisible := existing.ConfigVisible
if args.ConfigVisible != nil {
configVisible = *args.ConfigVisible
}
configJSON := existing.Config
if len(args.Config) > 0 {
raw, err := json.Marshal(args.Config)
if err != nil {
return fmt.Sprintf(`{"error":"failed to serialize strategy config: %s"}`, err)
}
configJSON = string(raw)
}
record := &store.Strategy{
ID: existing.ID,
UserID: storeUserID,
Name: name,
Description: description,
IsPublic: isPublic,
ConfigVisible: configVisible,
Config: configJSON,
}
if err := a.store.Strategy().Update(record); err != nil {
return fmt.Sprintf(`{"error":"failed to update strategy: %s"}`, err)
}
updated, err := a.store.Strategy().Get(storeUserID, existing.ID)
if err != nil {
return fmt.Sprintf(`{"error":"strategy updated but failed to reload: %s"}`, err)
}
payload, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "update",
"strategy": safeStrategyForTool(updated),
})
return string(payload)
case "delete":
strategyID := strings.TrimSpace(args.StrategyID)
if strategyID == "" {
return `{"error":"strategy_id is required for delete"}`
}
if err := a.store.Strategy().Delete(storeUserID, strategyID); err != nil {
if strings.Contains(err.Error(), "cannot delete active strategy") {
strategies, listErr := a.store.Strategy().List(storeUserID)
if listErr != nil {
return fmt.Sprintf(`{"error":"failed to prepare active strategy deletion: %s"}`, listErr)
}
var fallbackID string
for _, strategy := range strategies {
if strategy == nil || strategy.ID == strategyID {
continue
}
if strategy.IsDefault {
fallbackID = strategy.ID
break
}
if fallbackID == "" {
fallbackID = strategy.ID
}
}
if fallbackID == "" {
defaultConfig := store.GetDefaultStrategyConfig("zh")
defaultConfig.ClampLimits()
configJSON, marshalErr := json.Marshal(defaultConfig)
if marshalErr != nil {
return fmt.Sprintf(`{"error":"failed to create fallback strategy config: %s"}`, marshalErr)
}
fallbackID = fmt.Sprintf("strategy_%d", time.Now().UnixNano())
fallbackStrategy := &store.Strategy{
ID: fallbackID,
UserID: storeUserID,
Name: "默认策略",
Description: "Agent-generated fallback strategy",
Config: string(configJSON),
}
if createErr := a.store.Strategy().Create(fallbackStrategy); createErr != nil {
return fmt.Sprintf(`{"error":"failed to create fallback strategy before deletion: %s"}`, createErr)
}
}
if activateErr := a.store.Strategy().SetActive(storeUserID, fallbackID); activateErr != nil {
return fmt.Sprintf(`{"error":"failed to switch active strategy before deletion: %s"}`, activateErr)
}
if retryErr := a.store.Strategy().Delete(storeUserID, strategyID); retryErr != nil {
return fmt.Sprintf(`{"error":"failed to delete strategy: %s"}`, retryErr)
}
} else {
return fmt.Sprintf(`{"error":"failed to delete strategy: %s"}`, err)
}
}
payload, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "delete",
"strategy_id": strategyID,
})
return string(payload)
case "activate":
strategyID := strings.TrimSpace(args.StrategyID)
if strategyID == "" {
return `{"error":"strategy_id is required for activate"}`
}
if err := a.store.Strategy().SetActive(storeUserID, strategyID); err != nil {
return fmt.Sprintf(`{"error":"failed to activate strategy: %s"}`, err)
}
updated, err := a.store.Strategy().Get(storeUserID, strategyID)
if err != nil {
return fmt.Sprintf(`{"error":"strategy activated but failed to reload: %s"}`, err)
}
payload, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "activate",
"strategy": safeStrategyForTool(updated),
})
return string(payload)
case "duplicate":
sourceID := strings.TrimSpace(args.StrategyID)
name := strings.TrimSpace(args.Name)
if sourceID == "" {
return `{"error":"strategy_id is required for duplicate"}`
}
if name == "" {
return `{"error":"name is required for duplicate"}`
}
newID := fmt.Sprintf("strategy_%d", time.Now().UnixNano())
if err := a.store.Strategy().Duplicate(storeUserID, sourceID, newID, name); err != nil {
return fmt.Sprintf(`{"error":"failed to duplicate strategy: %s"}`, err)
}
created, err := a.store.Strategy().Get(storeUserID, newID)
if err != nil {
return fmt.Sprintf(`{"error":"strategy duplicated but failed to reload: %s"}`, err)
}
payload, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "duplicate",
"strategy": safeStrategyForTool(created),
})
return string(payload)
default:
return `{"error":"invalid action"}`
}
}
func (a *Agent) toolManageTrader(storeUserID, argsJSON string) string {
if a.store == nil {
return `{"error":"store unavailable"}`
}
var args manageTraderArgs
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err)
}
switch strings.TrimSpace(args.Action) {
case "list":
return a.toolListTraders(storeUserID)
case "create":
return a.toolCreateTrader(storeUserID, args)
case "update":
return a.toolUpdateTrader(storeUserID, args)
case "delete":
return a.toolDeleteTrader(storeUserID, strings.TrimSpace(args.TraderID))
case "start":
return a.toolStartTrader(storeUserID, strings.TrimSpace(args.TraderID))
case "stop":
return a.toolStopTrader(storeUserID, strings.TrimSpace(args.TraderID))
default:
return `{"error":"invalid action"}`
}
}
func (a *Agent) toolListTraders(storeUserID string) string {
traders, err := a.store.Trader().List(storeUserID)
if err != nil {
return fmt.Sprintf(`{"error":"failed to list traders: %s"}`, err)
}
safeTraders := make([]safeTraderToolConfig, 0, len(traders))
for _, traderCfg := range traders {
isRunning := traderCfg.IsRunning
if a.traderManager != nil {
if memTrader, err := a.traderManager.GetTrader(traderCfg.ID); err == nil {
if running, ok := memTrader.GetStatus()["is_running"].(bool); ok {
isRunning = running
}
}
}
safeTraders = append(safeTraders, safeTraderForTool(traderCfg, isRunning))
}
result, _ := json.Marshal(map[string]any{
"traders": safeTraders,
"count": len(safeTraders),
})
return string(result)
}
func (a *Agent) validateTraderReferences(storeUserID, aiModelID, exchangeID, strategyID string) error {
if strings.TrimSpace(aiModelID) == "" {
return fmt.Errorf("ai_model_id is required")
}
if strings.TrimSpace(exchangeID) == "" {
return fmt.Errorf("exchange_id is required")
}
model, err := a.store.AIModel().Get(storeUserID, strings.TrimSpace(aiModelID))
if err != nil {
return fmt.Errorf("invalid ai_model_id: %w", err)
}
if !model.Enabled {
return fmt.Errorf("ai model is disabled")
}
exchange, err := a.store.Exchange().GetByID(storeUserID, strings.TrimSpace(exchangeID))
if err != nil {
return fmt.Errorf("invalid exchange_id: %w", err)
}
if !exchange.Enabled {
return fmt.Errorf("exchange is disabled")
}
if trimmed := strings.TrimSpace(strategyID); trimmed != "" {
if _, err := a.store.Strategy().Get(storeUserID, trimmed); err != nil {
return fmt.Errorf("invalid strategy_id: %w", err)
}
}
return nil
}
func (a *Agent) toolCreateTrader(storeUserID string, args manageTraderArgs) string {
name := strings.TrimSpace(args.Name)
if name == "" {
return `{"error":"name is required for create"}`
}
if err := a.validateTraderReferences(storeUserID, args.AIModelID, args.ExchangeID, args.StrategyID); err != nil {
return fmt.Sprintf(`{"error":"%s"}`, err)
}
scanInterval := 3
if args.ScanIntervalMinutes != nil && *args.ScanIntervalMinutes > 0 {
scanInterval = *args.ScanIntervalMinutes
if scanInterval < 3 {
scanInterval = 3
}
}
initialBalance := 0.0
if args.InitialBalance != nil && *args.InitialBalance > 0 {
initialBalance = *args.InitialBalance
}
isCrossMargin := true
if args.IsCrossMargin != nil {
isCrossMargin = *args.IsCrossMargin
}
showInCompetition := true
if args.ShowInCompetition != nil {
showInCompetition = *args.ShowInCompetition
}
btcEthLeverage := 10
if args.BTCETHLeverage != nil && *args.BTCETHLeverage > 0 {
btcEthLeverage = *args.BTCETHLeverage
}
altcoinLeverage := 5
if args.AltcoinLeverage != nil && *args.AltcoinLeverage > 0 {
altcoinLeverage = *args.AltcoinLeverage
}
overrideBasePrompt := false
if args.OverrideBasePrompt != nil {
overrideBasePrompt = *args.OverrideBasePrompt
}
useAI500 := false
if args.UseAI500 != nil {
useAI500 = *args.UseAI500
}
useOITop := false
if args.UseOITop != nil {
useOITop = *args.UseOITop
}
systemPromptTemplate := strings.TrimSpace(args.SystemPromptTemplate)
if systemPromptTemplate == "" {
systemPromptTemplate = "default"
}
exchangeIDShort := strings.TrimSpace(args.ExchangeID)
if len(exchangeIDShort) > 8 {
exchangeIDShort = exchangeIDShort[:8]
}
traderID := fmt.Sprintf("%s_%s_%d", exchangeIDShort, strings.TrimSpace(args.AIModelID), time.Now().Unix())
record := &store.Trader{
ID: traderID,
UserID: storeUserID,
Name: name,
AIModelID: strings.TrimSpace(args.AIModelID),
ExchangeID: strings.TrimSpace(args.ExchangeID),
StrategyID: strings.TrimSpace(args.StrategyID),
InitialBalance: initialBalance,
ScanIntervalMinutes: scanInterval,
IsRunning: false,
IsCrossMargin: isCrossMargin,
ShowInCompetition: showInCompetition,
BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage,
TradingSymbols: strings.TrimSpace(args.TradingSymbols),
UseAI500: useAI500,
UseOITop: useOITop,
CustomPrompt: strings.TrimSpace(args.CustomPrompt),
OverrideBasePrompt: overrideBasePrompt,
SystemPromptTemplate: systemPromptTemplate,
}
if err := a.store.Trader().Create(record); err != nil {
return fmt.Sprintf(`{"error":"failed to create trader: %s"}`, err)
}
if a.traderManager != nil {
_ = a.traderManager.LoadUserTradersFromStore(a.store, storeUserID)
}
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "create",
"trader": safeTraderForTool(record, false),
})
return string(result)
}
func (a *Agent) toolUpdateTrader(storeUserID string, args manageTraderArgs) string {
traderID := strings.TrimSpace(args.TraderID)
if traderID == "" {
return `{"error":"trader_id is required for update"}`
}
traders, err := a.store.Trader().List(storeUserID)
if err != nil {
return fmt.Sprintf(`{"error":"failed to load traders: %s"}`, err)
}
var existing *store.Trader
for _, item := range traders {
if item.ID == traderID {
existing = item
break
}
}
if existing == nil {
return `{"error":"trader not found"}`
}
name := existing.Name
if trimmed := strings.TrimSpace(args.Name); trimmed != "" {
name = trimmed
}
aiModelID := existing.AIModelID
if trimmed := strings.TrimSpace(args.AIModelID); trimmed != "" {
aiModelID = trimmed
}
exchangeID := existing.ExchangeID
if trimmed := strings.TrimSpace(args.ExchangeID); trimmed != "" {
exchangeID = trimmed
}
strategyID := existing.StrategyID
if trimmed := strings.TrimSpace(args.StrategyID); trimmed != "" {
strategyID = trimmed
}
if err := a.validateTraderReferences(storeUserID, aiModelID, exchangeID, strategyID); err != nil {
return fmt.Sprintf(`{"error":"%s"}`, err)
}
record := &store.Trader{
ID: existing.ID,
UserID: storeUserID,
Name: name,
AIModelID: aiModelID,
ExchangeID: exchangeID,
StrategyID: strategyID,
InitialBalance: existing.InitialBalance,
ScanIntervalMinutes: existing.ScanIntervalMinutes,
IsRunning: existing.IsRunning,
IsCrossMargin: existing.IsCrossMargin,
ShowInCompetition: existing.ShowInCompetition,
BTCETHLeverage: existing.BTCETHLeverage,
AltcoinLeverage: existing.AltcoinLeverage,
TradingSymbols: existing.TradingSymbols,
UseAI500: existing.UseAI500,
UseOITop: existing.UseOITop,
CustomPrompt: existing.CustomPrompt,
OverrideBasePrompt: existing.OverrideBasePrompt,
SystemPromptTemplate: existing.SystemPromptTemplate,
}
if args.InitialBalance != nil && *args.InitialBalance > 0 {
record.InitialBalance = *args.InitialBalance
}
if args.ScanIntervalMinutes != nil && *args.ScanIntervalMinutes > 0 {
record.ScanIntervalMinutes = *args.ScanIntervalMinutes
if record.ScanIntervalMinutes < 3 {
record.ScanIntervalMinutes = 3
}
}
if args.IsCrossMargin != nil {
record.IsCrossMargin = *args.IsCrossMargin
}
if args.ShowInCompetition != nil {
record.ShowInCompetition = *args.ShowInCompetition
}
if args.BTCETHLeverage != nil && *args.BTCETHLeverage > 0 {
record.BTCETHLeverage = *args.BTCETHLeverage
}
if args.AltcoinLeverage != nil && *args.AltcoinLeverage > 0 {
record.AltcoinLeverage = *args.AltcoinLeverage
}
if trimmed := strings.TrimSpace(args.TradingSymbols); trimmed != "" {
record.TradingSymbols = trimmed
}
if trimmed := strings.TrimSpace(args.CustomPrompt); trimmed != "" {
record.CustomPrompt = trimmed
}
if args.OverrideBasePrompt != nil {
record.OverrideBasePrompt = *args.OverrideBasePrompt
}
if trimmed := strings.TrimSpace(args.SystemPromptTemplate); trimmed != "" {
record.SystemPromptTemplate = trimmed
}
if args.UseAI500 != nil {
record.UseAI500 = *args.UseAI500
}
if args.UseOITop != nil {
record.UseOITop = *args.UseOITop
}
if err := a.store.Trader().Update(record); err != nil {
return fmt.Sprintf(`{"error":"failed to update trader: %s"}`, err)
}
if a.traderManager != nil {
a.traderManager.RemoveTrader(record.ID)
_ = a.traderManager.LoadUserTradersFromStore(a.store, storeUserID)
}
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "update",
"trader": safeTraderForTool(record, record.IsRunning),
})
return string(result)
}
func (a *Agent) toolDeleteTrader(storeUserID, traderID string) string {
if traderID == "" {
return `{"error":"trader_id is required for delete"}`
}
if err := a.store.Trader().Delete(storeUserID, traderID); err != nil {
return fmt.Sprintf(`{"error":"failed to delete trader: %s"}`, err)
}
if a.traderManager != nil {
if trader, err := a.traderManager.GetTrader(traderID); err == nil {
trader.Stop()
}
a.traderManager.RemoveTrader(traderID)
}
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "delete",
"trader_id": traderID,
})
return string(result)
}
func (a *Agent) toolStartTrader(storeUserID, traderID string) string {
if traderID == "" {
return `{"error":"trader_id is required for start"}`
}
if a.traderManager == nil {
return `{"error":"trader manager unavailable"}`
}
if _, err := a.store.Trader().GetFullConfig(storeUserID, traderID); err != nil {
return fmt.Sprintf(`{"error":"trader not found or inaccessible: %s"}`, err)
}
if existing, err := a.traderManager.GetTrader(traderID); err == nil {
if running, ok := existing.GetStatus()["is_running"].(bool); ok && running {
return `{"error":"trader is already running"}`
}
a.traderManager.RemoveTrader(traderID)
}
if err := a.traderManager.LoadUserTradersFromStore(a.store, storeUserID); err != nil {
return fmt.Sprintf(`{"error":"failed to load trader config: %s"}`, err)
}
trader, err := a.traderManager.GetTrader(traderID)
if err != nil {
if loadErr := a.traderManager.GetLoadError(traderID); loadErr != nil {
return fmt.Sprintf(`{"error":"failed to load trader: %s"}`, loadErr)
}
return fmt.Sprintf(`{"error":"failed to get trader: %s"}`, err)
}
safe.GoNamed("agent-trader-start-"+traderID, func() {
if runErr := trader.Run(); runErr != nil {
a.logger.Error("agent tool trader runtime error", "trader_id", traderID, "error", runErr)
}
})
_ = a.store.Trader().UpdateStatus(storeUserID, traderID, true)
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "start",
"trader_id": traderID,
"message": "Trader started",
})
return string(result)
}
func (a *Agent) toolStopTrader(storeUserID, traderID string) string {
if traderID == "" {
return `{"error":"trader_id is required for stop"}`
}
if a.traderManager == nil {
return `{"error":"trader manager unavailable"}`
}
if _, err := a.store.Trader().GetFullConfig(storeUserID, traderID); err != nil {
return fmt.Sprintf(`{"error":"trader not found or inaccessible: %s"}`, err)
}
trader, err := a.traderManager.GetTrader(traderID)
if err != nil {
return fmt.Sprintf(`{"error":"trader not loaded: %s"}`, err)
}
if running, ok := trader.GetStatus()["is_running"].(bool); ok && !running {
return `{"error":"trader is already stopped"}`
}
trader.Stop()
_ = a.store.Trader().UpdateStatus(storeUserID, traderID, false)
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "stop",
"trader_id": traderID,
"message": "Trader stopped",
})
return string(result)
}
func (a *Agent) toolGetPreferences(userID int64) string {
prefs := a.getPersistentPreferences(userID)
result, _ := json.Marshal(map[string]any{
"preferences": prefs,
"count": len(prefs),
})
return string(result)
}
func (a *Agent) toolManagePreferences(userID int64, argsJSON string) string {
var args struct {
Action string `json:"action"`
Text string `json:"text"`
Match string `json:"match"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err)
}
switch args.Action {
case "add":
prefs, created, err := a.addPersistentPreference(userID, args.Text)
if err != nil {
return fmt.Sprintf(`{"error": "%s"}`, err)
}
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "add",
"preference": created,
"preferences": prefs,
})
return string(result)
case "update":
prefs, updated, err := a.updatePersistentPreference(userID, args.Match, args.Text)
if err != nil {
return fmt.Sprintf(`{"error": "%s"}`, err)
}
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "update",
"preference": updated,
"preferences": prefs,
})
return string(result)
case "delete":
prefs, removed, err := a.deletePersistentPreference(userID, args.Match)
if err != nil {
return fmt.Sprintf(`{"error": "%s"}`, err)
}
result, _ := json.Marshal(map[string]any{
"status": "ok",
"action": "delete",
"preference": removed,
"preferences": prefs,
})
return string(result)
default:
return `{"error": "invalid action"}`
}
}
func (a *Agent) toolSearchStock(argsJSON string) string {
var args struct {
Keyword string `json:"keyword"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err)
}
if args.Keyword == "" {
return `{"error": "keyword is required"}`
}
results, err := searchStock(args.Keyword)
if err != nil {
return fmt.Sprintf(`{"error": "search failed: %s"}`, err)
}
if len(results) == 0 {
return fmt.Sprintf(`{"results": [], "message": "no stocks found for '%s'"}`, args.Keyword)
}
// Limit to top 10 results
if len(results) > 10 {
results = results[:10]
}
// Also fetch real-time quotes for the top results (up to 3)
type enrichedResult struct {
Name string `json:"name"`
Code string `json:"code"`
Market string `json:"market"`
Quote *StockQuote `json:"quote,omitempty"`
}
var enriched []enrichedResult
for i, r := range results {
er := enrichedResult{Name: r.Name, Code: r.Code, Market: r.Market}
if i < 3 {
q, qErr := fetchStockQuote(r.Code)
if qErr == nil && q.Price > 0 {
er.Quote = q
}
}
enriched = append(enriched, er)
}
result, _ := json.Marshal(map[string]any{
"keyword": args.Keyword,
"count": len(enriched),
"results": enriched,
})
return string(result)
}
func (a *Agent) toolExecuteTrade(_ context.Context, userID int64, lang, argsJSON string) string {
var args struct {
Action string `json:"action"`
Symbol string `json:"symbol"`
Quantity float64 `json:"quantity"`
Leverage int `json:"leverage"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err)
}
// Normalize symbol
sym := strings.ToUpper(args.Symbol)
// Only append USDT for crypto symbols; stock tickers (e.g. AAPL, TSLA) stay as-is
if !isStockSymbol(sym) && !strings.HasSuffix(sym, "USDT") {
sym += "USDT"
}
// Validate action
validActions := map[string]bool{
"open_long": true, "open_short": true,
"close_long": true, "close_short": true,
}
if !validActions[args.Action] {
return fmt.Sprintf(`{"error": "invalid action: %s"}`, args.Action)
}
// For open actions, quantity must be > 0
if (args.Action == "open_long" || args.Action == "open_short") && args.Quantity <= 0 {
return `{"error": "quantity must be > 0 for opening positions"}`
}
// For stock symbols, check market hours and warn if closed
var marketWarning string
if isStockSymbol(sym) && a.traderManager != nil {
for _, t := range a.traderManager.GetAllTraders() {
if t.GetExchange() == "alpaca" {
ut := t.GetUnderlyingTrader()
if ut == nil {
continue
}
type marketChecker interface {
IsMarketOpen() (bool, string, error)
}
if mc, ok := ut.(marketChecker); ok {
isOpen, status, err := mc.IsMarketOpen()
if err == nil && !isOpen {
marketWarning = fmt.Sprintf("⚠️ US market is currently %s. Order will be queued for next market open.", status)
}
}
break
}
}
}
// Create pending trade — requires user confirmation
trade := &TradeAction{
ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()),
Action: args.Action,
Symbol: sym,
Quantity: args.Quantity,
Leverage: args.Leverage,
Status: "pending_confirmation",
CreatedAt: time.Now().Unix(),
}
a.pending.Add(trade)
a.pending.CleanExpired()
// Return confirmation info to LLM so it can present it to the user
resultMap := map[string]any{
"status": "pending_confirmation",
"trade_id": trade.ID,
"action": trade.Action,
"symbol": trade.Symbol,
"quantity": trade.Quantity,
"leverage": trade.Leverage,
"message": fmt.Sprintf("Trade created. User must confirm with: 确认 %s (or: confirm %s)", trade.ID, trade.ID),
"expires": "5 minutes",
}
if marketWarning != "" {
resultMap["market_warning"] = marketWarning
}
result, _ := json.Marshal(resultMap)
return string(result)
}
func (a *Agent) toolGetPositions() string {
if a.traderManager == nil {
return `{"error": "no trader manager configured"}`
}
var positions []map[string]any
for id, t := range a.traderManager.GetAllTraders() {
pos, err := t.GetPositions()
if err != nil {
continue
}
for _, p := range pos {
size := toFloat(p["size"])
if size == 0 {
continue
}
tid := id
if len(tid) > 8 {
tid = tid[:8]
}
positions = append(positions, map[string]any{
"trader": tid,
"exchange": t.GetExchange(),
"symbol": p["symbol"],
"side": p["side"],
"size": size,
"entry_price": toFloat(p["entryPrice"]),
"mark_price": toFloat(p["markPrice"]),
"unrealized_pnl": toFloat(p["unrealizedPnl"]),
"leverage": p["leverage"],
})
}
}
if len(positions) == 0 {
return `{"positions": [], "message": "no open positions"}`
}
result, _ := json.Marshal(map[string]any{"positions": positions})
return string(result)
}
func (a *Agent) toolGetBalance() string {
if a.traderManager == nil {
return `{"error": "no trader manager configured"}`
}
var balances []map[string]any
for id, t := range a.traderManager.GetAllTraders() {
info, err := t.GetAccountInfo()
if err != nil {
continue
}
tid := id
if len(tid) > 8 {
tid = tid[:8]
}
balances = append(balances, map[string]any{
"trader": tid,
"name": t.GetName(),
"exchange": t.GetExchange(),
"total_equity": toFloat(info["total_equity"]),
"available": toFloat(info["available_balance"]),
"used_margin": toFloat(info["used_margin"]),
})
}
result, _ := json.Marshal(map[string]any{"balances": balances})
return string(result)
}
func (a *Agent) toolGetMarketPrice(argsJSON string) string {
var args struct {
Symbol string `json:"symbol"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err)
}
sym := strings.ToUpper(args.Symbol)
if !isStockSymbol(sym) && !strings.HasSuffix(sym, "USDT") {
sym += "USDT"
}
if a.traderManager == nil {
return `{"error": "no trader manager configured"}`
}
wantStock := isStockSymbol(sym)
for _, t := range a.traderManager.GetAllTraders() {
underlying := t.GetUnderlyingTrader()
if underlying == nil {
continue
}
// Route to correct exchange type (stock vs crypto)
isAlpaca := t.GetExchange() == "alpaca"
if wantStock && !isAlpaca {
continue
}
if !wantStock && isAlpaca {
continue
}
price, err := underlying.GetMarketPrice(sym)
if err == nil && price > 0 {
priceResult := map[string]any{
"symbol": sym,
"price": price,
}
// For stocks, include market status
if wantStock && isAlpaca {
type marketChecker interface {
IsMarketOpen() (bool, string, error)
}
if mc, ok := underlying.(marketChecker); ok {
isOpen, status, mErr := mc.IsMarketOpen()
if mErr == nil {
priceResult["market_open"] = isOpen
priceResult["market_status"] = status
}
}
}
result, _ := json.Marshal(priceResult)
return string(result)
}
}
return fmt.Sprintf(`{"error": "could not get price for %s"}`, sym)
}
func (a *Agent) toolGetTradeHistory(argsJSON string) string {
if a.store == nil {
return `{"error": "store not available"}`
}
var args struct {
Limit int `json:"limit"`
}
if argsJSON != "" {
_ = json.Unmarshal([]byte(argsJSON), &args)
}
if args.Limit <= 0 {
args.Limit = 10
}
if args.Limit > 50 {
args.Limit = 50
}
if a.traderManager == nil {
return `{"error": "no trader manager configured"}`
}
var trades []map[string]any
var totalPnL float64
var wins, losses int
for id, t := range a.traderManager.GetAllTraders() {
positions, err := a.store.Position().GetClosedPositions(id, args.Limit)
if err != nil {
continue
}
tid := id
if len(tid) > 8 {
tid = tid[:8]
}
for _, pos := range positions {
pnl := pos.RealizedPnL
totalPnL += pnl
if pnl >= 0 {
wins++
} else {
losses++
}
entryTime := ""
if pos.EntryTime > 0 {
entryTime = time.Unix(pos.EntryTime/1000, 0).Format("2006-01-02 15:04")
}
exitTime := ""
if pos.ExitTime > 0 {
exitTime = time.Unix(pos.ExitTime/1000, 0).Format("2006-01-02 15:04")
}
trades = append(trades, map[string]any{
"trader": t.GetName(),
"trader_id": tid,
"symbol": pos.Symbol,
"side": pos.Side,
"entry_price": pos.EntryPrice,
"exit_price": pos.ExitPrice,
"quantity": pos.Quantity,
"leverage": pos.Leverage,
"pnl": pnl,
"entry_time": entryTime,
"exit_time": exitTime,
})
}
}
if len(trades) == 0 {
return `{"trades": [], "message": "no closed trades found"}`
}
// Sort trades by exit time (most recent first) for consistent ordering across traders
sort.Slice(trades, func(i, j int) bool {
ti, _ := trades[i]["exit_time"].(string)
tj, _ := trades[j]["exit_time"].(string)
return ti > tj // reverse chronological
})
// Only return up to the limit
if len(trades) > args.Limit {
trades = trades[:args.Limit]
}
winRate := 0.0
total := wins + losses
if total > 0 {
winRate = float64(wins) / float64(total) * 100
}
result, _ := json.Marshal(map[string]any{
"trades": trades,
"summary": map[string]any{
"total_trades": total,
"wins": wins,
"losses": losses,
"win_rate": fmt.Sprintf("%.1f%%", winRate),
"total_pnl": totalPnL,
},
})
return string(result)
}
func (a *Agent) toolGetCandidateCoins(storeUserID string, userID int64, argsJSON string) string {
if a.store == nil {
return `{"error":"store unavailable"}`
}
var args struct {
TraderID string `json:"trader_id"`
StrategyID string `json:"strategy_id"`
}
if strings.TrimSpace(argsJSON) != "" {
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err)
}
}
traderID := strings.TrimSpace(args.TraderID)
strategyID := strings.TrimSpace(args.StrategyID)
state := a.getExecutionState(userID)
if traderID == "" && state.CurrentReferences != nil && state.CurrentReferences.Trader != nil {
traderID = strings.TrimSpace(state.CurrentReferences.Trader.ID)
}
if strategyID == "" && state.CurrentReferences != nil && state.CurrentReferences.Strategy != nil {
strategyID = strings.TrimSpace(state.CurrentReferences.Strategy.ID)
}
if traderID != "" {
return a.toolGetCandidateCoinsForTrader(storeUserID, traderID)
}
if strategyID != "" {
return a.toolGetCandidateCoinsForStrategy(storeUserID, strategyID)
}
return `{"error":"trader_id or strategy_id is required"}`
}
func (a *Agent) toolGetCandidateCoinsForTrader(storeUserID, traderID string) string {
if a.traderManager == nil {
return `{"error":"no trader manager configured"}`
}
record, err := a.store.Trader().GetFullConfig(storeUserID, traderID)
if err != nil {
return fmt.Sprintf(`{"error":"failed to load trader: %s"}`, err)
}
memTrader, err := a.traderManager.GetTrader(traderID)
if err != nil {
return fmt.Sprintf(`{"error":"trader is not loaded in memory: %s"}`, err)
}
coins, coinErr := memTrader.GetCandidateCoins()
cfg := memTrader.GetStrategyConfig()
status := memTrader.GetStatus()
isRunning, _ := status["is_running"].(bool)
payload := map[string]any{
"trader": safeTraderForTool(record.Trader, isRunning),
"coin_source": candidateCoinSourceSummary(cfg),
"candidate_count": len(coins),
"candidate_symbols": candidateCoinSymbols(coins),
"candidates": candidateCoinDetails(coins),
}
if coinErr != nil {
payload["error"] = coinErr.Error()
}
result, _ := json.Marshal(payload)
return string(result)
}
func (a *Agent) toolGetCandidateCoinsForStrategy(storeUserID, strategyID string) string {
record, err := a.store.Strategy().Get(storeUserID, strategyID)
if err != nil {
return fmt.Sprintf(`{"error":"failed to load strategy: %s"}`, err)
}
cfg, err := record.ParseConfig()
if err != nil {
return fmt.Sprintf(`{"error":"failed to parse strategy config: %s"}`, err)
}
engine := kernel.NewStrategyEngine(cfg)
coins, coinErr := engine.GetCandidateCoins()
payload := map[string]any{
"strategy": safeStrategyForTool(record),
"coin_source": candidateCoinSourceSummary(cfg),
"candidate_count": len(coins),
"candidate_symbols": candidateCoinSymbols(coins),
"candidates": candidateCoinDetails(coins),
}
if coinErr != nil {
payload["error"] = coinErr.Error()
}
result, _ := json.Marshal(payload)
return string(result)
}
func candidateCoinSourceSummary(cfg *store.StrategyConfig) map[string]any {
if cfg == nil {
return nil
}
return map[string]any{
"source_type": cfg.CoinSource.SourceType,
"use_ai500": cfg.CoinSource.UseAI500,
"ai500_limit": cfg.CoinSource.AI500Limit,
"use_oi_top": cfg.CoinSource.UseOITop,
"oi_top_limit": cfg.CoinSource.OITopLimit,
"use_oi_low": cfg.CoinSource.UseOILow,
"oi_low_limit": cfg.CoinSource.OILowLimit,
"use_hyper_all": cfg.CoinSource.UseHyperAll,
"use_hyper_main": cfg.CoinSource.UseHyperMain,
"hyper_main_limit": cfg.CoinSource.HyperMainLimit,
"static_coins": cfg.CoinSource.StaticCoins,
"excluded_coins": cfg.CoinSource.ExcludedCoins,
}
}
func candidateCoinSymbols(coins []kernel.CandidateCoin) []string {
out := make([]string, 0, len(coins))
for _, coin := range coins {
out = append(out, coin.Symbol)
}
return out
}
func candidateCoinDetails(coins []kernel.CandidateCoin) []map[string]any {
out := make([]map[string]any, 0, len(coins))
for _, coin := range coins {
out = append(out, map[string]any{
"symbol": coin.Symbol,
"sources": coin.Sources,
})
}
return out
}
// knownCryptoSymbols is a set of well-known cryptocurrency base symbols.
// Without this, isStockSymbol("BTC") would incorrectly return true because
// "BTC" is 3 uppercase letters and the suffix check only catches "BTCUSDT"-style pairs.
var knownCryptoSymbols = map[string]bool{
"BTC": true, "ETH": true, "SOL": true, "BNB": true, "XRP": true,
"DOGE": true, "ADA": true, "AVAX": true, "DOT": true, "LINK": true,
"PEPE": true, "SHIB": true, "ARB": true, "OP": true, "SUI": true,
"APT": true, "SEI": true, "TIA": true, "JUP": true, "WIF": true,
"NEAR": true, "ATOM": true, "FTM": true, "MATIC": true, "INJ": true,
"RENDER": true, "FET": true, "TAO": true, "WLD": true, "USDT": true,
"USDC": true, "BUSD": true, "DAI": true, "UNI": true, "AAVE": true,
"LDO": true, "MKR": true, "CRV": true, "PENDLE": true, "ENA": true,
"ONDO": true, "TRUMP": true, "TON": true, "TRX": true, "LTC": true,
"BCH": true, "ETC": true, "FIL": true, "ICP": true, "HBAR": true,
"VET": true, "ALGO": true, "SAND": true, "MANA": true, "AXS": true,
"GMT": true, "APE": true, "GALA": true, "IMX": true, "BLUR": true,
"STRK": true, "ZK": true, "W": true, "IO": true, "ZRO": true,
"BONK": true, "FLOKI": true, "ORDI": true, "STX": true, "RUNE": true,
}
// isStockSymbol heuristically determines if a symbol is a stock ticker (not crypto).
// Stock tickers are 1-5 uppercase letters without numeric suffixes like "USDT".
// Known crypto base symbols (BTC, ETH, SOL etc.) are excluded.
func isStockSymbol(sym string) bool {
sym = strings.ToUpper(sym)
// Check known crypto base symbols first (critical: "BTC", "ETH" etc. are NOT stocks)
if knownCryptoSymbols[sym] {
return false
}
// If it already has a crypto quote suffix, it's crypto
cryptoSuffixes := []string{"USDT", "BUSD", "USDC", "BTC", "ETH", "BNB"}
for _, suffix := range cryptoSuffixes {
if strings.HasSuffix(sym, suffix) && len(sym) > len(suffix) {
return false
}
}
// Pure uppercase letters, 1-5 chars = likely a stock ticker
if len(sym) >= 1 && len(sym) <= 5 {
allLetters := true
for _, c := range sym {
if c < 'A' || c > 'Z' {
allLetters = false
break
}
}
if allLetters {
return true
}
}
return false
}