Split strategy config by strategy type

This commit is contained in:
lky-spec
2026-04-28 20:19:24 +08:00
parent 2d45e7ab15
commit 25d0b30ea9
11 changed files with 502 additions and 148 deletions

View File

@@ -161,6 +161,32 @@ func normalizeStrategyConfigPatch(patch map[string]any) {
if patch == nil {
return
}
if gridConfig, hasGrid := patch["grid_config"]; hasGrid && gridConfig != nil {
if _, hasType := patch["strategy_type"]; !hasType {
patch["strategy_type"] = "grid_trading"
}
}
aiKeys := []string{"coin_source", "indicators", "risk_control", "prompt_sections", "custom_prompt"}
for _, key := range aiKeys {
value, ok := patch[key]
if !ok {
continue
}
aiConfig, _ := patch["ai_config"].(map[string]any)
if aiConfig == nil {
aiConfig = map[string]any{}
patch["ai_config"] = aiConfig
}
aiConfig[key] = value
delete(patch, key)
}
if fmt.Sprint(patch["strategy_type"]) == "grid_trading" {
delete(patch, "ai_config")
}
if _, hasType := patch["strategy_type"]; hasType {
return
}
@@ -249,19 +275,128 @@ type StrategyConfig struct {
// language setting: "zh" for Chinese, "en" for English
// This determines the language used for data formatting and prompt generation
Language string `json:"language,omitempty"`
// coin source configuration
CoinSource CoinSourceConfig `json:"coin_source"`
// quantitative data configuration
Indicators IndicatorConfig `json:"indicators"`
// custom prompt (appended at the end)
CustomPrompt string `json:"custom_prompt,omitempty"`
// risk control configuration
RiskControl RiskControlConfig `json:"risk_control"`
// editable sections of System Prompt
PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"`
// AI trading configuration fields are kept on the Go struct for engine
// compatibility, but JSON persistence nests them under ai_config.
CoinSource CoinSourceConfig `json:"-"`
Indicators IndicatorConfig `json:"-"`
CustomPrompt string `json:"-"`
RiskControl RiskControlConfig `json:"-"`
PromptSections PromptSectionsConfig `json:"-"`
// Grid trading configuration (only used when StrategyType == "grid_trading")
GridConfig *GridStrategyConfig `json:"grid_config,omitempty"`
// Publish settings are shared by AI and grid strategies. The database still
// stores the authoritative booleans on Strategy, but config JSON may carry
// this object for agent/frontend schema consistency.
PublishConfig *PublishStrategyConfig `json:"publish_config,omitempty"`
}
// AIStrategyConfig contains fields only used by AI trading strategies.
type AIStrategyConfig struct {
CoinSource CoinSourceConfig `json:"coin_source"`
Indicators IndicatorConfig `json:"indicators"`
CustomPrompt string `json:"custom_prompt,omitempty"`
RiskControl RiskControlConfig `json:"risk_control"`
PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"`
}
// PublishStrategyConfig contains settings shared by all strategy types.
type PublishStrategyConfig struct {
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
}
// MarshalJSON writes the product-facing strategy schema:
// strategy_type + grid_config or ai_config + shared publish_config.
func (c StrategyConfig) MarshalJSON() ([]byte, error) {
strategyType := strings.TrimSpace(c.StrategyType)
if strategyType == "" {
strategyType = "ai_trading"
}
out := struct {
StrategyType string `json:"strategy_type"`
Language string `json:"language,omitempty"`
AIConfig *AIStrategyConfig `json:"ai_config,omitempty"`
GridConfig *GridStrategyConfig `json:"grid_config,omitempty"`
PublishConfig *PublishStrategyConfig `json:"publish_config,omitempty"`
}{
StrategyType: strategyType,
Language: c.Language,
PublishConfig: c.PublishConfig,
}
if strategyType == "grid_trading" {
out.GridConfig = c.GridConfig
} else {
out.AIConfig = &AIStrategyConfig{
CoinSource: c.CoinSource,
Indicators: c.Indicators,
CustomPrompt: c.CustomPrompt,
RiskControl: c.RiskControl,
PromptSections: c.PromptSections,
}
}
return json.Marshal(out)
}
// UnmarshalJSON accepts both the new nested schema and old flat configs. Old
// top-level AI fields are normalized into the Go compatibility fields.
func (c *StrategyConfig) UnmarshalJSON(data []byte) error {
type rawStrategyConfig struct {
StrategyType string `json:"strategy_type"`
Language string `json:"language"`
AIConfig *AIStrategyConfig `json:"ai_config"`
GridConfig *GridStrategyConfig `json:"grid_config"`
PublishConfig *PublishStrategyConfig `json:"publish_config"`
CoinSource *CoinSourceConfig `json:"coin_source"`
Indicators *IndicatorConfig `json:"indicators"`
CustomPrompt *string `json:"custom_prompt"`
RiskControl *RiskControlConfig `json:"risk_control"`
PromptSections *PromptSectionsConfig `json:"prompt_sections"`
}
var raw rawStrategyConfig
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
c.StrategyType = raw.StrategyType
c.Language = raw.Language
c.GridConfig = raw.GridConfig
c.PublishConfig = raw.PublishConfig
if raw.AIConfig != nil {
c.CoinSource = raw.AIConfig.CoinSource
c.Indicators = raw.AIConfig.Indicators
c.CustomPrompt = raw.AIConfig.CustomPrompt
c.RiskControl = raw.AIConfig.RiskControl
c.PromptSections = raw.AIConfig.PromptSections
} else {
if raw.CoinSource != nil {
c.CoinSource = *raw.CoinSource
}
if raw.Indicators != nil {
c.Indicators = *raw.Indicators
}
if raw.CustomPrompt != nil {
c.CustomPrompt = *raw.CustomPrompt
}
if raw.RiskControl != nil {
c.RiskControl = *raw.RiskControl
}
if raw.PromptSections != nil {
c.PromptSections = *raw.PromptSections
}
}
if strings.TrimSpace(c.StrategyType) == "" && c.GridConfig != nil {
c.StrategyType = "grid_trading"
}
return nil
}
// GridStrategyConfig grid trading specific configuration

View File

@@ -0,0 +1,78 @@
package store
import (
"encoding/json"
"testing"
)
func TestStrategyConfigMarshalSeparatesGridAndAIConfig(t *testing.T) {
cfg := GetDefaultStrategyConfig("zh")
cfg.StrategyType = "grid_trading"
cfg.GridConfig = &GridStrategyConfig{
Symbol: "BTCUSDT",
GridCount: 20,
TotalInvestment: 200,
Leverage: 2,
UseATRBounds: true,
ATRMultiplier: 2,
Distribution: "uniform",
}
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal grid config: %v", err)
}
var asMap map[string]any
if err := json.Unmarshal(raw, &asMap); err != nil {
t.Fatalf("unmarshal grid config map: %v", err)
}
if asMap["strategy_type"] != "grid_trading" {
t.Fatalf("expected grid strategy_type, got %v", asMap["strategy_type"])
}
if _, ok := asMap["grid_config"]; !ok {
t.Fatalf("expected grid_config in grid strategy JSON: %s", string(raw))
}
for _, key := range []string{"ai_config", "coin_source", "indicators", "risk_control", "prompt_sections", "custom_prompt"} {
if _, ok := asMap[key]; ok {
t.Fatalf("did not expect %s in grid strategy JSON: %s", key, string(raw))
}
}
}
func TestStrategyConfigUnmarshalLegacyFlatAIConfig(t *testing.T) {
raw := []byte(`{
"strategy_type":"ai_trading",
"coin_source":{"source_type":"static","static_coins":["ETHUSDT"]},
"indicators":{"klines":{"primary_timeframe":"15m"}},
"risk_control":{"max_positions":2,"min_confidence":80},
"prompt_sections":{"entry_standards":"trend only"},
"custom_prompt":"prefer ETH"
}`)
var cfg StrategyConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatalf("unmarshal legacy flat config: %v", err)
}
if cfg.CoinSource.SourceType != "static" || len(cfg.CoinSource.StaticCoins) != 1 || cfg.CoinSource.StaticCoins[0] != "ETHUSDT" {
t.Fatalf("legacy coin source was not normalized: %+v", cfg.CoinSource)
}
if cfg.Indicators.Klines.PrimaryTimeframe != "15m" {
t.Fatalf("legacy indicators were not normalized: %+v", cfg.Indicators.Klines)
}
normalized, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal normalized config: %v", err)
}
var asMap map[string]any
if err := json.Unmarshal(normalized, &asMap); err != nil {
t.Fatalf("unmarshal normalized map: %v", err)
}
if _, ok := asMap["ai_config"]; !ok {
t.Fatalf("expected ai_config after normalizing legacy config: %s", string(normalized))
}
if _, ok := asMap["coin_source"]; ok {
t.Fatalf("did not expect legacy coin_source at top level: %s", string(normalized))
}
}