mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
v2
This commit is contained in:
136
agent/agent.go
136
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, "<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) {
|
||||
|
||||
53
agent/agent_model_selection_test.go
Normal file
53
agent/agent_model_selection_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
31
agent/ai_service_failure_test.go
Normal file
31
agent/ai_service_failure_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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,量化数据=true,OI 排行=true,净流入排行=true,价格排行=true",
|
||||
} {
|
||||
if !strings.Contains(detail, expected) {
|
||||
t.Fatalf("expected strategy detail to contain %q, got: %s", expected, detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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":
|
||||
|
||||
107
agent/market_snapshot_test.go
Normal file
107
agent/market_snapshot_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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("- 仓位风险:最多持仓 %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"}
|
||||
|
||||
@@ -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":""}`
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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": [
|
||||
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
|
||||
|
||||
@@ -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": "明确指出目标交易员不存在、依赖资源不可用,或哪一个字段值仍需用户补充/修正。"
|
||||
|
||||
@@ -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",
|
||||
|
||||
402
agent/tools.go
402
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":
|
||||
|
||||
37
agent/trader_scope_test.go
Normal file
37
agent/trader_scope_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
3
agent/user_facing_prompt.go
Normal file
3
agent/user_facing_prompt.go
Normal 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."
|
||||
12
agent/user_facing_prompt_test.go
Normal file
12
agent/user_facing_prompt_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 // 是否在竞技场显示
|
||||
|
||||
Reference in New Issue
Block a user