From f0d3352971023dd9b493871f637dca77546ba561 Mon Sep 17 00:00:00 2001 From: deanokk Date: Fri, 27 Mar 2026 00:26:40 +0800 Subject: [PATCH] fix: prevent DeepSeek token overflow with product-level limits (#1431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- api/server.go | 1 + api/strategy.go | 33 ++ kernel/engine.go | 2 +- kernel/engine_analysis.go | 24 ++ mcp/client.go | 11 + store/strategy.go | 327 +++++++++++++++++- store/strategy_token_test.go | 112 ++++++ .../components/strategy/CoinSourceEditor.tsx | 107 +++--- .../components/strategy/GridConfigEditor.tsx | 37 +- .../components/strategy/IndicatorEditor.tsx | 79 +++-- .../components/strategy/RiskControlEditor.tsx | 2 +- .../components/strategy/TokenEstimateBar.tsx | 143 ++++++++ web/src/components/trader/CompetitionPage.tsx | 22 +- web/src/components/trader/PositionHistory.tsx | 55 ++- .../components/trader/TelegramConfigModal.tsx | 39 +-- .../components/trader/TraderConfigModal.tsx | 69 ++-- web/src/components/ui/select.tsx | 99 ++++++ web/src/i18n/translations.ts | 15 + web/src/pages/StrategyStudioPage.tsx | 167 +++++---- web/src/pages/TraderDashboardPage.tsx | 118 ++++--- 20 files changed, 1124 insertions(+), 338 deletions(-) create mode 100644 store/strategy_token_test.go create mode 100644 web/src/components/strategy/TokenEstimateBar.tsx create mode 100644 web/src/components/ui/select.tsx diff --git a/api/server.go b/api/server.go index a6037e77..f9377934 100644 --- a/api/server.go +++ b/api/server.go @@ -110,6 +110,7 @@ func (s *Server) setupRoutes() { // Public strategy market (no authentication required) s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies) + s.route(api, "POST", "/strategies/estimate-tokens", "Estimate token usage for a strategy config", s.handleEstimateTokens) // Authentication related routes (no authentication required) s.route(api, "POST", "/register", "Register new user", s.handleRegister) diff --git a/api/strategy.go b/api/strategy.go index c58f3278..8939c8d8 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -31,6 +31,20 @@ func validateStrategyConfig(config *store.StrategyConfig) []string { return warnings } +// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation) +func (s *Server) handleEstimateTokens(c *gin.Context) { + var req struct { + Config store.StrategyConfig `json:"config" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + SafeBadRequest(c, "Invalid request parameters") + return + } + + estimate := req.Config.EstimateTokens() + c.JSON(http.StatusOK, estimate) +} + // handlePublicStrategies Get public strategies for strategy market (no auth required) func (s *Server) handlePublicStrategies(c *gin.Context) { strategies, err := s.store.Strategy().ListPublic() @@ -289,6 +303,25 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) { return } + // Token overflow check — block save if all models exceed context limits + if mergedConfig.StrategyType == "" || mergedConfig.StrategyType == "ai_trading" { + estimate := mergedConfig.EstimateTokens() + allExceed := true + for _, ml := range estimate.ModelLimits { + if ml.UsagePct <= 100 { + allExceed = false + break + } + } + if allExceed && len(estimate.ModelLimits) > 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Estimated %d tokens exceeds all known model context limits. Reduce coins, timeframes, or K-line count.", estimate.Total), + "token_estimate": estimate, + }) + return + } + } + // Validate merged configuration and collect warnings warnings := validateStrategyConfig(&mergedConfig) diff --git a/kernel/engine.go b/kernel/engine.go index 147cdceb..a5e1f0ce 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -209,7 +209,7 @@ func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string) if claw402URL == "" { claw402URL = "https://claw402.ai" } - claw402Client, err := nofxos.NewClaw402DataClient(claw402URL, walletKey, nil) + claw402Client, err := nofxos.NewClaw402DataClient(claw402URL, walletKey, &logger.MCPLogger{}) if err == nil { client.SetClaw402(claw402Client) logger.Infof("🔗 NofxOS data routed through claw402 (%s)", claw402URL) diff --git a/kernel/engine_analysis.go b/kernel/engine_analysis.go index 4a1071bd..b367b1ac 100644 --- a/kernel/engine_analysis.go +++ b/kernel/engine_analysis.go @@ -51,6 +51,30 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S engine = NewStrategyEngine(&defaultConfig) } + // Clamp strategy limits to prevent token overflow + engineConfig := engine.GetConfig() + engineConfig.ClampLimits() + + // Token estimation check — warn or block if exceeding all known model limits + estimate := engineConfig.EstimateTokens() + allExceed := true + anyWarning := false + for _, ml := range estimate.ModelLimits { + if ml.UsagePct <= 100 { + allExceed = false + } + if ml.UsagePct >= 80 { + anyWarning = true + } + } + if allExceed && len(estimate.ModelLimits) > 0 { + logger.Errorf("🚫 Token estimate %d exceeds ALL known model context limits — blocking analysis", estimate.Total) + return nil, fmt.Errorf("estimated %d tokens exceeds all known model context limits; reduce coins, timeframes, or K-line count", estimate.Total) + } + if anyWarning { + logger.Infof("⚠️ Token estimate %d — approaching context limits for some models", estimate.Total) + } + // 1. Fetch market data using strategy config if len(ctx.MarketDataMap) == 0 { if err := fetchMarketDataWithStrategy(ctx, engine); err != nil { diff --git a/mcp/client.go b/mcp/client.go index 99b0fec2..047bc0e9 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -760,10 +760,21 @@ func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string } `json:"delta"` FinishReason *string `json:"finish_reason"` } `json:"choices"` + Usage *struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage,omitempty"` } if err := json.Unmarshal([]byte(data), &chunk); err != nil { continue // skip malformed chunks } + + if chunk.Usage != nil && chunk.Usage.TotalTokens > 0 { + fmt.Printf("📊 [TokenUsage] prompt=%d, completion=%d, total=%d\n", + chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.TotalTokens) + } + if len(chunk.Choices) == 0 { continue } diff --git a/store/strategy.go b/store/strategy.go index 80eaa171..80459269 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -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 +} diff --git a/store/strategy_token_test.go b/store/strategy_token_test.go new file mode 100644 index 00000000..d9d0997a --- /dev/null +++ b/store/strategy_token_test.go @@ -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) + } +} diff --git a/web/src/components/strategy/CoinSourceEditor.tsx b/web/src/components/strategy/CoinSourceEditor.tsx index 86751c89..fd2b7439 100644 --- a/web/src/components/strategy/CoinSourceEditor.tsx +++ b/web/src/components/strategy/CoinSourceEditor.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react' import type { CoinSourceConfig } from '../../types' import { coinSource, ts } from '../../i18n/strategy-translations' +import { NofxSelect } from '../ui/select' interface CoinSourceEditorProps { config: CoinSourceConfig @@ -24,7 +25,6 @@ export function CoinSourceEditor({ { value: 'ai500', icon: Database, color: '#F0B90B' }, { value: 'oi_top', icon: TrendingUp, color: '#0ECB81' }, { value: 'oi_low', icon: TrendingDown, color: '#F6465D' }, - { value: 'mixed', icon: Shuffle, color: '#60a5fa' }, ] as const // Calculate mixed mode summary @@ -71,8 +71,26 @@ export function CoinSourceEditor({ return xyzDexAssets.has(base) } + const MAX_STATIC_COINS = 3 + + const showToast = (msg: string) => { + const toast = document.createElement('div') + toast.textContent = msg + toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg' + toast.style.cssText = 'background:#F6465D;color:#fff;' + document.body.appendChild(toast) + setTimeout(() => toast.remove(), 2000) + } + const handleAddCoin = () => { if (!newCoin.trim()) return + + const currentCoins = config.static_coins || [] + if (currentCoins.length >= MAX_STATIC_COINS) { + showToast(language === 'zh' ? `最多添加 ${MAX_STATIC_COINS} 个币种` : `Maximum ${MAX_STATIC_COINS} coins allowed`) + return + } + const symbol = newCoin.toUpperCase().trim() // For xyz dex assets (stocks, forex, commodities), use xyz: prefix without USDT @@ -85,7 +103,6 @@ export function CoinSourceEditor({ formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT` } - const currentCoins = config.static_coins || [] if (!currentCoins.includes(formattedSymbol)) { onChange({ ...config, @@ -148,7 +165,7 @@ export function CoinSourceEditor({ -
+
{sourceTypes.map(({ value, icon: Icon, color }) => (
)} @@ -366,19 +380,16 @@ export function CoinSourceEditor({ {ts(coinSource.oiTopLimit, language)}: - + />
)} @@ -423,19 +434,16 @@ export function CoinSourceEditor({ {ts(coinSource.oiLowLimit, language)}: - + /> )} @@ -483,20 +491,13 @@ export function CoinSourceEditor({ {config.use_ai500 && (
Limit: - + />
)} @@ -530,20 +531,13 @@ export function CoinSourceEditor({ {config.use_oi_top && (
Limit: - + />
)} @@ -577,20 +571,13 @@ export function CoinSourceEditor({ {config.use_oi_low && (
Limit: - + />
)} diff --git a/web/src/components/strategy/GridConfigEditor.tsx b/web/src/components/strategy/GridConfigEditor.tsx index 4566501e..0219a031 100644 --- a/web/src/components/strategy/GridConfigEditor.tsx +++ b/web/src/components/strategy/GridConfigEditor.tsx @@ -1,6 +1,7 @@ import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react' import type { GridStrategyConfig } from '../../types' import { gridConfig, ts } from '../../i18n/strategy-translations' +import { NofxSelect } from '../ui/select' interface GridConfigEditorProps { config: GridStrategyConfig @@ -74,20 +75,21 @@ export function GridConfigEditor({

{ts(gridConfig.symbolDesc, language)}

- + options={[ + { value: 'BTCUSDT', label: 'BTC/USDT' }, + { value: 'ETHUSDT', label: 'ETH/USDT' }, + { value: 'SOLUSDT', label: 'SOL/USDT' }, + { value: 'BNBUSDT', label: 'BNB/USDT' }, + { value: 'XRPUSDT', label: 'XRP/USDT' }, + { value: 'DOGEUSDT', label: 'DOGE/USDT' }, + ]} + /> {/* Investment */} @@ -170,17 +172,18 @@ export function GridConfigEditor({

{ts(gridConfig.distributionDesc, language)}

- + options={[ + { value: 'uniform', label: ts(gridConfig.uniform, language) }, + { value: 'gaussian', label: ts(gridConfig.gaussian, language) }, + { value: 'pyramid', label: ts(gridConfig.pyramid, language) }, + ]} + /> diff --git a/web/src/components/strategy/IndicatorEditor.tsx b/web/src/components/strategy/IndicatorEditor.tsx index 2a0667c5..e54c1b56 100644 --- a/web/src/components/strategy/IndicatorEditor.tsx +++ b/web/src/components/strategy/IndicatorEditor.tsx @@ -1,6 +1,7 @@ import { Clock, Activity, TrendingUp, BarChart2, Info, Lock, ExternalLink, Zap, Check, AlertCircle, Key } from 'lucide-react' import type { IndicatorConfig } from '../../types' import { indicator, ts } from '../../i18n/strategy-translations' +import { NofxSelect } from '../ui/select' // Default NofxOS API Key const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c' @@ -60,6 +61,16 @@ export function IndicatorEditor({ }) } } else { + if (current.length >= 4) { + // Show toast notification + const toast = document.createElement('div') + toast.textContent = language === 'zh' ? '最多选择 4 个时间维度' : 'Maximum 4 timeframes allowed' + toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg' + toast.style.cssText = 'background:#F6465D;color:#fff;' + document.body.appendChild(toast) + setTimeout(() => toast.remove(), 2000) + return + } current.push(tf) onChange({ ...config, @@ -299,26 +310,22 @@ export function IndicatorEditor({

{ts(indicator.oiRankingDesc, language)}

{config.enable_oi_ranking && (
e.stopPropagation()}> - - + options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))} + />
)} @@ -359,26 +366,22 @@ export function IndicatorEditor({

{ts(indicator.netflowRankingDesc, language)}

{config.enable_netflow_ranking && (
e.stopPropagation()}> - - + options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))} + />
)} @@ -419,27 +422,27 @@ export function IndicatorEditor({

{ts(indicator.priceRankingDesc, language)}

{config.enable_price_ranking && (
e.stopPropagation()}> - - + options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))} + />
)} @@ -515,7 +518,7 @@ export function IndicatorEditor({ } disabled={disabled} min={10} - max={200} + max={30} className="w-16 px-2 py-1 rounded text-xs text-center" style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} /> diff --git a/web/src/components/strategy/RiskControlEditor.tsx b/web/src/components/strategy/RiskControlEditor.tsx index 8df913a1..152c9773 100644 --- a/web/src/components/strategy/RiskControlEditor.tsx +++ b/web/src/components/strategy/RiskControlEditor.tsx @@ -54,7 +54,7 @@ export function RiskControlEditor({ } disabled={disabled} min={1} - max={10} + max={3} className="w-32 px-3 py-2 rounded" style={{ background: '#1E2329', diff --git a/web/src/components/strategy/TokenEstimateBar.tsx b/web/src/components/strategy/TokenEstimateBar.tsx new file mode 100644 index 00000000..9dd33a7e --- /dev/null +++ b/web/src/components/strategy/TokenEstimateBar.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect, useRef } from 'react' +import { Loader2, Info } from 'lucide-react' +import type { StrategyConfig } from '../../types' +import { t, type Language } from '../../i18n/translations' + +const API_BASE = import.meta.env.VITE_API_BASE || '' + +interface ModelLimit { + name: string + context_limit: number + usage_pct: number + level: string +} + +interface TokenEstimateResult { + total: number + model_limits: ModelLimit[] + suggestions: string[] +} + +interface TokenEstimateBarProps { + config: StrategyConfig | null + language: Language + onOverflowChange?: (overflow: boolean) => void +} + +export function TokenEstimateBar({ config, language, onOverflowChange }: TokenEstimateBarProps) { + const [estimate, setEstimate] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const debounceRef = useRef | null>(null) + + const tr = (key: string) => t(`strategyStudio.${key}`, language) + + useEffect(() => { + if (!config) { + setEstimate(null) + return + } + + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + + debounceRef.current = setTimeout(async () => { + setIsLoading(true) + try { + const response = await fetch(`${API_BASE}/api/strategies/estimate-tokens`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }) + if (response.ok) { + const data = await response.json() + setEstimate(data) + } + } catch { + // silently ignore — non-critical UI element + } finally { + setIsLoading(false) + } + }, 800) + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + } + }, [config]) + + useEffect(() => { + if (!estimate) { + onOverflowChange?.(false) + return + } + const maxPct = estimate.model_limits.reduce((max, ml) => Math.max(max, ml.usage_pct), 0) + onOverflowChange?.(maxPct >= 100) + }, [estimate, onOverflowChange]) + + if (!config) return null + + if (isLoading && !estimate) { + return ( +
+ + {tr('tokenEstimating')} +
+ ) + } + + if (!estimate) return null + + // Find the strictest model (smallest context limit = highest usage_pct) + const strictest = estimate.model_limits.reduce( + (max, ml) => (ml.usage_pct > max.usage_pct ? ml : max), + estimate.model_limits[0] + ) + if (!strictest) return null + + const pct = strictest.usage_pct + const barWidth = Math.min(pct, 100) + + let barColor = '#0ECB81' // green + let textColor = '#848E9C' + if (pct >= 100) { + barColor = '#F6465D' // red + textColor = '#F6465D' + } else if (pct >= 80) { + barColor = '#F0B90B' // yellow + textColor = '#F0B90B' + } + + const exceedWarning = pct >= 100 ? tr('tokenExceedWarning') : null + + return ( +
+
+
+
+
+ + {isLoading ? : `${pct}%`} + +
+ +
+ {tr('tokenTooltip')} ({strictest.name} {(strictest.context_limit / 1000).toFixed(0)}K) +
+
+
+ {exceedWarning && ( +

+ {exceedWarning} +

+ )} +
+ ) +} diff --git a/web/src/components/trader/CompetitionPage.tsx b/web/src/components/trader/CompetitionPage.tsx index 71395eb0..83b6d6c6 100644 --- a/web/src/components/trader/CompetitionPage.tsx +++ b/web/src/components/trader/CompetitionPage.tsx @@ -281,14 +281,14 @@ export function CompetitionPage() {
{/* Stats */} -
+
{/* Total Equity */} -
-
+
+
{t('equity', language)}
{trader.total_equity?.toFixed(2) || '0.00'} @@ -297,11 +297,11 @@ export function CompetitionPage() { {/* P&L */}
-
+
{t('pnl', language)}
= 0 @@ -313,7 +313,7 @@ export function CompetitionPage() { {trader.total_pnl_pct?.toFixed(2) || '0.00'}%
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''} @@ -322,17 +322,17 @@ export function CompetitionPage() {
{/* Positions */} -
-
+
+
{t('pos', language)}
{trader.position_count}
-
+
{trader.margin_used_pct.toFixed(1)}%
diff --git a/web/src/components/trader/PositionHistory.tsx b/web/src/components/trader/PositionHistory.tsx index 03b17596..42303527 100644 --- a/web/src/components/trader/PositionHistory.tsx +++ b/web/src/components/trader/PositionHistory.tsx @@ -4,6 +4,7 @@ import { useLanguage } from '../../contexts/LanguageContext' import { t, type Language } from '../../i18n/translations' import { MetricTooltip } from '../common/MetricTooltip' import { formatPrice, formatQuantity } from '../../utils/format' +import { NofxSelect } from '../ui/select' import type { HistoricalPosition, TraderStats, @@ -664,23 +665,20 @@ export function PositionHistory({ traderId }: PositionHistoryProps) { {t('positionHistory.symbol', language)}: - + />
@@ -708,28 +706,26 @@ export function PositionHistory({ traderId }: PositionHistoryProps) { {t('positionHistory.sort', language)}: - + />
@@ -841,20 +837,21 @@ export function PositionHistory({ traderId }: PositionHistoryProps) { {language === 'zh' ? '每页' : 'Per page'}: - + />
{/* Page navigation */} diff --git a/web/src/components/trader/TelegramConfigModal.tsx b/web/src/components/trader/TelegramConfigModal.tsx index e06184ea..97a3fa05 100644 --- a/web/src/components/trader/TelegramConfigModal.tsx +++ b/web/src/components/trader/TelegramConfigModal.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { api } from '../../lib/api' import type { TelegramConfig, AIModel } from '../../types' import { t, type Language } from '../../i18n/translations' +import { NofxSelect } from '../ui/select' // Step indicator (reused pattern from ExchangeConfigModal) function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) { @@ -133,23 +134,20 @@ export function TelegramConfigModal({ onClose, language }: TelegramConfigModalPr {t('telegram.noEnabledModels', language)}
) : ( - + /> )}
{t('telegram.autoUseEnabled', language)} @@ -489,23 +487,20 @@ function BoundModelSelector({ {t('telegram.aiModelLabel', language)}
- + />
- + className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]" + options={availableExchanges.map((exchange) => ({ + value: exchange.id, + label: getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase() + + (exchange.account_name ? ` - ${exchange.account_name}` : ''), + }))} + /> {/* Exchange Registration Link */} {formData.exchange_id && (() => { // Find the selected exchange to get its type @@ -323,22 +320,20 @@ export function TraderConfigModal({ - + className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]" + options={[ + { value: '', label: t('noStrategyManual', language) }, + ...strategies.map((strategy) => ({ + value: strategy.id, + label: strategy.name + (strategy.is_active ? t('strategyActive', language) : '') + (strategy.is_default ? t('strategyDefault', language) : ''), + })), + ]} + /> {strategies.length === 0 && (

{t('noStrategyHint', language)} diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx new file mode 100644 index 00000000..ae1e13dc --- /dev/null +++ b/web/src/components/ui/select.tsx @@ -0,0 +1,99 @@ +import { useRef, useState, useEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { ChevronDown } from 'lucide-react' +import { cn } from '../../lib/cn' + +export interface SelectOption { + value: string | number + label: string +} + +interface NofxSelectProps { + value: string | number + onChange: (value: string) => void + options: SelectOption[] + disabled?: boolean + className?: string + style?: React.CSSProperties +} + +export function NofxSelect({ value, onChange, options, disabled, className, style }: NofxSelectProps) { + const [open, setOpen] = useState(false) + const triggerRef = useRef(null) + const dropdownRef = useRef(null) + const [pos, setPos] = useState({ top: 0, left: 0, width: 0 }) + const selected = options.find(o => String(o.value) === String(value)) + + const updatePos = useCallback(() => { + if (!triggerRef.current) return + const rect = triggerRef.current.getBoundingClientRect() + setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }) + }, []) + + useEffect(() => { + if (!open) return + updatePos() + const handleClose = (e: MouseEvent) => { + const target = e.target as Node + if (triggerRef.current?.contains(target)) return + if (dropdownRef.current?.contains(target)) return + setOpen(false) + } + const handleScroll = () => setOpen(false) + document.addEventListener('mousedown', handleClose) + window.addEventListener('scroll', handleScroll, true) + return () => { + document.removeEventListener('mousedown', handleClose) + window.removeEventListener('scroll', handleScroll, true) + } + }, [open, updatePos]) + + return ( +

+
{ + e.stopPropagation() + if (!disabled) setOpen(!open) + }} + > + {selected?.label ?? String(value)} + +
+ {open && createPortal( +
+ {options.map((opt) => ( +
{ + e.stopPropagation() + onChange(String(opt.value)) + setOpen(false) + }} + > + {opt.label} +
+ ))} +
, + document.body, + )} +
+ ) +} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index d524fad6..7540c25d 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -1080,11 +1080,16 @@ export const translations = { public: 'Public', addDescription: 'Add strategy description...', unsaved: 'Unsaved', + discardChanges: 'Discard', selectOrCreate: 'Select or create a strategy', customPromptDesc: 'Extra prompt appended to System Prompt for personalized trading style', customPromptPlaceholder: 'Enter custom prompt...', generatePromptPreview: 'Click to generate prompt preview', runAiTestHint: 'Click to run AI test', + tokenEstimate: 'Token Estimate', + tokenExceedWarning: 'Exceeds context limit. Reduce coins or timeframes.', + tokenEstimating: 'Estimating...', + tokenTooltip: 'Based on strictest model', }, // Metric Tooltip @@ -2371,11 +2376,16 @@ export const translations = { public: '公开', addDescription: '添加策略简介...', unsaved: '未保存', + discardChanges: '撤销', selectOrCreate: '选择或创建策略', customPromptDesc: '附加在 System Prompt 末尾的额外提示,用于补充个性化交易风格', customPromptPlaceholder: '输入自定义提示词...', generatePromptPreview: '点击生成 Prompt 预览', runAiTestHint: '点击运行 AI 测试', + tokenEstimate: 'Token 预估', + tokenExceedWarning: '超出上下文限制,建议减少币种或时间框架', + tokenEstimating: '预估中...', + tokenTooltip: '基于最严格模型计算', }, // Metric Tooltip @@ -3464,11 +3474,16 @@ export const translations = { public: 'Publik', addDescription: 'Tambah deskripsi strategi...', unsaved: 'Belum Disimpan', + discardChanges: 'Buang', selectOrCreate: 'Pilih atau buat strategi', customPromptDesc: 'Prompt tambahan di akhir System Prompt untuk gaya trading personal', customPromptPlaceholder: 'Masukkan prompt kustom...', generatePromptPreview: 'Klik untuk generate pratinjau prompt', runAiTestHint: 'Klik untuk menjalankan uji AI', + tokenEstimate: 'Estimasi Token', + tokenExceedWarning: 'Melebihi batas konteks. Kurangi koin atau timeframe.', + tokenEstimating: 'Mengestimasi...', + tokenTooltip: 'Berdasarkan model paling ketat', }, // Metric Tooltip diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index fbc90f36..8bf10a71 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -29,6 +29,7 @@ import { Download, Upload, Globe, + X, } from 'lucide-react' import type { Strategy, StrategyConfig, AIModel } from '../types' import { confirmToast, notify } from '../lib/notify' @@ -38,8 +39,10 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor' import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor' import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor' import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor' +import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar' import { DeepVoidBackground } from '../components/common/DeepVoidBackground' import { t } from '../i18n/translations' +import { NofxSelect } from '../components/ui/select' const API_BASE = import.meta.env.VITE_API_BASE || '' @@ -52,6 +55,7 @@ export function StrategyStudioPage() { const [editingConfig, setEditingConfig] = useState(null) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) + const [tokenOverflow, setTokenOverflow] = useState(false) const [error, setError] = useState(null) const [hasChanges, setHasChanges] = useState(false) @@ -378,6 +382,10 @@ export function StrategyStudioPage() { // Save strategy const handleSaveStrategy = async () => { if (!token || !selectedStrategy || !editingConfig) return + if (tokenOverflow && currentStrategyType === 'ai_trading') { + notify.error(tr('tokenExceedWarning')) + return + } setIsSaving(true) try { // Always sync the config language with the current interface language @@ -405,7 +413,17 @@ export function StrategyStudioPage() { if (!response.ok) throw new Error('Failed to save strategy') setHasChanges(false) notify.success(tr('strategySaved')) + const savedId = selectedStrategy.id await fetchStrategies() + // Stay on the strategy we just saved instead of jumping to active + setStrategies(prev => { + const saved = prev.find(s => s.id === savedId) + if (saved) { + setSelectedStrategy(saved) + setEditingConfig(saved.config) + } + return prev + }) } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error') } finally { @@ -641,7 +659,7 @@ export function StrategyStudioPage() {
-

{tr('strategyStudio')}

+

{tr('title')}

{tr('subtitle')}

@@ -756,34 +774,24 @@ export function StrategyStudioPage() { {selectedStrategy && editingConfig ? (
{/* Strategy Name & Actions */} -
-
- { - setSelectedStrategy({ ...selectedStrategy, name: e.target.value }) - setHasChanges(true) - }} - disabled={selectedStrategy.is_default} - className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted" - /> - { - setSelectedStrategy({ ...selectedStrategy, description: e.target.value }) - setHasChanges(true) - }} - disabled={selectedStrategy.is_default} - placeholder={tr('addDescription')} - className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1" - /> - {hasChanges && ( - ● {tr('unsaved')} - )} -
-
+
+
+
+ { + setSelectedStrategy({ ...selectedStrategy, name: e.target.value }) + setHasChanges(true) + }} + disabled={selectedStrategy.is_default} + className="text-lg font-bold bg-transparent border-none outline-none flex-1 min-w-0 text-nofx-text placeholder-nofx-text-muted" + /> + {hasChanges && ( + ● {tr('unsaved')} + )} +
+
{!selectedStrategy.is_active && ( )} + {!selectedStrategy.is_default && hasChanges && ( + + )} {!selectedStrategy.is_default && ( )}
+
+ { + setSelectedStrategy({ ...selectedStrategy, description: e.target.value }) + setHasChanges(true) + }} + disabled={selectedStrategy.is_default} + placeholder={tr('addDescription')} + className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1" + />
+ {/* Token Estimate Bar */} + {currentStrategyType === 'ai_trading' && ( +
+ +
+ )} + {/* Strategy Type Selector */} {editingConfig && (
@@ -818,9 +857,12 @@ export function StrategyStudioPage() {
{aiModels.length > 0 ? ( - + /> ) : (
{tr('noModel')} @@ -1025,15 +1067,16 @@ export function StrategyStudioPage() { )}
- + />
{/* Debug Info */} - {account && ( -
- SYSTEM_STATUS::ONLINE +
+ SYSTEM_STATUS::ONLINE + {account ? (
LAST_UPDATE::{lastUpdate} - EQ::{account?.total_equity?.toFixed(2)} - PNL::{account?.total_pnl?.toFixed(2)} + EQ::{account.total_equity?.toFixed(2)} + PNL::{account.total_pnl?.toFixed(2)}
-
- )} + ) : ( +
+ + + +
+ )} +
{/* Account Overview */}
@@ -504,6 +506,7 @@ export function TraderDashboardPage({ change={account?.total_pnl_pct || 0} positive={(account?.total_pnl ?? 0) > 0} icon="💰" + loading={!account} /> = 0} icon="📈" + loading={!account} />
@@ -671,15 +677,12 @@ export function TraderDashboardPage({
{t('traderDashboard.perPage', language)}: - + onChange={(val) => setPositionsPageSize(Number(val))} + options={[{ value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]} + className="bg-black/40 border border-white/10 rounded px-2 py-1 text-xs text-nofx-text-main transition-colors" + />
{totalPositionPages > 1 && (
@@ -752,17 +755,12 @@ export function TraderDashboardPage({ )}
{/* Limit Selector */} - + onChange={(val) => onDecisionsLimitChange(Number(val))} + options={[{ value: 5, label: '5' }, { value: 10, label: '10' }, { value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]} + className="px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer transition-all bg-black/40 text-nofx-text-main border border-white/10 hover:border-nofx-accent" + />
{/* Decisions List - Scrollable */} @@ -818,6 +816,7 @@ function StatCard({ positive, subtitle, icon, + loading, }: { title: string value: string @@ -826,6 +825,7 @@ function StatCard({ positive?: boolean subtitle?: string icon?: string + loading?: boolean }) { return (
@@ -835,27 +835,35 @@ function StatCard({
{title}
-
-
- {value} + {loading ? ( +
+
+
- {unit && {unit}} -
- - {change !== undefined && ( -
-
- {positive ? '▲' : '▼'} - {positive ? '+' : ''}{change.toFixed(2)}% + ) : ( + <> +
+
+ {value} +
+ {unit && {unit}}
-
- )} - {subtitle && ( -
- {subtitle} -
+ {change !== undefined && ( +
+
+ {positive ? '▲' : '▼'} + {positive ? '+' : ''}{change.toFixed(2)}% +
+
+ )} + {subtitle && ( +
+ {subtitle} +
+ )} + )}
)