mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
- Extend strategy storage and engine analysis for Hyperliquid defaults - Rework coin source and indicator editors for the stock strategy flow - Update Strategy Studio translations and page wiring
1488 lines
50 KiB
Go
1488 lines
50 KiB
Go
package store
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// Hard limits to prevent token explosion in AI requests
|
||
const (
|
||
MaxCandidateCoins = 10
|
||
MaxPositions = 3
|
||
MaxTimeframes = 4
|
||
MinKlineCount = 10
|
||
MaxKlineCount = 30
|
||
MinLeverage = 1
|
||
MaxBTCETHLeverage = 20
|
||
MaxAltLeverage = 20
|
||
MinPositionRatio = 0.5
|
||
MaxPositionRatio = 10.0
|
||
MinRiskReward = 1.0
|
||
MaxRiskReward = 10.0
|
||
MinMarginUsage = 0.1
|
||
MaxMarginUsage = 1.0
|
||
MinPositionSize = 10.0
|
||
MaxPositionSize = 1000.0
|
||
MinConfidence = 50
|
||
MaxConfidence = 100
|
||
)
|
||
|
||
// ClampLimits enforces product-level limits on strategy config to prevent token overflow.
|
||
func (c *StrategyConfig) ClampLimits() {
|
||
c.NormalizeProductSchema()
|
||
|
||
// Clamp coin source limits
|
||
if c.CoinSource.AI500Limit > MaxCandidateCoins {
|
||
c.CoinSource.AI500Limit = MaxCandidateCoins
|
||
}
|
||
if c.CoinSource.OITopLimit > MaxCandidateCoins {
|
||
c.CoinSource.OITopLimit = MaxCandidateCoins
|
||
}
|
||
if c.CoinSource.OILowLimit > MaxCandidateCoins {
|
||
c.CoinSource.OILowLimit = MaxCandidateCoins
|
||
}
|
||
|
||
// Clamp static coins
|
||
if len(c.CoinSource.StaticCoins) > MaxCandidateCoins {
|
||
c.CoinSource.StaticCoins = c.CoinSource.StaticCoins[:MaxCandidateCoins]
|
||
}
|
||
|
||
// Clamp kline count
|
||
if c.Indicators.Klines.PrimaryCount < MinKlineCount {
|
||
c.Indicators.Klines.PrimaryCount = MinKlineCount
|
||
}
|
||
if c.Indicators.Klines.PrimaryCount > MaxKlineCount {
|
||
c.Indicators.Klines.PrimaryCount = MaxKlineCount
|
||
}
|
||
if c.Indicators.Klines.LongerCount > MaxKlineCount {
|
||
c.Indicators.Klines.LongerCount = MaxKlineCount
|
||
}
|
||
|
||
// Clamp timeframes
|
||
if len(c.Indicators.Klines.SelectedTimeframes) > MaxTimeframes {
|
||
c.Indicators.Klines.SelectedTimeframes = c.Indicators.Klines.SelectedTimeframes[:MaxTimeframes]
|
||
}
|
||
|
||
// Clamp max positions
|
||
if c.RiskControl.MaxPositions < 1 {
|
||
c.RiskControl.MaxPositions = 1
|
||
}
|
||
if c.RiskControl.MaxPositions > MaxPositions {
|
||
c.RiskControl.MaxPositions = MaxPositions
|
||
}
|
||
|
||
// Clamp leverage limits to the same bounds as the manual config UI.
|
||
if c.RiskControl.BTCETHMaxLeverage < MinLeverage {
|
||
c.RiskControl.BTCETHMaxLeverage = MinLeverage
|
||
}
|
||
if c.RiskControl.BTCETHMaxLeverage > MaxBTCETHLeverage {
|
||
c.RiskControl.BTCETHMaxLeverage = MaxBTCETHLeverage
|
||
}
|
||
if c.RiskControl.AltcoinMaxLeverage < MinLeverage {
|
||
c.RiskControl.AltcoinMaxLeverage = MinLeverage
|
||
}
|
||
if c.RiskControl.AltcoinMaxLeverage > MaxAltLeverage {
|
||
c.RiskControl.AltcoinMaxLeverage = MaxAltLeverage
|
||
}
|
||
|
||
// Clamp position value ratio limits.
|
||
if c.RiskControl.BTCETHMaxPositionValueRatio < MinPositionRatio {
|
||
c.RiskControl.BTCETHMaxPositionValueRatio = MinPositionRatio
|
||
}
|
||
if c.RiskControl.BTCETHMaxPositionValueRatio > MaxPositionRatio {
|
||
c.RiskControl.BTCETHMaxPositionValueRatio = MaxPositionRatio
|
||
}
|
||
if c.RiskControl.AltcoinMaxPositionValueRatio < MinPositionRatio {
|
||
c.RiskControl.AltcoinMaxPositionValueRatio = MinPositionRatio
|
||
}
|
||
if c.RiskControl.AltcoinMaxPositionValueRatio > MaxPositionRatio {
|
||
c.RiskControl.AltcoinMaxPositionValueRatio = MaxPositionRatio
|
||
}
|
||
|
||
// Clamp risk parameters and entry requirements.
|
||
if c.RiskControl.MinRiskRewardRatio < MinRiskReward {
|
||
c.RiskControl.MinRiskRewardRatio = MinRiskReward
|
||
}
|
||
if c.RiskControl.MinRiskRewardRatio > MaxRiskReward {
|
||
c.RiskControl.MinRiskRewardRatio = MaxRiskReward
|
||
}
|
||
if c.RiskControl.MaxMarginUsage < MinMarginUsage {
|
||
c.RiskControl.MaxMarginUsage = MinMarginUsage
|
||
}
|
||
if c.RiskControl.MaxMarginUsage > MaxMarginUsage {
|
||
c.RiskControl.MaxMarginUsage = MaxMarginUsage
|
||
}
|
||
if c.RiskControl.MinPositionSize < MinPositionSize {
|
||
c.RiskControl.MinPositionSize = MinPositionSize
|
||
}
|
||
if c.RiskControl.MinPositionSize > MaxPositionSize {
|
||
c.RiskControl.MinPositionSize = MaxPositionSize
|
||
}
|
||
if c.RiskControl.MinConfidence < MinConfidence {
|
||
c.RiskControl.MinConfidence = MinConfidence
|
||
}
|
||
if c.RiskControl.MinConfidence > MaxConfidence {
|
||
c.RiskControl.MinConfidence = MaxConfidence
|
||
}
|
||
}
|
||
|
||
// NormalizeProductSchema keeps saved strategy JSON aligned with the product
|
||
// editor schema. LLMs may emit user-facing labels such as "AI500"; persistence
|
||
// must use the exact frontend/backend enum values.
|
||
func (c *StrategyConfig) NormalizeProductSchema() {
|
||
c.StrategyType = normalizeStrategyType(c.StrategyType)
|
||
c.CoinSource.SourceType = normalizeCoinSourceType(c.CoinSource.SourceType)
|
||
if c.CoinSource.SourceType == "" {
|
||
c.CoinSource.SourceType = inferCoinSourceType(c.CoinSource)
|
||
}
|
||
|
||
switch c.CoinSource.SourceType {
|
||
case "ai500":
|
||
c.CoinSource.UseAI500 = true
|
||
c.CoinSource.UseOITop = false
|
||
c.CoinSource.UseOILow = false
|
||
c.CoinSource.UseHyperAll = false
|
||
c.CoinSource.UseHyperMain = false
|
||
if c.CoinSource.AI500Limit <= 0 {
|
||
c.CoinSource.AI500Limit = 3
|
||
}
|
||
case "oi_top":
|
||
c.CoinSource.UseAI500 = false
|
||
c.CoinSource.UseOITop = true
|
||
c.CoinSource.UseOILow = false
|
||
c.CoinSource.UseHyperAll = false
|
||
c.CoinSource.UseHyperMain = false
|
||
if c.CoinSource.OITopLimit <= 0 {
|
||
c.CoinSource.OITopLimit = 3
|
||
}
|
||
case "oi_low":
|
||
c.CoinSource.UseAI500 = false
|
||
c.CoinSource.UseOITop = false
|
||
c.CoinSource.UseOILow = true
|
||
c.CoinSource.UseHyperAll = false
|
||
c.CoinSource.UseHyperMain = false
|
||
if c.CoinSource.OILowLimit <= 0 {
|
||
c.CoinSource.OILowLimit = 3
|
||
}
|
||
case "static":
|
||
c.CoinSource.UseAI500 = false
|
||
c.CoinSource.UseOITop = false
|
||
c.CoinSource.UseOILow = false
|
||
c.CoinSource.UseHyperAll = false
|
||
c.CoinSource.UseHyperMain = false
|
||
case "hyper_all":
|
||
c.CoinSource.UseAI500 = false
|
||
c.CoinSource.UseOITop = false
|
||
c.CoinSource.UseOILow = false
|
||
c.CoinSource.UseHyperAll = true
|
||
c.CoinSource.UseHyperMain = false
|
||
case "hyper_main":
|
||
c.CoinSource.UseAI500 = false
|
||
c.CoinSource.UseOITop = false
|
||
c.CoinSource.UseOILow = false
|
||
c.CoinSource.UseHyperAll = false
|
||
c.CoinSource.UseHyperMain = true
|
||
if c.CoinSource.HyperMainLimit <= 0 {
|
||
c.CoinSource.HyperMainLimit = 30
|
||
}
|
||
case "hyper_rank":
|
||
c.CoinSource.UseAI500 = false
|
||
c.CoinSource.UseOITop = false
|
||
c.CoinSource.UseOILow = false
|
||
c.CoinSource.UseHyperAll = false
|
||
c.CoinSource.UseHyperMain = false
|
||
if c.CoinSource.HyperRankCategory == "" {
|
||
c.CoinSource.HyperRankCategory = "stock"
|
||
}
|
||
if c.CoinSource.HyperRankDirection == "" {
|
||
c.CoinSource.HyperRankDirection = "gainers"
|
||
}
|
||
if c.CoinSource.HyperRankLimit <= 0 {
|
||
c.CoinSource.HyperRankLimit = 5
|
||
}
|
||
default:
|
||
c.CoinSource.SourceType = "hyper_rank"
|
||
c.CoinSource.UseAI500 = false
|
||
c.CoinSource.UseOITop = false
|
||
c.CoinSource.UseOILow = false
|
||
c.CoinSource.UseHyperAll = false
|
||
c.CoinSource.UseHyperMain = false
|
||
if c.CoinSource.HyperRankCategory == "" {
|
||
c.CoinSource.HyperRankCategory = "stock"
|
||
}
|
||
if c.CoinSource.HyperRankDirection == "" {
|
||
c.CoinSource.HyperRankDirection = "gainers"
|
||
}
|
||
if c.CoinSource.HyperRankLimit <= 0 {
|
||
c.CoinSource.HyperRankLimit = 5
|
||
}
|
||
}
|
||
|
||
c.CoinSource.StaticCoins = normalizeSymbols(c.CoinSource.StaticCoins)
|
||
c.CoinSource.ExcludedCoins = normalizeSymbols(c.CoinSource.ExcludedCoins)
|
||
c.Indicators.Klines.PrimaryTimeframe = normalizeTimeframe(c.Indicators.Klines.PrimaryTimeframe)
|
||
c.Indicators.Klines.LongerTimeframe = normalizeTimeframe(c.Indicators.Klines.LongerTimeframe)
|
||
c.Indicators.Klines.SelectedTimeframes = normalizeTimeframes(c.Indicators.Klines.SelectedTimeframes)
|
||
if len(c.Indicators.Klines.SelectedTimeframes) > 0 {
|
||
c.Indicators.Klines.EnableMultiTimeframe = true
|
||
}
|
||
}
|
||
|
||
func normalizeStrategyType(value string) string {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
switch value {
|
||
case "grid", "grid_strategy", "grid-trading", "grid trading", "grid_trading", "网格", "网格策略", "网格交易":
|
||
return "grid_trading"
|
||
case "", "ai", "ai_strategy", "ai-trading", "ai trading", "ai_trading", "ai策略", "ai 策略", "ai交易策略", "ai智能策略":
|
||
return "ai_trading"
|
||
default:
|
||
return value
|
||
}
|
||
}
|
||
|
||
func normalizeCoinSourceType(value string) string {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
compact := strings.NewReplacer(" ", "", "_", "", "-", "", "数据源", "", "选币", "", "币种", "").Replace(value)
|
||
switch {
|
||
case compact == "":
|
||
return ""
|
||
case strings.Contains(compact, "ai500"):
|
||
return "ai500"
|
||
case strings.Contains(compact, "oitop") || strings.Contains(value, "oi top") || strings.Contains(value, "持仓量最高") || strings.Contains(value, "持仓量靠前"):
|
||
return "oi_top"
|
||
case strings.Contains(compact, "oilow") || strings.Contains(value, "oi low") || strings.Contains(value, "持仓量最低") || strings.Contains(value, "持仓量较低"):
|
||
return "oi_low"
|
||
case strings.Contains(compact, "hyperrank") || strings.Contains(compact, "dynamicranking") || strings.Contains(value, "动态榜单") || strings.Contains(value, "涨幅榜"):
|
||
return "hyper_rank"
|
||
case strings.Contains(compact, "hyperall"):
|
||
return "hyper_all"
|
||
case strings.Contains(compact, "hypermain"):
|
||
return "hyper_main"
|
||
case strings.Contains(value, "static") || strings.Contains(value, "固定") || strings.Contains(value, "静态"):
|
||
return "static"
|
||
default:
|
||
return value
|
||
}
|
||
}
|
||
|
||
func inferCoinSourceType(source CoinSourceConfig) string {
|
||
switch {
|
||
case len(source.StaticCoins) > 0:
|
||
return "static"
|
||
case source.UseAI500:
|
||
return "ai500"
|
||
case source.UseOITop:
|
||
return "oi_top"
|
||
case source.UseOILow:
|
||
return "oi_low"
|
||
case source.UseHyperAll:
|
||
return "hyper_all"
|
||
case source.UseHyperMain:
|
||
return "hyper_main"
|
||
case source.HyperRankCategory != "" || source.HyperRankDirection != "" || source.HyperRankLimit > 0:
|
||
return "hyper_rank"
|
||
default:
|
||
return "hyper_rank"
|
||
}
|
||
}
|
||
|
||
func normalizeSymbols(values []string) []string {
|
||
out := make([]string, 0, len(values))
|
||
seen := make(map[string]bool, len(values))
|
||
for _, value := range splitLooseStringList(values) {
|
||
value = strings.ToUpper(strings.TrimSpace(value))
|
||
value = strings.Trim(value, ",,;; ")
|
||
if value == "" || seen[value] {
|
||
continue
|
||
}
|
||
seen[value] = true
|
||
out = append(out, value)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func normalizeTimeframes(values []string) []string {
|
||
out := make([]string, 0, len(values))
|
||
seen := make(map[string]bool, len(values))
|
||
for _, value := range splitLooseStringList(values) {
|
||
tf := normalizeTimeframe(value)
|
||
if tf == "" || seen[tf] {
|
||
continue
|
||
}
|
||
seen[tf] = true
|
||
out = append(out, tf)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func splitLooseStringList(values []string) []string {
|
||
if len(values) == 0 {
|
||
return nil
|
||
}
|
||
joined := strings.TrimSpace(strings.Join(values, ","))
|
||
if strings.HasPrefix(joined, "[") && strings.HasSuffix(joined, "]") {
|
||
var parsed []string
|
||
if err := json.Unmarshal([]byte(joined), &parsed); err == nil {
|
||
return parsed
|
||
}
|
||
}
|
||
parts := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
|
||
var parsed []string
|
||
if err := json.Unmarshal([]byte(value), &parsed); err == nil {
|
||
parts = append(parts, parsed...)
|
||
continue
|
||
}
|
||
}
|
||
value = strings.Trim(value, "[]")
|
||
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
|
||
return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n'
|
||
}) {
|
||
part = strings.Trim(strings.TrimSpace(part), "\"'")
|
||
if part != "" {
|
||
parts = append(parts, part)
|
||
}
|
||
}
|
||
}
|
||
return parts
|
||
}
|
||
|
||
func normalizeTimeframe(value string) string {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
value = strings.Trim(value, "\"',,。 ")
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
aliases := map[string]string{
|
||
"1分钟": "1m",
|
||
"3分钟": "3m",
|
||
"5分钟": "5m",
|
||
"15分钟": "15m",
|
||
"30分钟": "30m",
|
||
"1小时": "1h",
|
||
"2小时": "2h",
|
||
"4小时": "4h",
|
||
"6小时": "6h",
|
||
"8小时": "8h",
|
||
"12小时": "12h",
|
||
"1天": "1d",
|
||
"3天": "3d",
|
||
"1周": "1w",
|
||
}
|
||
if alias, ok := aliases[value]; ok {
|
||
return alias
|
||
}
|
||
allowed := map[string]bool{
|
||
"1m": true, "3m": true, "5m": true, "15m": true, "30m": true,
|
||
"1h": true, "2h": true, "4h": true, "6h": true, "8h": true, "12h": true,
|
||
"1d": true, "3d": true, "1w": true,
|
||
}
|
||
if !allowed[value] {
|
||
return ""
|
||
}
|
||
return value
|
||
}
|
||
|
||
// MergeStrategyConfig applies a partial JSON-style patch onto a full strategy config.
|
||
// Nested objects are merged recursively so omitted fields keep their previous values.
|
||
func MergeStrategyConfig(base StrategyConfig, patch map[string]any) (StrategyConfig, error) {
|
||
baseJSON, err := json.Marshal(base)
|
||
if err != nil {
|
||
return StrategyConfig{}, err
|
||
}
|
||
|
||
var mergedMap map[string]any
|
||
if err := json.Unmarshal(baseJSON, &mergedMap); err != nil {
|
||
return StrategyConfig{}, err
|
||
}
|
||
|
||
normalizeStrategyConfigPatch(patch)
|
||
if fmt.Sprint(patch["strategy_type"]) == "grid_trading" {
|
||
ensureDefaultGridConfigMap(mergedMap)
|
||
}
|
||
mergeJSONMaps(mergedMap, patch)
|
||
|
||
mergedJSON, err := json.Marshal(mergedMap)
|
||
if err != nil {
|
||
return StrategyConfig{}, err
|
||
}
|
||
|
||
var merged StrategyConfig
|
||
if err := json.Unmarshal(mergedJSON, &merged); err != nil {
|
||
return StrategyConfig{}, err
|
||
}
|
||
return merged, nil
|
||
}
|
||
|
||
func DefaultGridStrategyConfig() GridStrategyConfig {
|
||
return GridStrategyConfig{
|
||
Symbol: "BTCUSDT",
|
||
GridCount: 10,
|
||
TotalInvestment: 1000,
|
||
Leverage: 5,
|
||
UpperPrice: 0,
|
||
LowerPrice: 0,
|
||
UseATRBounds: true,
|
||
ATRMultiplier: 2.0,
|
||
Distribution: "gaussian",
|
||
MaxDrawdownPct: 15,
|
||
StopLossPct: 5,
|
||
DailyLossLimitPct: 10,
|
||
UseMakerOnly: true,
|
||
EnableDirectionAdjust: false,
|
||
DirectionBiasRatio: 0.7,
|
||
}
|
||
}
|
||
|
||
func ensureDefaultGridConfigMap(config map[string]any) {
|
||
if config == nil {
|
||
return
|
||
}
|
||
if existing, ok := config["grid_config"].(map[string]any); ok && len(existing) > 0 {
|
||
return
|
||
}
|
||
defaultGrid := DefaultGridStrategyConfig()
|
||
raw, err := json.Marshal(defaultGrid)
|
||
if err != nil {
|
||
return
|
||
}
|
||
var gridMap map[string]any
|
||
if err := json.Unmarshal(raw, &gridMap); err != nil {
|
||
return
|
||
}
|
||
config["grid_config"] = gridMap
|
||
}
|
||
|
||
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
|
||
}
|
||
if gridConfig, hasGrid := patch["grid_config"]; hasGrid && gridConfig != nil {
|
||
patch["strategy_type"] = "grid_trading"
|
||
}
|
||
}
|
||
|
||
func mergeJSONMaps(dst, src map[string]any) {
|
||
for key, srcVal := range src {
|
||
srcMap, srcIsMap := srcVal.(map[string]any)
|
||
dstMap, dstIsMap := dst[key].(map[string]any)
|
||
if srcIsMap && dstIsMap {
|
||
mergeJSONMaps(dstMap, srcMap)
|
||
continue
|
||
}
|
||
dst[key] = srcVal
|
||
}
|
||
}
|
||
|
||
func StrategyClampWarnings(before, after StrategyConfig, lang string) []string {
|
||
if lang != "zh" {
|
||
lang = "en"
|
||
}
|
||
warnings := make([]string, 0, 8)
|
||
appendInt := func(labelZH, labelEN string, from, to int) {
|
||
if from == to {
|
||
return
|
||
}
|
||
if lang == "zh" {
|
||
warnings = append(warnings, fmt.Sprintf("%s 已从 %d 调整为 %d", labelZH, from, to))
|
||
return
|
||
}
|
||
warnings = append(warnings, fmt.Sprintf("%s adjusted from %d to %d", labelEN, from, to))
|
||
}
|
||
appendFloat := func(labelZH, labelEN string, from, to float64) {
|
||
if from == to {
|
||
return
|
||
}
|
||
if lang == "zh" {
|
||
warnings = append(warnings, fmt.Sprintf("%s 已从 %.2f 调整为 %.2f", labelZH, from, to))
|
||
return
|
||
}
|
||
warnings = append(warnings, fmt.Sprintf("%s adjusted from %.2f to %.2f", labelEN, from, to))
|
||
}
|
||
|
||
appendInt("最大持仓数", "max_positions", before.RiskControl.MaxPositions, after.RiskControl.MaxPositions)
|
||
appendInt("BTC/ETH 最大杠杆", "btc_eth_max_leverage", before.RiskControl.BTCETHMaxLeverage, after.RiskControl.BTCETHMaxLeverage)
|
||
appendInt("山寨币最大杠杆", "altcoin_max_leverage", before.RiskControl.AltcoinMaxLeverage, after.RiskControl.AltcoinMaxLeverage)
|
||
appendFloat("BTC/ETH 最大仓位价值倍数", "btc_eth_max_position_value_ratio", before.RiskControl.BTCETHMaxPositionValueRatio, after.RiskControl.BTCETHMaxPositionValueRatio)
|
||
appendFloat("山寨币最大仓位价值倍数", "altcoin_max_position_value_ratio", before.RiskControl.AltcoinMaxPositionValueRatio, after.RiskControl.AltcoinMaxPositionValueRatio)
|
||
appendFloat("最小盈亏比", "min_risk_reward_ratio", before.RiskControl.MinRiskRewardRatio, after.RiskControl.MinRiskRewardRatio)
|
||
appendFloat("最大保证金使用率", "max_margin_usage", before.RiskControl.MaxMarginUsage, after.RiskControl.MaxMarginUsage)
|
||
appendFloat("最小开仓金额", "min_position_size", before.RiskControl.MinPositionSize, after.RiskControl.MinPositionSize)
|
||
appendInt("最低置信度", "min_confidence", before.RiskControl.MinConfidence, after.RiskControl.MinConfidence)
|
||
return warnings
|
||
}
|
||
|
||
// StrategyStore strategy storage
|
||
type StrategyStore struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
// Strategy strategy configuration
|
||
type Strategy struct {
|
||
ID string `gorm:"primaryKey" json:"id"`
|
||
UserID string `gorm:"column:user_id;not null;default:'';index" json:"user_id"`
|
||
Name string `gorm:"not null" json:"name"`
|
||
Description string `gorm:"default:''" json:"description"`
|
||
IsActive bool `gorm:"column:is_active;default:false;index" json:"is_active"`
|
||
IsDefault bool `gorm:"column:is_default;default:false" json:"is_default"`
|
||
IsPublic bool `gorm:"column:is_public;default:false;index" json:"is_public"` // whether visible in strategy market
|
||
ConfigVisible bool `gorm:"column:config_visible;default:true" json:"config_visible"` // whether config details are visible
|
||
Config string `gorm:"not null;default:'{}'" json:"config"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
func (Strategy) TableName() string { return "strategies" }
|
||
|
||
// StrategyConfig strategy configuration details (JSON structure)
|
||
type StrategyConfig struct {
|
||
// Strategy type: "ai_trading" (default) or "grid_trading"
|
||
StrategyType string `json:"strategy_type,omitempty"`
|
||
|
||
// language setting: "zh" for Chinese, "en" for English
|
||
// This determines the language used for data formatting and prompt generation
|
||
Language string `json:"language,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
|
||
type GridStrategyConfig struct {
|
||
// Trading pair (e.g., "BTCUSDT")
|
||
Symbol string `json:"symbol"`
|
||
// Number of grid levels (5-50)
|
||
GridCount int `json:"grid_count"`
|
||
// Total investment in USDT
|
||
TotalInvestment float64 `json:"total_investment"`
|
||
// Leverage (1-20)
|
||
Leverage int `json:"leverage"`
|
||
// Upper price boundary (0 = auto-calculate from ATR)
|
||
UpperPrice float64 `json:"upper_price"`
|
||
// Lower price boundary (0 = auto-calculate from ATR)
|
||
LowerPrice float64 `json:"lower_price"`
|
||
// Use ATR to auto-calculate bounds
|
||
UseATRBounds bool `json:"use_atr_bounds"`
|
||
// ATR multiplier for bound calculation (default 2.0)
|
||
ATRMultiplier float64 `json:"atr_multiplier"`
|
||
// Position distribution: "uniform" | "gaussian" | "pyramid"
|
||
Distribution string `json:"distribution"`
|
||
// Maximum drawdown percentage before emergency exit
|
||
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||
// Stop loss percentage per position
|
||
StopLossPct float64 `json:"stop_loss_pct"`
|
||
// Daily loss limit percentage
|
||
DailyLossLimitPct float64 `json:"daily_loss_limit_pct"`
|
||
// Use maker-only orders for lower fees
|
||
UseMakerOnly bool `json:"use_maker_only"`
|
||
// Enable automatic grid direction adjustment based on box breakouts
|
||
EnableDirectionAdjust bool `json:"enable_direction_adjust"`
|
||
// Direction bias ratio for long_bias/short_bias modes (default 0.7 = 70%/30%)
|
||
DirectionBiasRatio float64 `json:"direction_bias_ratio"`
|
||
}
|
||
|
||
// PromptSectionsConfig editable sections of System Prompt
|
||
type PromptSectionsConfig struct {
|
||
// role definition (title + description)
|
||
RoleDefinition string `json:"role_definition,omitempty"`
|
||
// trading frequency awareness
|
||
TradingFrequency string `json:"trading_frequency,omitempty"`
|
||
// entry standards
|
||
EntryStandards string `json:"entry_standards,omitempty"`
|
||
// decision process
|
||
DecisionProcess string `json:"decision_process,omitempty"`
|
||
}
|
||
|
||
// CoinSourceConfig coin source configuration
|
||
type CoinSourceConfig struct {
|
||
// source type shown in the product editor: "static" | "ai500" | "oi_top" | "oi_low"
|
||
SourceType string `json:"source_type"`
|
||
// static coin list (used when source_type = "static")
|
||
StaticCoins []string `json:"static_coins,omitempty"`
|
||
// excluded coins list (filtered out from all sources)
|
||
ExcludedCoins []string `json:"excluded_coins,omitempty"`
|
||
// whether to use AI500 coin pool
|
||
UseAI500 bool `json:"use_ai500"`
|
||
// AI500 coin pool maximum count
|
||
AI500Limit int `json:"ai500_limit,omitempty"`
|
||
// whether to use OI Top (OI increase ranking, suitable for long positions)
|
||
UseOITop bool `json:"use_oi_top"`
|
||
// OI Top maximum count
|
||
OITopLimit int `json:"oi_top_limit,omitempty"`
|
||
// whether to use OI Low (OI decrease ranking, suitable for short positions)
|
||
UseOILow bool `json:"use_oi_low"`
|
||
// OI Low maximum count
|
||
OILowLimit int `json:"oi_low_limit,omitempty"`
|
||
// whether to use Hyperliquid All coins (all available perp pairs)
|
||
UseHyperAll bool `json:"use_hyper_all"`
|
||
// whether to use Hyperliquid Main coins (top N by 24h volume)
|
||
UseHyperMain bool `json:"use_hyper_main"`
|
||
// Hyperliquid Main maximum count (default 20)
|
||
HyperMainLimit int `json:"hyper_main_limit,omitempty"`
|
||
// Hyperliquid dynamic ranking category: stock, commodity, index, forex, pre_ipo, crypto, all
|
||
HyperRankCategory string `json:"hyper_rank_category,omitempty"`
|
||
// Hyperliquid dynamic ranking direction: gainers, losers, volume
|
||
HyperRankDirection string `json:"hyper_rank_direction,omitempty"`
|
||
// Hyperliquid dynamic ranking maximum count. Defaults to 5 and is hard capped at 10 for AI context safety.
|
||
HyperRankLimit int `json:"hyper_rank_limit,omitempty"`
|
||
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
|
||
}
|
||
|
||
// IndicatorConfig indicator configuration
|
||
type IndicatorConfig struct {
|
||
// K-line configuration
|
||
Klines KlineConfig `json:"klines"`
|
||
// raw kline data (OHLCV) - always enabled, required for AI analysis
|
||
EnableRawKlines bool `json:"enable_raw_klines"`
|
||
// technical indicator switches
|
||
EnableEMA bool `json:"enable_ema"`
|
||
EnableMACD bool `json:"enable_macd"`
|
||
EnableRSI bool `json:"enable_rsi"`
|
||
EnableATR bool `json:"enable_atr"`
|
||
EnableBOLL bool `json:"enable_boll"` // Bollinger Bands
|
||
EnableVolume bool `json:"enable_volume"`
|
||
EnableOI bool `json:"enable_oi"` // open interest
|
||
EnableFundingRate bool `json:"enable_funding_rate"` // funding rate
|
||
// EMA period configuration
|
||
EMAPeriods []int `json:"ema_periods,omitempty"` // default [20, 50]
|
||
// RSI period configuration
|
||
RSIPeriods []int `json:"rsi_periods,omitempty"` // default [7, 14]
|
||
// ATR period configuration
|
||
ATRPeriods []int `json:"atr_periods,omitempty"` // default [14]
|
||
// BOLL period configuration (period, standard deviation multiplier is fixed at 2)
|
||
BOLLPeriods []int `json:"boll_periods,omitempty"` // default [20] - can select multiple timeframes
|
||
// external data sources
|
||
ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"`
|
||
|
||
// ========== NofxOS Unified API Configuration ==========
|
||
// Unified API Key for all NofxOS data sources
|
||
NofxOSAPIKey string `json:"nofxos_api_key,omitempty"`
|
||
|
||
// quantitative data sources (capital flow, position changes, price changes)
|
||
EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data
|
||
EnableQuantOI bool `json:"enable_quant_oi"` // whether to show OI data
|
||
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
|
||
|
||
// OI ranking data (market-wide open interest increase/decrease rankings)
|
||
EnableOIRanking bool `json:"enable_oi_ranking"` // whether to enable OI ranking data
|
||
OIRankingDuration string `json:"oi_ranking_duration,omitempty"` // duration: 1h, 4h, 24h
|
||
OIRankingLimit int `json:"oi_ranking_limit,omitempty"` // number of entries (default 10)
|
||
|
||
// NetFlow ranking data (market-wide fund flow rankings - institution/personal)
|
||
EnableNetFlowRanking bool `json:"enable_netflow_ranking"` // whether to enable NetFlow ranking data
|
||
NetFlowRankingDuration string `json:"netflow_ranking_duration,omitempty"` // duration: 1h, 4h, 24h
|
||
NetFlowRankingLimit int `json:"netflow_ranking_limit,omitempty"` // number of entries (default 10)
|
||
|
||
// Price ranking data (market-wide gainers/losers)
|
||
EnablePriceRanking bool `json:"enable_price_ranking"` // whether to enable price ranking data
|
||
PriceRankingDuration string `json:"price_ranking_duration,omitempty"` // durations: "1h" or "1h,4h,24h"
|
||
PriceRankingLimit int `json:"price_ranking_limit,omitempty"` // number of entries per ranking (default 10)
|
||
}
|
||
|
||
// KlineConfig K-line configuration
|
||
type KlineConfig struct {
|
||
// primary timeframe: "1m", "3m", "5m", "15m", "1h", "4h"
|
||
PrimaryTimeframe string `json:"primary_timeframe"`
|
||
// primary timeframe K-line count
|
||
PrimaryCount int `json:"primary_count"`
|
||
// longer timeframe
|
||
LongerTimeframe string `json:"longer_timeframe,omitempty"`
|
||
// longer timeframe K-line count
|
||
LongerCount int `json:"longer_count,omitempty"`
|
||
// whether to enable multi-timeframe analysis
|
||
EnableMultiTimeframe bool `json:"enable_multi_timeframe"`
|
||
// selected timeframe list (new: supports multi-timeframe selection)
|
||
SelectedTimeframes []string `json:"selected_timeframes,omitempty"`
|
||
}
|
||
|
||
// ExternalDataSource external data source configuration
|
||
type ExternalDataSource struct {
|
||
Name string `json:"name"` // data source name
|
||
Type string `json:"type"` // type: "api" | "webhook"
|
||
URL string `json:"url"` // API URL
|
||
Method string `json:"method"` // HTTP method
|
||
Headers map[string]string `json:"headers,omitempty"`
|
||
DataPath string `json:"data_path,omitempty"` // JSON data path
|
||
RefreshSecs int `json:"refresh_secs,omitempty"` // refresh interval (seconds)
|
||
}
|
||
|
||
// RiskControlConfig risk control configuration
|
||
type RiskControlConfig struct {
|
||
// Max number of coins held simultaneously (CODE ENFORCED)
|
||
MaxPositions int `json:"max_positions"`
|
||
|
||
// BTC/ETH exchange leverage for opening positions (AI guided)
|
||
BTCETHMaxLeverage int `json:"btc_eth_max_leverage"`
|
||
// Altcoin exchange leverage for opening positions (AI guided)
|
||
AltcoinMaxLeverage int `json:"altcoin_max_leverage"`
|
||
|
||
// BTC/ETH single position max value = equity × this ratio (CODE ENFORCED, default: 5)
|
||
BTCETHMaxPositionValueRatio float64 `json:"btc_eth_max_position_value_ratio"`
|
||
// Altcoin single position max value = equity × this ratio (CODE ENFORCED, default: 1)
|
||
AltcoinMaxPositionValueRatio float64 `json:"altcoin_max_position_value_ratio"`
|
||
|
||
// Max margin utilization (e.g. 0.9 = 90%) (CODE ENFORCED)
|
||
MaxMarginUsage float64 `json:"max_margin_usage"`
|
||
// Min position size in USDT (CODE ENFORCED)
|
||
MinPositionSize float64 `json:"min_position_size"`
|
||
|
||
// Min take_profit / stop_loss ratio (AI guided)
|
||
MinRiskRewardRatio float64 `json:"min_risk_reward_ratio"`
|
||
// Min AI confidence to open position (AI guided)
|
||
MinConfidence int `json:"min_confidence"`
|
||
}
|
||
|
||
// NewStrategyStore creates a new StrategyStore
|
||
func NewStrategyStore(db *gorm.DB) *StrategyStore {
|
||
return &StrategyStore{db: db}
|
||
}
|
||
|
||
func (s *StrategyStore) initTables() error {
|
||
// AutoMigrate will add missing columns without dropping existing data
|
||
return s.db.AutoMigrate(&Strategy{})
|
||
}
|
||
|
||
func (s *StrategyStore) initDefaultData() error {
|
||
// No longer pre-populate strategies - create on demand when user configures
|
||
return nil
|
||
}
|
||
|
||
// GetDefaultStrategyConfig returns the default strategy configuration for the given language
|
||
func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||
// Normalize language to "zh" or "en"
|
||
normalizedLang := "en"
|
||
if lang == "zh" {
|
||
normalizedLang = "zh"
|
||
}
|
||
|
||
config := StrategyConfig{
|
||
Language: normalizedLang,
|
||
CoinSource: CoinSourceConfig{
|
||
SourceType: "hyper_rank",
|
||
UseAI500: false,
|
||
AI500Limit: 3,
|
||
UseOITop: false,
|
||
OITopLimit: 3,
|
||
UseOILow: false,
|
||
OILowLimit: 3,
|
||
UseHyperAll: false,
|
||
UseHyperMain: false,
|
||
HyperMainLimit: 30,
|
||
HyperRankCategory: "stock",
|
||
HyperRankDirection: "gainers",
|
||
HyperRankLimit: 5,
|
||
},
|
||
Indicators: IndicatorConfig{
|
||
Klines: KlineConfig{
|
||
PrimaryTimeframe: "5m",
|
||
PrimaryCount: 20,
|
||
LongerTimeframe: "4h",
|
||
LongerCount: 10,
|
||
EnableMultiTimeframe: true,
|
||
SelectedTimeframes: []string{"5m", "15m", "1h"},
|
||
},
|
||
EnableRawKlines: true, // Required - raw OHLCV data for AI analysis
|
||
EnableEMA: false,
|
||
EnableMACD: false,
|
||
EnableRSI: false,
|
||
EnableATR: false,
|
||
EnableBOLL: false,
|
||
EnableVolume: true,
|
||
EnableOI: true,
|
||
EnableFundingRate: true,
|
||
EMAPeriods: []int{20, 50},
|
||
RSIPeriods: []int{7, 14},
|
||
ATRPeriods: []int{14},
|
||
BOLLPeriods: []int{20},
|
||
// Hyperliquid strategies must use native Hyperliquid market data by default.
|
||
// NofxOS datasets do not cover all Hyperliquid XYZ assets, so keep them off.
|
||
NofxOSAPIKey: "",
|
||
EnableQuantData: false,
|
||
EnableQuantOI: false,
|
||
EnableQuantNetflow: false,
|
||
EnableOIRanking: false,
|
||
OIRankingDuration: "1h",
|
||
OIRankingLimit: 10,
|
||
EnableNetFlowRanking: false,
|
||
NetFlowRankingDuration: "1h",
|
||
NetFlowRankingLimit: 10,
|
||
EnablePriceRanking: false,
|
||
PriceRankingDuration: "1h,4h,24h",
|
||
PriceRankingLimit: 10,
|
||
},
|
||
RiskControl: RiskControlConfig{
|
||
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
||
BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided)
|
||
AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided)
|
||
BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED)
|
||
AltcoinMaxPositionValueRatio: 1.0, // Altcoin: max position = 1x equity (CODE ENFORCED)
|
||
MaxMarginUsage: 0.9, // Max 90% margin usage (CODE ENFORCED)
|
||
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
|
||
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
|
||
MinConfidence: 75, // Min 75% confidence (AI guided)
|
||
},
|
||
}
|
||
|
||
if lang == "zh" {
|
||
config.PromptSections = PromptSectionsConfig{
|
||
RoleDefinition: `# 你是一个专业的 Hyperliquid USDC 多资产交易AI
|
||
|
||
你的任务是根据提供的市场数据做出交易决策。你可以分析并交易 Hyperliquid 上线的 USDC 永续合约,包括美股、大宗商品和加密资产。你是一个经验丰富的量化交易员,擅长跨资产技术分析和风险管理。`,
|
||
TradingFrequency: `# ⏱️ 交易频率意识
|
||
|
||
- 优秀交易员:每天2-4笔 ≈ 每小时0.1-0.2笔
|
||
- 每小时超过2笔 = 过度交易
|
||
- 单笔持仓时间 ≥ 30-60分钟
|
||
如果你发现自己每个周期都在交易 → 标准太低;如果持仓不到30分钟就平仓 → 太冲动。`,
|
||
EntryStandards: `# 🎯 入场标准(严格)
|
||
|
||
只在多个信号共振时入场。自由使用任何有效的分析方法,避免单一指标、信号矛盾、横盘震荡、或平仓后立即重新开仓等低质量行为。`,
|
||
DecisionProcess: `# 📋 决策流程
|
||
|
||
1. 检查持仓 → 是否止盈/止损
|
||
2. 扫描候选币种 + 多时间框架 → 是否存在强信号
|
||
3. 先写思维链,再输出结构化JSON`,
|
||
}
|
||
} else {
|
||
config.PromptSections = PromptSectionsConfig{
|
||
RoleDefinition: `# You are a professional Hyperliquid USDC multi-asset trading AI
|
||
|
||
Your task is to make trading decisions based on the provided market data. You can analyze and trade Hyperliquid-listed USDC perpetual markets, including US equities, commodities and crypto assets. You are an experienced quantitative trader skilled in cross-asset technical analysis and risk management.`,
|
||
TradingFrequency: `# ⏱️ Trading Frequency Awareness
|
||
|
||
- Excellent trader: 2-4 trades per day ≈ 0.1-0.2 trades per hour
|
||
- >2 trades per hour = overtrading
|
||
- Single position holding time ≥ 30-60 minutes
|
||
If you find yourself trading every cycle → standards are too low; if closing positions in <30 minutes → too impulsive.`,
|
||
EntryStandards: `# 🎯 Entry Standards (Strict)
|
||
|
||
Only enter positions when multiple signals resonate. Freely use any effective analysis methods, avoid low-quality behaviors such as single indicators, contradictory signals, sideways oscillation, or immediately restarting after closing positions.`,
|
||
DecisionProcess: `# 📋 Decision Process
|
||
|
||
1. Check positions → whether to take profit/stop loss
|
||
2. Scan candidate coins + multi-timeframe → whether strong signals exist
|
||
3. Write chain of thought first, then output structured JSON`,
|
||
}
|
||
}
|
||
|
||
return config
|
||
}
|
||
|
||
// Create create a strategy
|
||
func (s *StrategyStore) Create(strategy *Strategy) error {
|
||
return s.db.Create(strategy).Error
|
||
}
|
||
|
||
// Update update a strategy
|
||
func (s *StrategyStore) Update(strategy *Strategy) error {
|
||
return s.db.Model(&Strategy{}).
|
||
Where("id = ? AND user_id = ?", strategy.ID, strategy.UserID).
|
||
Updates(map[string]interface{}{
|
||
"name": strategy.Name,
|
||
"description": strategy.Description,
|
||
"config": strategy.Config,
|
||
"is_public": strategy.IsPublic,
|
||
"config_visible": strategy.ConfigVisible,
|
||
"updated_at": time.Now().UTC(),
|
||
}).Error
|
||
}
|
||
|
||
// Delete delete a strategy
|
||
func (s *StrategyStore) Delete(userID, id string) error {
|
||
// do not allow deleting system default strategy
|
||
var st Strategy
|
||
if err := s.db.Where("id = ?", id).First(&st).Error; err == nil {
|
||
if st.IsDefault {
|
||
return fmt.Errorf("cannot delete system default strategy")
|
||
}
|
||
if st.IsActive {
|
||
return fmt.Errorf("cannot delete active strategy")
|
||
}
|
||
}
|
||
|
||
// Check if any trader references this strategy
|
||
var count int64
|
||
if err := s.db.Model(&Trader{}).
|
||
Where("user_id = ? AND strategy_id = ?", userID, id).
|
||
Count(&count).Error; err == nil && count > 0 {
|
||
return fmt.Errorf("cannot delete strategy in use by %d trader(s) - reassign those traders first", count)
|
||
}
|
||
|
||
return s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&Strategy{}).Error
|
||
}
|
||
|
||
// List get user's strategy list
|
||
func (s *StrategyStore) List(userID string) ([]*Strategy, error) {
|
||
var strategies []*Strategy
|
||
err := s.db.Where("user_id = ? OR is_default = ?", userID, true).
|
||
Order("is_default DESC, created_at DESC").
|
||
Find(&strategies).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return strategies, nil
|
||
}
|
||
|
||
// ListPublic get all public strategies for the strategy market
|
||
func (s *StrategyStore) ListPublic() ([]*Strategy, error) {
|
||
var strategies []*Strategy
|
||
err := s.db.Where("is_public = ?", true).
|
||
Order("created_at DESC").
|
||
Find(&strategies).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return strategies, nil
|
||
}
|
||
|
||
// Get get a single strategy
|
||
func (s *StrategyStore) Get(userID, id string) (*Strategy, error) {
|
||
var st Strategy
|
||
err := s.db.Where("id = ? AND (user_id = ? OR is_default = ?)", id, userID, true).
|
||
First(&st).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &st, nil
|
||
}
|
||
|
||
// GetActive get user's currently active strategy
|
||
func (s *StrategyStore) GetActive(userID string) (*Strategy, error) {
|
||
var st Strategy
|
||
err := s.db.Where("user_id = ? AND is_active = ?", userID, true).First(&st).Error
|
||
if err == gorm.ErrRecordNotFound {
|
||
// no active strategy, return system default strategy
|
||
return s.GetDefault()
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &st, nil
|
||
}
|
||
|
||
// GetDefault get system default strategy
|
||
func (s *StrategyStore) GetDefault() (*Strategy, error) {
|
||
var st Strategy
|
||
err := s.db.Where("is_default = ?", true).First(&st).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &st, nil
|
||
}
|
||
|
||
// SetActive set active strategy (will first deactivate other strategies)
|
||
func (s *StrategyStore) SetActive(userID, strategyID string) error {
|
||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||
// first deactivate all strategies for the user
|
||
if err := tx.Model(&Strategy{}).Where("user_id = ?", userID).
|
||
Update("is_active", false).Error; err != nil {
|
||
return err
|
||
}
|
||
|
||
// activate specified strategy
|
||
return tx.Model(&Strategy{}).
|
||
Where("id = ? AND (user_id = ? OR is_default = ?)", strategyID, userID, true).
|
||
Update("is_active", true).Error
|
||
})
|
||
}
|
||
|
||
// Duplicate duplicate a strategy (used to create custom strategy based on default strategy)
|
||
func (s *StrategyStore) Duplicate(userID, sourceID, newID, newName string) error {
|
||
// get source strategy
|
||
source, err := s.Get(userID, sourceID)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to get source strategy: %w", err)
|
||
}
|
||
|
||
// create new strategy
|
||
newStrategy := &Strategy{
|
||
ID: newID,
|
||
UserID: userID,
|
||
Name: newName,
|
||
Description: "Created based on [" + source.Name + "]",
|
||
IsActive: false,
|
||
IsDefault: false,
|
||
Config: source.Config,
|
||
}
|
||
|
||
return s.Create(newStrategy)
|
||
}
|
||
|
||
// ParseConfig parse strategy configuration JSON
|
||
func (s *Strategy) ParseConfig() (*StrategyConfig, error) {
|
||
var config StrategyConfig
|
||
if err := json.Unmarshal([]byte(s.Config), &config); err != nil {
|
||
return nil, fmt.Errorf("failed to parse strategy configuration: %w", err)
|
||
}
|
||
return &config, nil
|
||
}
|
||
|
||
// SetConfig set strategy configuration
|
||
func (s *Strategy) SetConfig(config *StrategyConfig) error {
|
||
data, err := json.Marshal(config)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to serialize strategy configuration: %w", err)
|
||
}
|
||
s.Config = string(data)
|
||
return nil
|
||
}
|
||
|
||
// ============================================================================
|
||
// Token Estimation
|
||
// ============================================================================
|
||
|
||
// TokenEstimate holds the result of token estimation
|
||
type TokenEstimate struct {
|
||
Total int `json:"total"`
|
||
Breakdown TokenBreakdown `json:"breakdown"`
|
||
ModelLimits []ModelLimit `json:"model_limits"`
|
||
Suggestions []string `json:"suggestions"`
|
||
}
|
||
|
||
// TokenBreakdown shows estimated tokens per component
|
||
type TokenBreakdown struct {
|
||
SystemPrompt int `json:"system_prompt"`
|
||
MarketData int `json:"market_data"`
|
||
RankingData int `json:"ranking_data"`
|
||
QuantData int `json:"quant_data"`
|
||
FixedOverhead int `json:"fixed_overhead"`
|
||
}
|
||
|
||
// ModelLimit shows token usage against a specific model's context limit
|
||
type ModelLimit struct {
|
||
Name string `json:"name"`
|
||
ContextLimit int `json:"context_limit"`
|
||
UsagePct int `json:"usage_pct"`
|
||
Level string `json:"level"` // "ok" | "warning" | "danger"
|
||
}
|
||
|
||
// Context window sizes (tokens) for each model family
|
||
const (
|
||
contextLimitDeepSeek = 131_072 // 128K
|
||
contextLimitOpenAI = 128_000 // 128K
|
||
contextLimitClaude = 200_000 // 200K
|
||
contextLimitQwen = 131_072 // 128K
|
||
contextLimitGemini = 1_000_000 // 1M
|
||
contextLimitGrok = 131_072 // 128K
|
||
contextLimitKimi = 131_072 // 128K
|
||
contextLimitMinimax = 1_000_000 // 1M
|
||
)
|
||
|
||
// ModelContextLimits maps provider names to their context window sizes (in tokens)
|
||
var ModelContextLimits = map[string]int{
|
||
"deepseek": contextLimitDeepSeek,
|
||
"openai": contextLimitOpenAI,
|
||
"claude": contextLimitClaude,
|
||
"qwen": contextLimitQwen,
|
||
"gemini": contextLimitGemini,
|
||
"grok": contextLimitGrok,
|
||
"kimi": contextLimitKimi,
|
||
"minimax": contextLimitMinimax,
|
||
}
|
||
|
||
// GetContextLimit returns the context limit for a given provider
|
||
func GetContextLimit(provider string) int {
|
||
if limit, ok := ModelContextLimits[provider]; ok {
|
||
return limit
|
||
}
|
||
return contextLimitDeepSeek // safe default
|
||
}
|
||
|
||
// GetContextLimitForClient returns context limit for a provider+model pair.
|
||
// For claw402, the underlying model is inferred from the model name prefix.
|
||
func GetContextLimitForClient(provider, model string) int {
|
||
if provider == "claw402" {
|
||
switch {
|
||
case strings.HasPrefix(model, "claude"):
|
||
return ModelContextLimits["claude"]
|
||
case strings.HasPrefix(model, "gpt"), strings.HasPrefix(model, "o1"), strings.HasPrefix(model, "o3"):
|
||
return ModelContextLimits["openai"]
|
||
case strings.HasPrefix(model, "gemini"):
|
||
return ModelContextLimits["gemini"]
|
||
case strings.HasPrefix(model, "grok"):
|
||
return ModelContextLimits["grok"]
|
||
case strings.HasPrefix(model, "kimi"):
|
||
return ModelContextLimits["kimi"]
|
||
case strings.HasPrefix(model, "qwen"):
|
||
return ModelContextLimits["qwen"]
|
||
case strings.HasPrefix(model, "minimax"):
|
||
return ModelContextLimits["minimax"]
|
||
case strings.HasPrefix(model, "deepseek"):
|
||
return ModelContextLimits["deepseek"]
|
||
default:
|
||
return ModelContextLimits["deepseek"]
|
||
}
|
||
}
|
||
return GetContextLimit(provider)
|
||
}
|
||
|
||
// EstimateTokens estimates the total token count for a strategy configuration.
|
||
// This is a pure computation based on config fields — no network calls.
|
||
func (c *StrategyConfig) EstimateTokens() TokenEstimate {
|
||
breakdown := TokenBreakdown{}
|
||
|
||
// --- System Prompt ---
|
||
// Base system prompt: schema + role + rules + output format
|
||
baseChars := 4000 // English default
|
||
if c.Language == "zh" {
|
||
baseChars = 3000
|
||
}
|
||
// Add prompt sections
|
||
baseChars += len(c.PromptSections.RoleDefinition)
|
||
baseChars += len(c.PromptSections.TradingFrequency)
|
||
baseChars += len(c.PromptSections.EntryStandards)
|
||
baseChars += len(c.PromptSections.DecisionProcess)
|
||
baseChars += len(c.CustomPrompt)
|
||
|
||
if c.Language == "zh" {
|
||
breakdown.SystemPrompt = baseChars / 2 // CJK: ~2 chars per token
|
||
} else {
|
||
breakdown.SystemPrompt = baseChars / 4 // English: ~4 chars per token
|
||
}
|
||
|
||
// --- Fixed Overhead ---
|
||
// Time, BTC price, account info, section headers
|
||
breakdown.FixedOverhead = 800 / 4 // ~200 tokens
|
||
|
||
// --- Market Data ---
|
||
numCoins := c.getEffectiveCoinCount()
|
||
numTimeframes := c.getEffectiveTimeframeCount()
|
||
klineCount := c.Indicators.Klines.PrimaryCount
|
||
if klineCount <= 0 {
|
||
klineCount = 20
|
||
}
|
||
|
||
// Per coin per timeframe: kline OHLCV rows
|
||
charsPerCoinTF := klineCount * 80 // each OHLCV line ~80 chars
|
||
|
||
// Add enabled indicator overhead per timeframe
|
||
indicatorCharsPerLine := 0
|
||
if c.Indicators.EnableEMA {
|
||
indicatorCharsPerLine += 20 // EMA values appended
|
||
}
|
||
if c.Indicators.EnableMACD {
|
||
indicatorCharsPerLine += 30
|
||
}
|
||
if c.Indicators.EnableRSI {
|
||
indicatorCharsPerLine += 15
|
||
}
|
||
if c.Indicators.EnableATR {
|
||
indicatorCharsPerLine += 15
|
||
}
|
||
if c.Indicators.EnableBOLL {
|
||
indicatorCharsPerLine += 25
|
||
}
|
||
if c.Indicators.EnableVolume {
|
||
indicatorCharsPerLine += 10
|
||
}
|
||
charsPerCoinTF += klineCount * indicatorCharsPerLine
|
||
|
||
totalMarketChars := numCoins * numTimeframes * charsPerCoinTF
|
||
|
||
// OI + Funding per coin
|
||
if c.Indicators.EnableOI || c.Indicators.EnableFundingRate {
|
||
totalMarketChars += numCoins * 100
|
||
}
|
||
|
||
breakdown.MarketData = totalMarketChars / 4 // numeric data: ~4 chars per token
|
||
|
||
// --- Quant Data ---
|
||
if c.Indicators.EnableQuantData {
|
||
quantCharsPerCoin := 0
|
||
if c.Indicators.EnableQuantOI {
|
||
quantCharsPerCoin += 300
|
||
}
|
||
if c.Indicators.EnableQuantNetflow {
|
||
quantCharsPerCoin += 300
|
||
}
|
||
breakdown.QuantData = (numCoins * quantCharsPerCoin) / 4
|
||
}
|
||
|
||
// --- Ranking Data ---
|
||
rankingChars := 0
|
||
if c.Indicators.EnableOIRanking {
|
||
limit := c.Indicators.OIRankingLimit
|
||
if limit <= 0 {
|
||
limit = 10
|
||
}
|
||
rankingChars += limit * 60
|
||
}
|
||
if c.Indicators.EnableNetFlowRanking {
|
||
limit := c.Indicators.NetFlowRankingLimit
|
||
if limit <= 0 {
|
||
limit = 10
|
||
}
|
||
rankingChars += limit * 80
|
||
}
|
||
if c.Indicators.EnablePriceRanking {
|
||
limit := c.Indicators.PriceRankingLimit
|
||
if limit <= 0 {
|
||
limit = 10
|
||
}
|
||
// Count durations (comma-separated)
|
||
numDurations := 1
|
||
if c.Indicators.PriceRankingDuration != "" {
|
||
numDurations = len(strings.Split(c.Indicators.PriceRankingDuration, ","))
|
||
}
|
||
rankingChars += limit * numDurations * 40
|
||
}
|
||
breakdown.RankingData = rankingChars / 4
|
||
|
||
// --- Total with 15% safety margin ---
|
||
subtotal := breakdown.SystemPrompt + breakdown.MarketData + breakdown.RankingData + breakdown.QuantData + breakdown.FixedOverhead
|
||
total := subtotal * 115 / 100
|
||
|
||
// --- Model limits ---
|
||
modelLimits := make([]ModelLimit, 0, len(ModelContextLimits))
|
||
for name, limit := range ModelContextLimits {
|
||
pct := total * 100 / limit
|
||
level := "ok"
|
||
if pct >= 100 {
|
||
level = "danger"
|
||
} else if pct >= 80 {
|
||
level = "warning"
|
||
}
|
||
modelLimits = append(modelLimits, ModelLimit{
|
||
Name: name,
|
||
ContextLimit: limit,
|
||
UsagePct: pct,
|
||
Level: level,
|
||
})
|
||
}
|
||
|
||
// Sort by usage_pct desc, then name asc for deterministic order
|
||
sort.Slice(modelLimits, func(i, j int) bool {
|
||
if modelLimits[i].UsagePct != modelLimits[j].UsagePct {
|
||
return modelLimits[i].UsagePct > modelLimits[j].UsagePct
|
||
}
|
||
return modelLimits[i].Name < modelLimits[j].Name
|
||
})
|
||
|
||
// --- Suggestions ---
|
||
var suggestions []string
|
||
// Find the strictest model (smallest context)
|
||
minLimit := 0
|
||
for _, limit := range ModelContextLimits {
|
||
if minLimit == 0 || limit < minLimit {
|
||
minLimit = limit
|
||
}
|
||
}
|
||
if minLimit > 0 && total > minLimit {
|
||
if numTimeframes > 1 {
|
||
savedPerTF := (numCoins * klineCount * (80 + indicatorCharsPerLine)) / 4 * 115 / 100
|
||
suggestions = append(suggestions, fmt.Sprintf("Reduce 1 timeframe to save ~%d tokens", savedPerTF))
|
||
}
|
||
if numCoins > 1 {
|
||
savedPerCoin := (numTimeframes * klineCount * (80 + indicatorCharsPerLine)) / 4 * 115 / 100
|
||
suggestions = append(suggestions, fmt.Sprintf("Reduce 1 coin to save ~%d tokens", savedPerCoin))
|
||
}
|
||
if klineCount > 15 {
|
||
suggestions = append(suggestions, "Reduce K-line count to 15 to save tokens")
|
||
}
|
||
}
|
||
|
||
return TokenEstimate{
|
||
Total: total,
|
||
Breakdown: breakdown,
|
||
ModelLimits: modelLimits,
|
||
Suggestions: suggestions,
|
||
}
|
||
}
|
||
|
||
// getEffectiveCoinCount returns the estimated number of coins that will be analyzed
|
||
func (c *StrategyConfig) getEffectiveCoinCount() int {
|
||
count := 0
|
||
switch c.CoinSource.SourceType {
|
||
case "static":
|
||
count = len(c.CoinSource.StaticCoins)
|
||
case "ai500":
|
||
count = c.CoinSource.AI500Limit
|
||
case "oi_top":
|
||
count = c.CoinSource.OITopLimit
|
||
case "oi_low":
|
||
count = c.CoinSource.OILowLimit
|
||
case "hyper_rank":
|
||
count = c.CoinSource.HyperRankLimit
|
||
case "hyper_main":
|
||
count = c.CoinSource.HyperMainLimit
|
||
case "hyper_all":
|
||
count = c.CoinSource.HyperMainLimit
|
||
default:
|
||
count = c.CoinSource.HyperRankLimit
|
||
}
|
||
if count <= 0 {
|
||
count = 3
|
||
}
|
||
return count
|
||
}
|
||
|
||
// getEffectiveTimeframeCount returns the number of timeframes that will be used
|
||
func (c *StrategyConfig) getEffectiveTimeframeCount() int {
|
||
if len(c.Indicators.Klines.SelectedTimeframes) > 0 {
|
||
return len(c.Indicators.Klines.SelectedTimeframes)
|
||
}
|
||
count := 1
|
||
if c.Indicators.Klines.LongerTimeframe != "" {
|
||
count++
|
||
}
|
||
return count
|
||
}
|