This commit is contained in:
lky-spec
2026-04-25 20:24:46 +08:00
parent c244e4cdf1
commit 9ee931ee30
28 changed files with 1319 additions and 255 deletions

View File

@@ -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, "<html") ||
strings.Contains(lower, "<!doctype html")
if lang == "zh" {
if looksLikeHTMLGateway {
return "这不是“未配置模型”。这次更像是上游返回了 HTML 页面或网关/反代错误页,而不是标准 JSON 响应。更可能原因是模型服务地址配错、网关拦截、支付/鉴权页返回、或上游服务临时异常。请优先检查当前启用模型的 custom_api_url、反向代理/网关状态,以及对应 provider 的服务状态。"
}
return "这不是“未配置模型”。更可能是模型服务余额不足、接口报错、鉴权失败或超时。请检查当前启用模型的 API 状态后再试。"
}
if looksLikeHTMLGateway {
return "This is not a missing-model issue. It looks more like the upstream returned an HTML page or gateway/proxy error page instead of the expected JSON response. The likely causes are a wrong model endpoint URL, gateway interception, a payment/auth page being returned, or a temporary upstream outage. Check the active model's custom_api_url, proxy/gateway status, and the provider service health first."
}
return "This is not a missing-model issue. The active model provider more likely returned an API error, authentication failure, timeout, or insufficient-balance response. Please check the active model API and try again."
}
func (a *Agent) queryPositionsDirect(L string) (string, error) {

View File

@@ -0,0 +1,53 @@
package agent
import (
"log/slog"
"path/filepath"
"testing"
"nofx/store"
)
func TestLoadAIClientFromStoreUserPrefersModelWithBalance(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "agent-model-selection.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
if err := st.AIModel().UpdateWithName("default", "default_openai", "OpenAI", true, "sk-test", "", "gpt-5.2"); err != nil {
t.Fatalf("create openai model: %v", err)
}
if err := st.AIModel().UpdateWithName("default", "wallet_claw402", "Claw402", true, "0x205d759b80bae1afa31a36c4afaeec0b10378c1c55e3363bcde5a1db75c747ca", "", "glm-5"); err != nil {
t.Fatalf("create claw402 model: %v", err)
}
restoreWalletAddress := agentWalletAddressFromPrivateKey
restoreBalanceQuery := agentQueryUSDCBalanceCached
t.Cleanup(func() {
agentWalletAddressFromPrivateKey = restoreWalletAddress
agentQueryUSDCBalanceCached = restoreBalanceQuery
})
agentWalletAddressFromPrivateKey = func(privateKey string) (string, error) {
if privateKey == "0x205d759b80bae1afa31a36c4afaeec0b10378c1c55e3363bcde5a1db75c747ca" {
return "0xabc", nil
}
return "", nil
}
agentQueryUSDCBalanceCached = func(address string) (float64, error) {
if address == "0xabc" {
return 12.5, nil
}
return 0, nil
}
a := New(nil, st, DefaultConfig(), slog.Default())
_, modelName, ok := a.loadAIClientFromStoreUser("default")
if !ok {
t.Fatalf("expected model selection to succeed")
}
if modelName != "glm-5" {
t.Fatalf("expected model with wallet balance to be selected, got %q", modelName)
}
}

View File

@@ -0,0 +1,31 @@
package agent
import (
"errors"
"log/slog"
"strings"
"testing"
)
func TestAIServiceFailureHighlightsHTMLGatewayResponse(t *testing.T) {
a := New(nil, nil, DefaultConfig(), slog.Default())
msg, err := a.aiServiceFailure("zh", errors.New("fail to parse AI server response: failed to parse response: invalid character '<' looking for beginning of value"))
if err != nil {
t.Fatalf("aiServiceFailure returned error: %v", err)
}
for _, want := range []string{
"当前 AI 服务调用失败",
"上游返回了 HTML 页面或网关/反代错误页",
"custom_api_url",
"不是“未配置模型”",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected message to contain %q, got: %s", want, msg)
}
}
if strings.Contains(msg, "更可能是模型服务余额不足、接口报错或超时") {
t.Fatalf("html parse error should not use the generic balance/timeout-only guidance: %s", msg)
}
}

View File

@@ -368,21 +368,6 @@ func normalizeTraderArgsToManualLimits(lang string, args traderUpdateArgs) (trad
}
}
}
if args.InitialBalance != nil {
requested := *args.InitialBalance
normalized := requested
if normalized < manualTraderInitialBalance {
normalized = manualTraderInitialBalance
}
if normalized != requested {
args.InitialBalance = &normalized
if lang == "zh" {
warnings = append(warnings, fmt.Sprintf("初始资金手动面板最低是 %.2f,已从 %.2f 调整为 %.2f", manualTraderInitialBalance, requested, normalized))
} else {
warnings = append(warnings, fmt.Sprintf("initial balance has a manual minimum of %.2f, adjusted from %.2f to %.2f", manualTraderInitialBalance, requested, normalized))
}
}
}
return args, warnings
}
@@ -410,6 +395,30 @@ func formatRiskControlAcceptancePrompt(lang string, warnings []string, confirmLa
return strings.Join(lines, "\n")
}
func formatRiskControlRefusalPrompt(lang string, warnings []string, confirmLabel string) string {
if len(warnings) == 0 {
return ""
}
if lang == "zh" {
lines := []string{
"这些配置超出了手动面板允许的范围,本次不会按你给的原值直接保存:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("如果接受当前安全范围,回复“%s”也可以继续告诉我你想怎么改。", confirmLabel))
return strings.Join(lines, "\n")
}
lines := []string{
"Some values were outside the manual editor limits, so I did not save the original request as-is:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("Reply %q to accept these safe values, or keep refining the draft.", confirmLabel))
return strings.Join(lines, "\n")
}
func marshalStringList(values []string) string {
if len(values) == 0 {
return ""

View File

@@ -1,6 +1,7 @@
package agent
import (
"encoding/json"
"log/slog"
"path/filepath"
"strings"
@@ -23,6 +24,69 @@ func TestToolManageModelConfigCreateRequiresCredential(t *testing.T) {
}
}
func TestToolManageModelConfigCreateDefaultsToEnabledLikeManualPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "model-create-enabled.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
resp := a.toolManageModelConfig("default", `{"action":"create","provider":"qwen","name":"qwen","api_key":"sk-test-qwen-123456","custom_model_name":"qwen3-max"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected create to succeed, got: %s", resp)
}
model, err := st.AIModel().Get("default", "default_qwen")
if err != nil {
t.Fatalf("load created model: %v", err)
}
if !model.Enabled {
t.Fatalf("expected agent-created model to default to enabled so it matches manual creation")
}
}
func TestToolManageModelConfigCreateReusesExistingProviderRecord(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "model-create-upsert.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_qwen", "qwen1", false, "sk-old-qwen-123456", "", "qwen3-max"); err != nil {
t.Fatalf("seed existing qwen model: %v", err)
}
resp := a.toolManageModelConfig("default", `{"action":"create","provider":"qwen","name":"Qwen","api_key":"sk-new-qwen-123456","custom_model_name":"qwen3-max"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected create to reuse existing qwen config instead of failing, got: %s", resp)
}
models, err := st.AIModel().List("default")
if err != nil {
t.Fatalf("list models: %v", err)
}
qwenCount := 0
for _, model := range models {
if model != nil && model.Provider == "qwen" {
qwenCount++
if model.ID != "default_qwen" {
t.Fatalf("expected existing qwen record to be reused, got model id %q", model.ID)
}
if model.Name != "Qwen" {
t.Fatalf("expected reused qwen record to be renamed, got %q", model.Name)
}
if !model.Enabled {
t.Fatalf("expected reused qwen record to be enabled after agent create")
}
}
}
if qwenCount != 1 {
t.Fatalf("expected exactly one qwen record after reuse, got %d", qwenCount)
}
}
func TestToolGetModelConfigsHidesIncompleteRows(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "visibility-list.db")
st, err := store.New(dbPath)
@@ -47,6 +111,50 @@ func TestToolGetModelConfigsHidesIncompleteRows(t *testing.T) {
}
}
func TestToolManageStrategyUpdateRejectsOutOfRangeLeverageBeforeSave(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-risk-guard.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
strategy := &store.Strategy{
ID: "strategy-risk-guard",
UserID: "default",
Name: "AI500稳重策略",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("create strategy: %v", err)
}
resp := a.toolManageStrategy("default", `{"action":"update","strategy_id":"strategy-risk-guard","config":{"risk_control":{"btc_eth_max_leverage":100,"altcoin_max_leverage":100}}}`)
if !strings.Contains(resp, `不会按你给的原值直接保存`) {
t.Fatalf("expected out-of-range leverage update to be rejected before save, got: %s", resp)
}
updated, err := st.Strategy().Get("default", strategy.ID)
if err != nil {
t.Fatalf("reload strategy: %v", err)
}
parsed, err := updated.ParseConfig()
if err != nil {
t.Fatalf("parse updated strategy config: %v", err)
}
if parsed.RiskControl.BTCETHMaxLeverage != 5 || parsed.RiskControl.AltcoinMaxLeverage != 5 {
t.Fatalf("expected stored leverage to remain unchanged at safe defaults, got btc_eth=%d alt=%d", parsed.RiskControl.BTCETHMaxLeverage, parsed.RiskControl.AltcoinMaxLeverage)
}
}
func TestExchangeSkillOptionSummaryMatchesManualPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-options.db")
st, err := store.New(dbPath)
@@ -123,3 +231,174 @@ func TestSkillVisibleFieldSummaryForExchangeUsesReadableNames(t *testing.T) {
t.Fatalf("field summary should use readable labels instead of raw keys: %s", summary)
}
}
func TestSkillVisibleFieldSummaryForStrategyCoversManualPageFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.skillVisibleFieldSummary("default", "zh", "strategy_management", "update_config")
for _, expected := range []string{"发布到市场", "配置可见", "交易对", "杠杆", "主周期", "多周期时间框架", "NofxOS API key", "角色定义", "自定义 Prompt"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected field label %q in summary, got: %s", expected, summary)
}
}
}
func TestSkillVisibleFieldSummaryForTraderExcludesManualBalanceEditing(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.skillVisibleFieldSummary("default", "zh", "trader_management", "update")
for _, expected := range []string{"名称", "交易所", "模型", "策略", "扫描间隔"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected trader field label %q in summary, got: %s", expected, summary)
}
}
if strings.Contains(summary, "初始资金") || strings.Contains(summary, "初始余额") {
t.Fatalf("trader field summary should not expose manual balance editing: %s", summary)
}
}
func TestToolCreateTraderAutoReadsInitialBalanceFromExchange(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-auto-balance.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-auto-balance",
UserID: "default",
Name: "Auto Balance Strategy",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
originalFetcher := traderInitialBalanceFetcher
traderInitialBalanceFetcher = func(exchangeCfg *store.Exchange, userID string) (float64, bool, error) {
if exchangeCfg == nil || exchangeCfg.ID != exchangeID {
t.Fatalf("unexpected exchange config passed to balance fetcher: %#v", exchangeCfg)
}
if userID != "default" {
t.Fatalf("unexpected user id %q", userID)
}
return 4321.25, true, nil
}
defer func() {
traderInitialBalanceFetcher = originalFetcher
}()
resp := a.toolManageTrader("default", `{"action":"create","name":"奶茶","ai_model_id":"default_deepseek","exchange_id":"`+exchangeID+`","strategy_id":"strategy-auto-balance","initial_balance":999}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected trader create to succeed, got: %s", resp)
}
traders, err := st.Trader().List("default")
if err != nil {
t.Fatalf("list traders: %v", err)
}
if len(traders) != 1 {
t.Fatalf("expected one trader, got %d", len(traders))
}
if traders[0].InitialBalance != 4321.25 {
t.Fatalf("expected initial balance to be auto-read from exchange, got %.2f", traders[0].InitialBalance)
}
}
func TestDescribeStrategyIncludesManualPageSections(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-detail.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
cfg := store.GetDefaultStrategyConfig("zh")
cfg.StrategyType = "grid_trading"
cfg.GridConfig = &store.GridStrategyConfig{
Symbol: "BTCUSDT",
GridCount: 12,
TotalInvestment: 1500,
Leverage: 4,
UpperPrice: 120000,
LowerPrice: 90000,
UseATRBounds: false,
ATRMultiplier: 2,
Distribution: "gaussian",
MaxDrawdownPct: 15,
StopLossPct: 5,
DailyLossLimitPct: 10,
UseMakerOnly: true,
EnableDirectionAdjust: true,
DirectionBiasRatio: 0.7,
}
cfg.CoinSource.SourceType = "mixed"
cfg.CoinSource.StaticCoins = []string{"BTCUSDT", "ETHUSDT"}
cfg.CoinSource.ExcludedCoins = []string{"DOGEUSDT"}
cfg.Indicators.EnableOIRanking = true
cfg.Indicators.EnableNetFlowRanking = true
cfg.Indicators.EnablePriceRanking = true
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
strategy := &store.Strategy{
ID: "strategy-detail-1",
UserID: "default",
Name: "Grid Alpha",
Description: "grid strategy for regression",
IsPublic: true,
ConfigVisible: true,
Config: string(rawCfg),
}
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("create strategy: %v", err)
}
strategy.ConfigVisible = false
if err := st.Strategy().Update(strategy); err != nil {
t.Fatalf("update strategy visibility: %v", err)
}
detail, ok := a.describeStrategy("default", "zh", &EntityReference{ID: strategy.ID})
if !ok {
t.Fatal("expected describeStrategy to resolve seeded strategy")
}
for _, expected := range []string{
"策略“Grid Alpha”概览",
"发布设置:已发布到市场;配置隐藏",
"网格参数:交易对 BTCUSDT网格 12总投资 1500.00;杠杆 4分布 gaussian",
"网格边界:上沿 120000.0000,下沿 90000.0000",
"标的来源mixed | AI500=3 | static=BTCUSDT,ETHUSDT | excluded=DOGEUSDT",
"NofxOS 数据API Key=true量化数据=trueOI 排行=true净流入排行=true价格排行=true",
} {
if !strings.Contains(detail, expected) {
t.Fatalf("expected strategy detail to contain %q, got: %s", expected, detail)
}
}
}

View File

@@ -13,7 +13,6 @@ var traderFieldCatalog = []entityFieldMeta{
{Key: "ai_model_id", Keywords: []string{"换模型", "切换模型", "模型"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true},
{Key: "exchange_id", Keywords: []string{"换交易所", "切换交易所", "交易所"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true},
{Key: "strategy_id", Keywords: []string{"换策略", "切换策略", "策略"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true},
{Key: "initial_balance", Keywords: []string{"初始资金", "初始余额", "initial balance"}, ValueType: "float", ManualEditable: true, AgentUpdatable: true},
{Key: "scan_interval_minutes", Keywords: []string{"扫描间隔", "扫描频率", "scan interval", "scan frequency"}, ValueType: "int", ManualEditable: true, AgentUpdatable: true},
{Key: "is_cross_margin", Keywords: []string{"全仓", "cross margin", "is_cross_margin"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true},
{Key: "show_in_competition", Keywords: []string{"竞技场显示", "显示在竞技场", "show in competition", "competition"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true},

View File

@@ -248,7 +248,6 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl
add(&out, "strategy_id", slotDisplayName("strategy", lang)+" ID", false)
add(&out, "strategy_name", slotDisplayName("strategy", lang), true)
add(&out, "auto_start", "auto_start", false)
add(&out, "initial_balance", displayCatalogFieldName("initial_balance", lang), false)
add(&out, "scan_interval_minutes", displayCatalogFieldName("scan_interval_minutes", lang), false)
add(&out, "is_cross_margin", displayCatalogFieldName("is_cross_margin", lang), false)
add(&out, "show_in_competition", displayCatalogFieldName("show_in_competition", lang), false)
@@ -258,19 +257,7 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl
add(&out, "config_value", strategyConfigFieldDisplayName("config_value", lang), false)
}
add(&out, "name", slotDisplayName("name", lang), true)
for _, key := range []string{
"source_type", "static_coins", "excluded_coins", "use_ai500", "ai500_limit",
"use_oi_top", "oi_top_limit", "use_oi_low", "oi_low_limit", "max_positions",
"min_risk_reward_ratio", "min_confidence", "leverage", "btceth_max_leverage",
"altcoin_max_leverage", "primary_timeframe", "primary_count", "selected_timeframes",
"ema_periods", "rsi_periods", "atr_periods", "boll_periods", "enable_ema",
"enable_macd", "enable_rsi", "enable_atr", "enable_boll", "enable_volume",
"enable_oi", "enable_funding_rate", "enable_all_core_indicators", "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",
"role_definition", "trading_frequency", "entry_standards", "decision_process", "custom_prompt",
} {
for _, key := range manualStrategyEditableFieldKeys() {
add(&out, key, strategyConfigFieldDisplayName(key, lang), false)
}
}
@@ -501,7 +488,7 @@ func (a *Agent) applyLLMExtractionToSkillSession(storeUserID string, session *sk
setField(session, key, value)
case "name", "exchange_id", "exchange_name", "model_id", "ai_model_id", "model_name", "strategy_id", "strategy_name", "auto_start":
setField(session, key, value)
case "initial_balance", "scan_interval_minutes", "is_cross_margin", "show_in_competition":
case "scan_interval_minutes", "is_cross_margin", "show_in_competition":
setField(session, key, value)
}
case "strategy_management":

View File

@@ -0,0 +1,107 @@
package agent
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestToolGetMarketSnapshotReturnsRealtimeAnalysisContext(t *testing.T) {
prevBaseURL := binanceFuturesAPIBaseURL
prevClient := marketDataHTTPClient
binanceFuturesAPIBaseURL = "https://example.test"
marketDataHTTPClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := ""
switch {
case strings.HasPrefix(req.URL.Path, "/fapi/v1/ticker/24hr"):
body = `{"symbol":"BTCUSDT","lastPrice":"65000","priceChange":"1200","priceChangePercent":"1.88","highPrice":"66000","lowPrice":"63800","volume":"12345","quoteVolume":"800000000","count":98765}`
case strings.HasPrefix(req.URL.Path, "/fapi/v1/premiumIndex"):
body = `{"symbol":"BTCUSDT","markPrice":"65010","indexPrice":"64990","lastFundingRate":"0.00010000","nextFundingTime":1710000000000}`
case strings.HasPrefix(req.URL.Path, "/fapi/v1/openInterest"):
body = `{"symbol":"BTCUSDT","openInterest":"45678.9","time":1710000000000}`
case strings.HasPrefix(req.URL.Path, "/fapi/v1/klines"):
body = `[[1710000000000,"64000","65100","63900","64500","100",1710000899999],[1710000900000,"64500","65500","64400","65000","120",1710001799999]]`
default:
body = `{"error":"not found"}`
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}, nil
}),
}
defer func() {
binanceFuturesAPIBaseURL = prevBaseURL
marketDataHTTPClient = prevClient
}()
a := New(nil, nil, DefaultConfig(), nil)
raw := a.toolGetMarketSnapshot(`{"symbol":"BTC","interval":"15m","limit":2}`)
var resp struct {
Symbol string `json:"symbol"`
Price float64 `json:"price"`
Ticker24h struct {
PriceChangePercent float64 `json:"price_change_percent"`
} `json:"ticker_24h"`
PerpMetrics struct {
FundingRate float64 `json:"funding_rate"`
OpenInterest float64 `json:"open_interest"`
} `json:"perp_metrics"`
KlineSnapshot struct {
Interval string `json:"interval"`
Limit int `json:"limit"`
PeriodChangePercent float64 `json:"period_change_percent"`
RecentKlines []map[string]any `json:"recent_klines"`
} `json:"kline_snapshot"`
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatalf("failed to parse tool response: %v\nraw=%s", err, raw)
}
if resp.Error != "" {
t.Fatalf("unexpected tool error: %s", resp.Error)
}
if resp.Symbol != "BTCUSDT" {
t.Fatalf("expected normalized symbol BTCUSDT, got %s", resp.Symbol)
}
if resp.Price != 65000 {
t.Fatalf("expected price 65000, got %v", resp.Price)
}
if resp.Ticker24h.PriceChangePercent != 1.88 {
t.Fatalf("expected 24h change 1.88, got %v", resp.Ticker24h.PriceChangePercent)
}
if resp.PerpMetrics.FundingRate != 0.0001 {
t.Fatalf("expected funding rate 0.0001, got %v", resp.PerpMetrics.FundingRate)
}
if resp.PerpMetrics.OpenInterest != 45678.9 {
t.Fatalf("expected open interest 45678.9, got %v", resp.PerpMetrics.OpenInterest)
}
if resp.KlineSnapshot.Interval != "15m" || resp.KlineSnapshot.Limit != 2 {
t.Fatalf("unexpected kline snapshot metadata: %+v", resp.KlineSnapshot)
}
if len(resp.KlineSnapshot.RecentKlines) != 2 {
t.Fatalf("expected 2 klines, got %d", len(resp.KlineSnapshot.RecentKlines))
}
if resp.KlineSnapshot.PeriodChangePercent <= 0 {
t.Fatalf("expected positive period change, got %v", resp.KlineSnapshot.PeriodChangePercent)
}
}
func TestToolGetMarketSnapshotRejectsStockSymbols(t *testing.T) {
a := New(nil, nil, DefaultConfig(), nil)
raw := a.toolGetMarketSnapshot(`{"symbol":"AAPL"}`)
if !strings.Contains(raw, "currently supports crypto symbols only") {
t.Fatalf("expected stock rejection, got: %s", raw)
}
}

View File

@@ -43,3 +43,32 @@ func TestHandleModelCreateSkillAsksProviderFirstWithClaw402Recommendation(t *tes
}
}
}
func TestHandleModelCreateSkillAcceptsBareClaw402PrivateKey(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "agent-model-create-claw402.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
session := skillSession{
Name: "model_management",
Action: "create",
Phase: "collecting",
Fields: map[string]string{
"provider": "claw402",
"name": "Claw402 (Base USDC)",
"custom_model_name": "deepseek",
},
}
reply := a.handleModelCreateSkill("default", 42, "zh", "0x205d759b80bae1afa31a36c4afaeec0b10378c1c55e3363bcde5a1db75c747ca", session)
if strings.Contains(reply, "还缺这些字段:钱包私钥") {
t.Fatalf("expected bare private key to be accepted, got: %s", reply)
}
if !strings.Contains(reply, "我先整理了一份模型配置草稿") {
t.Fatalf("expected draft summary after accepting private key, got: %s", reply)
}
}

View File

@@ -1114,7 +1114,7 @@ func traderCreateFieldsFromExecutionExtraction(result executionFlowExtractionRes
fields["strategy_id"] = value
case "strategy_name":
fields["strategy_name"] = value
case "auto_start", "initial_balance", "scan_interval_minutes", "is_cross_margin", "show_in_competition":
case "auto_start", "scan_interval_minutes", "is_cross_margin", "show_in_competition":
fields[key] = value
}
}
@@ -3766,6 +3766,7 @@ func (a *Agent) generateFinalPlanResponse(ctx context.Context, userID int64, lan
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewSystemMessage("You are responding after a completed execution plan. Use the observations as the source of truth. Be concise and actionable."),
mcp.NewSystemMessage(cleanUserFacingReplyInstruction),
mcp.NewUserMessage(fmt.Sprintf("Goal: %s\nResponse instruction: %s\nObservations JSON: %s\nPersistent preferences: %s\nTask state: %s", state.Goal, instruction, string(obsJSON), a.buildPersistentPreferencesContext(userID), buildTaskStateContext(a.getTaskState(userID)))),
},
Ctx: stageCtx,

View File

@@ -77,19 +77,24 @@ func buildSkillDomainPrimer(lang, skillName string) string {
slotDisplayName("model", lang),
slotDisplayName("strategy", lang),
displayCatalogFieldName("scan_interval_minutes", lang),
displayCatalogFieldName("initial_balance", lang),
}
if lang == "zh" {
return strings.Join([]string{
"### 交易员配置领域约束",
"- 交易员创建/修改围绕名称、绑定交易所、绑定模型、绑定策略和运行参数展开。",
"- 交易员是装配层,负责创建、换绑策略/交易所/模型,以及启动、停止、删除、查询。",
"- 编辑交易员时,默认只处理绑定关系;不要顺手改策略、模型、交易所内部配置。",
"- 交易员初始余额由系统在创建时自动读取绑定交易所账户净值,不接受手动设置、充值或人为改余额。",
"- 若用户要改策略参数、模型配置或交易所凭证,应切到对应 management skill。",
"- 创建交易员时最关键的是:名称、交易所、模型、策略。",
"- 关键字段:" + strings.Join(fields, "、"),
}, "\n")
}
return strings.Join([]string{
"### Trader Config Domain Guard",
"- Trader create/update revolves around the trader name, bound exchange, bound model, bound strategy, and runtime settings.",
"- Traders are the assembly layer: create, rebind strategy/exchange/model, and control lifecycle.",
"- When editing a trader, default to changing bindings only; do not silently edit the internals of the strategy, model, or exchange.",
"- Trader initial balance is auto-read from the bound exchange account equity at creation time; do not ask the user to set, top up, or manually edit trader balance.",
"- If the user wants to change strategy parameters, model config, or exchange credentials, switch to the corresponding management skill.",
"- The key create fields are name, exchange, model, and strategy.",
"- Key fields: " + strings.Join(fields, ", "),
}, "\n")

View File

@@ -449,9 +449,6 @@ func applyTraderUpdateArgsToSession(session *skillSession, args traderUpdateArgs
if args.StrategyID != "" {
setField(session, "strategy_id", args.StrategyID)
}
if args.InitialBalance != nil {
setField(session, "initial_balance", strconv.FormatFloat(*args.InitialBalance, 'f', -1, 64))
}
if args.ScanIntervalMinutes != nil {
setField(session, "scan_interval_minutes", strconv.Itoa(*args.ScanIntervalMinutes))
}
@@ -492,11 +489,6 @@ func buildTraderUpdateArgsFromSession(session skillSession) traderUpdateArgs {
args.AIModelID = fieldValue(session, "ai_model_id")
args.ExchangeID = fieldValue(session, "exchange_id")
args.StrategyID = fieldValue(session, "strategy_id")
if value := fieldValue(session, "initial_balance"); value != "" {
if parsed, err := strconv.ParseFloat(value, 64); err == nil {
args.InitialBalance = &parsed
}
}
if value := fieldValue(session, "scan_interval_minutes"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
args.ScanIntervalMinutes = &parsed
@@ -1834,7 +1826,7 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64,
selectedField = detectCatalogField(text, traderFieldCatalog)
}
}
if selectedField == "name" || selectedField == "initial_balance" || selectedField == "scan_interval_minutes" || selectedField == "is_cross_margin" || selectedField == "show_in_competition" {
if selectedField == "name" || selectedField == "scan_interval_minutes" || selectedField == "is_cross_margin" || selectedField == "show_in_competition" {
selectedField = ""
}
if selectedField != "" {
@@ -2027,10 +2019,6 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64,
parsedArgs.ExchangeID = value
case "strategy_id":
parsedArgs.StrategyID = value
case "initial_balance":
if parsed, err := strconv.ParseFloat(value, 64); err == nil {
parsedArgs.InitialBalance = &parsed
}
case "scan_interval_minutes":
if parsed, err := strconv.Atoi(value); err == nil {
parsedArgs.ScanIntervalMinutes = &parsed
@@ -2135,7 +2123,7 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64,
}
return fmt.Sprintf("还差一步:请告诉我新的%s。", displayCatalogFieldName(selectedField, lang))
}
return "你可以直接告诉我想改哪一项,比如名称、扫描频率、初始资金、杠杆,或者绑定的模型、交易所、策略。"
return "你可以直接告诉我想改哪一项,比如名称,或者绑定的模型、交易所、策略。若你要改策略参数、模型配置或交易所凭证,我会切到对应配置流程。"
}
if selectedField != "" {
if selectedField == "ai_model_id" || selectedField == "exchange_id" || selectedField == "strategy_id" {
@@ -2143,7 +2131,7 @@ func (a *Agent) executeTraderManagementAction(storeUserID string, userID int64,
}
return fmt.Sprintf("One more thing: tell me the new %s.", displayCatalogFieldName(selectedField, lang))
}
return "Tell me what you want to change first, for example the name, scan interval, balance, leverage, or the linked model, exchange, or strategy."
return "Tell me what you want to change first, for example the name or the linked model, exchange, or strategy. If you want to edit the internals of a strategy, model, or exchange, I'll switch to the right config flow."
}
args := manageTraderArgs{Action: "update", TraderID: session.TargetRef.ID, Name: newName}
setSkillDAGStep(&session, "execute_update")

View File

@@ -736,10 +736,6 @@ func formatTraderCreateDraftSummary(lang string, session skillSession) string {
if args.ScanIntervalMinutes != nil && *args.ScanIntervalMinutes > 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("- 仓位风险:最多持仓 %dBTC/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量化数据=%tOI 排行=%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"}

View File

@@ -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":""}`

View File

@@ -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" {

View File

@@ -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_tradingAI 量化)或 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": "网格策略杠杆倍数,手动页面当前范围 15。"
},
"upper_price": {
"type": "float",
"description": "网格上边界价格grid_trading 类型专用。"
@@ -337,6 +357,7 @@
},
"validation_rules": [
"btceth_max_leverage 和 altcoin_max_leverage 范围均为 120超出时自动收敛并告知用户。",
"grid_trading 的 leverage 需与手动页面一致,范围 15超出时自动收敛并告知用户。",
"min_confidence 范围 0100超出时自动收敛并告知用户。",
"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": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",

View File

@@ -2,7 +2,7 @@
"name": "trader_management",
"kind": "management",
"domain": "trader",
"description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。适用于用户提到交易员名称、绑定交易所、绑定模型、绑定策略、保证金模式、扫描频率、竞技场显示、运行状态等管理需求。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。创建交易员时必须收齐名称、交易所、模型、策略;其中交易所、模型、策略既可以直接选择用户已有可用资源,也可以在当前主流程里先新建/启用对应资源,再继续完成交易员创建。",
"description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。交易员是装配层,核心是名称以及绑定交易所、模型、策略;编辑交易员默认只换绑定,不修改这些依赖对象的内部配置。若用户要改策略参数、模型配置或交易所凭证,应切到各自的 management skill。创建交易员时必须收齐名称、交易所、模型、策略;其中交易所、模型、策略既可以直接选择用户已有可用资源,也可以在当前主流程里先新建/启用对应资源,再继续完成交易员创建。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。",
"intents": [
"创建交易员",
"修改交易员",
@@ -40,11 +40,6 @@
"default": 5,
"description": "AI 扫描决策间隔,单位分钟,手动面板可配置范围 360 分钟。超出范围会自动收敛到边界值并告知用户。"
},
"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 超出 360 范围时,系统自动收敛到边界值,并通过 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 超出 360 时,自动收敛并告知用户。",
"若用户明确想覆盖杠杆、币种范围或提示词,应允许在创建阶段一并收集 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 超出 360 时,自动收敛并告知用户。",
"若用户修改的是交易员级杠杆、指定币对、提示词或选币来源,也应走 update而不是误导去改策略。"
"如果用户实际上是想修改策略参数、模型配置或交易所凭证,不要继续留在 trader update应切到对应 management skill。"
],
"success_output": "返回更新后的 trader_id 与简短配置摘要,明确哪些字段已经生效。",
"failure_output": "明确指出目标交易员不存在、依赖资源不可用,或哪一个字段值仍需用户补充/修正。"

View File

@@ -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",

View File

@@ -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":

View File

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

View File

@@ -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."

View File

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

View File

@@ -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"

View File

@@ -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 (
<div
style={{
@@ -41,7 +51,7 @@ export function AgentStepPanel({ steps, visible }: AgentStepPanelProps) {
Live Run
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{steps.map((step) => {
{sanitizedSteps.map((step) => {
const style = statusStyles[step.status]
return (
<div

View File

@@ -10,7 +10,14 @@ interface ChatMessagesProps {
function hasMeaningfulExecutionSteps(steps?: AgentStep[]) {
if (!steps || steps.length === 0) return false
return steps.some((step) => step.status !== 'planning')
return steps.some((step) => {
const label = step.label.trim().toLowerCase()
const detail = (step.detail || '').trim().toLowerCase()
if (label.startsWith('tool:') || detail === 'central_brain') {
return false
}
return step.status !== 'planning'
})
}
export const ChatMessages = forwardRef<HTMLDivElement, ChatMessagesProps>(

View File

@@ -1,8 +1,7 @@
import { useState, useEffect } from 'react'
import type { AIModel, Exchange, CreateTraderRequest, ExchangeAccountStateResponse, Strategy } from '../../types'
import type { AIModel, Exchange, CreateTraderRequest, Strategy, TraderConfigData } from '../../types'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
import { toast } from 'sonner'
import { Pencil, Plus, X as IconX, Sparkles, ExternalLink, UserPlus } from 'lucide-react'
import { httpClient } from '../../lib/httpClient'
import { NofxSelect } from '../ui/select'
@@ -22,9 +21,6 @@ const EXCHANGE_REGISTRATION_LINKS: Record<string, { url: string; hasReferral?: b
aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true },
lighter: { url: 'https://app.lighter.xyz/?referral=68151432', hasReferral: true },
}
import type { TraderConfigData } from '../../types'
// 表单内部状态类型
interface FormState {
trader_id?: string
@@ -35,7 +31,6 @@ interface FormState {
is_cross_margin: boolean
show_in_competition: boolean
scan_interval_minutes: number
initial_balance?: number
}
interface TraderConfigModalProps {
@@ -69,8 +64,6 @@ export function TraderConfigModal({
})
const [isSaving, setIsSaving] = useState(false)
const [strategies, setStrategies] = useState<Strategy[]>([])
const [isFetchingBalance, setIsFetchingBalance] = useState(false)
const [balanceFetchError, setBalanceFetchError] = useState<string>('')
// 获取用户的策略列表
useEffect(() => {
@@ -125,64 +118,7 @@ export function TraderConfigModal({
}
const handleExchangeChange = (exchangeId: string) => {
setBalanceFetchError('')
setFormData((prev) => {
if (prev.exchange_id === exchangeId) {
return prev
}
const next: FormState = { ...prev, exchange_id: exchangeId }
// Exchange balance belongs to the selected exchange, not the trader record.
// Clear the old baseline so we don't carry Exchange B's balance into Exchange A.
if (isEditMode) {
next.initial_balance = undefined
}
return next
})
}
const handleFetchCurrentBalance = async () => {
if (!isEditMode) {
setBalanceFetchError(t('fetchBalanceEditModeOnly', language))
return
}
if (!formData.exchange_id) {
setBalanceFetchError(t('balanceFetchFailed', language))
return
}
setIsFetchingBalance(true)
setBalanceFetchError('')
try {
const result = await httpClient.get<ExchangeAccountStateResponse>('/api/exchanges/account-state')
const selectedState = result.data?.states?.[formData.exchange_id]
if (result.success && selectedState?.status === 'ok') {
const currentBalance =
selectedState.total_equity ??
selectedState.available_balance ??
0
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
toast.success(t('balanceFetched', language))
} else {
setBalanceFetchError(
selectedState?.error_message || result.message || t('balanceFetchFailed', language)
)
}
} catch (error) {
console.error(t('balanceFetchFailed', language) + ':', error)
setBalanceFetchError(
error instanceof Error && error.message
? error.message
: t('balanceFetchNetworkError', language)
)
} finally {
setIsFetchingBalance(false)
}
setFormData((prev) => ({ ...prev, exchange_id: exchangeId }))
}
const handleSave = async () => {
@@ -200,11 +136,6 @@ export function TraderConfigModal({
scan_interval_minutes: formData.scan_interval_minutes,
}
// 只在编辑模式时包含initial_balance
if (isEditMode && formData.initial_balance !== undefined) {
saveData.initial_balance = formData.initial_balance
}
await onSave(saveData)
} catch (error) {
console.error(t('saveFailed', language) + ':', error)
@@ -495,48 +426,6 @@ export function TraderConfigModal({
</p>
</div>
{/* Initial Balance (Edit mode only) */}
{isEditMode && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-[#EAECEF]">
{t('initialBalanceLabel', language)}
</label>
<button
type="button"
onClick={handleFetchCurrentBalance}
disabled={isFetchingBalance}
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors disabled:bg-[#848E9C] disabled:cursor-not-allowed"
>
{isFetchingBalance ? t('fetching', language) : t('fetchCurrentBalance', language)}
</button>
</div>
<input
type="number"
value={formData.initial_balance || 0}
onChange={(e) =>
handleInputChange(
'initial_balance',
Number(e.target.value)
)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="100"
step="0.01"
/>
<p className="text-xs text-[#848E9C] mt-1">
{t('balanceUpdateHint', language)}
</p>
{balanceFetchError && (
<p className="text-xs text-red-500 mt-1">
{balanceFetchError}
</p>
)}
</div>
)}
{/* Create mode info */}
{!isEditMode && (
<div className="p-3 bg-[#1E2329] border border-[#2B3139] rounded flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -556,7 +445,7 @@ export function TraderConfigModal({
{t('autoFetchBalanceInfo', language)}
</span>
</div>
)}
</div>
</div>

View File

@@ -261,25 +261,6 @@ async function runAgentStream(params: {
),
storageUserId
)
} else if (eventType === 'tool') {
patchMessagesInStore(
(prev) =>
prev.map((m) =>
m.id === botId
? {
...m,
steps: appendStep(m.steps, {
id: `tool-${Date.now()}`,
label: `Tool: ${data}`,
status: 'running',
detail: data,
}),
time: now(),
}
: m
),
storageUserId
)
} else if (eventType === 'done') {
patchMessagesInStore(
(prev) =>

View File

@@ -96,7 +96,6 @@ export interface CreateTraderRequest {
ai_model_id: string
exchange_id: string
strategy_id?: string // 策略ID新版使用保存的策略配置
initial_balance?: number // 可选:创建时由后端自动获取,编辑时可手动更新
scan_interval_minutes?: number
is_cross_margin?: boolean
show_in_competition?: boolean // 是否在竞技场显示