mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-16 01:06:59 +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:
@@ -110,6 +110,7 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
// Public strategy market (no authentication required)
|
// Public strategy market (no authentication required)
|
||||||
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
|
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)
|
// Authentication related routes (no authentication required)
|
||||||
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
|
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
|
||||||
|
|||||||
@@ -31,6 +31,20 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
|
|||||||
return warnings
|
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)
|
// handlePublicStrategies Get public strategies for strategy market (no auth required)
|
||||||
func (s *Server) handlePublicStrategies(c *gin.Context) {
|
func (s *Server) handlePublicStrategies(c *gin.Context) {
|
||||||
strategies, err := s.store.Strategy().ListPublic()
|
strategies, err := s.store.Strategy().ListPublic()
|
||||||
@@ -289,6 +303,25 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
|||||||
return
|
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
|
// Validate merged configuration and collect warnings
|
||||||
warnings := validateStrategyConfig(&mergedConfig)
|
warnings := validateStrategyConfig(&mergedConfig)
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string)
|
|||||||
if claw402URL == "" {
|
if claw402URL == "" {
|
||||||
claw402URL = "https://claw402.ai"
|
claw402URL = "https://claw402.ai"
|
||||||
}
|
}
|
||||||
claw402Client, err := nofxos.NewClaw402DataClient(claw402URL, walletKey, nil)
|
claw402Client, err := nofxos.NewClaw402DataClient(claw402URL, walletKey, &logger.MCPLogger{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
client.SetClaw402(claw402Client)
|
client.SetClaw402(claw402Client)
|
||||||
logger.Infof("🔗 NofxOS data routed through claw402 (%s)", claw402URL)
|
logger.Infof("🔗 NofxOS data routed through claw402 (%s)", claw402URL)
|
||||||
|
|||||||
@@ -51,6 +51,30 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
|
|||||||
engine = NewStrategyEngine(&defaultConfig)
|
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
|
// 1. Fetch market data using strategy config
|
||||||
if len(ctx.MarketDataMap) == 0 {
|
if len(ctx.MarketDataMap) == 0 {
|
||||||
if err := fetchMarketDataWithStrategy(ctx, engine); err != nil {
|
if err := fetchMarketDataWithStrategy(ctx, engine); err != nil {
|
||||||
|
|||||||
@@ -760,10 +760,21 @@ func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string
|
|||||||
} `json:"delta"`
|
} `json:"delta"`
|
||||||
FinishReason *string `json:"finish_reason"`
|
FinishReason *string `json:"finish_reason"`
|
||||||
} `json:"choices"`
|
} `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 {
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||||
continue // skip malformed chunks
|
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 {
|
if len(chunk.Choices) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,63 @@ package store
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"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
|
// StrategyStore strategy storage
|
||||||
type StrategyStore struct {
|
type StrategyStore struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
@@ -260,20 +312,20 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
|||||||
CoinSource: CoinSourceConfig{
|
CoinSource: CoinSourceConfig{
|
||||||
SourceType: "ai500",
|
SourceType: "ai500",
|
||||||
UseAI500: true,
|
UseAI500: true,
|
||||||
AI500Limit: 10,
|
AI500Limit: 3,
|
||||||
UseOITop: false,
|
UseOITop: false,
|
||||||
OITopLimit: 10,
|
OITopLimit: 3,
|
||||||
UseOILow: false,
|
UseOILow: false,
|
||||||
OILowLimit: 10,
|
OILowLimit: 3,
|
||||||
},
|
},
|
||||||
Indicators: IndicatorConfig{
|
Indicators: IndicatorConfig{
|
||||||
Klines: KlineConfig{
|
Klines: KlineConfig{
|
||||||
PrimaryTimeframe: "5m",
|
PrimaryTimeframe: "5m",
|
||||||
PrimaryCount: 30,
|
PrimaryCount: 20,
|
||||||
LongerTimeframe: "4h",
|
LongerTimeframe: "4h",
|
||||||
LongerCount: 10,
|
LongerCount: 10,
|
||||||
EnableMultiTimeframe: true,
|
EnableMultiTimeframe: true,
|
||||||
SelectedTimeframes: []string{"5m", "15m", "1h", "4h"},
|
SelectedTimeframes: []string{"5m", "15m", "1h"},
|
||||||
},
|
},
|
||||||
EnableRawKlines: true, // Required - raw OHLCV data for AI analysis
|
EnableRawKlines: true, // Required - raw OHLCV data for AI analysis
|
||||||
EnableEMA: false,
|
EnableEMA: false,
|
||||||
@@ -510,3 +562,268 @@ func (s *Strategy) SetConfig(config *StrategyConfig) error {
|
|||||||
s.Config = string(data)
|
s.Config = string(data)
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react'
|
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react'
|
||||||
import type { CoinSourceConfig } from '../../types'
|
import type { CoinSourceConfig } from '../../types'
|
||||||
import { coinSource, ts } from '../../i18n/strategy-translations'
|
import { coinSource, ts } from '../../i18n/strategy-translations'
|
||||||
|
import { NofxSelect } from '../ui/select'
|
||||||
|
|
||||||
interface CoinSourceEditorProps {
|
interface CoinSourceEditorProps {
|
||||||
config: CoinSourceConfig
|
config: CoinSourceConfig
|
||||||
@@ -24,7 +25,6 @@ export function CoinSourceEditor({
|
|||||||
{ value: 'ai500', icon: Database, color: '#F0B90B' },
|
{ value: 'ai500', icon: Database, color: '#F0B90B' },
|
||||||
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
||||||
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
|
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
|
||||||
{ value: 'mixed', icon: Shuffle, color: '#60a5fa' },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
// Calculate mixed mode summary
|
// Calculate mixed mode summary
|
||||||
@@ -71,8 +71,26 @@ export function CoinSourceEditor({
|
|||||||
return xyzDexAssets.has(base)
|
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 = () => {
|
const handleAddCoin = () => {
|
||||||
if (!newCoin.trim()) return
|
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()
|
const symbol = newCoin.toUpperCase().trim()
|
||||||
|
|
||||||
// For xyz dex assets (stocks, forex, commodities), use xyz: prefix without USDT
|
// 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`
|
formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT`
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentCoins = config.static_coins || []
|
|
||||||
if (!currentCoins.includes(formattedSymbol)) {
|
if (!currentCoins.includes(formattedSymbol)) {
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
@@ -148,7 +165,7 @@ export function CoinSourceEditor({
|
|||||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||||
{ts(coinSource.sourceType, language)}
|
{ts(coinSource.sourceType, language)}
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{sourceTypes.map(({ value, icon: Icon, color }) => (
|
{sourceTypes.map(({ value, icon: Icon, color }) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
@@ -309,19 +326,16 @@ export function CoinSourceEditor({
|
|||||||
<span className="text-sm text-nofx-text-muted">
|
<span className="text-sm text-nofx-text-muted">
|
||||||
{ts(coinSource.ai500Limit, language)}:
|
{ts(coinSource.ai500Limit, language)}:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.ai500_limit || 10}
|
value={config.ai500_limit || 10}
|
||||||
onChange={(e) =>
|
onChange={(val) =>
|
||||||
!disabled &&
|
!disabled &&
|
||||||
onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
|
onChange({ ...config, ai500_limit: parseInt(val) || 10 })
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
options={[1, 2, 3].map(n => ({ value: n, label: String(n) }))}
|
||||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
>
|
/>
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -366,19 +380,16 @@ export function CoinSourceEditor({
|
|||||||
<span className="text-sm text-nofx-text-muted">
|
<span className="text-sm text-nofx-text-muted">
|
||||||
{ts(coinSource.oiTopLimit, language)}:
|
{ts(coinSource.oiTopLimit, language)}:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.oi_top_limit || 10}
|
value={config.oi_top_limit || 10}
|
||||||
onChange={(e) =>
|
onChange={(val) =>
|
||||||
!disabled &&
|
!disabled &&
|
||||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
onChange({ ...config, oi_top_limit: parseInt(val) || 10 })
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
options={[1, 2, 3].map(n => ({ value: n, label: String(n) }))}
|
||||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
>
|
/>
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -423,19 +434,16 @@ export function CoinSourceEditor({
|
|||||||
<span className="text-sm text-nofx-text-muted">
|
<span className="text-sm text-nofx-text-muted">
|
||||||
{ts(coinSource.oiLowLimit, language)}:
|
{ts(coinSource.oiLowLimit, language)}:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.oi_low_limit || 10}
|
value={config.oi_low_limit || 10}
|
||||||
onChange={(e) =>
|
onChange={(val) =>
|
||||||
!disabled &&
|
!disabled &&
|
||||||
onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
onChange({ ...config, oi_low_limit: parseInt(val) || 10 })
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
options={[1, 2, 3].map(n => ({ value: n, label: String(n) }))}
|
||||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
>
|
/>
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -483,20 +491,13 @@ export function CoinSourceEditor({
|
|||||||
{config.use_ai500 && (
|
{config.use_ai500 && (
|
||||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.ai500_limit || 10}
|
value={config.ai500_limit || 10}
|
||||||
onChange={(e) => {
|
onChange={(val) => !disabled && onChange({ ...config, ai500_limit: parseInt(val) || 10 })}
|
||||||
e.stopPropagation()
|
|
||||||
!disabled && onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
options={[5, 10, 15, 20, 30, 50].map(n => ({ value: n, label: String(n) }))}
|
||||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
onClick={(e) => e.stopPropagation()}
|
/>
|
||||||
>
|
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -530,20 +531,13 @@ export function CoinSourceEditor({
|
|||||||
{config.use_oi_top && (
|
{config.use_oi_top && (
|
||||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.oi_top_limit || 10}
|
value={config.oi_top_limit || 10}
|
||||||
onChange={(e) => {
|
onChange={(val) => !disabled && onChange({ ...config, oi_top_limit: parseInt(val) || 10 })}
|
||||||
e.stopPropagation()
|
|
||||||
!disabled && onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
options={[5, 10, 15, 20, 30, 50].map(n => ({ value: n, label: String(n) }))}
|
||||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
onClick={(e) => e.stopPropagation()}
|
/>
|
||||||
>
|
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -577,20 +571,13 @@ export function CoinSourceEditor({
|
|||||||
{config.use_oi_low && (
|
{config.use_oi_low && (
|
||||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.oi_low_limit || 10}
|
value={config.oi_low_limit || 10}
|
||||||
onChange={(e) => {
|
onChange={(val) => !disabled && onChange({ ...config, oi_low_limit: parseInt(val) || 10 })}
|
||||||
e.stopPropagation()
|
|
||||||
!disabled && onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
options={[5, 10, 15, 20, 30, 50].map(n => ({ value: n, label: String(n) }))}
|
||||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
onClick={(e) => e.stopPropagation()}
|
/>
|
||||||
>
|
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react'
|
import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react'
|
||||||
import type { GridStrategyConfig } from '../../types'
|
import type { GridStrategyConfig } from '../../types'
|
||||||
import { gridConfig, ts } from '../../i18n/strategy-translations'
|
import { gridConfig, ts } from '../../i18n/strategy-translations'
|
||||||
|
import { NofxSelect } from '../ui/select'
|
||||||
|
|
||||||
interface GridConfigEditorProps {
|
interface GridConfigEditorProps {
|
||||||
config: GridStrategyConfig
|
config: GridStrategyConfig
|
||||||
@@ -74,20 +75,21 @@ export function GridConfigEditor({
|
|||||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||||
{ts(gridConfig.symbolDesc, language)}
|
{ts(gridConfig.symbolDesc, language)}
|
||||||
</p>
|
</p>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.symbol}
|
value={config.symbol}
|
||||||
onChange={(e) => updateField('symbol', e.target.value)}
|
onChange={(val) => updateField('symbol', val)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-full px-3 py-2 rounded"
|
className="w-full px-3 py-2 rounded"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
>
|
options={[
|
||||||
<option value="BTCUSDT">BTC/USDT</option>
|
{ value: 'BTCUSDT', label: 'BTC/USDT' },
|
||||||
<option value="ETHUSDT">ETH/USDT</option>
|
{ value: 'ETHUSDT', label: 'ETH/USDT' },
|
||||||
<option value="SOLUSDT">SOL/USDT</option>
|
{ value: 'SOLUSDT', label: 'SOL/USDT' },
|
||||||
<option value="BNBUSDT">BNB/USDT</option>
|
{ value: 'BNBUSDT', label: 'BNB/USDT' },
|
||||||
<option value="XRPUSDT">XRP/USDT</option>
|
{ value: 'XRPUSDT', label: 'XRP/USDT' },
|
||||||
<option value="DOGEUSDT">DOGE/USDT</option>
|
{ value: 'DOGEUSDT', label: 'DOGE/USDT' },
|
||||||
</select>
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Investment */}
|
{/* Investment */}
|
||||||
@@ -170,17 +172,18 @@ export function GridConfigEditor({
|
|||||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||||
{ts(gridConfig.distributionDesc, language)}
|
{ts(gridConfig.distributionDesc, language)}
|
||||||
</p>
|
</p>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.distribution}
|
value={config.distribution}
|
||||||
onChange={(e) => updateField('distribution', e.target.value as 'uniform' | 'gaussian' | 'pyramid')}
|
onChange={(val) => updateField('distribution', val as 'uniform' | 'gaussian' | 'pyramid')}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-full px-3 py-2 rounded"
|
className="w-full px-3 py-2 rounded"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
>
|
options={[
|
||||||
<option value="uniform">{ts(gridConfig.uniform, language)}</option>
|
{ value: 'uniform', label: ts(gridConfig.uniform, language) },
|
||||||
<option value="gaussian">{ts(gridConfig.gaussian, language)}</option>
|
{ value: 'gaussian', label: ts(gridConfig.gaussian, language) },
|
||||||
<option value="pyramid">{ts(gridConfig.pyramid, language)}</option>
|
{ value: 'pyramid', label: ts(gridConfig.pyramid, language) },
|
||||||
</select>
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Clock, Activity, TrendingUp, BarChart2, Info, Lock, ExternalLink, Zap, Check, AlertCircle, Key } from 'lucide-react'
|
import { Clock, Activity, TrendingUp, BarChart2, Info, Lock, ExternalLink, Zap, Check, AlertCircle, Key } from 'lucide-react'
|
||||||
import type { IndicatorConfig } from '../../types'
|
import type { IndicatorConfig } from '../../types'
|
||||||
import { indicator, ts } from '../../i18n/strategy-translations'
|
import { indicator, ts } from '../../i18n/strategy-translations'
|
||||||
|
import { NofxSelect } from '../ui/select'
|
||||||
|
|
||||||
// Default NofxOS API Key
|
// Default NofxOS API Key
|
||||||
const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c'
|
const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c'
|
||||||
@@ -60,6 +61,16 @@ export function IndicatorEditor({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} 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)
|
current.push(tf)
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
@@ -299,26 +310,22 @@ export function IndicatorEditor({
|
|||||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.oiRankingDesc, language)}</p>
|
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.oiRankingDesc, language)}</p>
|
||||||
{config.enable_oi_ranking && (
|
{config.enable_oi_ranking && (
|
||||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.oi_ranking_duration || '1h'}
|
value={config.oi_ranking_duration || '1h'}
|
||||||
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_duration: e.target.value })}
|
onChange={(val) => !disabled && onChange({ ...config, oi_ranking_duration: val })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
>
|
options={[{ value: '1h', label: '1h' }, { value: '4h', label: '4h' }, { value: '24h', label: '24h' }]}
|
||||||
<option value="1h">1h</option>
|
/>
|
||||||
<option value="4h">4h</option>
|
<NofxSelect
|
||||||
<option value="24h">24h</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={config.oi_ranking_limit || 10}
|
value={config.oi_ranking_limit || 10}
|
||||||
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(e.target.value) })}
|
onChange={(val) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(val) })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
>
|
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
|
||||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
/>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -359,26 +366,22 @@ export function IndicatorEditor({
|
|||||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.netflowRankingDesc, language)}</p>
|
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.netflowRankingDesc, language)}</p>
|
||||||
{config.enable_netflow_ranking && (
|
{config.enable_netflow_ranking && (
|
||||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.netflow_ranking_duration || '1h'}
|
value={config.netflow_ranking_duration || '1h'}
|
||||||
onChange={(e) => !disabled && onChange({ ...config, netflow_ranking_duration: e.target.value })}
|
onChange={(val) => !disabled && onChange({ ...config, netflow_ranking_duration: val })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
>
|
options={[{ value: '1h', label: '1h' }, { value: '4h', label: '4h' }, { value: '24h', label: '24h' }]}
|
||||||
<option value="1h">1h</option>
|
/>
|
||||||
<option value="4h">4h</option>
|
<NofxSelect
|
||||||
<option value="24h">24h</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={config.netflow_ranking_limit || 10}
|
value={config.netflow_ranking_limit || 10}
|
||||||
onChange={(e) => !disabled && onChange({ ...config, netflow_ranking_limit: parseInt(e.target.value) })}
|
onChange={(val) => !disabled && onChange({ ...config, netflow_ranking_limit: parseInt(val) })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
>
|
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
|
||||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
/>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -419,27 +422,27 @@ export function IndicatorEditor({
|
|||||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.priceRankingDesc, language)}</p>
|
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.priceRankingDesc, language)}</p>
|
||||||
{config.enable_price_ranking && (
|
{config.enable_price_ranking && (
|
||||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.price_ranking_duration || '1h,4h,24h'}
|
value={config.price_ranking_duration || '1h,4h,24h'}
|
||||||
onChange={(e) => !disabled && onChange({ ...config, price_ranking_duration: e.target.value })}
|
onChange={(val) => !disabled && onChange({ ...config, price_ranking_duration: val })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
>
|
options={[
|
||||||
<option value="1h">1h</option>
|
{ value: '1h', label: '1h' },
|
||||||
<option value="4h">4h</option>
|
{ value: '4h', label: '4h' },
|
||||||
<option value="24h">24h</option>
|
{ value: '24h', label: '24h' },
|
||||||
<option value="1h,4h,24h">{ts(indicator.priceRankingMulti, language)}</option>
|
{ value: '1h,4h,24h', label: ts(indicator.priceRankingMulti, language) },
|
||||||
</select>
|
]}
|
||||||
<select
|
/>
|
||||||
|
<NofxSelect
|
||||||
value={config.price_ranking_limit || 10}
|
value={config.price_ranking_limit || 10}
|
||||||
onChange={(e) => !disabled && onChange({ ...config, price_ranking_limit: parseInt(e.target.value) })}
|
onChange={(val) => !disabled && onChange({ ...config, price_ranking_limit: parseInt(val) })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
>
|
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
|
||||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
/>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -515,7 +518,7 @@ export function IndicatorEditor({
|
|||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={10}
|
min={10}
|
||||||
max={200}
|
max={30}
|
||||||
className="w-16 px-2 py-1 rounded text-xs text-center"
|
className="w-16 px-2 py-1 rounded text-xs text-center"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function RiskControlEditor({
|
|||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={3}
|
||||||
className="w-32 px-3 py-2 rounded"
|
className="w-32 px-3 py-2 rounded"
|
||||||
style={{
|
style={{
|
||||||
background: '#1E2329',
|
background: '#1E2329',
|
||||||
|
|||||||
143
web/src/components/strategy/TokenEstimateBar.tsx
Normal file
143
web/src/components/strategy/TokenEstimateBar.tsx
Normal file
@@ -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<TokenEstimateResult | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-nofx-text-muted">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
<span>{tr('tokenEstimating')}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="flex-1 h-1.5 rounded-full overflow-hidden"
|
||||||
|
style={{ background: '#1E2329' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${barWidth}%`, background: barColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono whitespace-nowrap" style={{ color: textColor }}>
|
||||||
|
{isLoading ? <Loader2 className="w-3 h-3 animate-spin inline" /> : `${pct}%`}
|
||||||
|
</span>
|
||||||
|
<div className="relative group">
|
||||||
|
<Info className="w-3 h-3 text-nofx-text-muted cursor-help" />
|
||||||
|
<div className="absolute bottom-full right-0 mb-1.5 px-2.5 py-1.5 rounded-lg text-[10px] whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50 bg-nofx-bg-lighter border border-nofx-border text-nofx-text-muted shadow-lg">
|
||||||
|
{tr('tokenTooltip')} ({strictest.name} {(strictest.context_limit / 1000).toFixed(0)}K)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{exceedWarning && (
|
||||||
|
<p className="text-[10px]" style={{ color: '#F6465D' }}>
|
||||||
|
{exceedWarning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -281,14 +281,14 @@ export function CompetitionPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap">
|
<div className="flex items-center gap-4 md:gap-6">
|
||||||
{/* Total Equity */}
|
{/* Total Equity */}
|
||||||
<div className="text-right">
|
<div className="text-right min-w-[60px] md:min-w-[80px]">
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
|
||||||
{t('equity', language)}
|
{t('equity', language)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-xs md:text-sm font-bold mono"
|
className="text-sm md:text-base font-bold mono"
|
||||||
style={{ color: '#EAECEF' }}
|
style={{ color: '#EAECEF' }}
|
||||||
>
|
>
|
||||||
{trader.total_equity?.toFixed(2) || '0.00'}
|
{trader.total_equity?.toFixed(2) || '0.00'}
|
||||||
@@ -297,11 +297,11 @@ export function CompetitionPage() {
|
|||||||
|
|
||||||
{/* P&L */}
|
{/* P&L */}
|
||||||
<div className="text-right min-w-[70px] md:min-w-[90px]">
|
<div className="text-right min-w-[70px] md:min-w-[90px]">
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
|
||||||
{t('pnl', language)}
|
{t('pnl', language)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-base md:text-lg font-bold mono"
|
className="text-sm md:text-base font-bold mono"
|
||||||
style={{
|
style={{
|
||||||
color:
|
color:
|
||||||
(trader.total_pnl ?? 0) >= 0
|
(trader.total_pnl ?? 0) >= 0
|
||||||
@@ -313,7 +313,7 @@ export function CompetitionPage() {
|
|||||||
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-xs mono"
|
className="text-[10px] mono"
|
||||||
style={{ color: '#848E9C' }}
|
style={{ color: '#848E9C' }}
|
||||||
>
|
>
|
||||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||||
@@ -322,17 +322,17 @@ export function CompetitionPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Positions */}
|
{/* Positions */}
|
||||||
<div className="text-right">
|
<div className="text-right min-w-[40px] md:min-w-[50px]">
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
|
||||||
{t('pos', language)}
|
{t('pos', language)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-xs md:text-sm font-bold mono"
|
className="text-sm md:text-base font-bold mono"
|
||||||
style={{ color: '#EAECEF' }}
|
style={{ color: '#EAECEF' }}
|
||||||
>
|
>
|
||||||
{trader.position_count}
|
{trader.position_count}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
<div className="text-[10px]" style={{ color: '#848E9C' }}>
|
||||||
{trader.margin_used_pct.toFixed(1)}%
|
{trader.margin_used_pct.toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useLanguage } from '../../contexts/LanguageContext'
|
|||||||
import { t, type Language } from '../../i18n/translations'
|
import { t, type Language } from '../../i18n/translations'
|
||||||
import { MetricTooltip } from '../common/MetricTooltip'
|
import { MetricTooltip } from '../common/MetricTooltip'
|
||||||
import { formatPrice, formatQuantity } from '../../utils/format'
|
import { formatPrice, formatQuantity } from '../../utils/format'
|
||||||
|
import { NofxSelect } from '../ui/select'
|
||||||
import type {
|
import type {
|
||||||
HistoricalPosition,
|
HistoricalPosition,
|
||||||
TraderStats,
|
TraderStats,
|
||||||
@@ -664,23 +665,20 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||||
{t('positionHistory.symbol', language)}:
|
{t('positionHistory.symbol', language)}:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={filterSymbol}
|
value={filterSymbol}
|
||||||
onChange={(e) => setFilterSymbol(e.target.value)}
|
onChange={(val) => setFilterSymbol(val)}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: t('positionHistory.allSymbols', language) },
|
||||||
|
...uniqueSymbols.map(s => ({ value: s, label: (s || '').replace('USDT', '') }))
|
||||||
|
]}
|
||||||
className="rounded px-3 py-1.5 text-sm"
|
className="rounded px-3 py-1.5 text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
border: '1px solid #2B3139',
|
border: '1px solid #2B3139',
|
||||||
color: '#EAECEF',
|
color: '#EAECEF',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="all">{t('positionHistory.allSymbols', language)}</option>
|
|
||||||
{uniqueSymbols.map((symbol) => (
|
|
||||||
<option key={symbol} value={symbol}>
|
|
||||||
{(symbol || '').replace('USDT', '')}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -708,28 +706,26 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||||
{t('positionHistory.sort', language)}:
|
{t('positionHistory.sort', language)}:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={`${sortBy}-${sortOrder}`}
|
value={`${sortBy}-${sortOrder}`}
|
||||||
onChange={(e) => {
|
onChange={(val) => {
|
||||||
const [by, order] = e.target.value.split('-') as [
|
const [by, order] = val.split('-') as ['time' | 'pnl' | 'pnl_pct', 'asc' | 'desc']
|
||||||
'time' | 'pnl' | 'pnl_pct',
|
|
||||||
'asc' | 'desc',
|
|
||||||
]
|
|
||||||
setSortBy(by)
|
setSortBy(by)
|
||||||
setSortOrder(order)
|
setSortOrder(order)
|
||||||
}}
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'time-desc', label: t('positionHistory.latestFirst', language) },
|
||||||
|
{ value: 'time-asc', label: t('positionHistory.oldestFirst', language) },
|
||||||
|
{ value: 'pnl-desc', label: t('positionHistory.highestPnL', language) },
|
||||||
|
{ value: 'pnl-asc', label: t('positionHistory.lowestPnL', language) },
|
||||||
|
]}
|
||||||
className="rounded px-3 py-1.5 text-sm"
|
className="rounded px-3 py-1.5 text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
border: '1px solid #2B3139',
|
border: '1px solid #2B3139',
|
||||||
color: '#EAECEF',
|
color: '#EAECEF',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="time-desc">{t('positionHistory.latestFirst', language)}</option>
|
|
||||||
<option value="time-asc">{t('positionHistory.oldestFirst', language)}</option>
|
|
||||||
<option value="pnl-desc">{t('positionHistory.highestPnL', language)}</option>
|
|
||||||
<option value="pnl-asc">{t('positionHistory.lowestPnL', language)}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -841,20 +837,21 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||||
{language === 'zh' ? '每页' : 'Per page'}:
|
{language === 'zh' ? '每页' : 'Per page'}:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
onChange={(val) => setPageSize(Number(val))}
|
||||||
|
options={[
|
||||||
|
{ value: 20, label: '20' },
|
||||||
|
{ value: 50, label: '50' },
|
||||||
|
{ value: 100, label: '100' },
|
||||||
|
]}
|
||||||
className="rounded px-2 py-1 text-sm"
|
className="rounded px-2 py-1 text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
border: '1px solid #2B3139',
|
border: '1px solid #2B3139',
|
||||||
color: '#EAECEF',
|
color: '#EAECEF',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value={20}>20</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page navigation */}
|
{/* Page navigation */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
|||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import type { TelegramConfig, AIModel } from '../../types'
|
import type { TelegramConfig, AIModel } from '../../types'
|
||||||
import { t, type Language } from '../../i18n/translations'
|
import { t, type Language } from '../../i18n/translations'
|
||||||
|
import { NofxSelect } from '../ui/select'
|
||||||
|
|
||||||
// Step indicator (reused pattern from ExchangeConfigModal)
|
// Step indicator (reused pattern from ExchangeConfigModal)
|
||||||
function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) {
|
function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) {
|
||||||
@@ -133,23 +134,20 @@ export function TelegramConfigModal({ onClose, language }: TelegramConfigModalPr
|
|||||||
{t('telegram.noEnabledModels', language)}
|
{t('telegram.noEnabledModels', language)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<NofxSelect
|
||||||
value={selectedModelId}
|
value={selectedModelId}
|
||||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
onChange={(val) => setSelectedModelId(val)}
|
||||||
className="w-full px-4 py-3 rounded-xl text-sm appearance-none"
|
options={[
|
||||||
|
{ value: '', label: t('telegram.autoSelect', language) },
|
||||||
|
...models.map(m => ({ value: m.id, label: `${m.name} (${m.provider}${m.customModelName ? ` · ${m.customModelName}` : ''})` }))
|
||||||
|
]}
|
||||||
|
className="w-full px-4 py-3 rounded-xl text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
border: '1px solid #2B3139',
|
border: '1px solid #2B3139',
|
||||||
color: selectedModelId ? '#EAECEF' : '#848E9C',
|
color: selectedModelId ? '#EAECEF' : '#848E9C',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="">{t('telegram.autoSelect', language)}</option>
|
|
||||||
{models.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.name} ({m.provider}{m.customModelName ? ` · ${m.customModelName}` : ''})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||||
{t('telegram.autoUseEnabled', language)}
|
{t('telegram.autoUseEnabled', language)}
|
||||||
@@ -489,23 +487,20 @@ function BoundModelSelector({
|
|||||||
{t('telegram.aiModelLabel', language)}
|
{t('telegram.aiModelLabel', language)}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select
|
<NofxSelect
|
||||||
value={modelId}
|
value={modelId}
|
||||||
onChange={(e) => setModelId(e.target.value)}
|
onChange={(val) => setModelId(val)}
|
||||||
className="flex-1 px-3 py-2.5 rounded-xl text-sm appearance-none"
|
options={[
|
||||||
|
{ value: '', label: t('telegram.aiModelAutoSelect', language) },
|
||||||
|
...models.map(m => ({ value: m.id, label: `${m.name}${m.customModelName ? ` · ${m.customModelName}` : ''}` }))
|
||||||
|
]}
|
||||||
|
className="flex-1 px-3 py-2.5 rounded-xl text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
border: '1px solid #2B3139',
|
border: '1px solid #2B3139',
|
||||||
color: modelId ? '#EAECEF' : '#848E9C',
|
color: modelId ? '#EAECEF' : '#848E9C',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="">{t('telegram.aiModelAutoSelect', language)}</option>
|
|
||||||
{models.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.name}{m.customModelName ? ` · ${m.customModelName}` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving || modelId === currentModelId}
|
disabled={isSaving || modelId === currentModelId}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { t } from '../../i18n/translations'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Pencil, Plus, X as IconX, Sparkles, ExternalLink, UserPlus } from 'lucide-react'
|
import { Pencil, Plus, X as IconX, Sparkles, ExternalLink, UserPlus } from 'lucide-react'
|
||||||
import { httpClient } from '../../lib/httpClient'
|
import { httpClient } from '../../lib/httpClient'
|
||||||
|
import { NofxSelect } from '../ui/select'
|
||||||
|
|
||||||
// 提取下划线后面的名称部分
|
// 提取下划线后面的名称部分
|
||||||
function getShortName(fullName: string): string {
|
function getShortName(fullName: string): string {
|
||||||
@@ -250,38 +251,34 @@ export function TraderConfigModal({
|
|||||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||||
{t('aiModelRequired', language)}
|
{t('aiModelRequired', language)}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<NofxSelect
|
||||||
value={formData.ai_model}
|
value={formData.ai_model}
|
||||||
onChange={(e) =>
|
onChange={(val) =>
|
||||||
handleInputChange('ai_model', e.target.value)
|
handleInputChange('ai_model', val)
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
|
||||||
>
|
options={availableModels.map((model) => ({
|
||||||
{availableModels.map((model) => (
|
value: model.id,
|
||||||
<option key={model.id} value={model.id}>
|
label: getShortName(model.name || model.id).toUpperCase(),
|
||||||
{getShortName(model.name || model.id).toUpperCase()}
|
}))}
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||||
{t('exchangeRequired', language)}
|
{t('exchangeRequired', language)}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<NofxSelect
|
||||||
value={formData.exchange_id}
|
value={formData.exchange_id}
|
||||||
onChange={(e) =>
|
onChange={(val) =>
|
||||||
handleInputChange('exchange_id', e.target.value)
|
handleInputChange('exchange_id', val)
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
|
||||||
>
|
options={availableExchanges.map((exchange) => ({
|
||||||
{availableExchanges.map((exchange) => (
|
value: exchange.id,
|
||||||
<option key={exchange.id} value={exchange.id}>
|
label: getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()
|
||||||
{getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()}
|
+ (exchange.account_name ? ` - ${exchange.account_name}` : ''),
|
||||||
{exchange.account_name ? ` - ${exchange.account_name}` : ''}
|
}))}
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{/* Exchange Registration Link */}
|
{/* Exchange Registration Link */}
|
||||||
{formData.exchange_id && (() => {
|
{formData.exchange_id && (() => {
|
||||||
// Find the selected exchange to get its type
|
// Find the selected exchange to get its type
|
||||||
@@ -323,22 +320,20 @@ export function TraderConfigModal({
|
|||||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||||
{t('useStrategy', language)}
|
{t('useStrategy', language)}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<NofxSelect
|
||||||
value={formData.strategy_id}
|
value={formData.strategy_id}
|
||||||
onChange={(e) =>
|
onChange={(val) =>
|
||||||
handleInputChange('strategy_id', e.target.value)
|
handleInputChange('strategy_id', val)
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
|
||||||
>
|
options={[
|
||||||
<option value="">{t('noStrategyManual', language)}</option>
|
{ value: '', label: t('noStrategyManual', language) },
|
||||||
{strategies.map((strategy) => (
|
...strategies.map((strategy) => ({
|
||||||
<option key={strategy.id} value={strategy.id}>
|
value: strategy.id,
|
||||||
{strategy.name}
|
label: strategy.name + (strategy.is_active ? t('strategyActive', language) : '') + (strategy.is_default ? t('strategyDefault', language) : ''),
|
||||||
{strategy.is_active ? t('strategyActive', language) : ''}
|
})),
|
||||||
{strategy.is_default ? t('strategyDefault', language) : ''}
|
]}
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{strategies.length === 0 && (
|
{strategies.length === 0 && (
|
||||||
<p className="text-xs text-[#848E9C] mt-2">
|
<p className="text-xs text-[#848E9C] mt-2">
|
||||||
{t('noStrategyHint', language)}
|
{t('noStrategyHint', language)}
|
||||||
|
|||||||
99
web/src/components/ui/select.tsx
Normal file
99
web/src/components/ui/select.tsx
Normal file
@@ -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<HTMLDivElement>(null)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={triggerRef}
|
||||||
|
className={cn('relative', className)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-1.5 w-full h-full cursor-pointer',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!disabled) setOpen(!open)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="truncate">{selected?.label ?? String(value)}</span>
|
||||||
|
<ChevronDown className={cn('w-3 h-3 shrink-0 opacity-50 transition-transform', open && 'rotate-180')} />
|
||||||
|
</div>
|
||||||
|
{open && createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="fixed z-[9999] rounded border border-[#2B3139] bg-[#0B0E11] shadow-xl shadow-black/50 max-h-60 overflow-y-auto"
|
||||||
|
style={{ top: pos.top, left: pos.left, minWidth: pos.width }}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<div
|
||||||
|
key={opt.value}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm cursor-pointer transition-colors whitespace-nowrap',
|
||||||
|
String(opt.value) === String(value)
|
||||||
|
? 'bg-[#F0B90B]/10 text-[#F0B90B]'
|
||||||
|
: 'text-[#EAECEF] hover:bg-[#1E2329]',
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onChange(String(opt.value))
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1080,11 +1080,16 @@ export const translations = {
|
|||||||
public: 'Public',
|
public: 'Public',
|
||||||
addDescription: 'Add strategy description...',
|
addDescription: 'Add strategy description...',
|
||||||
unsaved: 'Unsaved',
|
unsaved: 'Unsaved',
|
||||||
|
discardChanges: 'Discard',
|
||||||
selectOrCreate: 'Select or create a strategy',
|
selectOrCreate: 'Select or create a strategy',
|
||||||
customPromptDesc: 'Extra prompt appended to System Prompt for personalized trading style',
|
customPromptDesc: 'Extra prompt appended to System Prompt for personalized trading style',
|
||||||
customPromptPlaceholder: 'Enter custom prompt...',
|
customPromptPlaceholder: 'Enter custom prompt...',
|
||||||
generatePromptPreview: 'Click to generate prompt preview',
|
generatePromptPreview: 'Click to generate prompt preview',
|
||||||
runAiTestHint: 'Click to run AI test',
|
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
|
// Metric Tooltip
|
||||||
@@ -2371,11 +2376,16 @@ export const translations = {
|
|||||||
public: '公开',
|
public: '公开',
|
||||||
addDescription: '添加策略简介...',
|
addDescription: '添加策略简介...',
|
||||||
unsaved: '未保存',
|
unsaved: '未保存',
|
||||||
|
discardChanges: '撤销',
|
||||||
selectOrCreate: '选择或创建策略',
|
selectOrCreate: '选择或创建策略',
|
||||||
customPromptDesc: '附加在 System Prompt 末尾的额外提示,用于补充个性化交易风格',
|
customPromptDesc: '附加在 System Prompt 末尾的额外提示,用于补充个性化交易风格',
|
||||||
customPromptPlaceholder: '输入自定义提示词...',
|
customPromptPlaceholder: '输入自定义提示词...',
|
||||||
generatePromptPreview: '点击生成 Prompt 预览',
|
generatePromptPreview: '点击生成 Prompt 预览',
|
||||||
runAiTestHint: '点击运行 AI 测试',
|
runAiTestHint: '点击运行 AI 测试',
|
||||||
|
tokenEstimate: 'Token 预估',
|
||||||
|
tokenExceedWarning: '超出上下文限制,建议减少币种或时间框架',
|
||||||
|
tokenEstimating: '预估中...',
|
||||||
|
tokenTooltip: '基于最严格模型计算',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Metric Tooltip
|
// Metric Tooltip
|
||||||
@@ -3464,11 +3474,16 @@ export const translations = {
|
|||||||
public: 'Publik',
|
public: 'Publik',
|
||||||
addDescription: 'Tambah deskripsi strategi...',
|
addDescription: 'Tambah deskripsi strategi...',
|
||||||
unsaved: 'Belum Disimpan',
|
unsaved: 'Belum Disimpan',
|
||||||
|
discardChanges: 'Buang',
|
||||||
selectOrCreate: 'Pilih atau buat strategi',
|
selectOrCreate: 'Pilih atau buat strategi',
|
||||||
customPromptDesc: 'Prompt tambahan di akhir System Prompt untuk gaya trading personal',
|
customPromptDesc: 'Prompt tambahan di akhir System Prompt untuk gaya trading personal',
|
||||||
customPromptPlaceholder: 'Masukkan prompt kustom...',
|
customPromptPlaceholder: 'Masukkan prompt kustom...',
|
||||||
generatePromptPreview: 'Klik untuk generate pratinjau prompt',
|
generatePromptPreview: 'Klik untuk generate pratinjau prompt',
|
||||||
runAiTestHint: 'Klik untuk menjalankan uji AI',
|
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
|
// Metric Tooltip
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Upload,
|
Upload,
|
||||||
Globe,
|
Globe,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
||||||
import { confirmToast, notify } from '../lib/notify'
|
import { confirmToast, notify } from '../lib/notify'
|
||||||
@@ -38,8 +39,10 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
|
|||||||
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
|
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
|
||||||
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
|
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
|
||||||
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
|
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
|
||||||
|
import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar'
|
||||||
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
||||||
import { t } from '../i18n/translations'
|
import { t } from '../i18n/translations'
|
||||||
|
import { NofxSelect } from '../components/ui/select'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
||||||
|
|
||||||
@@ -52,6 +55,7 @@ export function StrategyStudioPage() {
|
|||||||
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
|
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [tokenOverflow, setTokenOverflow] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [hasChanges, setHasChanges] = useState(false)
|
const [hasChanges, setHasChanges] = useState(false)
|
||||||
|
|
||||||
@@ -378,6 +382,10 @@ export function StrategyStudioPage() {
|
|||||||
// Save strategy
|
// Save strategy
|
||||||
const handleSaveStrategy = async () => {
|
const handleSaveStrategy = async () => {
|
||||||
if (!token || !selectedStrategy || !editingConfig) return
|
if (!token || !selectedStrategy || !editingConfig) return
|
||||||
|
if (tokenOverflow && currentStrategyType === 'ai_trading') {
|
||||||
|
notify.error(tr('tokenExceedWarning'))
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
// Always sync the config language with the current interface language
|
// 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')
|
if (!response.ok) throw new Error('Failed to save strategy')
|
||||||
setHasChanges(false)
|
setHasChanges(false)
|
||||||
notify.success(tr('strategySaved'))
|
notify.success(tr('strategySaved'))
|
||||||
|
const savedId = selectedStrategy.id
|
||||||
await fetchStrategies()
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -641,7 +659,7 @@ export function StrategyStudioPage() {
|
|||||||
<Sparkles className="w-5 h-5 text-black" />
|
<Sparkles className="w-5 h-5 text-black" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-nofx-text">{tr('strategyStudio')}</h1>
|
<h1 className="text-lg font-bold text-nofx-text">{tr('title')}</h1>
|
||||||
<p className="text-xs text-nofx-text-muted">{tr('subtitle')}</p>
|
<p className="text-xs text-nofx-text-muted">{tr('subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -756,34 +774,24 @@ export function StrategyStudioPage() {
|
|||||||
{selectedStrategy && editingConfig ? (
|
{selectedStrategy && editingConfig ? (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{/* Strategy Name & Actions */}
|
{/* Strategy Name & Actions */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex items-center justify-between">
|
||||||
<input
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
type="text"
|
<input
|
||||||
value={selectedStrategy.name}
|
type="text"
|
||||||
onChange={(e) => {
|
value={selectedStrategy.name}
|
||||||
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
|
onChange={(e) => {
|
||||||
setHasChanges(true)
|
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"
|
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"
|
||||||
<input
|
/>
|
||||||
type="text"
|
{hasChanges && (
|
||||||
value={selectedStrategy.description || ''}
|
<span className="text-xs text-nofx-gold whitespace-nowrap">● {tr('unsaved')}</span>
|
||||||
onChange={(e) => {
|
)}
|
||||||
setSelectedStrategy({ ...selectedStrategy, description: e.target.value })
|
</div>
|
||||||
setHasChanges(true)
|
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||||
}}
|
|
||||||
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 && (
|
|
||||||
<span className="text-xs text-nofx-gold">● {tr('unsaved')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
{!selectedStrategy.is_active && (
|
{!selectedStrategy.is_active && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleActivateStrategy(selectedStrategy.id)}
|
onClick={() => handleActivateStrategy(selectedStrategy.id)}
|
||||||
@@ -793,10 +801,22 @@ export function StrategyStudioPage() {
|
|||||||
{tr('activate')}
|
{tr('activate')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!selectedStrategy.is_default && hasChanges && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingConfig(selectedStrategy.config)
|
||||||
|
setHasChanges(false)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors text-nofx-text-muted hover:text-nofx-text hover:bg-nofx-bg-lighter border border-nofx-border"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
{tr('discardChanges')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!selectedStrategy.is_default && (
|
{!selectedStrategy.is_default && (
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveStrategy}
|
onClick={handleSaveStrategy}
|
||||||
disabled={isSaving || !hasChanges}
|
disabled={isSaving || !hasChanges || (tokenOverflow && currentStrategyType === 'ai_trading')}
|
||||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
|
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
|
||||||
${hasChanges ? 'bg-nofx-gold text-black hover:bg-yellow-500' : 'bg-nofx-bg-lighter text-nofx-text-muted cursor-not-allowed'}`}
|
${hasChanges ? 'bg-nofx-gold text-black hover:bg-yellow-500' : 'bg-nofx-bg-lighter text-nofx-text-muted cursor-not-allowed'}`}
|
||||||
>
|
>
|
||||||
@@ -805,8 +825,27 @@ export function StrategyStudioPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={selectedStrategy.description || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Token Estimate Bar */}
|
||||||
|
{currentStrategyType === 'ai_trading' && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<TokenEstimateBar config={editingConfig} language={language} onOverflowChange={setTokenOverflow} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Strategy Type Selector */}
|
{/* Strategy Type Selector */}
|
||||||
{editingConfig && (
|
{editingConfig && (
|
||||||
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">
|
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">
|
||||||
@@ -818,9 +857,12 @@ export function StrategyStudioPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!selectedStrategy?.is_default) {
|
if (!selectedStrategy?.is_default) {
|
||||||
updateConfig('strategy_type', 'ai_trading')
|
setEditingConfig(prev => prev ? {
|
||||||
// Clear grid config when switching to AI trading
|
...prev,
|
||||||
updateConfig('grid_config', undefined)
|
strategy_type: 'ai_trading',
|
||||||
|
grid_config: undefined,
|
||||||
|
} : prev)
|
||||||
|
setHasChanges(true)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={selectedStrategy?.is_default}
|
disabled={selectedStrategy?.is_default}
|
||||||
@@ -839,11 +881,12 @@ export function StrategyStudioPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!selectedStrategy?.is_default) {
|
if (!selectedStrategy?.is_default) {
|
||||||
updateConfig('strategy_type', 'grid_trading')
|
setEditingConfig(prev => prev ? {
|
||||||
// Initialize grid config if not exists
|
...prev,
|
||||||
if (!editingConfig.grid_config) {
|
strategy_type: 'grid_trading',
|
||||||
updateConfig('grid_config', defaultGridConfig)
|
grid_config: prev.grid_config || defaultGridConfig,
|
||||||
}
|
} : prev)
|
||||||
|
setHasChanges(true)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={selectedStrategy?.is_default}
|
disabled={selectedStrategy?.is_default}
|
||||||
@@ -934,15 +977,16 @@ export function StrategyStudioPage() {
|
|||||||
<div className="p-3 space-y-3">
|
<div className="p-3 space-y-3">
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<select
|
<NofxSelect
|
||||||
value={selectedVariant}
|
value={selectedVariant}
|
||||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
onChange={(val) => setSelectedVariant(val)}
|
||||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text outline-none focus:border-nofx-gold"
|
options={[
|
||||||
>
|
{ value: 'balanced', label: tr('balanced') },
|
||||||
<option value="balanced">{tr('balanced')}</option>
|
{ value: 'aggressive', label: tr('aggressive') },
|
||||||
<option value="aggressive">{tr('aggressive')}</option>
|
{ value: 'conservative', label: tr('conservative') },
|
||||||
<option value="conservative">{tr('conservative')}</option>
|
]}
|
||||||
</select>
|
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={fetchPromptPreview}
|
onClick={fetchPromptPreview}
|
||||||
disabled={isLoadingPrompt || !editingConfig}
|
disabled={isLoadingPrompt || !editingConfig}
|
||||||
@@ -1007,17 +1051,15 @@ export function StrategyStudioPage() {
|
|||||||
<span className="text-xs font-medium text-nofx-text">{tr('selectModel')}</span>
|
<span className="text-xs font-medium text-nofx-text">{tr('selectModel')}</span>
|
||||||
</div>
|
</div>
|
||||||
{aiModels.length > 0 ? (
|
{aiModels.length > 0 ? (
|
||||||
<select
|
<NofxSelect
|
||||||
value={selectedModelId}
|
value={selectedModelId}
|
||||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
onChange={(val) => setSelectedModelId(val)}
|
||||||
|
options={aiModels.map((model) => ({
|
||||||
|
value: model.id,
|
||||||
|
label: `${model.name} (${model.provider})`,
|
||||||
|
}))}
|
||||||
className="w-full px-3 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
className="w-full px-3 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
>
|
/>
|
||||||
{aiModels.map((model) => (
|
|
||||||
<option key={model.id} value={model.id}>
|
|
||||||
{model.name} ({model.provider})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="px-3 py-2 rounded-lg text-sm bg-nofx-danger/10 text-nofx-danger">
|
<div className="px-3 py-2 rounded-lg text-sm bg-nofx-danger/10 text-nofx-danger">
|
||||||
{tr('noModel')}
|
{tr('noModel')}
|
||||||
@@ -1025,15 +1067,16 @@ export function StrategyStudioPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<NofxSelect
|
||||||
value={selectedVariant}
|
value={selectedVariant}
|
||||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
onChange={(val) => setSelectedVariant(val)}
|
||||||
|
options={[
|
||||||
|
{ value: 'balanced', label: tr('balanced') },
|
||||||
|
{ value: 'aggressive', label: tr('aggressive') },
|
||||||
|
{ value: 'conservative', label: tr('conservative') },
|
||||||
|
]}
|
||||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
>
|
/>
|
||||||
<option value="balanced">{tr('balanced')}</option>
|
|
||||||
<option value="aggressive">{tr('aggressive')}</option>
|
|
||||||
<option value="conservative">{tr('conservative')}</option>
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
onClick={runAiTest}
|
onClick={runAiTest}
|
||||||
disabled={isRunningAiTest || !editingConfig || !selectedModelId}
|
disabled={isRunningAiTest || !editingConfig || !selectedModelId}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { formatPrice, formatQuantity } from '../utils/format'
|
|||||||
import { t, type Language } from '../i18n/translations'
|
import { t, type Language } from '../i18n/translations'
|
||||||
import { LogOut, Loader2, Eye, EyeOff, Copy, Check } from 'lucide-react'
|
import { LogOut, Loader2, Eye, EyeOff, Copy, Check } from 'lucide-react'
|
||||||
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
||||||
|
import { NofxSelect } from '../components/ui/select'
|
||||||
import { GridRiskPanel } from '../components/strategy/GridRiskPanel'
|
import { GridRiskPanel } from '../components/strategy/GridRiskPanel'
|
||||||
import type {
|
import type {
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
@@ -376,17 +377,12 @@ export function TraderDashboardPage({
|
|||||||
{/* Trader Selector */}
|
{/* Trader Selector */}
|
||||||
{traders && traders.length > 0 && (
|
{traders && traders.length > 0 && (
|
||||||
<div className="flex items-center gap-2 nofx-glass px-1 py-1 rounded-lg border border-white/5">
|
<div className="flex items-center gap-2 nofx-glass px-1 py-1 rounded-lg border border-white/5">
|
||||||
<select
|
<NofxSelect
|
||||||
value={selectedTraderId}
|
value={selectedTraderId || ''}
|
||||||
onChange={(e) => onTraderSelect(e.target.value)}
|
onChange={(val) => onTraderSelect(val)}
|
||||||
className="bg-transparent text-sm font-medium cursor-pointer transition-colors text-nofx-text-main focus:outline-none px-2 py-1"
|
options={traders.map(t => ({ value: t.trader_id, label: t.trader_name }))}
|
||||||
>
|
className="bg-transparent text-sm font-medium cursor-pointer transition-colors text-nofx-text-main px-2 py-1"
|
||||||
{traders.map((trader) => (
|
/>
|
||||||
<option key={trader.trader_id} value={trader.trader_id} className="bg-[#0B0E11]">
|
|
||||||
{trader.trader_name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -484,16 +480,22 @@ export function TraderDashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Debug Info */}
|
{/* Debug Info */}
|
||||||
{account && (
|
<div className="mb-4 px-3 py-1.5 rounded bg-black/40 border border-white/5 text-[10px] font-mono text-nofx-text-muted flex justify-between items-center opacity-60 hover:opacity-100 transition-opacity">
|
||||||
<div className="mb-4 px-3 py-1.5 rounded bg-black/40 border border-white/5 text-[10px] font-mono text-nofx-text-muted flex justify-between items-center opacity-60 hover:opacity-100 transition-opacity">
|
<span style={{ color: '#0ECB81' }}>SYSTEM_STATUS::ONLINE</span>
|
||||||
<span>SYSTEM_STATUS::ONLINE</span>
|
{account ? (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<span>LAST_UPDATE::{lastUpdate}</span>
|
<span>LAST_UPDATE::{lastUpdate}</span>
|
||||||
<span>EQ::{account?.total_equity?.toFixed(2)}</span>
|
<span>EQ::{account.total_equity?.toFixed(2)}</span>
|
||||||
<span>PNL::{account?.total_pnl?.toFixed(2)}</span>
|
<span>PNL::{account.total_pnl?.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="flex gap-4">
|
||||||
|
<span className="inline-block w-32 h-3 rounded bg-white/5 animate-pulse" />
|
||||||
|
<span className="inline-block w-16 h-3 rounded bg-white/5 animate-pulse" />
|
||||||
|
<span className="inline-block w-16 h-3 rounded bg-white/5 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Account Overview */}
|
{/* Account Overview */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
@@ -504,6 +506,7 @@ export function TraderDashboardPage({
|
|||||||
change={account?.total_pnl_pct || 0}
|
change={account?.total_pnl_pct || 0}
|
||||||
positive={(account?.total_pnl ?? 0) > 0}
|
positive={(account?.total_pnl ?? 0) > 0}
|
||||||
icon="💰"
|
icon="💰"
|
||||||
|
loading={!account}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t('availableBalance', language)}
|
title={t('availableBalance', language)}
|
||||||
@@ -511,6 +514,7 @@ export function TraderDashboardPage({
|
|||||||
unit="USDT"
|
unit="USDT"
|
||||||
subtitle={`${account?.available_balance && account?.total_equity ? ((account.available_balance / account.total_equity) * 100).toFixed(1) : '0.0'}% ${t('free', language)}`}
|
subtitle={`${account?.available_balance && account?.total_equity ? ((account.available_balance / account.total_equity) * 100).toFixed(1) : '0.0'}% ${t('free', language)}`}
|
||||||
icon="💳"
|
icon="💳"
|
||||||
|
loading={!account}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t('totalPnL', language)}
|
title={t('totalPnL', language)}
|
||||||
@@ -519,6 +523,7 @@ export function TraderDashboardPage({
|
|||||||
change={account?.total_pnl_pct || 0}
|
change={account?.total_pnl_pct || 0}
|
||||||
positive={(account?.total_pnl ?? 0) >= 0}
|
positive={(account?.total_pnl ?? 0) >= 0}
|
||||||
icon="📈"
|
icon="📈"
|
||||||
|
loading={!account}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t('positions', language)}
|
title={t('positions', language)}
|
||||||
@@ -526,6 +531,7 @@ export function TraderDashboardPage({
|
|||||||
unit="ACTIVE"
|
unit="ACTIVE"
|
||||||
subtitle={`${t('margin', language)}: ${account?.margin_used_pct?.toFixed(1) || '0.0'}%`}
|
subtitle={`${t('margin', language)}: ${account?.margin_used_pct?.toFixed(1) || '0.0'}%`}
|
||||||
icon="📊"
|
icon="📊"
|
||||||
|
loading={!account}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -671,15 +677,12 @@ export function TraderDashboardPage({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t('traderDashboard.perPage', language)}:</span>
|
<span>{t('traderDashboard.perPage', language)}:</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={positionsPageSize}
|
value={positionsPageSize}
|
||||||
onChange={(e) => setPositionsPageSize(Number(e.target.value))}
|
onChange={(val) => setPositionsPageSize(Number(val))}
|
||||||
className="bg-black/40 border border-white/10 rounded px-2 py-1 text-xs text-nofx-text-main focus:outline-none focus:border-nofx-gold/50 transition-colors"
|
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"
|
||||||
<option value={20}>20</option>
|
/>
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
{totalPositionPages > 1 && (
|
{totalPositionPages > 1 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -752,17 +755,12 @@ export function TraderDashboardPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Limit Selector */}
|
{/* Limit Selector */}
|
||||||
<select
|
<NofxSelect
|
||||||
value={decisionsLimit}
|
value={decisionsLimit}
|
||||||
onChange={(e) => onDecisionsLimitChange(Number(e.target.value))}
|
onChange={(val) => onDecisionsLimitChange(Number(val))}
|
||||||
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 focus:outline-none"
|
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"
|
||||||
<option value={5}>5</option>
|
/>
|
||||||
<option value={10}>10</option>
|
|
||||||
<option value={20}>20</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Decisions List - Scrollable */}
|
{/* Decisions List - Scrollable */}
|
||||||
@@ -818,6 +816,7 @@ function StatCard({
|
|||||||
positive,
|
positive,
|
||||||
subtitle,
|
subtitle,
|
||||||
icon,
|
icon,
|
||||||
|
loading,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
value: string
|
value: string
|
||||||
@@ -826,6 +825,7 @@ function StatCard({
|
|||||||
positive?: boolean
|
positive?: boolean
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
|
loading?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="group nofx-glass p-5 rounded-lg transition-all duration-300 hover:bg-white/5 hover:translate-y-[-2px] border border-white/5 hover:border-nofx-gold/20 relative overflow-hidden">
|
<div className="group nofx-glass p-5 rounded-lg transition-all duration-300 hover:bg-white/5 hover:translate-y-[-2px] border border-white/5 hover:border-nofx-gold/20 relative overflow-hidden">
|
||||||
@@ -835,27 +835,35 @@ function StatCard({
|
|||||||
<div className="text-xs mb-2 font-mono uppercase tracking-wider text-nofx-text-muted flex items-center gap-2">
|
<div className="text-xs mb-2 font-mono uppercase tracking-wider text-nofx-text-muted flex items-center gap-2">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1 mb-1">
|
{loading ? (
|
||||||
<div className="text-2xl font-bold font-mono text-nofx-text-main tracking-tight group-hover:text-white transition-colors">
|
<div className="space-y-2">
|
||||||
{value}
|
<div className="h-7 w-24 rounded bg-white/5 animate-pulse" />
|
||||||
|
<div className="h-3 w-16 rounded bg-white/5 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
{unit && <span className="text-xs font-mono text-nofx-text-muted opacity-60">{unit}</span>}
|
) : (
|
||||||
</div>
|
<>
|
||||||
|
<div className="flex items-baseline gap-1 mb-1">
|
||||||
{change !== undefined && (
|
<div className="text-2xl font-bold font-mono text-nofx-text-main tracking-tight group-hover:text-white transition-colors">
|
||||||
<div className="flex items-center gap-1">
|
{value}
|
||||||
<div
|
</div>
|
||||||
className={`text-sm mono font-bold flex items-center gap-1 ${positive ? 'text-nofx-green' : 'text-nofx-red'}`}
|
{unit && <span className="text-xs font-mono text-nofx-text-muted opacity-60">{unit}</span>}
|
||||||
>
|
|
||||||
<span>{positive ? '▲' : '▼'}</span>
|
|
||||||
<span>{positive ? '+' : ''}{change.toFixed(2)}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{change !== undefined && (
|
||||||
)}
|
<div className="flex items-center gap-1">
|
||||||
{subtitle && (
|
<div
|
||||||
<div className="text-xs mt-2 mono text-nofx-text-muted opacity-80">
|
className={`text-sm mono font-bold flex items-center gap-1 ${positive ? 'text-nofx-green' : 'text-nofx-red'}`}
|
||||||
{subtitle}
|
>
|
||||||
</div>
|
<span>{positive ? '▲' : '▼'}</span>
|
||||||
|
<span>{positive ? '+' : ''}{change.toFixed(2)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<div className="text-xs mt-2 mono text-nofx-text-muted opacity-80">
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user