Refine strategy creation flow and diagnostics

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

View File

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

View File

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