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()}>
-
-
+ !disabled && onChange({ ...config, oi_ranking_limit: parseInt(e.target.value) })}
+ onChange={(val) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(val) })}
disabled={disabled}
className="w-14 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
- >
- {[5, 10, 15, 20].map(n => )}
-
+ 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()}>
-
-
+ !disabled && onChange({ ...config, netflow_ranking_limit: parseInt(e.target.value) })}
+ onChange={(val) => !disabled && onChange({ ...config, netflow_ranking_limit: parseInt(val) })}
disabled={disabled}
className="w-14 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
- >
- {[5, 10, 15, 20].map(n => )}
-
+ 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()}>
-
-
+ !disabled && onChange({ ...config, price_ranking_limit: parseInt(e.target.value) })}
+ onChange={(val) => !disabled && onChange({ ...config, price_ranking_limit: parseInt(val) })}
disabled={disabled}
className="w-14 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
- >
- {[5, 10, 15, 20].map(n => )}
-
+ 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() {