From 25d0b30ea994d8f91d95a21adabec216771987e1 Mon Sep 17 00:00:00 2001 From: lky-spec Date: Tue, 28 Apr 2026 20:19:24 +0800 Subject: [PATCH] Split strategy config by strategy type --- agent/central_brain.go | 4 +- agent/config_visibility_test.go | 16 +- agent/llm_flow_extractor.go | 6 +- agent/tools.go | 169 ++++++++++-------- api/strategy.go | 42 ++++- store/strategy.go | 155 ++++++++++++++-- store/strategy_schema_test.go | 78 ++++++++ .../components/trader/TraderConfigModal.tsx | 40 ++++- web/src/pages/StrategyMarketPage.tsx | 25 +-- web/src/pages/StrategyStudioPage.tsx | 94 +++++++--- web/src/types/strategy.ts | 21 ++- 11 files changed, 502 insertions(+), 148 deletions(-) create mode 100644 store/strategy_schema_test.go diff --git a/agent/central_brain.go b/agent/central_brain.go index 2e5955dd..277f0fd9 100644 --- a/agent/central_brain.go +++ b/agent/central_brain.go @@ -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. diff --git a/agent/config_visibility_test.go b/agent/config_visibility_test.go index affedef6..df9666f9 100644 --- a/agent/config_visibility_test.go +++ b/agent/config_visibility_test.go @@ -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,量化数据=true,OI 排行=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) + } + } } diff --git a/agent/llm_flow_extractor.go b/agent/llm_flow_extractor.go index d4fcaeb3..4a6d10f6 100644 --- a/agent/llm_flow_extractor.go +++ b/agent/llm_flow_extractor.go @@ -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) } diff --git a/agent/tools.go b/agent/tools.go index 1651ffcb..26e77395 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -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"}, + }, + }, }, } } diff --git a/api/strategy.go b/api/strategy.go index 72b5ce2c..69e3021d 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -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 { diff --git a/store/strategy.go b/store/strategy.go index 74001809..3831110c 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -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 diff --git a/store/strategy_schema_test.go b/store/strategy_schema_test.go new file mode 100644 index 00000000..7e7622eb --- /dev/null +++ b/store/strategy_schema_test.go @@ -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)) + } +} diff --git a/web/src/components/trader/TraderConfigModal.tsx b/web/src/components/trader/TraderConfigModal.tsx index f5408e0d..c8155b1b 100644 --- a/web/src/components/trader/TraderConfigModal.tsx +++ b/web/src/components/trader/TraderConfigModal.tsx @@ -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 = { binance: { url: 'https://www.binance.com/join?ref=NOFXENG', hasReferral: true }, @@ -314,16 +325,27 @@ export function TraderConfigModal({

{selectedStrategy.description || (language === 'zh' ? '无描述' : 'No description')}

-
-
- {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 ? ( +
+
{language === 'zh' ? '交易对' : 'Symbol'}: {selectedStrategy.config.grid_config.symbol || '-'}
+
{language === 'zh' ? '网格数' : 'Grids'}: {selectedStrategy.config.grid_config.grid_count}
-
- {t('marginLimit', language)}: {((selectedStrategy.config.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}% -
-
+ ) : (() => { + const aiConfig = getStrategyAIConfig(selectedStrategy) + if (!aiConfig) return null + return ( +
+
+ {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' : '混合'} +
+
+ {t('marginLimit', language)}: {((aiConfig.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}% +
+
+ ) + })()}
)} diff --git a/web/src/pages/StrategyMarketPage.tsx b/web/src/pages/StrategyMarketPage.tsx index cf49a477..af17bd47 100644 --- a/web/src/pages/StrategyMarketPage.tsx +++ b/web/src/pages/StrategyMarketPage.tsx @@ -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() { {/* Risk Control */} - {strategy.config.risk_control && ( + {(strategy.config.ai_config?.risk_control || strategy.config.risk_control) && (
@@ -447,7 +448,7 @@ export function StrategyMarketPage() { LEV - {strategy.config.risk_control + {(strategy.config.ai_config?.risk_control || strategy.config.risk_control) .btc_eth_max_leverage || '-'} x @@ -457,7 +458,7 @@ export function StrategyMarketPage() { POS - {strategy.config.risk_control + {(strategy.config.ai_config?.risk_control || strategy.config.risk_control) .max_positions || '-'}
diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index b1a1cfe6..cb0a547a 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -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 = ( + 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 ) => { @@ -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 && ( 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 && ( 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 && ( 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 && ( - 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 && (

{tr('customPromptDesc')}