Files
nofx/store/strategy.go
tinkle-community 110bf52908 feat: cream terminal redesign, English-only UI, autopilot launch fixes
- Redesign dashboard into a cream-paper + vermilion IBM Plex Mono terminal
  (live L2 order book, cost/liq map, WS K-line, signal matrix, orchestration
  topology, risk radar, execution log, current positions, equity curve)
- Convert all user-facing UI and backend strings/prompts from Chinese to
  English (multi-language retained, default English)
- Add /api/statistics/full endpoint + full-stats frontend wiring
- Fix Autopilot launch: reuse the existing trader instead of creating
  duplicates (eliminates repeat ~35s create cost and stale-trader 404s);
  launch sends 5m scan interval
- Fix unreadable toasts: cream theme with high-contrast text + per-type accent
- Silence background dashboard polls (getTraderConfig) to stop error-toast spam
2026-06-30 16:03:52 +08:00

1535 lines
52 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = 8
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
}
if c.CoinSource.VergexLimit > MaxCandidateCoins {
c.CoinSource.VergexLimit = 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.StaticCoins = normalizeSymbols(c.CoinSource.StaticCoins)
c.CoinSource.ExcludedCoins = normalizeSymbols(c.CoinSource.ExcludedCoins)
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
}
case "vergex_signal":
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = false
c.CoinSource.UseHyperAll = false
c.CoinSource.UseHyperMain = false
minLimit := 10
if len(c.CoinSource.StaticCoins) > 0 {
minLimit = len(c.CoinSource.StaticCoins)
if minLimit > MaxCandidateCoins {
minLimit = MaxCandidateCoins
}
}
if c.CoinSource.VergexLimit < minLimit {
c.CoinSource.VergexLimit = minLimit
}
if c.CoinSource.VergexMarketType == "" {
c.CoinSource.VergexMarketType = "all"
}
if c.CoinSource.VergexChain == "" {
c.CoinSource.VergexChain = "hyperliquid"
}
default:
c.CoinSource.SourceType = "vergex_signal"
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = false
c.CoinSource.UseHyperAll = false
c.CoinSource.UseHyperMain = false
minLimit := 10
if len(c.CoinSource.StaticCoins) > 0 {
minLimit = len(c.CoinSource.StaticCoins)
if minLimit > MaxCandidateCoins {
minLimit = MaxCandidateCoins
}
}
if c.CoinSource.VergexLimit < minLimit {
c.CoinSource.VergexLimit = minLimit
}
if c.CoinSource.VergexMarketType == "" {
c.CoinSource.VergexMarketType = "all"
}
if c.CoinSource.VergexChain == "" {
c.CoinSource.VergexChain = "hyperliquid"
}
}
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", "grid strategy":
return "grid_trading"
case "", "ai", "ai_strategy", "ai-trading", "ai trading", "ai_trading", "ai strategy", "ai smart strategy":
return "ai_trading"
default:
return value
}
}
func normalizeCoinSourceType(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
compact := strings.NewReplacer(" ", "", "_", "", "-", "", "datasource", "", "coinselection", "", "coin", "").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, "highest open interest") || strings.Contains(value, "top open interest"):
return "oi_top"
case strings.Contains(compact, "oilow") || strings.Contains(value, "oi low") || strings.Contains(value, "lowest open interest") || strings.Contains(value, "low open interest"):
return "oi_low"
case strings.Contains(compact, "hyperrank"):
return "hyper_rank"
case strings.Contains(compact, "vergex") || strings.Contains(compact, "claw402") || strings.Contains(compact, "dynamicranking") || strings.Contains(value, "dynamic board") || strings.Contains(value, "gainers board") || strings.Contains(value, "signal board"):
return "vergex_signal"
case strings.Contains(compact, "hyperall"):
return "hyper_all"
case strings.Contains(compact, "hypermain"):
return "hyper_main"
case strings.Contains(value, "static") || strings.Contains(value, "fixed"):
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.VergexLimit > 0 || source.VergexMarketType != "" || source.VergexChain != "" || source.VergexLiqBand != "":
return "vergex_signal"
case source.HyperRankCategory != "" || source.HyperRankDirection != "" || source.HyperRankLimit > 0:
return "hyper_rank"
default:
return "vergex_signal"
}
}
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 minute": "1m",
"3 minute": "3m",
"5 minute": "5m",
"15 minute": "15m",
"30 minute": "30m",
"1 hour": "1h",
"2 hour": "2h",
"4 hour": "4h",
"6 hour": "6h",
"8 hour": "8h",
"12 hour": "12h",
"1 day": "1d",
"3 day": "3d",
"1 week": "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 adjusted from %d to %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 adjusted from %.2f to %.2f", labelZH, from, to))
return
}
warnings = append(warnings, fmt.Sprintf("%s adjusted from %.2f to %.2f", labelEN, from, to))
}
appendInt("Max Positions", "max_positions", before.RiskControl.MaxPositions, after.RiskControl.MaxPositions)
appendInt("BTC/ETH Max Leverage", "btc_eth_max_leverage", before.RiskControl.BTCETHMaxLeverage, after.RiskControl.BTCETHMaxLeverage)
appendInt("Altcoin Max Leverage", "altcoin_max_leverage", before.RiskControl.AltcoinMaxLeverage, after.RiskControl.AltcoinMaxLeverage)
appendFloat("BTC/ETH Max Position Value Ratio", "btc_eth_max_position_value_ratio", before.RiskControl.BTCETHMaxPositionValueRatio, after.RiskControl.BTCETHMaxPositionValueRatio)
appendFloat("Altcoin Max Position Value Ratio", "altcoin_max_position_value_ratio", before.RiskControl.AltcoinMaxPositionValueRatio, after.RiskControl.AltcoinMaxPositionValueRatio)
appendFloat("Min Risk/Reward Ratio", "min_risk_reward_ratio", before.RiskControl.MinRiskRewardRatio, after.RiskControl.MinRiskRewardRatio)
appendFloat("Max Margin Usage", "max_margin_usage", before.RiskControl.MaxMarginUsage, after.RiskControl.MaxMarginUsage)
appendFloat("Min Position Size", "min_position_size", before.RiskControl.MinPositionSize, after.RiskControl.MinPositionSize)
appendInt("Min Confidence", "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"`
// Vergex signal-ranking maximum count. Defaults to 5 and is hard capped at 10.
VergexLimit int `json:"vergex_limit,omitempty"`
// Vergex market type for detail endpoints, e.g. hip3_perp for Hyperliquid TradeFi perps.
VergexMarketType string `json:"vergex_market_type,omitempty"`
// Vergex chain query parameter. Defaults to hyperliquid.
VergexChain string `json:"vergex_chain,omitempty"`
// Vergex liquidation band query parameter.
VergexLiqBand string `json:"vergex_liq_band,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: "vergex_signal",
UseAI500: false,
AI500Limit: 3,
UseOITop: false,
OITopLimit: 3,
UseOILow: false,
OILowLimit: 3,
UseHyperAll: false,
UseHyperMain: false,
HyperMainLimit: 30,
HyperRankCategory: "all",
VergexLimit: 10,
VergexMarketType: "all",
VergexChain: "hyperliquid",
},
Indicators: IndicatorConfig{
Klines: KlineConfig{
PrimaryTimeframe: "15m",
PrimaryCount: 30,
LongerTimeframe: "",
LongerCount: 0,
EnableMultiTimeframe: false,
SelectedTimeframes: []string{"15m"},
},
EnableRawKlines: true, // Required - raw OHLCV data for AI analysis
EnableEMA: false,
EnableMACD: false,
EnableRSI: false,
EnableATR: false,
EnableBOLL: false,
EnableVolume: false,
EnableOI: false,
EnableFundingRate: false,
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: 6, // Hold up to 6 instruments (≈3 long + 3 short) simultaneously (CODE ENFORCED)
BTCETHMaxLeverage: 10, // BTC/ETH exchange leverage (AI guided)
AltcoinMaxLeverage: 10, // TradeFi exchange leverage (AI guided)
BTCETHMaxPositionValueRatio: 1.2, // Per-position notional = equity × 1.2 so several positions fit the margin
AltcoinMaxPositionValueRatio: 1.2, // Per-position notional = equity × 1.2 so several positions fit the margin
MaxMarginUsage: 1.0, // Claw402 Autopilot intentionally uses full margin when opening
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
MinConfidence: 78, // Min 78% confidence (AI guided)
},
}
if lang == "zh" {
config.PromptSections = PromptSectionsConfig{
RoleDefinition: `# You are the NOFX Claw402 auto-trader
Trade only the Hyperliquid tradable instruments returned by this cycle's Claw402.ai/Vergex board. The candidate pool comes from Claw402.ai/Vergex; before opening a position, you must combine Signal Lab, cost/liquidation heatmap and raw candles.`,
TradingFrequency: `# Trading Frequency
- Prioritize waiting for high-quality opportunities; you do not need to trade every cycle.
- Manage existing positions first, then consider opening new ones.
- Do not churn in and out of the same symbol in one cycle.`,
EntryStandards: `# Entry Standards
Open a position only when Claw402 Signal Lab, cost/liquidation heatmap and raw candles broadly agree. The Claw402 ranking is only the candidate pool, not a standalone buy reason. Wait by default when any key data is missing or contradictory.`,
DecisionProcess: `# Decision Process
1. Check existing positions first: decide take profit, stop loss or hold.
2. Pull this cycle's candidates from the Claw402 board, and for each candidate read Claw402 Ranking, Signal Lab and Cost/Liquidation Heatmap.
3. Use raw candles to confirm entry, stop loss and take profit.
4. Output concise reasoning and strict JSON.`,
}
} else {
config.PromptSections = PromptSectionsConfig{
RoleDefinition: `# You are the NOFX Claw402 auto-trader
Trade Hyperliquid Claw402-ranked instruments only. The candidate pool comes from Claw402.ai/Vergex; before opening a position, combine Signal Lab, cost/liquidation heatmap and raw candles.`,
TradingFrequency: `# Trading Frequency
- Wait for quality; you do not need to trade every cycle.
- Manage existing positions before opening new ones.
- Do not churn in and out of the same symbol in one cycle.`,
EntryStandards: `# Entry Standards
Open only when Claw402 Signal Lab, cost/liquidation heatmap and raw candles broadly agree. Ranking defines the candidate pool, not a standalone entry reason. Wait when key data is missing or contradictory.`,
DecisionProcess: `# Decision Process
1. Check current positions first: take profit, stop loss or hold.
2. Pull this cycle's Claw402 board and read Claw402 Ranking, Signal Lab and Cost/Liquidation Heatmap for each candidate.
3. Use raw candles to confirm entry, stop and target.
4. Output concise reasoning and strict 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 "vergex_signal":
count = c.CoinSource.VergexLimit
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
}