mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Split strategy config by strategy type
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -271,8 +271,12 @@ 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"}},
|
||||
"ai_config": map[string]any{
|
||||
"type": "object",
|
||||
"description": "AI trading only. Do not include this for grid_trading.",
|
||||
"properties": map[string]any{
|
||||
"coin_source": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
@@ -355,7 +359,10 @@ func strategyConfigSchema() map[string]any {
|
||||
"decision_process": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"grid_config": map[string]any{
|
||||
"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."},
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -166,6 +182,8 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
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,6 +228,9 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
Description: req.Description,
|
||||
IsActive: false,
|
||||
IsDefault: false,
|
||||
IsPublic: isPublic,
|
||||
// Existing default is true; keep that behavior when no explicit publish config is sent.
|
||||
ConfigVisible: configVisible || !hadPublishConfig,
|
||||
Config: string(configJSON),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
78
store/strategy_schema_test.go
Normal file
78
store/strategy_schema_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
{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>
|
||||
) : (() => {
|
||||
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)}: {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' : '混合'}
|
||||
{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)}: {((selectedStrategy.config.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%
|
||||
{t('marginLimit', language)}: {((aiConfig.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user