mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
112
store/strategy_token_test.go
Normal file
112
store/strategy_token_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user