fix: prevent DeepSeek token overflow with product-level limits (#1431)

* feat: enforce strategy limits to prevent token overflow

* fix: tune token limits after real-world testing

- Relax kline max 20→30, timeframes 3→4 (tested ~41K tokens, safe under 131K)
- Restore ranking limits to original [5,10,15,20] options (only ~1.5K token impact)
- Add static coins limit (max 3) with toast notification
- Add timeframe limit toast when exceeding 4
- Log SSE token usage (prompt/completion/total) from API response
- Fix nil logger crash in claw402 data client (engine.go)

* feat: add token estimation functionality for strategy configurations

* feat: add discard changes button in Strategy Studio for unsaved modifications

* feat: retain selected strategy after saving in Strategy Studio

* feat: enhance strategy display in Strategy Studio with improved layout and sorting of token limits

* refactor: improve layout and styling of stats display in CompetitionPage

* refactor: replace select elements with NofxSelect component for improved consistency in strategy configuration forms

* style: update NofxSelect component to use smaller text size for improved readability

* feat: implement token overflow handling in strategy updates and UI

---------

Co-authored-by: Dean <afei.wuhao@gmail.com>
This commit is contained in:
deanokk
2026-03-27 00:26:40 +08:00
committed by GitHub
parent af6f6d5930
commit f0d3352971
20 changed files with 1124 additions and 338 deletions

View File

@@ -3,11 +3,63 @@ package store
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"gorm.io/gorm"
)
// Hard limits to prevent token explosion in AI requests
const (
MaxCandidateCoins = 3
MaxPositions = 3
MaxTimeframes = 4
MinKlineCount = 10
MaxKlineCount = 30
)
// ClampLimits enforces product-level limits on strategy config to prevent token overflow.
func (c *StrategyConfig) ClampLimits() {
// 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 > MaxPositions {
c.RiskControl.MaxPositions = MaxPositions
}
}
// StrategyStore strategy storage
type StrategyStore struct {
db *gorm.DB
@@ -260,20 +312,20 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
CoinSource: CoinSourceConfig{
SourceType: "ai500",
UseAI500: true,
AI500Limit: 10,
AI500Limit: 3,
UseOITop: false,
OITopLimit: 10,
OITopLimit: 3,
UseOILow: false,
OILowLimit: 10,
OILowLimit: 3,
},
Indicators: IndicatorConfig{
Klines: KlineConfig{
PrimaryTimeframe: "5m",
PrimaryCount: 30,
PrimaryCount: 20,
LongerTimeframe: "4h",
LongerCount: 10,
EnableMultiTimeframe: true,
SelectedTimeframes: []string{"5m", "15m", "1h", "4h"},
SelectedTimeframes: []string{"5m", "15m", "1h"},
},
EnableRawKlines: true, // Required - raw OHLCV data for AI analysis
EnableEMA: false,
@@ -510,3 +562,268 @@ func (s *Strategy) SetConfig(config *StrategyConfig) error {
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"
}
// ModelContextLimits maps provider names to their context window sizes (in tokens)
var ModelContextLimits = map[string]int{
"deepseek": 131072,
"openai": 128000,
"claude": 200000,
"qwen": 131072,
"gemini": 1000000,
"grok": 131072,
"kimi": 131072,
"minimax": 1000000,
}
// GetContextLimit returns the context limit for a given provider
func GetContextLimit(provider string) int {
if limit, ok := ModelContextLimits[provider]; ok {
return limit
}
return 131072 // safe default
}
// 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 "mixed":
if c.CoinSource.UseAI500 {
count += c.CoinSource.AI500Limit
}
if c.CoinSource.UseOITop {
count += c.CoinSource.OITopLimit
}
if c.CoinSource.UseOILow {
count += c.CoinSource.OILowLimit
}
default:
count = c.CoinSource.AI500Limit
}
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
}

View File

@@ -0,0 +1,112 @@
package store
import "testing"
func TestEstimateTokens_DefaultConfig(t *testing.T) {
config := GetDefaultStrategyConfig("en")
est := config.EstimateTokens()
if est.Total <= 0 {
t.Errorf("expected positive token estimate, got %d", est.Total)
}
if est.Total > 200000 {
t.Errorf("token estimate %d seems unreasonably high for default config", est.Total)
}
// Breakdown should sum approximately to total (before 15% margin)
subtotal := est.Breakdown.SystemPrompt + est.Breakdown.MarketData +
est.Breakdown.RankingData + est.Breakdown.QuantData + est.Breakdown.FixedOverhead
expectedTotal := subtotal * 115 / 100
if est.Total != expectedTotal {
t.Errorf("total %d != breakdown subtotal %d * 1.15 = %d", est.Total, subtotal, expectedTotal)
}
// Should have model limits
if len(est.ModelLimits) == 0 {
t.Error("expected model limits to be populated")
}
// Default config should be ok for all models
for _, ml := range est.ModelLimits {
if ml.Level == "danger" {
t.Errorf("default config should not exceed %s limit, got %d%%", ml.Name, ml.UsagePct)
}
}
}
func TestEstimateTokens_ZhVsEn(t *testing.T) {
enConfig := GetDefaultStrategyConfig("en")
zhConfig := GetDefaultStrategyConfig("zh")
enEst := enConfig.EstimateTokens()
zhEst := zhConfig.EstimateTokens()
// Chinese config should have more tokens for system prompt due to CJK encoding
// but total can vary — just ensure both are reasonable
if enEst.Total <= 0 || zhEst.Total <= 0 {
t.Errorf("both estimates should be positive: en=%d, zh=%d", enEst.Total, zhEst.Total)
}
}
func TestEstimateTokens_HighConfig(t *testing.T) {
config := GetDefaultStrategyConfig("en")
// Push config to extremes (beyond clamped limits)
config.CoinSource.SourceType = "static"
config.CoinSource.StaticCoins = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "DOGEUSDT", "XRPUSDT"}
config.Indicators.Klines.SelectedTimeframes = []string{"1m", "3m", "5m", "15m", "1h", "4h"}
config.Indicators.Klines.PrimaryCount = 100
config.Indicators.EnableEMA = true
config.Indicators.EnableMACD = true
config.Indicators.EnableRSI = true
config.Indicators.EnableATR = true
config.Indicators.EnableBOLL = true
est := config.EstimateTokens()
// Should produce a higher estimate than default
defaultCfg := GetDefaultStrategyConfig("en")
defaultEst := defaultCfg.EstimateTokens()
if est.Total <= defaultEst.Total {
t.Errorf("high config estimate %d should be greater than default %d", est.Total, defaultEst.Total)
}
// Should have some models in warning/danger
hasDanger := false
for _, ml := range est.ModelLimits {
if ml.Level == "danger" || ml.Level == "warning" {
hasDanger = true
break
}
}
// With 5 coins * 6 timeframes * 100 klines, this should exceed small models
if !hasDanger {
t.Logf("high config estimate: %d tokens", est.Total)
}
}
func TestGetContextLimit(t *testing.T) {
if got := GetContextLimit("deepseek"); got != 131072 {
t.Errorf("deepseek limit = %d, want 131072", got)
}
if got := GetContextLimit("unknown_provider"); got != 131072 {
t.Errorf("unknown provider should return default 131072, got %d", got)
}
}
func TestGetEffectiveCoinCount(t *testing.T) {
config := StrategyConfig{
CoinSource: CoinSourceConfig{
SourceType: "static",
StaticCoins: []string{"BTCUSDT", "ETHUSDT"},
},
}
if got := config.getEffectiveCoinCount(); got != 2 {
t.Errorf("static coin count = %d, want 2", got)
}
config.CoinSource.SourceType = "ai500"
config.CoinSource.AI500Limit = 5
if got := config.getEffectiveCoinCount(); got != 5 {
t.Errorf("ai500 coin count = %d, want 5", got)
}
}