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

@@ -605,6 +605,8 @@ Rules:
- For strategy_management:create: when the user asks you to design/recommend settings, think as the strategy designer, produce a concrete recommended config in your reply, and also put the same structured config into extracted_data.config_patch. Do not ask the user to fill fields you can reasonably choose for them.
- For strategy_management:create: once the structured config is sufficient to create, ask for one final confirmation and set extracted_data.awaiting_final_confirmation=true. Do not execute create in that same turn.
- For strategy_management:create: choose execute_skill only when awaiting_final_confirmation is already true and the current user message confirms the final summary. If the user changes a number, update config_patch and ask for final confirmation again.
- For strategy_management:create: if the previous assistant reply said the strategy was not actually created yet and that the next step is to call the structured create tool, then a user request to continue/proceed means execute the current skill when the structured config is ready. Do not answer with another promise such as "I will create it now"; choose execute_skill.
- For any mutating task, a reply that only promises future execution ("now I will create/update/start it", "result soon") is not a valid finish_task or ask_user outcome. If execution is the next step, choose execute_skill.
- Never choose finish_task for an unfinished mutating active task by claiming it was created/updated/deleted/started/stopped. Only a real skill/tool execution outcome can support that claim.
- If the user says they do not understand the current form, choices, or required information, choose "ask_user" and explain the current pending question in plain language before asking the next easiest question. Cover the relevant concepts from the previous assistant reply; do not collapse the answer to only the first missing field.
- For beginner/confusion replies, give a safe recommended path when the domain supports one, but do not execute or create anything unless the user confirms after the explanation.
@@ -616,7 +618,7 @@ Rules:
- For trader bindings, exchange/model/strategy must resolve to an ID from Relevant disclosed resources before execution. Never invent a resource name or use a generic venue type like Binance/OKX as the bound exchange unless it appears as an actual disclosed resource.
- For strategy_management:create, do not ask for exchange accounts or model bindings. Strategy templates are independent drafts/configs; exchange/model are only needed when creating, deploying, or starting a trader.
- Strategy templates should be visible in the strategy list/page after creation. Do not bring up trader/model/exchange binding unless the user asks to run or deploy.
- For strategy_management:create or strategy_management:update_config, when the user describes strategy intent, output config_patch as a partial StrategyConfig JSON object instead of leaving the default template unchanged. Example: "BTC趋势做空" should set coin_source to static BTCUSDT and add prompt/risk/entry rules for BTC trend-following short bias.
- For strategy_management:create or strategy_management:update_config, when the user describes strategy intent, output config_patch as a partial StrategyConfig JSON object instead of leaving the default template unchanged. The product schema is type-isolated: grid strategies use only top-level strategy_type + grid_config + publish_config; AI strategies use only top-level strategy_type + ai_config + publish_config. For example, "BTC趋势做空" as an AI strategy should set ai_config.coin_source to static BTCUSDT and add ai_config prompt/risk/entry rules.
- If there are multiple targets and the user did not disambiguate, ask a natural question with the available names.
- If the current user message answers a missing field directly, extract it and continue.
- extracted_data must use only canonical keys from Allowed field spec JSON. Never output aliases, translated labels, or raw user wording as keys.

View File

@@ -645,12 +645,6 @@ func TestDescribeStrategyIncludesManualPageSections(t *testing.T) {
EnableDirectionAdjust: true,
DirectionBiasRatio: 0.7,
}
cfg.CoinSource.SourceType = "mixed"
cfg.CoinSource.StaticCoins = []string{"BTCUSDT", "ETHUSDT"}
cfg.CoinSource.ExcludedCoins = []string{"DOGEUSDT"}
cfg.Indicators.EnableOIRanking = true
cfg.Indicators.EnableNetFlowRanking = true
cfg.Indicators.EnablePriceRanking = true
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
@@ -682,11 +676,17 @@ func TestDescribeStrategyIncludesManualPageSections(t *testing.T) {
"发布设置:已发布到市场;配置隐藏",
"网格参数:交易对 BTCUSDT网格 12总投资 1500.00;杠杆 4分布 gaussian",
"网格边界:上沿 120000.0000,下沿 90000.0000",
"标的来源mixed | AI500=3 | static=BTCUSDT,ETHUSDT | excluded=DOGEUSDT",
"NofxOS 数据API Key=true量化数据=trueOI 排行=true净流入排行=true价格排行=true",
} {
if !strings.Contains(detail, expected) {
t.Fatalf("expected strategy detail to contain %q, got: %s", expected, detail)
}
}
for _, unexpected := range []string{
"标的来源:",
"NofxOS 数据:",
} {
if strings.Contains(detail, unexpected) {
t.Fatalf("expected grid strategy detail not to contain AI field %q, got: %s", unexpected, detail)
}
}
}

View File

@@ -255,11 +255,11 @@ func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFl
configPatchDescription := "Partial StrategyConfig JSON patch inferred from the user's strategy intent."
switch explicitStrategyCreateType(session) {
case "grid_trading":
configPatchDescription += " Current strategy_type is grid_trading: use only grid_config and publish/common fields; do not use coin source, indicators, timeframes, confidence, or prompt-section fields."
configPatchDescription += " Current strategy_type is grid_trading: use only top-level strategy_type, grid_config, publish_config, and language. Do not output ai_config or AI fields such as coin_source, indicators, risk_control, timeframes, confidence, or prompt_sections."
case "ai_trading":
configPatchDescription += " Current strategy_type is ai_trading: use coin source, indicators, risk, timeframes, and prompt sections; do not use grid_config fields."
configPatchDescription += " Current strategy_type is ai_trading: use top-level strategy_type, ai_config, publish_config, and language. Put coin_source, indicators, risk_control, prompt_sections, and custom_prompt inside ai_config. Do not output grid_config."
default:
configPatchDescription += " Include strategy_type first when the user chooses AI or grid; after strategy_type is known, use only fields for that type."
configPatchDescription += " Include strategy_type first when the user chooses AI or grid; after strategy_type is known, use only the config branch for that type: grid_config for grid, ai_config for AI."
}
add(&out, "config_patch", configPatchDescription, false)
}

View File

@@ -271,92 +271,99 @@ func strategyConfigSchema() map[string]any {
"type": "object",
"description": "Full or partial strategy config. Only include the fields you want to create or update.",
"properties": map[string]any{
"strategy_type": map[string]any{"type": "string", "enum": []string{"ai_trading", "grid_trading"}, "description": "ai_trading uses coin source, indicators, risk_control, and prompts. grid_trading uses grid_config and publish settings."},
"strategy_type": map[string]any{"type": "string", "enum": []string{"ai_trading", "grid_trading"}, "description": "Top-level discriminator. ai_trading must use ai_config only. grid_trading must use grid_config only."},
"language": map[string]any{"type": "string", "enum": []string{"zh", "en"}},
"coin_source": map[string]any{
"type": "object",
"ai_config": map[string]any{
"type": "object",
"description": "AI trading only. Do not include this for grid_trading.",
"properties": map[string]any{
"source_type": map[string]any{"type": "string", "enum": []string{"static", "ai500", "oi_top", "oi_low", "mixed"}, "description": "Manual page coin source: static, ai500, oi_top, oi_low; mixed can be displayed when already configured."},
"static_coins": stringArraySchema("Static coin symbols such as BTCUSDT or ETHUSDT. Manual page allows at most 10. xyz: assets such as xyz:TSLA, xyz:GOLD, xyz:XYZ100 are also supported."),
"excluded_coins": stringArraySchema("Coin symbols to exclude from all sources."),
"use_ai500": map[string]any{"type": "boolean"},
"ai500_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
"use_oi_top": map[string]any{"type": "boolean"},
"oi_top_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
"use_oi_low": map[string]any{"type": "boolean"},
"oi_low_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
"use_hyper_all": map[string]any{"type": "boolean"},
"use_hyper_main": map[string]any{"type": "boolean"},
"hyper_main_limit": map[string]any{"type": "number"},
},
},
"indicators": map[string]any{
"type": "object",
"properties": map[string]any{
"klines": map[string]any{
"coin_source": map[string]any{
"type": "object",
"properties": map[string]any{
"primary_timeframe": map[string]any{"type": "string", "enum": []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"}},
"primary_count": map[string]any{"type": "number", "minimum": 10, "maximum": 30, "description": "Manual page range 10-30."},
"longer_timeframe": map[string]any{"type": "string"},
"longer_count": map[string]any{"type": "number"},
"enable_multi_timeframe": map[string]any{"type": "boolean"},
"selected_timeframes": stringArraySchema("Selected analysis timeframes. Allowed values: 1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w. Manual page allows at most 4."),
"source_type": map[string]any{"type": "string", "enum": []string{"static", "ai500", "oi_top", "oi_low", "mixed"}, "description": "Manual page coin source: static, ai500, oi_top, oi_low; mixed can be displayed when already configured."},
"static_coins": stringArraySchema("Static coin symbols such as BTCUSDT or ETHUSDT. Manual page allows at most 10. xyz: assets such as xyz:TSLA, xyz:GOLD, xyz:XYZ100 are also supported."),
"excluded_coins": stringArraySchema("Coin symbols to exclude from all sources."),
"use_ai500": map[string]any{"type": "boolean"},
"ai500_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
"use_oi_top": map[string]any{"type": "boolean"},
"oi_top_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
"use_oi_low": map[string]any{"type": "boolean"},
"oi_low_limit": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10."},
"use_hyper_all": map[string]any{"type": "boolean"},
"use_hyper_main": map[string]any{"type": "boolean"},
"hyper_main_limit": map[string]any{"type": "number"},
},
},
"indicators": map[string]any{
"type": "object",
"properties": map[string]any{
"klines": map[string]any{
"type": "object",
"properties": map[string]any{
"primary_timeframe": map[string]any{"type": "string", "enum": []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"}},
"primary_count": map[string]any{"type": "number", "minimum": 10, "maximum": 30, "description": "Manual page range 10-30."},
"longer_timeframe": map[string]any{"type": "string"},
"longer_count": map[string]any{"type": "number"},
"enable_multi_timeframe": map[string]any{"type": "boolean"},
"selected_timeframes": stringArraySchema("Selected analysis timeframes. Allowed values: 1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w. Manual page allows at most 4."),
},
},
"enable_raw_klines": map[string]any{"type": "boolean"},
"enable_ema": map[string]any{"type": "boolean"},
"enable_macd": map[string]any{"type": "boolean"},
"enable_rsi": map[string]any{"type": "boolean"},
"enable_atr": map[string]any{"type": "boolean"},
"enable_boll": map[string]any{"type": "boolean"},
"enable_volume": map[string]any{"type": "boolean"},
"enable_oi": map[string]any{"type": "boolean"},
"enable_funding_rate": map[string]any{"type": "boolean"},
"ema_periods": intArraySchema("EMA periods such as [20,50]."),
"rsi_periods": intArraySchema("RSI periods such as [7,14]."),
"atr_periods": intArraySchema("ATR periods such as [14]."),
"boll_periods": intArraySchema("BOLL periods such as [20]."),
"nofxos_api_key": map[string]any{"type": "string"},
"enable_quant_data": map[string]any{"type": "boolean"},
"enable_quant_oi": map[string]any{"type": "boolean"},
"enable_quant_netflow": map[string]any{"type": "boolean"},
"enable_oi_ranking": map[string]any{"type": "boolean"},
"oi_ranking_duration": map[string]any{"type": "string", "enum": []string{"1h", "4h", "24h"}},
"oi_ranking_limit": map[string]any{"type": "number", "enum": []int{5, 10, 15, 20}},
"enable_netflow_ranking": map[string]any{"type": "boolean"},
"netflow_ranking_duration": map[string]any{"type": "string", "enum": []string{"1h", "4h", "24h"}},
"netflow_ranking_limit": map[string]any{"type": "number", "enum": []int{5, 10, 15, 20}},
"enable_price_ranking": map[string]any{"type": "boolean"},
"price_ranking_duration": map[string]any{"type": "string", "enum": []string{"1h", "4h", "24h", "1h,4h,24h"}},
"price_ranking_limit": map[string]any{"type": "number", "enum": []int{5, 10, 15, 20}},
},
},
"custom_prompt": map[string]any{"type": "string"},
"risk_control": map[string]any{
"type": "object",
"properties": map[string]any{
"max_positions": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless the user explicitly asks for advanced configuration."},
"btc_eth_max_leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 20},
"altcoin_max_leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 20},
"btc_eth_max_position_value_ratio": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless explicitly requested."},
"altcoin_max_position_value_ratio": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless explicitly requested."},
"max_margin_usage": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless explicitly requested."},
"min_risk_reward_ratio": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10, step 0.5."},
"min_confidence": map[string]any{"type": "number", "minimum": 50, "maximum": 100, "description": "Manual page range 50-100."},
},
},
"prompt_sections": map[string]any{
"type": "object",
"properties": map[string]any{
"role_definition": map[string]any{"type": "string"},
"trading_frequency": map[string]any{"type": "string"},
"entry_standards": map[string]any{"type": "string"},
"decision_process": map[string]any{"type": "string"},
},
},
"enable_raw_klines": map[string]any{"type": "boolean"},
"enable_ema": map[string]any{"type": "boolean"},
"enable_macd": map[string]any{"type": "boolean"},
"enable_rsi": map[string]any{"type": "boolean"},
"enable_atr": map[string]any{"type": "boolean"},
"enable_boll": map[string]any{"type": "boolean"},
"enable_volume": map[string]any{"type": "boolean"},
"enable_oi": map[string]any{"type": "boolean"},
"enable_funding_rate": map[string]any{"type": "boolean"},
"ema_periods": intArraySchema("EMA periods such as [20,50]."),
"rsi_periods": intArraySchema("RSI periods such as [7,14]."),
"atr_periods": intArraySchema("ATR periods such as [14]."),
"boll_periods": intArraySchema("BOLL periods such as [20]."),
"nofxos_api_key": map[string]any{"type": "string"},
"enable_quant_data": map[string]any{"type": "boolean"},
"enable_quant_oi": map[string]any{"type": "boolean"},
"enable_quant_netflow": map[string]any{"type": "boolean"},
"enable_oi_ranking": map[string]any{"type": "boolean"},
"oi_ranking_duration": map[string]any{"type": "string", "enum": []string{"1h", "4h", "24h"}},
"oi_ranking_limit": map[string]any{"type": "number", "enum": []int{5, 10, 15, 20}},
"enable_netflow_ranking": map[string]any{"type": "boolean"},
"netflow_ranking_duration": map[string]any{"type": "string", "enum": []string{"1h", "4h", "24h"}},
"netflow_ranking_limit": map[string]any{"type": "number", "enum": []int{5, 10, 15, 20}},
"enable_price_ranking": map[string]any{"type": "boolean"},
"price_ranking_duration": map[string]any{"type": "string", "enum": []string{"1h", "4h", "24h", "1h,4h,24h"}},
"price_ranking_limit": map[string]any{"type": "number", "enum": []int{5, 10, 15, 20}},
},
},
"custom_prompt": map[string]any{"type": "string"},
"risk_control": map[string]any{
"type": "object",
"properties": map[string]any{
"max_positions": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless the user explicitly asks for advanced configuration."},
"btc_eth_max_leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 20},
"altcoin_max_leverage": map[string]any{"type": "number", "minimum": 1, "maximum": 20},
"btc_eth_max_position_value_ratio": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless explicitly requested."},
"altcoin_max_position_value_ratio": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless explicitly requested."},
"max_margin_usage": map[string]any{"type": "number", "description": "Displayed as System enforced on the manual strategy page; do not change unless explicitly requested."},
"min_risk_reward_ratio": map[string]any{"type": "number", "minimum": 1, "maximum": 10, "description": "Manual page range 1-10, step 0.5."},
"min_confidence": map[string]any{"type": "number", "minimum": 50, "maximum": 100, "description": "Manual page range 50-100."},
},
},
"prompt_sections": map[string]any{
"type": "object",
"properties": map[string]any{
"role_definition": map[string]any{"type": "string"},
"trading_frequency": map[string]any{"type": "string"},
"entry_standards": map[string]any{"type": "string"},
"decision_process": map[string]any{"type": "string"},
},
},
"grid_config": map[string]any{
"type": "object",
"description": "Grid trading only. Do not include this for ai_trading.",
"type": "object",
"properties": map[string]any{
"symbol": map[string]any{"type": "string", "enum": []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT"}, "description": "Manual page dropdown options for grid trading symbols."},
"grid_count": map[string]any{"type": "number", "minimum": 5, "maximum": 50, "description": "Manual page range 5-50."},
@@ -375,6 +382,14 @@ func strategyConfigSchema() map[string]any {
"direction_bias_ratio": map[string]any{"type": "number", "minimum": 0.55, "maximum": 0.9, "description": "Manual page range 0.55-0.90 (shown as 55%-90%)."},
},
},
"publish_config": map[string]any{
"type": "object",
"description": "Shared publish settings for both AI and grid strategies.",
"properties": map[string]any{
"is_public": map[string]any{"type": "boolean"},
"config_visible": map[string]any{"type": "boolean"},
},
},
},
}
}

View File

@@ -20,6 +20,9 @@ import (
// validateStrategyConfig validates strategy configuration and returns warnings
func validateStrategyConfig(config *store.StrategyConfig) []string {
var warnings []string
if config.StrategyType == "grid_trading" {
return warnings
}
// Validate NofxOS API key if any NofxOS feature is enabled
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
@@ -31,6 +34,16 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
return warnings
}
func attachPublishConfig(config *store.StrategyConfig, strategy *store.Strategy) {
if config == nil || strategy == nil {
return
}
config.PublishConfig = &store.PublishStrategyConfig{
IsPublic: strategy.IsPublic,
ConfigVisible: strategy.ConfigVisible,
}
}
// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation)
func (s *Server) handleEstimateTokens(c *gin.Context) {
var req struct {
@@ -71,6 +84,7 @@ func (s *Server) handlePublicStrategies(c *gin.Context) {
if st.ConfigVisible {
var config store.StrategyConfig
json.Unmarshal([]byte(st.Config), &config)
attachPublishConfig(&config, st)
item["config"] = config
}
@@ -101,6 +115,7 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
for _, st := range strategies {
var config store.StrategyConfig
json.Unmarshal([]byte(st.Config), &config)
attachPublishConfig(&config, st)
result = append(result, gin.H{
"id": st.ID,
@@ -139,6 +154,7 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
var config store.StrategyConfig
json.Unmarshal([]byte(strategy.Config), &config)
attachPublishConfig(&config, strategy)
c.JSON(http.StatusOK, gin.H{
"id": strategy.ID,
@@ -162,10 +178,12 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
}
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -184,6 +202,17 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
}
beforeClamp := *req.Config
req.Config.ClampLimits()
hadPublishConfig := req.Config.PublishConfig != nil
isPublic := req.IsPublic
configVisible := req.ConfigVisible
if hadPublishConfig {
isPublic = req.Config.PublishConfig.IsPublic
configVisible = req.Config.PublishConfig.ConfigVisible
}
req.Config.PublishConfig = &store.PublishStrategyConfig{
IsPublic: isPublic,
ConfigVisible: configVisible,
}
// Serialize configuration
configJSON, err := json.Marshal(req.Config)
@@ -199,7 +228,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
Description: req.Description,
IsActive: false,
IsDefault: false,
Config: string(configJSON),
IsPublic: isPublic,
// Existing default is true; keep that behavior when no explicit publish config is sent.
ConfigVisible: configVisible || !hadPublishConfig,
Config: string(configJSON),
}
if err := s.store.Strategy().Create(strategy); err != nil {

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))
}
}

View File

@@ -12,6 +12,17 @@ function getShortName(fullName: string): string {
return parts.length > 1 ? parts[parts.length - 1] : fullName
}
function getStrategyAIConfig(strategy: Strategy) {
return strategy.config.ai_config || (
strategy.config.coin_source && strategy.config.risk_control
? {
coin_source: strategy.config.coin_source,
risk_control: strategy.config.risk_control,
}
: null
)
}
// 交易所注册链接配置
const EXCHANGE_REGISTRATION_LINKS: Record<string, { url: string; hasReferral?: boolean }> = {
binance: { url: 'https://www.binance.com/join?ref=NOFXENG', hasReferral: true },
@@ -314,16 +325,27 @@ export function TraderConfigModal({
<p className="text-sm text-[#848E9C] mb-2">
{selectedStrategy.description || (language === 'zh' ? '无描述' : 'No description')}
</p>
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
<div>
{t('coinSource', language)}: {selectedStrategy.config.coin_source.source_type === 'static' ? '固定币种' :
selectedStrategy.config.coin_source.source_type === 'ai500' ? 'AI500' :
selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
{selectedStrategy.config.strategy_type === 'grid_trading' && selectedStrategy.config.grid_config ? (
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
<div>{language === 'zh' ? '交易对' : 'Symbol'}: {selectedStrategy.config.grid_config.symbol || '-'}</div>
<div>{language === 'zh' ? '网格数' : 'Grids'}: {selectedStrategy.config.grid_config.grid_count}</div>
</div>
<div>
{t('marginLimit', language)}: {((selectedStrategy.config.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%
</div>
</div>
) : (() => {
const aiConfig = getStrategyAIConfig(selectedStrategy)
if (!aiConfig) return null
return (
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
<div>
{t('coinSource', language)}: {aiConfig.coin_source.source_type === 'static' ? '固定币种' :
aiConfig.coin_source.source_type === 'ai500' ? 'AI500' :
aiConfig.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
</div>
<div>
{t('marginLimit', language)}: {((aiConfig.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%
</div>
</div>
)
})()}
</div>
)}
</div>

View File

@@ -179,16 +179,17 @@ export function StrategyMarketPage() {
}
const getIndicatorList = (config: any) => {
if (!config?.indicators) return []
const indicatorsConfig = config?.ai_config?.indicators || config?.indicators
if (!indicatorsConfig) return []
const indicators = []
if (config.indicators.enable_ema) indicators.push('EMA')
if (config.indicators.enable_macd) indicators.push('MACD')
if (config.indicators.enable_rsi) indicators.push('RSI')
if (config.indicators.enable_atr) indicators.push('ATR')
if (config.indicators.enable_boll) indicators.push('BOLL')
if (config.indicators.enable_volume) indicators.push('VOL')
if (config.indicators.enable_oi) indicators.push('OI')
if (config.indicators.enable_funding_rate) indicators.push('FR')
if (indicatorsConfig.enable_ema) indicators.push('EMA')
if (indicatorsConfig.enable_macd) indicators.push('MACD')
if (indicatorsConfig.enable_rsi) indicators.push('RSI')
if (indicatorsConfig.enable_atr) indicators.push('ATR')
if (indicatorsConfig.enable_boll) indicators.push('BOLL')
if (indicatorsConfig.enable_volume) indicators.push('VOL')
if (indicatorsConfig.enable_oi) indicators.push('OI')
if (indicatorsConfig.enable_funding_rate) indicators.push('FR')
return indicators
}
@@ -439,7 +440,7 @@ export function StrategyMarketPage() {
</div>
{/* Risk Control */}
{strategy.config.risk_control && (
{(strategy.config.ai_config?.risk_control || strategy.config.risk_control) && (
<div className="flex justify-between items-center text-[10px]">
<div className="flex gap-3">
<div className="flex flex-col">
@@ -447,7 +448,7 @@ export function StrategyMarketPage() {
LEV
</span>
<span className="text-zinc-300 font-bold">
{strategy.config.risk_control
{(strategy.config.ai_config?.risk_control || strategy.config.risk_control)
.btc_eth_max_leverage || '-'}
x
</span>
@@ -457,7 +458,7 @@ export function StrategyMarketPage() {
POS
</span>
<span className="text-zinc-300 font-bold">
{strategy.config.risk_control
{(strategy.config.ai_config?.risk_control || strategy.config.risk_control)
.max_positions || '-'}
</span>
</div>

View File

@@ -33,6 +33,7 @@ import {
import type {
Strategy,
StrategyConfig,
AIStrategyConfig,
AIModel,
GridStrategyConfig,
} from '../types'
@@ -52,6 +53,32 @@ import { t } from '../i18n/translations'
const API_BASE = import.meta.env.VITE_API_BASE || ''
const getAIConfig = (config: StrategyConfig): AIStrategyConfig | null => {
if (config.ai_config) return config.ai_config
if (config.coin_source && config.indicators && config.risk_control) {
return {
coin_source: config.coin_source,
indicators: config.indicators,
risk_control: config.risk_control,
prompt_sections: config.prompt_sections,
custom_prompt: config.custom_prompt,
}
}
return null
}
const normalizeStrategyConfig = (config: StrategyConfig): StrategyConfig => {
const aiConfig = getAIConfig(config)
const strategyType = config.strategy_type || 'ai_trading'
return {
strategy_type: strategyType,
language: config.language,
ai_config: aiConfig || undefined,
grid_config: config.grid_config,
publish_config: config.publish_config,
}
}
export function StrategyStudioPage() {
const { token } = useAuth()
const { language } = useLanguage()
@@ -164,7 +191,7 @@ export function StrategyStudioPage() {
selectedStrategyIDRef.current = nextSelected?.id || ''
if (!hasChangesRef.current || !preservedSelection) {
setEditingConfig(nextSelected?.config || null)
setEditingConfig(nextSelected?.config ? normalizeStrategyConfig(nextSelected.config) : null)
}
if (!nextSelected) {
setEditingConfig(null)
@@ -234,7 +261,7 @@ export function StrategyStudioPage() {
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!response.ok) return
const defaultConfig = await response.json()
const defaultConfig = normalizeStrategyConfig(await response.json())
// Update only the prompt sections and language field
setEditingConfig((prev) => {
@@ -242,7 +269,12 @@ export function StrategyStudioPage() {
return {
...prev,
language: language as 'zh' | 'en',
prompt_sections: defaultConfig.prompt_sections,
ai_config: prev.ai_config
? {
...prev.ai_config,
prompt_sections: defaultConfig.ai_config?.prompt_sections,
}
: prev.ai_config,
}
})
setHasChanges(true)
@@ -263,7 +295,7 @@ export function StrategyStudioPage() {
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!configResponse.ok) throw new Error('Failed to fetch default config')
const defaultConfig = await configResponse.json()
const defaultConfig = normalizeStrategyConfig(await configResponse.json())
const response = await fetch(`${API_BASE}/api/strategies`, {
method: 'POST',
@@ -479,7 +511,7 @@ export function StrategyStudioPage() {
try {
// Always sync the config language with the current interface language
const configWithLanguage = {
...editingConfig,
...normalizeStrategyConfig(editingConfig),
language: language as 'zh' | 'en',
}
const response = await fetch(
@@ -525,6 +557,23 @@ export function StrategyStudioPage() {
setHasChanges(true)
}
const updateAIConfig = <K extends keyof AIStrategyConfig>(
section: K,
value: AIStrategyConfig[K]
) => {
setEditingConfig((prev) => {
if (!prev || !prev.ai_config) return prev
return {
...prev,
ai_config: {
...prev.ai_config,
[section]: value,
},
}
})
setHasChanges(true)
}
const handleStrategyTypeChange = (
strategyType: NonNullable<StrategyConfig['strategy_type']>
) => {
@@ -547,6 +596,7 @@ export function StrategyStudioPage() {
return {
...prev,
strategy_type: 'ai_trading',
ai_config: getAIConfig(prev) || prev.ai_config,
// Use null so the field is preserved in JSON and backend merge can actually clear it.
grid_config: null,
}
@@ -555,6 +605,7 @@ export function StrategyStudioPage() {
return {
...prev,
strategy_type: 'grid_trading',
ai_config: undefined,
grid_config: cachedGridConfig ??
prev.grid_config ?? { ...defaultGridConfig },
}
@@ -643,6 +694,7 @@ export function StrategyStudioPage() {
// Get current strategy type (default to ai_trading if not set)
const currentStrategyType = editingConfig?.strategy_type || 'ai_trading'
const currentAIConfig = editingConfig ? getAIConfig(editingConfig) : null
const configSections = [
// Grid Config - only for grid_trading
@@ -668,10 +720,10 @@ export function StrategyStudioPage() {
color: '#F0B90B',
title: tr('coinSource'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
content: currentAIConfig && (
<CoinSourceEditor
config={editingConfig.coin_source}
onChange={(coinSource) => updateConfig('coin_source', coinSource)}
config={currentAIConfig.coin_source}
onChange={(coinSource) => updateAIConfig('coin_source', coinSource)}
disabled={selectedStrategy?.is_default}
language={language}
/>
@@ -683,10 +735,10 @@ export function StrategyStudioPage() {
color: '#0ECB81',
title: tr('indicators'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
content: currentAIConfig && (
<IndicatorEditor
config={editingConfig.indicators}
onChange={(indicators) => updateConfig('indicators', indicators)}
config={currentAIConfig.indicators}
onChange={(indicators) => updateAIConfig('indicators', indicators)}
disabled={selectedStrategy?.is_default}
language={language}
/>
@@ -698,10 +750,10 @@ export function StrategyStudioPage() {
color: '#F6465D',
title: tr('riskControl'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
content: currentAIConfig && (
<RiskControlEditor
config={editingConfig.risk_control}
onChange={(riskControl) => updateConfig('risk_control', riskControl)}
config={currentAIConfig.risk_control}
onChange={(riskControl) => updateAIConfig('risk_control', riskControl)}
disabled={selectedStrategy?.is_default}
language={language}
/>
@@ -713,11 +765,11 @@ export function StrategyStudioPage() {
color: '#a855f7',
title: tr('promptSections'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
content: currentAIConfig && (
<PromptSectionsEditor
config={editingConfig.prompt_sections}
config={currentAIConfig.prompt_sections}
onChange={(promptSections) =>
updateConfig('prompt_sections', promptSections)
updateAIConfig('prompt_sections', promptSections)
}
disabled={selectedStrategy?.is_default}
language={language}
@@ -730,14 +782,14 @@ export function StrategyStudioPage() {
color: '#60a5fa',
title: tr('customPrompt'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
content: currentAIConfig && (
<div>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{tr('customPromptDesc')}
</p>
<textarea
value={editingConfig.custom_prompt || ''}
onChange={(e) => updateConfig('custom_prompt', e.target.value)}
value={currentAIConfig.custom_prompt || ''}
onChange={(e) => updateAIConfig('custom_prompt', e.target.value)}
disabled={selectedStrategy?.is_default}
placeholder={tr('customPromptPlaceholder')}
className="w-full h-32 px-3 py-2 rounded-lg resize-none font-mono text-xs"
@@ -848,7 +900,7 @@ export function StrategyStudioPage() {
key={strategy.id}
onClick={() => {
setSelectedStrategy(strategy)
setEditingConfig(strategy.config)
setEditingConfig(normalizeStrategyConfig(strategy.config))
setHasChanges(false)
setPromptPreview(null)
setAiTestResult(null)

View File

@@ -44,13 +44,30 @@ export interface StrategyConfig {
// Language setting: "zh" for Chinese, "en" for English
// Determines the language used for data formatting and prompt generation
language?: 'zh' | 'en';
// AI trading configuration. Legacy flat fields below are accepted only for
// old data returned before the schema was split by strategy type.
ai_config?: AIStrategyConfig;
coin_source?: CoinSourceConfig;
indicators?: IndicatorConfig;
custom_prompt?: string;
risk_control?: RiskControlConfig;
prompt_sections?: PromptSectionsConfig;
// Grid trading configuration (only used when strategy_type is 'grid_trading')
grid_config?: GridStrategyConfig | null;
publish_config?: PublishStrategyConfig;
}
export interface AIStrategyConfig {
coin_source: CoinSourceConfig;
indicators: IndicatorConfig;
custom_prompt?: string;
risk_control: RiskControlConfig;
prompt_sections?: PromptSectionsConfig;
// Grid trading configuration (only used when strategy_type is 'grid_trading')
grid_config?: GridStrategyConfig | null;
}
export interface PublishStrategyConfig {
is_public: boolean;
config_visible: boolean;
}
// Grid trading specific configuration