Refine strategy creation flow and diagnostics

This commit is contained in:
lky-spec
2026-05-09 14:48:24 +08:00
parent 0f11be77f8
commit e67a927a4f
29 changed files with 3410 additions and 1305 deletions

View File

@@ -94,11 +94,11 @@ func plannerToolNamesForDomain(domain string) []string {
case "strategy":
return []string{"get_strategies", "manage_strategy"}
case "diagnosis":
return []string{"get_backend_logs", "get_model_configs", "get_exchange_configs", "get_strategies", "manage_trader"}
return []string{"get_decisions", "get_backend_logs", "get_model_configs", "get_exchange_configs", "get_strategies", "manage_trader"}
default:
return []string{
"get_preferences", "manage_preferences",
"get_backend_logs",
"get_decisions", "get_backend_logs",
"get_exchange_configs", "manage_exchange_config",
"get_model_configs", "manage_model_config",
"get_strategies", "manage_strategy",
@@ -280,18 +280,15 @@ func strategyConfigSchema() map[string]any {
"coin_source": map[string]any{
"type": "object",
"properties": map[string]any{
"source_type": map[string]any{"type": "string", "enum": []string{"static", "ai500", "oi_top", "oi_low", "mixed"}, "description": "Manual page coin source: static, ai500, oi_top, oi_low; mixed can be displayed when already configured."},
"static_coins": stringArraySchema("Static coin symbols such as BTCUSDT or ETHUSDT. Manual page allows at most 10. xyz: assets such as xyz:TSLA, xyz:GOLD, xyz:XYZ100 are also supported."),
"excluded_coins": stringArraySchema("Coin symbols to exclude from all sources."),
"use_ai500": map[string]any{"type": "boolean"},
"ai500_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
"use_oi_top": map[string]any{"type": "boolean"},
"oi_top_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
"use_oi_low": map[string]any{"type": "boolean"},
"oi_low_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
"use_hyper_all": map[string]any{"type": "boolean"},
"use_hyper_main": map[string]any{"type": "boolean"},
"hyper_main_limit": map[string]any{"type": "number"},
"source_type": map[string]any{"type": "string", "enum": []string{"static", "ai500", "oi_top", "oi_low"}, "description": "Manual page coin source: static, ai500, oi_top, oi_low."},
"static_coins": stringArraySchema("Static coin symbols such as BTCUSDT or ETHUSDT. Manual page allows at most 10. xyz: assets such as xyz:TSLA, xyz:GOLD, xyz:XYZ100 are also supported."),
"excluded_coins": stringArraySchema("Coin symbols to exclude from all sources."),
"use_ai500": map[string]any{"type": "boolean"},
"ai500_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
"use_oi_top": map[string]any{"type": "boolean"},
"oi_top_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
"use_oi_low": map[string]any{"type": "boolean"},
"oi_low_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
},
},
"indicators": map[string]any{
@@ -340,14 +337,10 @@ func strategyConfigSchema() map[string]any {
"risk_control": map[string]any{
"type": "object",
"properties": map[string]any{
"max_positions": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless the user explicitly asks for advanced configuration."},
"btc_eth_max_leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 20},
"altcoin_max_leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 20},
"btc_eth_max_position_value_ratio": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless explicitly requested."},
"altcoin_max_position_value_ratio": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless explicitly requested."},
"max_margin_usage": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless explicitly requested."},
"min_risk_reward_ratio": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10, step 0.5."},
"min_confidence": map[string]any{"type": "number", "minimum": 50, "maximum": 100, "description": "Manual page range 50-100."},
"btc_eth_max_leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 20},
"altcoin_max_leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 20},
"min_risk_reward_ratio": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10, step 0.5."},
"min_confidence": map[string]any{"type": "number", "minimum": 50, "maximum": 100, "description": "Manual page range 50-100."},
},
},
"prompt_sections": map[string]any{
@@ -367,7 +360,7 @@ func strategyConfigSchema() map[string]any {
"properties": map[string]any{
"symbol": map[string]any{"type": "string", "enum": []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT"}, "description": "Manual page dropdown options for grid trading symbols."},
"grid_count": map[string]any{"type": "number", "minimum": 5, "maximum": 50, "description": "Manual page range 5-50."},
"total_investment": map[string]any{"type": "number", "minimum": 100, "description": "Manual page minimum 100 USDT."},
"total_investment": map[string]any{"type": "number", "minimum": 100, "description": "User's actual capital/margin budget for the grid strategy, not leveraged notional exposure. Minimum 100 USDT."},
"leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 5, "description": "Manual page range 1-5."},
"upper_price": map[string]any{"type": "number"},
"lower_price": map[string]any{"type": "number"},
@@ -529,6 +522,21 @@ func buildAgentTools() []mcp.Tool {
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
Name: "get_decisions",
Description: "Get recent AI decision records for a trader diagnosis. Use this before concluding why a trader is not opening orders: it shows candidate coins, wait/hold/open decisions, validation errors, execution logs, and AI call duration.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"trader_id": map[string]any{"type": "string", "description": "Trader id to diagnose."},
"trader_name": map[string]any{"type": "string", "description": "Trader name to diagnose. Used to look up the trader when id is not known."},
"limit": map[string]any{"type": "number", "description": "Maximum number of recent decision records to return. Default 5, max 20."},
},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
@@ -868,6 +876,8 @@ func (a *Agent) handleToolCall(ctx context.Context, storeUserID string, userID i
return a.toolManagePreferences(userID, tc.Function.Arguments)
case "get_backend_logs":
return a.toolGetBackendLogs(storeUserID, tc.Function.Arguments)
case "get_decisions":
return a.toolGetDecisions(storeUserID, tc.Function.Arguments)
case "get_exchange_configs":
return a.toolGetExchangeConfigs(storeUserID)
case "manage_exchange_config":
@@ -887,9 +897,9 @@ func (a *Agent) handleToolCall(ctx context.Context, storeUserID string, userID i
case "execute_trade":
return a.toolExecuteTrade(ctx, userID, lang, tc.Function.Arguments)
case "get_positions":
return a.toolGetPositions()
return a.toolGetPositions(storeUserID)
case "get_balance":
return a.toolGetBalance()
return a.toolGetBalance(storeUserID)
case "get_market_price":
return a.toolGetMarketPrice(tc.Function.Arguments)
case "get_market_snapshot":
@@ -1296,6 +1306,34 @@ func filterBackendLogEntriesAny(entries []string, needles ...string) []string {
return filtered
}
func (a *Agent) resolveTraderForTool(storeUserID, traderID, traderName string) (*store.Trader, error) {
traderID = strings.TrimSpace(traderID)
traderName = strings.TrimSpace(traderName)
if traderID == "" && traderName == "" {
return nil, fmt.Errorf("trader_id or trader_name is required")
}
if traderID != "" {
traderCfg, err := a.store.Trader().GetByID(traderID)
if err != nil {
return nil, fmt.Errorf("failed to load trader: %w", err)
}
if traderCfg.UserID != storeUserID {
return nil, fmt.Errorf("trader not found for current user")
}
return traderCfg, nil
}
traders, err := a.store.Trader().List(storeUserID)
if err != nil {
return nil, fmt.Errorf("failed to list traders: %w", err)
}
for _, traderCfg := range traders {
if strings.EqualFold(strings.TrimSpace(traderCfg.Name), traderName) {
return traderCfg, nil
}
}
return nil, fmt.Errorf("trader %q not found", traderName)
}
func (a *Agent) toolGetBackendLogs(storeUserID, argsJSON string) string {
var args struct {
TraderID string `json:"trader_id"`
@@ -1315,42 +1353,15 @@ func (a *Agent) toolGetBackendLogs(storeUserID, argsJSON string) string {
if args.ErrorsOnly != nil {
errorsOnly = *args.ErrorsOnly
}
traderID := strings.TrimSpace(args.TraderID)
traderName := strings.TrimSpace(args.TraderName)
if traderID == "" && traderName == "" {
return `{"error":"trader_id or trader_name is required"}`
}
// resolve by name if id not provided
if traderID == "" {
traders, err := a.store.Trader().List(storeUserID)
if err != nil {
return fmt.Sprintf(`{"error":"failed to list traders: %s"}`, err)
}
for _, t := range traders {
if strings.EqualFold(strings.TrimSpace(t.Name), traderName) {
traderID = t.ID
traderName = t.Name
break
}
}
if traderID == "" {
return fmt.Sprintf(`{"error":"trader %q not found"}`, traderName)
}
} else {
trader, err := a.store.Trader().GetByID(traderID)
if err != nil {
return fmt.Sprintf(`{"error":"failed to load trader: %s"}`, err)
}
if trader.UserID != storeUserID {
return `{"error":"trader not found for current user"}`
}
traderName = trader.Name
traderCfg, err := a.resolveTraderForTool(storeUserID, args.TraderID, args.TraderName)
if err != nil {
return fmt.Sprintf(`{"error":"%s"}`, err)
}
path, entries, err := readBackendLogEntries(args.Limit, "", errorsOnly)
if err != nil {
return fmt.Sprintf(`{"error":"failed to read backend logs: %s"}`, err)
}
entries = filterBackendLogEntriesAny(entries, traderID, traderName)
entries = filterBackendLogEntriesAny(entries, traderCfg.ID, traderCfg.Name)
if args.Limit <= 0 {
args.Limit = 30
}
@@ -1358,8 +1369,8 @@ func (a *Agent) toolGetBackendLogs(storeUserID, argsJSON string) string {
entries = entries[len(entries)-args.Limit:]
}
result, _ := json.Marshal(map[string]any{
"trader_id": traderID,
"trader_name": traderName,
"trader_id": traderCfg.ID,
"trader_name": traderCfg.Name,
"log_file": path,
"entries": entries,
"count": len(entries),
@@ -1368,6 +1379,59 @@ func (a *Agent) toolGetBackendLogs(storeUserID, argsJSON string) string {
return string(result)
}
func (a *Agent) toolGetDecisions(storeUserID, argsJSON string) string {
var args struct {
TraderID string `json:"trader_id"`
TraderName string `json:"trader_name"`
Limit int `json:"limit"`
}
if strings.TrimSpace(argsJSON) != "" {
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err)
}
}
if a.store == nil {
return `{"error":"store unavailable"}`
}
traderCfg, err := a.resolveTraderForTool(storeUserID, args.TraderID, args.TraderName)
if err != nil {
return fmt.Sprintf(`{"error":"%s"}`, err)
}
limit := args.Limit
if limit <= 0 {
limit = 5
}
if limit > 20 {
limit = 20
}
records, err := a.store.Decision().GetLatestRecords(traderCfg.ID, limit)
if err != nil {
return fmt.Sprintf(`{"error":"failed to get decision records: %s"}`, err)
}
items := make([]map[string]any, 0, len(records))
for _, record := range records {
items = append(items, map[string]any{
"id": record.ID,
"cycle_number": record.CycleNumber,
"timestamp": record.Timestamp,
"success": record.Success,
"error_message": record.ErrorMessage,
"ai_request_duration_ms": record.AIRequestDurationMs,
"candidate_coins": record.CandidateCoins,
"execution_log": record.ExecutionLog,
"decisions": record.Decisions,
"decision_json": record.DecisionJSON,
})
}
result, _ := json.Marshal(map[string]any{
"trader_id": traderCfg.ID,
"trader_name": traderCfg.Name,
"count": len(items),
"records": items,
})
return string(result)
}
func (a *Agent) toolManageExchangeConfig(storeUserID, argsJSON string) string {
if a.store == nil {
return `{"error":"store unavailable"}`
@@ -2739,13 +2803,27 @@ func (a *Agent) toolExecuteTrade(ctx context.Context, userID int64, lang, argsJS
return string(result)
}
func (a *Agent) toolGetPositions() string {
func (a *Agent) toolGetPositions(storeUserID string) string {
if a.traderManager == nil {
return `{"error": "no trader manager configured"}`
}
if a.store == nil {
return `{"error": "store unavailable"}`
}
traderConfigs, err := a.store.Trader().List(storeUserID)
if err != nil {
return fmt.Sprintf(`{"error": "failed to list traders: %s"}`, err)
}
var positions []map[string]any
for id, t := range a.traderManager.GetAllTraders() {
for _, traderCfg := range traderConfigs {
if strings.TrimSpace(traderCfg.ID) == "" {
continue
}
t, err := a.traderManager.GetTrader(traderCfg.ID)
if err != nil {
continue
}
pos, err := t.GetPositions()
if err != nil {
continue
@@ -2755,7 +2833,7 @@ func (a *Agent) toolGetPositions() string {
if size == 0 {
continue
}
tid := id
tid := traderCfg.ID
if len(tid) > 8 {
tid = tid[:8]
}
@@ -2781,18 +2859,32 @@ func (a *Agent) toolGetPositions() string {
return string(result)
}
func (a *Agent) toolGetBalance() string {
func (a *Agent) toolGetBalance(storeUserID string) string {
if a.traderManager == nil {
return `{"error": "no trader manager configured"}`
}
if a.store == nil {
return `{"error": "store unavailable"}`
}
traderConfigs, err := a.store.Trader().List(storeUserID)
if err != nil {
return fmt.Sprintf(`{"error": "failed to list traders: %s"}`, err)
}
var balances []map[string]any
for id, t := range a.traderManager.GetAllTraders() {
for _, traderCfg := range traderConfigs {
if strings.TrimSpace(traderCfg.ID) == "" {
continue
}
t, err := a.traderManager.GetTrader(traderCfg.ID)
if err != nil {
continue
}
info, err := t.GetAccountInfo()
if err != nil {
continue
}
tid := id
tid := traderCfg.ID
if len(tid) > 8 {
tid = tid[:8]
}
@@ -3081,6 +3173,26 @@ func maxInt(a, b int) int {
func strategyLockedFieldError(lang, field string) string {
switch strings.TrimSpace(field) {
case "max_positions":
if lang == "zh" {
return "最大持仓数是 System enforced 字段策略编辑页不提供普通输入控件Agent 不能修改。"
}
return "Max positions is System enforced in the strategy editor and cannot be changed by the agent."
case "btceth_max_position_value_ratio":
if lang == "zh" {
return "BTC/ETH 单币仓位上限是 System enforced 字段策略编辑页不提供普通输入控件Agent 不能修改。"
}
return "BTC/ETH position value ratio is System enforced in the strategy editor and cannot be changed by the agent."
case "altcoin_max_position_value_ratio":
if lang == "zh" {
return "山寨币单币仓位上限是 System enforced 字段策略编辑页不提供普通输入控件Agent 不能修改。"
}
return "Altcoin position value ratio is System enforced in the strategy editor and cannot be changed by the agent."
case "max_margin_usage":
if lang == "zh" {
return "最大保证金使用率是 System enforced 字段策略编辑页不提供普通输入控件Agent 不能修改。"
}
return "Max margin usage is System enforced in the strategy editor and cannot be changed by the agent."
case "min_position_size":
if lang == "zh" {
return "最小开仓金额是系统固定值 12 USDT手动面板里也是 System enforcedAgent 不能修改。"
@@ -3102,8 +3214,19 @@ func strategyConfigContainsLockedField(config map[string]any) (string, bool) {
return "min_position_size", true
}
if risk, ok := config["risk_control"].(map[string]any); ok {
if _, ok := risk["min_position_size"]; ok {
return "min_position_size", true
for _, field := range []string{"max_positions", "btc_eth_max_position_value_ratio", "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", "min_position_size"} {
if _, ok := risk[field]; ok {
return field, true
}
}
}
if aiConfig, ok := config["ai_config"].(map[string]any); ok {
if risk, ok := aiConfig["risk_control"].(map[string]any); ok {
for _, field := range []string{"max_positions", "btc_eth_max_position_value_ratio", "btceth_max_position_value_ratio", "altcoin_max_position_value_ratio", "max_margin_usage", "min_position_size"} {
if _, ok := risk[field]; ok {
return field, true
}
}
}
}
return "", false