diff --git a/agent/agent.go b/agent/agent.go index 67d91b8f..80a0f8d5 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -18,10 +18,13 @@ import ( "sync" "time" + gethcrypto "github.com/ethereum/go-ethereum/crypto" + "nofx/manager" "nofx/market" "nofx/mcp" "nofx/store" + "nofx/wallet" ) type Agent struct { @@ -51,6 +54,11 @@ type Config struct { BriefTimes []int `json:"brief_times"` } +var ( + agentWalletAddressFromPrivateKey = walletAddressFromPrivateKey + agentQueryUSDCBalanceCached = wallet.QueryUSDCBalanceCached +) + func DefaultConfig() *Config { return &Config{ Language: "zh", @@ -128,7 +136,9 @@ func (a *Agent) loadAIClientFromStoreUser(storeUserID string) (mcp.AIClient, str a.log().Warn("failed to list AI models for store user", "store_user_id", candidateUserID, "error", err) continue } - for _, model := range models { + candidates := rankAgentModelCandidates(models) + for _, candidate := range candidates { + model := candidate.model if model == nil || !model.Enabled || !agentModelHasUsableAPIKey(model) { continue } @@ -142,6 +152,8 @@ func (a *Agent) loadAIClientFromStoreUser(storeUserID string) (mcp.AIClient, str "has_api_key", len(model.APIKey) > 0, "custom_api_url", strings.TrimSpace(model.CustomAPIURL), "custom_model_name", strings.TrimSpace(model.CustomModelName), + "prefer_model_with_balance", candidate.preferModelWithBalance, + "wallet_balance_usdc", candidate.balanceUSDC, ) apiKey := strings.TrimSpace(string(model.APIKey)) @@ -172,6 +184,88 @@ func (a *Agent) loadAIClientFromStoreUser(storeUserID string) (mcp.AIClient, str return nil, "", false } +type agentModelCandidate struct { + model *store.AIModel + preferModelWithBalance bool + balanceUSDC float64 +} + +func rankAgentModelCandidates(models []*store.AIModel) []agentModelCandidate { + candidates := make([]agentModelCandidate, 0, len(models)) + for _, model := range models { + if model == nil { + continue + } + candidate := agentModelCandidate{model: model} + if balance, ok := agentModelUSDCBalance(model); ok && balance > 0 { + candidate.preferModelWithBalance = true + candidate.balanceUSDC = balance + } + candidates = append(candidates, candidate) + } + + sort.SliceStable(candidates, func(i, j int) bool { + left := candidates[i] + right := candidates[j] + if left.preferModelWithBalance != right.preferModelWithBalance { + return left.preferModelWithBalance + } + if left.balanceUSDC != right.balanceUSDC { + return left.balanceUSDC > right.balanceUSDC + } + leftUpdatedAt := time.Time{} + rightUpdatedAt := time.Time{} + if left.model != nil { + leftUpdatedAt = left.model.UpdatedAt + } + if right.model != nil { + rightUpdatedAt = right.model.UpdatedAt + } + if !leftUpdatedAt.Equal(rightUpdatedAt) { + return leftUpdatedAt.After(rightUpdatedAt) + } + leftID := "" + rightID := "" + if left.model != nil { + leftID = left.model.ID + } + if right.model != nil { + rightID = right.model.ID + } + return leftID < rightID + }) + + return candidates +} + +func agentModelUSDCBalance(model *store.AIModel) (float64, bool) { + if model == nil || !agentProviderSupportsUSDCBalance(model.Provider) { + return 0, false + } + privateKey := strings.TrimSpace(string(model.APIKey)) + if privateKey == "" { + return 0, false + } + walletAddress, err := agentWalletAddressFromPrivateKey(privateKey) + if err != nil || strings.TrimSpace(walletAddress) == "" { + return 0, false + } + balance, err := agentQueryUSDCBalanceCached(walletAddress) + if err != nil || balance <= 0 { + return 0, false + } + return balance, true +} + +func agentProviderSupportsUSDCBalance(provider string) bool { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "claw402", "blockrun-base": + return true + default: + return false + } +} + func agentModelHasUsableAPIKey(model *store.AIModel) bool { if model == nil { return false @@ -193,6 +287,23 @@ func agentModelHasUsableAPIKey(model *store.AIModel) bool { return envKey != "" && strings.TrimSpace(os.Getenv(envKey)) != "" } +func walletAddressFromPrivateKey(privateKey string) (string, error) { + key := strings.TrimSpace(privateKey) + if !strings.HasPrefix(key, "0x") { + return "", fmt.Errorf("private key must start with 0x") + } + if len(key) != 66 { + return "", fmt.Errorf("private key must be 66 characters") + } + + privateKeyObj, err := gethcrypto.HexToECDSA(strings.TrimPrefix(key, "0x")) + if err != nil { + return "", err + } + + return gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey).Hex(), nil +} + func resolveModelRuntimeConfig(provider, customAPIURL, customModelName, fallbackModelID string) (string, string) { provider = strings.ToLower(strings.TrimSpace(provider)) customAPIURL = strings.TrimSpace(customAPIURL) @@ -746,9 +857,28 @@ func (a *Agent) aiServiceFailure(lang string, err error) (string, error) { } a.logger.Error("AI service call failed", "error", reason) if lang == "zh" { - return fmt.Sprintf("当前 AI 服务调用失败:%s\n\n这不是“未配置模型”。更可能是模型服务余额不足、接口报错或超时。请检查当前启用模型的 API 状态后再试。", reason), nil + return fmt.Sprintf("当前 AI 服务调用失败:%s\n\n%s", reason, aiServiceFailureGuidance("zh", reason)), nil } - return fmt.Sprintf("The AI service call failed: %s\n\nThis is not a missing-model issue. The active model provider likely returned an error, timed out, or has insufficient balance. Please check the active model API and try again.", reason), nil + return fmt.Sprintf("The AI service call failed: %s\n\n%s", reason, aiServiceFailureGuidance(lang, reason)), nil +} + +func aiServiceFailureGuidance(lang, reason string) string { + lower := strings.ToLower(strings.TrimSpace(reason)) + looksLikeHTMLGateway := strings.Contains(lower, "invalid character '<'") || + strings.Contains(lower, "unexpected character '<'") || + strings.Contains(lower, " 0 { scanInterval = *args.ScanIntervalMinutes } - initialBalance := 0.0 - if args.InitialBalance != nil && *args.InitialBalance > 0 { - initialBalance = *args.InitialBalance - } isCrossMargin := true if args.IsCrossMargin != nil { isCrossMargin = *args.IsCrossMargin @@ -761,7 +757,7 @@ func formatTraderCreateDraftSummary(lang string, session skillSession) string { fmt.Sprintf("- 模型:%s", traderCreateModelNameOrID(session)), fmt.Sprintf("- 策略:%s", traderCreateStrategyNameOrID(session)), fmt.Sprintf("- 扫描间隔:%d 分钟(未指定时默认 3)", scanInterval), - fmt.Sprintf("- 初始资金:%.2f(未指定时默认 0)", initialBalance), + "- 初始余额:创建时由系统自动读取绑定交易所账户净值", fmt.Sprintf("- 全仓模式:%t(未指定时默认 true)", isCrossMargin), fmt.Sprintf("- 竞技场显示:%t(未指定时默认 true)", showInCompetition), } @@ -792,7 +788,7 @@ func formatTraderCreateDraftSummary(lang string, session skillSession) string { fmt.Sprintf("- Model: %s", traderCreateModelNameOrID(session)), fmt.Sprintf("- Strategy: %s", traderCreateStrategyNameOrID(session)), fmt.Sprintf("- Scan interval: %d minutes (defaults to 3)", scanInterval), - fmt.Sprintf("- Initial balance: %.2f (defaults to 0)", initialBalance), + "- Initial balance: auto-read from the bound exchange account equity at creation time", fmt.Sprintf("- Cross margin: %t (defaults to true)", isCrossMargin), fmt.Sprintf("- Show in competition: %t (defaults to true)", showInCompetition), } @@ -991,6 +987,9 @@ func formatStrategyDetailResponse(lang string, strategy *store.Strategy, cfg sto if len(cfg.CoinSource.StaticCoins) > 0 { sourceBits = append(sourceBits, "static="+strings.Join(cfg.CoinSource.StaticCoins, ",")) } + if len(cfg.CoinSource.ExcludedCoins) > 0 { + sourceBits = append(sourceBits, "excluded="+strings.Join(cfg.CoinSource.ExcludedCoins, ",")) + } timeframes := append([]string(nil), cfg.Indicators.Klines.SelectedTimeframes...) if len(timeframes) == 0 { @@ -1048,15 +1047,43 @@ func formatStrategyDetailResponse(lang string, strategy *store.Strategy, cfg sto customPromptPreview = string(runes[:120]) + "..." } + publishStatusZh := "未发布" + publishStatusEn := "private" + if strategy.IsPublic { + publishStatusZh = "已发布到市场" + publishStatusEn = "public" + } + configVisibleZh := "隐藏" + configVisibleEn := "hidden" + if strategy.ConfigVisible { + configVisibleZh = "可见" + configVisibleEn = "visible" + } + if lang == "zh" { lines := []string{ fmt.Sprintf("策略“%s”概览:", name), fmt.Sprintf("- 类型:%s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")), fmt.Sprintf("- 语言:%s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "zh")), + fmt.Sprintf("- 发布设置:%s;配置%s", publishStatusZh, configVisibleZh), } if strings.TrimSpace(strategy.Description) != "" { lines = append(lines, fmt.Sprintf("- 描述:%s", strings.TrimSpace(strategy.Description))) } + if cfg.GridConfig != nil { + lines = append(lines, fmt.Sprintf("- 网格参数:交易对 %s;网格 %d;总投资 %.2f;杠杆 %d;分布 %s", + defaultIfEmpty(strings.TrimSpace(cfg.GridConfig.Symbol), "未设置"), + cfg.GridConfig.GridCount, + cfg.GridConfig.TotalInvestment, + cfg.GridConfig.Leverage, + defaultIfEmpty(strings.TrimSpace(cfg.GridConfig.Distribution), "未设置"), + )) + if cfg.GridConfig.UseATRBounds { + lines = append(lines, fmt.Sprintf("- 网格边界:ATR 自动边界,倍数 %.2f", cfg.GridConfig.ATRMultiplier)) + } else if cfg.GridConfig.UpperPrice > 0 || cfg.GridConfig.LowerPrice > 0 { + lines = append(lines, fmt.Sprintf("- 网格边界:上沿 %.4f,下沿 %.4f", cfg.GridConfig.UpperPrice, cfg.GridConfig.LowerPrice)) + } + } if len(sourceBits) > 0 { lines = append(lines, "- 标的来源:"+strings.Join(sourceBits, " | ")) } @@ -1065,9 +1092,20 @@ func formatStrategyDetailResponse(lang string, strategy *store.Strategy, cfg sto } lines = append(lines, fmt.Sprintf("- 仓位风险:最多持仓 %d,BTC/ETH 最大杠杆 %d,山寨最大杠杆 %d,最低置信度 %d", cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence)) + lines = append(lines, fmt.Sprintf("- 风控阈值:最小盈亏比 %.2f;最大保证金使用率 %.2f;最小开仓金额 %.2f", + cfg.RiskControl.MinRiskRewardRatio, cfg.RiskControl.MaxMarginUsage, cfg.RiskControl.MinPositionSize)) if len(indicatorBits) > 0 { lines = append(lines, "- 已启用指标:"+strings.Join(indicatorBits, "、")) } + if strings.TrimSpace(cfg.Indicators.NofxOSAPIKey) != "" || cfg.Indicators.EnableQuantData || cfg.Indicators.EnableOIRanking || cfg.Indicators.EnableNetFlowRanking || cfg.Indicators.EnablePriceRanking { + lines = append(lines, fmt.Sprintf("- NofxOS 数据:API Key=%t,量化数据=%t,OI 排行=%t,净流入排行=%t,价格排行=%t", + strings.TrimSpace(cfg.Indicators.NofxOSAPIKey) != "", + cfg.Indicators.EnableQuantData, + cfg.Indicators.EnableOIRanking, + cfg.Indicators.EnableNetFlowRanking, + cfg.Indicators.EnablePriceRanking, + )) + } if len(promptBits) > 0 { lines = append(lines, "- Prompt 模块:"+strings.Join(promptBits, "、")) } @@ -1084,10 +1122,25 @@ func formatStrategyDetailResponse(lang string, strategy *store.Strategy, cfg sto fmt.Sprintf("Strategy %q overview:", name), fmt.Sprintf("- Type: %s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")), fmt.Sprintf("- Language: %s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "en")), + fmt.Sprintf("- Publish settings: %s; config %s", publishStatusEn, configVisibleEn), } if strings.TrimSpace(strategy.Description) != "" { lines = append(lines, fmt.Sprintf("- Description: %s", strings.TrimSpace(strategy.Description))) } + if cfg.GridConfig != nil { + lines = append(lines, fmt.Sprintf("- Grid config: symbol %s; grids %d; investment %.2f; leverage %d; distribution %s", + defaultIfEmpty(strings.TrimSpace(cfg.GridConfig.Symbol), "not set"), + cfg.GridConfig.GridCount, + cfg.GridConfig.TotalInvestment, + cfg.GridConfig.Leverage, + defaultIfEmpty(strings.TrimSpace(cfg.GridConfig.Distribution), "not set"), + )) + if cfg.GridConfig.UseATRBounds { + lines = append(lines, fmt.Sprintf("- Grid bounds: ATR auto bounds with multiplier %.2f", cfg.GridConfig.ATRMultiplier)) + } else if cfg.GridConfig.UpperPrice > 0 || cfg.GridConfig.LowerPrice > 0 { + lines = append(lines, fmt.Sprintf("- Grid bounds: upper %.4f, lower %.4f", cfg.GridConfig.UpperPrice, cfg.GridConfig.LowerPrice)) + } + } if len(sourceBits) > 0 { lines = append(lines, "- Coin source: "+strings.Join(sourceBits, " | ")) } @@ -1096,9 +1149,20 @@ func formatStrategyDetailResponse(lang string, strategy *store.Strategy, cfg sto } lines = append(lines, fmt.Sprintf("- Risk: max positions %d, BTC/ETH max leverage %d, alt max leverage %d, min confidence %d", cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence)) + lines = append(lines, fmt.Sprintf("- Risk thresholds: min RR %.2f, max margin usage %.2f, min position size %.2f", + cfg.RiskControl.MinRiskRewardRatio, cfg.RiskControl.MaxMarginUsage, cfg.RiskControl.MinPositionSize)) if len(indicatorBits) > 0 { lines = append(lines, "- Enabled indicators: "+strings.Join(indicatorBits, ", ")) } + if strings.TrimSpace(cfg.Indicators.NofxOSAPIKey) != "" || cfg.Indicators.EnableQuantData || cfg.Indicators.EnableOIRanking || cfg.Indicators.EnableNetFlowRanking || cfg.Indicators.EnablePriceRanking { + lines = append(lines, fmt.Sprintf("- NofxOS data: API key=%t, quant data=%t, OI ranking=%t, netflow ranking=%t, price ranking=%t", + strings.TrimSpace(cfg.Indicators.NofxOSAPIKey) != "", + cfg.Indicators.EnableQuantData, + cfg.Indicators.EnableOIRanking, + cfg.Indicators.EnableNetFlowRanking, + cfg.Indicators.EnablePriceRanking, + )) + } if len(promptBits) > 0 { lines = append(lines, "- Prompt modules: "+strings.Join(promptBits, ", ")) } @@ -1526,6 +1590,11 @@ func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, t patch := buildModelUpdatePatch(text) applyModelUpdatePatchToSession(&session, patch) provider := fieldValue(session, "provider") + if provider != "" && fieldValue(session, "api_key") == "" { + if credential := inferModelCredentialFromText(provider, text); credential != "" { + setField(&session, "api_key", credential) + } + } if provider != "" { if fieldValue(session, "name") == "" { setField(&session, "name", defaultModelConfigName(provider)) @@ -1611,6 +1680,43 @@ func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, t return fmt.Sprintf("Created model config %s.", fieldValue(session, "name")) } +func inferModelCredentialFromText(provider, text string) string { + provider = strings.ToLower(strings.TrimSpace(provider)) + text = strings.TrimSpace(text) + if provider == "" || text == "" { + return "" + } + + if value := extractQuotedContent(text); value != "" { + trimmed := strings.TrimSpace(value) + if credentialLooksCompatibleWithProvider(provider, trimmed) { + return trimmed + } + } + + if credentialLooksCompatibleWithProvider(provider, text) { + return text + } + return "" +} + +func credentialLooksCompatibleWithProvider(provider, value string) bool { + provider = strings.ToLower(strings.TrimSpace(provider)) + value = strings.TrimSpace(value) + if provider == "" || value == "" { + return false + } + + switch provider { + case "claw402", "blockrun-base", "blockrun-sol": + return hexCredentialPattern.MatchString(value) + case "openai": + return openAIAPIKeyPattern.MatchString(value) + default: + return genericAPIKeyPattern.MatchString(value) || hexCredentialPattern.MatchString(value) + } +} + func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string { if session.Name == "" { session = skillSession{Name: "strategy_management", Action: "create", Phase: "collecting"} diff --git a/agent/skill_outcome.go b/agent/skill_outcome.go index 99d4079d..9c40e7e9 100644 --- a/agent/skill_outcome.go +++ b/agent/skill_outcome.go @@ -160,6 +160,7 @@ Rules: - Use route "replan" when the user's task is not complete yet and the planner should continue from the new skill outcome. - Prefer route "replan" for recoverable errors, unmet goals, missing prerequisites, or cases where another skill/tool sequence may help. - If you choose "complete", produce the final user-facing answer in the user's language. +- ` + cleanUserFacingReplyInstruction + ` Return JSON with this exact shape: {"route":"complete|replan","answer":""}` diff --git a/agent/skill_registry.go b/agent/skill_registry.go index f9fa27b0..bfe2dc54 100644 --- a/agent/skill_registry.go +++ b/agent/skill_registry.go @@ -197,9 +197,9 @@ func buildSkillRoutingSummary(lang string, skillNames []string) string { switch name { case "trader_management": if lang == "zh" { - parts = append(parts, "这个 skill 负责交易员本体,以及交易员绑定的模型、交易所、策略配置。") + parts = append(parts, "这个 skill 负责交易员本体和绑定关系;交易员编辑默认只换绑定,不改策略、模型、交易所的内部配置。") } else { - parts = append(parts, "This skill owns the trader itself plus its bound model, exchange, and strategy.") + parts = append(parts, "This skill owns the trader itself and its bindings; trader edits should switch bindings, not mutate the internals of the strategy, model, or exchange.") } case "strategy_management": if lang == "zh" { @@ -231,9 +231,9 @@ func buildSkillDefinitionSummary(lang string, skillNames []string) string { switch name { case "trader_management": if lang == "zh" { - parts = append(parts, "这个 skill 负责交易员本体,以及交易员绑定的模型、交易所、策略配置。") + parts = append(parts, "这个 skill 负责交易员本体和绑定关系;交易员编辑默认只换绑定,不改策略、模型、交易所的内部配置。") } else { - parts = append(parts, "This skill owns the trader itself plus its bound model, exchange, and strategy.") + parts = append(parts, "This skill owns the trader itself and its bindings; trader edits should switch bindings, not mutate the internals of the strategy, model, or exchange.") } case "strategy_management": if lang == "zh" { @@ -269,9 +269,9 @@ func buildSkillDependencySummary(lang string, session skillSession) string { return "trader_management:create requires 4 core slots: trader name, exchange, model, and strategy. The last 3 dependencies can be satisfied in two ways: choose an existing usable resource, or create/enable one inline and then resume trader creation. If the user is enabling, fixing, or creating one of those dependencies, that is still continuation of the trader creation flow, not a new peer task." } if lang == "zh" { - return "当当前对象是交易员时,配置模型、交易所、策略都属于 trader_management 的继续操作。" + return "当当前对象是交易员时,换绑模型、交易所、策略都属于 trader_management 的继续操作;但如果用户要改这些对象的内部配置,应切到对应 management skill。" } - return "When the current object is a trader, configuring its model, exchange, or strategy remains inside trader_management." + return "When the current object is a trader, rebinding its model, exchange, or strategy remains inside trader_management; but if the user wants to change the internals of those resources, switch to the corresponding management skill." default: return "" } @@ -348,8 +348,10 @@ func buildSkillForbiddenSummary(lang string, skillNames []string) string { case "trader_management": if lang == "zh" { lines = append(lines, "- trader_management 不能直接设计赚钱/不亏钱方案;那类目标应交给 planner。") + lines = append(lines, "- trader_management 不能让用户手动设置、充值或修改交易员余额;交易员初始余额应由系统自动读取绑定交易所净值。") } else { lines = append(lines, "- trader_management must not invent a profit-seeking plan; those requests belong to the planner.") + lines = append(lines, "- trader_management must not let the user set, top up, or manually edit trader balance; trader initial balance should be auto-read from the bound exchange equity.") } case "exchange_management": if lang == "zh" { diff --git a/agent/skills/strategy_management.json b/agent/skills/strategy_management.json index 91b59aec..04d39b1f 100644 --- a/agent/skills/strategy_management.json +++ b/agent/skills/strategy_management.json @@ -14,6 +14,16 @@ "type": "string", "description": "策略描述,可选。" }, + "is_public": { + "type": "bool", + "default": false, + "description": "是否发布到策略市场。" + }, + "config_visible": { + "type": "bool", + "default": true, + "description": "发布到市场后,是否允许别人查看策略配置。" + }, "lang": { "type": "enum", "values": ["zh", "en"], @@ -26,6 +36,10 @@ "default": "ai_trading", "description": "策略类型:ai_trading(AI 量化)或 grid_trading(网格策略)。" }, + "symbol": { + "type": "string", + "description": "网格策略的交易对,例如 BTCUSDT。" + }, "source_type": { "type": "enum", "values": ["static", "ai500", "oi_top", "oi_low", "mixed"], @@ -41,7 +55,7 @@ }, "primary_timeframe": { "type": "string", - "values": ["1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"], + "values": ["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"], "description": "主 K 线周期,例如 5m、15m、1h。" }, "selected_timeframes": { @@ -106,6 +120,12 @@ "min": 0, "description": "网格总投入金额,grid_trading 类型专用。" }, + "leverage": { + "type": "int", + "min": 1, + "max": 5, + "description": "网格策略杠杆倍数,手动页面当前范围 1~5。" + }, "upper_price": { "type": "float", "description": "网格上边界价格,grid_trading 类型专用。" @@ -337,6 +357,7 @@ }, "validation_rules": [ "btceth_max_leverage 和 altcoin_max_leverage 范围均为 1~20,超出时自动收敛并告知用户。", + "grid_trading 的 leverage 需与手动页面一致,范围 1~5,超出时自动收敛并告知用户。", "min_confidence 范围 0~100,超出时自动收敛并告知用户。", "grid_trading 类型时,lower_price 必须小于 upper_price,否则提示用户修正。", "grid_count 最小为 2,低于 2 时提示用户修正。", @@ -352,7 +373,7 @@ "create": { "description": "创建策略模板。至少需要名称,其他配置可按需追问或按默认值补齐。", "required_slots": ["name"], - "optional_slots": ["description", "lang", "strategy_type", "source_type", "static_coins", "excluded_coins", "primary_timeframe", "selected_timeframes", "btceth_max_leverage", "altcoin_max_leverage", "max_positions", "min_confidence", "min_risk_reward_ratio", "custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process", "use_atr_bounds", "atr_multiplier", "enable_direction_adjust", "direction_bias_ratio", "grid_count", "total_investment", "upper_price", "lower_price", "distribution", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_ai500", "ai500_limit", "use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "primary_count", "enable_ema", "enable_macd", "enable_rsi", "enable_atr", "enable_boll", "enable_volume", "enable_oi", "enable_funding_rate", "ema_periods", "rsi_periods", "atr_periods", "boll_periods", "nofxos_api_key", "enable_quant_data", "enable_quant_oi", "enable_quant_netflow", "enable_oi_ranking", "oi_ranking_duration", "oi_ranking_limit", "enable_netflow_ranking", "netflow_ranking_duration", "netflow_ranking_limit", "enable_price_ranking", "price_ranking_duration", "price_ranking_limit", "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", "min_position_size"], + "optional_slots": ["description", "is_public", "config_visible", "lang", "strategy_type", "symbol", "source_type", "static_coins", "excluded_coins", "primary_timeframe", "selected_timeframes", "btceth_max_leverage", "altcoin_max_leverage", "max_positions", "min_confidence", "min_risk_reward_ratio", "custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process", "use_atr_bounds", "atr_multiplier", "enable_direction_adjust", "direction_bias_ratio", "grid_count", "total_investment", "leverage", "upper_price", "lower_price", "distribution", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_ai500", "ai500_limit", "use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "primary_count", "enable_ema", "enable_macd", "enable_rsi", "enable_atr", "enable_boll", "enable_volume", "enable_oi", "enable_funding_rate", "ema_periods", "rsi_periods", "atr_periods", "boll_periods", "nofxos_api_key", "enable_quant_data", "enable_quant_oi", "enable_quant_netflow", "enable_oi_ranking", "oi_ranking_duration", "oi_ranking_limit", "enable_netflow_ranking", "netflow_ranking_duration", "netflow_ranking_limit", "enable_price_ranking", "price_ranking_duration", "price_ranking_limit", "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", "min_position_size"], "goal": "创建一个可供 trader 绑定使用的策略模板。", "dynamic_rules": [ "若用户只是要给 trader 绑定现有策略,应优先在父任务里补 strategy 槽位,而不是误开新的 create。", @@ -366,7 +387,7 @@ "update": { "description": "更新策略模板的任意可编辑字段。", "required_slots": ["target_ref"], - "optional_slots": ["name", "description", "source_type", "static_coins", "excluded_coins", "primary_timeframe", "selected_timeframes", "btceth_max_leverage", "altcoin_max_leverage", "max_positions", "min_confidence", "min_risk_reward_ratio", "custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process", "grid_count", "total_investment", "upper_price", "lower_price", "distribution", "use_atr_bounds", "atr_multiplier", "enable_direction_adjust", "direction_bias_ratio", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_ai500", "ai500_limit", "use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "primary_count", "enable_ema", "enable_macd", "enable_rsi", "enable_atr", "enable_boll", "enable_volume", "enable_oi", "enable_funding_rate", "ema_periods", "rsi_periods", "atr_periods", "boll_periods", "nofxos_api_key", "enable_quant_data", "enable_quant_oi", "enable_quant_netflow", "enable_oi_ranking", "oi_ranking_duration", "oi_ranking_limit", "enable_netflow_ranking", "netflow_ranking_duration", "netflow_ranking_limit", "enable_price_ranking", "price_ranking_duration", "price_ranking_limit", "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", "min_position_size"], + "optional_slots": ["name", "description", "is_public", "config_visible", "symbol", "source_type", "static_coins", "excluded_coins", "primary_timeframe", "selected_timeframes", "btceth_max_leverage", "altcoin_max_leverage", "max_positions", "min_confidence", "min_risk_reward_ratio", "custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process", "grid_count", "total_investment", "leverage", "upper_price", "lower_price", "distribution", "use_atr_bounds", "atr_multiplier", "enable_direction_adjust", "direction_bias_ratio", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_ai500", "ai500_limit", "use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "primary_count", "enable_ema", "enable_macd", "enable_rsi", "enable_atr", "enable_boll", "enable_volume", "enable_oi", "enable_funding_rate", "ema_periods", "rsi_periods", "atr_periods", "boll_periods", "nofxos_api_key", "enable_quant_data", "enable_quant_oi", "enable_quant_netflow", "enable_oi_ranking", "oi_ranking_duration", "oi_ranking_limit", "enable_netflow_ranking", "netflow_ranking_duration", "netflow_ranking_limit", "enable_price_ranking", "price_ranking_duration", "price_ranking_limit", "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", "min_position_size"], "goal": "更新一个已有策略模板的指定配置,而不覆盖未提及字段。", "dynamic_rules": [ "只更新用户明确提到的字段,不要覆盖未提及的字段。", diff --git a/agent/skills/trader_management.json b/agent/skills/trader_management.json index d23f2271..c0b0f2f2 100644 --- a/agent/skills/trader_management.json +++ b/agent/skills/trader_management.json @@ -2,7 +2,7 @@ "name": "trader_management", "kind": "management", "domain": "trader", - "description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。适用于用户提到交易员名称、绑定交易所、绑定模型、绑定策略、保证金模式、扫描频率、竞技场显示、运行状态等管理需求。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。创建交易员时必须收齐名称、交易所、模型、策略;其中交易所、模型、策略既可以直接选择用户已有可用资源,也可以在当前主流程里先新建/启用对应资源,再继续完成交易员创建。", + "description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。交易员是装配层,核心是名称以及绑定的交易所、模型、策略;编辑交易员默认只换绑定,不修改这些依赖对象的内部配置。若用户要改策略参数、模型配置或交易所凭证,应切到各自的 management skill。创建交易员时必须收齐名称、交易所、模型、策略;其中交易所、模型、策略既可以直接选择用户已有可用资源,也可以在当前主流程里先新建/启用对应资源,再继续完成交易员创建。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。", "intents": [ "创建交易员", "修改交易员", @@ -40,11 +40,6 @@ "default": 5, "description": "AI 扫描决策间隔,单位分钟,手动面板可配置范围 3~60 分钟。超出范围会自动收敛到边界值并告知用户。" }, - "initial_balance": { - "type": "float", - "min": 100.0, - "description": "初始资金,手动面板最低 100。超出范围会自动收敛到最低值并告知用户。" - }, "is_cross_margin": { "type": "bool", "default": true, @@ -105,7 +100,7 @@ "ai_model_id 对应的模型配置必须已启用(enabled=true)且配置完整(api_key、custom_model_name 不为空;custom_api_url 若填写必须为合法 HTTPS),否则无法创建或启动交易员。", "strategy_id 对应的策略模板必须存在,否则无法创建交易员。", "scan_interval_minutes 超出 3~60 范围时,系统自动收敛到边界值,并通过 LLM 告知用户已调整,询问是否接受。", - "initial_balance 低于 100 时,系统自动收敛到 100,并通过 LLM 告知用户已调整。", + "交易员初始余额由系统在创建时自动读取绑定交易所账户净值,不接受用户手动设置、充值或修改。", "启动交易员前,绑定的模型必须已启用且完整,绑定的交易所也必须已启用且通过对应交易所的完整性校验,否则拒绝启动并明确指出缺哪一项。", "若绑定的是 OKX 交易所,启用前必须已有 passphrase;若绑定的是 Hyperliquid,启用前必须已有 wallet_addr;若绑定的是 Aster,启用前必须已有 user、signer、private_key;若绑定的是 Lighter,启用前必须已有 wallet_addr 和 api_key_private_key。", "btc_eth_leverage 和 altcoin_leverage 若超出系统允许范围,应自动收敛或提示用户修正。", @@ -117,7 +112,7 @@ "create": { "description": "创建新的交易员。若缺少交易所、模型或策略,可在当前流程内先选择已有资源,或切去对应 skill 新建/启用后自动回流继续。", "required_slots": ["name", "exchange", "model", "strategy"], - "optional_slots": ["auto_start", "scan_interval_minutes", "initial_balance", "is_cross_margin", "show_in_competition", "btc_eth_leverage", "altcoin_leverage", "trading_symbols", "custom_prompt", "override_base_prompt", "system_prompt_template", "use_ai500", "use_oi_top"], + "optional_slots": ["auto_start", "scan_interval_minutes", "is_cross_margin", "show_in_competition", "btc_eth_leverage", "altcoin_leverage", "trading_symbols", "custom_prompt", "override_base_prompt", "system_prompt_template", "use_ai500", "use_oi_top"], "goal": "创建并初始化一个交易员。", "dynamic_rules": [ "若用户提到的交易所、模型或策略已经存在且可用,应优先直接补入对应槽位,不要重新创建。", @@ -125,21 +120,21 @@ "子任务成功后,系统会恢复当前交易员草稿并继续补齐剩余槽位。", "scan_interval_minutes 超出 3~60 时,自动收敛并告知用户。", "若用户明确想覆盖杠杆、币种范围或提示词,应允许在创建阶段一并收集 btc_eth_leverage、altcoin_leverage、trading_symbols、custom_prompt、override_base_prompt、system_prompt_template、use_ai500、use_oi_top。", + "不要向用户收集或确认初始余额;创建时由系统自动读取绑定交易所账户净值作为初始余额。", "创建完成后询问用户是否立即启动(auto_start),启动前再次确认。" ], "success_output": "返回 trader_id,并给出创建结果摘要(名称、绑定的交易所/模型/策略、是否已启动)。", "failure_output": "用人话指出缺失依赖项,或说明当前正在进入哪个依赖子任务。" }, "update": { - "description": "更新已有交易员的任意可编辑字段。", + "description": "更新已有交易员,但默认只处理改名或换绑策略、交易所、模型。", "required_slots": ["target_ref"], - "optional_slots": ["name", "exchange_id", "ai_model_id", "strategy_id", "scan_interval_minutes", "initial_balance", "is_cross_margin", "show_in_competition", "btc_eth_leverage", "altcoin_leverage", "trading_symbols", "custom_prompt", "override_base_prompt", "system_prompt_template", "use_ai500", "use_oi_top"], - "goal": "更新一个已有交易员的配置,但只修改用户明确要求变更的字段。", + "optional_slots": ["name", "exchange_id", "ai_model_id", "strategy_id"], + "goal": "更新一个已有交易员的名称或绑定关系,但不改动策略、模型、交易所内部配置。", "dynamic_rules": [ "只更新用户明确提到的字段,不要覆盖未提及的字段。", "换绑交易所/模型/策略时,新的资源必须已存在且已启用,否则提示用户先启用或新建。", - "scan_interval_minutes 超出 3~60 时,自动收敛并告知用户。", - "若用户修改的是交易员级杠杆、指定币对、提示词或选币来源,也应走 update,而不是误导去改策略。" + "如果用户实际上是想修改策略参数、模型配置或交易所凭证,不要继续留在 trader update;应切到对应 management skill。" ], "success_output": "返回更新后的 trader_id 与简短配置摘要,明确哪些字段已经生效。", "failure_output": "明确指出目标交易员不存在、依赖资源不可用,或哪一个字段值仍需用户补充/修正。" diff --git a/agent/strategy_field_catalog.go b/agent/strategy_field_catalog.go index e13b853e..b5d227d5 100644 --- a/agent/strategy_field_catalog.go +++ b/agent/strategy_field_catalog.go @@ -10,6 +10,7 @@ func manualStrategyEditableFieldKeys() []string { "symbol", "grid_count", "total_investment", + "leverage", "upper_price", "lower_price", "use_atr_bounds", @@ -84,6 +85,7 @@ func agentStrategyUpdatableFieldKeys() []string { "symbol", "grid_count", "total_investment", + "leverage", "upper_price", "lower_price", "use_atr_bounds", diff --git a/agent/tools.go b/agent/tools.go index 6b644c5a..c0126109 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "time" @@ -17,11 +18,28 @@ import ( "nofx/safe" "nofx/security" "nofx/store" + "nofx/trader" + "nofx/trader/aster" + "nofx/trader/binance" + "nofx/trader/bitget" + "nofx/trader/bybit" + "nofx/trader/gate" + hyperliquidtrader "nofx/trader/hyperliquid" + "nofx/trader/indodax" + "nofx/trader/kucoin" + "nofx/trader/lighter" + "nofx/trader/okx" ) // cachedTools holds the static tool definitions (built once, reused per message). var cachedTools = buildAgentTools() +var ( + binanceFuturesAPIBaseURL = "https://fapi.binance.com" + marketDataHTTPClient = http.DefaultClient + traderInitialBalanceFetcher = defaultTraderInitialBalanceFetcher +) + // agentTools returns the tools available to the LLM for autonomous action. func agentTools() []mcp.Tool { return cachedTools } @@ -49,6 +67,23 @@ func (a *Agent) ensureUniqueModelName(storeUserID, name, excludeID string) error return nil } +func (a *Agent) findModelByProvider(storeUserID, provider string) (*store.AIModel, error) { + models, err := a.store.AIModel().List(storeUserID) + if err != nil { + return nil, err + } + normalizedProvider := strings.ToLower(strings.TrimSpace(provider)) + for _, model := range models { + if model == nil { + continue + } + if strings.ToLower(strings.TrimSpace(model.Provider)) == normalizedProvider { + return model, nil + } + } + return nil, nil +} + func (a *Agent) ensureUniqueExchangeAccountName(storeUserID, accountName, excludeID string) error { exchanges, err := a.store.Exchange().List(storeUserID) if err != nil { @@ -304,7 +339,6 @@ func traderConfigFieldsSchema() map[string]any { "ai_model_id": map[string]any{"type": "string", "description": "Bound AI model id."}, "exchange_id": map[string]any{"type": "string", "description": "Bound exchange id."}, "strategy_id": map[string]any{"type": "string", "description": "Bound strategy id."}, - "initial_balance": map[string]any{"type": "number", "description": "Initial balance / bankroll."}, "scan_interval_minutes": map[string]any{"type": "number", "description": "Trading scan interval in minutes."}, "is_cross_margin": map[string]any{"type": "boolean", "description": "Whether cross margin is enabled."}, "show_in_competition": map[string]any{"type": "boolean", "description": "Whether to show this trader in competition views."}, @@ -481,7 +515,7 @@ func buildAgentTools() []mcp.Tool { 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, tune leverage, prompts, symbol scope, scan interval, or control its running state.", + 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 bindings, or control its running state. If the user wants to modify the internal config of a strategy, model, or exchange, use the corresponding management tool instead.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ @@ -494,7 +528,6 @@ func buildAgentTools() []mcp.Tool { "ai_model_id": traderConfigFieldsSchema()["ai_model_id"], "exchange_id": traderConfigFieldsSchema()["exchange_id"], "strategy_id": traderConfigFieldsSchema()["strategy_id"], - "initial_balance": traderConfigFieldsSchema()["initial_balance"], "scan_interval_minutes": traderConfigFieldsSchema()["scan_interval_minutes"], "is_cross_margin": traderConfigFieldsSchema()["is_cross_margin"], "show_in_competition": traderConfigFieldsSchema()["show_in_competition"], @@ -591,6 +624,31 @@ func buildAgentTools() []mcp.Tool { }, }, }, + { + Type: "function", + Function: mcp.FunctionDef{ + Name: "get_market_snapshot", + Description: "Get a real-time crypto market snapshot for analysis. Returns current price, 24h change, high/low, volume, funding rate, open interest, and recent K-line structure in one tool call. Prefer this when the user asks to analyze a coin, assess current行情, or wants a richer market read than a single price.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "symbol": map[string]any{ + "type": "string", + "description": "Crypto trading symbol, for example BTC, ETH, BTCUSDT, or ETHUSDT.", + }, + "interval": map[string]any{ + "type": "string", + "description": "Kline interval for the structure snapshot, for example 5m, 15m, 1h, or 4h. Defaults to 15m.", + }, + "limit": map[string]any{ + "type": "number", + "description": "Number of recent candles to fetch for the structure snapshot. Defaults to 20 and is capped at 100.", + }, + }, + "required": []string{"symbol"}, + }, + }, + }, { Type: "function", Function: mcp.FunctionDef{ @@ -718,6 +776,8 @@ func (a *Agent) handleToolCall(ctx context.Context, storeUserID string, userID i return a.toolGetBalance() case "get_market_price": return a.toolGetMarketPrice(tc.Function.Arguments) + case "get_market_snapshot": + return a.toolGetMarketSnapshot(tc.Function.Arguments) case "get_kline": return a.toolGetKline(tc.Function.Arguments) case "get_trade_history": @@ -869,6 +929,89 @@ func safeExchangeForTool(ex *store.Exchange) safeExchangeToolConfig { } } +func defaultTraderInitialBalanceFetcher(exchangeCfg *store.Exchange, userID string) (float64, bool, error) { + if exchangeCfg == nil { + return 0, false, fmt.Errorf("exchange config not found") + } + probe, err := buildTraderExchangeProbe(exchangeCfg, userID) + if err != nil { + return 0, false, err + } + balanceInfo, err := probe.GetBalance() + if err != nil { + return 0, false, err + } + return extractTraderInitialBalance(balanceInfo) +} + +func buildTraderExchangeProbe(exchangeCfg *store.Exchange, userID string) (trader.Trader, error) { + switch exchangeCfg.ExchangeType { + case "binance": + return binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID), nil + case "bybit": + return bybit.NewBybitTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil + case "okx": + return okx.NewOKXTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil + case "bitget": + return bitget.NewBitgetTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil + case "gate": + return gate.NewGateTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil + case "kucoin": + return kucoin.NewKuCoinTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil + case "indodax": + return indodax.NewIndodaxTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil + case "hyperliquid": + return hyperliquidtrader.NewHyperliquidTrader( + string(exchangeCfg.APIKey), + exchangeCfg.HyperliquidWalletAddr, + exchangeCfg.Testnet, + exchangeCfg.HyperliquidUnifiedAcct, + ) + case "aster": + return aster.NewAsterTrader( + exchangeCfg.AsterUser, + exchangeCfg.AsterSigner, + string(exchangeCfg.AsterPrivateKey), + ) + case "lighter": + return lighter.NewLighterTraderV2( + exchangeCfg.LighterWalletAddr, + string(exchangeCfg.LighterAPIKeyPrivateKey), + exchangeCfg.LighterAPIKeyIndex, + false, + ) + default: + return nil, fmt.Errorf("unsupported exchange type: %s", exchangeCfg.ExchangeType) + } +} + +func extractTraderInitialBalance(balanceInfo map[string]interface{}) (float64, bool, error) { + for _, key := range []string{"total_equity", "totalEquity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"} { + raw, ok := balanceInfo[key] + if !ok { + continue + } + switch v := raw.(type) { + case float64: + return v, true, nil + case float32: + return float64(v), true, nil + case int: + return float64(v), true, nil + case int64: + return float64(v), true, nil + case int32: + return float64(v), true, nil + case string: + parsed, err := strconv.ParseFloat(v, 64) + if err == nil { + return parsed, true, nil + } + } + } + return 0, false, fmt.Errorf("initial balance not set and unable to fetch balance from exchange") +} + func safeModelForTool(model *store.AIModel) safeModelToolConfig { return safeModelToolConfig{ ID: model.ID, @@ -1157,7 +1300,9 @@ func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string { if exchangeType == "" { return `{"error":"exchange_type is required for create"}` } - enabled := false + // Match the manual settings page: newly created model configs should be + // enabled unless the caller explicitly asks to keep them disabled. + enabled := true if args.Enabled != nil { enabled = *args.Enabled } @@ -1454,7 +1599,9 @@ func (a *Agent) toolManageModelConfig(storeUserID, argsJSON string) string { if modelID == "" { modelID = provider } - enabled := false + // Match the manual settings page: newly created model configs should be + // enabled unless the caller explicitly asks to keep them disabled. + enabled := true if args.Enabled != nil { enabled = *args.Enabled } @@ -1480,7 +1627,16 @@ func (a *Agent) toolManageModelConfig(storeUserID, argsJSON string) string { }).Validate(); err != nil { return fmt.Sprintf(`{"error":"%s"}`, err) } - if err := a.ensureUniqueModelName(storeUserID, name, ""); err != nil { + existingByProvider, err := a.findModelByProvider(storeUserID, provider) + if err != nil { + return fmt.Sprintf(`{"error":"failed to inspect existing model configs: %s"}`, err) + } + excludeID := "" + if existingByProvider != nil { + modelID = existingByProvider.ID + excludeID = existingByProvider.ID + } + if err := a.ensureUniqueModelName(storeUserID, name, excludeID); err != nil { return fmt.Sprintf(`{"error":"%s"}`, err) } if err := a.store.AIModel().UpdateWithName( @@ -1634,6 +1790,7 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { Lang string `json:"lang"` IsPublic *bool `json:"is_public"` ConfigVisible *bool `json:"config_visible"` + AllowClamped bool `json:"allow_clamped_update"` Config map[string]any `json:"config"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { @@ -1674,6 +1831,9 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { before := merged merged.ClampLimits() warnings = store.StrategyClampWarnings(before, merged, merged.Language) + if len(warnings) > 0 && !args.AllowClamped { + return fmt.Sprintf(`{"error":"%s"}`, formatRiskControlRefusalPrompt(merged.Language, warnings, "确认应用")) + } cfg = merged } configJSON, err := json.Marshal(cfg) @@ -1750,6 +1910,9 @@ func (a *Agent) toolManageStrategy(storeUserID, argsJSON string) string { before := merged merged.ClampLimits() warnings = store.StrategyClampWarnings(before, merged, merged.Language) + if len(warnings) > 0 && !args.AllowClamped { + return fmt.Sprintf(`{"error":"%s"}`, formatRiskControlRefusalPrompt(merged.Language, warnings, "确认应用")) + } normalized, err := json.Marshal(merged) if err != nil { return fmt.Sprintf(`{"error":"failed to serialize strategy config: %s"}`, err) @@ -1980,6 +2143,10 @@ func (a *Agent) toolCreateTrader(storeUserID string, args manageTraderArgs) stri if err := a.validateTraderReferences(storeUserID, args.AIModelID, args.ExchangeID, args.StrategyID); err != nil { return fmt.Sprintf(`{"error":"%s"}`, err) } + exchangeCfg, err := a.store.Exchange().GetByID(storeUserID, strings.TrimSpace(args.ExchangeID)) + if err != nil { + return fmt.Sprintf(`{"error":"failed to load exchange config: %s"}`, err) + } scanInterval := 3 if args.ScanIntervalMinutes != nil && *args.ScanIntervalMinutes > 0 { scanInterval = *args.ScanIntervalMinutes @@ -1987,9 +2154,12 @@ func (a *Agent) toolCreateTrader(storeUserID string, args manageTraderArgs) stri scanInterval = 3 } } - initialBalance := 0.0 - if args.InitialBalance != nil && *args.InitialBalance > 0 { - initialBalance = *args.InitialBalance + initialBalance, found, err := traderInitialBalanceFetcher(exchangeCfg, storeUserID) + if err != nil { + return fmt.Sprintf(`{"error":"failed to auto-read trader initial balance from exchange: %s"}`, err) + } + if !found { + return `{"error":"failed to auto-read trader initial balance from exchange"}` } isCrossMargin := true if args.IsCrossMargin != nil { @@ -2109,9 +2279,6 @@ func (a *Agent) toolUpdateTrader(storeUserID string, args manageTraderArgs) stri 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 { @@ -2605,6 +2772,217 @@ func (a *Agent) toolGetMarketPrice(argsJSON string) string { return fmt.Sprintf(`{"error": "could not get price for %s"}`, sym) } +func binanceFuturesGET(path string, out any) error { + req, err := http.NewRequest(http.MethodGet, binanceFuturesAPIBaseURL+path, nil) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + req = req.WithContext(ctx) + + resp, err := marketDataHTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("source returned status %d", resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func (a *Agent) toolGetMarketSnapshot(argsJSON string) string { + var args struct { + Symbol string `json:"symbol"` + Interval string `json:"interval"` + Limit int `json:"limit"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err) + } + + symbol := strings.ToUpper(strings.TrimSpace(args.Symbol)) + if symbol == "" { + return `{"error":"symbol is required"}` + } + if isStockSymbol(symbol) { + return `{"error":"get_market_snapshot currently supports crypto symbols only"}` + } + if !strings.HasSuffix(symbol, "USDT") { + symbol += "USDT" + } + + interval := strings.TrimSpace(strings.ToLower(args.Interval)) + if interval == "" { + interval = "15m" + } + if !validKlineInterval(interval) { + return fmt.Sprintf(`{"error":"invalid interval %q"}`, interval) + } + + limit := args.Limit + switch { + case limit <= 0: + limit = 20 + case limit > 100: + limit = 100 + } + + var ticker24h struct { + Symbol string `json:"symbol"` + LastPrice string `json:"lastPrice"` + PriceChange string `json:"priceChange"` + PriceChangePercent string `json:"priceChangePercent"` + HighPrice string `json:"highPrice"` + LowPrice string `json:"lowPrice"` + Volume string `json:"volume"` + QuoteVolume string `json:"quoteVolume"` + Count int64 `json:"count"` + } + if err := binanceFuturesGET("/fapi/v1/ticker/24hr?symbol="+symbol, &ticker24h); err != nil { + return fmt.Sprintf(`{"error":"failed to fetch 24h ticker for %s: %s"}`, symbol, err) + } + + var premiumIndex struct { + Symbol string `json:"symbol"` + MarkPrice string `json:"markPrice"` + IndexPrice string `json:"indexPrice"` + LastFundingRate string `json:"lastFundingRate"` + NextFundingTime int64 `json:"nextFundingTime"` + Time int64 `json:"time"` + } + if err := binanceFuturesGET("/fapi/v1/premiumIndex?symbol="+symbol, &premiumIndex); err != nil { + return fmt.Sprintf(`{"error":"failed to fetch funding data for %s: %s"}`, symbol, err) + } + + var openInterest struct { + OpenInterest string `json:"openInterest"` + Symbol string `json:"symbol"` + Time int64 `json:"time"` + } + if err := binanceFuturesGET("/fapi/v1/openInterest?symbol="+symbol, &openInterest); err != nil { + return fmt.Sprintf(`{"error":"failed to fetch open interest for %s: %s"}`, symbol, err) + } + + var rawKlines [][]any + if err := binanceFuturesGET(fmt.Sprintf("/fapi/v1/klines?symbol=%s&interval=%s&limit=%d", symbol, interval, limit), &rawKlines); err != nil { + return fmt.Sprintf(`{"error":"failed to fetch kline for %s: %s"}`, symbol, err) + } + if len(rawKlines) == 0 { + return fmt.Sprintf(`{"error":"empty kline response for %s"}`, symbol) + } + + klines := make([]map[string]any, 0, len(rawKlines)) + highestHigh := 0.0 + lowestLow := 0.0 + firstClose := 0.0 + lastClose := 0.0 + totalVolume := 0.0 + for i, row := range rawKlines { + if len(row) < 7 { + continue + } + openVal := toSnapshotFloat(row[1]) + highVal := toSnapshotFloat(row[2]) + lowVal := toSnapshotFloat(row[3]) + closeVal := toSnapshotFloat(row[4]) + volumeVal := toSnapshotFloat(row[5]) + if i == 0 { + firstClose = closeVal + highestHigh = highVal + lowestLow = lowVal + } + if highVal > highestHigh { + highestHigh = highVal + } + if lowestLow == 0 || (lowVal > 0 && lowVal < lowestLow) { + lowestLow = lowVal + } + lastClose = closeVal + totalVolume += volumeVal + klines = append(klines, map[string]any{ + "open_time": row[0], + "open": openVal, + "high": highVal, + "low": lowVal, + "close": closeVal, + "volume": volumeVal, + "close_time": row[6], + }) + } + + periodChangePercent := 0.0 + if firstClose > 0 && lastClose > 0 { + periodChangePercent = ((lastClose - firstClose) / firstClose) * 100 + } + + tickerLastPrice, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.LastPrice), 64) + tickerPriceChange, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.PriceChange), 64) + tickerPriceChangePercent, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.PriceChangePercent), 64) + tickerHighPrice, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.HighPrice), 64) + tickerLowPrice, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.LowPrice), 64) + tickerVolume, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.Volume), 64) + tickerQuoteVolume, _ := strconv.ParseFloat(strings.TrimSpace(ticker24h.QuoteVolume), 64) + markPrice, _ := strconv.ParseFloat(strings.TrimSpace(premiumIndex.MarkPrice), 64) + indexPrice, _ := strconv.ParseFloat(strings.TrimSpace(premiumIndex.IndexPrice), 64) + fundingRate, _ := strconv.ParseFloat(strings.TrimSpace(premiumIndex.LastFundingRate), 64) + oiValue, _ := strconv.ParseFloat(strings.TrimSpace(openInterest.OpenInterest), 64) + + out, _ := json.Marshal(map[string]any{ + "symbol": symbol, + "price": tickerLastPrice, + "ticker_24h": map[string]any{ + "price_change": tickerPriceChange, + "price_change_percent": tickerPriceChangePercent, + "high_price": tickerHighPrice, + "low_price": tickerLowPrice, + "volume": tickerVolume, + "quote_volume": tickerQuoteVolume, + "trade_count": ticker24h.Count, + }, + "perp_metrics": map[string]any{ + "mark_price": markPrice, + "index_price": indexPrice, + "funding_rate": fundingRate, + "next_funding_time": premiumIndex.NextFundingTime, + "open_interest": oiValue, + }, + "kline_snapshot": map[string]any{ + "interval": interval, + "limit": len(klines), + "period_change_percent": periodChangePercent, + "highest_high": highestHigh, + "lowest_low": lowestLow, + "average_volume": totalVolume / float64(maxInt(len(klines), 1)), + "recent_klines": klines, + }, + }) + return string(out) +} + +func toSnapshotFloat(value any) float64 { + switch v := value.(type) { + case string: + f, _ := strconv.ParseFloat(strings.TrimSpace(v), 64) + return f + case float64: + return v + case json.Number: + f, _ := v.Float64() + return f + default: + return 0 + } +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + func validKlineInterval(interval string) bool { switch strings.TrimSpace(strings.ToLower(interval)) { case "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w", "1mo": diff --git a/agent/trader_scope_test.go b/agent/trader_scope_test.go new file mode 100644 index 00000000..621dcea1 --- /dev/null +++ b/agent/trader_scope_test.go @@ -0,0 +1,37 @@ +package agent + +import ( + "strings" + "testing" +) + +func TestClassifyWorkflowTaskTreatsTraderEditAsBindingsOrRename(t *testing.T) { + task, ok := classifyWorkflowTask("帮我把交易员小爱换策略") + if !ok { + t.Fatal("expected trader binding edit to classify") + } + if task.Skill != "trader_management" || task.Action != "update_bindings" { + t.Fatalf("unexpected task: %+v", task) + } + + task, ok = classifyWorkflowTask("帮我把交易员小爱改名") + if !ok { + t.Fatal("expected trader rename to classify") + } + if task.Skill != "trader_management" || task.Action != "update_name" { + t.Fatalf("unexpected rename task: %+v", task) + } +} + +func TestTraderDomainPrimerExplainsInternalConfigBoundary(t *testing.T) { + primer := buildSkillDomainPrimer("zh", "trader_management") + for _, want := range []string{ + "交易员是装配层", + "默认只处理绑定关系", + "应切到对应 management skill", + } { + if !strings.Contains(primer, want) { + t.Fatalf("expected primer to contain %q, got: %s", want, primer) + } + } +} diff --git a/agent/user_facing_prompt.go b/agent/user_facing_prompt.go new file mode 100644 index 00000000..8af3a506 --- /dev/null +++ b/agent/user_facing_prompt.go @@ -0,0 +1,3 @@ +package agent + +const cleanUserFacingReplyInstruction = "Your final reply must be clean and easy to understand, with no fluff, no internal jargon, and no unnecessary explanation." diff --git a/agent/user_facing_prompt_test.go b/agent/user_facing_prompt_test.go new file mode 100644 index 00000000..273503af --- /dev/null +++ b/agent/user_facing_prompt_test.go @@ -0,0 +1,12 @@ +package agent + +import "testing" + +func TestCleanUserFacingReplyInstruction(t *testing.T) { + if cleanUserFacingReplyInstruction == "" { + t.Fatal("expected clean user-facing reply instruction to be defined") + } + if got, want := cleanUserFacingReplyInstruction, "Your final reply must be clean and easy to understand, with no fluff, no internal jargon, and no unnecessary explanation."; got != want { + t.Fatalf("unexpected instruction\nwant: %q\ngot: %q", want, got) + } +} diff --git a/agent/workflow.go b/agent/workflow.go index 35506c54..95d34704 100644 --- a/agent/workflow.go +++ b/agent/workflow.go @@ -439,7 +439,8 @@ func (a *Agent) generateWorkflowSummary(ctx context.Context, userID int64, lang defer cancel() systemPrompt := `You are summarizing a finished workflow for NOFXi. Return one short user-facing summary in the user's language. -Do not mention internal DAG, scheduler, or JSON.` +Do not mention internal DAG, scheduler, or JSON. +` + cleanUserFacingReplyInstruction userPrompt := fmt.Sprintf("Language: %s\nOriginal request: %s\nCompleted tasks:\n- %s", lang, session.OriginalRequest, strings.Join(completed, "\n- ")) raw, err := a.aiClient.CallWithRequest(&mcp.Request{ Messages: []mcp.Message{ @@ -716,7 +717,7 @@ func classifyContextualStrategyWorkflowTasks(text string) []WorkflowTask { func classifyContextualTraderWorkflowTasks(text string) []WorkflowTask { lower := strings.ToLower(strings.TrimSpace(text)) - hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "杠杆", "提示词"}) + hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "改名", "重命名"}) hasStart := containsAny(lower, []string{"启动", "开始", "run", "start"}) hasStop := containsAny(lower, []string{"停止", "停掉", "stop", "pause"}) if !hasUpdate && !hasStart && !hasStop { @@ -725,9 +726,9 @@ func classifyContextualTraderWorkflowTasks(text string) []WorkflowTask { var tasks []WorkflowTask if hasUpdate { action := "update_bindings" - if containsAny(lower, []string{"扫描间隔", "杠杆", "提示词", "修改", "更新"}) && + if containsAny(lower, []string{"改名", "重命名", "rename", "name"}) && !containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略"}) { - action = "update" + action = "update_name" } tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: action, Request: text}) } @@ -803,7 +804,7 @@ func classifyCompoundTraderWorkflowTasks(text string) []WorkflowTask { } lower := strings.ToLower(strings.TrimSpace(text)) hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}) - hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "杠杆", "提示词"}) + hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "改名", "重命名"}) hasStart := containsAny(lower, []string{"启动", "开始", "run", "start"}) hasStop := containsAny(lower, []string{"停止", "停掉", "stop", "pause"}) @@ -813,9 +814,9 @@ func classifyCompoundTraderWorkflowTasks(text string) []WorkflowTask { } if hasUpdate { action := "update_bindings" - if containsAny(lower, []string{"扫描间隔", "杠杆", "提示词", "修改", "更新"}) && + if containsAny(lower, []string{"改名", "重命名", "rename", "name"}) && !containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略"}) { - action = "update" + action = "update_name" } tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: action, Request: text}) } @@ -926,7 +927,9 @@ func classifyWorkflowTask(text string) (WorkflowTask, bool) { action = "delete" case containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略"}): action = "update_bindings" - case containsAny(lower, []string{"修改", "更新", "改", "扫描间隔", "杠杆", "提示词"}): + case containsAny(lower, []string{"改名", "重命名", "rename", "名字", "名称", "name"}): + action = "update_name" + case containsAny(lower, []string{"修改", "更新", "改"}): action = "update" case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}): action = "query_detail" diff --git a/web/src/components/agent/AgentStepPanel.tsx b/web/src/components/agent/AgentStepPanel.tsx index d2bf6202..acb4f216 100644 --- a/web/src/components/agent/AgentStepPanel.tsx +++ b/web/src/components/agent/AgentStepPanel.tsx @@ -18,6 +18,16 @@ export function AgentStepPanel({ steps, visible }: AgentStepPanelProps) { return null } + const sanitizedSteps = steps.filter((step) => { + const label = step.label.trim().toLowerCase() + const detail = (step.detail || '').trim().toLowerCase() + return !(label.startsWith('tool:') || detail === 'central_brain') + }) + + if (sanitizedSteps.length === 0) { + return null + } + return (
- {t('balanceUpdateHint', language)} -
- {balanceFetchError && ( -- {balanceFetchError} -
- )} -