mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
2639 lines
100 KiB
Go
2639 lines
100 KiB
Go
package agent
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"nofx/store"
|
||
)
|
||
|
||
var urlPattern = regexp.MustCompile(`https://[^\s"'<>]+`)
|
||
|
||
func hasExplicitCreateIntentForDomain(text, domain string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" || !hasExplicitManagementDomainCue(text, domain) {
|
||
return false
|
||
}
|
||
return containsAny(lower, []string{"创建", "新建", "创一个", "创个", "建一个", "create", "new"})
|
||
}
|
||
|
||
func extractURL(text string) string {
|
||
return strings.TrimSpace(urlPattern.FindString(text))
|
||
}
|
||
|
||
func setField(session *skillSession, key, value string) {
|
||
ensureSkillFields(session)
|
||
key = normalizeFieldKey(session, key)
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return
|
||
}
|
||
if session != nil && session.Name == "trader_management" && key == "name" {
|
||
value = normalizeTraderDraftName(value)
|
||
if value == "" {
|
||
return
|
||
}
|
||
}
|
||
session.Fields[key] = value
|
||
syncTraderCreateSlotMirror(session)
|
||
}
|
||
|
||
func fieldValue(session skillSession, key string) string {
|
||
key = normalizeFieldKey(&session, key)
|
||
if session.Fields != nil {
|
||
if value := strings.TrimSpace(session.Fields[key]); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
if session.Name == "trader_management" && session.Slots != nil {
|
||
switch key {
|
||
case "name":
|
||
return strings.TrimSpace(session.Slots.Name)
|
||
case "exchange_id":
|
||
return strings.TrimSpace(session.Slots.ExchangeID)
|
||
case "exchange_name":
|
||
return strings.TrimSpace(session.Slots.ExchangeName)
|
||
case "model_id":
|
||
return strings.TrimSpace(session.Slots.ModelID)
|
||
case "model_name":
|
||
return strings.TrimSpace(session.Slots.ModelName)
|
||
case "strategy_id":
|
||
return strings.TrimSpace(session.Slots.StrategyID)
|
||
case "strategy_name":
|
||
return strings.TrimSpace(session.Slots.StrategyName)
|
||
case "auto_start":
|
||
if session.Slots.AutoStart != nil {
|
||
if *session.Slots.AutoStart {
|
||
return "true"
|
||
}
|
||
return "false"
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func normalizeFieldKey(session *skillSession, key string) string {
|
||
key = strings.TrimSpace(key)
|
||
if session == nil || session.Name != "trader_management" {
|
||
return key
|
||
}
|
||
switch key {
|
||
case "ai_model_id":
|
||
return "model_id"
|
||
default:
|
||
return key
|
||
}
|
||
}
|
||
|
||
func syncTraderCreateSlotMirror(session *skillSession) {
|
||
if session == nil || session.Name != "trader_management" {
|
||
return
|
||
}
|
||
if session.Slots == nil {
|
||
session.Slots = &createTraderSkillSlots{}
|
||
}
|
||
if session.Fields == nil {
|
||
return
|
||
}
|
||
if value := strings.TrimSpace(session.Fields["name"]); value != "" {
|
||
session.Slots.Name = value
|
||
}
|
||
if value := strings.TrimSpace(session.Fields["exchange_id"]); value != "" {
|
||
session.Slots.ExchangeID = value
|
||
}
|
||
if value := strings.TrimSpace(session.Fields["exchange_name"]); value != "" {
|
||
session.Slots.ExchangeName = value
|
||
}
|
||
if value := strings.TrimSpace(session.Fields["model_id"]); value != "" {
|
||
session.Slots.ModelID = value
|
||
}
|
||
if value := strings.TrimSpace(session.Fields["model_name"]); value != "" {
|
||
session.Slots.ModelName = value
|
||
}
|
||
if value := strings.TrimSpace(session.Fields["strategy_id"]); value != "" {
|
||
session.Slots.StrategyID = value
|
||
}
|
||
if value := strings.TrimSpace(session.Fields["strategy_name"]); value != "" {
|
||
session.Slots.StrategyName = value
|
||
}
|
||
if value := strings.TrimSpace(session.Fields["auto_start"]); value != "" {
|
||
b := strings.EqualFold(value, "true")
|
||
session.Slots.AutoStart = &b
|
||
}
|
||
}
|
||
|
||
func textMeansAllTargets(text string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
return containsAny(lower, []string{
|
||
"全部", "所有", "全都", "全部策略", "所有策略", "全部删除", "全部删掉", "全部删了",
|
||
"全删", "全删了", "都删", "都删了", "全清", "全清掉",
|
||
"all", "all strategies", "every strategy",
|
||
})
|
||
}
|
||
|
||
func supportsBulkTargetSelection(skillName, action string) bool {
|
||
switch skillName {
|
||
case "strategy_management", "trader_management":
|
||
return action == "delete"
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func resolveTargetFromText(text string, options []traderSkillOption, existing *EntityReference) *EntityReference {
|
||
return resolveTargetSelection(text, options, existing).Ref
|
||
}
|
||
|
||
func hasStrictOptionMention(text string, options []traderSkillOption) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
for _, option := range options {
|
||
name := strings.ToLower(strings.TrimSpace(option.Name))
|
||
if name != "" && strings.Contains(lower, name) {
|
||
return true
|
||
}
|
||
id := strings.ToLower(strings.TrimSpace(option.ID))
|
||
if id != "" && strings.Contains(lower, id) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isSimpleEntityMutationAction(action string) bool {
|
||
switch strings.TrimSpace(action) {
|
||
case "update", "update_name", "update_status", "update_endpoint", "update_bindings",
|
||
"configure_strategy", "configure_exchange", "configure_model",
|
||
"update_prompt", "update_config", "activate", "duplicate":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func hasExplicitManagementDomainCue(text, domain string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
switch strings.TrimSpace(domain) {
|
||
case "trader":
|
||
return containsAny(lower, []string{"交易员", "trader", "agent"})
|
||
case "exchange":
|
||
return containsAny(lower, []string{"交易所", "exchange", "okx", "binance", "bybit", "gate", "kucoin", "hyperliquid"})
|
||
case "model":
|
||
return containsAny(lower, []string{"模型", "model"})
|
||
case "strategy":
|
||
return containsAny(lower, []string{"策略", "strategy"})
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func ensureLiveTargetReference(session *skillSession, options []traderSkillOption) bool {
|
||
if session == nil || session.TargetRef == nil {
|
||
return true
|
||
}
|
||
var match *traderSkillOption
|
||
if id := strings.TrimSpace(session.TargetRef.ID); id != "" {
|
||
match = findOptionByIDOrName(options, id)
|
||
}
|
||
if match == nil {
|
||
if name := strings.TrimSpace(session.TargetRef.Name); name != "" {
|
||
match = findOptionByIDOrName(options, name)
|
||
if match == nil {
|
||
match = findUniqueContainingOption(options, name)
|
||
}
|
||
}
|
||
}
|
||
if match == nil {
|
||
session.TargetRef = nil
|
||
return false
|
||
}
|
||
session.TargetRef.ID = match.ID
|
||
session.TargetRef.Name = defaultIfEmpty(match.Name, session.TargetRef.Name)
|
||
return true
|
||
}
|
||
|
||
func (a *Agent) buildSimpleEntityConversationResources(storeUserID string, session skillSession, options []traderSkillOption) map[string]any {
|
||
missing := missingFieldKeysForSkillSession(session)
|
||
resources := map[string]any{}
|
||
for _, field := range missing {
|
||
switch strings.TrimSpace(field) {
|
||
case "target_ref":
|
||
if len(options) > 0 {
|
||
resources["targets"] = options
|
||
}
|
||
case "exchange_name", "exchange_id", "exchange":
|
||
resources["exchanges"] = a.loadExchangeOptions(storeUserID)
|
||
case "model_name", "model_id", "ai_model_id", "model":
|
||
resources["models"] = a.loadEnabledModelOptions(storeUserID)
|
||
case "strategy_name", "strategy_id", "strategy":
|
||
resources["strategies"] = a.loadStrategyOptions(storeUserID)
|
||
}
|
||
}
|
||
return resources
|
||
}
|
||
|
||
func (a *Agent) handleTraderManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
|
||
if session.Name != "trader_management" || session.Action == "" {
|
||
return "", false
|
||
}
|
||
action := session.Action
|
||
if action == "query_running" {
|
||
answer := formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID))
|
||
return applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only"), true
|
||
}
|
||
if action == "query_detail" {
|
||
if detail, ok := a.describeTrader(storeUserID, lang, session.TargetRef); ok {
|
||
return detail, true
|
||
}
|
||
return formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID)), true
|
||
}
|
||
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "trader_management", action, a.loadTraderOptions(storeUserID))
|
||
}
|
||
|
||
func (a *Agent) handleExchangeManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
|
||
if session.Name != "exchange_management" || session.Action == "" {
|
||
return "", false
|
||
}
|
||
action := session.Action
|
||
options := a.loadExchangeOptions(storeUserID)
|
||
switch action {
|
||
case "query_list":
|
||
return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)), true
|
||
case "query_detail":
|
||
if detail, ok := a.describeExchange(storeUserID, lang, session.TargetRef); ok {
|
||
return detail, true
|
||
}
|
||
return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)), true
|
||
case "create":
|
||
return a.handleExchangeCreateSkill(storeUserID, userID, lang, text, session), true
|
||
default:
|
||
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "exchange_management", action, options)
|
||
}
|
||
}
|
||
|
||
func (a *Agent) handleModelManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
|
||
if session.Name != "model_management" || session.Action == "" {
|
||
return "", false
|
||
}
|
||
action := session.Action
|
||
options := a.loadEnabledModelOptions(storeUserID)
|
||
switch action {
|
||
case "query_list":
|
||
return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)), true
|
||
case "query_detail":
|
||
if detail, ok := a.describeModel(storeUserID, lang, session.TargetRef); ok {
|
||
return detail, true
|
||
}
|
||
return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)), true
|
||
case "create":
|
||
return a.handleModelCreateSkill(storeUserID, userID, lang, text, session), true
|
||
default:
|
||
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "model_management", action, options)
|
||
}
|
||
}
|
||
|
||
func (a *Agent) handleStrategyManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
|
||
if session.Name != "strategy_management" || session.Action == "" {
|
||
return "", false
|
||
}
|
||
action := session.Action
|
||
options := a.loadStrategyOptions(storeUserID)
|
||
switch action {
|
||
case "query_detail":
|
||
if detail, ok := a.describeStrategy(storeUserID, lang, session.TargetRef); ok {
|
||
return detail, true
|
||
}
|
||
return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)), true
|
||
case "query_list":
|
||
return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)), true
|
||
case "create":
|
||
return a.handleStrategyCreateSkill(storeUserID, userID, lang, text, session), true
|
||
default:
|
||
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "strategy_management", action, options)
|
||
}
|
||
}
|
||
|
||
// 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 marshalStrategyCreateDraft(cfg store.StrategyConfig) string {
|
||
raw, err := json.Marshal(cfg)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return string(raw)
|
||
}
|
||
|
||
func unmarshalStrategyCreateDraft(raw, lang string) store.StrategyConfig {
|
||
cfg := store.GetDefaultStrategyConfig(lang)
|
||
if strings.TrimSpace(raw) == "" {
|
||
return cfg
|
||
}
|
||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||
return store.GetDefaultStrategyConfig(lang)
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
func strategyCreateConfigFromSession(session skillSession, lang string) (store.StrategyConfig, map[string]any, []string, error) {
|
||
normalizeLegacyStrategyCreateSession(&session)
|
||
cfg := unmarshalStrategyCreateDraft(fieldValue(session, strategyCreateDraftConfigField), lang)
|
||
patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField))
|
||
var patch map[string]any
|
||
if patchRaw != "" {
|
||
if err := json.Unmarshal([]byte(patchRaw), &patch); err != nil {
|
||
return cfg, nil, nil, fmt.Errorf("策略配置 patch 不是合法 JSON:%w", err)
|
||
}
|
||
merged, err := store.MergeStrategyConfig(cfg, patch)
|
||
if err != nil {
|
||
return cfg, nil, nil, fmt.Errorf("策略配置 patch 无法应用:%w", err)
|
||
}
|
||
cfg = merged
|
||
}
|
||
applyStrategyCreateTypeDefaults(&cfg)
|
||
beforeClamp := cfg
|
||
cfg.ClampLimits()
|
||
rawCfg, _ := json.Marshal(cfg)
|
||
var configMap map[string]any
|
||
_ = json.Unmarshal(rawCfg, &configMap)
|
||
removeLockedStrategyCreateFields(configMap)
|
||
return cfg, configMap, store.StrategyClampWarnings(beforeClamp, cfg, cfg.Language), nil
|
||
}
|
||
|
||
func resolveStrategyCreateName(session *skillSession, text string) string {
|
||
if session == nil {
|
||
return ""
|
||
}
|
||
name := strings.TrimSpace(fieldValue(*session, "name"))
|
||
if name == "" {
|
||
if inferred := inferStandaloneStrategyName(text); inferred != "" {
|
||
name = inferred
|
||
}
|
||
}
|
||
if name != "" {
|
||
setField(session, "name", name)
|
||
}
|
||
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
|
||
}
|
||
switch strings.TrimSpace(cfg.StrategyType) {
|
||
case "grid_trading":
|
||
defaultGrid := store.DefaultGridStrategyConfig()
|
||
if cfg.GridConfig == nil {
|
||
cfg.GridConfig = &defaultGrid
|
||
return
|
||
}
|
||
if strings.TrimSpace(cfg.GridConfig.Symbol) == "" {
|
||
cfg.GridConfig.Symbol = defaultGrid.Symbol
|
||
}
|
||
if cfg.GridConfig.GridCount <= 0 {
|
||
cfg.GridConfig.GridCount = defaultGrid.GridCount
|
||
}
|
||
if cfg.GridConfig.TotalInvestment <= 0 {
|
||
cfg.GridConfig.TotalInvestment = defaultGrid.TotalInvestment
|
||
}
|
||
if cfg.GridConfig.Leverage <= 0 {
|
||
cfg.GridConfig.Leverage = defaultGrid.Leverage
|
||
}
|
||
if cfg.GridConfig.ATRMultiplier <= 0 {
|
||
cfg.GridConfig.ATRMultiplier = defaultGrid.ATRMultiplier
|
||
}
|
||
if strings.TrimSpace(cfg.GridConfig.Distribution) == "" {
|
||
cfg.GridConfig.Distribution = defaultGrid.Distribution
|
||
}
|
||
if cfg.GridConfig.MaxDrawdownPct <= 0 {
|
||
cfg.GridConfig.MaxDrawdownPct = defaultGrid.MaxDrawdownPct
|
||
}
|
||
if cfg.GridConfig.StopLossPct <= 0 {
|
||
cfg.GridConfig.StopLossPct = defaultGrid.StopLossPct
|
||
}
|
||
if cfg.GridConfig.DailyLossLimitPct <= 0 {
|
||
cfg.GridConfig.DailyLossLimitPct = defaultGrid.DailyLossLimitPct
|
||
}
|
||
if cfg.GridConfig.DirectionBiasRatio <= 0 {
|
||
cfg.GridConfig.DirectionBiasRatio = defaultGrid.DirectionBiasRatio
|
||
}
|
||
if cfg.GridConfig.UpperPrice <= 0 && cfg.GridConfig.LowerPrice <= 0 {
|
||
cfg.GridConfig.UseATRBounds = true
|
||
}
|
||
case "":
|
||
cfg.StrategyType = "ai_trading"
|
||
}
|
||
}
|
||
|
||
func removeLockedStrategyCreateFields(configMap map[string]any) {
|
||
if configMap == nil {
|
||
return
|
||
}
|
||
risk, ok := configMap["risk_control"].(map[string]any)
|
||
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")
|
||
}
|
||
|
||
func strategyCreateConfirmationReply(text string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
for _, exact := range []string{
|
||
"确认创建", "确认", "创建吧", "就按这个创建", "按这个创建", "确认应用", "就按这个应用",
|
||
"可以", "好的", "好", "没问题", "就这样", "按这个", "ok", "okay", "yes", "yep", "looks good",
|
||
} {
|
||
if lower == exact {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func strategyCreateDefaultConfigReply(text string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
return containsAny(lower, []string{
|
||
"默认", "先创建", "直接创建", "不用配置", "其他默认", "用默认", "按默认", "默认配置",
|
||
"use default", "use defaults", "default config", "create now", "create directly",
|
||
})
|
||
}
|
||
|
||
func explicitStrategyCreateType(session skillSession) string {
|
||
if value := strings.TrimSpace(fieldValue(session, "strategy_type")); value != "" {
|
||
return value
|
||
}
|
||
patchRaw := strings.TrimSpace(fieldValue(session, strategyCreateConfigPatchField))
|
||
if patchRaw == "" {
|
||
return ""
|
||
}
|
||
var patch map[string]any
|
||
if err := json.Unmarshal([]byte(patchRaw), &patch); err != nil {
|
||
return ""
|
||
}
|
||
if value, ok := patch["strategy_type"].(string); ok {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
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 ""
|
||
}
|
||
|
||
func strategyCreateConfigReady(session skillSession, cfg store.StrategyConfig, text string) (bool, string) {
|
||
strategyType := explicitStrategyCreateType(session)
|
||
if strategyType == "" {
|
||
return false, "strategy_type"
|
||
}
|
||
if missing := strategyCreateMissingTemplateFields(session, cfg); len(missing) > 0 {
|
||
return false, strings.Join(missing, ",")
|
||
}
|
||
return true, ""
|
||
}
|
||
|
||
func strategyCreateFinalConfirmationReady(session skillSession) bool {
|
||
return strings.EqualFold(strings.TrimSpace(fieldValue(session, "awaiting_final_confirmation")), "true")
|
||
}
|
||
|
||
func strategyCreateHasExplicitConfigBeyondType(session skillSession) bool {
|
||
for _, key := range manualStrategyEditableFieldKeys() {
|
||
switch key {
|
||
case "name", "description", "is_public", "config_visible", "strategy_type":
|
||
continue
|
||
}
|
||
if strings.TrimSpace(fieldValue(session, key)) != "" {
|
||
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 true
|
||
}
|
||
for key := range patch {
|
||
if strings.TrimSpace(key) != "" && strings.TrimSpace(key) != "strategy_type" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func strategyCreateMissingTemplateFields(session skillSession, cfg store.StrategyConfig) []string {
|
||
switch explicitStrategyCreateType(session) {
|
||
case "ai_trading":
|
||
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_trading(AI 策略)。类型确认后我会继续收集对应配置,配置好后再创建。"
|
||
}
|
||
if hints := formatStrategyMissingFieldHints(lang, missingKind); hints != "" {
|
||
return "这份策略模板还没填完整,还缺这些字段。你可以按下面选,也可以直接说“你帮我按稳健/高频/激进来推荐”:\n" + hints
|
||
}
|
||
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 最大杠杆:1~20 倍"
|
||
case "altcoin_max_leverage":
|
||
return "山寨币最大杠杆:1~20 倍"
|
||
case "min_confidence":
|
||
return "最低置信度:50~100,越高越谨慎"
|
||
case "min_risk_reward_ratio":
|
||
return "最小盈亏比:1~10,步进 0.5"
|
||
case "trading_frequency":
|
||
return "交易频率规则:文本,例如“每天最多 2~4 笔,避免连续追单”"
|
||
case "entry_standards":
|
||
return "开仓标准:文本,例如“趋势明确、成交量配合、风险收益合理才开仓”"
|
||
case "symbol":
|
||
return "交易对:BTCUSDT / ETHUSDT / SOLUSDT / BNBUSDT / XRPUSDT / DOGEUSDT"
|
||
case "grid_count":
|
||
return "网格数量:5~50"
|
||
case "total_investment":
|
||
return "总投入:用户实际投入/保证金预算,最低 100 USDT;不是杠杆后的名义仓位"
|
||
case "leverage":
|
||
return "杠杆:1~5 倍"
|
||
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) + "范围是 1~20 倍。数值越高风险越大。"
|
||
case "min_confidence":
|
||
return "最低置信度范围是 50~100。数值越高越谨慎,开单会更少。"
|
||
case "min_risk_reward_ratio":
|
||
return "最小盈亏比范围是 1~10,步进 0.5。比如 1.5 表示预期收益至少是风险的 1.5 倍。"
|
||
case "trading_frequency":
|
||
return "交易频率规则是文本规则,例如“每天最多 2~4 笔,避免连续追单”。你也可以说“你帮我按高频但不过度交易来写”。"
|
||
case "entry_standards":
|
||
return "开仓标准是文本规则,例如“只在趋势明确、成交量配合、风险收益合理时开仓”。你也可以说“你帮我写一版稳健开仓标准”。"
|
||
case "symbol":
|
||
return "网格交易对可选:BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT。"
|
||
case "grid_count":
|
||
return "网格数量范围是 5~50。数量越多越密,交易更频繁;数量越少,每格空间更大。"
|
||
case "total_investment":
|
||
return "网格总投入是用户实际投入/保证金预算,不是杠杆后的名义仓位;最小 100 USDT,按 100 USDT 步进。"
|
||
case "leverage":
|
||
return "网格杠杆范围是 1~5 倍。稳健一般用 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 {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
if lang == "zh" {
|
||
name = "未命名策略"
|
||
} else {
|
||
name = "unnamed strategy"
|
||
}
|
||
}
|
||
if lang == "zh" {
|
||
lines := []string{
|
||
fmt.Sprintf("我先把策略草稿整理成了“%s”。", name),
|
||
}
|
||
if len(changedFields) > 0 {
|
||
lines = append(lines, "我已经识别到这些配置意图:")
|
||
for _, field := range changedFields {
|
||
lines = append(lines, "- "+field)
|
||
}
|
||
}
|
||
if len(warnings) > 0 {
|
||
lines = append(lines, "其中有些参数超出了当前安全范围,我先拦下来了:")
|
||
for _, warning := range warnings {
|
||
lines = append(lines, "- "+warning)
|
||
}
|
||
lines = append(lines, "你可以继续告诉我其他字段怎么设计;如果接受当前安全范围,也可以直接回复“确认创建”。")
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
switch strategyType {
|
||
case "grid_trading":
|
||
lines = append(lines, "这是网格策略草稿。请继续补充交易对、网格数量、总投入、杠杆、价格区间和网格风控;我只会按产品编辑页模板填你明确给出或明确委托我设计的字段。")
|
||
case "ai_trading":
|
||
lines = append(lines, "这是 AI 策略草稿。请继续补充选币来源、时间周期、风险参数和提示词方向;我只会按产品编辑页模板填你明确给出或明确委托我设计的字段。")
|
||
default:
|
||
lines = append(lines, "你可以继续补充策略类型和对应参数;如果现在就创建,直接回复“确认创建”。")
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
lines := []string{
|
||
fmt.Sprintf("I turned that into a draft strategy named %q.", name),
|
||
}
|
||
if len(changedFields) > 0 {
|
||
lines = append(lines, "Recognized fields:")
|
||
for _, field := range changedFields {
|
||
lines = append(lines, "- "+field)
|
||
}
|
||
}
|
||
if len(warnings) > 0 {
|
||
lines = append(lines, "Some values exceeded the current safety limits, so I stopped before creating it:")
|
||
for _, warning := range warnings {
|
||
lines = append(lines, "- "+warning)
|
||
}
|
||
lines = append(lines, "You can keep refining the draft, or reply 'confirm' to create it with the safe adjusted values.")
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
switch strategyType {
|
||
case "grid_trading":
|
||
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. 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.")
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func formatStrategyCreateFinalConfirmation(lang string, session skillSession, cfg store.StrategyConfig) string {
|
||
name := defaultIfEmpty(fieldValue(session, "name"), "未命名策略")
|
||
if lang != "zh" {
|
||
name = defaultIfEmpty(fieldValue(session, "name"), "unnamed strategy")
|
||
}
|
||
if lang == "zh" {
|
||
lines := []string{fmt.Sprintf("我已经把“%s”的配置整理好了,确认后我再创建到策略列表。", name)}
|
||
switch cfg.StrategyType {
|
||
case "grid_trading":
|
||
grid := cfg.GridConfig
|
||
if grid == nil {
|
||
grid = &store.GridStrategyConfig{}
|
||
}
|
||
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),
|
||
fmt.Sprintf("- 杠杆:%d倍", grid.Leverage),
|
||
)
|
||
if grid.UseATRBounds {
|
||
lines = append(lines, fmt.Sprintf("- 价格区间:ATR 动态范围(倍数 %.2f)", grid.ATRMultiplier))
|
||
} else {
|
||
lines = append(lines, fmt.Sprintf("- 价格区间:%.2f ~ %.2f", grid.LowerPrice, grid.UpperPrice))
|
||
}
|
||
lines = append(lines,
|
||
fmt.Sprintf("- 网格分布:%s", defaultIfEmpty(grid.Distribution, "uniform")),
|
||
fmt.Sprintf("- 最大回撤:%.2f%%", grid.MaxDrawdownPct),
|
||
fmt.Sprintf("- 止损:%.2f%%", grid.StopLossPct),
|
||
fmt.Sprintf("- 日亏损限制:%.2f%%", grid.DailyLossLimitPct),
|
||
)
|
||
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, "确认创建的话,直接回复“确认创建”。要调整也可以直接说改哪项。")
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
lines := []string{fmt.Sprintf("I prepared the config for %q. Confirm and I will create it in the strategy list.", name)}
|
||
if cfg.StrategyType == "grid_trading" && cfg.GridConfig != nil {
|
||
grid := cfg.GridConfig
|
||
lines = append(lines,
|
||
"- Type: grid strategy",
|
||
fmt.Sprintf("- Symbol: %s", defaultIfEmpty(grid.Symbol, "unset")),
|
||
fmt.Sprintf("- Grid count: %d", grid.GridCount),
|
||
fmt.Sprintf("- Total investment: %.2f USDT", grid.TotalInvestment),
|
||
fmt.Sprintf("- Leverage: %dx", grid.Leverage),
|
||
)
|
||
} else {
|
||
lines = append(lines, "- Type: AI strategy")
|
||
}
|
||
lines = append(lines, "Reply 'confirm create' to create it, or tell me what to change.")
|
||
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)
|
||
}
|
||
|
||
func formatMissingFieldList(lang string, fields []string) string {
|
||
if len(fields) == 0 {
|
||
return ""
|
||
}
|
||
if lang == "zh" {
|
||
return strings.Join(fields, "、")
|
||
}
|
||
return strings.Join(fields, ", ")
|
||
}
|
||
|
||
func availableModelProvidersMessage(lang string) string {
|
||
return modelProviderChoicePrompt(lang)
|
||
}
|
||
|
||
func inferCreateDisplayName(text string) string {
|
||
clean := func(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
value = strings.Trim(value, "“”\"':: ,,。.;;")
|
||
for _, sep := range []string{",", ",", "。", ";", ";", "\n"} {
|
||
if idx := strings.Index(value, sep); idx >= 0 {
|
||
value = strings.TrimSpace(value[:idx])
|
||
}
|
||
}
|
||
for _, marker := range []string{" 交易所", " 模型", " 策略", " exchange", " model", " strategy"} {
|
||
if idx := strings.Index(value, marker); idx >= 0 {
|
||
value = strings.TrimSpace(value[:idx])
|
||
}
|
||
}
|
||
for _, suffix := range []string{"的交易员", "的模型", "的策略", "的交易所", "这个交易员", "这个模型", "这个策略", "这个交易所"} {
|
||
if strings.HasSuffix(value, suffix) {
|
||
value = strings.TrimSpace(strings.TrimSuffix(value, suffix))
|
||
}
|
||
}
|
||
return strings.TrimSpace(value)
|
||
}
|
||
if value := extractDelimitedSegmentAfterKeywords(text, []string{"名称叫", "名字叫", "配置名", "叫", "名为", "名称", "名字是", "called"}); value != "" {
|
||
return clean(value)
|
||
}
|
||
if value := extractQuotedContent(text); value != "" && !containsAny(strings.ToLower(text), []string{"api key", "apikey", "api_key", "secret", "passphrase"}) {
|
||
return clean(value)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func formatModelCreateDraftSummary(lang string, session skillSession) string {
|
||
providerID := fieldValue(session, "provider")
|
||
name := defaultIfEmpty(fieldValue(session, "name"), defaultIfEmpty(defaultModelConfigName(providerID), "未命名模型"))
|
||
provider := defaultIfEmpty(providerID, "未选择")
|
||
modelName := defaultIfEmpty(fieldValue(session, "custom_model_name"), defaultIfEmpty(defaultModelNameForProvider(providerID), "未设置"))
|
||
apiURL := defaultIfEmpty(fieldValue(session, "custom_api_url"), "默认官方地址")
|
||
if lang != "zh" {
|
||
apiURL = defaultIfEmpty(fieldValue(session, "custom_api_url"), "provider default endpoint")
|
||
}
|
||
enabled := fieldValue(session, "enabled") != "false"
|
||
if lang == "zh" {
|
||
lines := []string{
|
||
fmt.Sprintf("我先整理了一份模型配置草稿“%s”。", name),
|
||
fmt.Sprintf("- Provider:%s", provider),
|
||
fmt.Sprintf("- 配置名称:%s", name),
|
||
fmt.Sprintf("- 模型名称:%s", modelName),
|
||
fmt.Sprintf("- 接口地址:%s", apiURL),
|
||
fmt.Sprintf("- 启用状态:%t(未指定时默认 true)", enabled),
|
||
modelProviderDetailedGuidance(lang, providerID),
|
||
"如果这些字段没问题,直接回复“确认创建”;也可以继续补充或修改任意字段。",
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
lines := []string{
|
||
fmt.Sprintf("I prepared a draft model config %q.", name),
|
||
fmt.Sprintf("- Provider: %s", provider),
|
||
fmt.Sprintf("- Config name: %s", name),
|
||
fmt.Sprintf("- Model name: %s", modelName),
|
||
fmt.Sprintf("- API URL: %s", apiURL),
|
||
fmt.Sprintf("- Enabled: %t (defaults to true if omitted)", enabled),
|
||
modelProviderDetailedGuidance(lang, providerID),
|
||
"Reply 'confirm' to create it, or keep refining any field.",
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func formatExchangeCreateDraftSummary(lang string, session skillSession) string {
|
||
exType := defaultIfEmpty(fieldValue(session, "exchange_type"), "未选择")
|
||
accountName := defaultIfEmpty(fieldValue(session, "account_name"), "未命名账户")
|
||
enabled := fieldValue(session, "enabled") != "false"
|
||
testnet := fieldValue(session, "testnet") == "true"
|
||
if lang == "zh" {
|
||
lines := []string{
|
||
fmt.Sprintf("我先整理了一份交易所配置草稿“%s”。", accountName),
|
||
fmt.Sprintf("- 交易所:%s", exType),
|
||
fmt.Sprintf("- 账户名:%s", accountName),
|
||
fmt.Sprintf("- 启用状态:%t(未指定时默认 true)", enabled),
|
||
fmt.Sprintf("- 测试网:%t(未指定时默认 false)", testnet),
|
||
}
|
||
switch exType {
|
||
case "binance", "bybit", "gate", "indodax":
|
||
lines = append(lines,
|
||
fmt.Sprintf("- 已提供 API Key:%t", fieldValue(session, "api_key") != ""),
|
||
fmt.Sprintf("- 已提供 Secret:%t", fieldValue(session, "secret_key") != ""),
|
||
)
|
||
case "okx", "bitget", "kucoin":
|
||
lines = append(lines,
|
||
fmt.Sprintf("- 已提供 API Key:%t", fieldValue(session, "api_key") != ""),
|
||
fmt.Sprintf("- 已提供 Secret:%t", fieldValue(session, "secret_key") != ""),
|
||
fmt.Sprintf("- 已提供 Passphrase:%t", fieldValue(session, "passphrase") != ""),
|
||
)
|
||
case "hyperliquid":
|
||
lines = append(lines,
|
||
fmt.Sprintf("- 已提供 API Key:%t", fieldValue(session, "api_key") != ""),
|
||
fmt.Sprintf("- Hyperliquid 钱包地址:%s", defaultIfEmpty(fieldValue(session, "hyperliquid_wallet_addr"), "未设置")),
|
||
)
|
||
case "aster":
|
||
lines = append(lines,
|
||
fmt.Sprintf("- Aster User:%s", defaultIfEmpty(fieldValue(session, "aster_user"), "未设置")),
|
||
fmt.Sprintf("- Aster Signer:%s", defaultIfEmpty(fieldValue(session, "aster_signer"), "未设置")),
|
||
fmt.Sprintf("- 已提供 Aster 私钥:%t", fieldValue(session, "aster_private_key") != ""),
|
||
)
|
||
case "lighter":
|
||
lines = append(lines,
|
||
fmt.Sprintf("- Lighter 钱包地址:%s", defaultIfEmpty(fieldValue(session, "lighter_wallet_addr"), "未设置")),
|
||
fmt.Sprintf("- 已提供 Lighter API Key 私钥:%t", fieldValue(session, "lighter_api_key_private_key") != ""),
|
||
)
|
||
if value := fieldValue(session, "lighter_api_key_index"); value != "" {
|
||
lines = append(lines, fmt.Sprintf("- Lighter API Key Index:%s", value))
|
||
}
|
||
default:
|
||
lines = append(lines,
|
||
fmt.Sprintf("- 已提供 API Key:%t", fieldValue(session, "api_key") != ""),
|
||
fmt.Sprintf("- 已提供 Secret:%t", fieldValue(session, "secret_key") != ""),
|
||
)
|
||
}
|
||
lines = append(lines, "如果这些字段没问题,直接回复“确认创建”;也可以继续补充或修改任意字段。")
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
lines := []string{
|
||
fmt.Sprintf("I prepared a draft exchange config %q.", accountName),
|
||
fmt.Sprintf("- Exchange: %s", exType),
|
||
fmt.Sprintf("- Account name: %s", accountName),
|
||
fmt.Sprintf("- Enabled: %t (defaults to true if omitted)", enabled),
|
||
fmt.Sprintf("- Testnet: %t (defaults to false if omitted)", testnet),
|
||
}
|
||
switch exType {
|
||
case "binance", "bybit", "gate", "indodax":
|
||
lines = append(lines,
|
||
fmt.Sprintf("- API key provided: %t", fieldValue(session, "api_key") != ""),
|
||
fmt.Sprintf("- Secret provided: %t", fieldValue(session, "secret_key") != ""),
|
||
)
|
||
case "okx", "bitget", "kucoin":
|
||
lines = append(lines,
|
||
fmt.Sprintf("- API key provided: %t", fieldValue(session, "api_key") != ""),
|
||
fmt.Sprintf("- Secret provided: %t", fieldValue(session, "secret_key") != ""),
|
||
fmt.Sprintf("- Passphrase provided: %t", fieldValue(session, "passphrase") != ""),
|
||
)
|
||
case "hyperliquid":
|
||
lines = append(lines,
|
||
fmt.Sprintf("- API key provided: %t", fieldValue(session, "api_key") != ""),
|
||
fmt.Sprintf("- Hyperliquid wallet address: %s", defaultIfEmpty(fieldValue(session, "hyperliquid_wallet_addr"), "not set")),
|
||
)
|
||
case "aster":
|
||
lines = append(lines,
|
||
fmt.Sprintf("- Aster user: %s", defaultIfEmpty(fieldValue(session, "aster_user"), "not set")),
|
||
fmt.Sprintf("- Aster signer: %s", defaultIfEmpty(fieldValue(session, "aster_signer"), "not set")),
|
||
fmt.Sprintf("- Aster private key provided: %t", fieldValue(session, "aster_private_key") != ""),
|
||
)
|
||
case "lighter":
|
||
lines = append(lines,
|
||
fmt.Sprintf("- Lighter wallet address: %s", defaultIfEmpty(fieldValue(session, "lighter_wallet_addr"), "not set")),
|
||
fmt.Sprintf("- Lighter API key private key provided: %t", fieldValue(session, "lighter_api_key_private_key") != ""),
|
||
)
|
||
if value := fieldValue(session, "lighter_api_key_index"); value != "" {
|
||
lines = append(lines, fmt.Sprintf("- Lighter API key index: %s", value))
|
||
}
|
||
default:
|
||
lines = append(lines,
|
||
fmt.Sprintf("- API key provided: %t", fieldValue(session, "api_key") != ""),
|
||
fmt.Sprintf("- Secret provided: %t", fieldValue(session, "secret_key") != ""),
|
||
)
|
||
}
|
||
lines = append(lines, "Reply 'confirm' to create it, or keep refining any field.")
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func formatTraderCreateDraftSummary(lang string, session skillSession) string {
|
||
args := buildTraderUpdateArgsFromSession(session)
|
||
args, warnings := normalizeTraderArgsToManualLimits(lang, args)
|
||
scanInterval := 3
|
||
if args.ScanIntervalMinutes != nil && *args.ScanIntervalMinutes > 0 {
|
||
scanInterval = *args.ScanIntervalMinutes
|
||
}
|
||
isCrossMargin := true
|
||
if args.IsCrossMargin != nil {
|
||
isCrossMargin = *args.IsCrossMargin
|
||
}
|
||
showInCompetition := true
|
||
if args.ShowInCompetition != nil {
|
||
showInCompetition = *args.ShowInCompetition
|
||
}
|
||
autoStart := fieldValue(session, "auto_start") == "true"
|
||
name := defaultIfEmpty(fieldValue(session, "name"), "未命名交易员")
|
||
if lang != "zh" {
|
||
name = defaultIfEmpty(fieldValue(session, "name"), "unnamed trader")
|
||
}
|
||
if lang == "zh" {
|
||
lines := []string{
|
||
fmt.Sprintf("我先整理了一份交易员草稿“%s”。", name),
|
||
fmt.Sprintf("- 名称:%s", name),
|
||
fmt.Sprintf("- 交易所:%s", traderCreateExchangeNameOrID(session)),
|
||
fmt.Sprintf("- 模型:%s", traderCreateModelNameOrID(session)),
|
||
fmt.Sprintf("- 策略:%s", traderCreateStrategyNameOrID(session)),
|
||
fmt.Sprintf("- 扫描间隔:%d 分钟(未指定时默认 3)", scanInterval),
|
||
"- 初始余额:创建时由系统自动读取绑定交易所账户净值",
|
||
fmt.Sprintf("- 全仓模式:%t(未指定时默认 true)", isCrossMargin),
|
||
fmt.Sprintf("- 竞技场显示:%t(未指定时默认 true)", showInCompetition),
|
||
}
|
||
if autoStart {
|
||
lines = append(lines, "- 创建后立即启动:true")
|
||
if len(warnings) > 0 {
|
||
lines = append(lines, "这些字段里有超出手动面板范围的值,我已经先按风控范围收敛:")
|
||
for _, warning := range warnings {
|
||
lines = append(lines, "- "+warning)
|
||
}
|
||
}
|
||
lines = append(lines, "如果这些字段没问题,直接回复“确认创建并启动”;也可以继续补充或修改任意字段。")
|
||
} else {
|
||
if len(warnings) > 0 {
|
||
lines = append(lines, "这些字段里有超出手动面板范围的值,我已经先按风控范围收敛:")
|
||
for _, warning := range warnings {
|
||
lines = append(lines, "- "+warning)
|
||
}
|
||
}
|
||
lines = append(lines, "如果这些字段没问题,直接回复“确认创建”;也可以继续补充或修改任意字段。")
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
lines := []string{
|
||
fmt.Sprintf("I prepared a draft trader %q.", name),
|
||
fmt.Sprintf("- Name: %s", name),
|
||
fmt.Sprintf("- Exchange: %s", traderCreateExchangeNameOrID(session)),
|
||
fmt.Sprintf("- Model: %s", traderCreateModelNameOrID(session)),
|
||
fmt.Sprintf("- Strategy: %s", traderCreateStrategyNameOrID(session)),
|
||
fmt.Sprintf("- Scan interval: %d minutes (defaults to 3)", scanInterval),
|
||
"- Initial balance: auto-read from the bound exchange account equity at creation time",
|
||
fmt.Sprintf("- Cross margin: %t (defaults to true)", isCrossMargin),
|
||
fmt.Sprintf("- Show in competition: %t (defaults to true)", showInCompetition),
|
||
}
|
||
if autoStart {
|
||
lines = append(lines, "- Start immediately after creation: true")
|
||
if len(warnings) > 0 {
|
||
lines = append(lines, "Some values exceeded the manual editor limits, so I normalized them first:")
|
||
for _, warning := range warnings {
|
||
lines = append(lines, "- "+warning)
|
||
}
|
||
}
|
||
lines = append(lines, "Reply 'confirm' to create and start it, or keep refining any field.")
|
||
} else {
|
||
if len(warnings) > 0 {
|
||
lines = append(lines, "Some values exceeded the manual editor limits, so I normalized them first:")
|
||
for _, warning := range warnings {
|
||
lines = append(lines, "- "+warning)
|
||
}
|
||
}
|
||
lines = append(lines, "Reply 'confirm' to create it, or keep refining any field.")
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func hasExplicitStrategyDetailIntent(text string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
if !hasExplicitManagementDomainCue(text, "strategy") {
|
||
return false
|
||
}
|
||
return containsAny(lower, []string{
|
||
"什么样", "怎么样", "详情", "详细", "prompt", "提示词",
|
||
"哪个策略", "哪一个策略", "你改的是哪个策略", "你把哪个策略",
|
||
"what kind", "details", "detail", "prompt", "which strategy",
|
||
})
|
||
}
|
||
|
||
func shouldPreferStrategyQueryDetail(text string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
if !containsAny(lower, []string{"?", "?", "哪个", "哪一个", "哪条", "which"}) {
|
||
return false
|
||
}
|
||
return containsAny(lower, []string{"策略", "strategy"})
|
||
}
|
||
|
||
func shouldExplainStrategyRuntimeBoundary(text string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
if !containsAny(lower, []string{"策略", "strategy"}) {
|
||
return false
|
||
}
|
||
if !containsAny(lower, []string{"启动", "运行", "run", "start", "deploy"}) {
|
||
return false
|
||
}
|
||
if containsAny(lower, []string{"交易员", "trader", "机器人", "bot"}) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func wantsDefaultStrategyConfig(text string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
if lower == "" {
|
||
return false
|
||
}
|
||
return containsAny(lower, []string{
|
||
"默认配置", "默认策略", "默认模板", "模板配置",
|
||
"default config", "default strategy", "default template",
|
||
})
|
||
}
|
||
|
||
func (a *Agent) describeStrategy(storeUserID, lang string, target *EntityReference) (string, bool) {
|
||
if a.store == nil {
|
||
return "", false
|
||
}
|
||
|
||
var strategy *store.Strategy
|
||
var err error
|
||
if target != nil && strings.TrimSpace(target.ID) != "" {
|
||
strategy, err = a.store.Strategy().Get(storeUserID, strings.TrimSpace(target.ID))
|
||
} else if target != nil && strings.TrimSpace(target.Name) != "" {
|
||
strategies, listErr := a.store.Strategy().List(storeUserID)
|
||
if listErr != nil {
|
||
return "", false
|
||
}
|
||
for _, item := range strategies {
|
||
if item != nil && strings.EqualFold(strings.TrimSpace(item.Name), strings.TrimSpace(target.Name)) {
|
||
strategy = item
|
||
break
|
||
}
|
||
}
|
||
} else {
|
||
strategies, listErr := a.store.Strategy().List(storeUserID)
|
||
if listErr != nil || len(strategies) != 1 {
|
||
return "", false
|
||
}
|
||
strategy = strategies[0]
|
||
}
|
||
if err != nil || strategy == nil {
|
||
return "", false
|
||
}
|
||
|
||
var cfg store.StrategyConfig
|
||
if strings.TrimSpace(strategy.Config) != "" {
|
||
_ = json.Unmarshal([]byte(strategy.Config), &cfg)
|
||
}
|
||
|
||
return formatStrategyDetailResponse(lang, strategy, cfg), true
|
||
}
|
||
|
||
func formatStrategyDetailResponse(lang string, strategy *store.Strategy, cfg store.StrategyConfig) string {
|
||
name := strings.TrimSpace(strategy.Name)
|
||
if name == "" {
|
||
name = strings.TrimSpace(strategy.ID)
|
||
}
|
||
|
||
sourceBits := make([]string, 0, 4)
|
||
if strings.TrimSpace(cfg.CoinSource.SourceType) != "" {
|
||
sourceBits = append(sourceBits, cfg.CoinSource.SourceType)
|
||
}
|
||
if cfg.CoinSource.UseAI500 {
|
||
sourceBits = append(sourceBits, fmt.Sprintf("AI500=%d", cfg.CoinSource.AI500Limit))
|
||
}
|
||
if cfg.CoinSource.UseOITop {
|
||
sourceBits = append(sourceBits, fmt.Sprintf("OITop=%d", cfg.CoinSource.OITopLimit))
|
||
}
|
||
if cfg.CoinSource.UseOILow {
|
||
sourceBits = append(sourceBits, fmt.Sprintf("OILow=%d", cfg.CoinSource.OILowLimit))
|
||
}
|
||
if len(cfg.CoinSource.StaticCoins) > 0 {
|
||
sourceBits = append(sourceBits, "static="+strings.Join(cfg.CoinSource.StaticCoins, ","))
|
||
}
|
||
if len(cfg.CoinSource.ExcludedCoins) > 0 {
|
||
sourceBits = append(sourceBits, "excluded="+strings.Join(cfg.CoinSource.ExcludedCoins, ","))
|
||
}
|
||
|
||
timeframes := append([]string(nil), cfg.Indicators.Klines.SelectedTimeframes...)
|
||
if len(timeframes) == 0 {
|
||
timeframes = cleanStringList([]string{cfg.Indicators.Klines.PrimaryTimeframe, cfg.Indicators.Klines.LongerTimeframe})
|
||
}
|
||
|
||
indicatorBits := make([]string, 0, 8)
|
||
if cfg.Indicators.EnableRawKlines {
|
||
indicatorBits = append(indicatorBits, "raw_klines")
|
||
}
|
||
if cfg.Indicators.EnableVolume {
|
||
indicatorBits = append(indicatorBits, "volume")
|
||
}
|
||
if cfg.Indicators.EnableOI {
|
||
indicatorBits = append(indicatorBits, "oi")
|
||
}
|
||
if cfg.Indicators.EnableFundingRate {
|
||
indicatorBits = append(indicatorBits, "funding_rate")
|
||
}
|
||
if cfg.Indicators.EnableEMA {
|
||
indicatorBits = append(indicatorBits, "ema")
|
||
}
|
||
if cfg.Indicators.EnableMACD {
|
||
indicatorBits = append(indicatorBits, "macd")
|
||
}
|
||
if cfg.Indicators.EnableRSI {
|
||
indicatorBits = append(indicatorBits, "rsi")
|
||
}
|
||
if cfg.Indicators.EnableATR {
|
||
indicatorBits = append(indicatorBits, "atr")
|
||
}
|
||
if cfg.Indicators.EnableBOLL {
|
||
indicatorBits = append(indicatorBits, "boll")
|
||
}
|
||
sort.Strings(indicatorBits)
|
||
|
||
promptBits := make([]string, 0, 5)
|
||
if strings.TrimSpace(cfg.PromptSections.RoleDefinition) != "" {
|
||
promptBits = append(promptBits, "role_definition")
|
||
}
|
||
if strings.TrimSpace(cfg.PromptSections.TradingFrequency) != "" {
|
||
promptBits = append(promptBits, "trading_frequency")
|
||
}
|
||
if strings.TrimSpace(cfg.PromptSections.EntryStandards) != "" {
|
||
promptBits = append(promptBits, "entry_standards")
|
||
}
|
||
if strings.TrimSpace(cfg.PromptSections.DecisionProcess) != "" {
|
||
promptBits = append(promptBits, "decision_process")
|
||
}
|
||
|
||
customPrompt := strings.TrimSpace(cfg.CustomPrompt)
|
||
customPromptPreview := customPrompt
|
||
if len([]rune(customPromptPreview)) > 120 {
|
||
runes := []rune(customPromptPreview)
|
||
customPromptPreview = string(runes[:120]) + "..."
|
||
}
|
||
|
||
publishStatusZh := "未发布"
|
||
publishStatusEn := "private"
|
||
if strategy.IsPublic {
|
||
publishStatusZh = "已发布到市场"
|
||
publishStatusEn = "public"
|
||
}
|
||
configVisibleZh := "隐藏"
|
||
configVisibleEn := "hidden"
|
||
if strategy.ConfigVisible {
|
||
configVisibleZh = "可见"
|
||
configVisibleEn = "visible"
|
||
}
|
||
|
||
if lang == "zh" {
|
||
lines := []string{
|
||
fmt.Sprintf("策略“%s”概览:", name),
|
||
fmt.Sprintf("- 类型:%s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")),
|
||
fmt.Sprintf("- 语言:%s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "zh")),
|
||
fmt.Sprintf("- 发布设置:%s;配置%s", publishStatusZh, configVisibleZh),
|
||
}
|
||
if strings.TrimSpace(strategy.Description) != "" {
|
||
lines = append(lines, fmt.Sprintf("- 描述:%s", strings.TrimSpace(strategy.Description)))
|
||
}
|
||
if cfg.GridConfig != nil {
|
||
lines = append(lines, fmt.Sprintf("- 网格参数:交易对 %s;网格 %d;总投资 %.2f;杠杆 %d;分布 %s",
|
||
defaultIfEmpty(strings.TrimSpace(cfg.GridConfig.Symbol), "未设置"),
|
||
cfg.GridConfig.GridCount,
|
||
cfg.GridConfig.TotalInvestment,
|
||
cfg.GridConfig.Leverage,
|
||
defaultIfEmpty(strings.TrimSpace(cfg.GridConfig.Distribution), "未设置"),
|
||
))
|
||
if cfg.GridConfig.UseATRBounds {
|
||
lines = append(lines, fmt.Sprintf("- 网格边界:ATR 自动边界,倍数 %.2f", cfg.GridConfig.ATRMultiplier))
|
||
} else if cfg.GridConfig.UpperPrice > 0 || cfg.GridConfig.LowerPrice > 0 {
|
||
lines = append(lines, fmt.Sprintf("- 网格边界:上沿 %.4f,下沿 %.4f", cfg.GridConfig.UpperPrice, cfg.GridConfig.LowerPrice))
|
||
}
|
||
}
|
||
if len(sourceBits) > 0 {
|
||
lines = append(lines, "- 标的来源:"+strings.Join(sourceBits, " | "))
|
||
}
|
||
if len(timeframes) > 0 {
|
||
lines = append(lines, "- K线周期:"+strings.Join(timeframes, " / "))
|
||
}
|
||
lines = append(lines, fmt.Sprintf("- 仓位风险:最多持仓 %d,BTC/ETH 最大杠杆 %d,山寨最大杠杆 %d,最低置信度 %d",
|
||
cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence))
|
||
lines = append(lines, fmt.Sprintf("- 风控阈值:最小盈亏比 %.2f;最大保证金使用率 %.2f;最小开仓金额 %.2f",
|
||
cfg.RiskControl.MinRiskRewardRatio, cfg.RiskControl.MaxMarginUsage, cfg.RiskControl.MinPositionSize))
|
||
if len(indicatorBits) > 0 {
|
||
lines = append(lines, "- 已启用指标:"+strings.Join(indicatorBits, "、"))
|
||
}
|
||
if strings.TrimSpace(cfg.Indicators.NofxOSAPIKey) != "" || cfg.Indicators.EnableQuantData || cfg.Indicators.EnableOIRanking || cfg.Indicators.EnableNetFlowRanking || cfg.Indicators.EnablePriceRanking {
|
||
lines = append(lines, fmt.Sprintf("- NofxOS 数据:API Key=%t,量化数据=%t,OI 排行=%t,净流入排行=%t,价格排行=%t",
|
||
strings.TrimSpace(cfg.Indicators.NofxOSAPIKey) != "",
|
||
cfg.Indicators.EnableQuantData,
|
||
cfg.Indicators.EnableOIRanking,
|
||
cfg.Indicators.EnableNetFlowRanking,
|
||
cfg.Indicators.EnablePriceRanking,
|
||
))
|
||
}
|
||
if len(promptBits) > 0 {
|
||
lines = append(lines, "- Prompt 模块:"+strings.Join(promptBits, "、"))
|
||
}
|
||
if customPromptPreview != "" {
|
||
lines = append(lines, "- 自定义 Prompt:"+customPromptPreview)
|
||
} else {
|
||
lines = append(lines, "- 自定义 Prompt:当前为空,主要使用策略模板内置 prompt sections。")
|
||
}
|
||
lines = append(lines, "- 如果你要,我还可以继续展开这条策略的完整参数 JSON,或者逐段解释它的 prompt。")
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
lines := []string{
|
||
fmt.Sprintf("Strategy %q overview:", name),
|
||
fmt.Sprintf("- Type: %s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")),
|
||
fmt.Sprintf("- Language: %s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "en")),
|
||
fmt.Sprintf("- Publish settings: %s; config %s", publishStatusEn, configVisibleEn),
|
||
}
|
||
if strings.TrimSpace(strategy.Description) != "" {
|
||
lines = append(lines, fmt.Sprintf("- Description: %s", strings.TrimSpace(strategy.Description)))
|
||
}
|
||
if cfg.GridConfig != nil {
|
||
lines = append(lines, fmt.Sprintf("- Grid config: symbol %s; grids %d; investment %.2f; leverage %d; distribution %s",
|
||
defaultIfEmpty(strings.TrimSpace(cfg.GridConfig.Symbol), "not set"),
|
||
cfg.GridConfig.GridCount,
|
||
cfg.GridConfig.TotalInvestment,
|
||
cfg.GridConfig.Leverage,
|
||
defaultIfEmpty(strings.TrimSpace(cfg.GridConfig.Distribution), "not set"),
|
||
))
|
||
if cfg.GridConfig.UseATRBounds {
|
||
lines = append(lines, fmt.Sprintf("- Grid bounds: ATR auto bounds with multiplier %.2f", cfg.GridConfig.ATRMultiplier))
|
||
} else if cfg.GridConfig.UpperPrice > 0 || cfg.GridConfig.LowerPrice > 0 {
|
||
lines = append(lines, fmt.Sprintf("- Grid bounds: upper %.4f, lower %.4f", cfg.GridConfig.UpperPrice, cfg.GridConfig.LowerPrice))
|
||
}
|
||
}
|
||
if len(sourceBits) > 0 {
|
||
lines = append(lines, "- Coin source: "+strings.Join(sourceBits, " | "))
|
||
}
|
||
if len(timeframes) > 0 {
|
||
lines = append(lines, "- Timeframes: "+strings.Join(timeframes, " / "))
|
||
}
|
||
lines = append(lines, fmt.Sprintf("- Risk: max positions %d, BTC/ETH max leverage %d, alt max leverage %d, min confidence %d",
|
||
cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence))
|
||
lines = append(lines, fmt.Sprintf("- Risk thresholds: min RR %.2f, max margin usage %.2f, min position size %.2f",
|
||
cfg.RiskControl.MinRiskRewardRatio, cfg.RiskControl.MaxMarginUsage, cfg.RiskControl.MinPositionSize))
|
||
if len(indicatorBits) > 0 {
|
||
lines = append(lines, "- Enabled indicators: "+strings.Join(indicatorBits, ", "))
|
||
}
|
||
if strings.TrimSpace(cfg.Indicators.NofxOSAPIKey) != "" || cfg.Indicators.EnableQuantData || cfg.Indicators.EnableOIRanking || cfg.Indicators.EnableNetFlowRanking || cfg.Indicators.EnablePriceRanking {
|
||
lines = append(lines, fmt.Sprintf("- NofxOS data: API key=%t, quant data=%t, OI ranking=%t, netflow ranking=%t, price ranking=%t",
|
||
strings.TrimSpace(cfg.Indicators.NofxOSAPIKey) != "",
|
||
cfg.Indicators.EnableQuantData,
|
||
cfg.Indicators.EnableOIRanking,
|
||
cfg.Indicators.EnableNetFlowRanking,
|
||
cfg.Indicators.EnablePriceRanking,
|
||
))
|
||
}
|
||
if len(promptBits) > 0 {
|
||
lines = append(lines, "- Prompt modules: "+strings.Join(promptBits, ", "))
|
||
}
|
||
if customPromptPreview != "" {
|
||
lines = append(lines, "- Custom prompt: "+customPromptPreview)
|
||
} else {
|
||
lines = append(lines, "- Custom prompt: empty right now; it mainly uses the built-in prompt sections from the strategy template.")
|
||
}
|
||
lines = append(lines, "- I can also expand the full strategy config JSON or walk through the prompt section by section.")
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (a *Agent) describeDefaultStrategyConfig(lang string) string {
|
||
if lang != "zh" {
|
||
lang = "en"
|
||
}
|
||
cfg := store.GetDefaultStrategyConfig(lang)
|
||
name := "Default Strategy Template"
|
||
description := "System default strategy configuration template"
|
||
if lang == "zh" {
|
||
name = "默认策略模板"
|
||
description = "系统默认策略配置模板"
|
||
}
|
||
return formatStrategyDetailResponse(lang, &store.Strategy{
|
||
ID: "default_strategy_template",
|
||
Name: name,
|
||
Description: description,
|
||
}, cfg)
|
||
}
|
||
|
||
func (a *Agent) describeTrader(storeUserID, lang string, target *EntityReference) (string, bool) {
|
||
raw := a.toolListTraders(storeUserID)
|
||
var payload struct {
|
||
Traders []safeTraderToolConfig `json:"traders"`
|
||
}
|
||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||
return "", false
|
||
}
|
||
trader := findTraderByReference(payload.Traders, target)
|
||
if trader == nil {
|
||
if len(payload.Traders) != 1 {
|
||
return "", false
|
||
}
|
||
trader = &payload.Traders[0]
|
||
}
|
||
if lang == "zh" {
|
||
status := "未运行"
|
||
if trader.IsRunning {
|
||
status = "运行中"
|
||
}
|
||
return fmt.Sprintf("交易员“%s”详情:\n- 状态:%s\n- 模型:%s\n- 交易所:%s\n- 策略:%s\n- 扫描间隔:%d 分钟\n- 初始余额:%.2f",
|
||
trader.Name, status, trader.AIModelID, trader.ExchangeID, defaultIfEmpty(trader.StrategyID, "未绑定"), trader.ScanIntervalMinutes, trader.InitialBalance), true
|
||
}
|
||
status := "stopped"
|
||
if trader.IsRunning {
|
||
status = "running"
|
||
}
|
||
return fmt.Sprintf("Trader %q details:\n- Status: %s\n- Model: %s\n- Exchange: %s\n- Strategy: %s\n- Scan interval: %d minutes\n- Initial balance: %.2f",
|
||
trader.Name, status, trader.AIModelID, trader.ExchangeID, defaultIfEmpty(trader.StrategyID, "none"), trader.ScanIntervalMinutes, trader.InitialBalance), true
|
||
}
|
||
|
||
func (a *Agent) describeExchange(storeUserID, lang string, target *EntityReference) (string, bool) {
|
||
raw := a.toolGetExchangeConfigs(storeUserID)
|
||
var payload struct {
|
||
ExchangeConfigs []safeExchangeToolConfig `json:"exchange_configs"`
|
||
}
|
||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||
return "", false
|
||
}
|
||
exchange := findExchangeByReference(payload.ExchangeConfigs, target)
|
||
if exchange == nil {
|
||
if len(payload.ExchangeConfigs) != 1 {
|
||
return "", false
|
||
}
|
||
exchange = &payload.ExchangeConfigs[0]
|
||
}
|
||
name := defaultIfEmpty(exchange.AccountName, exchange.ID)
|
||
credentialLinesZh := make([]string, 0, 8)
|
||
credentialLinesEn := make([]string, 0, 8)
|
||
addCredentialLine := func(labelZh, labelEn string, present bool) {
|
||
credentialLinesZh = append(credentialLinesZh, fmt.Sprintf("- %s:%t", labelZh, present))
|
||
credentialLinesEn = append(credentialLinesEn, fmt.Sprintf("- %s: %t", labelEn, present))
|
||
}
|
||
switch exchange.ExchangeType {
|
||
case "binance", "bybit", "gate", "indodax":
|
||
addCredentialLine("API Key", "API key present", exchange.HasAPIKey)
|
||
addCredentialLine("Secret", "Secret present", exchange.HasSecretKey)
|
||
case "okx", "bitget", "kucoin":
|
||
addCredentialLine("API Key", "API key present", exchange.HasAPIKey)
|
||
addCredentialLine("Secret", "Secret present", exchange.HasSecretKey)
|
||
addCredentialLine("Passphrase", "Passphrase present", exchange.HasPassphrase)
|
||
case "hyperliquid":
|
||
addCredentialLine("API Key", "API key present", exchange.HasAPIKey)
|
||
credentialLinesZh = append(credentialLinesZh, fmt.Sprintf("- Hyperliquid 钱包地址:%s", defaultIfEmpty(exchange.HyperliquidWalletAddr, "未设置")))
|
||
credentialLinesEn = append(credentialLinesEn, fmt.Sprintf("- Hyperliquid wallet address: %s", defaultIfEmpty(exchange.HyperliquidWalletAddr, "not set")))
|
||
case "aster":
|
||
credentialLinesZh = append(credentialLinesZh,
|
||
fmt.Sprintf("- Aster User:%s", defaultIfEmpty(exchange.AsterUser, "未设置")),
|
||
fmt.Sprintf("- Aster Signer:%s", defaultIfEmpty(exchange.AsterSigner, "未设置")),
|
||
fmt.Sprintf("- Aster 私钥:%t", exchange.HasAsterPrivateKey),
|
||
)
|
||
credentialLinesEn = append(credentialLinesEn,
|
||
fmt.Sprintf("- Aster user: %s", defaultIfEmpty(exchange.AsterUser, "not set")),
|
||
fmt.Sprintf("- Aster signer: %s", defaultIfEmpty(exchange.AsterSigner, "not set")),
|
||
fmt.Sprintf("- Aster private key present: %t", exchange.HasAsterPrivateKey),
|
||
)
|
||
case "lighter":
|
||
credentialLinesZh = append(credentialLinesZh,
|
||
fmt.Sprintf("- Lighter 钱包地址:%s", defaultIfEmpty(exchange.LighterWalletAddr, "未设置")),
|
||
fmt.Sprintf("- Lighter API Key 私钥:%t", exchange.HasLighterAPIKey),
|
||
fmt.Sprintf("- Lighter API Key Index:%d", exchange.LighterAPIKeyIndex),
|
||
)
|
||
credentialLinesEn = append(credentialLinesEn,
|
||
fmt.Sprintf("- Lighter wallet address: %s", defaultIfEmpty(exchange.LighterWalletAddr, "not set")),
|
||
fmt.Sprintf("- Lighter API key private key present: %t", exchange.HasLighterAPIKey),
|
||
fmt.Sprintf("- Lighter API key index: %d", exchange.LighterAPIKeyIndex),
|
||
)
|
||
default:
|
||
addCredentialLine("API Key", "API key present", exchange.HasAPIKey)
|
||
addCredentialLine("Secret", "Secret present", exchange.HasSecretKey)
|
||
if exchange.HasPassphrase {
|
||
addCredentialLine("Passphrase", "Passphrase present", true)
|
||
}
|
||
}
|
||
if lang == "zh" {
|
||
lines := []string{
|
||
fmt.Sprintf("交易所配置“%s”详情:", name),
|
||
fmt.Sprintf("- 交易所:%s", exchange.ExchangeType),
|
||
fmt.Sprintf("- 账户名:%s", name),
|
||
fmt.Sprintf("- 已启用:%t", exchange.Enabled),
|
||
fmt.Sprintf("- Testnet:%t", exchange.Testnet),
|
||
}
|
||
lines = append(lines, credentialLinesZh...)
|
||
return strings.Join(lines, "\n"), true
|
||
}
|
||
lines := []string{
|
||
fmt.Sprintf("Exchange config %q details:", name),
|
||
fmt.Sprintf("- Exchange: %s", exchange.ExchangeType),
|
||
fmt.Sprintf("- Account name: %s", name),
|
||
fmt.Sprintf("- Enabled: %t", exchange.Enabled),
|
||
fmt.Sprintf("- Testnet: %t", exchange.Testnet),
|
||
}
|
||
lines = append(lines, credentialLinesEn...)
|
||
return strings.Join(lines, "\n"), true
|
||
}
|
||
|
||
func (a *Agent) describeModel(storeUserID, lang string, target *EntityReference) (string, bool) {
|
||
raw := a.toolGetModelConfigs(storeUserID)
|
||
var payload struct {
|
||
ModelConfigs []safeModelToolConfig `json:"model_configs"`
|
||
}
|
||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||
return "", false
|
||
}
|
||
model := findModelByReference(payload.ModelConfigs, target)
|
||
if model == nil {
|
||
if len(payload.ModelConfigs) != 1 {
|
||
return "", false
|
||
}
|
||
model = &payload.ModelConfigs[0]
|
||
}
|
||
if lang == "zh" {
|
||
lines := []string{
|
||
fmt.Sprintf("模型配置“%s”详情:", defaultIfEmpty(model.Name, model.ID)),
|
||
fmt.Sprintf("- Provider:%s", model.Provider),
|
||
fmt.Sprintf("- 已启用:%t", model.Enabled),
|
||
fmt.Sprintf("- API Key:%t", model.HasAPIKey),
|
||
fmt.Sprintf("- URL:%s", defaultIfEmpty(model.CustomAPIURL, "未设置")),
|
||
fmt.Sprintf("- Model Name:%s", defaultIfEmpty(model.CustomModelName, "未设置")),
|
||
}
|
||
if strings.TrimSpace(model.WalletAddress) != "" {
|
||
lines = append(lines, fmt.Sprintf("- 钱包地址:%s", model.WalletAddress))
|
||
}
|
||
if strings.TrimSpace(model.BalanceUSDC) != "" {
|
||
lines = append(lines, fmt.Sprintf("- 钱包余额:%s USDC", model.BalanceUSDC))
|
||
}
|
||
return strings.Join(lines, "\n"), true
|
||
}
|
||
lines := []string{
|
||
fmt.Sprintf("Model config %q details:", defaultIfEmpty(model.Name, model.ID)),
|
||
fmt.Sprintf("- Provider: %s", model.Provider),
|
||
fmt.Sprintf("- Enabled: %t", model.Enabled),
|
||
fmt.Sprintf("- API key present: %t", model.HasAPIKey),
|
||
fmt.Sprintf("- URL: %s", defaultIfEmpty(model.CustomAPIURL, "not set")),
|
||
fmt.Sprintf("- Model name: %s", defaultIfEmpty(model.CustomModelName, "not set")),
|
||
}
|
||
if strings.TrimSpace(model.WalletAddress) != "" {
|
||
lines = append(lines, fmt.Sprintf("- Wallet address: %s", model.WalletAddress))
|
||
}
|
||
if strings.TrimSpace(model.BalanceUSDC) != "" {
|
||
lines = append(lines, fmt.Sprintf("- Wallet balance: %s USDC", model.BalanceUSDC))
|
||
}
|
||
return strings.Join(lines, "\n"), true
|
||
}
|
||
|
||
func findTraderByReference(items []safeTraderToolConfig, target *EntityReference) *safeTraderToolConfig {
|
||
if target == nil {
|
||
return nil
|
||
}
|
||
for i := range items {
|
||
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
|
||
return &items[i]
|
||
}
|
||
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(items[i].Name), strings.TrimSpace(target.Name)) {
|
||
return &items[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func findExchangeByReference(items []safeExchangeToolConfig, target *EntityReference) *safeExchangeToolConfig {
|
||
if target == nil {
|
||
return nil
|
||
}
|
||
for i := range items {
|
||
name := defaultIfEmpty(items[i].AccountName, items[i].Name)
|
||
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
|
||
return &items[i]
|
||
}
|
||
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(name), strings.TrimSpace(target.Name)) {
|
||
return &items[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func findModelByReference(items []safeModelToolConfig, target *EntityReference) *safeModelToolConfig {
|
||
if target == nil {
|
||
return nil
|
||
}
|
||
for i := range items {
|
||
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
|
||
return &items[i]
|
||
}
|
||
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(items[i].Name), strings.TrimSpace(target.Name)) {
|
||
return &items[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *Agent) loadTraderOptions(storeUserID string) []traderSkillOption {
|
||
if a.store == nil {
|
||
return nil
|
||
}
|
||
traders, err := a.store.Trader().List(storeUserID)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
exchangeNames := map[string]string{}
|
||
if exchanges, err := a.store.Exchange().List(storeUserID); err == nil {
|
||
for _, exchange := range exchanges {
|
||
if !store.IsVisibleExchange(exchange) {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(exchange.AccountName)
|
||
if name == "" {
|
||
name = strings.TrimSpace(exchange.ExchangeType)
|
||
}
|
||
if name != "" {
|
||
exchangeNames[exchange.ID] = name
|
||
}
|
||
}
|
||
}
|
||
modelNames := map[string]string{}
|
||
if models, err := a.store.AIModel().List(storeUserID); err == nil {
|
||
for _, model := range models {
|
||
name := strings.TrimSpace(model.Name)
|
||
if name == "" {
|
||
name = strings.TrimSpace(model.CustomModelName)
|
||
}
|
||
if name != "" {
|
||
modelNames[model.ID] = name
|
||
}
|
||
}
|
||
}
|
||
out := make([]traderSkillOption, 0, len(traders))
|
||
for _, trader := range traders {
|
||
hints := make([]string, 0, 2)
|
||
if exchangeName := strings.TrimSpace(exchangeNames[trader.ExchangeID]); exchangeName != "" {
|
||
hints = append(hints, "交易所 "+exchangeName)
|
||
}
|
||
if modelName := strings.TrimSpace(modelNames[trader.AIModelID]); modelName != "" {
|
||
hints = append(hints, "模型 "+modelName)
|
||
}
|
||
out = append(out, traderSkillOption{
|
||
ID: trader.ID,
|
||
Name: trader.Name,
|
||
Enabled: trader.IsRunning,
|
||
Hint: strings.Join(hints, ","),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
|
||
if session.Name == "" {
|
||
session = skillSession{Name: "exchange_management", Action: "create", Phase: "collecting"}
|
||
}
|
||
if fieldValue(session, skillDAGStepField) == "" {
|
||
setSkillDAGStep(&session, "resolve_exchange_type")
|
||
}
|
||
if isCancelSkillReply(text) {
|
||
a.clearSkillSession(userID)
|
||
if lang == "zh" {
|
||
return "已取消当前创建交易所配置流程。"
|
||
}
|
||
return "Cancelled the current exchange creation flow."
|
||
}
|
||
exType := fieldValue(session, "exchange_type")
|
||
accountName := fieldValue(session, "account_name")
|
||
missing := make([]string, 0, 6)
|
||
if actionRequiresSlot("exchange_management", "create", "exchange_type") && exType == "" {
|
||
missing = append(missing, slotDisplayName("exchange_type", lang))
|
||
}
|
||
if accountName == "" {
|
||
missing = append(missing, displayCatalogFieldName("account_name", lang))
|
||
}
|
||
if fieldValue(session, "api_key") == "" {
|
||
missing = append(missing, displayCatalogFieldName("api_key", lang))
|
||
}
|
||
if fieldValue(session, "secret_key") == "" {
|
||
missing = append(missing, displayCatalogFieldName("secret_key", lang))
|
||
}
|
||
switch exType {
|
||
case "okx":
|
||
if fieldValue(session, "passphrase") == "" {
|
||
missing = append(missing, displayCatalogFieldName("passphrase", lang))
|
||
}
|
||
case "hyperliquid":
|
||
if fieldValue(session, "hyperliquid_wallet_addr") == "" {
|
||
missing = append(missing, "Hyperliquid Wallet")
|
||
}
|
||
}
|
||
if len(missing) > 0 {
|
||
setSkillDAGStep(&session, "resolve_exchange_type")
|
||
a.saveSkillSession(userID, session)
|
||
if lang == "zh" {
|
||
reply := "要创建交易所配置,还缺这些字段:" + formatMissingFieldList(lang, missing) + "。"
|
||
if exType == "" {
|
||
reply += "\n例如:OKX、Binance、Bybit。"
|
||
}
|
||
return reply
|
||
}
|
||
return "One more thing: please tell me these details: " + formatMissingFieldList(lang, missing) + "."
|
||
}
|
||
validator := exchangeConfigValidator{
|
||
exchangeType: exType,
|
||
enabled: fieldValue(session, "enabled") == "true",
|
||
apiKey: fieldValue(session, "api_key"),
|
||
secretKey: fieldValue(session, "secret_key"),
|
||
passphrase: fieldValue(session, "passphrase"),
|
||
hyperliquidWalletAddr: fieldValue(session, "hyperliquid_wallet_addr"),
|
||
asterUser: fieldValue(session, "aster_user"),
|
||
asterSigner: fieldValue(session, "aster_signer"),
|
||
asterPrivateKey: fieldValue(session, "aster_private_key"),
|
||
lighterWalletAddr: fieldValue(session, "lighter_wallet_addr"),
|
||
lighterAPIKeyPrivateKey: fieldValue(session, "lighter_api_key_private_key"),
|
||
}
|
||
if err := validator.Validate(); err != nil {
|
||
a.saveSkillSession(userID, session)
|
||
return formatValidationFeedback(lang, "exchange", err)
|
||
}
|
||
if !createConfirmationReply(text) {
|
||
session.Phase = "await_create_confirmation"
|
||
setSkillDAGStep(&session, "await_create_confirmation")
|
||
a.saveSkillSession(userID, session)
|
||
return formatExchangeCreateDraftSummary(lang, session)
|
||
}
|
||
setSkillDAGStep(&session, "execute_create")
|
||
args := map[string]any{
|
||
"action": "create",
|
||
"exchange_type": exType,
|
||
"account_name": accountName,
|
||
}
|
||
for _, field := range []string{"api_key", "secret_key", "passphrase", "hyperliquid_wallet_addr", "aster_user", "aster_signer", "aster_private_key", "lighter_wallet_addr", "lighter_api_key_private_key"} {
|
||
if value := fieldValue(session, field); value != "" {
|
||
args[field] = value
|
||
}
|
||
}
|
||
if value := fieldValue(session, "enabled"); value != "" {
|
||
args["enabled"] = value == "true"
|
||
}
|
||
if value := fieldValue(session, "testnet"); value != "" {
|
||
args["testnet"] = value == "true"
|
||
}
|
||
if value := fieldValue(session, "lighter_api_key_index"); value != "" {
|
||
if parsed, err := strconv.Atoi(value); err == nil {
|
||
args["lighter_api_key_index"] = parsed
|
||
}
|
||
}
|
||
raw, _ := json.Marshal(args)
|
||
resp := a.toolManageExchangeConfig(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_exchange_config", resp)
|
||
if lang == "zh" {
|
||
return fmt.Sprintf("已创建交易所配置:%s(%s)。", accountName, exType)
|
||
}
|
||
return fmt.Sprintf("Created exchange config %s (%s).", accountName, exType)
|
||
}
|
||
|
||
func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
|
||
if session.Name == "" {
|
||
session = skillSession{Name: "model_management", Action: "create", Phase: "collecting"}
|
||
}
|
||
if fieldValue(session, skillDAGStepField) == "" {
|
||
setSkillDAGStep(&session, "resolve_provider")
|
||
}
|
||
if isCancelSkillReply(text) {
|
||
a.clearSkillSession(userID)
|
||
if lang == "zh" {
|
||
return "已取消当前创建模型配置流程。"
|
||
}
|
||
return "Cancelled the current model creation flow."
|
||
}
|
||
provider := fieldValue(session, "provider")
|
||
if provider != "" {
|
||
if fieldValue(session, "name") == "" {
|
||
setField(&session, "name", defaultModelConfigName(provider))
|
||
}
|
||
if modelProviderSupportsCustomModel(provider) && fieldValue(session, "custom_model_name") == "" {
|
||
if defaultModel := defaultModelNameForProvider(provider); defaultModel != "" {
|
||
setField(&session, "custom_model_name", defaultModel)
|
||
}
|
||
}
|
||
if !modelProviderSupportsCustomAPIURL(provider) {
|
||
setField(&session, "custom_api_url", "")
|
||
}
|
||
}
|
||
missing := make([]string, 0, 4)
|
||
providerMissing := actionRequiresSlot("model_management", "create", "provider") && provider == ""
|
||
if providerMissing {
|
||
missing = append(missing, slotDisplayName("provider", lang))
|
||
}
|
||
if !providerMissing && fieldValue(session, "api_key") == "" {
|
||
missing = append(missing, modelProviderCredentialLabel(lang, provider))
|
||
}
|
||
if len(missing) > 0 {
|
||
setSkillDAGStep(&session, "resolve_provider")
|
||
a.saveSkillSession(userID, session)
|
||
if lang == "zh" {
|
||
reply := "要创建模型配置,还缺这些字段:" + formatMissingFieldList(lang, missing) + "。"
|
||
if provider == "" {
|
||
reply += "\n" + availableModelProvidersMessage(lang)
|
||
} else {
|
||
reply += "\n" + modelProviderDetailedGuidance(lang, provider)
|
||
}
|
||
return reply
|
||
}
|
||
reply := "One more thing: please tell me these details: " + formatMissingFieldList(lang, missing) + "."
|
||
if provider != "" {
|
||
reply += "\n" + modelProviderDetailedGuidance(lang, provider)
|
||
}
|
||
return reply
|
||
}
|
||
validator := modelConfigValidator{
|
||
provider: provider,
|
||
enabled: fieldValue(session, "enabled") == "true",
|
||
apiKey: fieldValue(session, "api_key"),
|
||
customAPIURL: fieldValue(session, "custom_api_url"),
|
||
customModelName: fieldValue(session, "custom_model_name"),
|
||
}
|
||
if err := validator.Validate(); err != nil {
|
||
a.saveSkillSession(userID, session)
|
||
return formatValidationFeedback(lang, "model", err)
|
||
}
|
||
if !createConfirmationReply(text) {
|
||
session.Phase = "await_create_confirmation"
|
||
setSkillDAGStep(&session, "await_create_confirmation")
|
||
a.saveSkillSession(userID, session)
|
||
return formatModelCreateDraftSummary(lang, session)
|
||
}
|
||
setSkillDAGStep(&session, "execute_create")
|
||
args := map[string]any{
|
||
"action": "create",
|
||
"provider": provider,
|
||
"name": fieldValue(session, "name"),
|
||
"api_key": fieldValue(session, "api_key"),
|
||
"custom_api_url": fieldValue(session, "custom_api_url"),
|
||
"custom_model_name": fieldValue(session, "custom_model_name"),
|
||
}
|
||
if value := fieldValue(session, "enabled"); value != "" {
|
||
args["enabled"] = value == "true"
|
||
}
|
||
raw, _ := json.Marshal(args)
|
||
resp := a.toolManageModelConfig(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_model_config", resp)
|
||
if lang == "zh" {
|
||
return fmt.Sprintf("已创建模型配置:%s。", fieldValue(session, "name"))
|
||
}
|
||
return fmt.Sprintf("Created model config %s.", fieldValue(session, "name"))
|
||
}
|
||
|
||
func inferModelCredentialFromText(provider, text string) string {
|
||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||
text = strings.TrimSpace(text)
|
||
if provider == "" || text == "" {
|
||
return ""
|
||
}
|
||
|
||
if value := extractQuotedContent(text); value != "" {
|
||
trimmed := strings.TrimSpace(value)
|
||
if credentialLooksCompatibleWithProvider(provider, trimmed) {
|
||
return trimmed
|
||
}
|
||
}
|
||
|
||
if credentialLooksCompatibleWithProvider(provider, text) {
|
||
return text
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func credentialLooksCompatibleWithProvider(provider, value string) bool {
|
||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||
value = strings.TrimSpace(value)
|
||
if provider == "" || value == "" {
|
||
return false
|
||
}
|
||
|
||
switch provider {
|
||
case "claw402", "blockrun-base", "blockrun-sol":
|
||
return hexCredentialPattern.MatchString(value)
|
||
case "openai":
|
||
return openAIAPIKeyPattern.MatchString(value)
|
||
default:
|
||
return genericAPIKeyPattern.MatchString(value) || hexCredentialPattern.MatchString(value)
|
||
}
|
||
}
|
||
|
||
func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
|
||
if session.Name == "" {
|
||
session = skillSession{Name: "strategy_management", Action: "create", Phase: "collecting"}
|
||
}
|
||
if fieldValue(session, skillDAGStepField) == "" {
|
||
setSkillDAGStep(&session, "resolve_name")
|
||
}
|
||
if isCancelSkillReply(text) {
|
||
a.clearSkillSession(userID)
|
||
if lang == "zh" {
|
||
return "已取消当前创建策略流程。"
|
||
}
|
||
return "Cancelled the current strategy creation flow."
|
||
}
|
||
name := resolveStrategyCreateName(&session, text)
|
||
if actionRequiresSlot("strategy_management", "create", "name") && name == "" {
|
||
setSkillDAGStep(&session, "resolve_name")
|
||
a.saveSkillSession(userID, session)
|
||
if lang == "zh" {
|
||
return "要创建策略,我还需要:" + slotDisplayName("name", lang) + "。你可以直接说:创建一个叫“趋势策略A”的策略。"
|
||
}
|
||
return "To create a strategy, I need a strategy name. You can say: create a strategy called 'Trend A'."
|
||
}
|
||
if fieldValue(session, "strategy_type") == "" {
|
||
if strategyType := parseStrategyTypeValue(text); strategyType != "" {
|
||
setStrategyCreateType(&session, strategyType)
|
||
}
|
||
} else if strategyType := parseStrategyTypeValue(text); strategyType != "" {
|
||
setStrategyCreateType(&session, strategyType)
|
||
}
|
||
cfg, configMap, warnings, cfgErr := strategyCreateConfigFromSession(session, lang)
|
||
if cfgErr != nil {
|
||
a.saveSkillSession(userID, session)
|
||
if lang == "zh" {
|
||
return "创建策略失败:" + cfgErr.Error()
|
||
}
|
||
return "That strategy config could not be prepared: " + cfgErr.Error()
|
||
}
|
||
if ready, missingKind := strategyCreateConfigReady(session, cfg, text); !ready {
|
||
setField(&session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg))
|
||
setSkillDAGStep(&session, "collect_config")
|
||
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)
|
||
}
|
||
|
||
setSkillDAGStep(&session, "execute_create")
|
||
args := map[string]any{
|
||
"action": "create",
|
||
"name": name,
|
||
"lang": defaultIfEmpty(lang, "zh"),
|
||
"allow_clamped_update": true,
|
||
"confirmed": true,
|
||
}
|
||
if 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)
|
||
return formatCreatedStrategyReply(lang, name, cfg, warnings)
|
||
}
|
||
|
||
func formatCreatedStrategyReply(lang, name string, cfg store.StrategyConfig, warnings []string) string {
|
||
name = defaultIfEmpty(strings.TrimSpace(name), "未命名策略")
|
||
if lang != "zh" {
|
||
name = defaultIfEmpty(strings.TrimSpace(name), "unnamed strategy")
|
||
}
|
||
_ = warnings
|
||
if lang == "zh" {
|
||
lines := []string{fmt.Sprintf("已创建策略“%s”。实际保存配置如下:", name)}
|
||
if cfg.StrategyType == "grid_trading" && cfg.GridConfig != nil {
|
||
grid := cfg.GridConfig
|
||
lines = append(lines,
|
||
"- 类型:网格策略",
|
||
fmt.Sprintf("- 交易对:%s", defaultIfEmpty(grid.Symbol, "未设置")),
|
||
fmt.Sprintf("- 网格数量:%d", grid.GridCount),
|
||
fmt.Sprintf("- 总投入:%.2f USDT", grid.TotalInvestment),
|
||
fmt.Sprintf("- 杠杆:%d倍", grid.Leverage),
|
||
fmt.Sprintf("- 分布方式:%s", defaultIfEmpty(grid.Distribution, "未设置")),
|
||
)
|
||
if grid.UseATRBounds {
|
||
lines = append(lines, fmt.Sprintf("- 价格范围:ATR 自动计算(倍数 %.2f)", grid.ATRMultiplier))
|
||
} else {
|
||
lines = append(lines, fmt.Sprintf("- 价格范围:%.2f ~ %.2f USDT", grid.LowerPrice, grid.UpperPrice))
|
||
}
|
||
lines = append(lines,
|
||
fmt.Sprintf("- 最大回撤:%.2f%%", grid.MaxDrawdownPct),
|
||
fmt.Sprintf("- 止损:%.2f%%", grid.StopLossPct),
|
||
fmt.Sprintf("- 日亏损限制:%.2f%%", grid.DailyLossLimitPct),
|
||
fmt.Sprintf("- 只挂 Maker:%t", grid.UseMakerOnly),
|
||
)
|
||
} else {
|
||
lines = append(lines,
|
||
"- 类型:AI 策略",
|
||
fmt.Sprintf("- 选币来源:%s", defaultIfEmpty(cfg.CoinSource.SourceType, "未设置")),
|
||
fmt.Sprintf("- 主周期:%s", defaultIfEmpty(cfg.Indicators.Klines.PrimaryTimeframe, "未设置")),
|
||
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),
|
||
)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
lines := []string{fmt.Sprintf("Created strategy %q with this saved config:", name)}
|
||
if cfg.StrategyType == "grid_trading" && cfg.GridConfig != nil {
|
||
grid := cfg.GridConfig
|
||
lines = append(lines,
|
||
"- Type: grid strategy",
|
||
fmt.Sprintf("- Symbol: %s", defaultIfEmpty(grid.Symbol, "unset")),
|
||
fmt.Sprintf("- Grid count: %d", grid.GridCount),
|
||
fmt.Sprintf("- Total investment: %.2f USDT", grid.TotalInvestment),
|
||
fmt.Sprintf("- Leverage: %dx", grid.Leverage),
|
||
fmt.Sprintf("- Distribution: %s", defaultIfEmpty(grid.Distribution, "unset")),
|
||
)
|
||
if grid.UseATRBounds {
|
||
lines = append(lines, fmt.Sprintf("- Price range: ATR auto bounds (multiplier %.2f)", grid.ATRMultiplier))
|
||
} else {
|
||
lines = append(lines, fmt.Sprintf("- Price range: %.2f - %.2f USDT", grid.LowerPrice, grid.UpperPrice))
|
||
}
|
||
} else {
|
||
lines = append(lines,
|
||
"- Type: AI strategy",
|
||
fmt.Sprintf("- Coin source: %s", defaultIfEmpty(cfg.CoinSource.SourceType, "unset")),
|
||
fmt.Sprintf("- Primary timeframe: %s", defaultIfEmpty(cfg.Indicators.Klines.PrimaryTimeframe, "unset")),
|
||
)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, text string, session skillSession, skillName, action string, options []traderSkillOption) (string, bool) {
|
||
if session.Name == "" {
|
||
session = skillSession{Name: skillName, Action: action, Phase: "collecting"}
|
||
}
|
||
if session.Name != skillName || session.Action != action {
|
||
return "", false
|
||
}
|
||
if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) {
|
||
setField(&session, "bulk_scope", "all")
|
||
session.TargetRef = nil
|
||
}
|
||
|
||
if dag, ok := getSkillDAG(skillName, action); ok && len(dag.Steps) > 0 {
|
||
currentStep, _ := currentSkillDAGStep(session)
|
||
if currentStep.ID == "resolve_target" {
|
||
if resolved := resolveTargetSelection(text, options, session.TargetRef); resolved.Ref != nil {
|
||
session.TargetRef = resolved.Ref
|
||
}
|
||
if session.TargetRef == nil {
|
||
if !(supportsBulkTargetSelection(skillName, action) && fieldValue(session, "bulk_scope") == "all") {
|
||
setSkillDAGStep(&session, "resolve_target")
|
||
a.saveSkillSession(userID, session)
|
||
label := "可选对象:"
|
||
if lang != "zh" {
|
||
label = "Available targets:"
|
||
}
|
||
optionList := formatOptionList(label, options)
|
||
if lang == "zh" {
|
||
reply := "当前这一步需要先确定目标对象。请告诉我你要操作哪一个。"
|
||
if optionList != "" {
|
||
reply += "\n" + optionList
|
||
}
|
||
return reply, true
|
||
}
|
||
reply := "One more thing: tell me which one you want me to work on."
|
||
if optionList != "" {
|
||
reply += "\n" + optionList
|
||
}
|
||
return reply, true
|
||
}
|
||
}
|
||
if fieldValue(session, skillDAGStepField) == currentStep.ID {
|
||
advanceSkillDAGStep(&session, currentStep.ID)
|
||
}
|
||
}
|
||
} else {
|
||
if resolved := resolveTargetSelection(text, options, session.TargetRef); resolved.Ref != nil {
|
||
session.TargetRef = resolved.Ref
|
||
}
|
||
if session.TargetRef == nil && fieldValue(session, "bulk_scope") != "all" && action != "query" && action != "query_list" && action != "query_detail" && action != "query_running" {
|
||
a.saveSkillSession(userID, session)
|
||
label := formatOptionList("可选对象:", options)
|
||
if lang == "zh" {
|
||
reply := "我还需要你明确要操作的是哪一个对象。"
|
||
if label != "" {
|
||
reply += "\n" + label
|
||
}
|
||
return reply, true
|
||
}
|
||
reply := "One more thing: tell me which one you want to work on."
|
||
if label != "" {
|
||
reply += "\n" + label
|
||
}
|
||
return reply, true
|
||
}
|
||
}
|
||
|
||
if session.TargetRef != nil && action != "create" && action != "query_list" && action != "query_running" {
|
||
if !ensureLiveTargetReference(&session, options) {
|
||
a.saveSkillSession(userID, session)
|
||
label := formatOptionList("可选对象:", options)
|
||
if lang == "zh" {
|
||
reply := "我刚检查了一下,刚才记住的对象已经不存在或已失效了。请重新告诉我要操作哪一个对象。"
|
||
if label != "" {
|
||
reply += "\n" + label
|
||
}
|
||
return reply, true
|
||
}
|
||
reply := "The object remembered from earlier no longer exists. Please tell me which object to operate on now."
|
||
if label != "" {
|
||
reply += "\n" + label
|
||
}
|
||
return reply, true
|
||
}
|
||
}
|
||
|
||
switch skillName {
|
||
case "trader_management":
|
||
return a.executeTraderManagementAction(storeUserID, userID, lang, text, session), true
|
||
case "exchange_management":
|
||
return a.executeExchangeManagementAction(storeUserID, userID, lang, text, session), true
|
||
case "model_management":
|
||
return a.executeModelManagementAction(storeUserID, userID, lang, text, session), true
|
||
case "strategy_management":
|
||
return a.executeStrategyManagementAction(storeUserID, userID, lang, text, session), true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
func (a *Agent) askLLMAmbiguousTargetQuestion(storeUserID string, userID int64, lang, text string, session skillSession, skillName, action string, allOptions, ambiguous []traderSkillOption) string {
|
||
return formatAmbiguousTargetPrompt(lang, ambiguous)
|
||
}
|
||
|
||
func defaultIfEmpty(value, fallback string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return strings.TrimSpace(fallback)
|
||
}
|
||
return value
|
||
}
|