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

@@ -513,8 +513,12 @@ const (
// buildSystemPrompt creates the system prompt that makes NOFXi behave like a real agent.
func (a *Agent) buildSystemPrompt(lang string) string {
return a.buildSystemPromptForStoreUser(lang, "default")
}
func (a *Agent) buildSystemPromptForStoreUser(lang, storeUserID string) string {
// Gather live system state
traderInfo := a.getTradersSummary()
traderInfo := a.getTradersSummaryForStoreUser(storeUserID)
watchlist := ""
if a.sentinel != nil {
watchlist = a.sentinel.FormatWatchlist(lang)
@@ -700,7 +704,7 @@ Current time: %s`, traderInfo, watchlist, skillCatalog, time.Now().Format("2006-
}
// gatherContext collects real-time market data relevant to the user's message.
func (a *Agent) gatherContext(text string) string {
func (a *Agent) gatherContext(storeUserID, text string) string {
var parts []string
upper := strings.ToUpper(text)
@@ -765,8 +769,16 @@ func (a *Agent) gatherContext(text string) string {
}
// Trader positions
if a.traderManager != nil {
for _, t := range a.traderManager.GetAllTraders() {
if a.traderManager != nil && a.store != nil {
traderConfigs, _ := a.store.Trader().List(storeUserID)
for _, traderCfg := range traderConfigs {
if strings.TrimSpace(traderCfg.ID) == "" {
continue
}
t, err := a.traderManager.GetTrader(traderCfg.ID)
if err != nil {
continue
}
positions, err := t.GetPositions()
if err != nil {
continue
@@ -786,27 +798,51 @@ func (a *Agent) gatherContext(text string) string {
}
func (a *Agent) getTradersSummary() string {
return a.getTradersSummaryForStoreUser("default")
}
func (a *Agent) getTradersSummaryForStoreUser(storeUserID string) string {
if a.traderManager == nil {
return "Traders: none configured"
}
traders := a.traderManager.GetAllTraders()
if len(traders) == 0 {
if a.store == nil {
return "Traders: none configured"
}
if strings.TrimSpace(storeUserID) == "" {
storeUserID = "default"
}
traderConfigs, err := a.store.Trader().List(storeUserID)
if err != nil || len(traderConfigs) == 0 {
return "Traders: none configured"
}
var lines []string
for id, t := range traders {
for _, traderCfg := range traderConfigs {
if strings.TrimSpace(traderCfg.ID) == "" {
continue
}
t, err := a.traderManager.GetTrader(traderCfg.ID)
isRunning := traderCfg.IsRunning
exchange := traderCfg.ExchangeID
if err == nil && t != nil {
s := t.GetStatus()
running, _ := s["is_running"].(bool)
if running, ok := s["is_running"].(bool); ok {
isRunning = running
}
exchange = t.GetExchange()
}
status := "stopped"
if running {
if isRunning {
status = "running"
}
tid := id
tid := traderCfg.ID
if len(tid) > 8 {
tid = tid[:8]
}
lines = append(lines, fmt.Sprintf("• %s [%s] %s | %s", t.GetName(), tid, status, t.GetExchange()))
lines = append(lines, fmt.Sprintf("• %s [%s] %s | %s", traderCfg.Name, tid, status, exchange))
}
if len(lines) == 0 {
return "Traders: none configured"
}
return "Traders:\n" + strings.Join(lines, "\n")
}
@@ -834,7 +870,7 @@ func (a *Agent) handleStatus(L string) string {
}
// noAIFallback — when no AI is available, still try to be useful.
func (a *Agent) noAIFallback(lang, text string) (string, error) {
func (a *Agent) noAIFallback(storeUserID, lang, text string) (string, error) {
upper := strings.ToUpper(text)
// Try to provide market data directly
@@ -849,10 +885,10 @@ func (a *Agent) noAIFallback(lang, text string) (string, error) {
// Check if asking about positions/balance
if strings.Contains(text, "持仓") || strings.Contains(upper, "POSITION") {
return a.queryPositionsDirect(lang)
return a.queryPositionsDirect(storeUserID, lang)
}
if strings.Contains(text, "余额") || strings.Contains(upper, "BALANCE") {
return a.queryBalancesDirect(lang)
return a.queryBalancesDirect(storeUserID, lang)
}
if lang == "zh" {
@@ -884,11 +920,25 @@ func aiServiceFailureGuidance(lang, reason string) string {
looksLikeRateLimit := strings.Contains(lower, "status 429") ||
strings.Contains(lower, "rate limit") ||
strings.Contains(lower, "rate_limit_error")
looksLikeBannedAccount := strings.Contains(lower, "user_is_banned") ||
strings.Contains(lower, "account is banned") ||
strings.Contains(lower, "account banned")
looksLikeAuthFailure := strings.Contains(lower, "status 401") ||
strings.Contains(lower, "authentication_failed") ||
strings.Contains(lower, "authentication_error") ||
strings.Contains(lower, "unauthorized") ||
strings.Contains(lower, "invalid api key")
if lang == "zh" {
if looksLikeHTMLGateway {
return "这不是“未配置模型”。这次更像是上游返回了 HTML 页面或网关/反代错误页,而不是标准 JSON 响应。更可能原因是模型服务地址配错、网关拦截、支付/鉴权页返回、或上游服务临时异常。请优先检查当前启用模型的 custom_api_url、反向代理/网关状态,以及对应 provider 的服务状态。"
}
if looksLikeBannedAccount {
return "这不是“未配置模型”。当前启用模型已经连到了上游,但上游明确拒绝登录,原因是账号被禁用/封禁USER_IS_BANNED。请检查当前启用模型配置对应的账号或 API Key换一个可用账号/API Key或切换到另一个已启用模型后再试。"
}
if looksLikeAuthFailure {
return "这不是“未配置模型”。当前启用模型已经连到了上游,但鉴权失败了。请检查当前启用模型的 API Key、钱包凭证、provider 账号状态和 custom_api_url 是否匹配;修复凭证或切换到另一个可用模型后再试。"
}
if looksLikeUpstreamEmptyOutput {
return "这不是“未配置模型”。这次更像是上游模型没有返回有效内容,当前 provider 把它包装成了 429 / rate_limit_error。更可能原因是上游临时限流、服务拥塞、模型空响应或 provider 网关没有拿到有效结果;不应优先归因成“余额不足”。请先重试一次;如果持续出现,再检查当前启用模型的 provider 状态、限流配额、网关日志,或先切换到另一个可用模型。"
}
@@ -900,6 +950,12 @@ func aiServiceFailureGuidance(lang, reason string) string {
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."
}
if looksLikeBannedAccount {
return "This is not a missing-model issue. The active model reached the upstream provider, but login was rejected because the account is banned (USER_IS_BANNED). Check the active model account/API key, replace it with a usable credential, or switch to another enabled model."
}
if looksLikeAuthFailure {
return "This is not a missing-model issue. The active model reached the upstream provider, but authentication failed. Check the active model API key, wallet credential, provider account status, and custom_api_url, or switch to another enabled model."
}
if looksLikeUpstreamEmptyOutput {
return "This is not a missing-model issue. The upstream model appears to have returned no usable output, and the provider wrapped it as a 429 / rate_limit_error. The more likely causes are temporary throttling, upstream congestion, an empty model response, or a gateway that did not receive a valid result. Do not treat this as an insufficient-balance issue first. Retry once, then check the active provider status, rate limits, gateway logs, or switch to another model."
}
@@ -909,14 +965,28 @@ func aiServiceFailureGuidance(lang, reason string) string {
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) {
func (a *Agent) queryPositionsDirect(storeUserID, L string) (string, error) {
if a.traderManager == nil {
return a.msg(L, "no_traders"), nil
}
if a.store == nil {
return a.msg(L, "no_traders"), nil
}
traderConfigs, err := a.store.Trader().List(storeUserID)
if err != nil {
return a.msg(L, "no_traders"), nil
}
var sb strings.Builder
sb.WriteString("📊 *Positions*\n\n")
hasAny := false
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
}
positions, err := t.GetPositions()
if err != nil {
continue
@@ -932,7 +1002,11 @@ func (a *Agent) queryPositionsDirect(L string) (string, error) {
if pnl < 0 {
e = "🔴"
}
sb.WriteString(fmt.Sprintf("%s *%s* %s — $%.2f | Trader: %s\n", e, p["symbol"], p["side"], pnl, id[:8]))
tid := traderCfg.ID
if len(tid) > 8 {
tid = tid[:8]
}
sb.WriteString(fmt.Sprintf("%s *%s* %s — $%.2f | Trader: %s\n", e, p["symbol"], p["side"], pnl, tid))
}
}
if !hasAny {
@@ -941,18 +1015,32 @@ func (a *Agent) queryPositionsDirect(L string) (string, error) {
return sb.String(), nil
}
func (a *Agent) queryBalancesDirect(L string) (string, error) {
func (a *Agent) queryBalancesDirect(storeUserID, L string) (string, error) {
if a.traderManager == nil {
return a.msg(L, "no_traders"), nil
}
if a.store == nil {
return a.msg(L, "no_traders"), nil
}
traderConfigs, err := a.store.Trader().List(storeUserID)
if err != nil {
return a.msg(L, "no_traders"), nil
}
var sb strings.Builder
sb.WriteString("💰 *Balance*\n\n")
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]
}

View File

@@ -53,6 +53,32 @@ func TestAIServiceFailureHighlightsUpstreamEmptyOutputRateLimit(t *testing.T) {
}
}
func TestAIServiceFailureHighlightsBannedAccountAuthFailure(t *testing.T) {
a := New(nil, nil, DefaultConfig(), slog.Default())
msg, err := a.aiServiceFailure("zh", errors.New(`API returned error (status 401): {"error":{"code":"authentication_failed","message":"login failed: USER_IS_BANNED","param":null,"type":"authentication_error"}}`))
if err != nil {
t.Fatalf("aiServiceFailure returned error: %v", err)
}
for _, want := range []string{
"当前 AI 服务调用失败",
"账号被禁用/封禁",
"USER_IS_BANNED",
"换一个可用账号/API Key",
"切换到另一个已启用模型",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected message to contain %q, got: %s", want, msg)
}
}
for _, unexpected := range []string{"余额不足", "超时"} {
if strings.Contains(msg, unexpected) {
t.Fatalf("banned account auth failure should not mention %q: %s", unexpected, msg)
}
}
}
func TestCompletedPlanFallbackDoesNotExposeFinalSummaryFailure(t *testing.T) {
msg := formatCompletedPlanFallback("zh", []PlanStep{
{

View File

@@ -7,6 +7,7 @@ import (
"strings"
"nofx/mcp"
"nofx/store"
)
// brainDecision is the routing contract between the first-pass LLM and the executor.
@@ -110,7 +111,7 @@ func buildBrainUserPrompt(lang, text, previousAssistantReply, recentHistory, cur
sb.WriteString("\n\n")
sb.WriteString("=== MANAGEMENT DOMAIN PRIMER ===\n")
if hasActive {
sb.WriteString(defaultIfEmpty(buildSkillDomainPrimer(lang, activeSession.SkillName), "none"))
sb.WriteString(defaultIfEmpty(buildSkillDomainPrimerForSession(lang, activeToLegacySkillSession(activeSession)), "none"))
} else {
sb.WriteString(defaultIfEmpty(buildManagementDomainPrimer(lang), "none"))
}
@@ -247,6 +248,7 @@ func (a *Agent) executeBrainDecision(ctx context.Context, storeUserID string, us
session := newActiveSkillSession(userID, skill, action)
session.Goal = strings.TrimSpace(text)
d.ExtractedData = filterExtractedDataForActiveSession(session, d.ExtractedData, lang)
markStrategyCreateConfigProgressThisTurn(&session, d.ExtractedData)
mergeExtractedData(&session, d.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
@@ -255,6 +257,7 @@ func (a *Agent) executeBrainDecision(ctx context.Context, storeUserID string, us
return "", false, nil
}
d.ExtractedData = filterExtractedDataForActiveSession(activeSession, d.ExtractedData, lang)
markStrategyCreateConfigProgressThisTurn(&activeSession, d.ExtractedData)
mergeExtractedData(&activeSession, d.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent)
@@ -271,7 +274,12 @@ func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, user
if !ok {
stepDecision = activeSessionStepDecision{}
}
configProgressThisTurn := consumeStrategyCreateConfigProgressThisTurn(&session)
if strategyCreateDecisionHasConfigProgress(session, stepDecision.ExtractedData) {
configProgressThisTurn = true
}
mergeExtractedData(&session, stepDecision.ExtractedData)
maybeForceStrategyCreateExecutionOnConfirmation(lang, text, &session, &stepDecision)
if stepDecision.Route == "" {
if len(missingRequiredFields(session)) > 0 {
@@ -301,6 +309,14 @@ func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, user
a.recordSkillInteraction(userID, text, guarded)
return guarded, true, nil
}
if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked {
session = appendActiveSessionLocalHistory(session, "assistant", guarded)
setActiveSessionPendingHint(&session, guarded)
a.saveActiveSkillSession(session)
emitBrainReply(onEvent, guarded)
a.recordSkillInteraction(userID, text, guarded)
return guarded, true, nil
}
a.clearActiveSkillSession(userID)
if reply == "" {
return "", false, nil
@@ -310,10 +326,25 @@ func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, user
return reply, true, nil
case "ask_user":
reply := strings.TrimSpace(stepDecision.Reply)
reply := ""
if guarded, blocked := guardStrategyCreateBeforeFinalConfirmation(lang, session); blocked {
session.CollectedFields["awaiting_final_confirmation"] = true
reply = guarded
}
if reply == "" && configProgressThisTurn {
if deterministic, ok := strategyCreateTemplateMissingReply(lang, text, session); ok {
reply = deterministic
}
}
if reply == "" {
reply = strings.TrimSpace(stepDecision.Reply)
if reply == "" {
reply = a.askForMissingFields(lang, session)
}
}
if guarded, blocked := guardStrategyCreateAINonTemplateQuestion(lang, session, reply); blocked {
reply = guarded
}
if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked {
reply = guarded
}
@@ -333,7 +364,11 @@ func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, user
var canExecute bool
session, repairReply, canExecute = a.ensureStrategyCreateExecutableState(ctx, lang, text, session)
if !canExecute {
if strategyCreateLooseConfirmationReply(text) {
repairReply = a.askForMissingFields(lang, session)
} else {
repairReply = defaultIfEmpty(repairReply, a.askForMissingFields(lang, session))
}
session = appendActiveSessionLocalHistory(session, "assistant", repairReply)
setActiveSessionPendingHint(&session, repairReply)
a.saveActiveSkillSession(session)
@@ -341,6 +376,7 @@ func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, user
a.recordSkillInteraction(userID, text, repairReply)
return repairReply, true, nil
}
if !strategyCreateLooseConfirmationReply(text) {
if guarded, blocked := guardStrategyCreateBeforeFinalConfirmation(lang, session); blocked {
session.CollectedFields["awaiting_final_confirmation"] = true
session = appendActiveSessionLocalHistory(session, "assistant", guarded)
@@ -350,6 +386,7 @@ func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, user
a.recordSkillInteraction(userID, text, guarded)
return guarded, true, nil
}
}
outcome, nextSession, pending, ok := a.executeActiveSkillSession(storeUserID, userID, lang, text, session)
if !ok {
return "", false, nil
@@ -402,6 +439,16 @@ func (a *Agent) driveActiveSession(ctx context.Context, storeUserID string, user
}
}
func strategyCreateLooseConfirmationReply(text string) bool {
if strategyCreateConfirmationReply(text) {
return true
}
lower := strings.ToLower(strings.TrimSpace(text))
return strings.Contains(lower, "确认创建") ||
strings.Contains(lower, "按这个创建") ||
strings.Contains(lower, "confirm create")
}
func (a *Agent) ensureStrategyCreateExecutableState(ctx context.Context, lang, text string, session ActiveSkillSession) (ActiveSkillSession, string, bool) {
if session.SkillName != "strategy_management" || session.ActionName != "create" {
return session, "", true
@@ -425,13 +472,18 @@ Return JSON only.
Rules:
- Think from the current user message, previous assistant proposal, and active history.
- If concrete strategy settings can be determined, write them into extracted_data.config_patch as a StrategyConfig-shaped JSON patch.
- If the previous assistant already asked the user to confirm a concrete creation proposal in chat and the current user confirms it, set extracted_data.awaiting_final_confirmation=true too.
- For strategy creation, after the initial strategy type is known, do not ask field-by-field. Propose one complete draft using user-provided values plus safe defaults for anything still unspecified.
- If the user is asking you to design settings but has not confirmed creation yet, use route ask_user, provide a concise chat confirmation reply, and include the designed config in extracted_data.config_patch plus extracted_data.awaiting_final_confirmation=true.
- For each user message, decide how it relates to the currently selected strategy product template.
- If the message provides explicit values, corrections, preferences, constraints, or asks you to recommend/design, translate only the determinable template fields into extracted_data.config_patch as a StrategyConfig-shaped JSON patch.
- If the message is only a question, explanation request, greeting, or unrelated text, answer it without inventing config_patch.
- Do not silently fill missing fields when the user has not authorized it. But if the user explicitly says things like "你帮我定 / 你推荐 / 按稳健高频设计 / 其他你定", that is authorization for the Agent to design the remaining fields. In that case you must produce a recommended config_patch based on the current strategy template and field limits, and explain which values came from the user versus which values are Agent recommendations.
- The product editor template is the source of truth. Use only fields from the selected product template.
- If the user switches strategy type, set extracted_data.strategy_type to the new type and discard fields from the previous type. Keep only shared fields such as name/description/publish settings.
- In NOFXi product schema, AI500/OI Top/OI Low/static coin-source requests are ai_trading, not grid_trading.
- Strategy creation is chat-executable. Do not tell the user to click a web/app button, open a page, or manually create it elsewhere.
- Do not claim the strategy was created. This step only repairs state or asks for more information.
- If there is not enough information to determine a config, ask one natural follow-up question.
- Do not claim the strategy was created and do not promise future execution ("马上创建", "正在创建", "稍后通知"). This step only repairs state or asks for missing information.
- When the current user message is a confirmation, prefer route="ready" whenever the structured template can be repaired. If it cannot be repaired, route="ask_user" with only the missing fields; never reply that you are about to create it.
- If the template is still incomplete after applying determinable config_patch, ask one natural follow-up question or explain the missing fields.
Return shape:
{"route":"ready|ask_user","reply":"","extracted_data":{}}`)
@@ -507,6 +559,75 @@ func strategyCreateSessionReady(lang string, session ActiveSkillSession) bool {
return ready
}
func strategyCreateDecisionHasConfigProgress(session ActiveSkillSession, data map[string]any) bool {
if session.SkillName != "strategy_management" || session.ActionName != "create" || len(data) == 0 {
return false
}
patch, ok := data[strategyCreateConfigPatchField]
if !ok {
return false
}
sanitized := sanitizeStrategyCreateConfigPatchForType(patch, defaultIfEmpty(strategyTypeFromExtractedData(data), strategyTypeFromCollectedFields(session.CollectedFields)))
return len(sanitized) > 0
}
const strategyCreateConfigProgressThisTurnField = "__strategy_create_config_progress_this_turn"
func markStrategyCreateConfigProgressThisTurn(session *ActiveSkillSession, data map[string]any) {
if session == nil || !strategyCreateDecisionHasConfigProgress(*session, data) {
return
}
if session.CollectedFields == nil {
session.CollectedFields = map[string]any{}
}
session.CollectedFields[strategyCreateConfigProgressThisTurnField] = true
}
func consumeStrategyCreateConfigProgressThisTurn(session *ActiveSkillSession) bool {
if session == nil || session.CollectedFields == nil {
return false
}
progress := activeFieldBool(session.CollectedFields[strategyCreateConfigProgressThisTurnField])
delete(session.CollectedFields, strategyCreateConfigProgressThisTurnField)
return progress
}
func maybeForceStrategyCreateExecutionOnConfirmation(lang, text string, session *ActiveSkillSession, decision *activeSessionStepDecision) bool {
if session == nil || decision == nil {
return false
}
if session.SkillName != "strategy_management" || session.ActionName != "create" {
return false
}
if !strategyCreateLooseConfirmationReply(text) {
return false
}
if !strategyCreateSessionReady(lang, *session) {
return false
}
if session.CollectedFields == nil {
session.CollectedFields = map[string]any{}
}
session.CollectedFields["awaiting_final_confirmation"] = true
decision.Route = "execute_skill"
decision.Reply = ""
return true
}
func (a *Agent) activeStrategyCreateSession(userID int64) (ActiveSkillSession, bool) {
if session, ok := a.getActiveSkillSession(userID); ok && session.SkillName == "strategy_management" && session.ActionName == "create" {
return session, true
}
if legacy := a.getSkillSession(userID); legacy.Name == "strategy_management" && legacy.Action == "create" {
return activeSessionFromLegacy(ActiveSkillSession{
UserID: userID,
SkillName: "strategy_management",
ActionName: "create",
}, legacy), true
}
return ActiveSkillSession{}, false
}
func guardStrategyCreateBeforeFinalConfirmation(lang string, session ActiveSkillSession) (string, bool) {
if session.SkillName != "strategy_management" || session.ActionName != "create" {
return "", false
@@ -525,6 +646,25 @@ func guardStrategyCreateBeforeFinalConfirmation(lang string, session ActiveSkill
return formatStrategyCreateFinalConfirmation(lang, legacy, cfg), true
}
func strategyCreateTemplateMissingReply(lang, text string, session ActiveSkillSession) (string, bool) {
if session.SkillName != "strategy_management" || session.ActionName != "create" {
return "", false
}
legacy := activeToLegacySkillSession(session)
cfg, _, _, err := strategyCreateConfigFromSession(legacy, lang)
if err != nil {
return "", false
}
ready, missingKind := strategyCreateConfigReady(legacy, cfg, "")
if ready || strings.TrimSpace(missingKind) == "" {
return "", false
}
if reply := formatStrategyCreateFieldOptionsReply(lang, text, missingKind); reply != "" {
return reply, true
}
return formatStrategyCreateConfigNeeded(lang, missingKind), true
}
func strategyCreateHasPriorConfirmationPrompt(session ActiveSkillSession) bool {
for i := len(session.LocalHistory) - 1; i >= 0; i-- {
msg := session.LocalHistory[i]
@@ -539,6 +679,9 @@ func strategyCreateHasPriorConfirmationPrompt(session ActiveSkillSession) bool {
return strings.Contains(content, "确认创建") ||
strings.Contains(content, "确认后我再创建") ||
strings.Contains(content, "配置整理好了") ||
strings.Contains(content, "请确认是否按以上设置创建") ||
strings.Contains(content, "如果没问题,我就执行创建") ||
strings.Contains(content, "是否按以上设置创建") ||
strings.Contains(lower, "confirm") ||
strings.Contains(lower, "create it")
}
@@ -569,6 +712,39 @@ func guardUnexecutedActiveTaskCompletion(lang string, session ActiveSkillSession
return "It has not actually been executed yet. The previous step only prepared or confirmed the draft; I need to run the structured tool before claiming completion.", true
}
func guardStrategyCreateAINonTemplateQuestion(lang string, session ActiveSkillSession, reply string) (string, bool) {
if session.SkillName != "strategy_management" || session.ActionName != "create" {
return "", false
}
if strategyTypeFromCollectedFields(session.CollectedFields) != "ai_trading" {
return "", false
}
lower := strings.ToLower(strings.TrimSpace(reply))
if lower == "" {
return "", false
}
if !containsAny(lower, []string{
"投入多少", "投入资金", "总投入", "固定投入", "每笔交易", "每笔固定", "100u", "500u", "1000u",
"止损", "日亏损", "最大回撤",
"investment amount", "capital", "fixed amount", "per-trade", "stop loss", "daily loss", "max drawdown",
}) {
return "", false
}
legacy := activeToLegacySkillSession(session)
cfg, _, _, err := strategyCreateConfigFromSession(legacy, lang)
if err != nil {
return "", false
}
_, missingKind := strategyCreateConfigReady(legacy, cfg, "")
if strings.TrimSpace(missingKind) == "" {
return "", false
}
if lang == "zh" {
return "这些不是 AI 策略创建模板里的字段。我会继续按 AI 策略模板填写;当前还需要围绕选币来源、周期、杠杆、置信度、盈亏比、交易频率和开仓标准来确定配置。你也可以直接说“全部你定,按稳健/高频/激进”。", true
}
return "Those are not fields in the AI strategy creation template. I will continue using the AI strategy template: coin source, timeframes, leverage, confidence, risk/reward, trading frequency, and entry standards.", true
}
func guardUnsupportedAsyncPromise(lang, reply string) (string, bool) {
lower := strings.ToLower(strings.TrimSpace(reply))
if lower == "" {
@@ -587,7 +763,7 @@ func guardUnsupportedAsyncPromise(lang, reply string) (string, bool) {
return "", false
}
if lang == "zh" {
return "我需要纠正一下:我没有后台异步任务在运行,也不会稍后自动推送结果。诊断/创建/修改/启动这类任务必须在当前回复里实际执行并给出真实结果;如果还不能执行,我应该直接说明缺少哪个对象、时间范围或数据。", true
return "我需要纠正一下:我没有后台异步任务在运行,也不会自动推送后续结果。诊断/创建/修改/启动这类任务必须在当前回复里实际执行并给出真实结果;如果还不能执行,我应该直接说明缺少哪个对象、时间范围或数据。", true
}
return "I need to correct that: there is no background task running, and I will not automatically push a later result. Diagnosis/create/update/start tasks must actually execute and return a real result in the current response; if execution is not possible, I should state which target, range, or data is missing.", true
}
@@ -633,7 +809,8 @@ func (a *Agent) planActiveSessionStep(ctx context.Context, storeUserID string, u
}
previousAssistantReply := a.currentPendingHintText(userID)
domainPrimer := buildSkillDomainPrimer(lang, session.SkillName)
domainPrimer := buildSkillDomainPrimerForSession(lang, legacy)
specificRules := activeSessionSpecificRules(legacy)
systemPrompt := prependNOFXiAdvisorPreamble(fmt.Sprintf(`You are the active-task orchestration loop for NOFXi.
You decide the NEXT step for exactly one active task. Return JSON only.
@@ -664,15 +841,7 @@ Rules:
- Use contextual memory from the active task history and current references.
- Prefer "execute_skill" when the user has already given enough information to act.
- Prefer "ask_user" only when something truly necessary is still missing.
- For strategy_management:create, the only normal first fork is strategy type: AI strategy or grid strategy. After that, do not collect fields one by one; produce a complete recommended draft from user-provided values plus safe defaults, then ask the user to confirm or change any item.
- For strategy_management:create/update_config: every turn, reason about whether any config fields can now be determined from the user's message and conversation history. If yes, write them into extracted_data.config_patch.
- For strategy_management:create: when the user asks you to design/recommend settings, think as the strategy designer, produce a concrete recommended config in your reply, and also put the same structured config into extracted_data.config_patch. Do not ask the user to fill fields you can reasonably choose for them.
- For strategy_management:create: clearly distinguish user-provided fields from your recommended/defaulted fields. Never say a value is "already filled", "user provided", or "already configured" unless it appears in Current collected fields or the current user message. For values you choose, say "我建议/我先按安全默认值".
- For strategy_management:create grid_trading: never infer "current BTC/ETH/SOL price" or explicit upper/lower grid bounds from memory. If no fresh market tool observation is present, recommend ATR auto bounds instead and set grid_config.use_atr_bounds=true with upper_price/lower_price omitted or 0. Only recommend numeric upper_price/lower_price as "based on current price" when the price was actually fetched in this task.
- For strategy_management:create: once the structured config is sufficient to create, ask for one chat confirmation reply (for example, "回复“确认创建”") and set extracted_data.awaiting_final_confirmation=true. Do not execute create in that same turn.
- For strategy_management:create: choose execute_skill only when awaiting_final_confirmation is already true and the current user message confirms the chat summary. If the user changes a number, update config_patch and ask for chat confirmation again.
- For strategy_management:create: the confirmation happens in chat. Never tell the user to click a web/app button, find a page button, or manually create it elsewhere.
- For strategy_management:create: if the previous assistant reply said the strategy was not actually created yet and that the next step is to call the structured create tool, then a user request to continue/proceed means execute the current skill when the structured config is ready. Do not answer with another promise such as "I will create it now"; choose execute_skill.
%s
- For any mutating task, a reply that only promises future execution ("now I will create/update/start it", "result soon") is not a valid finish_task or ask_user outcome. If execution is the next step, choose execute_skill.
- For diagnosis, create, update, delete, start, stop, query/history, and performance-analysis tasks, never answer with only "马上处理 / 请稍等 / 诊断中 / I'll tell you later". NOFXi has no background chat job that will later push an answer. Choose execute_skill/planned_agent when enough information exists; otherwise ask for the missing target/range/data.
- Never choose finish_task for an unfinished mutating active task by claiming it was created/updated/deleted/started/stopped. Only a real skill/tool execution outcome can support that claim.
@@ -684,9 +853,6 @@ Rules:
- If the user refers to a specific object from disclosed targets, set target_ref_id and target_ref_name when you can resolve it.
- Current references are context for reasoning only. Do not copy a current reference into target_ref_id/target_ref_name unless the user explicitly refers to that object by name/id or clearly says "this/current/that previous one". If the target is not clear, ask instead of executing.
- For trader bindings, exchange/model/strategy must resolve to an ID from Relevant disclosed resources before execution. Never invent a resource name or use a generic venue type like Binance/OKX as the bound exchange unless it appears as an actual disclosed resource.
- For strategy_management:create, do not ask for exchange accounts or model bindings. Strategy templates are independent drafts/configs; exchange/model are only needed when creating, deploying, or starting a trader.
- Strategy templates should be visible in the strategy list/page after creation. Do not bring up trader/model/exchange binding unless the user asks to run or deploy.
- For strategy_management:create or strategy_management:update_config, when the user describes strategy intent, output config_patch as a partial StrategyConfig JSON object instead of leaving the default template unchanged. The product schema is type-isolated: grid strategies use only top-level strategy_type + grid_config + publish_config; AI strategies use only top-level strategy_type + ai_config + publish_config. For example, "BTC趋势做空" as an AI strategy should set ai_config.coin_source to static BTCUSDT and add ai_config prompt/risk/entry rules.
- If there are multiple targets and the user did not disambiguate, ask a natural question with the available names.
- If the current user message answers a missing field directly, extract it and continue.
- extracted_data must use only canonical keys from Allowed field spec JSON. Never output aliases, translated labels, or raw user wording as keys.
@@ -704,6 +870,7 @@ Return JSON with this exact shape:
defaultIfEmpty(string(resourcesJSON), "{}"),
defaultIfEmpty(string(fieldSpecsJSON), "[]"),
defaultIfEmpty(domainPrimer, "(none)"),
specificRules,
))
userPrompt := fmt.Sprintf("Language: %s\nCurrent user message: %s\n\nPrevious assistant reply:\n%s\n\nActive task local history:\n%s\n", lang, text, defaultIfEmpty(previousAssistantReply, "(empty)"), localHistory)
@@ -728,6 +895,29 @@ Return JSON with this exact shape:
return decision, true
}
func activeSessionSpecificRules(session skillSession) string {
if session.Name != "strategy_management" {
return ""
}
switch session.Action {
case "create", "update_config":
return strings.Join([]string{
"- For strategy_management:create/update_config, the selected product editor template is the only schema. Write values only through extracted_data.config_patch, using the current type branch only: ai_trading => strategy_type + ai_config + publish_config; grid_trading => strategy_type + grid_config + publish_config.",
"- For strategy_management:create/update_config, config_patch values must be product schema raw values, not user-facing labels. Examples: source_type=\"ai500\" not \"AI500\"; strategy_type=\"ai_trading\" not \"AI 策略\"; selected_timeframes=[\"1m\",\"5m\",\"15m\"] not a JSON string.",
"- For strategy_management:create, AI500/OI Top/OI Low/static coin-source requests imply strategy_type=\"ai_trading\". Do not leave strategy type ambiguous in that case.",
"- For strategy_management:create/update_config, judge the user's natural-language intent. Explicit values, corrections, constraints, preferences, or requests to recommend/design must become config_patch for every determinable current-template field; pure questions/greetings/acknowledgements must not invent config_patch.",
"- For strategy_management:create, the Relevant disclosed resources include product_default_template and current_missing_template_fields. Treat product_default_template as the product editor's default template and field shape.",
"- For strategy_management:create, do not ask for or present fields listed in product_default_template.non_fields. They are not part of the selected product editor template.",
"- For strategy_management:create, when the user states a strategy style/preference or authorizes the Agent to recommend/design remaining settings, use product_default_template as the base, adjust it to the user's stated preference, and output config_patch that fills every determinable missing template field. Do not ask the user to restate fields that can be responsibly selected from the default template.",
"- For grid_trading create, if the user authorizes the Agent to choose/recommend remaining settings, set grid_config.use_atr_bounds=true for the price range unless the user explicitly gives manual upper_price/lower_price. Never invent current market prices or say a symbol is currently near a price without a fresh market-data tool result.",
"- For strategy_management:create, any user-facing strategy plan must be generated from the post-merge structured config built from config_patch and the current strategy type. Do not display fields that would be filtered out or belong to the other strategy type.",
"- For strategy_management:create, once complete, ask for one chat confirmation with awaiting_final_confirmation=true; after confirmation execute synchronously with empty reply and only report success after the tool returns.",
}, "\n")
default:
return ""
}
}
func (a *Agent) executeActiveSkillSession(storeUserID string, userID int64, lang, text string, session ActiveSkillSession) (skillOutcome, ActiveSkillSession, bool, bool) {
legacy := activeToLegacySkillSession(session)
a.saveSkillSession(userID, legacy)
@@ -840,13 +1030,16 @@ func activeToLegacySkillSession(s ActiveSkillSession) skillSession {
legacy.Fields[k] = str
}
}
if s.SkillName == "strategy_management" && s.ActionName == "create" {
draft := buildStrategyDraftFromActiveSession(s)
if legacy.Fields["name"] == "" && strings.TrimSpace(draft.Name) != "" {
legacy.Fields["name"] = strings.TrimSpace(draft.Name)
if s.SkillName == "strategy_management" && s.ActionName == "create" && legacy.Fields["name"] == "" {
for i := len(s.LocalHistory) - 1; i > 0; i-- {
msg := s.LocalHistory[i]
if msg.Role != "user" || !activeHistoryMessageAsksStrategyName(s.LocalHistory[i-1].Content) {
continue
}
if inferred := inferStandaloneStrategyName(msg.Content); inferred != "" {
legacy.Fields["name"] = inferred
break
}
if draftRaw := marshalStrategyDraft(draft); draftRaw != "{}" {
legacy.Fields[strategyCreateDraftIntentField] = draftRaw
}
}
return legacy
@@ -913,11 +1106,20 @@ func (a *Agent) buildActiveSessionResources(storeUserID string, session skillSes
case "strategy_management":
resources := a.buildSimpleEntityConversationResources(storeUserID, session, a.loadStrategyOptions(storeUserID))
if strategyType := explicitStrategyCreateType(session); strategyType != "" {
lang := defaultIfEmpty(a.config.Language, "zh")
resources["current_strategy_type"] = strategyType
resources["current_editable_fields"] = manualStrategyEditableFieldKeysForType(strategyType)
if session.Action == "create" || session.Action == "update_config" {
resources["product_default_template"] = strategyProductDefaultTemplateResource(lang, strategyType)
if cfg, _, _, err := strategyCreateConfigFromSession(session, lang); err == nil {
resources["current_missing_template_fields"] = strategyCreateMissingTemplateFields(session, cfg)
}
}
} else if strategyType, ok := a.strategyTypeForTarget(storeUserID, session.TargetRef); ok {
lang := defaultIfEmpty(a.config.Language, "zh")
resources["target_strategy_type"] = strategyType
resources["target_editable_fields"] = manualStrategyEditableFieldKeysForType(strategyType)
resources["product_default_template"] = strategyProductDefaultTemplateResource(lang, strategyType)
}
return resources
default:
@@ -925,6 +1127,89 @@ func (a *Agent) buildActiveSessionResources(storeUserID string, session skillSes
}
}
func strategyProductDefaultTemplateResource(lang, strategyType string) map[string]any {
cfg := store.GetDefaultStrategyConfig(defaultIfEmpty(lang, "zh"))
cfg.StrategyType = strings.TrimSpace(strategyType)
cfg.ClampLimits()
publish := map[string]any{
"is_public": false,
"config_visible": true,
}
switch cfg.StrategyType {
case "grid_trading":
grid := cfg.GridConfig
if grid == nil {
defaultGrid := store.DefaultGridStrategyConfig()
grid = &defaultGrid
}
return map[string]any{
"strategy_type": "grid_trading",
"grid_config": grid,
"publish_config": publish,
"required_fields": strategyCreateMissingGridFields(skillSession{}),
}
default:
return map[string]any{
"strategy_type": "ai_trading",
"ai_config": map[string]any{
"coin_source": map[string]any{
"source_type": cfg.CoinSource.SourceType,
"static_coins": cfg.CoinSource.StaticCoins,
"excluded_coins": cfg.CoinSource.ExcludedCoins,
"ai500_limit": cfg.CoinSource.AI500Limit,
"oi_top_limit": cfg.CoinSource.OITopLimit,
"oi_low_limit": cfg.CoinSource.OILowLimit,
},
"indicators": map[string]any{
"klines": map[string]any{
"primary_timeframe": cfg.Indicators.Klines.PrimaryTimeframe,
"primary_count": cfg.Indicators.Klines.PrimaryCount,
"selected_timeframes": cfg.Indicators.Klines.SelectedTimeframes,
},
"enable_ema": cfg.Indicators.EnableEMA,
"enable_macd": cfg.Indicators.EnableMACD,
"enable_rsi": cfg.Indicators.EnableRSI,
"enable_atr": cfg.Indicators.EnableATR,
"enable_boll": cfg.Indicators.EnableBOLL,
"enable_volume": cfg.Indicators.EnableVolume,
"enable_oi": cfg.Indicators.EnableOI,
"enable_funding_rate": cfg.Indicators.EnableFundingRate,
},
"risk_control": map[string]any{
"btc_eth_max_leverage": cfg.RiskControl.BTCETHMaxLeverage,
"altcoin_max_leverage": cfg.RiskControl.AltcoinMaxLeverage,
"min_confidence": cfg.RiskControl.MinConfidence,
"min_risk_reward_ratio": cfg.RiskControl.MinRiskRewardRatio,
},
"prompt_sections": map[string]any{
"trading_frequency": cfg.PromptSections.TradingFrequency,
"entry_standards": cfg.PromptSections.EntryStandards,
},
"custom_prompt": cfg.CustomPrompt,
},
"publish_config": publish,
"non_fields": []string{
"investment_amount",
"fixed_position_size",
"stop_loss_pct",
"daily_loss_limit_pct",
"max_drawdown_pct",
},
"required_fields": []string{
"source_type",
"primary_timeframe",
"selected_timeframes",
"btceth_max_leverage",
"altcoin_max_leverage",
"min_confidence",
"min_risk_reward_ratio",
"trading_frequency",
"entry_standards",
},
}
}
}
func missingRequiredFieldsForBrain(session ActiveSkillSession) []string {
missing := missingRequiredFields(session)
if len(missing) == 0 {
@@ -992,6 +1277,14 @@ func mergeExtractedData(s *ActiveSkillSession, data map[string]any) {
if s.CollectedFields == nil {
s.CollectedFields = map[string]any{}
}
if s.SkillName == "strategy_management" && s.ActionName == "create" {
if incomingType := strategyTypeFromExtractedData(data); incomingType != "" {
currentType := strategyTypeFromCollectedFields(s.CollectedFields)
if currentType != "" && currentType != incomingType {
resetActiveStrategyCreateFieldsForType(s, incomingType)
}
}
}
for k, v := range data {
k = strings.TrimSpace(k)
if k == "" {
@@ -1027,12 +1320,166 @@ func filterExtractedDataForActiveSession(session ActiveSkillSession, data map[st
}
out[key] = value
}
if session.SkillName == "strategy_management" && session.ActionName == "create" {
out = filterStrategyCreateExtractedDataByTemplate(session, out)
}
if len(out) == 0 {
return nil
}
return out
}
func strategyTypeFromExtractedData(data map[string]any) string {
if len(data) == 0 {
return ""
}
if value, ok := data["strategy_type"]; ok {
if strategyType := parseStrategyTypeValue(fmt.Sprint(value)); strategyType != "" {
return strategyType
}
}
if patch, ok := data[strategyCreateConfigPatchField]; ok {
if strategyType := strategyTypeFromConfigPatchAny(patch); strategyType != "" {
return strategyType
}
}
return ""
}
func strategyTypeFromCollectedFields(fields map[string]any) string {
if len(fields) == 0 {
return ""
}
if value, ok := fields["strategy_type"]; ok {
if strategyType := parseStrategyTypeValue(fmt.Sprint(value)); strategyType != "" {
return strategyType
}
}
if patch, ok := fields[strategyCreateConfigPatchField]; ok {
if strategyType := strategyTypeFromConfigPatchAny(patch); strategyType != "" {
return strategyType
}
}
return ""
}
func strategyTypeFromConfigPatchAny(value any) string {
patch := mapFromAny(value)
if len(patch) == 0 {
return ""
}
if strategyType := parseStrategyTypeValue(fmt.Sprint(patch["strategy_type"])); strategyType != "" {
return strategyType
}
if _, ok := patch["grid_config"]; ok {
return "grid_trading"
}
if _, ok := patch["ai_config"]; ok {
return "ai_trading"
}
return ""
}
func resetActiveStrategyCreateFieldsForType(s *ActiveSkillSession, strategyType string) {
if s.CollectedFields == nil {
s.CollectedFields = map[string]any{}
}
keep := map[string]any{}
for _, key := range []string{"name", "description", "is_public", "config_visible", "lang"} {
if value, ok := s.CollectedFields[key]; ok {
keep[key] = value
}
}
keep["strategy_type"] = strategyType
s.CollectedFields = keep
}
func filterStrategyCreateExtractedDataByTemplate(session ActiveSkillSession, data map[string]any) map[string]any {
if len(data) == 0 {
return data
}
strategyType := strategyTypeFromExtractedData(data)
if strategyType == "" {
strategyType = strategyTypeFromCollectedFields(session.CollectedFields)
}
if strategyType == "" {
return data
}
allowed := map[string]struct{}{}
for _, key := range manualStrategyEditableFieldKeysForType(strategyType) {
allowed[key] = struct{}{}
}
out := make(map[string]any, len(data))
for key, value := range data {
if key == strategyCreateConfigPatchField {
if patch := sanitizeStrategyCreateConfigPatchForType(value, strategyType); len(patch) > 0 {
out[key] = patch
}
continue
}
if key == "awaiting_final_confirmation" {
out[key] = value
continue
}
if _, ok := allowed[key]; ok {
out[key] = value
}
}
if len(out) == 0 {
return nil
}
return out
}
func sanitizeStrategyCreateConfigPatchForType(value any, strategyType string) map[string]any {
patch := mapFromAny(value)
if len(patch) == 0 {
return nil
}
out := map[string]any{
"strategy_type": strategyType,
}
if publish := mapFromAny(patch["publish_config"]); len(publish) > 0 {
out["publish_config"] = publish
}
switch strategyType {
case "grid_trading":
if grid := mapFromAny(patch["grid_config"]); len(grid) > 0 {
out["grid_config"] = grid
}
case "ai_trading":
ai := mapFromAny(patch["ai_config"])
if ai == nil {
ai = map[string]any{}
}
for _, key := range []string{"coin_source", "indicators", "risk_control", "prompt_sections", "custom_prompt"} {
if value, ok := patch[key]; ok {
ai[key] = value
}
}
if len(ai) > 0 {
out["ai_config"] = ai
}
}
if len(out) == 1 {
return nil
}
return out
}
func mapFromAny(value any) map[string]any {
switch typed := value.(type) {
case map[string]any:
return typed
case string:
var out map[string]any
if err := json.Unmarshal([]byte(typed), &out); err == nil {
return out
}
}
return nil
}
func emitBrainReply(onEvent func(event, data string), reply string) {
if onEvent == nil || reply == "" {
return

View File

@@ -252,7 +252,10 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl
add(&out, "show_in_competition", displayCatalogFieldName("show_in_competition", lang), false)
case "strategy_management":
if session.Action == "create" || session.Action == "update_config" {
configPatchDescription := "Partial StrategyConfig JSON patch inferred from the user's strategy intent."
if session.Action == "create" {
add(&out, "strategy_type", "Strategy type. Use ai_trading for AI strategies, including AI500/OI/static coin-source requests; use grid_trading only for grid strategy requests.", false)
}
configPatchDescription := "Partial StrategyConfig JSON patch inferred from the user's strategy intent. Use exact product schema values, not display labels: source_type must be one of static, ai500, oi_top, oi_low; strategy_type must be ai_trading or grid_trading; selected_timeframes must be a JSON array of strings, not a JSON-encoded string."
switch explicitStrategyCreateType(session) {
case "grid_trading":
configPatchDescription += " Current strategy_type is grid_trading: use only top-level strategy_type, grid_config, publish_config, and language. Do not output ai_config or AI fields such as coin_source, indicators, risk_control, timeframes, confidence, or prompt_sections."
@@ -271,10 +274,12 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl
add(&out, "custom_prompt", strategyConfigFieldDisplayName("custom_prompt", lang), false)
}
if session.Action == "update_config" {
add(&out, "config_field", strategyConfigFieldDisplayName("config_field", lang), false)
add(&out, "config_value", strategyConfigFieldDisplayName("config_value", lang), false)
return out
}
add(&out, "name", slotDisplayName("name", lang), true)
if session.Action == "create" {
return out
}
keys := manualStrategyEditableFieldKeys()
if strategyType := explicitStrategyCreateType(session); strategyType != "" {
keys = manualStrategyEditableFieldKeysForType(strategyType)
@@ -424,13 +429,8 @@ func missingFieldKeysForSkillSession(session skillSession) []string {
missing = append(missing, "prompt")
}
case "update_config":
if fieldValue(session, "config_patch") != "" {
break
}
if fieldValue(session, "config_field") == "" {
missing = append(missing, "config_field")
} else if fieldValue(session, "config_value") == "" {
missing = append(missing, "config_value")
if fieldValue(session, "config_patch") == "" {
missing = append(missing, "config_patch")
}
case "create":
if fieldValue(session, "name") == "" {
@@ -551,13 +551,22 @@ func (a *Agent) applyLLMExtractionToSkillSession(storeUserID string, session *sk
setField(session, "name", value)
continue
}
if key == "config_field" || key == "config_value" {
setField(session, key, value)
continue
if session.Action == "create" || session.Action == "update_config" {
switch key {
case "strategy_type":
if strategyType := parseStrategyTypeValue(value); strategyType != "" {
setStrategyCreateType(session, strategyType)
}
case strategyCreateConfigPatchField:
strategyType := explicitStrategyCreateType(*session)
if strategyType == "" {
strategyType = strategyTypeFromConfigPatchAny(value)
}
if sanitized := sanitizeStrategyCreateConfigPatchForType(value, strategyType); len(sanitized) > 0 {
raw, _ := json.Marshal(sanitized)
setField(session, strategyCreateConfigPatchField, string(raw))
}
}
if session.Action == "update_config" {
setField(session, "config_field", key)
setField(session, "config_value", value)
continue
}
cfg := unmarshalStrategyCreateDraft(fieldValue(*session, strategyCreateDraftConfigField), lang)

View File

@@ -257,6 +257,9 @@ Return JSON with this exact shape:
}
func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID string, userID int64, lang, text string, decision unifiedTurnDecision, onEvent func(event, data string)) (string, bool, error) {
if session, ok := a.activeStrategyCreateSession(userID); ok && strategyCreateConfirmationReply(text) {
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
switch decision.TopicIntent {
case "cancel":
a.clearPendingProposalSession(userID)
@@ -344,6 +347,9 @@ func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID stri
mergeExtractedData(&activeSession, decision.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent)
case "planned_agent":
if session, ok := a.activeStrategyCreateSession(userID); ok {
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
contextMode := decision.ContextMode
if contextMode == "resume_snapshot" {
contextMode = "use_current"
@@ -362,6 +368,23 @@ func (a *Agent) executeUnifiedSkillTasks(ctx context.Context, storeUserID string
if len(tasks) == 0 {
return "", false, nil
}
if task, ok := strategyCreateWorkflowTask(tasks); ok {
if a.hasAnyActiveContext(userID) && decision.ContextMode == "fresh_context" {
if !a.suspendActiveContexts(userID, lang) {
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
a.clearExecutionState(userID)
}
a.clearActiveSkillSession(userID)
}
a.clearWorkflowSession(userID)
a.clearExecutionState(userID)
session := newActiveSkillSession(userID, task.Skill, task.Action)
session.Goal = defaultIfEmpty(strings.TrimSpace(task.Request), strings.TrimSpace(text))
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
mergeExtractedData(&session, decision.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, defaultIfEmpty(task.Request, text), session, onEvent)
}
if a.hasAnyActiveContext(userID) && decision.ContextMode == "fresh_context" {
if !a.suspendActiveContexts(userID, lang) {
a.clearSkillSession(userID)
@@ -390,6 +413,15 @@ func (a *Agent) executeUnifiedSkillTasks(ctx context.Context, storeUserID string
return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent)
}
func strategyCreateWorkflowTask(tasks []WorkflowTask) (WorkflowTask, bool) {
for _, task := range tasks {
if strings.TrimSpace(task.Skill) == "strategy_management" && strings.TrimSpace(task.Action) == "create" {
return task, true
}
}
return WorkflowTask{}, false
}
func buildTopLevelActiveFlowSummary(lang string, skill skillSession, activeTask ActiveSkillSession, hasActiveTask bool, workflow WorkflowSession, state ExecutionState, pendingProposal PendingProposalSession, hasPendingProposal bool) string {
lines := make([]string, 0, 8)
if hasActiveTask {

View File

@@ -570,9 +570,9 @@ func isEphemeralReadFastPathKind(kind string) bool {
func (a *Agent) executeReadFastPath(storeUserID string, _ int64, req *readFastPathRequest) string {
switch req.Kind {
case "get_balance":
return a.toolGetBalance()
return a.toolGetBalance(storeUserID)
case "get_positions":
return a.toolGetPositions()
return a.toolGetPositions(storeUserID)
case "get_trade_history":
return a.toolGetTradeHistory(req.ArgsJSON)
case "get_strategies":
@@ -842,7 +842,7 @@ func (a *Agent) thinkAndAct(ctx context.Context, storeUserID string, userID int6
if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, nil); ok {
return a.maybeAppendResumePrompt(userID, lang, text, answer), nil
}
return a.noAIFallback(lang, text)
return a.noAIFallback(storeUserID, lang, text)
}
answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, nil)
return a.maybeAppendResumePrompt(userID, lang, text, answer), err
@@ -877,7 +877,7 @@ func (a *Agent) thinkAndActStream(ctx context.Context, storeUserID string, userI
if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent); ok {
return a.maybeAppendResumePrompt(userID, lang, text, answer), nil
}
return a.noAIFallback(lang, text)
return a.noAIFallback(storeUserID, lang, text)
}
answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent)
return a.maybeAppendResumePrompt(userID, lang, text, answer), err
@@ -933,12 +933,26 @@ func (a *Agent) tryStatePriorityPath(ctx context.Context, storeUserID string, us
}
}
if workflow := a.getWorkflowSession(userID); hasActiveWorkflowSession(workflow) {
if task, _, ok := nextRunnableWorkflowTask(workflow); ok && strings.TrimSpace(task.Skill) == "strategy_management" && strings.TrimSpace(task.Action) == "create" {
a.clearWorkflowSession(userID)
session := newActiveSkillSession(userID, "strategy_management", "create")
session.Goal = defaultIfEmpty(strings.TrimSpace(task.Request), strings.TrimSpace(text))
answer, handled, err := a.driveActiveSession(ctx, storeUserID, userID, lang, defaultIfEmpty(task.Request, text), session, onEvent)
return answer, handled, err
}
answer, handled, err := a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, workflow, onEvent)
if handled || err != nil {
return answer, true, err
}
}
if session := a.getSkillSession(userID); strings.TrimSpace(session.Name) != "" {
if answer, ok := a.redirectModelCreateSessionToStrategyCreateIfNeeded(storeUserID, userID, lang, text, session); ok {
if onEvent != nil && strings.TrimSpace(answer) != "" {
onEvent(StreamEventTool, "hard_skill:strategy_management")
emitStreamText(onEvent, answer)
}
return answer, true, nil
}
decision, _ := a.resolveSkillSessionTurn(ctx, userID, lang, text, session)
switch decision.Intent {
case "cancel":
@@ -984,6 +998,9 @@ func (a *Agent) tryStatePriorityPath(ctx context.Context, storeUserID string, us
return answer, handled, err
default:
if decision.Intent == "continue_active" {
if answer, handled, err := a.redirectExecutionStateStrategyCreate(ctx, storeUserID, userID, lang, text, state, onEvent); handled || err != nil {
return answer, handled, err
}
if session, ok := a.bridgeExecutionStateToSkillSession(storeUserID, userID, text, state, extraction); ok {
answer, handled := a.dispatchBridgedSkillSession(storeUserID, userID, lang, text, session)
return answer, handled, nil
@@ -1129,6 +1146,9 @@ func (a *Agent) bridgeExecutionStateToSkillSession(storeUserID string, userID in
if a == nil || skillName == "" || action == "" || !hasSkillBridgeSignal(a, storeUserID, skillName, action, text, extraction) {
return skillSession{}, false
}
if skillName == "strategy_management" && action == "create" {
return skillSession{}, false
}
session := a.getSkillSession(userID)
if session.Name != "" && (session.Name != skillName || session.Action != action) {
@@ -1166,6 +1186,38 @@ func (a *Agent) bridgeExecutionStateToSkillSession(storeUserID string, userID in
return session, true
}
func (a *Agent) redirectExecutionStateStrategyCreate(ctx context.Context, storeUserID string, userID int64, lang, text string, state ExecutionState, onEvent func(event, data string)) (string, bool, error) {
skillName, action := inferExecutionStateSkillBridge(state, text)
if skillName != "strategy_management" || action != "create" {
return "", false, nil
}
a.clearExecutionState(userID)
session := newActiveSkillSession(userID, "strategy_management", "create")
session.Goal = defaultIfEmpty(strings.TrimSpace(state.Goal), strings.TrimSpace(text))
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
func (a *Agent) redirectModelCreateSessionToStrategyCreateIfNeeded(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
if strings.TrimSpace(session.Name) != "model_management" || strings.TrimSpace(session.Action) != "create" {
return "", false
}
strategyType := parseStrategyTypeValue(text)
if strategyType == "" && !hasExplicitCreateIntentForDomain(text, "strategy") {
return "", false
}
strategySession := skillSession{
Name: "strategy_management",
Action: "create",
Phase: "collecting",
Fields: map[string]string{},
}
if strategyType != "" {
setStrategyCreateType(&strategySession, strategyType)
}
a.clearSkillSession(userID)
return a.handleStrategyCreateSkill(storeUserID, userID, lang, text, strategySession), true
}
func (a *Agent) dispatchBridgedSkillSession(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
switch session.Name {
case "trader_management":
@@ -1276,7 +1328,7 @@ func (a *Agent) handoffFromActiveFlow(ctx context.Context, storeUserID string, u
if answer, ok := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent); ok {
return a.maybeAppendResumePrompt(userID, lang, text, answer), true, nil
}
answer, err := a.noAIFallback(lang, text)
answer, err := a.noAIFallback(storeUserID, lang, text)
return a.maybeAppendResumePrompt(userID, lang, text, answer), true, err
}
answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, text, onEvent)
@@ -1685,13 +1737,13 @@ func isSkillFlowDeflection(session skillSession, text string) bool {
}
switch strings.TrimSpace(session.Name) {
case "exchange_management":
return hasExplicitDiagnosisIntentForDomain(text, "model") || hasExplicitDiagnosisIntentForDomain(text, "trader") || hasExplicitDiagnosisIntentForDomain(text, "strategy")
return false
case "model_management":
return hasExplicitDiagnosisIntentForDomain(text, "exchange") || hasExplicitDiagnosisIntentForDomain(text, "trader") || hasExplicitDiagnosisIntentForDomain(text, "strategy")
return false
case "strategy_management":
return hasExplicitDiagnosisIntentForDomain(text, "exchange") || hasExplicitDiagnosisIntentForDomain(text, "trader") || hasExplicitDiagnosisIntentForDomain(text, "model")
return false
case "trader_management":
return hasExplicitDiagnosisIntentForDomain(text, "exchange") || hasExplicitDiagnosisIntentForDomain(text, "model") || hasExplicitDiagnosisIntentForDomain(text, "strategy")
return false
default:
return false
}
@@ -2225,13 +2277,13 @@ func isExplicitFlowAbort(text string) bool {
func belongsToSkillDomain(skillName, text string) bool {
switch strings.TrimSpace(skillName) {
case "trader_management":
return hasExplicitCreateIntentForDomain(text, "trader") || hasExplicitDiagnosisIntentForDomain(text, "trader")
return hasExplicitCreateIntentForDomain(text, "trader")
case "strategy_management":
return hasExplicitDiagnosisIntentForDomain(text, "strategy")
return false
case "model_management":
return hasExplicitDiagnosisIntentForDomain(text, "model")
return false
case "exchange_management":
return hasExplicitDiagnosisIntentForDomain(text, "exchange")
return false
default:
return false
}
@@ -2245,11 +2297,7 @@ func looksLikeNewTopLevelIntent(text string) bool {
if strings.HasPrefix(lower, "/") {
return true
}
if hasExplicitCreateIntentForDomain(text, "trader") ||
hasExplicitDiagnosisIntentForDomain(text, "trader") ||
hasExplicitDiagnosisIntentForDomain(text, "exchange") ||
hasExplicitDiagnosisIntentForDomain(text, "model") ||
hasExplicitDiagnosisIntentForDomain(text, "strategy") {
if hasExplicitCreateIntentForDomain(text, "trader") {
return true
}
if detectReadFastPath(text) != nil {
@@ -2482,6 +2530,9 @@ func (a *Agent) tryRecoverFromInternalAgentJSON(ctx context.Context, storeUserID
Fields: firstFlowExtractionFields(result),
Reason: result.Reason,
}
if answer, handled, err := a.redirectExecutionStateStrategyCreate(ctx, storeUserID, userID, lang, text, state, onEvent); handled || err != nil {
return answer, handled, err
}
if session, ok := a.bridgeExecutionStateToSkillSession(storeUserID, userID, text, state, extraction); ok {
answer, handled := a.dispatchBridgedSkillSession(storeUserID, userID, lang, text, session)
return answer, handled, nil
@@ -2496,6 +2547,10 @@ func (a *Agent) runPlannedAgent(ctx context.Context, storeUserID string, userID
}
func (a *Agent) runPlannedAgentWithContextMode(ctx context.Context, storeUserID string, userID int64, lang, text string, contextMode string, onEvent func(event, data string)) (string, error) {
if session, ok := a.activeStrategyCreateSession(userID); ok {
answer, _, err := a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
return answer, err
}
a.history.Add(userID, "user", text)
if onEvent != nil {
onEvent(StreamEventPlanning, a.planningStatusText(lang))
@@ -2513,6 +2568,13 @@ func (a *Agent) runPlannedAgentWithContextMode(ctx context.Context, storeUserID
}
return msg, nil
}
if hasExplicitCreateIntentForDomain(text, "strategy") {
a.logger.Warn("planner failed during strategy create; using template strategy flow instead of legacy loop", "error", err, "user_id", userID)
session := newActiveSkillSession(userID, "strategy_management", "create")
session.Goal = strings.TrimSpace(text)
answer, _, flowErr := a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
return answer, flowErr
}
a.logger.Warn("planner failed, falling back to legacy loop", "error", err, "user_id", userID)
return a.thinkAndActLegacyWithStore(ctx, storeUserID, userID, lang, text, onEvent)
}
@@ -2533,10 +2595,21 @@ func (a *Agent) runPlannedAgentWithContextMode(ctx context.Context, storeUserID
if answer, ok := a.tryExecutionSummaryFallbackOnAIError(lang, &state, err, onEvent); ok {
return answer, nil
}
if hasExplicitCreateIntentForDomain(state.Goal, "strategy") || hasExplicitCreateIntentForDomain(text, "strategy") {
a.logger.Warn("plan execution failed during strategy create; using template strategy flow instead of legacy loop", "error", err, "user_id", userID)
a.clearExecutionState(userID)
session := newActiveSkillSession(userID, "strategy_management", "create")
session.Goal = defaultIfEmpty(strings.TrimSpace(state.Goal), strings.TrimSpace(text))
answer, _, flowErr := a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
return answer, flowErr
}
a.logger.Warn("plan execution failed, falling back to legacy loop", "error", err, "user_id", userID)
return a.thinkAndActLegacyWithStore(ctx, storeUserID, userID, lang, text, onEvent)
}
if guarded, blocked := guardUnsupportedAsyncPromise(lang, answer); blocked {
answer = guarded
}
a.history.Add(userID, "assistant", answer)
a.runPostResponseMaintenanceAsync(userID)
a.logPlannerTiming(state.SessionID, userID, "run_planned_agent_total", requestStartedAt, nil)
@@ -2763,9 +2836,9 @@ func (a *Agent) refreshStateForDynamicRequests(storeUserID, userText string, sta
case "current_strategies":
appendSnapshot(kind, a.toolGetStrategies(storeUserID))
case "current_balances":
appendSnapshot(kind, a.toolGetBalance())
appendSnapshot(kind, a.toolGetBalance(storeUserID))
case "current_positions":
appendSnapshot(kind, a.toolGetPositions())
appendSnapshot(kind, a.toolGetPositions(storeUserID))
case "recent_trade_history":
appendSnapshot(kind, a.toolGetTradeHistory(`{"limit":10}`))
}
@@ -2838,11 +2911,11 @@ Rules:
- Use tool steps whenever fresh external data is required.
- Use ask_user if required parameters are missing.
- For config or create flows, prefer multi-slot ask_user prompts: ask for the main missing fields together instead of one field per turn whenever practical.
- When safe defaults are common and the user has not expressed a preference, offer those defaults in the same ask_user turn instead of forcing a separate follow-up for every slot.
- Never place a trade unless the user intent is explicit.
- For exchange binding or exchange credential requests, prefer get_exchange_configs/manage_exchange_config.
- For AI model binding or model credential requests, prefer get_model_configs/manage_model_config.
- For strategy template creation or editing requests, prefer get_strategies/manage_strategy.
- For strategy template editing/query requests, prefer get_strategies/manage_strategy.
- For strategy template creation, do not call manage_strategy action=create from the planner. Strategy creation must be handled by the active strategy template flow so the selected product editor template can collect fields and require chat confirmation.
- For trader creation or trader lifecycle requests, prefer manage_trader.
- A strategy template is independent and does not require exchange/model bindings unless the user explicitly asks to run or deploy it through a trader.
- Do NOT expand the goal beyond what the user explicitly requested. When the user's request is fulfilled, respond and stop. Do not proactively suggest or ask about the next logical step (e.g. do not ask "should I bind this to a trader?" after a strategy update unless the user asked for that).
@@ -3002,6 +3075,13 @@ func (a *Agent) executePlan(ctx context.Context, storeUserID string, userID int6
switch step.Type {
case planStepTypeTool:
if answer, handled := a.redirectPlannerStrategyCreateStep(storeUserID, userID, lang, state.Goal, *step); handled {
a.clearExecutionState(userID)
if onEvent != nil && strings.TrimSpace(answer) != "" {
emitStreamText(onEvent, answer)
}
return answer, nil
}
if onEvent != nil {
onEvent(StreamEventTool, step.ToolName)
}
@@ -3083,7 +3163,7 @@ func (a *Agent) executePlan(ctx context.Context, storeUserID string, userID int6
return finalText, nil
}
respondStartedAt := time.Now()
finalText, err := a.generateFinalPlanResponse(ctx, userID, lang, *state, step.Instruction)
finalText, err := a.generateFinalPlanResponse(ctx, storeUserID, userID, lang, *state, step.Instruction)
a.logPlannerTiming(state.SessionID, userID, "respond_step", respondStartedAt, err)
if err != nil {
return "", err
@@ -3575,6 +3655,38 @@ func (a *Agent) executePlanTool(ctx context.Context, storeUserID string, userID
})
}
func (a *Agent) redirectPlannerStrategyCreateStep(storeUserID string, userID int64, lang, text string, step PlanStep) (string, bool) {
if strings.TrimSpace(step.ToolName) != "manage_strategy" {
return "", false
}
action, _ := step.ToolArgs["action"].(string)
if strings.TrimSpace(action) != "create" {
return "", false
}
session := skillSession{
Name: "strategy_management",
Action: "create",
Phase: "collecting",
Fields: map[string]string{},
}
if name, _ := step.ToolArgs["name"].(string); strings.TrimSpace(name) != "" {
setField(&session, "name", name)
}
if rawConfig, ok := step.ToolArgs["config"]; ok {
if strategyType := strategyTypeFromConfigPatchAny(rawConfig); strategyType != "" {
setStrategyCreateType(&session, strategyType)
if sanitized := sanitizeStrategyCreateConfigPatchForType(rawConfig, strategyType); len(sanitized) > 0 {
raw, _ := json.Marshal(sanitized)
setField(&session, strategyCreateConfigPatchField, string(raw))
}
}
}
if confirmed, ok := step.ToolArgs["confirmed"].(bool); ok && confirmed {
setField(&session, "awaiting_final_confirmation", "true")
}
return a.handleStrategyCreateSkill(storeUserID, userID, lang, text, session), true
}
func (a *Agent) executeReasonStep(ctx context.Context, userID int64, lang, goal string, state ExecutionState, step PlanStep) (string, error) {
obsJSON, _ := json.Marshal(buildObservationContext(state))
stageCtx, cancel := withPlannerStageTimeout(ctx, plannerReasonTimeout)
@@ -3595,9 +3707,8 @@ func (a *Agent) executeReasonStep(ctx context.Context, userID int64, lang, goal
return summarizeObservation(resp), nil
}
func (a *Agent) generateFinalPlanResponse(ctx context.Context, userID int64, lang string, state ExecutionState, instruction string) (string, error) {
func (a *Agent) generateFinalPlanResponse(ctx context.Context, storeUserID string, userID int64, lang string, state ExecutionState, instruction string) (string, error) {
obsJSON, _ := json.Marshal(buildObservationContext(state))
systemPrompt := a.buildSystemPrompt(lang)
if instruction == "" {
instruction = "Provide the best possible final response to the user based on the finished execution."
}
@@ -3606,7 +3717,7 @@ func (a *Agent) generateFinalPlanResponse(ctx context.Context, userID int64, lan
startedAt := time.Now()
resp, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewSystemMessage(finalPlanResponseSystemPrompt(lang)),
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)))),
@@ -3617,6 +3728,21 @@ func (a *Agent) generateFinalPlanResponse(ctx context.Context, userID int64, lan
return resp, err
}
func finalPlanResponseSystemPrompt(lang string) string {
if lang == "zh" {
return `你是 NOFXi 的执行结果回复模块。
只根据 Observations JSON 和已完成步骤回答用户。
不要引入未观察到的策略、交易员、模型或交易所信息。
不要承诺稍后通知;如果工具已经执行,直接说结果;如果工具失败,直接说失败原因和下一步。
用中文,简洁清楚。`
}
return `You are NOFXi's execution-result response module.
Answer only from Observations JSON and completed steps.
Do not introduce unobserved strategy, trader, model, or exchange details.
Do not promise later notification; if a tool executed, state the result; if it failed, state the reason and next step.
Be concise and clear.`
}
func (a *Agent) logPlannerTiming(sessionID string, userID int64, stage string, startedAt time.Time, err error) {
if stage == "" || startedAt.IsZero() {
return
@@ -3770,8 +3896,8 @@ func (a *Agent) thinkAndActLegacy(ctx context.Context, userID int64, lang, text
}
func (a *Agent) thinkAndActLegacyWithStore(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, error) {
systemPrompt := a.buildSystemPrompt(lang)
enrichment := a.gatherContext(text)
systemPrompt := a.buildSystemPromptForStoreUser(lang, storeUserID)
enrichment := a.gatherContext(storeUserID, text)
preferencesCtx := a.buildPersistentPreferencesContext(userID)
userPrompt := text
@@ -3864,7 +3990,15 @@ func (a *Agent) thinkAndActLegacyWithStore(ctx context.Context, storeUserID stri
return "I can tell you're continuing the previous task, but the internal response format was invalid. Please repeat that step and I'll keep going.", nil
}
if onEvent != nil {
emitStreamText(onEvent, resp.Content)
reply := resp.Content
if guarded, blocked := guardUnsupportedAsyncPromise(lang, reply); blocked {
reply = guarded
}
emitStreamText(onEvent, reply)
return reply, nil
}
if guarded, blocked := guardUnsupportedAsyncPromise(lang, resp.Content); blocked {
return guarded, nil
}
return resp.Content, nil
}
@@ -3910,7 +4044,14 @@ func (a *Agent) thinkAndActLegacyWithStore(ctx context.Context, storeUserID stri
return "I can tell you're continuing the previous task, but the internal response format was invalid. Please repeat that step and I'll keep going.", nil
}
if onEvent != nil {
if guarded, blocked := guardUnsupportedAsyncPromise(lang, finalResp); blocked {
finalResp = guarded
}
emitStreamText(onEvent, finalResp)
return finalResp, nil
}
if guarded, blocked := guardUnsupportedAsyncPromise(lang, finalResp); blocked {
return guarded, nil
}
return finalResp, nil
}

View File

@@ -129,12 +129,9 @@ func buildSkillDAGRegistry() map[string]SkillDAG {
SkillName: "strategy_management",
Action: "update_config",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"resolve_config_field"}},
{ID: "resolve_config_field", Kind: "collect_slot", RequiredFields: []string{"config_field"}, Next: []string{"resolve_config_value"}},
{ID: "resolve_config_value", Kind: "collect_slot", RequiredFields: []string{"config_value"}, Next: []string{"load_config"}},
{ID: "load_config", Kind: "load_state", RequiredFields: []string{"target_ref"}, Next: []string{"apply_field_update"}},
{ID: "apply_field_update", Kind: "transform", RequiredFields: []string{"config_field", "config_value"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "config_field", "config_value"}, Terminal: true},
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_config_patch"}},
{ID: "collect_config_patch", Kind: "collect_slot", RequiredFields: []string{"config_patch"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "config_patch"}, Terminal: true},
},
},
{

View File

@@ -397,42 +397,6 @@ func (a *Agent) tryHardSkill(ctx context.Context, storeUserID string, userID int
return answer, true
}
}
if hasExplicitDiagnosisIntentForDomain(text, "model") {
answer := a.handleModelDiagnosisSkill(storeUserID, lang, text)
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
onEvent(StreamEventTool, "hard_skill:model_diagnosis")
emitStreamText(onEvent, answer)
}
return answer, true
}
if hasExplicitDiagnosisIntentForDomain(text, "exchange") {
answer := a.handleExchangeDiagnosisSkill(storeUserID, lang, text)
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
onEvent(StreamEventTool, "hard_skill:exchange_diagnosis")
emitStreamText(onEvent, answer)
}
return answer, true
}
if hasExplicitDiagnosisIntentForDomain(text, "trader") {
answer := a.handleTraderDiagnosisSkill(storeUserID, lang, text)
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
onEvent(StreamEventTool, "hard_skill:trader_diagnosis")
emitStreamText(onEvent, answer)
}
return answer, true
}
if hasExplicitDiagnosisIntentForDomain(text, "strategy") {
answer := a.handleStrategyDiagnosisSkill(storeUserID, lang, text)
a.recordSkillInteraction(userID, text, answer)
if onEvent != nil {
onEvent(StreamEventTool, "hard_skill:strategy_diagnosis")
emitStreamText(onEvent, answer)
}
return answer, true
}
return "", false
}

View File

@@ -102,37 +102,25 @@ func buildSkillDomainPrimer(lang, skillName string) string {
fields := []string{
slotDisplayName("name", lang),
displayCatalogFieldName("strategy_type", lang),
displayCatalogFieldName("source_type", lang),
displayCatalogFieldName("primary_timeframe", lang),
displayCatalogFieldName("selected_timeframes", lang),
displayCatalogFieldName("custom_prompt", lang),
}
if lang == "zh" {
return strings.Join([]string{
"### 策略配置领域约束",
"- 策略围绕策略类型、选币来源、时间周期、风险参数和提示词展开。",
"- source_type 是选币来源,不是交易所,也不是模型。",
"- 本领域只处理策略模板。",
"- strategy_type 选项ai_trading、grid_trading。",
"- source_type 选项static、ai500、oi_top、oi_low、mixed。",
"- grid_trading 页面交易对选项BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT。",
"- grid_trading 页面范围grid_count 550、total_investment 最小 100、leverage 15、atr_multiplier 15、max_drawdown_pct 550、stop_loss_pct 120、daily_loss_limit_pct 130、direction_bias_ratio 0.550.90。",
"- ai_trading 页面范围static_coins 最多 10 个、selected_timeframes 最多 4 个、primary_count 1030、min_confidence 50100、min_risk_reward_ratio 110。",
"- 排行榜页面选项duration 为 1h/4h/24h价格榜还支持 1h,4h,24hlimit 为 5/10/15/20。",
"- max_positions、仓位价值占比、max_margin_usage、min_position_size 在策略页属于 System enforced / 非普通手动编辑项。",
"- 用户提到 AI500、OI Top、OI Low、静态币种/固定币种这类选币来源时,属于 ai_trading。",
"- 策略类型确定后,只能使用当前类型的产品编辑页模板。",
"- 策略类型未确定时,只判断类型,不要展示或混合任一分支的具体配置字段。",
"- 关键字段:" + strings.Join(fields, "、"),
}, "\n")
}
return strings.Join([]string{
"### Strategy Config Domain Guard",
"- Strategy configuration revolves around strategy type, coin source, timeframes, risk parameters, and prompts.",
"- source_type means the coin source, not an exchange or model.",
"- This domain only handles strategy templates.",
"- strategy_type options: ai_trading, grid_trading.",
"- source_type options: static, ai500, oi_top, oi_low, mixed.",
"- grid_trading symbol dropdown: BTCUSDT, ETHUSDT, SOLUSDT, BNBUSDT, XRPUSDT, DOGEUSDT.",
"- grid_trading page ranges: grid_count 5-50, total_investment >=100, leverage 1-5, atr_multiplier 1-5, max_drawdown_pct 5-50, stop_loss_pct 1-20, daily_loss_limit_pct 1-30, direction_bias_ratio 0.55-0.90.",
"- ai_trading page ranges: static_coins at most 10, selected_timeframes at most 4, primary_count 10-30, min_confidence 50-100, min_risk_reward_ratio 1-10.",
"- Ranking page options: duration 1h/4h/24h (price ranking also supports 1h,4h,24h), limit 5/10/15/20.",
"- max_positions, position value ratios, max_margin_usage, and min_position_size are System enforced / not ordinary manual fields on the strategy page.",
"- AI500, OI Top, OI Low, and static coin-source requests imply ai_trading.",
"- Once strategy_type is known, use only that product editor template.",
"- Before strategy_type is known, only determine the type; do not show or mix concrete fields from either branch.",
"- Key fields: " + strings.Join(fields, ", "),
}, "\n")
default:
@@ -140,12 +128,82 @@ func buildSkillDomainPrimer(lang, skillName string) string {
}
}
func buildSkillDomainPrimerForSession(lang string, session skillSession) string {
if session.Name != "strategy_management" {
return buildSkillDomainPrimer(lang, session.Name)
}
strategyType := explicitStrategyCreateType(session)
if strategyType == "" {
return buildSkillDomainPrimer(lang, session.Name)
}
if lang == "zh" {
switch strategyType {
case "ai_trading":
return strings.Join([]string{
"### AI 策略模板",
"- 只使用 ai_trading 模板strategy_type + ai_config + publish_config。",
"- config_patch 必须使用产品 schema 原值不要使用展示文案strategy_type=ai_tradingsource_type 只能是 static、ai500、oi_top、oi_low没有 mixed/混合模式。",
"- 时间周期必须输出为产品枚举字符串,例如 1m、3m、5m、15m、1hselected_timeframes 必须是字符串数组,例如 [\"1m\",\"5m\",\"15m\"],不要输出 JSON 字符串。",
"- AI500/OI Top/OI Low 选币数量范围 110static_coins 最多 10 个selected_timeframes 最多 4 个primary_count 1030。",
"- BTC/ETH 最大杠杆 120山寨币最大杠杆 120min_confidence 50100min_risk_reward_ratio 110。",
"- AI 策略创建方案不要展示或询问非 AI 模板字段:投入金额、每笔固定投入、止损、日亏损限制、最大回撤、网格字段。",
}, "\n")
case "grid_trading":
return strings.Join([]string{
"### 网格策略模板",
"- 只使用 grid_trading 模板strategy_type + grid_config + publish_configconfig_patch 必须使用产品 schema 原值strategy_type=grid_trading。",
"- 交易对选项BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT。",
"- grid_count 550total_investment 最小 100leverage 15atr_multiplier 15。",
"- total_investment 是用户实际投入/保证金预算,不是杠杆后的名义仓位;最大名义仓位约等于 total_investment × leverage。用户说“投入/总投入/本金/保证金”时默认映射到 total_investment。",
"- max_drawdown_pct 550stop_loss_pct 120daily_loss_limit_pct 130direction_bias_ratio 0.550.90。",
"- 没有实时行情工具结果时,不要猜当前价格或手动价格上下界;推荐 use_atr_bounds=true 的 ATR 自动边界。",
"- 如果用户让你选择/推荐剩余网格参数,价格区间默认写入 use_atr_bounds=true不要反问用户手动价格区间也不要编造“当前 BTC/ETH 在某价附近”。",
}, "\n")
}
}
switch strategyType {
case "ai_trading":
return strings.Join([]string{
"### AI Strategy Template",
"- Use only ai_trading: strategy_type + ai_config + publish_config.",
"- config_patch must use product schema raw values, not display labels: strategy_type=ai_trading; source_type is only static, ai500, oi_top, or oi_low; no mixed mode.",
"- Timeframes must be product enum strings such as 1m, 3m, 5m, 15m, 1h; selected_timeframes must be a JSON string array such as [\"1m\",\"5m\",\"15m\"], not a JSON-encoded string.",
"- AI500/OI source counts 1-10; static_coins at most 10; selected_timeframes at most 4; primary_count 10-30.",
"- BTC/ETH leverage 1-20; altcoin leverage 1-20; min_confidence 50-100; min_risk_reward_ratio 1-10.",
"- Do not show or ask for non-AI-template fields in AI strategy drafts: investment amount, fixed per-trade amount, stop loss, daily loss limit, max drawdown, or grid fields.",
}, "\n")
case "grid_trading":
return strings.Join([]string{
"### Grid Strategy Template",
"- Use only grid_trading: strategy_type + grid_config + publish_config; config_patch must use product schema raw values with strategy_type=grid_trading.",
"- Symbol options: BTCUSDT, ETHUSDT, SOLUSDT, BNBUSDT, XRPUSDT, DOGEUSDT.",
"- grid_count 5-50; total_investment >=100; leverage 1-5; atr_multiplier 1-5.",
"- total_investment is the user's actual capital/margin budget, not leveraged notional exposure; maximum notional exposure is approximately total_investment * leverage. When the user says investment, capital, amount to put in, or margin, map it to total_investment by default.",
"- max_drawdown_pct 5-50; stop_loss_pct 1-20; daily_loss_limit_pct 1-30; direction_bias_ratio 0.55-0.90.",
"- Without fresh market data, do not guess the current price or manual upper/lower prices; recommend ATR auto bounds with use_atr_bounds=true.",
"- If the user asks you to choose/recommend the remaining grid parameters, default the price range to use_atr_bounds=true; do not ask for manual price bounds or invent statements like the current BTC/ETH price is near a value.",
}, "\n")
}
return buildSkillDomainPrimer(lang, session.Name)
}
func buildManagementDomainPrimer(lang string) string {
parts := []string{
buildSkillDomainPrimer(lang, "model_management"),
buildSkillDomainPrimer(lang, "exchange_management"),
buildSkillDomainPrimer(lang, "trader_management"),
buildSkillDomainPrimer(lang, "strategy_management"),
if lang == "zh" {
return strings.Join([]string{
"### 管理领域路由速记",
"- 模型/API Key/providermodel_management",
"- 交易所账户/API 凭证exchange_management",
"- 交易员创建、启动、停止、绑定策略/模型/交易所trader_management。",
"- 策略模板创建、查看、修改、删除、激活、复制strategy_management。",
"- 这里只用于路由;具体字段和模板只在进入对应 skill 后注入。",
}, "\n")
}
return strings.Join(filterNonEmptyStrings(parts), "\n\n")
return strings.Join([]string{
"### Management Routing Cheat Sheet",
"- Model/API key/provider: model_management.",
"- Exchange account/API credentials: exchange_management.",
"- Trader create/start/stop/bind strategy/model/exchange: trader_management.",
"- Strategy template create/query/update/delete/activate/duplicate: strategy_management.",
"- This is only for routing; detailed fields/templates are injected after entering the selected skill.",
}, "\n")
}

View File

@@ -1,12 +1,14 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"nofx/mcp"
"nofx/store"
)
@@ -505,10 +507,6 @@ func buildExchangeUpdatePatchFromSession(session skillSession) exchangeUpdatePat
return patch
}
func detectStrategyConfigField(text string) string {
return ""
}
func strategyConfigFieldDisplayName(field, lang string) string {
switch field {
case "name":
@@ -828,19 +826,6 @@ func strategyConfigFieldDisplayName(field, lang string) string {
}
}
func extractStrategyConfigValue(text, field string) (string, bool) {
return "", false
}
type strategyConfigPatch struct {
Field string
Value string
}
func detectStrategyConfigPatches(text string) []strategyConfigPatch {
return nil
}
func applyStrategyConfigPatch(cfg *store.StrategyConfig, field, value string) error {
ensureGridConfig := func() *store.GridStrategyConfig {
if cfg.GridConfig == nil {
@@ -925,11 +910,7 @@ func applyStrategyConfigPatch(cfg *store.StrategyConfig, field, value string) er
case "description", "is_public", "config_visible":
return nil
case "max_positions":
parsed, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("最大持仓需要是整数")
}
cfg.RiskControl.MaxPositions = parsed
return fmt.Errorf("%s", strategyLockedFieldError("zh", field))
case "source_type":
cfg.CoinSource.SourceType = value
case "static_coins":
@@ -992,29 +973,13 @@ func applyStrategyConfigPatch(cfg *store.StrategyConfig, field, value string) er
}
cfg.RiskControl.AltcoinMaxLeverage = parsed
case "btceth_max_position_value_ratio":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return fmt.Errorf("BTC/ETH 仓位价值倍数需要是数字")
}
cfg.RiskControl.BTCETHMaxPositionValueRatio = parsed
return fmt.Errorf("%s", strategyLockedFieldError("zh", field))
case "altcoin_max_position_value_ratio":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return fmt.Errorf("山寨币仓位价值倍数需要是数字")
}
cfg.RiskControl.AltcoinMaxPositionValueRatio = parsed
return fmt.Errorf("%s", strategyLockedFieldError("zh", field))
case "max_margin_usage":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return fmt.Errorf("最大保证金使用率需要是数字")
}
cfg.RiskControl.MaxMarginUsage = parsed
return fmt.Errorf("%s", strategyLockedFieldError("zh", field))
case "min_position_size":
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return fmt.Errorf("最小开仓金额需要是数字")
}
cfg.RiskControl.MinPositionSize = parsed
return fmt.Errorf("%s", strategyLockedFieldError("zh", field))
case "primary_timeframe":
cfg.Indicators.Klines.PrimaryTimeframe = value
case "primary_count":
@@ -1214,9 +1179,15 @@ func extractDurationValue(text string) string {
func parseStrategyTypeValue(text string) string {
lower := strings.ToLower(strings.TrimSpace(text))
switch {
case lower == "grid_trading":
return "grid_trading"
case lower == "ai_trading":
return "ai_trading"
case containsAny(lower, []string{"grid", "网格"}):
return "grid_trading"
case containsAny(lower, []string{"ai trading", "ai策略", "普通策略"}):
case containsAny(lower, []string{"ai500", "oi top", "oi low", "静态币", "固定币", "选币来源"}):
return "ai_trading"
case containsAny(lower, []string{"ai trading", "ai策略", "ai 策略", "ai交易", "ai 交易", "ai智能", "智能策略", "普通策略"}):
return "ai_trading"
default:
return ""
@@ -2417,10 +2388,7 @@ func (a *Agent) executeStrategyManagementAction(storeUserID string, userID int64
if session.Action == "update_prompt" {
return a.executeStrategyPromptUpdate(storeUserID, userID, lang, text, session)
}
if session.Action == "update_config" ||
fieldValue(session, strategyPendingUpdateConfigField) != "" ||
fieldValue(session, "config_field") != "" ||
fieldValue(session, "config_value") != "" {
if session.Action == "update_config" || fieldValue(session, strategyPendingUpdateConfigField) != "" {
return a.executeStrategyConfigUpdate(storeUserID, userID, lang, text, session)
}
if fieldValue(session, skillDAGStepField) == "" {
@@ -2550,11 +2518,10 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la
if _, ok := getSkillDAG("strategy_management", "update_config"); ok {
if fieldValue(session, skillDAGStepField) == "" {
setSkillDAGStep(&session, "resolve_config_field")
setSkillDAGStep(&session, "collect_config_patch")
}
}
currentStep, _ := currentSkillDAGStep(session)
strategy, cfg, err := a.loadStrategyConfigForUpdate(storeUserID, session.TargetRef.ID)
if err != nil {
if lang == "zh" {
@@ -2566,7 +2533,7 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la
if patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField)); patchRaw != "" {
var patch map[string]any
if err := json.Unmarshal([]byte(patchRaw), &patch); err != nil {
setSkillDAGStep(&session, "resolve_config_field")
setSkillDAGStep(&session, "collect_config_patch")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "策略配置 patch 不是合法 JSON" + err.Error()
@@ -2575,7 +2542,7 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la
}
merged, err := store.MergeStrategyConfig(cfg, patch)
if err != nil {
setSkillDAGStep(&session, "resolve_config_field")
setSkillDAGStep(&session, "collect_config_patch")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "策略配置 patch 无法应用:" + err.Error()
@@ -2586,7 +2553,7 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la
merged.ClampLimits()
msgZH := "已更新策略配置。"
msgEN := "Updated strategy config."
setSkillDAGStep(&session, "apply_field_update")
setSkillDAGStep(&session, "execute_update")
if warnings := store.StrategyClampWarnings(beforeClamp, merged, lang); len(warnings) > 0 {
return a.deferStrategyRiskControlledUpdate(userID, lang, &session, merged, warnings, msgZH, msgEN)
}
@@ -2612,146 +2579,12 @@ func (a *Agent) executeStrategyConfigUpdate(storeUserID string, userID int64, la
return msgEN
}
if generatedDraftRequiresConfirmation(session) && fieldValue(session, "config_field") == "" && fieldValue(session, "config_value") == "" {
if generated := fieldValue(session, "custom_prompt"); generated != "" {
setField(&session, "config_field", "custom_prompt")
setField(&session, "config_value", generated)
}
}
if generatedDraftRequiresConfirmation(session) {
switch {
case createConfirmationReply(text):
clearGeneratedDraftConfirmation(&session)
case isNoReply(text):
clearGeneratedDraftConfirmation(&session, "config_field", "config_value", "custom_prompt")
setSkillDAGStep(&session, "resolve_config_field")
session.Phase = "collecting"
setSkillDAGStep(&session, "collect_config_patch")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "好,我先不用这版草稿。你可以直接告诉我要改哪个配置,或者继续让我重新设计一版。"
return "你可以直接说想怎么改策略配置,比如“选币来源改成 AI500最低置信度 80”。我会按当前策略类型的产品模板生成 config_patch 后再更新。"
}
return "Okay, I won't use that draft. Tell me which config to change, or ask me to draft another version."
}
}
if fieldValue(session, "config_field") == "" && fieldValue(session, "config_value") == "" {
if strings.Contains(strings.ToLower(text), "min position size") || strings.Contains(strings.ToLower(text), "最小开仓金额") {
a.clearSkillSession(userID)
return strategyLockedFieldError(lang, "min_position_size")
}
patches := detectStrategyConfigPatches(text)
if len(patches) > 1 {
changed := make([]string, 0, len(patches))
for _, patch := range patches {
if patch.Field == "min_position_size" {
a.clearSkillSession(userID)
return strategyLockedFieldError(lang, "min_position_size")
}
if err := applyStrategyConfigPatch(&cfg, patch.Field, patch.Value); err != nil {
a.saveSkillSession(userID, session)
if lang == "zh" {
return "这次没改成功:" + err.Error()
}
return "That change did not go through: " + err.Error()
}
switch patch.Field {
case "description":
strategy.Description = patch.Value
case "is_public":
strategy.IsPublic = patch.Value == "true"
case "config_visible":
strategy.ConfigVisible = patch.Value == "true"
}
changed = append(changed, strategyConfigFieldDisplayName(patch.Field, lang))
}
beforeClamp := cfg
cfg.ClampLimits()
setSkillDAGStep(&session, "apply_field_update")
msgZH := "已更新策略参数:" + strings.Join(changed, "、") + "。"
msgEN := "Updated strategy config fields: " + strings.Join(changed, ", ") + "."
if warnings := store.StrategyClampWarnings(beforeClamp, cfg, lang); len(warnings) > 0 {
return a.deferStrategyRiskControlledUpdate(userID, lang, &session, cfg, warnings, msgZH, msgEN)
}
setSkillDAGStep(&session, "execute_update")
return a.persistStrategyConfigUpdate(storeUserID, userID, lang, strategy, cfg, msgZH, msgEN)
}
}
field := fieldValue(session, "config_field")
if field == "" {
field = detectStrategyConfigField(text)
if field != "" {
if field == "min_position_size" {
a.clearSkillSession(userID)
return strategyLockedFieldError(lang, field)
}
setField(&session, "config_field", field)
if currentStep.ID == "resolve_config_field" {
advanceSkillDAGStep(&session, currentStep.ID)
currentStep, _ = currentSkillDAGStep(session)
}
}
}
if field == "" {
setSkillDAGStep(&session, "resolve_config_field")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "你可以直接告诉我想改哪一项,比如币种来源、杠杆、时间周期、技术指标,或者提示词。"
}
return "Tell me what you want to change, for example coin source, leverage, timeframes, indicators, or the prompt."
}
if fieldValue(session, "config_value") == "" {
if value, ok := extractStrategyConfigValue(text, field); ok {
setField(&session, "config_value", value)
if currentStep.ID == "resolve_config_value" {
advanceSkillDAGStep(&session, currentStep.ID)
currentStep, _ = currentSkillDAGStep(session)
}
}
}
value := fieldValue(session, "config_value")
if value == "" {
setSkillDAGStep(&session, "resolve_config_value")
a.saveSkillSession(userID, session)
if lang == "zh" {
return fmt.Sprintf("还差一步:请告诉我新的%s。", strategyConfigFieldDisplayName(field, lang))
}
return fmt.Sprintf("One more thing: tell me the new %s.", strategyConfigFieldDisplayName(field, lang))
}
if err := applyStrategyConfigPatch(&cfg, field, value); err != nil {
setSkillDAGStep(&session, "resolve_config_value")
a.saveSkillSession(userID, session)
if lang == "zh" {
return err.Error()
}
return err.Error()
}
switch field {
case "description":
strategy.Description = value
case "is_public":
strategy.IsPublic = value == "true"
case "config_visible":
strategy.ConfigVisible = value == "true"
}
beforeClamp := cfg
cfg.ClampLimits()
changed := []string{field}
displayChanged := make([]string, 0, len(changed))
for _, item := range changed {
displayChanged = append(displayChanged, strategyConfigFieldDisplayName(item, lang))
}
msgZH := "已更新策略参数:" + strings.Join(displayChanged, "、") + "。"
msgEN := "Updated strategy config fields: " + strings.Join(displayChanged, ", ") + "."
setSkillDAGStep(&session, "apply_field_update")
if warnings := store.StrategyClampWarnings(beforeClamp, cfg, lang); len(warnings) > 0 {
return a.deferStrategyRiskControlledUpdate(userID, lang, &session, cfg, warnings, msgZH, msgEN)
}
setSkillDAGStep(&session, "execute_update")
return a.persistStrategyConfigUpdate(storeUserID, userID, lang, strategy, cfg, msgZH, msgEN)
return "Tell me how you want to change the strategy config, for example: set coin source to ai500 and minimum confidence to 80. I will turn it into a config_patch for the current strategy type before updating."
}
func (a *Agent) loadStrategyConfigForUpdate(storeUserID, strategyID string) (*store.Strategy, store.StrategyConfig, error) {
@@ -2913,20 +2746,396 @@ func extractTimeframes(text string) []string {
}
func (a *Agent) handleTraderDiagnosisSkill(storeUserID, lang, text string) string {
target := resolveDiagnosisTraderTarget(a.loadTraderOptions(storeUserID), text)
if target == nil {
raw := a.toolListTraders(storeUserID)
list := formatReadFastPathResponse(lang, "list_traders", raw)
if lang == "zh" {
reply := "现象:这是交易员运行诊断问题。\n优先排查\n1. 交易员是否已创建并处于运行状态。\n2. 绑定的模型、交易所、策略是否齐全。\n3. 是“没有启动”、还是“启动了但 AI 没有下单”、还是“下单失败”。\n当前交易员概览\n" + list
if excerpt := backendLogDiagnosisExcerpt(lang, text, "trader"); excerpt != "" {
reply += "\n" + excerpt
return "我需要先确定要诊断哪个交易员。当前交易员:\n" + list
}
return reply
return "I need to know which trader to diagnose first. Current traders:\n" + list
}
reply := "This looks like a trader diagnosis issue.\nCheck whether the trader exists, is running, and has model/exchange/strategy bindings.\nCurrent trader overview:\n" + list
if excerpt := backendLogDiagnosisExcerpt(lang, text, "trader"); excerpt != "" {
reply += "\n" + excerpt
evidence := a.collectTraderDiagnosisEvidence(storeUserID, target.ID, target.Name)
if answer, ok := a.generateTraderDiagnosisAnswerWithLLM(context.Background(), lang, text, evidence); ok {
return answer
}
return reply
return formatTraderDiagnosisEvidence(lang, evidence)
}
func resolveDiagnosisTraderTarget(options []traderSkillOption, text string) *traderSkillOption {
if opt := findOptionByIDOrName(options, text); opt != nil {
return opt
}
if opt := findUniqueContainingOption(options, text); opt != nil {
return opt
}
if len(options) == 1 {
return &options[0]
}
return nil
}
type traderDecisionToolResponse struct {
Error string `json:"error"`
TraderID string `json:"trader_id"`
TraderName string `json:"trader_name"`
Count int `json:"count"`
Records []struct {
Success bool `json:"success"`
ErrorMessage string `json:"error_message"`
AIRequestDurationMs int `json:"ai_request_duration_ms"`
CandidateCoins []string `json:"candidate_coins"`
ExecutionLog []string `json:"execution_log"`
DecisionJSON string `json:"decision_json"`
Decisions []map[string]any `json:"decisions"`
} `json:"records"`
}
type traderDiagnosisEvidence struct {
TraderName string
TraderConfig *store.Trader
Model *safeModelToolConfig
Exchange *safeExchangeToolConfig
Strategy *safeStrategyToolConfig
Runtime map[string]any
Account map[string]any
Positions []map[string]any
Decisions traderDecisionToolResponse
Logs struct {
Entries []any `json:"entries"`
Count int `json:"count"`
Error string `json:"error"`
}
}
func (a *Agent) collectTraderDiagnosisEvidence(storeUserID, traderID, traderName string) traderDiagnosisEvidence {
ev := traderDiagnosisEvidence{TraderName: traderName}
if a.store != nil {
if traderCfg, err := a.resolveTraderForTool(storeUserID, traderID, traderName); err == nil {
ev.TraderConfig = traderCfg
ev.TraderName = defaultIfEmpty(traderCfg.Name, ev.TraderName)
if model, err := a.store.AIModel().Get(storeUserID, traderCfg.AIModelID); err == nil && model != nil {
safeModel := safeModelForTool(model)
ev.Model = &safeModel
}
if exchange, err := a.store.Exchange().GetByID(storeUserID, traderCfg.ExchangeID); err == nil && exchange != nil {
safeExchange := safeExchangeForTool(exchange)
ev.Exchange = &safeExchange
}
if strings.TrimSpace(traderCfg.StrategyID) != "" {
if strategy, err := a.store.Strategy().Get(storeUserID, traderCfg.StrategyID); err == nil && strategy != nil {
safeStrategy := safeStrategyForTool(strategy)
ev.Strategy = &safeStrategy
}
}
}
}
if a.traderManager != nil && ev.TraderConfig != nil {
if runtimeTrader, err := a.traderManager.GetTrader(ev.TraderConfig.ID); err == nil && runtimeTrader != nil {
ev.Runtime = runtimeTrader.GetStatus()
if account, err := runtimeTrader.GetAccountInfo(); err == nil {
ev.Account = account
}
if positions, err := runtimeTrader.GetPositions(); err == nil {
ev.Positions = positions
}
}
}
if ev.TraderConfig != nil {
decisionArgs, _ := json.Marshal(map[string]any{"trader_id": ev.TraderConfig.ID, "limit": 5})
_ = json.Unmarshal([]byte(a.toolGetDecisions(storeUserID, string(decisionArgs))), &ev.Decisions)
logArgs, _ := json.Marshal(map[string]any{"trader_id": ev.TraderConfig.ID, "limit": 30, "errors_only": false})
_ = json.Unmarshal([]byte(a.toolGetBackendLogs(storeUserID, string(logArgs))), &ev.Logs)
}
return ev
}
func (a *Agent) generateTraderDiagnosisAnswerWithLLM(ctx context.Context, lang, userText string, ev traderDiagnosisEvidence) (string, bool) {
if a == nil || a.aiClient == nil || ev.TraderConfig == nil {
return "", false
}
evidenceJSON, err := json.MarshalIndent(ev, "", " ")
if err != nil {
return "", false
}
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
systemPrompt := `You are the trader diagnosis reasoning layer for NOFXi.
You receive a complete evidence package collected by tools: trader config, bound model, bound exchange, bound strategy, account/positions, recent AI decisions, and backend logs.
Your job:
- Reason from the evidence and produce the final user-facing diagnosis in the user's language.
- The answer must be short and useful: final cause + what the user should do.
- Prefer recent AI decisions, order validation, exchange result, runtime/account/positions over scattered backend logs.
- Do not expose evidence-package wording, tool names, raw logs, HTTP status codes, backend internals, or engineering troubleshooting unless the user explicitly asked for technical logs.
- Do not invent subscriptions, data services, websites, missing product fields, or unsupported actions.
- Never say "subscription expired" unless the evidence explicitly contains a confirmed subscription state.
- If an order is blocked because the amount is too small, explain it as account size/order minimum/system limit. Do not suggest editing position_size_usd, min_position_size, max_positions, position value ratios, or other System enforced fields.
- If the latest decision is wait/hold, explain that the trader is running and the AI chose to wait because the entry standard was not met.
- If evidence is insufficient, say what is missing and the next concrete check.
Return plain text only. No markdown tables.`
userPrompt := fmt.Sprintf("Language: %s\nUser question: %s\n\nEvidence JSON:\n%s", lang, userText, string(evidenceJSON))
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
a.log().Warn("trader diagnosis LLM failed; using deterministic fallback", "error", err)
return "", false
}
answer := strings.TrimSpace(raw)
if answer == "" {
return "", false
}
return answer, true
}
func formatTraderDiagnosisEvidence(lang string, ev traderDiagnosisEvidence) string {
traderName := defaultIfEmpty(ev.TraderName, "未知交易员")
if ev.TraderConfig == nil {
if lang == "zh" {
return fmt.Sprintf("我没有找到交易员“%s”所以没法继续诊断。", traderName)
}
return fmt.Sprintf("I could not find trader %q, so I cannot diagnose it yet.", traderName)
}
latest := struct {
Success bool `json:"success"`
ErrorMessage string `json:"error_message"`
AIRequestDurationMs int `json:"ai_request_duration_ms"`
CandidateCoins []string `json:"candidate_coins"`
ExecutionLog []string `json:"execution_log"`
DecisionJSON string `json:"decision_json"`
Decisions []map[string]any `json:"decisions"`
}{}
hasDecision := len(ev.Decisions.Records) > 0
if hasDecision {
latest = ev.Decisions.Records[0]
}
rawDecisions, _ := json.Marshal(ev.Decisions)
allEvidence := strings.ToLower(string(rawDecisions))
latestEvidence := strings.ToLower(strings.Join(append(append([]string{}, latest.ExecutionLog...), latest.ErrorMessage, latest.DecisionJSON), "\n"))
hasAmountTooSmall := containsAny(allEvidence, []string{"opening amount too small", "below minimum", "must be ≥", "must be >=", "position value below minimum"})
latestWait := containsAny(latestEvidence, []string{"wait succeeded", `"action":"wait"`, `"action":"hold"`})
primarySymbol := primaryDiagnosisSymbol(latest.CandidateCoins, latest.DecisionJSON)
amount, minimum := openingAmountAndMinimum(string(rawDecisions))
totalEquity := toFloat(ev.Account["total_equity"])
available := toFloat(ev.Account["available_balance"])
if available == 0 {
available = toFloat(ev.Account["available"])
}
var maxBTCETHPositionValue float64
if ev.Strategy != nil && ev.Strategy.Config != nil {
if risk, ok := nestedMap(ev.Strategy.Config, "ai_config", "risk_control"); ok {
maxBTCETHPositionValue = totalEquity * firstPositiveFloat(risk["btc_eth_max_position_value_ratio"], risk["btceth_max_position_value_ratio"])
}
if maxBTCETHPositionValue == 0 {
if risk, ok := ev.Strategy.Config["risk_control"].(map[string]any); ok {
maxBTCETHPositionValue = totalEquity * firstPositiveFloat(risk["btc_eth_max_position_value_ratio"], risk["btceth_max_position_value_ratio"])
}
}
}
if lang == "zh" {
lines := []string{}
switch {
case !ev.TraderConfig.IsRunning:
lines = append(lines, fmt.Sprintf("%s 现在没有运行,所以不会开单。", traderName))
lines = append(lines, "该怎么办:先启动这个交易员;启动后等它跑到下一个扫描周期,再看是否有新的 AI 决策。")
case strings.TrimSpace(ev.TraderConfig.AIModelID) == "":
lines = append(lines, fmt.Sprintf("%s 没有绑定 AI 模型,所以没法做交易决策。", traderName))
lines = append(lines, "该怎么办:先给这个交易员绑定一个已启用、可正常调用的模型。")
case ev.Model != nil && !modelEnabled(ev.Model):
lines = append(lines, fmt.Sprintf("%s 绑定的 AI 模型目前没有启用,所以没法稳定做交易决策。", traderName))
lines = append(lines, "该怎么办:启用当前模型,或者把交易员换到另一个可用模型。")
case strings.TrimSpace(ev.TraderConfig.ExchangeID) == "":
lines = append(lines, fmt.Sprintf("%s 没有绑定交易所账户,所以即使有信号也不能下单。", traderName))
lines = append(lines, "该怎么办:先绑定一个可用的交易所账户。")
case ev.Exchange != nil && !exchangeEnabled(ev.Exchange):
lines = append(lines, fmt.Sprintf("%s 绑定的交易所账户目前没有启用,所以不能下单。", traderName))
lines = append(lines, "该怎么办:启用这个交易所账户,或换成另一个可用账户。")
case hasAmountTooSmall:
summary := fmt.Sprintf("%s 不是没运行。最近它有尝试开 %s 的单,但账户资金太小,算出来的开仓金额", traderName, primarySymbol)
if amount > 0 {
summary += fmt.Sprintf("约 %.2f USDT", amount)
}
summary += ",低于系统最小下单要求"
if minimum > 0 {
summary += fmt.Sprintf(" %.2f USDT", minimum)
}
summary += ",所以这笔单被拦下了。"
lines = append(lines, summary)
if totalEquity > 0 && maxBTCETHPositionValue > 0 {
lines = append(lines, fmt.Sprintf("当前账户权益约 %.2f USDT按策略风控算出来的单笔仓位上限约 %.2f USDT容易达不到最小下单金额。", totalEquity, maxBTCETHPositionValue))
}
if latestWait {
lines = append(lines, "另外,最近也有一些周期是 AI 主动选择等待,说明并不是系统完全没跑。")
}
lines = append(lines, "该怎么办:增加账户资金,或者换更适合小资金的策略/标的。AI 智能策略里的最小开仓金额是系统限制,不能手动修改。")
case latestWait:
lines = append(lines, fmt.Sprintf("%s 是运行的,最近 AI 决策也成功了;它不开单的原因是当前信号没有达到入场标准,所以主动选择等待。", traderName))
lines = append(lines, "该怎么办:如果你想让它更容易出手,可以调整产品里真实可改的策略偏好,比如降低最低置信度或最低盈亏比;如果你更重视安全,就让它继续等待更明确的机会。")
case !hasDecision:
lines = append(lines, fmt.Sprintf("%s 目前没有读到最近 AI 决策记录,所以还不能证明它已经跑到完整决策周期。", traderName))
lines = append(lines, "该怎么办:确认交易员已启动,并等待一个扫描周期后再查;如果仍然没有决策记录,再检查运行状态和模型调用。")
case len(latest.CandidateCoins) == 0:
lines = append(lines, fmt.Sprintf("%s 最近没有拿到可交易候选币,所以没有进入开单。", traderName))
lines = append(lines, "该怎么办:检查策略的选币方式、指定币种或排除币设置,确认当前策略确实有可交易标的。")
case strings.TrimSpace(latest.ErrorMessage) != "":
lines = append(lines, fmt.Sprintf("%s 最近没有开单,是因为系统在决策或下单校验时返回了错误:%s", traderName, latest.ErrorMessage))
lines = append(lines, "该怎么办:先按这条错误处理;如果它涉及交易所权限、余额、仓位模式或最小下单金额,就优先处理对应账户或策略可编辑项。")
default:
lines = append(lines, fmt.Sprintf("%s 最近没有开单,但现有记录没有显示明确的拒单原因。", traderName))
lines = append(lines, "该怎么办:继续观察下一个扫描周期;如果连续没有开单,再重点看策略门槛、账户余额、交易所权限和模型调用是否正常。")
}
return strings.Join(lines, "\n")
}
lines := []string{}
switch {
case !ev.TraderConfig.IsRunning:
lines = append(lines, fmt.Sprintf("%s is not running, so it will not open trades.", traderName))
case hasAmountTooSmall:
lines = append(lines, fmt.Sprintf("%s did try to open a %s trade, but the calculated order size was below the system minimum, so it was blocked.", traderName, primarySymbol))
case latestWait:
lines = append(lines, fmt.Sprintf("%s is running, but the latest AI decision chose to wait because the signal did not meet its entry standard.", traderName))
case !hasDecision:
lines = append(lines, fmt.Sprintf("%s has no recent AI decision records yet, so there is not enough evidence that it completed a decision cycle.", traderName))
case len(latest.CandidateCoins) == 0:
lines = append(lines, fmt.Sprintf("%s has no tradable candidate coins in the latest decision, so it did not open a trade.", traderName))
case strings.TrimSpace(latest.ErrorMessage) != "":
lines = append(lines, fmt.Sprintf("%s did not open a trade because the latest decision/check returned: %s", traderName, latest.ErrorMessage))
default:
lines = append(lines, fmt.Sprintf("%s has no clear rejection reason in the latest records yet.", traderName))
}
lines = append(lines, "What to do: use the real editable product settings or account actions, such as adding funds, changing to a small-account-friendly symbol/strategy, or adjusting confidence/risk-reward preferences. Do not change system-enforced fields.")
return strings.Join(lines, "\n")
}
func primaryDiagnosisSymbol(candidates []string, decisionJSON string) string {
for _, candidate := range candidates {
if trimmed := strings.TrimSpace(candidate); trimmed != "" {
return trimmed
}
}
match := regexp.MustCompile(`(?i)"symbol"\s*:\s*"([^"]+)"`).FindStringSubmatch(decisionJSON)
if len(match) >= 2 && strings.TrimSpace(match[1]) != "" {
return strings.ToUpper(strings.TrimSpace(match[1]))
}
return "当前标的"
}
func openingAmountAndMinimum(evidence string) (float64, float64) {
amount := 0.0
minimum := 0.0
if match := regexp.MustCompile(`(?i)opening amount too small \((\d+(?:\.\d+)?)\s*USDT\)`).FindStringSubmatch(evidence); len(match) >= 2 {
amount, _ = strconv.ParseFloat(match[1], 64)
}
if amount == 0 {
if match := regexp.MustCompile(`(?i)"position_size_usd"\s*:\s*(\d+(?:\.\d+)?)`).FindStringSubmatch(evidence); len(match) >= 2 {
amount, _ = strconv.ParseFloat(match[1], 64)
}
}
if match := regexp.MustCompile(`(?:must be|must be ≥|>=|≥)\s*(\d+(?:\.\d+)?)\s*USDT`).FindStringSubmatch(evidence); len(match) >= 2 {
minimum, _ = strconv.ParseFloat(match[1], 64)
}
return amount, minimum
}
func nestedMap(root map[string]any, path ...string) (map[string]any, bool) {
var current any = root
for _, key := range path {
obj, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = obj[key]
if !ok {
return nil, false
}
}
obj, ok := current.(map[string]any)
return obj, ok
}
func firstPositiveFloat(values ...any) float64 {
for _, value := range values {
parsed := toFloat(value)
if parsed > 0 {
return parsed
}
}
return 0
}
func nonZeroPositions(positions []map[string]any) []map[string]any {
out := make([]map[string]any, 0, len(positions))
for _, position := range positions {
if toFloat(position["size"]) != 0 {
out = append(out, position)
}
}
return out
}
func joinAnyLines(values []any) string {
lines := make([]string, 0, len(values))
for _, value := range values {
switch typed := value.(type) {
case string:
lines = append(lines, typed)
default:
raw, _ := json.Marshal(typed)
if len(raw) > 0 {
lines = append(lines, string(raw))
}
}
}
return strings.Join(lines, "\n")
}
func valueOrUnset(value string) string {
return defaultIfEmpty(strings.TrimSpace(value), "未设置")
}
func modelName(model *safeModelToolConfig) string {
if model == nil {
return ""
}
return model.Name
}
func modelProvider(model *safeModelToolConfig) string {
if model == nil {
return ""
}
return model.Provider
}
func modelEnabled(model *safeModelToolConfig) bool {
return model != nil && model.Enabled
}
func exchangeName(exchange *safeExchangeToolConfig) string {
if exchange == nil {
return ""
}
return defaultIfEmpty(exchange.AccountName, exchange.ExchangeType)
}
func exchangeEnabled(exchange *safeExchangeToolConfig) bool {
return exchange != nil && exchange.Enabled
}
func strategyName(strategy *safeStrategyToolConfig) string {
if strategy == nil {
return ""
}
return strategy.Name
}
func (a *Agent) handleStrategyDiagnosisSkill(storeUserID, lang, text string) string {

View File

@@ -21,25 +21,6 @@ func hasExplicitCreateIntentForDomain(text, domain string) bool {
return containsAny(lower, []string{"创建", "新建", "创一个", "创个", "建一个", "create", "new"})
}
func hasExplicitDiagnosisIntentForDomain(text, domain string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" || !hasExplicitManagementDomainCue(text, domain) {
return false
}
switch strings.TrimSpace(domain) {
case "trader":
return containsAny(lower, []string{"启动失败", "不交易", "没开仓", "无法启动", "诊断", "报错", "错误", "diagnose", "not trading"})
case "strategy":
return containsAny(lower, []string{"不生效", "没生效", "失效", "不一致", "诊断", "报错", "错误", "diagnose"})
case "model":
return containsAny(lower, []string{"api key", "base url", "custom_api_url", "模型配置失败", "模型不可用", "ai unavailable", "无效", "报错", "错误", "失败", "不可用", "invalid", "error", "failed", "诊断", "diagnose"})
case "exchange":
return containsAny(lower, []string{"invalid signature", "timestamp", "ip not allowed", "permission denied", "签名错误", "签名失败", "时间戳", "白名单", "权限不足", "交易所 api 报错", "交易所连接不上", "报错", "错误", "失败", "诊断", "diagnose"})
default:
return false
}
}
func extractURL(text string) string {
return strings.TrimSpace(urlPattern.FindString(text))
}
@@ -345,14 +326,12 @@ func (a *Agent) handleStrategyManagementSkill(storeUserID string, userID int64,
}
}
// strategyCreateDraftConfigField stores the materialized, product-normalized
// draft between turns. User-visible strategy proposals should still be rendered
// from the post-merge structured config, not from free-form LLM text.
const strategyCreateDraftConfigField = "strategy_create_draft_config"
const strategyCreateConfigPatchField = "config_patch"
func applyStrategyCreateIntentToConfig(cfg *store.StrategyConfig, text, lang string) []string {
draft := applyStrategyDraftText(strategyDraft{}, text)
return applyStrategyDraftToConfig(cfg, draft)
}
func marshalStrategyCreateDraft(cfg store.StrategyConfig) string {
raw, err := json.Marshal(cfg)
if err != nil {
@@ -373,18 +352,8 @@ func unmarshalStrategyCreateDraft(raw, lang string) store.StrategyConfig {
}
func strategyCreateConfigFromSession(session skillSession, lang string) (store.StrategyConfig, map[string]any, []string, error) {
normalizeLegacyStrategyCreateSession(&session)
cfg := unmarshalStrategyCreateDraft(fieldValue(session, strategyCreateDraftConfigField), lang)
for _, key := range manualStrategyEditableFieldKeys() {
switch key {
case "name", "description", "is_public", "config_visible":
continue
}
if value := fieldValue(session, key); strings.TrimSpace(value) != "" {
if err := applyStrategyConfigPatch(&cfg, key, value); err != nil {
return cfg, nil, nil, err
}
}
}
patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField))
var patch map[string]any
if patchRaw != "" {
@@ -397,15 +366,9 @@ func strategyCreateConfigFromSession(session skillSession, lang string) (store.S
}
cfg = merged
}
if draftRaw := strings.TrimSpace(fieldValue(session, strategyCreateDraftIntentField)); draftRaw != "" {
applyStrategyDraftToConfig(&cfg, unmarshalStrategyDraft(draftRaw))
}
applyStrategyCreateTypeDefaults(&cfg)
beforeClamp := cfg
cfg.ClampLimits()
if strings.TrimSpace(cfg.StrategyType) == "" {
cfg.StrategyType = "ai_trading"
}
rawCfg, _ := json.Marshal(cfg)
var configMap map[string]any
_ = json.Unmarshal(rawCfg, &configMap)
@@ -418,11 +381,6 @@ func resolveStrategyCreateName(session *skillSession, text string) string {
return ""
}
name := strings.TrimSpace(fieldValue(*session, "name"))
if name == "" {
if draft := unmarshalStrategyDraft(fieldValue(*session, strategyCreateDraftIntentField)); strings.TrimSpace(draft.Name) != "" {
name = strings.TrimSpace(draft.Name)
}
}
if name == "" {
if inferred := inferStandaloneStrategyName(text); inferred != "" {
name = inferred
@@ -434,6 +392,78 @@ func resolveStrategyCreateName(session *skillSession, text string) string {
return name
}
func normalizeLegacyStrategyCreateSession(session *skillSession) {
if session == nil || session.Action != "create" {
return
}
strategyType := explicitStrategyCreateType(*session)
if strategyType == "" {
return
}
filterLegacyStrategyCreateFieldsForType(session, strategyType)
if patchRaw := strings.TrimSpace(fieldValue(*session, strategyCreateConfigPatchField)); patchRaw != "" {
if sanitized := sanitizeStrategyCreateConfigPatchForType(patchRaw, strategyType); len(sanitized) > 0 {
raw, _ := json.Marshal(sanitized)
setField(session, strategyCreateConfigPatchField, string(raw))
} else {
delete(session.Fields, strategyCreateConfigPatchField)
}
}
}
func filterLegacyStrategyCreateFieldsForType(session *skillSession, strategyType string) {
if session == nil || len(session.Fields) == 0 {
return
}
allowed := map[string]struct{}{}
for _, key := range []string{
"name",
"description",
"is_public",
"config_visible",
"lang",
"strategy_type",
strategyCreateDraftConfigField,
strategyCreateConfigPatchField,
skillDAGStepField,
"awaiting_final_confirmation",
} {
allowed[key] = struct{}{}
}
for key := range session.Fields {
if _, ok := allowed[key]; !ok {
delete(session.Fields, key)
}
}
}
func resetLegacyStrategyCreateSessionForType(session *skillSession, strategyType string) {
if session == nil {
return
}
keep := map[string]string{}
for _, key := range []string{"name", "description", "is_public", "config_visible", "lang"} {
if value := fieldValue(*session, key); strings.TrimSpace(value) != "" {
keep[key] = value
}
}
session.Fields = keep
setField(session, "strategy_type", strategyType)
}
func setStrategyCreateType(session *skillSession, strategyType string) {
if session == nil || strategyType == "" {
return
}
current := explicitStrategyCreateType(*session)
if current != "" && current != strategyType {
resetLegacyStrategyCreateSessionForType(session, strategyType)
return
}
setField(session, "strategy_type", strategyType)
filterLegacyStrategyCreateFieldsForType(session, strategyType)
}
func applyStrategyCreateTypeDefaults(cfg *store.StrategyConfig) {
if cfg == nil {
return
@@ -488,9 +518,22 @@ func removeLockedStrategyCreateFields(configMap map[string]any) {
return
}
risk, ok := configMap["risk_control"].(map[string]any)
if !ok {
return
if ok {
removeLockedAIRiskFields(risk)
}
if aiConfig, ok := configMap["ai_config"].(map[string]any); ok {
if risk, ok := aiConfig["risk_control"].(map[string]any); ok {
removeLockedAIRiskFields(risk)
}
}
}
func removeLockedAIRiskFields(risk map[string]any) {
delete(risk, "max_positions")
delete(risk, "btc_eth_max_position_value_ratio")
delete(risk, "btceth_max_position_value_ratio")
delete(risk, "altcoin_max_position_value_ratio")
delete(risk, "max_margin_usage")
delete(risk, "min_position_size")
}
@@ -499,7 +542,10 @@ func strategyCreateConfirmationReply(text string) bool {
if lower == "" {
return false
}
for _, exact := range []string{"确认创建", "创建吧", "就按这个创建", "按这个创建", "确认应用", "就按这个应用"} {
for _, exact := range []string{
"确认创建", "确认", "创建吧", "就按这个创建", "按这个创建", "确认应用", "就按这个应用",
"可以", "好的", "好", "没问题", "就这样", "按这个", "ok", "okay", "yes", "yep", "looks good",
} {
if lower == exact {
return true
}
@@ -522,9 +568,6 @@ func explicitStrategyCreateType(session skillSession) string {
if value := strings.TrimSpace(fieldValue(session, "strategy_type")); value != "" {
return value
}
if draft := unmarshalStrategyDraft(fieldValue(session, strategyCreateDraftIntentField)); draft.StrategyKind != "" || len(draft.Symbols) > 0 || draft.Timeframe != "" || draft.Leverage > 0 {
return "ai_trading"
}
patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField))
if patchRaw == "" {
return ""
@@ -539,6 +582,9 @@ func explicitStrategyCreateType(session skillSession) string {
if gridConfig, ok := patch["grid_config"]; ok && gridConfig != nil {
return "grid_trading"
}
if aiConfig, ok := patch["ai_config"]; ok && aiConfig != nil {
return "ai_trading"
}
return ""
}
@@ -547,33 +593,10 @@ func strategyCreateConfigReady(session skillSession, cfg store.StrategyConfig, t
if strategyType == "" {
return false, "strategy_type"
}
if strategyCreateDefaultConfigReply(text) || strategyCreateConfirmationReply(text) || strategyCreateFinalConfirmationReady(session) {
return true, ""
}
if !strategyCreateHasExplicitConfigBeyondType(session) {
return false, strategyType
}
switch strategyType {
case "grid_trading":
grid := cfg.GridConfig
if grid == nil {
return false, "grid_trading"
}
if strings.TrimSpace(grid.Symbol) == "" || grid.GridCount <= 0 || grid.TotalInvestment <= 0 || grid.Leverage <= 0 {
return false, "grid_trading"
}
if !grid.UseATRBounds && (grid.UpperPrice <= 0 || grid.LowerPrice <= 0) {
return false, "grid_trading"
if missing := strategyCreateMissingTemplateFields(session, cfg); len(missing) > 0 {
return false, strings.Join(missing, ",")
}
return true, ""
case "ai_trading":
if strings.TrimSpace(cfg.CoinSource.SourceType) == "" || strings.TrimSpace(cfg.Indicators.Klines.PrimaryTimeframe) == "" {
return false, "ai_trading"
}
return true, ""
default:
return false, "strategy_type"
}
}
func strategyCreateFinalConfirmationReady(session skillSession) bool {
@@ -590,9 +613,6 @@ func strategyCreateHasExplicitConfigBeyondType(session skillSession) bool {
return true
}
}
if draft := unmarshalStrategyDraft(fieldValue(session, strategyCreateDraftIntentField)); len(draft.Symbols) > 0 || draft.Timeframe != "" || draft.Leverage > 0 || draft.CoinSourceIntent != "" {
return true
}
patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField))
if patchRaw == "" {
return false
@@ -609,25 +629,415 @@ func strategyCreateHasExplicitConfigBeyondType(session skillSession) bool {
return false
}
func formatStrategyCreateConfigNeeded(lang, strategyType string) string {
if lang == "zh" {
switch strategyType {
case "grid_trading":
return "我先按一套安全默认网格参数整理完整草稿,不逐项问你。你可以直接改任何一项,或者确认后我创建。"
func strategyCreateMissingTemplateFields(session skillSession, cfg store.StrategyConfig) []string {
switch explicitStrategyCreateType(session) {
case "ai_trading":
return "我先按一套安全默认 AI 策略参数整理完整草稿,不逐项问你。你可以直接改任何一项,或者确认后我创建。"
return strategyCreateMissingAIFields(session, cfg)
case "grid_trading":
return strategyCreateMissingGridFields(session)
default:
return []string{"strategy_type"}
}
}
func strategyCreateMissingAIFields(session skillSession, cfg store.StrategyConfig) []string {
required := []string{
"source_type",
"primary_timeframe",
"selected_timeframes",
"btceth_max_leverage",
"altcoin_max_leverage",
"min_confidence",
"min_risk_reward_ratio",
"trading_frequency",
"entry_standards",
}
missing := make([]string, 0, len(required)+1)
for _, field := range required {
if !strategyCreateFieldExplicit(session, field) {
missing = append(missing, field)
}
}
if strings.EqualFold(strings.TrimSpace(cfg.CoinSource.SourceType), "static") && !strategyCreateFieldExplicit(session, "static_coins") {
missing = append(missing, "static_coins")
}
return missing
}
func strategyCreateMissingGridFields(session skillSession) []string {
required := []string{
"symbol",
"grid_count",
"total_investment",
"leverage",
"distribution",
"max_drawdown_pct",
"stop_loss_pct",
"daily_loss_limit_pct",
"use_maker_only",
}
missing := make([]string, 0, len(required)+1)
for _, field := range required {
if !strategyCreateFieldExplicit(session, field) {
missing = append(missing, field)
}
}
if !strategyCreateFieldExplicit(session, "use_atr_bounds") && (!strategyCreateFieldExplicit(session, "upper_price") || !strategyCreateFieldExplicit(session, "lower_price")) {
missing = append(missing, "use_atr_bounds 或 upper_price/lower_price")
}
return missing
}
func strategyCreateFieldExplicit(session skillSession, field string) bool {
field = strings.TrimSpace(field)
if field == "" {
return false
}
if strings.TrimSpace(fieldValue(session, field)) != "" {
return true
}
patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField))
if patchRaw == "" {
return false
}
var patch map[string]any
if err := json.Unmarshal([]byte(patchRaw), &patch); err != nil {
return false
}
for _, path := range strategyCreatePatchPaths(field) {
if strategyCreatePatchHasPath(patch, path...) {
return true
}
}
return false
}
func strategyCreatePatchPaths(field string) [][]string {
switch strings.TrimSpace(field) {
case "strategy_type":
return [][]string{{"strategy_type"}}
case "source_type":
return [][]string{
{"ai_config", "coin_source", "source_type"}, {"coin_source", "source_type"},
{"ai_config", "coin_source", "static_coins"}, {"coin_source", "static_coins"},
{"ai_config", "coin_source", "use_ai500"}, {"coin_source", "use_ai500"},
{"ai_config", "coin_source", "use_oi_top"}, {"coin_source", "use_oi_top"},
{"ai_config", "coin_source", "use_oi_low"}, {"coin_source", "use_oi_low"},
}
case "static_coins":
return [][]string{{"ai_config", "coin_source", "static_coins"}, {"coin_source", "static_coins"}}
case "primary_timeframe":
return [][]string{{"ai_config", "indicators", "klines", "primary_timeframe"}, {"indicators", "klines", "primary_timeframe"}}
case "selected_timeframes":
return [][]string{{"ai_config", "indicators", "klines", "selected_timeframes"}, {"indicators", "klines", "selected_timeframes"}}
case "btceth_max_leverage":
return [][]string{{"ai_config", "risk_control", "btc_eth_max_leverage"}, {"risk_control", "btc_eth_max_leverage"}, {"ai_config", "risk_control", "btceth_max_leverage"}, {"risk_control", "btceth_max_leverage"}}
case "altcoin_max_leverage":
return [][]string{{"ai_config", "risk_control", "altcoin_max_leverage"}, {"risk_control", "altcoin_max_leverage"}}
case "min_confidence":
return [][]string{{"ai_config", "risk_control", "min_confidence"}, {"risk_control", "min_confidence"}}
case "min_risk_reward_ratio":
return [][]string{{"ai_config", "risk_control", "min_risk_reward_ratio"}, {"risk_control", "min_risk_reward_ratio"}}
case "trading_frequency":
return [][]string{{"ai_config", "prompt_sections", "trading_frequency"}, {"prompt_sections", "trading_frequency"}}
case "entry_standards":
return [][]string{{"ai_config", "prompt_sections", "entry_standards"}, {"prompt_sections", "entry_standards"}}
case "symbol", "grid_count", "total_investment", "leverage", "distribution", "max_drawdown_pct", "stop_loss_pct", "daily_loss_limit_pct", "use_maker_only", "use_atr_bounds", "upper_price", "lower_price":
return [][]string{{"grid_config", field}}
default:
return [][]string{{field}}
}
}
func strategyCreatePatchHasPath(value any, path ...string) bool {
current := value
for _, part := range path {
obj, ok := current.(map[string]any)
if !ok {
return false
}
next, ok := obj[part]
if !ok {
return false
}
current = next
}
return true
}
func formatStrategyCreateConfigNeeded(lang, missingKind string) string {
if lang == "zh" {
if missingKind == "strategy_type" {
return "先选择策略类型grid_trading网格策略或 ai_tradingAI 策略)。类型确认后我会继续收集对应配置,配置好后再创建。"
}
if hints := formatStrategyMissingFieldHints(lang, missingKind); hints != "" {
return "这份策略模板还没填完整,还缺这些字段。你可以按下面选,也可以直接说“你帮我按稳健/高频/激进来推荐”:\n" + hints
}
switch strategyType {
case "grid_trading":
return "I prepared a complete safe default grid draft instead of asking field by field. You can change any field or confirm to create it."
case "ai_trading":
return "I prepared a complete safe default AI draft instead of asking field by field. You can change any field or confirm to create it."
default:
return "这份策略模板还没填完整,还缺:" + formatStrategyMissingFieldNames(lang, missingKind) + "。你可以一句话告诉我这些字段,我会继续填模板。"
}
if missingKind == "strategy_type" {
return "Choose the strategy type first: grid_trading or ai_trading. I will collect the matching config before creating it."
}
if hints := formatStrategyMissingFieldHints(lang, missingKind); hints != "" {
return "This strategy template is not complete yet. You can choose from these options, or ask me to recommend a conservative/balanced/high-frequency setup:\n" + hints
}
return "This strategy template is not complete yet. Missing: " + formatStrategyMissingFieldNames(lang, missingKind) + ". Tell me these fields in one message and I will keep filling the template."
}
func formatStrategyMissingFieldHints(lang, missingKind string) string {
parts := strings.Split(missingKind, ",")
lines := make([]string, 0, len(parts))
for _, part := range parts {
field := strings.TrimSpace(part)
if field == "" {
continue
}
hint := strategyCreateFieldInlineHint(lang, field)
if hint == "" {
hint = strategyCreateFieldDisplayName(lang, field)
}
if lang == "zh" {
lines = append(lines, "- "+hint)
} else {
lines = append(lines, "- "+hint)
}
}
return strings.Join(lines, "\n")
}
func strategyCreateFieldInlineHint(lang, field string) string {
field = strings.TrimSpace(field)
if lang != "zh" {
switch field {
case "source_type":
return "Coin source: ai500 / oi_top / oi_low / static"
case "static_coins":
return "Static coins: up to 10 symbols, e.g. BTCUSDT, ETHUSDT"
case "primary_timeframe":
return "Primary timeframe: 1m / 3m / 5m / 15m / 30m / 1h / 2h / 4h / 6h / 8h / 12h / 1d / 3d / 1w"
case "selected_timeframes":
return "Multi-timeframes: up to 4, e.g. 5m,15m,1h"
case "btceth_max_leverage", "altcoin_max_leverage":
return strategyCreateFieldDisplayName(lang, field) + ": 1-20"
case "min_confidence":
return "Minimum confidence: 50-100"
case "min_risk_reward_ratio":
return "Minimum risk/reward ratio: 1-10, step 0.5"
case "trading_frequency":
return "Trading frequency rule: free text, e.g. max 2-4 trades per day"
case "entry_standards":
return "Entry standards: free text, e.g. enter only when trend and risk/reward align"
case "symbol":
return "Symbol: BTCUSDT / ETHUSDT / SOLUSDT / BNBUSDT / XRPUSDT / DOGEUSDT"
case "grid_count":
return "Grid count: 5-50"
case "total_investment":
return "Total investment: user's capital/margin budget, minimum 100 USDT; not leveraged notional exposure"
case "leverage":
return "Grid leverage: 1-5"
case "distribution":
return "Distribution: uniform / gaussian / pyramid"
case "max_drawdown_pct":
return "Max drawdown: 5%-50%"
case "stop_loss_pct":
return "Stop loss: 1%-20%"
case "daily_loss_limit_pct":
return "Daily loss limit: 1%-30%"
case "use_maker_only":
return "Maker only: on / off"
}
return ""
}
switch field {
case "source_type":
return "选币来源AI500 / OI Top / OI Low / 静态币种(没有混合模式)"
case "static_coins":
return "静态币种:最多 10 个,例如 BTCUSDT、ETHUSDT"
case "primary_timeframe":
return "主周期1m / 3m / 5m / 15m / 30m / 1h / 2h / 4h / 6h / 8h / 12h / 1d / 3d / 1w"
case "selected_timeframes":
return "多周期时间框架:最多 4 个,例如 5m,15m,1h"
case "btceth_max_leverage":
return "BTC/ETH 最大杠杆120 倍"
case "altcoin_max_leverage":
return "山寨币最大杠杆120 倍"
case "min_confidence":
return "最低置信度50100越高越谨慎"
case "min_risk_reward_ratio":
return "最小盈亏比110步进 0.5"
case "trading_frequency":
return "交易频率规则:文本,例如“每天最多 24 笔,避免连续追单”"
case "entry_standards":
return "开仓标准:文本,例如“趋势明确、成交量配合、风险收益合理才开仓”"
case "symbol":
return "交易对BTCUSDT / ETHUSDT / SOLUSDT / BNBUSDT / XRPUSDT / DOGEUSDT"
case "grid_count":
return "网格数量550"
case "total_investment":
return "总投入:用户实际投入/保证金预算,最低 100 USDT不是杠杆后的名义仓位"
case "leverage":
return "杠杆15 倍"
case "distribution":
return "网格分布uniform均匀/ gaussian正态/ pyramid金字塔"
case "max_drawdown_pct":
return "最大回撤5%50%"
case "stop_loss_pct":
return "止损1%20%"
case "daily_loss_limit_pct":
return "日亏损限制1%30%"
case "use_maker_only":
return "只挂 Maker开启 / 关闭"
case "use_atr_bounds 或 upper_price/lower_price":
return "价格边界:开启 ATR 自动边界,或手动填写上边界/下边界"
}
return ""
}
func formatStrategyCreateFieldOptionsReply(lang, text, missingKind string) string {
if !strategyCreateAsksFieldOptions(text) {
return ""
}
field := firstStrategyMissingField(missingKind)
if field == "" {
return ""
}
if lang != "zh" {
switch field {
case "source_type":
return "Coin source options: ai500, oi_top, oi_low, or static. Pick one and I will continue filling the AI strategy template."
case "primary_timeframe", "selected_timeframes":
return "Timeframe options: 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d, 3d, 1w."
}
return "For " + strategyCreateFieldDisplayName(lang, field) + ", tell me the value you want and I will keep filling the selected strategy template."
}
switch field {
case "strategy_type":
return "策略类型只有两个:\n- AI 策略:让 AI 根据行情和策略规则判断开平仓。\n- 网格策略:在价格区间内按网格低买高卖。\n你直接回复“AI 策略”或“网格策略”就行。"
case "source_type":
return "AI 策略的选币来源有 4 个:\n- AI500从 NOFX AI500 榜单自动选币。\n- OI Top选持仓量靠前/更活跃的币。\n- OI Low选持仓量较低或变化较弱的币。\n- 静态币种:你指定固定币种,比如 BTCUSDT、ETHUSDT。\n没有混合模式。你选一个我继续填模板。"
case "primary_timeframe":
return "主周期可选1m、3m、5m、15m、30m、1h、2h、4h、6h、8h、12h、1d、3d、1w。高频一般偏 1m/3m/5m稳健一点可以用 15m/1h。"
case "selected_timeframes":
return "多周期最多选 4 个可选1m、3m、5m、15m、30m、1h、2h、4h、6h、8h、12h、1d、3d、1w。常见组合比如 5m,15m,1h。"
case "btceth_max_leverage", "altcoin_max_leverage":
return strategyCreateFieldDisplayName(lang, field) + "范围是 120 倍。数值越高风险越大。"
case "min_confidence":
return "最低置信度范围是 50100。数值越高越谨慎开单会更少。"
case "min_risk_reward_ratio":
return "最小盈亏比范围是 110步进 0.5。比如 1.5 表示预期收益至少是风险的 1.5 倍。"
case "trading_frequency":
return "交易频率规则是文本规则,例如“每天最多 24 笔,避免连续追单”。你也可以说“你帮我按高频但不过度交易来写”。"
case "entry_standards":
return "开仓标准是文本规则,例如“只在趋势明确、成交量配合、风险收益合理时开仓”。你也可以说“你帮我写一版稳健开仓标准”。"
case "symbol":
return "网格交易对可选BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT。"
case "grid_count":
return "网格数量范围是 550。数量越多越密交易更频繁数量越少每格空间更大。"
case "total_investment":
return "网格总投入是用户实际投入/保证金预算,不是杠杆后的名义仓位;最小 100 USDT按 100 USDT 步进。"
case "leverage":
return "网格杠杆范围是 15 倍。稳健一般用 1 倍。"
case "distribution":
return "网格分布可选uniform均匀、gaussian正态、pyramid金字塔。"
case "max_drawdown_pct":
return "最大回撤范围是 5%50%。"
case "stop_loss_pct":
return "止损范围是 1%20%。"
case "daily_loss_limit_pct":
return "日亏损限制范围是 1%30%。"
case "use_maker_only":
return "只挂 Maker 是开关项:开启会更偏向低手续费挂单,成交可能慢一些;关闭则更灵活。"
}
return strategyCreateFieldDisplayName(lang, field) + "是当前模板字段。你告诉我想怎么设置,我继续填模板。"
}
func strategyCreateAsksFieldOptions(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
return containsAny(lower, []string{
"有哪些", "有什么", "可选", "选项", "怎么选", "怎么填", "不知道", "不会填",
"what options", "which options", "options", "how to choose", "how should i fill",
})
}
func firstStrategyMissingField(missingKind string) string {
for _, part := range strings.Split(missingKind, ",") {
part = strings.TrimSpace(part)
if part != "" {
return part
}
}
return ""
}
func formatStrategyMissingFieldNames(lang, missingKind string) string {
parts := strings.Split(missingKind, ",")
names := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.Contains(part, "或") || strings.Contains(part, "/") {
names = append(names, part)
continue
}
names = append(names, strategyCreateFieldDisplayName(lang, part))
}
if lang == "zh" {
return strings.Join(names, "、")
}
return strings.Join(names, ", ")
}
func strategyCreateFieldDisplayName(lang, field string) string {
if lang != "zh" {
return field
}
switch strings.TrimSpace(field) {
case "source_type":
return "选币来源"
case "static_coins":
return "静态币种"
case "primary_timeframe":
return "主周期"
case "selected_timeframes":
return "多周期时间框架"
case "btceth_max_leverage":
return "BTC/ETH 最大杠杆"
case "altcoin_max_leverage":
return "山寨币最大杠杆"
case "min_confidence":
return "最低置信度"
case "min_risk_reward_ratio":
return "最小盈亏比"
case "trading_frequency":
return "交易频率规则"
case "entry_standards":
return "开仓标准"
case "symbol":
return "交易对"
case "grid_count":
return "网格数量"
case "total_investment":
return "总投入"
case "leverage":
return "杠杆"
case "distribution":
return "网格分布"
case "max_drawdown_pct":
return "最大回撤"
case "stop_loss_pct":
return "止损"
case "daily_loss_limit_pct":
return "日亏损限制"
case "use_maker_only":
return "只挂 Maker"
default:
return field
}
}
func formatStrategyCreateDraftSummary(lang, name, strategyType string, changedFields, warnings []string) string {
@@ -659,9 +1069,9 @@ func formatStrategyCreateDraftSummary(lang, name, strategyType string, changedFi
}
switch strategyType {
case "grid_trading":
lines = append(lines, "这是网格策略草稿。你可以继续补充交易对、网格数量、总投入、杠杆、价格区间和网格风控;如果想让我按默认值补齐,直接说“用默认配置创建”。")
lines = append(lines, "这是网格策略草稿。继续补充交易对、网格数量、总投入、杠杆、价格区间和网格风控;我只会按产品编辑页模板填你明确给出或明确委托我设计的字段。")
case "ai_trading":
lines = append(lines, "这是 AI 策略草稿。你可以继续补充选币来源、时间周期、风险参数和提示词方向;如果想让我按默认值补齐,直接说“用默认配置创建”。")
lines = append(lines, "这是 AI 策略草稿。继续补充选币来源、时间周期、风险参数和提示词方向;我只会按产品编辑页模板填你明确给出或明确委托我设计的字段。")
default:
lines = append(lines, "你可以继续补充策略类型和对应参数;如果现在就创建,直接回复“确认创建”。")
}
@@ -687,9 +1097,9 @@ func formatStrategyCreateDraftSummary(lang, name, strategyType string, changedFi
}
switch strategyType {
case "grid_trading":
lines = append(lines, "This is a grid strategy draft. You can keep refining symbol, grid count, total investment, leverage, price bounds, and grid risk settings, or say 'use defaults' before creating it.")
lines = append(lines, "This is a grid strategy draft. Keep refining symbol, grid count, total investment, leverage, price bounds, and grid risk settings; I will only fill fields you explicitly provide or ask me to design.")
case "ai_trading":
lines = append(lines, "This is an AI strategy draft. You can keep refining coin source, timeframes, risk settings, and prompt direction, or say 'use defaults' before creating it.")
lines = append(lines, "This is an AI strategy draft. Keep refining coin source, timeframes, risk settings, and prompt direction; I will only fill fields you explicitly provide or ask me to design.")
default:
lines = append(lines, "You can keep refining the strategy type and matching parameters, or reply 'confirm' to create it now.")
}
@@ -711,6 +1121,8 @@ func formatStrategyCreateFinalConfirmation(lang string, session skillSession, cf
}
lines = append(lines,
"- 类型:网格策略",
fmt.Sprintf("- 发布到策略市场:%t", fieldValue(session, "is_public") == "true"),
fmt.Sprintf("- 发布后配置可见:%t", fieldValue(session, "config_visible") != "false"),
fmt.Sprintf("- 交易对:%s", defaultIfEmpty(grid.Symbol, "未设置")),
fmt.Sprintf("- 网格数量:%d", grid.GridCount),
fmt.Sprintf("- 总投入:%.2f USDT", grid.TotalInvestment),
@@ -730,10 +1142,34 @@ func formatStrategyCreateFinalConfirmation(lang string, session skillSession, cf
default:
lines = append(lines,
"- 类型AI 策略",
fmt.Sprintf("- 发布到策略市场:%t", fieldValue(session, "is_public") == "true"),
fmt.Sprintf("- 发布后配置可见:%t", fieldValue(session, "config_visible") != "false"),
fmt.Sprintf("- 选币来源:%s", defaultIfEmpty(cfg.CoinSource.SourceType, "未设置")),
)
lines = append(lines, formatAICoinSourceSummaryZH(cfg)...)
lines = append(lines,
fmt.Sprintf("- 主周期:%s", defaultIfEmpty(cfg.Indicators.Klines.PrimaryTimeframe, "未设置")),
fmt.Sprintf("- K线数量%d", cfg.Indicators.Klines.PrimaryCount),
fmt.Sprintf("- 多周期:%s", defaultIfEmpty(strings.Join(cfg.Indicators.Klines.SelectedTimeframes, ","), "未设置")),
fmt.Sprintf("- 指标:%s", formatEnabledAIIndicatorsZH(cfg)),
fmt.Sprintf("- NofxOS 量化数据:%t", cfg.Indicators.EnableQuantData),
fmt.Sprintf("- OI 排行数据:%t%s / %d", cfg.Indicators.EnableOIRanking, defaultIfEmpty(cfg.Indicators.OIRankingDuration, "未设置"), cfg.Indicators.OIRankingLimit),
fmt.Sprintf("- 资金流排行数据:%t%s / %d", cfg.Indicators.EnableNetFlowRanking, defaultIfEmpty(cfg.Indicators.NetFlowRankingDuration, "未设置"), cfg.Indicators.NetFlowRankingLimit),
fmt.Sprintf("- 涨跌幅排行数据:%t%s / %d", cfg.Indicators.EnablePriceRanking, defaultIfEmpty(cfg.Indicators.PriceRankingDuration, "未设置"), cfg.Indicators.PriceRankingLimit),
fmt.Sprintf("- BTC/ETH 最大杠杆:%d倍", cfg.RiskControl.BTCETHMaxLeverage),
fmt.Sprintf("- 山寨币最大杠杆:%d倍", cfg.RiskControl.AltcoinMaxLeverage),
fmt.Sprintf("- 最小置信度:%d", cfg.RiskControl.MinConfidence),
fmt.Sprintf("- 最小盈亏比:%.2f", cfg.RiskControl.MinRiskRewardRatio),
fmt.Sprintf("- 最大持仓数System enforced%d", cfg.RiskControl.MaxPositions),
fmt.Sprintf("- BTC/ETH 单币仓位上限System enforced账户权益 %.2f 倍", cfg.RiskControl.BTCETHMaxPositionValueRatio),
fmt.Sprintf("- 山寨币单币仓位上限System enforced账户权益 %.2f 倍", cfg.RiskControl.AltcoinMaxPositionValueRatio),
fmt.Sprintf("- 最大保证金使用率System enforced%.0f%%", cfg.RiskControl.MaxMarginUsage*100),
fmt.Sprintf("- 最小开仓金额System enforced%.2f USDT", cfg.RiskControl.MinPositionSize),
fmt.Sprintf("- 角色定义:%s", compactSummaryText(cfg.PromptSections.RoleDefinition)),
fmt.Sprintf("- 交易频率规则:%s", compactSummaryText(cfg.PromptSections.TradingFrequency)),
fmt.Sprintf("- 开仓标准:%s", compactSummaryText(cfg.PromptSections.EntryStandards)),
fmt.Sprintf("- 决策流程:%s", compactSummaryText(cfg.PromptSections.DecisionProcess)),
fmt.Sprintf("- 自定义 Prompt%s", compactSummaryText(cfg.CustomPrompt)),
)
}
lines = append(lines, "确认创建的话,直接回复“确认创建”。要调整也可以直接说改哪项。")
@@ -756,6 +1192,83 @@ func formatStrategyCreateFinalConfirmation(lang string, session skillSession, cf
return strings.Join(lines, "\n")
}
func formatEnabledAIIndicatorsZH(cfg store.StrategyConfig) string {
enabled := make([]string, 0, 8)
if cfg.Indicators.EnableRawKlines {
enabled = append(enabled, "K线")
}
if cfg.Indicators.EnableVolume {
enabled = append(enabled, "成交量")
}
if cfg.Indicators.EnableOI {
enabled = append(enabled, "OI")
}
if cfg.Indicators.EnableFundingRate {
enabled = append(enabled, "资金费率")
}
if cfg.Indicators.EnableEMA {
enabled = append(enabled, "EMA")
}
if cfg.Indicators.EnableMACD {
enabled = append(enabled, "MACD")
}
if cfg.Indicators.EnableRSI {
enabled = append(enabled, "RSI")
}
if cfg.Indicators.EnableATR {
enabled = append(enabled, "ATR")
}
if cfg.Indicators.EnableBOLL {
enabled = append(enabled, "BOLL")
}
if len(enabled) == 0 {
return "无"
}
return strings.Join(enabled, ",")
}
func formatAICoinSourceSummaryZH(cfg store.StrategyConfig) []string {
lines := make([]string, 0, 4)
sourceType := strings.ToLower(strings.TrimSpace(cfg.CoinSource.SourceType))
switch sourceType {
case "static":
lines = append(lines, fmt.Sprintf("- 静态币种:%s", defaultIfEmpty(strings.Join(cfg.CoinSource.StaticCoins, ","), "未设置")))
case "ai500":
lines = append(lines, fmt.Sprintf("- AI500 数量:%d", cfg.CoinSource.AI500Limit))
case "oi_top":
lines = append(lines, fmt.Sprintf("- OI Top 数量:%d", cfg.CoinSource.OITopLimit))
case "oi_low":
lines = append(lines, fmt.Sprintf("- OI Low 数量:%d", cfg.CoinSource.OILowLimit))
default:
if cfg.CoinSource.UseAI500 {
lines = append(lines, fmt.Sprintf("- AI500 数量:%d", cfg.CoinSource.AI500Limit))
}
if cfg.CoinSource.UseOITop {
lines = append(lines, fmt.Sprintf("- OI Top 数量:%d", cfg.CoinSource.OITopLimit))
}
if cfg.CoinSource.UseOILow {
lines = append(lines, fmt.Sprintf("- OI Low 数量:%d", cfg.CoinSource.OILowLimit))
}
}
if len(cfg.CoinSource.ExcludedCoins) > 0 {
lines = append(lines, fmt.Sprintf("- 排除币种:%s", strings.Join(cfg.CoinSource.ExcludedCoins, ",")))
}
return lines
}
func compactSummaryText(value string) string {
value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
if value == "" {
return "未设置"
}
const maxLen = 120
runes := []rune(value)
if len(runes) <= maxLen {
return value
}
return string(runes[:maxLen]) + "..."
}
func createConfirmationReply(text string) bool {
return strategyCreateConfirmationReply(text)
}
@@ -1025,78 +1538,6 @@ func formatTraderCreateDraftSummary(lang string, session skillSession) string {
return strings.Join(lines, "\n")
}
func (a *Agent) continueStrategyCreateDraft(storeUserID string, userID int64, lang, text string, session skillSession) string {
name := resolveStrategyCreateName(&session, text)
if actionRequiresSlot("strategy_management", "create", "name") && strings.TrimSpace(name) == "" {
setSkillDAGStep(&session, "resolve_name")
a.saveSkillSession(userID, session)
if lang == "zh" {
return "要创建策略我还需要策略名。你可以直接说创建一个叫“趋势策略A”的策略。"
}
return "One more thing: give this strategy a name."
}
if fieldValue(session, "strategy_type") == "" {
if strategyType := parseStrategyTypeValue(text); strategyType != "" {
setField(&session, "strategy_type", strategyType)
}
}
cfg := unmarshalStrategyCreateDraft(fieldValue(session, strategyCreateDraftConfigField), lang)
changedFields := applyStrategyCreateIntentToConfig(&cfg, text, lang)
if fieldValue(session, strategyCreateDraftConfigField) == "" && len(changedFields) == 0 {
cfg = store.GetDefaultStrategyConfig(lang)
}
beforeClamp := cfg
cfg.ClampLimits()
warnings := store.StrategyClampWarnings(beforeClamp, cfg, cfg.Language)
setField(&session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg))
setSkillDAGStep(&session, "await_create_confirmation")
session.Phase = "draft_create"
if strategyCreateConfirmationReply(text) || strategyCreateFinalConfirmationReady(session) {
if ready, missingKind := strategyCreateConfigReady(session, cfg, text); !ready {
if missingKind != "strategy_type" {
setField(&session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg))
setField(&session, "awaiting_final_confirmation", "true")
a.saveSkillSession(userID, session)
return formatStrategyCreateFinalConfirmation(lang, session, cfg)
}
a.saveSkillSession(userID, session)
return formatStrategyCreateConfigNeeded(lang, missingKind)
}
args := map[string]any{
"action": "create",
"name": name,
"lang": defaultIfEmpty(lang, "zh"),
"confirmed": true,
}
rawCfg, _ := json.Marshal(cfg)
var configMap map[string]any
if err := json.Unmarshal(rawCfg, &configMap); err == nil && len(configMap) > 0 {
args["config"] = configMap
}
raw, _ := json.Marshal(args)
resp := a.toolManageStrategy(storeUserID, string(raw))
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
a.saveSkillSession(userID, session)
if lang == "zh" {
return "创建策略失败:" + errMsg
}
return "That create request did not go through: " + errMsg
}
a.clearSkillSession(userID)
a.rememberReferencesFromToolResult(userID, "manage_strategy", resp)
if lang == "zh" {
return formatCreatedStrategyReply(lang, name, cfg, warnings)
}
return formatCreatedStrategyReply(lang, name, cfg, warnings)
}
a.saveSkillSession(userID, session)
return formatStrategyCreateDraftSummary(lang, name, explicitStrategyCreateType(session), changedFields, warnings)
}
func hasExplicitStrategyDetailIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
@@ -1948,10 +2389,6 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang
return "Cancelled the current strategy creation flow."
}
name := resolveStrategyCreateName(&session, text)
hasDescriptiveDraftIntent := session.Phase == "draft_create"
if hasDescriptiveDraftIntent {
return a.continueStrategyCreateDraft(storeUserID, userID, lang, text, session)
}
if actionRequiresSlot("strategy_management", "create", "name") && name == "" {
setSkillDAGStep(&session, "resolve_name")
a.saveSkillSession(userID, session)
@@ -1962,8 +2399,10 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang
}
if fieldValue(session, "strategy_type") == "" {
if strategyType := parseStrategyTypeValue(text); strategyType != "" {
setField(&session, "strategy_type", strategyType)
setStrategyCreateType(&session, strategyType)
}
} else if strategyType := parseStrategyTypeValue(text); strategyType != "" {
setStrategyCreateType(&session, strategyType)
}
cfg, configMap, warnings, cfgErr := strategyCreateConfigFromSession(session, lang)
if cfgErr != nil {
@@ -1976,15 +2415,21 @@ func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang
if ready, missingKind := strategyCreateConfigReady(session, cfg, text); !ready {
setField(&session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg))
setSkillDAGStep(&session, "collect_config")
session.Phase = "draft_create"
if missingKind != "strategy_type" {
session.Phase = "collecting"
a.saveSkillSession(userID, session)
if reply := formatStrategyCreateFieldOptionsReply(lang, text, missingKind); reply != "" {
return reply
}
return formatStrategyCreateConfigNeeded(lang, missingKind)
}
if !strategyCreateConfirmationReply(text) && !strategyCreateFinalConfirmationReady(session) {
setField(&session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg))
setField(&session, "awaiting_final_confirmation", "true")
setSkillDAGStep(&session, "await_create_confirmation")
session.Phase = "await_create_confirmation"
a.saveSkillSession(userID, session)
return formatStrategyCreateFinalConfirmation(lang, session, cfg)
}
a.saveSkillSession(userID, session)
return formatStrategyCreateConfigNeeded(lang, missingKind)
}
setSkillDAGStep(&session, "execute_create")
args := map[string]any{
@@ -2044,7 +2489,6 @@ func formatCreatedStrategyReply(lang, name string, cfg store.StrategyConfig, war
lines = append(lines,
"- 类型AI 策略",
fmt.Sprintf("- 选币来源:%s", defaultIfEmpty(cfg.CoinSource.SourceType, "未设置")),
fmt.Sprintf("- 静态币种:%s", strings.Join(cfg.CoinSource.StaticCoins, ", ")),
fmt.Sprintf("- 主周期:%s", defaultIfEmpty(cfg.Indicators.Klines.PrimaryTimeframe, "未设置")),
fmt.Sprintf("- BTC/ETH 最大杠杆:%d倍", cfg.RiskControl.BTCETHMaxLeverage),
fmt.Sprintf("- 山寨币最大杠杆:%d倍", cfg.RiskControl.AltcoinMaxLeverage),

View File

@@ -13,6 +13,10 @@
"若候选币为空,检查 source_type/static_coins/AI500/OI 榜单/排除币/量化数据开关,不要直接归因为模型问题。",
"若 AI 一直 hold/wait先检查 min_confidence、min_risk_reward_ratio、提示词是否过于保守、行情是否满足入场条件再判断是否需要放宽策略。",
"若交易员绑定了策略但没有下单,应与 trader_diagnosis 协作区分策略无信号、风控拦截和交易所下单失败。",
"策略诊断必须区分可编辑策略字段和 System enforced 字段。AI 智能策略里的 max_positions、btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage、min_position_size 只能解释,不能建议用户修改。",
"如果不开单原因来自最小下单金额、保证金或仓位价值边界,不要建议修改 min_position_size 或 position_size_usd应建议增加账户权益、换更适合小资金的标的、调整可编辑风险偏好或让策略在资金不足时等待。",
"策略页不存在 position_size_usd 这类固定配置项position_size_usd 是 AI 每轮决策输出,不是策略模板字段。不要把 AI 决策里的 position_size_usd 说成可以在策略页手动修改的参数。",
"后台 402/404/EOF 类数据源错误只能作为策略分析质量的辅助影响,不能在决策记录已经显示明确风控/最小金额拒绝时作为主因。",
"策略模板本身不保存交易所、模型、扫描间隔或初始余额;这些问题应引导到 trader/model/exchange 相关诊断。"
]
}

View File

@@ -2,7 +2,7 @@
"name": "strategy_management",
"kind": "management",
"domain": "strategy",
"description": "当用户想创建、查看、修改、删除、激活或复制策略模板时调用。策略模板不能直接启动运行;只有绑定了该策略的交易员可以启动。",
"description": "当用户想创建、查看、修改、删除、激活或复制策略模板时调用。",
"field_constraints": {
"name": {
"type": "string",
@@ -33,8 +33,7 @@
"strategy_type": {
"type": "enum",
"values": ["ai_trading", "grid_trading"],
"default": "ai_trading",
"description": "策略类型ai_tradingAI 量化)或 grid_trading网格策略。"
"description": "策略类型:ai_tradingAI 量化)或 grid_trading网格策略。创建策略时必须先由用户选择或从用户话语明确识别不能默认成 ai_trading。"
},
"symbol": {
"type": "enum",
@@ -43,8 +42,8 @@
},
"source_type": {
"type": "enum",
"values": ["static", "ai500", "oi_top", "oi_low", "mixed"],
"description": "选币来源类型。static=用户指定静态币池ai500=AI500榜单oi_top=持仓量增长oi_low=持仓量下降mixed=混合。"
"values": ["static", "ai500", "oi_top", "oi_low"],
"description": "选币来源类型。static=用户指定静态币池ai500=AI500榜单oi_top=持仓量增长oi_low=持仓量下降。"
},
"static_coins": {
"type": "string_array",
@@ -78,11 +77,6 @@
"max": 20,
"description": "山寨币最大杠杆倍数,范围 120。"
},
"max_positions": {
"type": "int",
"min": 1,
"description": "最大同时持仓数量。策略页展示为 System enforced不是普通手动可编辑项除非用户明确要求高级配置否则不要主动修改。"
},
"min_confidence": {
"type": "int",
"min": 50,
@@ -124,7 +118,7 @@
"total_investment": {
"type": "float",
"min": 100,
"description": "网格总投入金额grid_trading 类型专用,手动页面最小 100 USDT步进 100。"
"description": "网格总投入金额grid_trading 类型专用,表示用户实际投入/保证金预算,不是杠杆后的名义仓位;名义仓位约等于 total_investment × leverage。手动页面最小 100 USDT步进 100。"
},
"leverage": {
"type": "int",
@@ -351,81 +345,28 @@
"min": 5,
"max": 20,
"description": "价格排行榜返回数量,页面选项为 5、10、15、20。"
},
"btceth_max_position_value_ratio": {
"type": "float",
"min": 0,
"description": "BTC/ETH 单仓最大仓位价值占比。策略页展示为 System enforced不是普通手动可编辑项除非用户明确要求高级配置否则不要主动修改。"
},
"altcoin_max_position_value_ratio": {
"type": "float",
"min": 0,
"description": "山寨币单仓最大仓位价值占比。策略页展示为 System enforced不是普通手动可编辑项除非用户明确要求高级配置否则不要主动修改。"
},
"max_margin_usage": {
"type": "float",
"min": 0,
"description": "最大保证金占用比例。策略页展示为 System enforced不是普通手动可编辑项除非用户明确要求高级配置否则不要主动修改。"
}
},
"validation_rules": [
"AI 策略页面包含:选币来源、指标/K线/量化数据、风险控制、Prompt Sections、自定义提示词、发布设置。",
"网格策略页面包含symbol、grid_count、total_investment、leverage、upper_price、lower_price、use_atr_bounds、atr_multiplier、distribution、max_drawdown_pct、stop_loss_pct、daily_loss_limit_pct、use_maker_only、enable_direction_adjust、direction_bias_ratio、发布设置。",
"用户询问字段有哪些选项、范围或页面能不能设置时,应直接按本 skill 的 field_constraints 回答;不要说“平台会自动匹配”或编造页面不存在的字段。",
"grid_trading 的 symbol 只能从页面下拉选项 BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT 中选择。",
"grid_trading 的 grid_count 范围为 550total_investment 最小 100leverage 范围 15atr_multiplier 范围 15。",
"grid_trading 的 max_drawdown_pct 范围为 550stop_loss_pct 范围为 120daily_loss_limit_pct 范围为 130direction_bias_ratio 范围为 0.550.90。",
"grid_trading 推荐价格区间时,不能凭记忆猜“当前 BTC/ETH/SOL 价格”。没有实时行情工具结果时,应优先推荐 use_atr_bounds=true 的 ATR 自动边界;只有本轮已经获取过实时价格,才可以给基于当前价格的 upper_price/lower_price 数值。",
"AI 策略的 static_coins 最多 10 个selected_timeframes 最多 4 个primary_count 范围为 1030。",
"排行榜 duration 只能用页面选项ranking_limit 只能用 5、10、15、20。",
"btceth_max_leverage 和 altcoin_max_leverage 范围均为 120超出时自动收敛并告知用户。",
"min_confidence 范围 50100超出时自动收敛并告知用户。",
"grid_trading 类型时lower_price 必须小于 upper_price否则提示用户修正。",
"策略模板不能直接启动运行,只有绑定了该策略的交易员才能启动。",
"策略模板创建成功后应出现在策略列表/策略页。",
"本 skill 的“页面/手动页面”表述只用于说明字段范围和 UI 约束,不表示用户必须去页面操作;创建、更新、删除、激活、复制等动作都应通过 Agent 工具在聊天中执行。",
"创建策略的确认发生在聊天里:当配置已整理好,应让用户回复“确认创建”;用户确认后必须调用创建工具,不要要求用户点击网页或 APP 里的按钮。",
"创建策略模板时不要要求用户先绑定或添加交易所账户,也不要要求绑定 AI 模型;交易所和模型只属于 trader 创建、部署或启动流程。",
"策略是模板/规则,不保存交易所 API、模型 provider、钱包余额或扫描间隔这些属于交易员、模型或交易所配置。",
"如果用户只是创建或配置策略模板,不要把它升级成 trader 创建流程。",
"如果用户说“用某个策略创建/启动交易员”,不要新建同名策略;应先查找并绑定已有策略,只有用户明确要求新策略时才创建。",
"删除操作不可逆,必须先向用户确认再执行。",
"激活activate操作将该策略设为默认模板不是启动运行。",
"scan_interval_minutes、initial_balance、lighter_api_key_index 这类交易员/交易所边界值不属于策略本身,若用户在改策略时提到,应引导去对应 trader 或 exchange 配置。",
"max_positions、btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage、min_position_size 在策略页属于 System enforced / 非普通手动编辑项若用户问页面能否改应说明页面不提供普通编辑入口。min_position_size 固定为 12 USDTAgent 不能修改。",
"启用量化数据相关开关时,若需要 nofxos_api_key应主动提醒用户补齐。",
"启用排行榜相关能力时,只修改用户明确提到的 enable_*、duration、limit 字段,不要偷偷打开其他排行榜。"
"本 skill 只负责策略模板创建、查看、修改、删除、激活和复制。",
"字段选项和范围来自 field_constraints产品行为规则由 active session prompt 负责。"
],
"actions": {
"create": {
"description": "创建策略模板。至少需要名称,其他配置可按需追问或按默认值补齐。",
"description": "创建策略模板。",
"required_slots": ["name"],
"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"],
"optional_slots": ["strategy_type", "config_patch"],
"goal": "创建一个可供 trader 绑定使用的策略模板。",
"dynamic_rules": [
"若用户只是要给 trader 绑定现有策略,应优先在父任务里补 strategy 槽位,而不是误开新的 create。",
"若用户明确要求新建策略,至少先收齐名称;如果用户尚未说明策略类型,只问一次 AI 策略还是网格策略。",
"策略类型确定后,不要一个字段一个字段追问配置;应把用户已给出的信息合并进去,剩余字段用安全默认值补齐,直接给一份完整草稿让用户确认或修改。",
"如果配置已经足够且用户在聊天里确认创建,应直接调用 Agent 的创建工具;不要告诉用户去页面点击“确认”或“创建策略”按钮。",
"当用户说“全部由你定”“你帮我决定”时,可以给出安全默认/推荐参数,但必须说清楚这些是 Agent 建议值;不要说成“你已经填好/你已经配置好”。",
"创建策略模板本身不需要交易所账户或 AI 模型;不要在 create 策略时询问用户是否已有交易所账户。",
"如果用户同时提到模型 provider、交易所账户或扫描间隔应把这些信息留给 trader 创建流程,不要写入策略配置。",
"只有当用户明确要求运行、部署、实盘、创建交易员或绑定到交易员时,才进入 trader 流程并收集交易所/模型。",
"策略模板创建成功后应出现在策略列表/策略页。",
"只有用户要运行或部署时,才继续 trader 绑定。",
"杠杆超出 120 范围时,自动收敛并告知用户。"
],
"success_output": "返回 strategy_id 和新策略摘要(名称、类型、主要配置)。",
"failure_output": "明确指出仍缺哪些核心参数,或说明需要先确认的风控收敛结果。"
},
"update": {
"description": "更新策略模板的任意可编辑字段。",
"required_slots": ["target_ref"],
"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"],
"optional_slots": ["name", "description", "is_public", "config_visible", "config_patch"],
"goal": "更新一个已有策略模板的指定配置,而不覆盖未提及字段。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
"如果用户想修改绑定的模型、交易所、扫描间隔或交易员运行状态,应转去 trader_management不要写进策略。",
"杠杆超出 120 范围时,自动收敛并告知用户。",
"grid_trading 类型时lower_price 必须小于 upper_price。"
],
@@ -458,25 +399,20 @@
},
"update_config": {
"description": "修改策略的某个具体配置参数(选币来源、指标、风控参数等)。",
"required_slots": ["target_ref", "config_field", "config_value"],
"required_slots": ["target_ref"],
"optional_slots": ["config_patch"],
"goal": "修改策略模板中的一个或一组具体配置参数。",
"dynamic_rules": [
"配置值超出手动面板边界时,应先自动收敛并明确告知用户。",
"若用户一次提到多个配置 patch可在同一轮内整体应用但要明确说明最终修改了哪些字段。",
"拒绝把 exchange_id、ai_model_id、scan_interval_minutes、initial_balance、wallet/private key 等非策略字段作为策略 config 写入。",
"当配置更新涉及 custom_prompt、role_definition、trading_frequency、entry_standards、decision_process、description、name 等文本槽位,且用户表达了“交给你”“你帮我写”“你自己设计”等委托生成意图时,严禁再次向用户索要正文。",
"此时你必须直接生成一版可用文本,写入对应 extracted 字段,并用确认式问题向用户展示:“我先为你拟了一版……,要直接按这版更新吗?”"
"配置变更统一通过 config_patch 表达,字段必须来自当前策略类型的产品模板。",
"字段选项、范围和非策略字段拦截由 active session prompt 与后端 schema 负责。"
],
"success_output": "返回 strategy_id并明确告知已修改的配置字段及其最终值。",
"failure_output": "明确指出目标策略不存在、配置字段非法,或值仍需用户澄清。"
},
"activate": {
"description": "将策略模板设为默认模板(激活)。注意:这不是启动运行,只是设为默认。",
"description": "将策略模板设为默认模板(激活)。",
"required_slots": ["target_ref"],
"goal": "将某个策略模板设为默认模板,而不是直接运行它。",
"dynamic_rules": [
"必须明确区分“激活模板”和“启动交易员运行”,不要把 activate 解释成运行。"
],
"goal": "将某个策略模板设为默认模板。",
"success_output": "返回 strategy_id并明确告知该策略已被设为默认模板。",
"failure_output": "明确指出目标策略不存在,或激活失败原因。"
},

View File

@@ -5,18 +5,29 @@
"description": "当用户反馈交易员无法启动、启动后不交易、反复报错、绑定模型或交易所缺失、运行状态异常、收益或仓位表现异常时调用。适用于交易员运行过程中的排障与原因定位。不用于创建、修改、删除、启动、停止或查询交易员这类管理操作。",
"capabilities": [
"读取交易员当前状态、账户、持仓和最近决策记录",
"读取交易员绑定的策略、模型、交易所配置摘要,并把它们纳入不开单诊断证据包",
"在用户明确指定目标交易员后,读取该交易员最近的后端日志",
"把错误日志、运行状态和绑定信息合并成适合新手理解的诊断结论"
"把完整证据合并成适合新手理解的最终原因和下一步行动"
],
"dynamic_rules": [
"当用户问“为什么报错”“为什么不交易”“为什么停了”这类问题时,优先走诊断而不是管理类 skill。",
"如果已经能唯一确定目标交易员,应优先结合 get_backend_logs、持仓、状态和决策记录一起分析而不是只看配置。",
"如果已经能唯一确定目标交易员,应一次性收集完整诊断证据包:交易员配置/运行状态、绑定策略、绑定模型、绑定交易所、账户权益/可用余额、当前持仓、get_decisions 最近决策记录、get_backend_logs 后台日志。不要只查其中一项就下结论。",
"面向普通用户的诊断回复只说最终原因和该怎么办不要输出证据包清单、工具名、后台日志片段、HTTP 状态码或工程排障过程。",
"诊断结论内部必须区分:直接原因、次要影响、待确认因素。直接原因必须来自最近决策记录、交易所下单结果、风控校验或明确运行状态;后台日志里的零散错误只能作为辅助证据。",
"证据优先级固定为:最近决策记录 > 交易员运行状态/账户/持仓 > 交易所下单结果 > 后台日志。除非最近决策记录本身显示数据获取失败或 AI 决策中断,否则不要让 backend logs 盖过决策记录。",
"交易员不下单的排查顺序固定为:是否运行中 -> 是否已到扫描间隔 -> 策略候选币/行情数据是否为空 -> 最近 AI 决策是否为 hold/wait -> 风控是否拦截 -> 交易所下单是否报错 -> 余额、杠杆、仓位模式或权限是否限制。",
"判断“不下单/不开单”的主因时,最近决策记录优先级高于零散 backend error 日志;如果最近决策显示 wait succeeded应解释为 AI 主动等待;如果最新决策 error_message 显示 opening amount too small / below minimum / must be ≥,应解释为开仓金额低于系统或交易所最小下单门槛。",
"遇到 opening amount too small、position value below minimum、must be ≥ 这类错误时,不要建议用户修改 AI 智能策略的 min_position_size 或 position_size_usd。先说明这是系统/交易所门槛或 System enforced 边界,再建议增加账户权益、换更适合小资金的交易标的、调整可编辑策略偏好,或让策略在资金不足时等待。",
"AI 智能策略里的 System enforced 字段max_positions、btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage、min_position_size只能解释不能建议用户修改如果限制来自这些字段行动建议必须落在产品实际可改项或用户账户/标的选择上。",
"不要只因为 backend logs 里出现 402、404、EOF、payment retry failed 就直接归因为数据服务、订阅到期或付款失败;这些内部异常不应在普通用户回答里出现,除非用户明确追问后台日志或技术细节。",
"402 不要直接翻译成“订阅到期”。在没有钱包余额、支付状态或服务侧确认前,不能说订阅过期;普通用户回答里也不要主动说 402。",
"如果最近决策记录显示 candidate_coins 非空、AI call completed、wait succeeded 或 open_* 决策已生成,则说明核心决策链路并非完全拿不到数据;此时不要把 402/404/EOF 说成不开单主因。",
"行动建议必须对应产品里真实存在且可修改的字段或操作。不要编造策略页不存在的 position_size_usd 参数,不要建议修改 System enforced 字段。",
"如果模型是 claw402 或 blockrun-base应单独检查钱包 USDC 余额;余额不足时应说“支付余额不足/需要充值”,不要泛化成“模型没启用”。",
"如果日志显示 AI 返回 hold/wait应解释为模型判断当前没有足够交易信号不应误判为系统没有运行。",
"如果日志显示下单失败应优先归因到交易所权限、API 凭证、仓位模式、余额、杠杆或 symbol 可交易性,而不是策略没有生效。",
"当用户表达“启动不了”“启动失败”“无法启动”“一启动就报错”“为什么启动不起来”这类启动故障时,只要目标交易员能唯一确定,就优先自动读取 get_backend_logs。",
"当日志中已经出现明确错误原因时,直接用人话解释原因和下一步,不要复述原始日志。"
"当证据中已经出现明确错误原因时,直接用人话解释最终原因和下一步,不要复述原始日志。"
],
"tool_mapping": {
"query_runtime_state": "get_trader_system_status",

View File

@@ -1,132 +1,9 @@
package agent
import (
"encoding/json"
"regexp"
"strconv"
"strings"
"nofx/store"
)
const strategyCreateDraftIntentField = "strategy_draft"
var compactCoinPairRE = regexp.MustCompile(`(?i)\b([A-Z0-9]{2,10})\s*(?:和|与|/|,||、|\+)\s*([A-Z0-9]{2,10})\b`)
type strategyDraft struct {
Name string `json:"name,omitempty"`
StrategyKind string `json:"strategy_kind,omitempty"`
CoinSourceIntent string `json:"coin_source_intent,omitempty"`
Symbols []string `json:"symbols,omitempty"`
Timeframe string `json:"timeframe,omitempty"`
Leverage int `json:"leverage,omitempty"`
}
func normalizeStrategyDraft(d strategyDraft) strategyDraft {
d.Name = strings.TrimSpace(d.Name)
d.StrategyKind = strings.TrimSpace(d.StrategyKind)
d.CoinSourceIntent = strings.TrimSpace(d.CoinSourceIntent)
d.Timeframe = strings.ToLower(strings.TrimSpace(d.Timeframe))
if d.Leverage < 0 {
d.Leverage = 0
}
if len(d.Symbols) > 0 {
normalized := make([]string, 0, len(d.Symbols))
for _, symbol := range d.Symbols {
symbol = normalizeCoinSymbol(symbol)
if symbol != "" {
normalized = append(normalized, symbol)
}
}
d.Symbols = cleanStringList(normalized)
}
if len(d.Symbols) > 0 && d.CoinSourceIntent == "" {
d.CoinSourceIntent = "static"
}
if d.CoinSourceIntent == "static" && len(d.Symbols) == 0 {
d.CoinSourceIntent = ""
}
return d
}
func marshalStrategyDraft(d strategyDraft) string {
d = normalizeStrategyDraft(d)
raw, err := json.Marshal(d)
if err != nil {
return ""
}
return string(raw)
}
func unmarshalStrategyDraft(raw string) strategyDraft {
if strings.TrimSpace(raw) == "" {
return strategyDraft{}
}
var d strategyDraft
if err := json.Unmarshal([]byte(raw), &d); err != nil {
return strategyDraft{}
}
return normalizeStrategyDraft(d)
}
func buildStrategyDraftFromActiveSession(session ActiveSkillSession) strategyDraft {
d := strategyDraft{}
if value, ok := session.CollectedFields[strategyCreateDraftIntentField]; ok {
d = unmarshalStrategyDraft(activeFieldString(value))
}
if value, ok := session.CollectedFields["name"]; ok {
d.Name = activeFieldString(value)
}
d = applyStrategyDraftText(d, session.Goal)
for i, msg := range session.LocalHistory {
if msg.Role != "user" {
continue
}
d = applyStrategyDraftText(d, msg.Content)
if d.Name == "" && i > 0 && activeHistoryMessageAsksStrategyName(session.LocalHistory[i-1].Content) {
d.Name = inferStandaloneStrategyName(msg.Content)
}
}
return normalizeStrategyDraft(d)
}
func applyStrategyDraftText(d strategyDraft, text string) strategyDraft {
text = strings.TrimSpace(text)
if text == "" {
return normalizeStrategyDraft(d)
}
lower := strings.ToLower(text)
if containsAny(lower, []string{"趋势", "trend"}) {
d.StrategyKind = "trend"
}
if d.Name == "" {
if value := extractDelimitedSegmentAfterKeywords(text, []string{"取名为", "取名叫", "命名为", "名称叫", "名字叫", "名为", "叫做", "取名", "名称", "名字是", "called"}); value != "" {
d.Name = value
}
}
if containsAny(lower, []string{"ai500"}) {
d.CoinSourceIntent = "ai500"
}
if symbols := extractStrategyDraftSymbols(text); len(symbols) > 0 {
d.Symbols = symbols
d.CoinSourceIntent = "static"
}
if timeframes := extractTimeframes(text); len(timeframes) > 0 {
d.Timeframe = timeframes[0]
}
if leverage, ok := extractLabeledInt(text, []string{"杠杆", "leverage"}); ok && leverage > 0 {
d.Leverage = leverage
} else if leverage := extractCompactLeverage(text); leverage > 0 {
d.Leverage = leverage
}
return normalizeStrategyDraft(d)
}
func activeHistoryMessageAsksStrategyName(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
return containsAny(lower, []string{"策略名", "名称", "名字", "叫什么", "name"})
}
func inferStandaloneStrategyName(text string) string {
value := strings.TrimSpace(text)
if value == "" || len([]rune(value)) > 50 {
@@ -138,105 +15,13 @@ func inferStandaloneStrategyName(text string) string {
if parseStrategyTypeValue(value) != "" {
return ""
}
if containsAny(strings.ToLower(value), []string{"创建", "grid_trading", "ai_trading"}) {
if containsAny(strings.ToLower(value), []string{"创建", "新建", "create", "grid_trading", "ai_trading"}) {
return ""
}
return value
}
func extractStrategyDraftSymbols(text string) []string {
upper := strings.ToUpper(text)
candidates := []string{
"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK",
"PEPE", "SHIB", "ARB", "OP", "SUI", "APT", "SEI", "TIA", "JUP", "WIF",
"NEAR", "ATOM", "MATIC", "INJ", "AAVE", "UNI", "LDO", "MKR", "CRV",
}
found := make([]string, 0, 4)
for _, match := range compactCoinPairRE.FindAllStringSubmatch(upper, -1) {
if len(match) >= 3 {
found = append(found, match[1], match[2])
}
}
for _, symbol := range candidates {
if strings.Contains(upper, symbol+"USDT") || strings.Contains(upper, symbol+"USD") || strings.Contains(upper, symbol) {
found = append(found, symbol)
}
}
return cleanStringList(symbolsToUSDT(found))
}
func symbolsToUSDT(symbols []string) []string {
out := make([]string, 0, len(symbols))
for _, symbol := range symbols {
symbol = normalizeCoinSymbol(symbol)
if symbol != "" {
out = append(out, symbol)
}
}
return out
}
func extractCompactLeverage(text string) int {
lower := strings.ToLower(text)
for _, marker := range []string{"x", "倍"} {
idx := strings.Index(lower, marker)
if idx <= 0 {
continue
}
prefix := lower[:idx]
matches := firstIntegerPattern.FindAllString(prefix, -1)
if len(matches) == 0 {
continue
}
value, err := strconv.Atoi(matches[len(matches)-1])
if err == nil {
return value
}
}
return 0
}
func applyStrategyDraftToConfig(cfg *store.StrategyConfig, draft strategyDraft) []string {
if cfg == nil {
return nil
}
draft = normalizeStrategyDraft(draft)
changed := make([]string, 0, 4)
if draft.StrategyKind != "" {
cfg.StrategyType = "ai_trading"
changed = append(changed, "strategy_kind")
}
switch draft.CoinSourceIntent {
case "static":
if len(draft.Symbols) > 0 {
cfg.CoinSource.SourceType = "static"
cfg.CoinSource.StaticCoins = append([]string(nil), draft.Symbols...)
cfg.CoinSource.UseAI500 = false
cfg.CoinSource.UseOITop = false
cfg.CoinSource.UseOILow = false
changed = append(changed, "symbols")
}
case "ai500":
cfg.CoinSource.SourceType = "ai500"
cfg.CoinSource.UseAI500 = true
if cfg.CoinSource.AI500Limit <= 0 {
cfg.CoinSource.AI500Limit = 3
}
changed = append(changed, "coin_source")
}
if draft.Timeframe != "" {
cfg.Indicators.Klines.PrimaryTimeframe = draft.Timeframe
if len(cfg.Indicators.Klines.SelectedTimeframes) == 0 {
cfg.Indicators.Klines.SelectedTimeframes = []string{draft.Timeframe}
} else if !containsString(cfg.Indicators.Klines.SelectedTimeframes, draft.Timeframe) {
cfg.Indicators.Klines.SelectedTimeframes = append([]string{draft.Timeframe}, cfg.Indicators.Klines.SelectedTimeframes...)
}
changed = append(changed, "timeframe")
}
if draft.Leverage > 0 {
cfg.RiskControl.BTCETHMaxLeverage = draft.Leverage
cfg.RiskControl.AltcoinMaxLeverage = draft.Leverage
changed = append(changed, "leverage")
}
return cleanStringList(changed)
func activeHistoryMessageAsksStrategyName(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
return containsAny(lower, []string{"策略名", "名称", "名字", "叫什么", "name"})
}

View File

@@ -61,9 +61,6 @@ func manualStrategyEditableFieldKeys() []string {
"price_ranking_limit",
"btceth_max_leverage",
"altcoin_max_leverage",
"btceth_max_position_value_ratio",
"altcoin_max_position_value_ratio",
"max_margin_usage",
"min_risk_reward_ratio",
"min_confidence",
"role_definition",
@@ -142,10 +139,6 @@ func manualStrategyEditableFieldKeysForType(strategyType string) []string {
"price_ranking_limit",
"btceth_max_leverage",
"altcoin_max_leverage",
"max_positions",
"btceth_max_position_value_ratio",
"altcoin_max_position_value_ratio",
"max_margin_usage",
"min_risk_reward_ratio",
"min_confidence",
"role_definition",
@@ -220,9 +213,6 @@ func agentStrategyUpdatableFieldKeys() []string {
"price_ranking_limit",
"btceth_max_leverage",
"altcoin_max_leverage",
"btceth_max_position_value_ratio",
"altcoin_max_position_value_ratio",
"max_margin_usage",
"min_risk_reward_ratio",
"min_confidence",
"role_definition",

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,7 +280,7 @@ 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."},
"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"},
@@ -289,9 +289,6 @@ func strategyConfigSchema() map[string]any {
"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"},
},
},
"indicators": map[string]any{
@@ -340,12 +337,8 @@ 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."},
},
@@ -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)
traderCfg, err := a.resolveTraderForTool(storeUserID, args.TraderID, args.TraderName)
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
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

File diff suppressed because it is too large Load Diff

View File

@@ -171,7 +171,7 @@ func TestExecuteUnifiedTurnDecisionContinueActiveDoesNotHandOffToPlanner(t *test
if !handled {
t.Fatal("expected active session continuation to be handled")
}
if !strings.Contains(answer, "配置整理好了") || !strings.Contains(answer, "BTCUSDT") || strings.Contains(answer, "交易机器人") || strings.Contains(answer, "AI模型和交易所") {
if !strings.Contains(answer, "还缺") || !strings.Contains(answer, "交易对") || strings.Contains(answer, "交易机器人") || strings.Contains(answer, "AI模型和交易所") {
t.Fatalf("expected strategy session to continue without planner/trader handoff, got: %s", answer)
}
if _, ok := a.getActiveSkillSession(userID); !ok {
@@ -220,6 +220,16 @@ func TestGuardUnsupportedAsyncPromiseBlocksFakeDiagnosisProgress(t *testing.T) {
}
}
func TestFinishTaskGuardBlocksFakeCreateProgressPromise(t *testing.T) {
reply, blocked := guardUnsupportedAsyncPromise("zh", "策略正在创建中,请稍等一会儿。创建成功后我会立刻告诉您。")
if !blocked {
t.Fatal("expected fake create progress promise to be blocked")
}
if !strings.Contains(reply, "没有后台异步任务") || !strings.Contains(reply, "实际执行") {
t.Fatalf("expected honest execution correction, got: %s", reply)
}
}
func TestBuildUnifiedTurnRouterPromptNamesContextPolicy(t *testing.T) {
a := New(nil, nil, DefaultConfig(), nil)
systemPrompt, userPrompt := a.buildUnifiedTurnRouterPrompt(42, "zh", "不是交易员,是策略")

View File

@@ -259,7 +259,7 @@ CRITICAL: Always use the "id" field for strategy_id.`,
IMPORTANT: For most use cases just POST {"name":"<name>"} — the backend fills everything in. Only include "config" when the user explicitly requests custom settings (specific coins, custom leverage, custom timeframes).
StrategyConfig fields:
coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short) | "mixed"
coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short)
coin_source.static_coins: ["BTCUSDT","ETHUSDT"] — only when source_type="static"
coin_source.use_ai500, ai500_limit: number of coins from AI500 pool (default 10)
coin_source.use_oi_top/use_oi_low, oi_top_limit/oi_low_limit: OI-based coin selection

View File

@@ -38,6 +38,7 @@ func attachPublishConfig(config *store.StrategyConfig, strategy *store.Strategy)
if config == nil || strategy == nil {
return
}
config.ClampLimits()
config.PublishConfig = &store.PublishStrategyConfig{
IsPublic: strategy.IsPublic,
ConfigVisible: strategy.ConfigVisible,
@@ -460,6 +461,7 @@ func (s *Server) handleGetActiveStrategy(c *gin.Context) {
var config store.StrategyConfig
json.Unmarshal([]byte(strategy.Config), &config)
attachPublishConfig(&config, strategy)
c.JSON(http.StatusOK, gin.H{
"id": strategy.ID,

View File

@@ -34,6 +34,8 @@ const (
// ClampLimits enforces product-level limits on strategy config to prevent token overflow.
func (c *StrategyConfig) ClampLimits() {
c.NormalizeProductSchema()
// Clamp coin source limits
if c.CoinSource.AI500Limit > MaxCandidateCoins {
c.CoinSource.AI500Limit = MaxCandidateCoins
@@ -129,6 +131,208 @@ func (c *StrategyConfig) ClampLimits() {
}
}
// NormalizeProductSchema keeps saved strategy JSON aligned with the product
// editor schema. LLMs may emit user-facing labels such as "AI500"; persistence
// must use the exact frontend/backend enum values.
func (c *StrategyConfig) NormalizeProductSchema() {
c.StrategyType = normalizeStrategyType(c.StrategyType)
c.CoinSource.SourceType = normalizeCoinSourceType(c.CoinSource.SourceType)
if c.CoinSource.SourceType == "" {
c.CoinSource.SourceType = inferCoinSourceType(c.CoinSource)
}
switch c.CoinSource.SourceType {
case "ai500":
c.CoinSource.UseAI500 = true
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = false
if c.CoinSource.AI500Limit <= 0 {
c.CoinSource.AI500Limit = 3
}
case "oi_top":
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = true
c.CoinSource.UseOILow = false
if c.CoinSource.OITopLimit <= 0 {
c.CoinSource.OITopLimit = 3
}
case "oi_low":
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = true
if c.CoinSource.OILowLimit <= 0 {
c.CoinSource.OILowLimit = 3
}
case "static":
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = false
default:
c.CoinSource.SourceType = "ai500"
c.CoinSource.UseAI500 = true
if c.CoinSource.AI500Limit <= 0 {
c.CoinSource.AI500Limit = 3
}
}
c.CoinSource.StaticCoins = normalizeSymbols(c.CoinSource.StaticCoins)
c.CoinSource.ExcludedCoins = normalizeSymbols(c.CoinSource.ExcludedCoins)
c.Indicators.Klines.PrimaryTimeframe = normalizeTimeframe(c.Indicators.Klines.PrimaryTimeframe)
c.Indicators.Klines.LongerTimeframe = normalizeTimeframe(c.Indicators.Klines.LongerTimeframe)
c.Indicators.Klines.SelectedTimeframes = normalizeTimeframes(c.Indicators.Klines.SelectedTimeframes)
if len(c.Indicators.Klines.SelectedTimeframes) > 0 {
c.Indicators.Klines.EnableMultiTimeframe = true
}
}
func normalizeStrategyType(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
switch value {
case "grid", "grid_strategy", "grid-trading", "grid trading", "grid_trading", "网格", "网格策略", "网格交易":
return "grid_trading"
case "", "ai", "ai_strategy", "ai-trading", "ai trading", "ai_trading", "ai策略", "ai 策略", "ai交易策略", "ai智能策略":
return "ai_trading"
default:
return value
}
}
func normalizeCoinSourceType(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
compact := strings.NewReplacer(" ", "", "_", "", "-", "", "数据源", "", "选币", "", "币种", "").Replace(value)
switch {
case compact == "":
return ""
case strings.Contains(compact, "ai500"):
return "ai500"
case strings.Contains(compact, "oitop") || strings.Contains(value, "oi top") || strings.Contains(value, "持仓量最高") || strings.Contains(value, "持仓量靠前"):
return "oi_top"
case strings.Contains(compact, "oilow") || strings.Contains(value, "oi low") || strings.Contains(value, "持仓量最低") || strings.Contains(value, "持仓量较低"):
return "oi_low"
case strings.Contains(value, "static") || strings.Contains(value, "固定") || strings.Contains(value, "静态"):
return "static"
default:
return value
}
}
func inferCoinSourceType(source CoinSourceConfig) string {
switch {
case len(source.StaticCoins) > 0:
return "static"
case source.UseAI500:
return "ai500"
case source.UseOITop:
return "oi_top"
case source.UseOILow:
return "oi_low"
default:
return "ai500"
}
}
func normalizeSymbols(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range splitLooseStringList(values) {
value = strings.ToUpper(strings.TrimSpace(value))
value = strings.Trim(value, ",; ")
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
func normalizeTimeframes(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range splitLooseStringList(values) {
tf := normalizeTimeframe(value)
if tf == "" || seen[tf] {
continue
}
seen[tf] = true
out = append(out, tf)
}
return out
}
func splitLooseStringList(values []string) []string {
if len(values) == 0 {
return nil
}
joined := strings.TrimSpace(strings.Join(values, ","))
if strings.HasPrefix(joined, "[") && strings.HasSuffix(joined, "]") {
var parsed []string
if err := json.Unmarshal([]byte(joined), &parsed); err == nil {
return parsed
}
}
parts := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
var parsed []string
if err := json.Unmarshal([]byte(value), &parsed); err == nil {
parts = append(parts, parsed...)
continue
}
}
value = strings.Trim(value, "[]")
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == '' || r == ';' || r == '' || r == '\n'
}) {
part = strings.Trim(strings.TrimSpace(part), "\"'")
if part != "" {
parts = append(parts, part)
}
}
}
return parts
}
func normalizeTimeframe(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.Trim(value, "\"',。 ")
if value == "" {
return ""
}
aliases := map[string]string{
"1分钟": "1m",
"3分钟": "3m",
"5分钟": "5m",
"15分钟": "15m",
"30分钟": "30m",
"1小时": "1h",
"2小时": "2h",
"4小时": "4h",
"6小时": "6h",
"8小时": "8h",
"12小时": "12h",
"1天": "1d",
"3天": "3d",
"1周": "1w",
}
if alias, ok := aliases[value]; ok {
return alias
}
allowed := map[string]bool{
"1m": true, "3m": true, "5m": true, "15m": true, "30m": true,
"1h": true, "2h": true, "4h": true, "6h": true, "8h": true, "12h": true,
"1d": true, "3d": true, "1w": true,
}
if !allowed[value] {
return ""
}
return value
}
// MergeStrategyConfig applies a partial JSON-style patch onto a full strategy config.
// Nested objects are merged recursively so omitted fields keep their previous values.
func MergeStrategyConfig(base StrategyConfig, patch map[string]any) (StrategyConfig, error) {
@@ -489,7 +693,7 @@ type PromptSectionsConfig struct {
// CoinSourceConfig coin source configuration
type CoinSourceConfig struct {
// source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed"
// source type shown in the product editor: "static" | "ai500" | "oi_top" | "oi_low"
SourceType string `json:"source_type"`
// static coin list (used when source_type = "static")
StaticCoins []string `json:"static_coins,omitempty"`
@@ -1186,16 +1390,6 @@ func (c *StrategyConfig) getEffectiveCoinCount() int {
count = c.CoinSource.OITopLimit
case "oi_low":
count = c.CoinSource.OILowLimit
case "mixed":
if c.CoinSource.UseAI500 {
count += c.CoinSource.AI500Limit
}
if c.CoinSource.UseOITop {
count += c.CoinSource.OITopLimit
}
if c.CoinSource.UseOILow {
count += c.CoinSource.OILowLimit
}
default:
count = c.CoinSource.AI500Limit
}

View File

@@ -76,3 +76,49 @@ func TestStrategyConfigUnmarshalLegacyFlatAIConfig(t *testing.T) {
t.Fatalf("did not expect legacy coin_source at top level: %s", string(normalized))
}
}
func TestStrategyConfigNormalizeProductSchemaForLLMLabels(t *testing.T) {
cfg := GetDefaultStrategyConfig("zh")
patch := map[string]any{
"strategy_type": "AI 策略",
"ai_config": map[string]any{
"coin_source": map[string]any{
"source_type": "AI500",
},
"indicators": map[string]any{
"klines": map[string]any{
"primary_timeframe": "1分钟",
"selected_timeframes": []any{`["1m"`, `"5m"`, `"15m"]`},
},
},
},
}
merged, err := MergeStrategyConfig(cfg, patch)
if err != nil {
t.Fatalf("merge strategy config: %v", err)
}
merged.ClampLimits()
if merged.StrategyType != "ai_trading" {
t.Fatalf("strategy_type = %q, want ai_trading", merged.StrategyType)
}
if merged.CoinSource.SourceType != "ai500" {
t.Fatalf("source_type = %q, want ai500", merged.CoinSource.SourceType)
}
if !merged.CoinSource.UseAI500 || merged.CoinSource.UseOITop || merged.CoinSource.UseOILow {
t.Fatalf("coin source flags not normalized: %+v", merged.CoinSource)
}
if merged.Indicators.Klines.PrimaryTimeframe != "1m" {
t.Fatalf("primary_timeframe = %q, want 1m", merged.Indicators.Klines.PrimaryTimeframe)
}
want := []string{"1m", "5m", "15m"}
if len(merged.Indicators.Klines.SelectedTimeframes) != len(want) {
t.Fatalf("selected_timeframes = %+v, want %+v", merged.Indicators.Klines.SelectedTimeframes, want)
}
for i := range want {
if merged.Indicators.Klines.SelectedTimeframes[i] != want[i] {
t.Fatalf("selected_timeframes = %+v, want %+v", merged.Indicators.Klines.SelectedTimeframes, want)
}
}
}

View File

@@ -1,4 +1,5 @@
import useSWR from 'swr'
import { useEffect } from 'react'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { ArrowUpRight, ArrowDownRight, Wallet } from 'lucide-react'
@@ -7,7 +8,7 @@ import type { Position, TraderInfo } from '../../types'
export function PositionsPanel() {
const { user, token } = useAuth()
const { data: traders } = useSWR<TraderInfo[]>(
const { data: traders, mutate: mutateTraders } = useSWR<TraderInfo[]>(
user && token ? 'agent-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
@@ -17,12 +18,21 @@ export function PositionsPanel() {
const runningTrader = traders?.find((t) => t.is_running)
const traderId = runningTrader?.trader_id
const { data: positions } = useSWR<Position[]>(
const { data: positions, mutate: mutatePositions } = useSWR<Position[]>(
traderId ? `agent-positions-${traderId}` : null,
() => api.getPositions(traderId),
{ refreshInterval: 15000, shouldRetryOnError: false }
)
useEffect(() => {
const handleRefresh = () => {
void mutateTraders()
void mutatePositions()
}
window.addEventListener('agent-config-refresh', handleRefresh)
return () => window.removeEventListener('agent-config-refresh', handleRefresh)
}, [mutatePositions, mutateTraders])
if (!user || !token) {
return (
<div

View File

@@ -1,4 +1,5 @@
import useSWR from 'swr'
import { useEffect } from 'react'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { Activity, CircleOff, Bot } from 'lucide-react'
@@ -7,12 +8,20 @@ import type { TraderInfo } from '../../types'
export function TraderStatusPanel() {
const { user, token } = useAuth()
const { data: traders } = useSWR<TraderInfo[]>(
const { data: traders, mutate } = useSWR<TraderInfo[]>(
user && token ? 'agent-sidebar-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
)
useEffect(() => {
const handleRefresh = () => {
void mutate()
}
window.addEventListener('agent-config-refresh', handleRefresh)
return () => window.removeEventListener('agent-config-refresh', handleRefresh)
}, [mutate])
if (!user || !token) {
return (
<div

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react'
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap } from 'lucide-react'
import type { CoinSourceConfig } from '../../types'
import { coinSource, ts } from '../../i18n/strategy-translations'
import { NofxSelect } from '../ui/select'
@@ -27,31 +27,6 @@ export function CoinSourceEditor({
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
] as const
// Calculate mixed mode summary
const getMixedSummary = () => {
const sources: string[] = []
let totalLimit = 0
if (config.use_ai500) {
sources.push(`AI500(${config.ai500_limit || 3})`)
totalLimit += config.ai500_limit || 3
}
if (config.use_oi_top) {
sources.push(`${ts(coinSource.oiIncreaseShort, language)}(${config.oi_top_limit || 3})`)
totalLimit += config.oi_top_limit || 3
}
if (config.use_oi_low) {
sources.push(`${ts(coinSource.oiDecreaseShort, language)}(${config.oi_low_limit || 3})`)
totalLimit += config.oi_low_limit || 3
}
if ((config.static_coins || []).length > 0) {
sources.push(`${ts(coinSource.custom, language)}(${config.static_coins?.length || 0})`)
totalLimit += config.static_coins?.length || 0
}
return { sources, totalLimit }
}
// xyz dex assets (stocks, forex, commodities) - should NOT get USDT suffix
const xyzDexAssets = new Set([
// Stocks
@@ -453,228 +428,6 @@ export function CoinSourceEditor({
</div>
</div>
)}
{/* Mixed Mode - Unified Card Selector */}
{config.source_type === 'mixed' && (
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
<div className="flex items-center gap-2 mb-4">
<Shuffle className="w-4 h-4 text-blue-400" />
<span className="text-sm font-medium text-nofx-text">
{ts(coinSource.mixedConfig, language)}
</span>
</div>
{/* 4 Source Cards in 2x2 Grid */}
<div className="grid grid-cols-2 gap-3 mb-4">
{/* AI500 Card */}
<div
className={`p-3 rounded-lg border transition-all cursor-pointer ${
config.use_ai500
? 'bg-nofx-gold/10 border-nofx-gold/50'
: 'bg-nofx-bg border-nofx-border hover:border-nofx-gold/30'
}`}
onClick={() => !disabled && onChange({ ...config, use_ai500: !config.use_ai500 })}
>
<div className="flex items-center gap-2 mb-2">
<input
type="checkbox"
checked={config.use_ai500}
onChange={(e) => !disabled && onChange({ ...config, use_ai500: e.target.checked })}
disabled={disabled}
className="w-4 h-4 rounded accent-nofx-gold"
onClick={(e) => e.stopPropagation()}
/>
<Database className="w-4 h-4 text-nofx-gold" />
<span className="text-sm font-medium text-nofx-text">AI500</span>
<NofxOSBadge />
</div>
{config.use_ai500 && (
<div className="flex items-center gap-2 mt-2 pl-6">
<span className="text-xs text-nofx-text-muted">Limit:</span>
<NofxSelect
value={config.ai500_limit || 3}
onChange={(val) => !disabled && onChange({ ...config, ai500_limit: parseInt(val) || 3 })}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))}
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
</div>
)}
</div>
{/* OI Top Card */}
<div
className={`p-3 rounded-lg border transition-all cursor-pointer ${
config.use_oi_top
? 'bg-nofx-success/10 border-nofx-success/50'
: 'bg-nofx-bg border-nofx-border hover:border-nofx-success/30'
}`}
onClick={() => !disabled && onChange({ ...config, use_oi_top: !config.use_oi_top })}
>
<div className="flex items-center gap-2 mb-2">
<input
type="checkbox"
checked={config.use_oi_top}
onChange={(e) => !disabled && onChange({ ...config, use_oi_top: e.target.checked })}
disabled={disabled}
className="w-4 h-4 rounded accent-nofx-success"
onClick={(e) => e.stopPropagation()}
/>
<TrendingUp className="w-4 h-4 text-nofx-success" />
<span className="text-sm font-medium text-nofx-text">
{ts(coinSource.oiIncreaseLabel, language)}
</span>
</div>
<p className="text-xs text-nofx-text-muted pl-6 mb-1">
{ts(coinSource.forLong, language)}
</p>
{config.use_oi_top && (
<div className="flex items-center gap-2 mt-2 pl-6">
<span className="text-xs text-nofx-text-muted">Limit:</span>
<NofxSelect
value={config.oi_top_limit || 3}
onChange={(val) => !disabled && onChange({ ...config, oi_top_limit: parseInt(val) || 3 })}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))}
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
</div>
)}
</div>
{/* OI Low Card */}
<div
className={`p-3 rounded-lg border transition-all cursor-pointer ${
config.use_oi_low
? 'bg-nofx-danger/10 border-nofx-danger/50'
: 'bg-nofx-bg border-nofx-border hover:border-nofx-danger/30'
}`}
onClick={() => !disabled && onChange({ ...config, use_oi_low: !config.use_oi_low })}
>
<div className="flex items-center gap-2 mb-2">
<input
type="checkbox"
checked={config.use_oi_low}
onChange={(e) => !disabled && onChange({ ...config, use_oi_low: e.target.checked })}
disabled={disabled}
className="w-4 h-4 rounded accent-red-500"
onClick={(e) => e.stopPropagation()}
/>
<TrendingDown className="w-4 h-4 text-nofx-danger" />
<span className="text-sm font-medium text-nofx-text">
{ts(coinSource.oiDecreaseLabel, language)}
</span>
</div>
<p className="text-xs text-nofx-text-muted pl-6 mb-1">
{ts(coinSource.forShort, language)}
</p>
{config.use_oi_low && (
<div className="flex items-center gap-2 mt-2 pl-6">
<span className="text-xs text-nofx-text-muted">Limit:</span>
<NofxSelect
value={config.oi_low_limit || 3}
onChange={(val) => !disabled && onChange({ ...config, oi_low_limit: parseInt(val) || 3 })}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))}
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
</div>
)}
</div>
{/* Static/Custom Card */}
<div
className={`p-3 rounded-lg border transition-all cursor-pointer ${
(config.static_coins || []).length > 0
? 'bg-gray-500/10 border-gray-500/50'
: 'bg-nofx-bg border-nofx-border hover:border-gray-500/30'
}`}
>
<div className="flex items-center gap-2 mb-2">
<List className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium text-nofx-text">
{ts(coinSource.custom, language)}
</span>
{(config.static_coins || []).length > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-500/20 text-gray-400">
{config.static_coins?.length}
</span>
)}
</div>
<div className="flex flex-wrap gap-1 mt-2">
{(config.static_coins || []).slice(0, 3).map((coin) => (
<span
key={coin}
className="flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-nofx-bg-lighter text-nofx-text"
>
{coin}
{!disabled && (
<button
onClick={(e) => {
e.stopPropagation()
handleRemoveCoin(coin)
}}
className="hover:text-red-400 transition-colors"
>
<X className="w-2.5 h-2.5" />
</button>
)}
</span>
))}
{(config.static_coins || []).length > 3 && (
<span className="text-xs text-nofx-text-muted">
+{(config.static_coins?.length || 0) - 3}
</span>
)}
</div>
{!disabled && (
<div className="flex gap-1 mt-2">
<input
type="text"
value={newCoin}
onChange={(e) => setNewCoin(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') handleAddCoin()
}}
onClick={(e) => e.stopPropagation()}
placeholder="BTC, ETH..."
className="flex-1 px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
/>
<button
onClick={(e) => {
e.stopPropagation()
handleAddCoin()
}}
className="px-2 py-1 rounded text-xs bg-nofx-gold text-black hover:bg-yellow-500"
>
<Plus className="w-3 h-3" />
</button>
</div>
)}
</div>
</div>
{/* Summary */}
{(() => {
const { sources, totalLimit } = getMixedSummary()
if (sources.length === 0) return null
return (
<div className="p-2 rounded bg-nofx-bg border border-nofx-border">
<div className="flex items-center justify-between text-xs">
<span className="text-nofx-text-muted">{ts(coinSource.mixedSummary, language)}:</span>
<span className="text-nofx-text font-medium">
{sources.join(' + ')}
</span>
</div>
<div className="text-xs text-nofx-text-muted mt-1">
{ts(coinSource.maxCoins, language)} {totalLimit} {ts(coinSource.coins, language)}
</div>
</div>
)
})()}
</div>
)}
</div>
)
}

View File

@@ -338,7 +338,8 @@ export function TraderConfigModal({
<div>
{t('coinSource', language)}: {aiConfig.coin_source.source_type === 'static' ? '固定币种' :
aiConfig.coin_source.source_type === 'ai500' ? 'AI500' :
aiConfig.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
aiConfig.coin_source.source_type === 'oi_top' ? 'OI Top' :
aiConfig.coin_source.source_type === 'oi_low' ? 'OI Low' : '-'}
</div>
<div>
{t('marginLimit', language)}: {((aiConfig.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%

View File

@@ -12,7 +12,6 @@ export const coinSource = {
ai500: { zh: 'AI500 数据源', en: 'AI500 Data Provider', es: 'Proveedor AI500' },
oi_top: { zh: 'OI 持仓增加', en: 'OI Increase', es: 'Aumento OI' },
oi_low: { zh: 'OI 持仓减少', en: 'OI Decrease', es: 'Disminución OI' },
mixed: { zh: '混合模式', en: 'Mixed Mode', es: 'Modo Mixto' },
staticCoins: { zh: '自定义币种', en: 'Custom Coins', es: 'Monedas Personalizadas' },
addCoin: { zh: '添加币种', en: 'Add Coin', es: 'Agregar Moneda' },
useAI500: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider', es: 'Habilitar AI500' },
@@ -22,8 +21,6 @@ export const coinSource = {
useOILow: { zh: '启用 OI 持仓减少榜', en: 'Enable OI Decrease', es: 'Habilitar Disminución OI' },
oiLowLimit: { zh: '数量上限', en: 'Limit', es: 'Límite' },
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins', es: 'Especificar monedas manualmente' },
mixedConfig: { zh: '组合数据源配置', en: 'Combined Sources Configuration', es: 'Configuración Combinada' },
mixedSummary: { zh: '已选组合', en: 'Selected Sources', es: 'Fuentes Seleccionadas' },
maxCoins: { zh: '最多', en: 'Up to', es: 'Hasta' },
coins: { zh: '个币种', en: 'coins', es: 'monedas' },
dataSourceConfig: { zh: '数据源配置', en: 'Data Source Configuration', es: 'Configuración de Fuente' },
@@ -34,7 +31,6 @@ export const coinSource = {
ai500Desc: { zh: '使用 AI500 智能筛选的热门币种', en: 'Use AI500 smart-filtered popular coins', es: 'Monedas filtradas por AI500' },
oi_topDesc: { zh: '持仓增加榜,适合做多', en: 'OI increase ranking, for long', es: 'Ranking OI creciente, para largo' },
oi_lowDesc: { zh: '持仓减少榜,适合做空', en: 'OI decrease ranking, for short', es: 'Ranking OI decreciente, para corto' },
mixedDesc: { zh: '组合多种数据源', en: 'Combine multiple sources', es: 'Combinar fuentes múltiples' },
oiIncreaseShort: { zh: 'OI增', en: 'OI↑', es: 'OI↑' },
oiDecreaseShort: { zh: 'OI减', en: 'OI↓', es: 'OI↓' },
custom: { zh: '自定义', en: 'Custom', es: 'Personalizado' },

View File

@@ -105,7 +105,7 @@ export interface GridStrategyConfig {
}
export interface CoinSourceConfig {
source_type: 'static' | 'ai500' | 'oi_top' | 'oi_low' | 'mixed';
source_type: 'static' | 'ai500' | 'oi_top' | 'oi_low';
static_coins?: string[];
excluded_coins?: string[]; // 排除的币种列表
use_ai500: boolean;