mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Compare commits
31 Commits
577a0918c3
...
release/me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0537ff3961 | ||
|
|
a99718ac60 | ||
|
|
b7635b0238 | ||
|
|
d353d8aed9 | ||
|
|
085ae3c875 | ||
|
|
649ef50e44 | ||
|
|
47fb1c4675 | ||
|
|
fa048b44ac | ||
|
|
620eca08ec | ||
|
|
d7461c739a | ||
|
|
796606a8a8 | ||
|
|
4173c7678a | ||
|
|
886650cc0e | ||
|
|
7ea813a46b | ||
|
|
9a64d0f485 | ||
|
|
07fbe6d053 | ||
|
|
c50b964fd1 | ||
|
|
a8057f6e9e | ||
|
|
8c6ca75e93 | ||
|
|
b9f6a32ad6 | ||
|
|
eae05c5715 | ||
|
|
d6e3088998 | ||
|
|
4a483eaca6 | ||
|
|
655b49450e | ||
|
|
85e1f7f963 | ||
|
|
b9a33ce809 | ||
|
|
39b05b1a09 | ||
|
|
79a3be1874 | ||
|
|
b705810ec2 | ||
|
|
a942c5312f | ||
|
|
c18d3d5682 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -27,6 +27,8 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.bak
|
||||
*.backup
|
||||
.cache/
|
||||
.gh-config/
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
|
||||
@@ -69,7 +69,7 @@ No accounts. No API keys. No prepaid credits. One wallet, every model.
|
||||
|
||||
| Feature | Description |
|
||||
|:--------|:------------|
|
||||
| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — switch anytime |
|
||||
| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — switch anytime |
|
||||
| **Multi-Exchange** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Strategy Studio** | Visual builder — coin sources, indicators, risk controls |
|
||||
| **AI Competition** | AIs compete in real-time, leaderboard ranks performance |
|
||||
@@ -110,6 +110,7 @@ Crypto · US Stocks · Forex · Metals
|
||||
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
|
||||
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
|
||||
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
|
||||
| <img src="web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Get API Key](https://platform.minimaxi.com) |
|
||||
|
||||
### AI Models (x402 Mode — No API Key)
|
||||
|
||||
@@ -211,6 +212,10 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
## Setup
|
||||
|
||||
**Beginner mode**: First-time users get a guided onboarding flow — select beginner mode at registration and the system walks you through AI, exchange, and strategy setup step by step.
|
||||
|
||||
**Advanced mode**:
|
||||
|
||||
1. **AI** — Add API keys or configure x402 wallet
|
||||
2. **Exchange** — Connect exchange API credentials
|
||||
3. **Strategy** — Build in Strategy Studio
|
||||
|
||||
@@ -8,6 +8,25 @@ import (
|
||||
"nofx/logger"
|
||||
)
|
||||
|
||||
type APIErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
ErrorKey string `json:"error_key,omitempty"`
|
||||
ErrorParams map[string]string `json:"error_params,omitempty"`
|
||||
}
|
||||
|
||||
func writeAPIError(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string) {
|
||||
resp := APIErrorResponse{
|
||||
Error: publicMsg,
|
||||
}
|
||||
if errorKey != "" {
|
||||
resp.ErrorKey = errorKey
|
||||
}
|
||||
if len(errorParams) > 0 {
|
||||
resp.ErrorParams = errorParams
|
||||
}
|
||||
c.JSON(statusCode, resp)
|
||||
}
|
||||
|
||||
// SafeError returns a safe error message without exposing internal details
|
||||
// It logs the actual error for debugging but returns a generic message to the client
|
||||
func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr error) {
|
||||
@@ -16,34 +35,46 @@ func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr err
|
||||
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
|
||||
}
|
||||
|
||||
c.JSON(statusCode, gin.H{"error": publicMsg})
|
||||
writeAPIError(c, statusCode, publicMsg, "", nil)
|
||||
}
|
||||
|
||||
func SafeErrorWithDetails(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string, internalErr error) {
|
||||
if internalErr != nil {
|
||||
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
|
||||
}
|
||||
|
||||
writeAPIError(c, statusCode, publicMsg, errorKey, errorParams)
|
||||
}
|
||||
|
||||
// SafeInternalError logs internal error and returns a generic message
|
||||
func SafeInternalError(c *gin.Context, operation string, err error) {
|
||||
logger.Errorf("[Internal Error] %s: %v", operation, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": operation + " failed"})
|
||||
writeAPIError(c, http.StatusInternalServerError, operation+" failed", "", nil)
|
||||
}
|
||||
|
||||
// SafeBadRequest returns a safe bad request error
|
||||
// For validation errors, we can be more specific since they're about user input
|
||||
func SafeBadRequest(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||||
writeAPIError(c, http.StatusBadRequest, msg, "", nil)
|
||||
}
|
||||
|
||||
func SafeBadRequestWithDetails(c *gin.Context, msg, errorKey string, errorParams map[string]string) {
|
||||
writeAPIError(c, http.StatusBadRequest, msg, errorKey, errorParams)
|
||||
}
|
||||
|
||||
// SafeNotFound returns a generic not found error
|
||||
func SafeNotFound(c *gin.Context, resource string) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": resource + " not found"})
|
||||
writeAPIError(c, http.StatusNotFound, resource+" not found", "", nil)
|
||||
}
|
||||
|
||||
// SafeUnauthorized returns unauthorized error
|
||||
func SafeUnauthorized(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
writeAPIError(c, http.StatusUnauthorized, "Unauthorized", "", nil)
|
||||
}
|
||||
|
||||
// SafeForbidden returns forbidden error
|
||||
func SafeForbidden(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": msg})
|
||||
writeAPIError(c, http.StatusForbidden, msg, "", nil)
|
||||
}
|
||||
|
||||
// IsSensitiveError checks if an error message contains sensitive information
|
||||
|
||||
381
api/exchange_account_state.go
Normal file
381
api/exchange_account_state.go
Normal file
@@ -0,0 +1,381 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
"nofx/trader"
|
||||
"nofx/trader/aster"
|
||||
"nofx/trader/binance"
|
||||
"nofx/trader/bitget"
|
||||
"nofx/trader/bybit"
|
||||
"nofx/trader/gate"
|
||||
hyperliquidtrader "nofx/trader/hyperliquid"
|
||||
"nofx/trader/indodax"
|
||||
"nofx/trader/kucoin"
|
||||
"nofx/trader/lighter"
|
||||
"nofx/trader/okx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const exchangeAccountStateCacheTTL = 30 * time.Second
|
||||
|
||||
const (
|
||||
exchangeAccountStatusOK = "ok"
|
||||
exchangeAccountStatusDisabled = "disabled"
|
||||
exchangeAccountStatusMissingCredentials = "missing_credentials"
|
||||
exchangeAccountStatusInvalidCredentials = "invalid_credentials"
|
||||
exchangeAccountStatusPermissionDenied = "permission_denied"
|
||||
exchangeAccountStatusUnavailable = "unavailable"
|
||||
)
|
||||
|
||||
type ExchangeAccountState struct {
|
||||
ExchangeID string `json:"exchange_id"`
|
||||
Status string `json:"status"`
|
||||
DisplayBalance string `json:"display_balance,omitempty"`
|
||||
Asset string `json:"asset,omitempty"`
|
||||
TotalEquity float64 `json:"total_equity,omitempty"`
|
||||
AvailableBalance float64 `json:"available_balance,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
type cachedExchangeAccountStates struct {
|
||||
states map[string]ExchangeAccountState
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
type ExchangeAccountStateCache struct {
|
||||
entries map[string]cachedExchangeAccountStates
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewExchangeAccountStateCache() *ExchangeAccountStateCache {
|
||||
return &ExchangeAccountStateCache{
|
||||
entries: make(map[string]cachedExchangeAccountStates),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ExchangeAccountStateCache) Get(userID string) (map[string]ExchangeAccountState, bool) {
|
||||
c.mu.RLock()
|
||||
entry, ok := c.entries[userID]
|
||||
c.mu.RUnlock()
|
||||
if !ok || time.Since(entry.cachedAt) >= exchangeAccountStateCacheTTL {
|
||||
return nil, false
|
||||
}
|
||||
return cloneExchangeAccountStates(entry.states), true
|
||||
}
|
||||
|
||||
func (c *ExchangeAccountStateCache) Set(userID string, states map[string]ExchangeAccountState) {
|
||||
c.mu.Lock()
|
||||
c.entries[userID] = cachedExchangeAccountStates{
|
||||
states: cloneExchangeAccountStates(states),
|
||||
cachedAt: time.Now(),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *ExchangeAccountStateCache) Invalidate(userID string) {
|
||||
c.mu.Lock()
|
||||
delete(c.entries, userID)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func cloneExchangeAccountStates(states map[string]ExchangeAccountState) map[string]ExchangeAccountState {
|
||||
cloned := make(map[string]ExchangeAccountState, len(states))
|
||||
for id, state := range states {
|
||||
cloned[id] = state
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func (s *Server) handleGetExchangeAccountStates(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
states, err := s.getExchangeAccountStates(userID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to get exchange account states", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"states": states})
|
||||
}
|
||||
|
||||
func (s *Server) getExchangeAccountStates(userID string) (map[string]ExchangeAccountState, error) {
|
||||
if cached, ok := s.exchangeAccountStateCache.Get(userID); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
exchanges, err := s.store.Exchange().List(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
states := make(map[string]ExchangeAccountState, len(exchanges))
|
||||
if len(exchanges) == 0 {
|
||||
return states, nil
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for _, exchangeCfg := range exchanges {
|
||||
exchangeCfg := exchangeCfg
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
state := probeExchangeAccountState(exchangeCfg, userID)
|
||||
mu.Lock()
|
||||
states[exchangeCfg.ID] = state
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
s.exchangeAccountStateCache.Set(userID, states)
|
||||
|
||||
return cloneExchangeAccountStates(states), nil
|
||||
}
|
||||
|
||||
func probeExchangeAccountState(exchangeCfg *store.Exchange, userID string) ExchangeAccountState {
|
||||
state := ExchangeAccountState{
|
||||
ExchangeID: exchangeCfg.ID,
|
||||
CheckedAt: time.Now().UTC(),
|
||||
Asset: accountAssetForExchange(exchangeCfg.ExchangeType),
|
||||
}
|
||||
|
||||
if !exchangeCfg.Enabled {
|
||||
state.Status = exchangeAccountStatusDisabled
|
||||
state.ErrorCode = "EXCHANGE_DISABLED"
|
||||
state.ErrorMessage = "Exchange account is disabled"
|
||||
return state
|
||||
}
|
||||
|
||||
if status, code, message, missing := missingExchangeCredentials(exchangeCfg); missing {
|
||||
state.Status = status
|
||||
state.ErrorCode = code
|
||||
state.ErrorMessage = message
|
||||
return state
|
||||
}
|
||||
|
||||
tempTrader, err := buildExchangeProbeTrader(exchangeCfg, userID)
|
||||
if err != nil {
|
||||
status, code, message := classifyExchangeProbeError(err)
|
||||
state.Status = status
|
||||
state.ErrorCode = code
|
||||
state.ErrorMessage = message
|
||||
return state
|
||||
}
|
||||
|
||||
balanceInfo, err := tempTrader.GetBalance()
|
||||
if err != nil {
|
||||
status, code, message := classifyExchangeProbeError(err)
|
||||
state.Status = status
|
||||
state.ErrorCode = code
|
||||
state.ErrorMessage = message
|
||||
logger.Infof("⚠️ Failed to probe exchange account %s (%s): %v", exchangeCfg.ID, exchangeCfg.ExchangeType, err)
|
||||
return state
|
||||
}
|
||||
|
||||
totalEquity, totalFound := extractFirstNumeric(balanceInfo,
|
||||
"total_equity", "totalEquity", "totalWalletBalance", "wallet_balance", "totalEq", "balance")
|
||||
availableBalance, availableFound := extractFirstNumeric(balanceInfo,
|
||||
"available_balance", "availableBalance", "available")
|
||||
|
||||
if !totalFound && availableFound {
|
||||
totalEquity = availableBalance
|
||||
totalFound = true
|
||||
}
|
||||
|
||||
if !availableFound && totalFound {
|
||||
availableBalance = totalEquity
|
||||
availableFound = true
|
||||
}
|
||||
|
||||
if !totalFound && !availableFound {
|
||||
state.Status = exchangeAccountStatusUnavailable
|
||||
state.ErrorCode = "BALANCE_NOT_FOUND"
|
||||
state.ErrorMessage = "Connected but no balance fields were returned"
|
||||
return state
|
||||
}
|
||||
|
||||
state.Status = exchangeAccountStatusOK
|
||||
if totalFound {
|
||||
state.TotalEquity = totalEquity
|
||||
state.DisplayBalance = formatDisplayBalance(totalEquity, state.Asset)
|
||||
}
|
||||
if availableFound {
|
||||
state.AvailableBalance = availableBalance
|
||||
if state.DisplayBalance == "" {
|
||||
state.DisplayBalance = formatDisplayBalance(availableBalance, state.Asset)
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
func buildExchangeProbeTrader(exchangeCfg *store.Exchange, userID string) (trader.Trader, error) {
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance":
|
||||
return binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID), nil
|
||||
case "bybit":
|
||||
return bybit.NewBybitTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil
|
||||
case "okx":
|
||||
return okx.NewOKXTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil
|
||||
case "bitget":
|
||||
return bitget.NewBitgetTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil
|
||||
case "gate":
|
||||
return gate.NewGateTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil
|
||||
case "kucoin":
|
||||
return kucoin.NewKuCoinTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil
|
||||
case "indodax":
|
||||
return indodax.NewIndodaxTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil
|
||||
case "hyperliquid":
|
||||
return hyperliquidtrader.NewHyperliquidTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
exchangeCfg.HyperliquidUnifiedAcct,
|
||||
)
|
||||
case "aster":
|
||||
return aster.NewAsterTrader(
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
string(exchangeCfg.AsterPrivateKey),
|
||||
)
|
||||
case "lighter":
|
||||
return lighter.NewLighterTraderV2(
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||||
exchangeCfg.LighterAPIKeyIndex,
|
||||
false,
|
||||
)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported exchange type: %s", exchangeCfg.ExchangeType)
|
||||
}
|
||||
}
|
||||
|
||||
func extractExchangeTotalEquity(balanceInfo map[string]interface{}) (float64, bool) {
|
||||
return extractFirstNumeric(balanceInfo,
|
||||
"total_equity", "totalEquity", "totalWalletBalance", "wallet_balance", "totalEq", "balance")
|
||||
}
|
||||
|
||||
func extractFirstNumeric(values map[string]interface{}, keys ...string) (float64, bool) {
|
||||
for _, key := range keys {
|
||||
raw, ok := values[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case int32:
|
||||
return float64(v), true
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(v, 64)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func formatDisplayBalance(value float64, asset string) string {
|
||||
formatted := strconv.FormatFloat(value, 'f', 4, 64)
|
||||
formatted = strings.TrimRight(strings.TrimRight(formatted, "0"), ".")
|
||||
if formatted == "" {
|
||||
formatted = "0"
|
||||
}
|
||||
if asset == "" {
|
||||
return formatted
|
||||
}
|
||||
return fmt.Sprintf("%s %s", formatted, asset)
|
||||
}
|
||||
|
||||
func accountAssetForExchange(exchangeType string) string {
|
||||
switch exchangeType {
|
||||
case "hyperliquid", "aster", "lighter":
|
||||
return "USDC"
|
||||
default:
|
||||
return "USDT"
|
||||
}
|
||||
}
|
||||
|
||||
func missingExchangeCredentials(exchangeCfg *store.Exchange) (status string, code string, message string, missing bool) {
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance", "bybit", "gate", "indodax":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key and secret key are required", true
|
||||
}
|
||||
case "okx", "bitget", "kucoin":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" || exchangeCfg.Passphrase == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key, secret key, and passphrase are required", true
|
||||
}
|
||||
case "hyperliquid":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.HyperliquidWalletAddr == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Private key and wallet address are required", true
|
||||
}
|
||||
case "aster":
|
||||
if exchangeCfg.AsterUser == "" || exchangeCfg.AsterSigner == "" || exchangeCfg.AsterPrivateKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Aster user, signer, and private key are required", true
|
||||
}
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterWalletAddr == "" || exchangeCfg.LighterAPIKeyPrivateKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Wallet address and API key private key are required", true
|
||||
}
|
||||
default:
|
||||
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", true
|
||||
}
|
||||
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
func classifyExchangeProbeError(err error) (status string, code string, message string) {
|
||||
if err == nil {
|
||||
return exchangeAccountStatusOK, "", ""
|
||||
}
|
||||
|
||||
rawMessage := err.Error()
|
||||
msg := strings.ToLower(rawMessage)
|
||||
|
||||
switch {
|
||||
case strings.Contains(msg, "unsupported exchange type"):
|
||||
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type"
|
||||
case strings.Contains(msg, "requires ") || strings.Contains(msg, "missing") || strings.Contains(msg, "empty"):
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Exchange credentials are incomplete"
|
||||
case strings.Contains(msg, "permission") || strings.Contains(msg, "forbidden") || strings.Contains(msg, "no authority") || strings.Contains(msg, "not allowed"):
|
||||
return exchangeAccountStatusPermissionDenied, "PERMISSION_DENIED", "Exchange account has no permission to read balances"
|
||||
case strings.Contains(msg, "invalid") || strings.Contains(msg, "signature") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "api key") || strings.Contains(msg, "api-key") || strings.Contains(msg, "auth"):
|
||||
return exchangeAccountStatusInvalidCredentials, "INVALID_CREDENTIALS", "Exchange credentials are invalid"
|
||||
default:
|
||||
return exchangeAccountStatusUnavailable, "EXCHANGE_UNAVAILABLE", limitErrorMessage(rawMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func limitErrorMessage(message string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return "Unable to fetch exchange balance right now"
|
||||
}
|
||||
if len(message) <= 160 {
|
||||
return message
|
||||
}
|
||||
return message[:157] + "..."
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"nofx/security"
|
||||
"nofx/wallet"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -31,6 +32,8 @@ type SafeModelConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
|
||||
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
|
||||
WalletAddress string `json:"walletAddress,omitempty"`
|
||||
BalanceUSDC string `json:"balanceUsdc,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateModelConfigRequest struct {
|
||||
@@ -75,7 +78,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
// Convert to safe response structure, remove sensitive information
|
||||
safeModels := make([]SafeModelConfig, len(models))
|
||||
for i, model := range models {
|
||||
safeModels[i] = SafeModelConfig{
|
||||
safeModel := SafeModelConfig{
|
||||
ID: model.ID,
|
||||
Name: model.Name,
|
||||
Provider: model.Provider,
|
||||
@@ -83,6 +86,19 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
CustomAPIURL: model.CustomAPIURL,
|
||||
CustomModelName: model.CustomModelName,
|
||||
}
|
||||
|
||||
if model.Provider == "claw402" {
|
||||
if privateKey := strings.TrimSpace(model.APIKey.String()); privateKey != "" {
|
||||
if walletAddress, addrErr := walletAddressFromPrivateKey(privateKey); addrErr == nil {
|
||||
safeModel.WalletAddress = walletAddress
|
||||
safeModel.BalanceUSDC = wallet.QueryUSDCBalanceStr(walletAddress)
|
||||
} else {
|
||||
logger.Warnf("⚠️ Failed to derive claw402 wallet address for model %s: %v", model.ID, addrErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
safeModels[i] = safeModel
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, safeModels)
|
||||
@@ -201,8 +217,8 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
||||
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
|
||||
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
|
||||
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
|
||||
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.5"},
|
||||
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek"},
|
||||
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
|
||||
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "glm-5"},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, supportedModels)
|
||||
|
||||
@@ -192,6 +192,8 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
s.exchangeAccountStateCache.Invalidate(userID)
|
||||
|
||||
// Remove affected traders from memory BEFORE reloading to pick up new config
|
||||
for traderID := range tradersToReload {
|
||||
logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID)
|
||||
@@ -284,6 +286,8 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
s.exchangeAccountStateCache.Invalidate(userID)
|
||||
|
||||
logger.Infof("✓ Created exchange account: type=%s, name=%s, id=%s", req.ExchangeType, req.AccountName, id)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Exchange account created",
|
||||
@@ -327,6 +331,8 @@ func (s *Server) handleDeleteExchange(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
s.exchangeAccountStateCache.Invalidate(userID)
|
||||
|
||||
logger.Infof("✓ Deleted exchange account: id=%s", exchangeID)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Exchange account deleted"})
|
||||
}
|
||||
|
||||
343
api/handler_onboarding.go
Normal file
343
api/handler_onboarding.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/wallet"
|
||||
|
||||
gethcrypto "github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type beginnerOnboardingResponse struct {
|
||||
Address string `json:"address"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
Chain string `json:"chain"`
|
||||
Asset string `json:"asset"`
|
||||
Provider string `json:"provider"`
|
||||
DefaultModel string `json:"default_model"`
|
||||
ConfiguredModelID string `json:"configured_model_id"`
|
||||
BalanceUSDC string `json:"balance_usdc"`
|
||||
EnvSaved bool `json:"env_saved"`
|
||||
EnvPath string `json:"env_path,omitempty"`
|
||||
ReusedExisting bool `json:"reused_existing"`
|
||||
EnvWarning string `json:"env_warning,omitempty"`
|
||||
}
|
||||
|
||||
type currentBeginnerWalletResponse struct {
|
||||
Found bool `json:"found"`
|
||||
Address string `json:"address,omitempty"`
|
||||
BalanceUSDC string `json:"balance_usdc,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Claw402Status string `json:"claw402_status"`
|
||||
}
|
||||
|
||||
func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
|
||||
return
|
||||
}
|
||||
|
||||
privateKey, address, configuredModelID, reusedExisting, err := s.resolveBeginnerWallet(userID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to resolve beginner wallet for user %s: %v", userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare beginner wallet"})
|
||||
return
|
||||
}
|
||||
|
||||
if !reusedExisting {
|
||||
if err := s.store.AIModel().Update(userID, "claw402", true, privateKey, "", "glm-5"); err != nil {
|
||||
logger.Errorf("Failed to save beginner claw402 config for user %s: %v", userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save beginner model configuration"})
|
||||
return
|
||||
}
|
||||
|
||||
configuredModelID, err = s.findConfiguredClaw402ModelID(userID)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not resolve configured claw402 model id for user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
os.Setenv("CLAW402_WALLET_KEY", privateKey)
|
||||
os.Setenv("CLAW402_WALLET_ADDRESS", address)
|
||||
os.Setenv("CLAW402_DEFAULT_MODEL", "glm-5")
|
||||
|
||||
envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address)
|
||||
resp := beginnerOnboardingResponse{
|
||||
Address: address,
|
||||
PrivateKey: privateKey,
|
||||
Chain: "base",
|
||||
Asset: "USDC",
|
||||
Provider: "claw402",
|
||||
DefaultModel: "glm-5",
|
||||
ConfiguredModelID: configuredModelID,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
EnvSaved: envSaved,
|
||||
EnvPath: envPath,
|
||||
ReusedExisting: reusedExisting,
|
||||
}
|
||||
if envErr != nil {
|
||||
resp.EnvWarning = envErr.Error()
|
||||
logger.Warnf("Beginner wallet env persistence warning for user %s: %v", userID, envErr)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
|
||||
return
|
||||
}
|
||||
claw402Status := checkClaw402Health()
|
||||
|
||||
models, err := s.store.AIModel().List(userID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to load current beginner wallet for user %s: %v", userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load current wallet"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
if model == nil || model.Provider != "claw402" {
|
||||
continue
|
||||
}
|
||||
|
||||
privateKey := strings.TrimSpace(model.APIKey.String())
|
||||
if privateKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
address, addrErr := walletAddressFromPrivateKey(privateKey)
|
||||
if addrErr != nil {
|
||||
logger.Warnf("Failed to derive current beginner wallet for user %s: %v", userID, addrErr)
|
||||
continue
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: true,
|
||||
Address: address,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
Source: "model",
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
address := strings.TrimSpace(os.Getenv("CLAW402_WALLET_ADDRESS"))
|
||||
if address != "" {
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: true,
|
||||
Address: address,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
Source: "env",
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: false,
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) resolveBeginnerWallet(userID string) (privateKey string, address string, configuredModelID string, reused bool, err error) {
|
||||
// 1. Check if current user already has a claw402 wallet
|
||||
models, err := s.store.AIModel().List(userID)
|
||||
if err != nil {
|
||||
return "", "", "", false, err
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
if model == nil || model.Provider != "claw402" {
|
||||
continue
|
||||
}
|
||||
existingKey := strings.TrimSpace(model.APIKey.String())
|
||||
if existingKey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
addr, addrErr := walletAddressFromPrivateKey(existingKey)
|
||||
if addrErr != nil {
|
||||
logger.Warnf("Existing claw402 key for user %s is invalid, regenerating: %v", userID, addrErr)
|
||||
break
|
||||
}
|
||||
|
||||
return existingKey, addr, model.ID, true, nil
|
||||
}
|
||||
|
||||
// 2. Check for orphan claw402 wallet from a previous account (e.g. after account reset).
|
||||
// Adopt it to preserve funds.
|
||||
orphan, orphanErr := s.store.AIModel().FindOrphanClaw402()
|
||||
if orphanErr == nil && orphan != nil {
|
||||
existingKey := strings.TrimSpace(orphan.APIKey.String())
|
||||
if existingKey != "" {
|
||||
addr, addrErr := walletAddressFromPrivateKey(existingKey)
|
||||
if addrErr == nil {
|
||||
if adoptErr := s.store.AIModel().AdoptModel(orphan.ID, userID); adoptErr != nil {
|
||||
logger.Warnf("Failed to adopt orphan claw402 wallet for user %s: %v", userID, adoptErr)
|
||||
} else {
|
||||
logger.Infof("✓ Adopted orphan claw402 wallet %s for new user %s (address: %s)", orphan.ID, userID, addr)
|
||||
return existingKey, addr, orphan.ID, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No existing wallet found — generate a new one
|
||||
privateKeyObj, genErr := gethcrypto.GenerateKey()
|
||||
if genErr != nil {
|
||||
return "", "", "", false, genErr
|
||||
}
|
||||
|
||||
addr := gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey)
|
||||
keyHex := "0x" + hex.EncodeToString(gethcrypto.FromECDSA(privateKeyObj))
|
||||
return keyHex, addr.Hex(), "", false, nil
|
||||
}
|
||||
|
||||
func (s *Server) findConfiguredClaw402ModelID(userID string) (string, error) {
|
||||
models, err := s.store.AIModel().List(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
if model != nil && model.Provider == "claw402" {
|
||||
return model.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("claw402 model not found")
|
||||
}
|
||||
|
||||
func walletAddressFromPrivateKey(privateKey string) (string, error) {
|
||||
key := strings.TrimSpace(privateKey)
|
||||
if !strings.HasPrefix(key, "0x") {
|
||||
return "", fmt.Errorf("private key must start with 0x")
|
||||
}
|
||||
if len(key) != 66 {
|
||||
return "", fmt.Errorf("private key must be 66 characters")
|
||||
}
|
||||
|
||||
privateKeyObj, err := gethcrypto.HexToECDSA(strings.TrimPrefix(key, "0x"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey).Hex(), nil
|
||||
}
|
||||
|
||||
func persistBeginnerWalletEnv(privateKey string, address string) (bool, string, error) {
|
||||
paths := uniqueEnvPaths([]string{
|
||||
".env",
|
||||
filepath.Join(".", ".env"),
|
||||
"/app/.env",
|
||||
})
|
||||
|
||||
var lastErr error
|
||||
for _, path := range paths {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := upsertEnvFile(path, map[string]string{
|
||||
"CLAW402_WALLET_KEY": privateKey,
|
||||
"CLAW402_WALLET_ADDRESS": address,
|
||||
"CLAW402_DEFAULT_MODEL": "glm-5",
|
||||
}); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
return true, path, nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no writable .env path found")
|
||||
}
|
||||
return false, "", lastErr
|
||||
}
|
||||
|
||||
func uniqueEnvPaths(paths []string) []string {
|
||||
seen := make(map[string]struct{}, len(paths))
|
||||
result := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
clean := filepath.Clean(path)
|
||||
if _, ok := seen[clean]; ok {
|
||||
continue
|
||||
}
|
||||
seen[clean] = struct{}{}
|
||||
result = append(result, clean)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func upsertEnvFile(path string, values map[string]string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingLines := make([]string, 0)
|
||||
if file, err := os.Open(path); err == nil {
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
existingLines = append(existingLines, scanner.Text())
|
||||
}
|
||||
file.Close()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
remaining := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
remaining[key] = value
|
||||
}
|
||||
|
||||
updatedLines := make([]string, 0, len(existingLines)+len(values))
|
||||
for _, line := range existingLines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") || !strings.Contains(line, "=") {
|
||||
updatedLines = append(updatedLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value, ok := remaining[key]
|
||||
if !ok {
|
||||
updatedLines = append(updatedLines, line)
|
||||
continue
|
||||
}
|
||||
|
||||
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
|
||||
delete(remaining, key)
|
||||
}
|
||||
|
||||
for key, value := range remaining {
|
||||
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
|
||||
content := strings.Join(updatedLines, "\n")
|
||||
if content != "" && !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -8,18 +9,9 @@ import (
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
"nofx/trader"
|
||||
"nofx/trader/aster"
|
||||
"nofx/trader/binance"
|
||||
"nofx/trader/bitget"
|
||||
"nofx/trader/bybit"
|
||||
"nofx/trader/gate"
|
||||
hyperliquidtrader "nofx/trader/hyperliquid"
|
||||
"nofx/trader/kucoin"
|
||||
"nofx/trader/lighter"
|
||||
"nofx/trader/okx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AI trader management related structures
|
||||
@@ -62,22 +54,265 @@ type UpdateTraderRequest struct {
|
||||
SystemPromptTemplate string `json:"system_prompt_template"`
|
||||
}
|
||||
|
||||
func formatTraderCreationError(reason, nextStep string) string {
|
||||
if nextStep == "" {
|
||||
return fmt.Sprintf("这次未能创建机器人:%s。", reason)
|
||||
}
|
||||
return fmt.Sprintf("这次未能创建机器人:%s。%s。", reason, nextStep)
|
||||
}
|
||||
|
||||
func traderCreationRequestError(reason string) string {
|
||||
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
|
||||
}
|
||||
|
||||
func exchangeDisplayName(exchange *store.Exchange) string {
|
||||
if exchange == nil {
|
||||
return "所选交易所账户"
|
||||
}
|
||||
if exchange.AccountName != "" {
|
||||
return fmt.Sprintf("%s(%s)", exchange.Name, exchange.AccountName)
|
||||
}
|
||||
if exchange.Name != "" {
|
||||
return exchange.Name
|
||||
}
|
||||
return "所选交易所账户"
|
||||
}
|
||||
|
||||
func missingExchangeFields(exchange *store.Exchange) []string {
|
||||
if exchange == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var missing []string
|
||||
switch exchange.ExchangeType {
|
||||
case "binance", "bybit", "gate", "indodax":
|
||||
if exchange.APIKey == "" {
|
||||
missing = append(missing, "API Key")
|
||||
}
|
||||
if exchange.SecretKey == "" {
|
||||
missing = append(missing, "Secret Key")
|
||||
}
|
||||
case "okx", "bitget", "kucoin":
|
||||
if exchange.APIKey == "" {
|
||||
missing = append(missing, "API Key")
|
||||
}
|
||||
if exchange.SecretKey == "" {
|
||||
missing = append(missing, "Secret Key")
|
||||
}
|
||||
if exchange.Passphrase == "" {
|
||||
missing = append(missing, "Passphrase")
|
||||
}
|
||||
case "hyperliquid":
|
||||
if exchange.APIKey == "" {
|
||||
missing = append(missing, "私钥")
|
||||
}
|
||||
if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" {
|
||||
missing = append(missing, "钱包地址")
|
||||
}
|
||||
case "aster":
|
||||
if strings.TrimSpace(exchange.AsterUser) == "" {
|
||||
missing = append(missing, "Aster User")
|
||||
}
|
||||
if strings.TrimSpace(exchange.AsterSigner) == "" {
|
||||
missing = append(missing, "Aster Signer")
|
||||
}
|
||||
if exchange.AsterPrivateKey == "" {
|
||||
missing = append(missing, "Aster Private Key")
|
||||
}
|
||||
case "lighter":
|
||||
if strings.TrimSpace(exchange.LighterWalletAddr) == "" {
|
||||
missing = append(missing, "钱包地址")
|
||||
}
|
||||
if exchange.LighterAPIKeyPrivateKey == "" {
|
||||
missing = append(missing, "API Key Private Key")
|
||||
}
|
||||
}
|
||||
|
||||
return missing
|
||||
}
|
||||
|
||||
func mapStringPairs(kv ...string) map[string]string {
|
||||
if len(kv) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
params := make(map[string]string, len(kv)/2)
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
params[kv[i]] = kv[i+1]
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string, map[string]string) {
|
||||
if exchange == nil {
|
||||
return formatTraderCreationError("还没有找到你选择的交易所账户", "请前往「设置 > 交易所配置」先添加一个可用账户,再回来创建机器人"),
|
||||
"trader.create.exchange_not_found", nil
|
||||
}
|
||||
if !exchange.Enabled {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」目前处于未启用状态", exchangeDisplayName(exchange)),
|
||||
"请前往「设置 > 交易所配置」启用该账户后,再重新创建机器人",
|
||||
), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange))
|
||||
}
|
||||
|
||||
missing := missingExchangeFields(exchange)
|
||||
if len(missing) > 0 {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」的配置还不完整,缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
|
||||
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
|
||||
), "trader.create.exchange_missing_fields", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"missing_fields", strings.Join(missing, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
switch exchange.ExchangeType {
|
||||
case "binance", "bybit", "okx", "bitget", "gate", "kucoin", "hyperliquid", "aster", "lighter", "indodax":
|
||||
return "", "", nil
|
||||
default:
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
|
||||
"请改用当前版本支持的交易所账户后,再重新创建机器人",
|
||||
), "trader.create.exchange_unsupported", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"exchange_type", exchange.ExchangeType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func classifyTraderSetupReason(reason string) (string, string) {
|
||||
trimmed := strings.TrimSpace(reason)
|
||||
if trimmed == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
|
||||
switch {
|
||||
case strings.Contains(lower, "failed to parse strategy config"),
|
||||
strings.Contains(lower, "failed to parse strategy configuration"):
|
||||
return "trader.reason.strategy_config_invalid", "当前策略配置内容已损坏,系统暂时无法解析"
|
||||
case strings.Contains(lower, "has no strategy configured"):
|
||||
return "trader.reason.strategy_missing", "当前机器人缺少有效的交易策略配置"
|
||||
case strings.Contains(lower, "failed to parse private key"),
|
||||
(strings.Contains(lower, "invalid hex character") && strings.Contains(lower, "private key")):
|
||||
return "trader.reason.private_key_invalid", "私钥格式不正确,系统无法识别"
|
||||
case strings.Contains(lower, "failed to initialize hyperliquid trader"):
|
||||
return "trader.reason.hyperliquid_init_failed", "Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确"
|
||||
case strings.Contains(lower, "failed to initialize aster trader"):
|
||||
return "trader.reason.aster_init_failed", "Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确"
|
||||
case strings.Contains(lower, "failed to get meta information"):
|
||||
return "trader.reason.exchange_meta_unavailable", "系统暂时无法从交易所读取账户元信息"
|
||||
case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"):
|
||||
return "trader.reason.hyperliquid_agent_balance_too_high", "Hyperliquid Agent Wallet 余额过高,不符合当前安全要求"
|
||||
case strings.Contains(lower, "failed to initialize account"):
|
||||
return "trader.reason.exchange_account_init_failed", "交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配"
|
||||
case strings.Contains(lower, "unsupported trading platform"):
|
||||
return "trader.reason.exchange_unsupported", "当前交易所类型暂不支持机器人初始化"
|
||||
case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"):
|
||||
return "trader.reason.exchange_balance_unavailable", "系统暂时无法从交易所读取账户余额"
|
||||
case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"):
|
||||
return "trader.reason.exchange_service_unreachable", "系统暂时无法连接交易所服务"
|
||||
default:
|
||||
return "trader.reason.unknown", trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func humanizeTraderSetupReason(reason string) string {
|
||||
_, message := classifyTraderSetupReason(reason)
|
||||
return message
|
||||
}
|
||||
|
||||
func traderSetupReasonParams(err error, fallback string, kv ...string) map[string]string {
|
||||
params := mapStringPairs(kv...)
|
||||
rawReason := SanitizeError(err, fallback)
|
||||
reasonKey, reasonMessage := classifyTraderSetupReason(rawReason)
|
||||
if reasonMessage == "" && fallback != "" {
|
||||
reasonMessage = fallback
|
||||
}
|
||||
if reasonMessage != "" {
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
params["reason"] = reasonMessage
|
||||
}
|
||||
if reasonKey != "" {
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
params["reason_key"] = reasonKey
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func describeTraderLoadError(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return formatTraderCreationError("机器人配置虽然保存了,但运行实例没有成功初始化", "请检查模型、策略和交易所配置是否完整,然后再试一次")
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动", traderName),
|
||||
"请检查模型、策略和交易所配置是否完整,然后再试一次",
|
||||
)
|
||||
}
|
||||
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动,原因是:%s", traderName, reason),
|
||||
"请检查模型、策略和交易所配置是否完整,然后再试一次",
|
||||
)
|
||||
}
|
||||
|
||||
func describeTraderCreationWarning(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前还没有通过启动前校验。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName)
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动,原因是:%s。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName, reason)
|
||||
}
|
||||
|
||||
func describeTraderStartError(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName)
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动,原因是:%s。请检查模型、策略和交易所配置后,再重新点击启动。", traderName, reason)
|
||||
}
|
||||
|
||||
func formatTraderStartError(reason, nextStep string) string {
|
||||
if nextStep == "" {
|
||||
return fmt.Sprintf("这次未能启动机器人:%s。", reason)
|
||||
}
|
||||
return fmt.Sprintf("这次未能启动机器人:%s。%s。", reason, nextStep)
|
||||
}
|
||||
|
||||
// handleCreateTrader Create new AI trader
|
||||
func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
var req CreateTraderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("提交的信息不完整,或者格式不正确"), "trader.create.invalid_request", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate leverage values
|
||||
if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "BTC/ETH leverage must be between 1-50x"})
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 50 倍之间"), "trader.create.invalid_btc_eth_leverage", nil)
|
||||
return
|
||||
}
|
||||
if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Altcoin leverage must be between 1-20x"})
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,12 +322,61 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
for _, symbol := range symbols {
|
||||
symbol = strings.TrimSpace(symbol)
|
||||
if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid symbol format: %s, must end with USDT", symbol)})
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError(
|
||||
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持以 USDT 结尾的合约交易对", symbol),
|
||||
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
model, err := s.store.AIModel().Get(userID, req.AIModelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("还没有找到你选择的 AI 模型", "请前往「设置 > 模型配置」先添加并启用一个可用模型,再回来创建机器人"), "trader.create.model_not_found", nil)
|
||||
return
|
||||
}
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你的 AI 模型配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if !model.Enabled {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("AI 模型「%s」目前还没有启用", model.Name),
|
||||
"请前往「设置 > 模型配置」启用它后,再重新创建机器人",
|
||||
), "trader.create.model_disabled", mapStringPairs("model_name", model.Name))
|
||||
return
|
||||
}
|
||||
if model.APIKey == "" {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("AI 模型「%s」缺少 API Key 或支付凭证", model.Name),
|
||||
"请前往「设置 > 模型配置」补全模型凭证后,再重新创建机器人",
|
||||
), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name))
|
||||
return
|
||||
}
|
||||
|
||||
if req.StrategyID == "" {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("你还没有选择交易策略", "请先选择一个策略,再继续创建机器人"), "trader.create.strategy_required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.StrategyID != "" {
|
||||
_, err = s.store.Strategy().Get(userID, req.StrategyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("你选择的策略不存在,或者已经被删除了", "请重新选择一个可用策略后,再继续创建机器人"), "trader.create.strategy_not_found", nil)
|
||||
return
|
||||
}
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你选择的策略配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate trader ID (use short UUID prefix for readability)
|
||||
exchangeIDShort := req.ExchangeID
|
||||
if len(exchangeIDShort) > 8 {
|
||||
@@ -137,7 +421,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
actualBalance := req.InitialBalance // Default to use user input
|
||||
exchanges, err := s.store.Exchange().List(userID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get exchange config, using user input for initial balance: %v", err)
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你的交易所配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Find matching exchange configuration
|
||||
@@ -149,97 +437,32 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if exchangeCfg == nil {
|
||||
logger.Infof("⚠️ Exchange %s configuration not found, using user input for initial balance", req.ExchangeID)
|
||||
} else if !exchangeCfg.Enabled {
|
||||
logger.Infof("⚠️ Exchange %s not enabled, using user input for initial balance", req.ExchangeID)
|
||||
} else {
|
||||
// Create temporary trader based on exchange type to query balance
|
||||
var tempTrader trader.Trader
|
||||
var createErr error
|
||||
|
||||
// Use ExchangeType (e.g., "binance") instead of ID (UUID)
|
||||
// Convert EncryptedString fields to string
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance":
|
||||
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
|
||||
case "hyperliquid":
|
||||
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
|
||||
string(exchangeCfg.APIKey), // private key
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
exchangeCfg.HyperliquidUnifiedAcct,
|
||||
)
|
||||
case "aster":
|
||||
tempTrader, createErr = aster.NewAsterTrader(
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
string(exchangeCfg.AsterPrivateKey),
|
||||
)
|
||||
case "bybit":
|
||||
tempTrader = bybit.NewBybitTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
)
|
||||
case "okx":
|
||||
tempTrader = okx.NewOKXTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "bitget":
|
||||
tempTrader = bitget.NewBitgetTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "gate":
|
||||
tempTrader = gate.NewGateTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
)
|
||||
case "kucoin":
|
||||
tempTrader = kucoin.NewKuCoinTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
|
||||
// Lighter only supports mainnet
|
||||
tempTrader, createErr = lighter.NewLighterTraderV2(
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||||
exchangeCfg.LighterAPIKeyIndex,
|
||||
false, // Always use mainnet for Lighter
|
||||
)
|
||||
} else {
|
||||
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
|
||||
}
|
||||
default:
|
||||
logger.Infof("⚠️ Unsupported exchange type: %s, using user input for initial balance", exchangeCfg.ExchangeType)
|
||||
}
|
||||
if exchangeMsg, exchangeErrorKey, exchangeErrorParams := validateExchangeForTraderCreation(exchangeCfg); exchangeMsg != "" {
|
||||
SafeBadRequestWithDetails(c, exchangeMsg, exchangeErrorKey, exchangeErrorParams)
|
||||
return
|
||||
}
|
||||
|
||||
{
|
||||
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
|
||||
if createErr != nil {
|
||||
logger.Infof("⚠️ Failed to create temporary trader, using user input for initial balance: %v", createErr)
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」没有通过初始化校验,原因是:%s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "配置校验未通过"))),
|
||||
"请前往「设置 > 交易所配置」检查这个账户的密钥、地址和账户信息是否填写正确",
|
||||
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "配置校验未通过",
|
||||
"exchange_name", exchangeDisplayName(exchangeCfg),
|
||||
))
|
||||
return
|
||||
} else if tempTrader != nil {
|
||||
// Query actual balance
|
||||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
||||
if balanceErr != nil {
|
||||
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
|
||||
} else {
|
||||
// Extract total equity (account total value = wallet balance + unrealized PnL)
|
||||
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
|
||||
// Note: Must use total_equity (not availableBalance) for accurate P&L calculation
|
||||
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
|
||||
for _, key := range balanceKeys {
|
||||
if balance, ok := balanceInfo[key].(float64); ok && balance > 0 {
|
||||
actualBalance = balance
|
||||
logger.Infof("✓ Queried exchange total equity (%s): %.2f USDT (user input: %.2f USDT)", key, actualBalance, req.InitialBalance)
|
||||
break
|
||||
}
|
||||
}
|
||||
if actualBalance <= 0 {
|
||||
if extractedBalance, found := extractExchangeTotalEquity(balanceInfo); found {
|
||||
actualBalance = extractedBalance
|
||||
logger.Infof("✓ Queried exchange total equity: %.2f %s (user input: %.2f)",
|
||||
actualBalance, accountAssetForExchange(exchangeCfg.ExchangeType), req.InitialBalance)
|
||||
} else {
|
||||
logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
|
||||
}
|
||||
}
|
||||
@@ -275,27 +498,48 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
err = s.store.Trader().Create(traderRecord)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to create trader: %v", err)
|
||||
SafeInternalError(c, "Failed to create trader", err)
|
||||
publicMsg := SanitizeError(err, formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次"))
|
||||
statusCode := http.StatusBadRequest
|
||||
if publicMsg == formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次") {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
SafeError(c, statusCode, publicMsg, err)
|
||||
return
|
||||
}
|
||||
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
|
||||
|
||||
// Immediately load new trader into TraderManager
|
||||
logger.Infof("🔧 DEBUG: Preparing to call LoadUserTraders")
|
||||
startupWarning := ""
|
||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to load user traders into memory: %v", err)
|
||||
// Don't return error here since trader was successfully created in database
|
||||
startupWarning = describeTraderCreationWarning(req.Name, err)
|
||||
}
|
||||
logger.Infof("🔧 DEBUG: LoadUserTraders completed")
|
||||
|
||||
if startupWarning == "" {
|
||||
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
|
||||
logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr)
|
||||
startupWarning = describeTraderCreationWarning(req.Name, loadErr)
|
||||
}
|
||||
}
|
||||
|
||||
if startupWarning == "" {
|
||||
if _, getErr := s.traderManager.GetTrader(traderID); getErr != nil {
|
||||
logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr)
|
||||
startupWarning = describeTraderCreationWarning(req.Name, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"trader_id": traderID,
|
||||
"trader_name": req.Name,
|
||||
"ai_model": req.AIModelID,
|
||||
"is_running": false,
|
||||
"trader_id": traderID,
|
||||
"trader_name": req.Name,
|
||||
"ai_model": req.AIModelID,
|
||||
"is_running": false,
|
||||
"startup_warning": startupWarning,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -373,6 +617,17 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
strategyID = existingTrader.StrategyID
|
||||
}
|
||||
|
||||
exchangeChanged := req.ExchangeID != "" && req.ExchangeID != existingTrader.ExchangeID
|
||||
resetInitialBalance := exchangeChanged && req.InitialBalance <= 0
|
||||
|
||||
initialBalance := existingTrader.InitialBalance
|
||||
if req.InitialBalance > 0 {
|
||||
initialBalance = req.InitialBalance
|
||||
}
|
||||
if resetInitialBalance {
|
||||
initialBalance = 0
|
||||
}
|
||||
|
||||
// Update trader configuration
|
||||
traderRecord := &store.Trader{
|
||||
ID: traderID,
|
||||
@@ -381,7 +636,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
AIModelID: req.AIModelID,
|
||||
ExchangeID: req.ExchangeID,
|
||||
StrategyID: strategyID, // Associated strategy ID
|
||||
InitialBalance: req.InitialBalance,
|
||||
InitialBalance: initialBalance,
|
||||
BTCETHLeverage: btcEthLeverage,
|
||||
AltcoinLeverage: altcoinLeverage,
|
||||
TradingSymbols: req.TradingSymbols,
|
||||
@@ -413,6 +668,14 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if resetInitialBalance {
|
||||
logger.Infof("🔄 Exchange changed for trader %s, resetting stale initial_balance to 0", traderID)
|
||||
if err := s.store.Trader().UpdateInitialBalance(userID, traderID, 0); err != nil {
|
||||
SafeInternalError(c, "Failed to reset trader initial balance", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old trader from memory first (this also stops if running)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
|
||||
@@ -478,11 +741,15 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
traderID := c.Param("id")
|
||||
|
||||
// Verify trader belongs to current user
|
||||
_, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
|
||||
return
|
||||
}
|
||||
traderName := traderID
|
||||
if fullCfg != nil && fullCfg.Trader != nil && fullCfg.Trader.Name != "" {
|
||||
traderName = fullCfg.Trader.Name
|
||||
}
|
||||
|
||||
// Check if trader exists in memory and if it's running
|
||||
existingTrader, _ := s.traderManager.GetTrader(traderID)
|
||||
@@ -501,45 +768,49 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
logger.Infof("🔄 Loading trader %s from database...", traderID)
|
||||
if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil {
|
||||
logger.Infof("❌ Failed to load user traders: %v", loadErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load trader: " + loadErr.Error()})
|
||||
SafeErrorWithDetails(c, http.StatusInternalServerError, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName), loadErr)
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
// Check detailed reason
|
||||
fullCfg, _ := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if fullCfg != nil && fullCfg.Trader != nil {
|
||||
// Check strategy
|
||||
if fullCfg.Strategy == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader has no strategy configured, please create a strategy in Strategy Studio and associate it with the trader"})
|
||||
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, fmt.Errorf("trader has no strategy configured")), "trader.start.strategy_missing", mapStringPairs("trader_name", traderName))
|
||||
return
|
||||
}
|
||||
// Check AI model
|
||||
if fullCfg.AIModel == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model does not exist, please check AI model configuration"})
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的 AI 模型不存在", "请前往「设置 > 模型配置」检查后,再重新点击启动"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
|
||||
return
|
||||
}
|
||||
if !fullCfg.AIModel.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model is not enabled, please enable the AI model first"})
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("机器人「%s」关联的 AI 模型「%s」目前还没有启用", traderName, fullCfg.AIModel.Name),
|
||||
"请前往「设置 > 模型配置」启用它后,再重新点击启动",
|
||||
), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name))
|
||||
return
|
||||
}
|
||||
// Check exchange
|
||||
if fullCfg.Exchange == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange does not exist, please check exchange configuration"})
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的交易所账户不存在", "请前往「设置 > 交易所配置」检查后,再重新点击启动"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
|
||||
return
|
||||
}
|
||||
if !fullCfg.Exchange.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange is not enabled, please enable the exchange first"})
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("机器人「%s」关联的交易所账户「%s」目前还没有启用", traderName, exchangeDisplayName(fullCfg.Exchange)),
|
||||
"请前往「设置 > 交易所配置」启用它后,再重新点击启动",
|
||||
), "trader.start.exchange_disabled", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
|
||||
return
|
||||
}
|
||||
}
|
||||
// Check if there's a specific load error
|
||||
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load trader: " + loadErr.Error()})
|
||||
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Failed to load trader, please check AI model, exchange and strategy configuration"})
|
||||
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, err), "trader.start.setup_invalid", traderSetupReasonParams(err, "", "trader_name", traderName))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -58,73 +58,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create temporary trader to query balance
|
||||
var tempTrader trader.Trader
|
||||
var createErr error
|
||||
|
||||
// Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID)
|
||||
// Convert EncryptedString fields to string
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance":
|
||||
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
|
||||
case "hyperliquid":
|
||||
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
exchangeCfg.HyperliquidUnifiedAcct,
|
||||
)
|
||||
case "aster":
|
||||
tempTrader, createErr = aster.NewAsterTrader(
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
string(exchangeCfg.AsterPrivateKey),
|
||||
)
|
||||
case "bybit":
|
||||
tempTrader = bybit.NewBybitTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
)
|
||||
case "okx":
|
||||
tempTrader = okx.NewOKXTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "bitget":
|
||||
tempTrader = bitget.NewBitgetTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "gate":
|
||||
tempTrader = gate.NewGateTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
)
|
||||
case "kucoin":
|
||||
tempTrader = kucoin.NewKuCoinTrader(
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
)
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
|
||||
// Lighter only supports mainnet
|
||||
tempTrader, createErr = lighter.NewLighterTraderV2(
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||||
exchangeCfg.LighterAPIKeyIndex,
|
||||
false, // Always use mainnet for Lighter
|
||||
)
|
||||
} else {
|
||||
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
|
||||
return
|
||||
}
|
||||
|
||||
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
|
||||
if createErr != nil {
|
||||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
||||
SafeInternalError(c, "Failed to connect to exchange", createErr)
|
||||
@@ -140,20 +74,14 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Extract total equity (for P&L calculation, we need total account value, not available balance)
|
||||
var actualBalance float64
|
||||
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
|
||||
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
|
||||
for _, key := range balanceKeys {
|
||||
if balance, ok := balanceInfo[key].(float64); ok && balance > 0 {
|
||||
actualBalance = balance
|
||||
break
|
||||
}
|
||||
}
|
||||
if actualBalance <= 0 {
|
||||
actualBalance, found := extractExchangeTotalEquity(balanceInfo)
|
||||
if !found {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
|
||||
return
|
||||
}
|
||||
|
||||
s.exchangeAccountStateCache.Invalidate(userID)
|
||||
|
||||
oldBalance := traderConfig.InitialBalance
|
||||
|
||||
// Smart balance change detection
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// handleLogout Add current token to blacklist
|
||||
@@ -59,6 +61,7 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -66,6 +69,11 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
lang := req.Lang
|
||||
if lang != "zh" && lang != "id" {
|
||||
lang = "en"
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
_, err = s.store.User().GetByEmail(req.Email)
|
||||
if err == nil {
|
||||
@@ -94,6 +102,10 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Adopt orphan records from previous account (e.g. after account reset)
|
||||
// This preserves wallet keys and exchange configs so funds are not lost.
|
||||
s.adoptOrphanRecords(userID)
|
||||
|
||||
// Generate JWT token
|
||||
token, err := auth.GenerateJWT(user.ID, user.Email)
|
||||
if err != nil {
|
||||
@@ -102,7 +114,7 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Initialize default model and exchange configs for user
|
||||
err = s.initUserDefaultConfigs(user.ID)
|
||||
err = s.initUserDefaultConfigs(user.ID, lang)
|
||||
if err != nil {
|
||||
logger.Infof("Failed to initialize user default configs: %v", err)
|
||||
}
|
||||
@@ -214,10 +226,172 @@ func (s *Server) handleResetPassword(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"})
|
||||
}
|
||||
|
||||
// initUserDefaultConfigs Initialize default model and exchange configs for new user
|
||||
func (s *Server) initUserDefaultConfigs(userID string) error {
|
||||
// Commented out auto-creation of default configs, let users add manually
|
||||
// This way new users won't have config items automatically after registration
|
||||
logger.Infof("User %s registration completed, waiting for manual AI model and exchange configuration", userID)
|
||||
// handleResetAccount clears user authentication data so the system returns to
|
||||
// uninitialized state for re-registration. Wallet keys (ai_models) are preserved
|
||||
// so funds are not lost — they will be adopted by the new account during onboarding.
|
||||
func (s *Server) handleResetAccount(c *gin.Context) {
|
||||
err := s.store.Transaction(func(tx *gorm.DB) error {
|
||||
// Delete traders and strategies (config, not funds)
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
|
||||
// Delete users — ai_models and exchanges are intentionally kept
|
||||
// so wallet private keys and exchange configs survive re-registration
|
||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete users: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to reset account", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✓ User accounts cleared (wallets preserved) — system reset to uninitialized")
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Account reset successful, you can now register a new account"})
|
||||
}
|
||||
|
||||
// adoptOrphanRecords re-assigns ai_models and exchanges whose user_id no longer
|
||||
// exists in the users table. This happens after account reset so the new user
|
||||
// inherits the previous wallet keys and exchange configurations.
|
||||
func (s *Server) adoptOrphanRecords(newUserID string) {
|
||||
db := s.store.GormDB()
|
||||
result := db.Model(&store.AIModel{}).
|
||||
Where("user_id NOT IN (SELECT id FROM users)").
|
||||
Update("user_id", newUserID)
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Infof("✓ Adopted %d orphan ai_model(s) for new user %s", result.RowsAffected, newUserID)
|
||||
}
|
||||
|
||||
result = db.Model(&store.Exchange{}).
|
||||
Where("user_id NOT IN (SELECT id FROM users)").
|
||||
Update("user_id", newUserID)
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Infof("✓ Adopted %d orphan exchange(s) for new user %s", result.RowsAffected, newUserID)
|
||||
}
|
||||
}
|
||||
|
||||
// initUserDefaultConfigs Initialize default configs for new user
|
||||
func (s *Server) initUserDefaultConfigs(userID string, lang string) error {
|
||||
if err := s.createDefaultStrategies(userID, lang); err != nil {
|
||||
logger.Warnf("Failed to create default strategies for user %s: %v", userID, err)
|
||||
// Non-fatal: user can create strategies manually
|
||||
}
|
||||
logger.Infof("✓ User %s registration completed with default strategies", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
type strategyI18n struct {
|
||||
name, description string
|
||||
}
|
||||
type strategyLocale struct {
|
||||
balanced, conservative, aggressive strategyI18n
|
||||
}
|
||||
locales := map[string]strategyLocale{
|
||||
"zh": {
|
||||
balanced: strategyI18n{"均衡策略", "系统默认策略。均衡风险收益,适合大多数市场环境。5倍杠杆,最多3个仓位。"},
|
||||
conservative: strategyI18n{"稳健策略", "系统默认策略。低杠杆保守操作,优先保护本金。3倍杠杆,专注主流资产。"},
|
||||
aggressive: strategyI18n{"积极策略", "系统默认策略。高杠杆主动交易,更广泛的币种选择,适合经验丰富的交易者。10倍杠杆,最多5个仓位。"},
|
||||
},
|
||||
"en": {
|
||||
balanced: strategyI18n{"Balanced Strategy", "System default strategy. Balanced risk-reward, suitable for most market conditions. 5x leverage, up to 3 positions."},
|
||||
conservative: strategyI18n{"Conservative Strategy", "System default strategy. Low-leverage conservative trading, capital preservation first. 3x leverage, focused on major assets."},
|
||||
aggressive: strategyI18n{"Aggressive Strategy", "System default strategy. High-leverage active trading, wider asset selection, for experienced traders. 10x leverage, up to 5 positions."},
|
||||
},
|
||||
"id": {
|
||||
balanced: strategyI18n{"Strategi Seimbang", "Strategi default sistem. Risiko-reward seimbang, cocok untuk sebagian besar kondisi pasar. Leverage 5x, hingga 3 posisi."},
|
||||
conservative: strategyI18n{"Strategi Konservatif", "Strategi default sistem. Trading konservatif leverage rendah, utamakan perlindungan modal. Leverage 3x, fokus aset utama."},
|
||||
aggressive: strategyI18n{"Strategi Agresif", "Strategi default sistem. Trading aktif leverage tinggi, pilihan aset lebih luas, untuk trader berpengalaman. Leverage 10x, hingga 5 posisi."},
|
||||
},
|
||||
}
|
||||
locale, ok := locales[lang]
|
||||
if !ok {
|
||||
locale = locales["en"]
|
||||
}
|
||||
|
||||
type strategyDef struct {
|
||||
name string
|
||||
description string
|
||||
isActive bool
|
||||
applyConfig func(*store.StrategyConfig)
|
||||
}
|
||||
|
||||
definitions := []strategyDef{
|
||||
{
|
||||
name: locale.balanced.name,
|
||||
description: locale.balanced.description,
|
||||
isActive: true,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
// Uses default config as-is
|
||||
},
|
||||
},
|
||||
{
|
||||
name: locale.conservative.name,
|
||||
description: locale.conservative.description,
|
||||
isActive: false,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
c.RiskControl.BTCETHMaxLeverage = 3
|
||||
c.RiskControl.AltcoinMaxLeverage = 3
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 3.0
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 0.5
|
||||
c.RiskControl.MinConfidence = 80
|
||||
c.RiskControl.MinRiskRewardRatio = 4.0
|
||||
c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"}
|
||||
c.Indicators.Klines.PrimaryTimeframe = "15m"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: locale.aggressive.name,
|
||||
description: locale.aggressive.description,
|
||||
isActive: false,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
c.RiskControl.BTCETHMaxLeverage = 10
|
||||
c.RiskControl.AltcoinMaxLeverage = 7
|
||||
c.RiskControl.MaxPositions = 5
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 2.0
|
||||
c.RiskControl.MinConfidence = 70
|
||||
c.CoinSource.AI500Limit = 5
|
||||
c.CoinSource.UseOITop = true
|
||||
c.CoinSource.OITopLimit = 5
|
||||
c.Indicators.Klines.SelectedTimeframes = []string{"3m", "15m", "1h"}
|
||||
c.Indicators.Klines.PrimaryTimeframe = "3m"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// GetDefaultStrategyConfig only supports zh/en; map id -> en
|
||||
configLang := lang
|
||||
if lang == "id" {
|
||||
configLang = "en"
|
||||
}
|
||||
|
||||
// Pre-build all strategy objects before opening the transaction
|
||||
var strategies []*store.Strategy
|
||||
for _, def := range definitions {
|
||||
config := store.GetDefaultStrategyConfig(configLang)
|
||||
def.applyConfig(&config)
|
||||
|
||||
strategy := &store.Strategy{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: def.name,
|
||||
Description: def.description,
|
||||
IsActive: def.isActive,
|
||||
IsDefault: false,
|
||||
}
|
||||
if err := strategy.SetConfig(&config); err != nil {
|
||||
return fmt.Errorf("failed to set config for strategy %q: %w", def.name, err)
|
||||
}
|
||||
strategies = append(strategies, strategy)
|
||||
}
|
||||
|
||||
return s.store.Transaction(func(tx *gorm.DB) error {
|
||||
for _, strategy := range strategies {
|
||||
if err := tx.Create(strategy).Error; err != nil {
|
||||
return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err)
|
||||
}
|
||||
logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,13 +18,14 @@ import (
|
||||
|
||||
// Server HTTP API server
|
||||
type Server struct {
|
||||
router *gin.Engine
|
||||
traderManager *manager.TraderManager
|
||||
store *store.Store
|
||||
cryptoHandler *CryptoHandler
|
||||
httpServer *http.Server
|
||||
port int
|
||||
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
|
||||
router *gin.Engine
|
||||
traderManager *manager.TraderManager
|
||||
store *store.Store
|
||||
cryptoHandler *CryptoHandler
|
||||
exchangeAccountStateCache *ExchangeAccountStateCache
|
||||
httpServer *http.Server
|
||||
port int
|
||||
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
|
||||
}
|
||||
|
||||
// NewServer Creates API server
|
||||
@@ -41,11 +42,12 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
|
||||
cryptoHandler := NewCryptoHandler(cryptoService)
|
||||
|
||||
s := &Server{
|
||||
router: router,
|
||||
traderManager: traderManager,
|
||||
store: st,
|
||||
cryptoHandler: cryptoHandler,
|
||||
port: port,
|
||||
router: router,
|
||||
traderManager: traderManager,
|
||||
store: st,
|
||||
cryptoHandler: cryptoHandler,
|
||||
exchangeAccountStateCache: NewExchangeAccountStateCache(),
|
||||
port: port,
|
||||
}
|
||||
|
||||
// Setup routes
|
||||
@@ -110,17 +112,21 @@ func (s *Server) setupRoutes() {
|
||||
|
||||
// Public strategy market (no authentication required)
|
||||
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
|
||||
s.route(api, "POST", "/strategies/estimate-tokens", "Estimate token usage for a strategy config", s.handleEstimateTokens)
|
||||
|
||||
// Authentication related routes (no authentication required)
|
||||
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
|
||||
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
|
||||
s.route(api, "POST", "/reset-password", "Reset password", s.handleResetPassword)
|
||||
s.route(api, "POST", "/reset-account", "Clear all users and reset system to allow re-registration", s.handleResetAccount)
|
||||
|
||||
// Routes requiring authentication
|
||||
protected := api.Group("/", s.authMiddleware())
|
||||
{
|
||||
// Logout (add to blacklist)
|
||||
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
|
||||
s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding)
|
||||
s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet)
|
||||
|
||||
// User account management
|
||||
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
|
||||
@@ -194,6 +200,10 @@ Defaults when custom fields empty: openai→api.openai.com/v1, deepseek→api.de
|
||||
`Returns: [{"id":"<EXACT id — use this as exchange_id when creating/updating a trader>","exchange_type":"<e.g. okx, binance>","account_name":"<user label>","enabled":<bool>}]
|
||||
CRITICAL: Always use the "id" field for exchange_id. Do not use "exchange_type" as an id.`,
|
||||
s.handleGetExchangeConfigs)
|
||||
s.routeWithSchema(protected, "GET", "/exchanges/account-state", "Get connection and balance state for each exchange account",
|
||||
`Returns: {"states":{"<exchange_id>":{"status":"ok|disabled|missing_credentials|invalid_credentials|permission_denied|unavailable","display_balance":"<string>","total_equity":<number>,"available_balance":<number>,"asset":"USDT|USDC","checked_at":"<RFC3339>","error_code":"<string>","error_message":"<string>"}}}
|
||||
Use this endpoint to show balance and health in the exchange list without depending on traders.`,
|
||||
s.handleGetExchangeAccountStates)
|
||||
s.routeWithSchema(protected, "POST", "/exchanges", "Create a new exchange account",
|
||||
`Body: {"exchange_type":"<string>","account_name":"<string, user label>","enabled":true,"api_key":"<string>","secret_key":"<string>","passphrase":"<string, required for okx/gate/kucoin>"}
|
||||
exchange_type values: "binance","bybit","okx","bitget","gate","kucoin","indodax" (CEX) | "hyperliquid","aster","lighter" (DEX)
|
||||
|
||||
@@ -31,6 +31,20 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
return warnings
|
||||
}
|
||||
|
||||
// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation)
|
||||
func (s *Server) handleEstimateTokens(c *gin.Context) {
|
||||
var req struct {
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
estimate := req.Config.EstimateTokens()
|
||||
c.JSON(http.StatusOK, estimate)
|
||||
}
|
||||
|
||||
// handlePublicStrategies Get public strategies for strategy market (no auth required)
|
||||
func (s *Server) handlePublicStrategies(c *gin.Context) {
|
||||
strategies, err := s.store.Strategy().ListPublic()
|
||||
@@ -150,8 +164,8 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
|
||||
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
|
||||
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
|
||||
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -289,6 +303,25 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Token overflow check — block save if all models exceed context limits
|
||||
if mergedConfig.StrategyType == "" || mergedConfig.StrategyType == "ai_trading" {
|
||||
estimate := mergedConfig.EstimateTokens()
|
||||
allExceed := true
|
||||
for _, ml := range estimate.ModelLimits {
|
||||
if ml.UsagePct <= 100 {
|
||||
allExceed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allExceed && len(estimate.ModelLimits) > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Estimated %d tokens exceeds all known model context limits. Reduce coins, timeframes, or K-line count.", estimate.Total),
|
||||
"token_estimate": estimate,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate merged configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&mergedConfig)
|
||||
|
||||
@@ -311,7 +344,7 @@ func (s *Server) handleDeleteStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().Delete(userID, strategyID); err != nil {
|
||||
SafeInternalError(c, "Failed to delete strategy", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": SanitizeError(err, "Failed to delete strategy")})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -419,9 +452,9 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
AccountEquity float64 `json:"account_equity"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
AccountEquity float64 `json:"account_equity"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -664,4 +697,3 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||
- "6060:6060" # pprof profiling
|
||||
volumes:
|
||||
- ./.env:/app/.env
|
||||
- ./data:/app/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
@@ -49,4 +50,4 @@ services:
|
||||
|
||||
networks:
|
||||
nofx-network:
|
||||
driver: bridge
|
||||
driver: bridge
|
||||
|
||||
@@ -69,7 +69,7 @@ x402 フロー:
|
||||
|
||||
| 機能 | 説明 |
|
||||
|:--------|:------------|
|
||||
| **マルチ AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — いつでも切替 |
|
||||
| **マルチ AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — いつでも切替 |
|
||||
| **マルチ取引所** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **ストラテジースタジオ** | ビジュアルビルダー — コインソース、インジケーター、リスク管理 |
|
||||
| **AI ディベートアリーナ** | 複数 AI が取引を議論(ブル vs ベア vs アナリスト)、投票、実行 |
|
||||
@@ -112,6 +112,7 @@ x402 フロー:
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API キー取得](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API キー取得](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API キー取得](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API キー取得](https://platform.minimaxi.com) |
|
||||
|
||||
### AI モデル (x402 モード — API キー不要)
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ x402 플로우:
|
||||
|
||||
| 기능 | 설명 |
|
||||
|:--------|:------------|
|
||||
| **멀티 AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — 언제든 전환 |
|
||||
| **멀티 AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — 언제든 전환 |
|
||||
| **멀티 거래소** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **전략 스튜디오** | 비주얼 빌더 — 코인 소스, 지표, 리스크 관리 |
|
||||
| **AI 토론 아레나** | 여러 AI가 거래 토론 (강세 vs 약세 vs 분석가), 투표, 실행 |
|
||||
@@ -112,6 +112,7 @@ x402 플로우:
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API 키 받기](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API 키 받기](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API 키 받기](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API 키 받기](https://platform.minimaxi.com) |
|
||||
|
||||
### AI 모델 (x402 모드 — API 키 불필요)
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ x402 процесс:
|
||||
|
||||
| Функция | Описание |
|
||||
|:--------|:------------|
|
||||
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключение в любой момент |
|
||||
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — переключение в любой момент |
|
||||
| **Мульти-биржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Студия стратегий** | Визуальный конструктор — источники монет, индикаторы, контроль рисков |
|
||||
| **AI Арена дебатов** | Несколько AI обсуждают сделки (Бык vs Медведь vs Аналитик), голосуют, исполняют |
|
||||
@@ -112,6 +112,7 @@ x402 процесс:
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Получить](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Получить](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Получить](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Получить](https://platform.minimaxi.com) |
|
||||
|
||||
### AI Модели (Режим x402 — без API ключей)
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ x402 процес:
|
||||
|
||||
| Функція | Опис |
|
||||
|:--------|:------------|
|
||||
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикання будь-коли |
|
||||
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — перемикання будь-коли |
|
||||
| **Мульти-біржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Студія стратегій** | Візуальний конструктор — джерела монет, індикатори, контроль ризиків |
|
||||
| **AI Арена дебатів** | Кілька AI обговорюють угоди, голосують, виконують |
|
||||
@@ -112,6 +112,7 @@ x402 процес:
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Отримати](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Отримати](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Отримати](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Отримати](https://platform.minimaxi.com) |
|
||||
|
||||
### AI Моделі (Режим x402 — без API ключів)
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ Không tài khoản. Không API key. Không trả trước. Một ví, tất c
|
||||
|
||||
| Tính năng | Mô tả |
|
||||
|:--------|:------------|
|
||||
| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — chuyển đổi bất cứ lúc nào |
|
||||
| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — chuyển đổi bất cứ lúc nào |
|
||||
| **Đa Sàn** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Strategy Studio** | Trình xây dựng trực quan — nguồn coin, chỉ báo, kiểm soát rủi ro |
|
||||
| **AI Competition** | AI cạnh tranh thời gian thực, bảng xếp hạng hiệu suất |
|
||||
@@ -110,6 +110,7 @@ Crypto · Cổ phiếu Mỹ · Forex · Kim loại
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Lấy API Key](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Lấy API Key](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Lấy API Key](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Lấy API Key](https://platform.minimaxi.com) |
|
||||
|
||||
### Mô hình AI (Chế độ x402 — Không cần API Key)
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ x402 流程:
|
||||
|
||||
| 功能 | 描述 |
|
||||
|:--------|:------------|
|
||||
| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi — 随时切换 |
|
||||
| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi、MiniMax — 随时切换 |
|
||||
| **多交易所** | Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster、Lighter |
|
||||
| **策略工作室** | 可视化构建器 — 币种来源、指标、风控 |
|
||||
| **AI 竞赛** | AI 实时竞争,排行榜排名 |
|
||||
@@ -113,6 +113,7 @@ x402 流程:
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [获取 API Key](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [获取 API Key](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [获取 API Key](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [获取 API Key](https://platform.minimaxi.com) |
|
||||
|
||||
### AI 模型 (x402 模式 — 无需 API Key)
|
||||
|
||||
@@ -170,6 +171,10 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
## 配置
|
||||
|
||||
**新手模式**:首次使用的用户可以在注册时选择新手模式,系统会引导你逐步完成 AI、交易所和策略的配置。
|
||||
|
||||
**进阶模式**:
|
||||
|
||||
1. **AI** — 添加 API Key 或配置 x402 钱包
|
||||
2. **交易所** — 连接交易所 API 凭证
|
||||
3. **策略** — 在策略工作室构建
|
||||
|
||||
137
docs/token-estimation.zh-CN.md
Normal file
137
docs/token-estimation.zh-CN.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# 📊 Token 估算分析与候选币种上限指南
|
||||
|
||||
> 版本:v1.0 | 更新:2026-03-27
|
||||
> 适用:策略配置 · 模型选择 · 候选币种数量决策
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [Token 估算公式](#-token-估算公式)
|
||||
- [系统提示词的准确性分析](#-系统提示词的准确性分析)
|
||||
- [典型配置下的安全币种数量](#-典型配置下的安全币种数量)
|
||||
- [模型上限参考](#-模型上限参考)
|
||||
- [MaxCandidateCoins 常量说明](#-maxcandidatecoins-常量说明)
|
||||
|
||||
---
|
||||
|
||||
## 📐 Token 估算公式
|
||||
|
||||
代码入口:`store/strategy.go` → `EstimateTokens()`
|
||||
|
||||
整体结构:
|
||||
|
||||
```
|
||||
total = (staticTokens + N × perCoinTokens) × 1.15
|
||||
```
|
||||
|
||||
其中 `1.15` 为 15% 安全边际。
|
||||
|
||||
### 静态部分(与候选币数量无关)
|
||||
|
||||
```
|
||||
SystemPrompt = baseChars / 2(zh)或 / 4(en)
|
||||
baseChars ≈ 3000(zh)/ 4000(en)+ 自定义提示段落长度
|
||||
|
||||
FixedOverhead = 200 tokens(时间戳、账户信息、章节标题)
|
||||
|
||||
RankingData = (OILimit × 60 + NetFlowLimit × 80 + PriceLimit × durations × 40) / 4
|
||||
|
||||
staticTokens = SystemPrompt + FixedOverhead + RankingData
|
||||
≈ 1500 + 200 + 650 = 2350 tokens(默认中文配置)
|
||||
```
|
||||
|
||||
### 每枚币的 Token 开销
|
||||
|
||||
```
|
||||
# 每行指标额外字符数(I)
|
||||
I = EnableEMA×20 + EnableMACD×30 + EnableRSI×15
|
||||
+ EnableATR×15 + EnableBOLL×25 + EnableVolume×10
|
||||
|
||||
# 每枚币的市场数据 token
|
||||
marketPerCoin = (T × K × (80 + I) + 100) / 4
|
||||
↑ T=时间框架数 K=每TF K线数
|
||||
↑ 100 = OI + 资金费率固定开销
|
||||
|
||||
# 每枚币的量化数据 token
|
||||
quantPerCoin = (EnableQuantOI×300 + EnableQuantNetflow×300) / 4
|
||||
|
||||
perCoinTokens = marketPerCoin + quantPerCoin
|
||||
```
|
||||
|
||||
### 反向公式:最大安全币数
|
||||
|
||||
```
|
||||
budget = modelContextLimit × 0.80 / 1.15
|
||||
maxSafeCoins = floor((budget - staticTokens) / perCoinTokens)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 典型配置下的安全币种数量
|
||||
|
||||
**基准:131K 模型(DeepSeek / Grok / Qwen)**,80% 警戒线
|
||||
|
||||
### 三种配置的 perCoinTokens
|
||||
|
||||
| 配置 | T | K | I | quantPerCoin | perCoinTokens |
|
||||
| ------------------------------------------ | --- | --- | --- | ------------ | ------------- |
|
||||
| **最小**(单TF,无指标,无量化) | 1 | 10 | 0 | 0 | **225** |
|
||||
| **默认**(3TF,仅Volume,QuantOI+Netflow) | 3 | 20 | 10 | 600 | **1525** |
|
||||
| **最大**(4TF,全部指标,全量化) | 4 | 30 | 115 | 600 | **6025** |
|
||||
|
||||
### 各模型下的最大安全币数
|
||||
|
||||
| 模型上限 | 最小配置 | 默认配置 | 最大配置 |
|
||||
| ------------------------------ | ------------ | ------------ | ----------- |
|
||||
| 131K(DeepSeek / Grok / Qwen) | ≥10(封顶) | ≥10(封顶) | **14** |
|
||||
| 128K(OpenAI GPT-4) | ≥10(封顶) | ≥10(封顶) | **14** |
|
||||
| 200K(Claude) | ≥10(封顶) | ≥10(封顶) | ≥10(封顶) |
|
||||
| 1M(Gemini / Minimax) | ≥10(封顶) | ≥10(封顶) | ≥10(封顶) |
|
||||
|
||||
---
|
||||
|
||||
## 🤖 模型上限参考
|
||||
|
||||
来源:`store/strategy.go` → `ModelContextLimits`
|
||||
|
||||
| 模型 | Context 上限 | 80% 警戒线 |
|
||||
| -------- | ------------ | ---------- |
|
||||
| deepseek | 131,072 | 104,858 |
|
||||
| openai | 128,000 | 102,400 |
|
||||
| claude | 200,000 | 160,000 |
|
||||
| qwen | 131,072 | 104,858 |
|
||||
| gemini | 1,000,000 | 800,000 |
|
||||
| grok | 131,072 | 104,858 |
|
||||
| kimi | 131,072 | 104,858 |
|
||||
| minimax | 1,000,000 | 800,000 |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 MaxCandidateCoins 常量说明
|
||||
|
||||
来源:`store/strategy.go` 第 14-20 行
|
||||
|
||||
```go
|
||||
const (
|
||||
MaxCandidateCoins = 10 // UI 硬限制:用户最多设定的候选币数量
|
||||
MaxPositions = 3 // 最大同时持仓数
|
||||
MaxTimeframes = 4 // 最大时间框架数
|
||||
MinKlineCount = 10 // 最少 K 线数
|
||||
MaxKlineCount = 30 // 最多 K 线数
|
||||
)
|
||||
```
|
||||
|
||||
### 为什么 MaxCandidateCoins = 10?
|
||||
|
||||
- **默认配置**下 10 枚币约用 **~20,000 tokens**(~15% of 131K),完全安全
|
||||
- **极端配置**(4TF + 全指标)10 枚币约用 **~72,000 tokens**(~55% of 131K),仍有充足余量
|
||||
- 因此 10 是保守且安全的 UI 上限:在所有模型和配置组合下均不会触发 token 限制
|
||||
|
||||
### 建议使用范围
|
||||
|
||||
| 用户类型 | 建议配置 | 最大建议币数 |
|
||||
| ------------------- | ----------------------- | ------------ |
|
||||
| 新手 / 使用默认配置 | 3TF, K=20, 仅 Volume | 10-20 枚 |
|
||||
| 进阶 / 启用部分指标 | 3TF, K=20, EMA+MACD+RSI | 10-15 枚 |
|
||||
| 高级 / 全部指标 | 3-4TF, K=20-30, 全指标 | 5-10 枚 |
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/provider/hyperliquid"
|
||||
@@ -185,8 +186,9 @@ type StrategyEngine struct {
|
||||
nofxosClient *nofxos.Client
|
||||
}
|
||||
|
||||
// NewStrategyEngine creates strategy execution engine
|
||||
func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine {
|
||||
// NewStrategyEngine creates strategy execution engine.
|
||||
// claw402WalletKey is optional — if provided, nofxos data requests are routed through claw402.
|
||||
func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string) *StrategyEngine {
|
||||
// Create NofxOS client with API key from config
|
||||
apiKey := config.Indicators.NofxOSAPIKey
|
||||
if apiKey == "" {
|
||||
@@ -194,6 +196,28 @@ func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine {
|
||||
}
|
||||
client := nofxos.NewClient(nofxos.DefaultBaseURL, apiKey)
|
||||
|
||||
// If claw402 wallet key is provided (from trader's AI config), route through claw402
|
||||
walletKey := ""
|
||||
if len(claw402WalletKey) > 0 {
|
||||
walletKey = claw402WalletKey[0]
|
||||
}
|
||||
if walletKey == "" {
|
||||
walletKey = os.Getenv("CLAW402_WALLET_KEY")
|
||||
}
|
||||
if walletKey != "" {
|
||||
claw402URL := os.Getenv("CLAW402_URL")
|
||||
if claw402URL == "" {
|
||||
claw402URL = "https://claw402.ai"
|
||||
}
|
||||
claw402Client, err := nofxos.NewClaw402DataClient(claw402URL, walletKey, &logger.MCPLogger{})
|
||||
if err == nil {
|
||||
client.SetClaw402(claw402Client)
|
||||
logger.Infof("🔗 NofxOS data routed through claw402 (%s)", claw402URL)
|
||||
} else {
|
||||
logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &StrategyEngine{
|
||||
config: config,
|
||||
nofxosClient: client,
|
||||
|
||||
@@ -51,6 +51,33 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
|
||||
engine = NewStrategyEngine(&defaultConfig)
|
||||
}
|
||||
|
||||
// Clamp strategy limits to prevent token overflow
|
||||
engineConfig := engine.GetConfig()
|
||||
engineConfig.ClampLimits()
|
||||
|
||||
// Token estimation check — block if exceeding the specific model's context limit
|
||||
estimate := engineConfig.EstimateTokens()
|
||||
|
||||
// Determine context limit for the specific model being used
|
||||
contextLimit := 131072 // safe default (strictest common limit)
|
||||
var providerName string
|
||||
if embedder, ok := mcpClient.(mcp.ClientEmbedder); ok {
|
||||
base := embedder.BaseClient()
|
||||
providerName = base.Provider
|
||||
contextLimit = store.GetContextLimitForClient(base.Provider, base.Model)
|
||||
}
|
||||
|
||||
if estimate.Total > contextLimit {
|
||||
logger.Errorf("🚫 Token estimate %d exceeds %s context limit %d — blocking analysis",
|
||||
estimate.Total, providerName, contextLimit)
|
||||
return nil, fmt.Errorf("estimated %d tokens exceeds model context limit of %d; reduce coins, timeframes, or K-line count",
|
||||
estimate.Total, contextLimit)
|
||||
}
|
||||
if estimate.Total*100/contextLimit >= 80 {
|
||||
logger.Infof("⚠️ Token estimate %d — approaching %s context limit %d",
|
||||
estimate.Total, providerName, contextLimit)
|
||||
}
|
||||
|
||||
// 1. Fetch market data using strategy config
|
||||
if len(ctx.MarketDataMap) == 0 {
|
||||
if err := fetchMarketDataWithStrategy(ctx, engine); err != nil {
|
||||
|
||||
8
main.go
8
main.go
@@ -118,8 +118,12 @@ func main() {
|
||||
if t.IsRunning {
|
||||
status = "✅ Running"
|
||||
}
|
||||
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
|
||||
t.Name, t.ID[:8], status, t.AIModelID, t.ExchangeID)
|
||||
idShort := t.ID
|
||||
if len(idShort) > 8 {
|
||||
idShort = idShort[:8]
|
||||
}
|
||||
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
|
||||
t.Name, idShort, status, t.AIModelID, t.ExchangeID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,15 +78,19 @@ func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline
|
||||
ts := time.Now().UnixMilli()
|
||||
// Use "To" side to search backward from current time (get historical klines)
|
||||
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
|
||||
if err != nil {
|
||||
// If exchange-specific data fails, fallback to Binance
|
||||
if err != nil || len(coinankKlines) == 0 {
|
||||
// If exchange-specific data fails or returns empty, fallback to Binance
|
||||
if coinankExchange != coinank_enum.Binance {
|
||||
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
|
||||
if err != nil {
|
||||
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
|
||||
} else {
|
||||
logger.Warnf("⚠️ CoinAnk %s %s data empty for %s, falling back to Binance", exchange, interval, symbol)
|
||||
}
|
||||
coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CoinAnk API error (fallback): %w", err)
|
||||
}
|
||||
} else {
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("CoinAnk API error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +392,9 @@ func (client *Client) String() string {
|
||||
client.Provider, client.Model)
|
||||
}
|
||||
|
||||
// BaseClient returns the underlying *Client (satisfies ClientEmbedder interface).
|
||||
func (c *Client) BaseClient() *Client { return c }
|
||||
|
||||
// IsRetryableError determines if error is retryable (network errors, timeouts, etc.)
|
||||
func (client *Client) IsRetryableError(err error) bool {
|
||||
errStr := err.Error()
|
||||
@@ -760,10 +763,21 @@ func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue // skip malformed chunks
|
||||
}
|
||||
|
||||
if chunk.Usage != nil && chunk.Usage.TotalTokens > 0 {
|
||||
fmt.Printf("📊 [TokenUsage] prompt=%d, completion=%d, total=%d\n",
|
||||
chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.TotalTokens)
|
||||
}
|
||||
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
const (
|
||||
DefaultClaw402URL = "https://claw402.ai"
|
||||
DefaultClaw402Model = "deepseek"
|
||||
DefaultClaw402Model = "glm-5"
|
||||
)
|
||||
|
||||
// claw402ModelEndpoints maps user-friendly model names to claw402 API paths.
|
||||
@@ -39,6 +39,9 @@ var claw402ModelEndpoints = map[string]string{
|
||||
"gemini-3.1-pro": "/api/v1/ai/gemini/chat/3.1-pro",
|
||||
// Kimi
|
||||
"kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5",
|
||||
// Z.AI (智谱)
|
||||
"glm-5": "/api/v1/ai/zhipu/chat",
|
||||
"glm-5-turbo": "/api/v1/ai/zhipu/chat/turbo",
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -81,6 +81,13 @@ func X402DecodeHeader(b64 string) ([]byte, error) {
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// MakeClaw402SignFunc creates an X402SignFunc from a private key for claw402 payments.
|
||||
func MakeClaw402SignFunc(privateKey *ecdsa.PrivateKey) X402SignFunc {
|
||||
return func(paymentHeaderB64 string) (string, error) {
|
||||
return SignBasePaymentHeader(privateKey, paymentHeaderB64, "Claw402")
|
||||
}
|
||||
}
|
||||
|
||||
// SignBasePaymentHeader decodes a base64 x402 header, parses it, and signs with
|
||||
// EIP-712 (USDC TransferWithAuthorization).
|
||||
func SignBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
const (
|
||||
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
|
||||
DefaultMiniMaxModel = "MiniMax-M2.5"
|
||||
DefaultMiniMaxModel = "MiniMax-M2.7"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -25,5 +25,5 @@ const (
|
||||
|
||||
// Default MiniMax configuration (used by WithMiniMaxConfig convenience option)
|
||||
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
|
||||
DefaultMiniMaxModel = "MiniMax-M2.5"
|
||||
DefaultMiniMaxModel = "MiniMax-M2.7"
|
||||
)
|
||||
|
||||
112
provider/nofxos/claw402.go
Normal file
112
provider/nofxos/claw402.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"nofx/mcp"
|
||||
"nofx/mcp/payment"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
// Claw402DataClient wraps nofxos API calls through claw402's x402 payment gateway.
|
||||
// Instead of calling nofxos.ai directly, it calls claw402.ai/api/v1/nofx/...
|
||||
// and pays with USDC for each request.
|
||||
type Claw402DataClient struct {
|
||||
claw402URL string
|
||||
privateKey *ecdsa.PrivateKey
|
||||
httpClient *http.Client
|
||||
logger mcp.Logger
|
||||
}
|
||||
|
||||
// NewClaw402DataClient creates a client that routes nofxos requests through claw402.
|
||||
// privateKeyHex is the wallet private key (0x-prefixed hex string).
|
||||
func NewClaw402DataClient(claw402URL, privateKeyHex string, logger mcp.Logger) (*Claw402DataClient, error) {
|
||||
if claw402URL == "" {
|
||||
claw402URL = "https://claw402.ai"
|
||||
}
|
||||
claw402URL = strings.TrimRight(claw402URL, "/")
|
||||
|
||||
if privateKeyHex == "" {
|
||||
privateKeyHex = os.Getenv("CLAW402_WALLET_KEY")
|
||||
}
|
||||
if privateKeyHex == "" {
|
||||
return nil, fmt.Errorf("claw402 wallet private key not set")
|
||||
}
|
||||
|
||||
hexKey := strings.TrimPrefix(privateKeyHex, "0x")
|
||||
pk, err := crypto.HexToECDSA(hexKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid claw402 private key: %w", err)
|
||||
}
|
||||
|
||||
return &Claw402DataClient{
|
||||
claw402URL: claw402URL,
|
||||
privateKey: pk,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// endpoint mapping: nofxos path → claw402 path
|
||||
var endpointMap = map[string]string{
|
||||
"/api/ai500/list": "/api/v1/nofx/ai500/list",
|
||||
"/api/ai500/stats": "/api/v1/nofx/ai500/stats",
|
||||
}
|
||||
|
||||
// mapEndpoint converts a nofxos endpoint to a claw402 endpoint.
|
||||
// For endpoints not in the static map, applies the general pattern:
|
||||
// /api/xxx → /api/v1/nofx/xxx
|
||||
func mapEndpoint(nofxosPath string) string {
|
||||
if mapped, ok := endpointMap[nofxosPath]; ok {
|
||||
return mapped
|
||||
}
|
||||
// General pattern: /api/xxx → /api/v1/nofx/xxx
|
||||
if strings.HasPrefix(nofxosPath, "/api/") {
|
||||
return "/api/v1/nofx/" + strings.TrimPrefix(nofxosPath, "/api/")
|
||||
}
|
||||
return nofxosPath
|
||||
}
|
||||
|
||||
// DoRequest makes a GET request through claw402 with x402 payment.
|
||||
func (c *Claw402DataClient) DoRequest(endpoint string) ([]byte, error) {
|
||||
claw402Path := mapEndpoint(endpoint)
|
||||
// Strip auth= query params (claw402 uses x402 payment, not auth keys)
|
||||
if idx := strings.Index(claw402Path, "?auth="); idx != -1 {
|
||||
claw402Path = claw402Path[:idx]
|
||||
}
|
||||
if idx := strings.Index(claw402Path, "&auth="); idx != -1 {
|
||||
claw402Path = claw402Path[:idx]
|
||||
}
|
||||
|
||||
fullURL := c.claw402URL + claw402Path
|
||||
|
||||
buildReq := func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Client-ID", "nofx")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
signFn := payment.MakeClaw402SignFunc(c.privateKey)
|
||||
|
||||
body, err := payment.DoX402Request(
|
||||
c.httpClient,
|
||||
buildReq,
|
||||
signFn,
|
||||
"claw402-data",
|
||||
c.logger,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claw402 data request failed (%s): %w", claw402Path, err)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type Client struct {
|
||||
AuthKey string
|
||||
Timeout time.Duration
|
||||
mu sync.RWMutex
|
||||
claw402 *Claw402DataClient // If set, routes requests through claw402
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -59,6 +60,13 @@ func NewClient(baseURL, authKey string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// SetClaw402 enables routing requests through claw402 payment gateway.
|
||||
func (c *Client) SetClaw402(claw402Client *Claw402DataClient) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.claw402 = claw402Client
|
||||
}
|
||||
|
||||
// SetConfig updates client configuration
|
||||
func (c *Client) SetConfig(baseURL, authKey string) {
|
||||
c.mu.Lock()
|
||||
@@ -85,14 +93,21 @@ func (c *Client) GetAuthKey() string {
|
||||
return c.AuthKey
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP GET request with authentication
|
||||
// doRequest performs an HTTP GET request with authentication.
|
||||
// If claw402 client is configured, routes through claw402 payment gateway instead.
|
||||
func (c *Client) doRequest(endpoint string) ([]byte, error) {
|
||||
c.mu.RLock()
|
||||
claw402Client := c.claw402
|
||||
baseURL := c.BaseURL
|
||||
authKey := c.AuthKey
|
||||
timeout := c.Timeout
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Route through claw402 if configured
|
||||
if claw402Client != nil {
|
||||
return claw402Client.DoRequest(endpoint)
|
||||
}
|
||||
|
||||
url := baseURL + endpoint
|
||||
if !strings.Contains(url, "auth=") {
|
||||
if strings.Contains(url, "?") {
|
||||
|
||||
@@ -54,6 +54,24 @@ func (s *AIModelStore) initDefaultData() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindOrphanClaw402 finds a claw402 model whose user_id no longer exists in the users table.
|
||||
// Used to recover wallets after account reset.
|
||||
func (s *AIModelStore) FindOrphanClaw402() (*AIModel, error) {
|
||||
var model AIModel
|
||||
err := s.db.Where("provider = ? AND api_key != '' AND user_id NOT IN (SELECT id FROM users)", "claw402").
|
||||
First(&model).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// AdoptModel re-assigns an existing model to a new user.
|
||||
func (s *AIModelStore) AdoptModel(modelID, newUserID string) error {
|
||||
return s.db.Model(&AIModel{}).Where("id = ?", modelID).
|
||||
Update("user_id", newUserID).Error
|
||||
}
|
||||
|
||||
// List retrieves user's AI model list
|
||||
func (s *AIModelStore) List(userID string) ([]*AIModel, error) {
|
||||
var models []*AIModel
|
||||
|
||||
@@ -3,11 +3,63 @@ package store
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Hard limits to prevent token explosion in AI requests
|
||||
const (
|
||||
MaxCandidateCoins = 10
|
||||
MaxPositions = 3
|
||||
MaxTimeframes = 4
|
||||
MinKlineCount = 10
|
||||
MaxKlineCount = 30
|
||||
)
|
||||
|
||||
// ClampLimits enforces product-level limits on strategy config to prevent token overflow.
|
||||
func (c *StrategyConfig) ClampLimits() {
|
||||
// Clamp coin source limits
|
||||
if c.CoinSource.AI500Limit > MaxCandidateCoins {
|
||||
c.CoinSource.AI500Limit = MaxCandidateCoins
|
||||
}
|
||||
if c.CoinSource.OITopLimit > MaxCandidateCoins {
|
||||
c.CoinSource.OITopLimit = MaxCandidateCoins
|
||||
}
|
||||
if c.CoinSource.OILowLimit > MaxCandidateCoins {
|
||||
c.CoinSource.OILowLimit = MaxCandidateCoins
|
||||
}
|
||||
|
||||
// Clamp static coins
|
||||
if len(c.CoinSource.StaticCoins) > MaxCandidateCoins {
|
||||
c.CoinSource.StaticCoins = c.CoinSource.StaticCoins[:MaxCandidateCoins]
|
||||
}
|
||||
|
||||
// Clamp kline count
|
||||
if c.Indicators.Klines.PrimaryCount < MinKlineCount {
|
||||
c.Indicators.Klines.PrimaryCount = MinKlineCount
|
||||
}
|
||||
if c.Indicators.Klines.PrimaryCount > MaxKlineCount {
|
||||
c.Indicators.Klines.PrimaryCount = MaxKlineCount
|
||||
}
|
||||
if c.Indicators.Klines.LongerCount > MaxKlineCount {
|
||||
c.Indicators.Klines.LongerCount = MaxKlineCount
|
||||
}
|
||||
|
||||
// Clamp timeframes
|
||||
if len(c.Indicators.Klines.SelectedTimeframes) > MaxTimeframes {
|
||||
c.Indicators.Klines.SelectedTimeframes = c.Indicators.Klines.SelectedTimeframes[:MaxTimeframes]
|
||||
}
|
||||
|
||||
// Clamp max positions
|
||||
if c.RiskControl.MaxPositions > MaxPositions {
|
||||
c.RiskControl.MaxPositions = MaxPositions
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// StrategyStore strategy storage
|
||||
type StrategyStore struct {
|
||||
db *gorm.DB
|
||||
@@ -21,8 +73,8 @@ type Strategy struct {
|
||||
Description string `gorm:"default:''" json:"description"`
|
||||
IsActive bool `gorm:"column:is_active;default:false;index" json:"is_active"`
|
||||
IsDefault bool `gorm:"column:is_default;default:false" json:"is_default"`
|
||||
IsPublic bool `gorm:"column:is_public;default:false;index" json:"is_public"` // whether visible in strategy market
|
||||
ConfigVisible bool `gorm:"column:config_visible;default:true" json:"config_visible"` // whether config details are visible
|
||||
IsPublic bool `gorm:"column:is_public;default:false;index" json:"is_public"` // whether visible in strategy market
|
||||
ConfigVisible bool `gorm:"column:config_visible;default:true" json:"config_visible"` // whether config details are visible
|
||||
Config string `gorm:"not null;default:'{}'" json:"config"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -139,7 +191,7 @@ type IndicatorConfig struct {
|
||||
EnableMACD bool `json:"enable_macd"`
|
||||
EnableRSI bool `json:"enable_rsi"`
|
||||
EnableATR bool `json:"enable_atr"`
|
||||
EnableBOLL bool `json:"enable_boll"` // Bollinger Bands
|
||||
EnableBOLL bool `json:"enable_boll"` // Bollinger Bands
|
||||
EnableVolume bool `json:"enable_volume"`
|
||||
EnableOI bool `json:"enable_oi"` // open interest
|
||||
EnableFundingRate bool `json:"enable_funding_rate"` // funding rate
|
||||
@@ -197,10 +249,10 @@ type KlineConfig struct {
|
||||
|
||||
// ExternalDataSource external data source configuration
|
||||
type ExternalDataSource struct {
|
||||
Name string `json:"name"` // data source name
|
||||
Type string `json:"type"` // type: "api" | "webhook"
|
||||
URL string `json:"url"` // API URL
|
||||
Method string `json:"method"` // HTTP method
|
||||
Name string `json:"name"` // data source name
|
||||
Type string `json:"type"` // type: "api" | "webhook"
|
||||
URL string `json:"url"` // API URL
|
||||
Method string `json:"method"` // HTTP method
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
DataPath string `json:"data_path,omitempty"` // JSON data path
|
||||
RefreshSecs int `json:"refresh_secs,omitempty"` // refresh interval (seconds)
|
||||
@@ -260,20 +312,20 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
CoinSource: CoinSourceConfig{
|
||||
SourceType: "ai500",
|
||||
UseAI500: true,
|
||||
AI500Limit: 10,
|
||||
AI500Limit: 3,
|
||||
UseOITop: false,
|
||||
OITopLimit: 10,
|
||||
OITopLimit: 3,
|
||||
UseOILow: false,
|
||||
OILowLimit: 10,
|
||||
OILowLimit: 3,
|
||||
},
|
||||
Indicators: IndicatorConfig{
|
||||
Klines: KlineConfig{
|
||||
PrimaryTimeframe: "5m",
|
||||
PrimaryCount: 30,
|
||||
PrimaryCount: 20,
|
||||
LongerTimeframe: "4h",
|
||||
LongerCount: 10,
|
||||
EnableMultiTimeframe: true,
|
||||
SelectedTimeframes: []string{"5m", "15m", "1h", "4h"},
|
||||
SelectedTimeframes: []string{"5m", "15m", "1h"},
|
||||
},
|
||||
EnableRawKlines: true, // Required - raw OHLCV data for AI analysis
|
||||
EnableEMA: false,
|
||||
@@ -308,15 +360,15 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
PriceRankingLimit: 10,
|
||||
},
|
||||
RiskControl: RiskControlConfig{
|
||||
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
||||
BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided)
|
||||
AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided)
|
||||
BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED)
|
||||
AltcoinMaxPositionValueRatio: 1.0, // Altcoin: max position = 1x equity (CODE ENFORCED)
|
||||
MaxMarginUsage: 0.9, // Max 90% margin usage (CODE ENFORCED)
|
||||
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
|
||||
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
|
||||
MinConfidence: 75, // Min 75% confidence (AI guided)
|
||||
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
||||
BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided)
|
||||
AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided)
|
||||
BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED)
|
||||
AltcoinMaxPositionValueRatio: 1.0, // Altcoin: max position = 1x equity (CODE ENFORCED)
|
||||
MaxMarginUsage: 0.9, // Max 90% margin usage (CODE ENFORCED)
|
||||
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
|
||||
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
|
||||
MinConfidence: 75, // Min 75% confidence (AI guided)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -388,8 +440,18 @@ func (s *StrategyStore) Update(strategy *Strategy) error {
|
||||
func (s *StrategyStore) Delete(userID, id string) error {
|
||||
// do not allow deleting system default strategy
|
||||
var st Strategy
|
||||
if err := s.db.Where("id = ?", id).First(&st).Error; err == nil && st.IsDefault {
|
||||
return fmt.Errorf("cannot delete system default strategy")
|
||||
if err := s.db.Where("id = ?", id).First(&st).Error; err == nil {
|
||||
if st.IsDefault {
|
||||
return fmt.Errorf("cannot delete system default strategy")
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any trader references this strategy
|
||||
var count int64
|
||||
if err := s.db.Model(&Trader{}).
|
||||
Where("user_id = ? AND strategy_id = ?", userID, id).
|
||||
Count(&count).Error; err == nil && count > 0 {
|
||||
return fmt.Errorf("cannot delete strategy in use by %d trader(s) - reassign those traders first", count)
|
||||
}
|
||||
|
||||
return s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&Strategy{}).Error
|
||||
@@ -510,3 +572,308 @@ func (s *Strategy) SetConfig(config *StrategyConfig) error {
|
||||
s.Config = string(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Token Estimation
|
||||
// ============================================================================
|
||||
|
||||
// TokenEstimate holds the result of token estimation
|
||||
type TokenEstimate struct {
|
||||
Total int `json:"total"`
|
||||
Breakdown TokenBreakdown `json:"breakdown"`
|
||||
ModelLimits []ModelLimit `json:"model_limits"`
|
||||
Suggestions []string `json:"suggestions"`
|
||||
}
|
||||
|
||||
// TokenBreakdown shows estimated tokens per component
|
||||
type TokenBreakdown struct {
|
||||
SystemPrompt int `json:"system_prompt"`
|
||||
MarketData int `json:"market_data"`
|
||||
RankingData int `json:"ranking_data"`
|
||||
QuantData int `json:"quant_data"`
|
||||
FixedOverhead int `json:"fixed_overhead"`
|
||||
}
|
||||
|
||||
// ModelLimit shows token usage against a specific model's context limit
|
||||
type ModelLimit struct {
|
||||
Name string `json:"name"`
|
||||
ContextLimit int `json:"context_limit"`
|
||||
UsagePct int `json:"usage_pct"`
|
||||
Level string `json:"level"` // "ok" | "warning" | "danger"
|
||||
}
|
||||
|
||||
// Context window sizes (tokens) for each model family
|
||||
const (
|
||||
contextLimitDeepSeek = 131_072 // 128K
|
||||
contextLimitOpenAI = 128_000 // 128K
|
||||
contextLimitClaude = 200_000 // 200K
|
||||
contextLimitQwen = 131_072 // 128K
|
||||
contextLimitGemini = 1_000_000 // 1M
|
||||
contextLimitGrok = 131_072 // 128K
|
||||
contextLimitKimi = 131_072 // 128K
|
||||
contextLimitMinimax = 1_000_000 // 1M
|
||||
)
|
||||
|
||||
// ModelContextLimits maps provider names to their context window sizes (in tokens)
|
||||
var ModelContextLimits = map[string]int{
|
||||
"deepseek": contextLimitDeepSeek,
|
||||
"openai": contextLimitOpenAI,
|
||||
"claude": contextLimitClaude,
|
||||
"qwen": contextLimitQwen,
|
||||
"gemini": contextLimitGemini,
|
||||
"grok": contextLimitGrok,
|
||||
"kimi": contextLimitKimi,
|
||||
"minimax": contextLimitMinimax,
|
||||
}
|
||||
|
||||
// GetContextLimit returns the context limit for a given provider
|
||||
func GetContextLimit(provider string) int {
|
||||
if limit, ok := ModelContextLimits[provider]; ok {
|
||||
return limit
|
||||
}
|
||||
return contextLimitDeepSeek // safe default
|
||||
}
|
||||
|
||||
// GetContextLimitForClient returns context limit for a provider+model pair.
|
||||
// For claw402, the underlying model is inferred from the model name prefix.
|
||||
func GetContextLimitForClient(provider, model string) int {
|
||||
if provider == "claw402" {
|
||||
switch {
|
||||
case strings.HasPrefix(model, "claude"):
|
||||
return ModelContextLimits["claude"]
|
||||
case strings.HasPrefix(model, "gpt"), strings.HasPrefix(model, "o1"), strings.HasPrefix(model, "o3"):
|
||||
return ModelContextLimits["openai"]
|
||||
case strings.HasPrefix(model, "gemini"):
|
||||
return ModelContextLimits["gemini"]
|
||||
case strings.HasPrefix(model, "grok"):
|
||||
return ModelContextLimits["grok"]
|
||||
case strings.HasPrefix(model, "kimi"):
|
||||
return ModelContextLimits["kimi"]
|
||||
case strings.HasPrefix(model, "qwen"):
|
||||
return ModelContextLimits["qwen"]
|
||||
case strings.HasPrefix(model, "minimax"):
|
||||
return ModelContextLimits["minimax"]
|
||||
case strings.HasPrefix(model, "deepseek"):
|
||||
return ModelContextLimits["deepseek"]
|
||||
default:
|
||||
return ModelContextLimits["deepseek"]
|
||||
}
|
||||
}
|
||||
return GetContextLimit(provider)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,11 @@ func (s *UserStore) UpdatePassword(userID, passwordHash string) error {
|
||||
}).Error
|
||||
}
|
||||
|
||||
// DeleteAll deletes all users (reset system to uninitialized state)
|
||||
func (s *UserStore) DeleteAll() error {
|
||||
return s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&User{}).Error
|
||||
}
|
||||
|
||||
// EnsureAdmin ensures admin user exists
|
||||
func (s *UserStore) EnsureAdmin() error {
|
||||
var count int64
|
||||
|
||||
@@ -333,7 +333,13 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
if config.StrategyConfig == nil {
|
||||
return nil, fmt.Errorf("[%s] strategy not configured", config.Name)
|
||||
}
|
||||
strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig)
|
||||
// Pass claw402 wallet key to strategy engine so nofxos data requests
|
||||
// are routed through claw402 (reuses the same wallet as AI calls)
|
||||
var claw402Key string
|
||||
if config.AIModel == "claw402" && config.CustomAPIKey != "" {
|
||||
claw402Key = config.CustomAPIKey
|
||||
}
|
||||
strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig, claw402Key)
|
||||
logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name)
|
||||
|
||||
return &AutoTrader{
|
||||
|
||||
@@ -49,6 +49,12 @@ func (at *AutoTrader) checkPositionDrawdown() {
|
||||
quantity = -quantity // Short position quantity is negative, convert to positive
|
||||
}
|
||||
|
||||
// Guard: skip if entry price is zero (prevents division by zero panic)
|
||||
if entryPrice <= 0 {
|
||||
logger.Warnf("⚠️ Drawdown monitoring: %s %s has zero entry price, skipping", symbol, side)
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate current P&L percentage
|
||||
leverage := 10 // Default value
|
||||
if lev, ok := pos["leverage"].(float64); ok {
|
||||
|
||||
361
web/package-lock.json
generated
361
web/package-lock.json
generated
@@ -1010,9 +1010,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1021,9 +1021,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -1084,9 +1084,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1105,9 +1105,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -1728,9 +1728,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
|
||||
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
|
||||
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1742,9 +1742,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1756,9 +1756,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1770,9 +1770,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
|
||||
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1784,9 +1784,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1798,9 +1798,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
|
||||
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1812,9 +1812,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
|
||||
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
|
||||
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1826,9 +1826,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
|
||||
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
|
||||
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1840,9 +1840,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1854,9 +1854,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1868,9 +1868,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1882,9 +1896,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1896,9 +1924,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1910,9 +1938,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1924,9 +1952,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1938,9 +1966,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1952,9 +1980,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1965,10 +1993,24 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1980,9 +2022,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1994,9 +2036,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2008,9 +2050,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2022,9 +2064,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2694,9 +2736,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3029,13 +3071,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -3070,9 +3112,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4285,9 +4327,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4296,9 +4338,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -4349,9 +4391,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4383,9 +4425,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -4640,9 +4682,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -5933,9 +5975,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -6095,13 +6137,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -6497,9 +6539,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -6968,9 +7010,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz",
|
||||
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==",
|
||||
"version": "7.13.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
|
||||
"integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -6990,12 +7032,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz",
|
||||
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==",
|
||||
"version": "7.13.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz",
|
||||
"integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.10.1"
|
||||
"react-router": "7.13.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -7247,9 +7289,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
|
||||
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7263,28 +7305,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.53.3",
|
||||
"@rollup/rollup-android-arm64": "4.53.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.53.3",
|
||||
"@rollup/rollup-darwin-x64": "4.53.3",
|
||||
"@rollup/rollup-freebsd-arm64": "4.53.3",
|
||||
"@rollup/rollup-freebsd-x64": "4.53.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.53.3",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.53.3",
|
||||
"@rollup/rollup-openharmony-arm64": "4.53.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.53.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.53.3",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.0",
|
||||
"@rollup/rollup-android-arm64": "4.60.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.0",
|
||||
"@rollup/rollup-darwin-x64": "4.60.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -8094,9 +8139,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -8541,9 +8586,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -8632,9 +8677,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -8932,9 +8977,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
|
||||
163
web/src/App.tsx
163
web/src/App.tsx
@@ -15,6 +15,7 @@ import { FAQPage } from './pages/FAQPage'
|
||||
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
||||
import { StrategyMarketPage } from './pages/StrategyMarketPage'
|
||||
import { DataPage } from './pages/DataPage'
|
||||
import { BeginnerOnboardingPage } from './pages/BeginnerOnboardingPage'
|
||||
import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay'
|
||||
import HeaderBar from './components/common/HeaderBar'
|
||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||
@@ -22,6 +23,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { ConfirmDialogProvider } from './components/common/ConfirmDialog'
|
||||
import { t } from './i18n/translations'
|
||||
import { useSystemConfig } from './hooks/useSystemConfig'
|
||||
import { getUserMode, hasCompletedBeginnerOnboarding } from './lib/onboarding'
|
||||
|
||||
import { OFFICIAL_LINKS } from './constants/branding'
|
||||
import type {
|
||||
@@ -53,16 +55,12 @@ function App() {
|
||||
const { config: systemConfig, loading: configLoading } = useSystemConfig()
|
||||
const [route, setRoute] = useState(window.location.pathname)
|
||||
|
||||
// Debug log
|
||||
useEffect(() => {
|
||||
console.log('[App] Mounted. Route:', window.location.pathname);
|
||||
}, []);
|
||||
|
||||
// 从URL路径读取初始页面状态(支持刷新保持页面)
|
||||
const getInitialPage = (): Page => {
|
||||
const path = window.location.pathname
|
||||
const hash = window.location.hash.slice(1) // 去掉 #
|
||||
|
||||
if (path === '/welcome') return 'traders'
|
||||
if (path === '/traders' || hash === 'traders') return 'traders'
|
||||
if (path === '/strategy' || hash === 'strategy') return 'strategy'
|
||||
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
|
||||
@@ -132,6 +130,20 @@ function App() {
|
||||
}
|
||||
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
|
||||
const [decisionsLimit, setDecisionsLimit] = useState<number>(5)
|
||||
const hasPersistedAuth =
|
||||
!!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user')
|
||||
|
||||
// Poll-off states: stop polling after 3 consecutive failures
|
||||
const [accountPollOff, setAccountPollOff] = useState(false)
|
||||
const [positionsPollOff, setPositionsPollOff] = useState(false)
|
||||
const [decisionsPollOff, setDecisionsPollOff] = useState(false)
|
||||
|
||||
// Reset poll-off states when trader changes
|
||||
useEffect(() => {
|
||||
setAccountPollOff(false)
|
||||
setPositionsPollOff(false)
|
||||
setDecisionsPollOff(false)
|
||||
}, [selectedTraderId])
|
||||
|
||||
// 监听URL变化,同步页面状态
|
||||
useEffect(() => {
|
||||
@@ -141,7 +153,9 @@ function App() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const traderParam = params.get('trader')
|
||||
|
||||
if (path === '/traders' || hash === 'traders') {
|
||||
if (path === '/welcome') {
|
||||
setCurrentPage('traders')
|
||||
} else if (path === '/traders' || hash === 'traders') {
|
||||
setCurrentPage('traders')
|
||||
} else if (path === '/strategy' || hash === 'strategy') {
|
||||
setCurrentPage('strategy')
|
||||
@@ -156,9 +170,7 @@ function App() {
|
||||
) {
|
||||
setCurrentPage('trader')
|
||||
// 如果 URL 中有 trader 参数(slug 格式),更新选中的 trader
|
||||
if (traderParam) {
|
||||
setSelectedTraderSlug(traderParam)
|
||||
}
|
||||
setSelectedTraderSlug(traderParam || undefined)
|
||||
} else if (
|
||||
path === '/competition' ||
|
||||
hash === 'competition' ||
|
||||
@@ -186,7 +198,7 @@ function App() {
|
||||
// 获取trader列表(仅在用户登录时)
|
||||
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
|
||||
user && token ? 'traders' : null,
|
||||
api.getTraders,
|
||||
() => api.getTraders(currentPage === 'trader'),
|
||||
{
|
||||
refreshInterval: 10000,
|
||||
shouldRetryOnError: false, // 避免在后端未运行时无限重试
|
||||
@@ -205,19 +217,22 @@ function App() {
|
||||
|
||||
// 当获取到traders后,根据 URL 中的 trader slug 设置选中的 trader,或默认选中第一个
|
||||
useEffect(() => {
|
||||
if (traders && traders.length > 0 && !selectedTraderId) {
|
||||
if (selectedTraderSlug) {
|
||||
// 通过 slug 找到对应的 trader
|
||||
const trader = findTraderBySlug(selectedTraderSlug, traders)
|
||||
if (trader) {
|
||||
setSelectedTraderId(trader.trader_id)
|
||||
} else {
|
||||
// 如果找不到,选中第一个
|
||||
setSelectedTraderId(traders[0].trader_id)
|
||||
}
|
||||
} else {
|
||||
setSelectedTraderId(traders[0].trader_id)
|
||||
if (!traders || traders.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedTraderSlug) {
|
||||
// 通过 slug 找到对应的 trader
|
||||
const trader = findTraderBySlug(selectedTraderSlug, traders)
|
||||
const nextTraderId = trader?.trader_id || traders[0].trader_id
|
||||
if (nextTraderId !== selectedTraderId) {
|
||||
setSelectedTraderId(nextTraderId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedTraderId) {
|
||||
setSelectedTraderId(traders[0].trader_id)
|
||||
}
|
||||
}, [traders, selectedTraderId, selectedTraderSlug])
|
||||
|
||||
@@ -226,7 +241,7 @@ function App() {
|
||||
currentPage === 'trader' && selectedTraderId
|
||||
? `status-${selectedTraderId}`
|
||||
: null,
|
||||
() => api.getStatus(selectedTraderId),
|
||||
() => api.getStatus(selectedTraderId, true),
|
||||
{
|
||||
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
|
||||
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
|
||||
@@ -238,11 +253,16 @@ function App() {
|
||||
currentPage === 'trader' && selectedTraderId
|
||||
? `account-${selectedTraderId}`
|
||||
: null,
|
||||
() => api.getAccount(selectedTraderId),
|
||||
() => api.getAccount(selectedTraderId, true),
|
||||
{
|
||||
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
|
||||
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
|
||||
dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求
|
||||
refreshInterval: accountPollOff ? 0 : 15000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
|
||||
if (retryCount >= 2) { setAccountPollOff(true); return }
|
||||
setTimeout(() => revalidate({ retryCount }), 500)
|
||||
},
|
||||
onSuccess: () => { if (accountPollOff) setAccountPollOff(false) },
|
||||
}
|
||||
)
|
||||
|
||||
@@ -250,11 +270,16 @@ function App() {
|
||||
currentPage === 'trader' && selectedTraderId
|
||||
? `positions-${selectedTraderId}`
|
||||
: null,
|
||||
() => api.getPositions(selectedTraderId),
|
||||
() => api.getPositions(selectedTraderId, true),
|
||||
{
|
||||
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
|
||||
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
|
||||
dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求
|
||||
refreshInterval: positionsPollOff ? 0 : 15000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
|
||||
if (retryCount >= 2) { setPositionsPollOff(true); return }
|
||||
setTimeout(() => revalidate({ retryCount }), 500)
|
||||
},
|
||||
onSuccess: () => { if (positionsPollOff) setPositionsPollOff(false) },
|
||||
}
|
||||
)
|
||||
|
||||
@@ -262,11 +287,16 @@ function App() {
|
||||
currentPage === 'trader' && selectedTraderId
|
||||
? `decisions/latest-${selectedTraderId}-${decisionsLimit}`
|
||||
: null,
|
||||
() => api.getLatestDecisions(selectedTraderId, decisionsLimit),
|
||||
() => api.getLatestDecisions(selectedTraderId, decisionsLimit, true),
|
||||
{
|
||||
refreshInterval: 30000, // 30秒刷新(决策更新频率较低)
|
||||
refreshInterval: decisionsPollOff ? 0 : 30000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 20000,
|
||||
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
|
||||
if (retryCount >= 2) { setDecisionsPollOff(true); return }
|
||||
setTimeout(() => revalidate({ retryCount }), 500)
|
||||
},
|
||||
onSuccess: () => { if (decisionsPollOff) setDecisionsPollOff(false) },
|
||||
}
|
||||
)
|
||||
|
||||
@@ -274,7 +304,7 @@ function App() {
|
||||
currentPage === 'trader' && selectedTraderId
|
||||
? `statistics-${selectedTraderId}`
|
||||
: null,
|
||||
() => api.getStatistics(selectedTraderId),
|
||||
() => api.getStatistics(selectedTraderId, true),
|
||||
{
|
||||
refreshInterval: 30000, // 30秒刷新(统计数据更新频率较低)
|
||||
revalidateOnFocus: false,
|
||||
@@ -291,6 +321,10 @@ function App() {
|
||||
|
||||
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId)
|
||||
|
||||
const effectiveAccount = account
|
||||
const effectivePositions = positions
|
||||
const effectiveDecisions = decisions
|
||||
|
||||
// Handle routing
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
@@ -302,7 +336,9 @@ function App() {
|
||||
|
||||
// Set current page based on route for consistent navigation state
|
||||
useEffect(() => {
|
||||
if (route === '/competition') {
|
||||
if (route === '/welcome') {
|
||||
setCurrentPage('traders')
|
||||
} else if (route === '/competition') {
|
||||
setCurrentPage('competition')
|
||||
} else if (route === '/traders') {
|
||||
setCurrentPage('traders')
|
||||
@@ -311,6 +347,9 @@ function App() {
|
||||
}
|
||||
}, [route])
|
||||
|
||||
const showBeginnerOnboarding =
|
||||
route === '/welcome' && (!!user || hasPersistedAuth) && getUserMode() === 'beginner' && !hasCompletedBeginnerOnboarding()
|
||||
|
||||
// Show loading spinner while checking auth or config
|
||||
if (isLoading || configLoading) {
|
||||
return (
|
||||
@@ -347,6 +386,16 @@ function App() {
|
||||
}
|
||||
return <SetupPage />
|
||||
}
|
||||
if (route === '/welcome') {
|
||||
if ((!user || !token) && !hasPersistedAuth) {
|
||||
window.location.href = '/login'
|
||||
return null
|
||||
}
|
||||
if (getUserMode() !== 'beginner') {
|
||||
window.location.href = '/traders'
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (route === '/faq') {
|
||||
return (
|
||||
<div
|
||||
@@ -376,7 +425,7 @@ function App() {
|
||||
return <ResetPasswordPage />
|
||||
}
|
||||
if (route === '/settings') {
|
||||
if (!user || !token) {
|
||||
if ((!user || !token) && !hasPersistedAuth) {
|
||||
window.location.href = '/login'
|
||||
return null
|
||||
}
|
||||
@@ -398,19 +447,7 @@ function App() {
|
||||
// Data page - publicly accessible with embedded dashboard
|
||||
if (route === '/data') {
|
||||
const dataPageNavigate = (page: Page) => {
|
||||
const pathMap: Record<string, string> = {
|
||||
'data': '/data',
|
||||
'competition': '/competition',
|
||||
'strategy-market': '/strategy-market',
|
||||
'traders': '/traders',
|
||||
'trader': '/dashboard',
|
||||
'strategy': '/strategy',
|
||||
'faq': '/faq',
|
||||
}
|
||||
const path = pathMap[page]
|
||||
if (path) {
|
||||
window.location.href = path
|
||||
}
|
||||
navigateToPage(page)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
@@ -484,7 +521,18 @@ function App() {
|
||||
<AITradersPage
|
||||
onTraderSelect={(traderId) => {
|
||||
setSelectedTraderId(traderId)
|
||||
window.history.pushState({}, '', '/dashboard')
|
||||
const trader = traders?.find((item) => item.trader_id === traderId)
|
||||
const url = new URL(window.location.href)
|
||||
url.pathname = '/dashboard'
|
||||
if (trader) {
|
||||
const slug = getTraderSlug(trader)
|
||||
url.searchParams.set('trader', slug)
|
||||
setSelectedTraderSlug(slug)
|
||||
} else {
|
||||
url.searchParams.delete('trader')
|
||||
setSelectedTraderSlug(undefined)
|
||||
}
|
||||
window.history.pushState({}, '', url.toString())
|
||||
setRoute('/dashboard')
|
||||
setCurrentPage('trader')
|
||||
}}
|
||||
@@ -495,9 +543,12 @@ function App() {
|
||||
<TraderDashboardPage
|
||||
selectedTrader={selectedTrader}
|
||||
status={status}
|
||||
account={account}
|
||||
positions={positions}
|
||||
decisions={decisions}
|
||||
account={effectiveAccount}
|
||||
accountFailed={accountPollOff}
|
||||
positions={effectivePositions}
|
||||
positionsFailed={positionsPollOff}
|
||||
decisions={effectiveDecisions}
|
||||
decisionsFailed={decisionsPollOff}
|
||||
decisionsLimit={decisionsLimit}
|
||||
onDecisionsLimitChange={setDecisionsLimit}
|
||||
stats={stats}
|
||||
@@ -511,8 +562,10 @@ function App() {
|
||||
// 更新 URL 参数(使用 slug: name-id前4位)
|
||||
const trader = traders?.find(t => t.trader_id === traderId)
|
||||
if (trader) {
|
||||
const slug = getTraderSlug(trader)
|
||||
setSelectedTraderSlug(slug)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('trader', getTraderSlug(trader))
|
||||
url.searchParams.set('trader', slug)
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}
|
||||
}}
|
||||
@@ -646,6 +699,8 @@ function App() {
|
||||
onClose={() => setLoginOverlayOpen(false)}
|
||||
featureName={loginOverlayFeature}
|
||||
/>
|
||||
|
||||
{showBeginnerOnboarding && <BeginnerOnboardingPage />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||
import { LanguageSwitcher } from '../common/LanguageSwitcher'
|
||||
import { OnboardingModeSelector } from './OnboardingModeSelector'
|
||||
import type { UserMode } from '../../lib/onboarding'
|
||||
import { invalidateSystemConfig } from '../../lib/config'
|
||||
|
||||
export function LoginPage() {
|
||||
const { language } = useLanguage()
|
||||
@@ -15,7 +19,16 @@ export function LoginPage() {
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
|
||||
const [mode, setMode] = useState<UserMode>('beginner')
|
||||
|
||||
// Clean up stale auth state once on mount
|
||||
useEffect(() => {
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
localStorage.removeItem('user_id')
|
||||
}, [])
|
||||
|
||||
// Show session-expired toast (re-runs on language change to update text)
|
||||
useEffect(() => {
|
||||
if (sessionStorage.getItem('from401') === 'true') {
|
||||
const id = toast.warning(t('sessionExpired', language), { duration: Infinity })
|
||||
@@ -24,11 +37,32 @@ export function LoginPage() {
|
||||
}
|
||||
}, [language])
|
||||
|
||||
const handleResetAccount = async () => {
|
||||
if (!window.confirm(t('forgotAccountConfirm', language))) return
|
||||
try {
|
||||
const res = await fetch('/api/reset-account', { method: 'POST' })
|
||||
if (res.ok) {
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
localStorage.removeItem('user_id')
|
||||
sessionStorage.removeItem('from401')
|
||||
invalidateSystemConfig()
|
||||
toast.success(t('forgotAccountSuccess', language))
|
||||
setTimeout(() => { window.location.href = '/setup' }, 1500)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
toast.error(data.error || 'Reset failed')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
const result = await login(email, password)
|
||||
const result = await login(email, password, mode)
|
||||
setLoading(false)
|
||||
if (result.success) {
|
||||
if (expiredToastId) toast.dismiss(expiredToastId)
|
||||
@@ -41,6 +75,8 @@ export function LoginPage() {
|
||||
|
||||
return (
|
||||
<DeepVoidBackground disableAnimation>
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="flex-1 flex items-center justify-center px-4 py-16">
|
||||
<div className="w-full max-w-sm">
|
||||
|
||||
@@ -109,6 +145,12 @@ export function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnboardingModeSelector
|
||||
language={language}
|
||||
mode={mode}
|
||||
onChange={setMode}
|
||||
/>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
@@ -125,6 +167,16 @@ export function LoginPage() {
|
||||
{loading ? t('loggingIn', language) || 'Signing in...' : t('signIn', language) || 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetAccount}
|
||||
className="text-xs text-zinc-600 hover:text-red-400 transition-colors"
|
||||
>
|
||||
{t('forgotAccount', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
75
web/src/components/auth/OnboardingModeSelector.tsx
Normal file
75
web/src/components/auth/OnboardingModeSelector.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { UserMode } from '../../lib/onboarding'
|
||||
|
||||
interface OnboardingModeSelectorProps {
|
||||
language: string
|
||||
mode: UserMode
|
||||
onChange: (mode: UserMode) => void
|
||||
}
|
||||
|
||||
export function OnboardingModeSelector({
|
||||
language,
|
||||
mode,
|
||||
onChange,
|
||||
}: OnboardingModeSelectorProps) {
|
||||
const isZh = language === 'zh'
|
||||
|
||||
const options: Array<{
|
||||
id: UserMode
|
||||
title: string
|
||||
badge?: string
|
||||
description: string
|
||||
}> = [
|
||||
{
|
||||
id: 'beginner',
|
||||
title: isZh ? '新手模式' : 'Beginner Mode',
|
||||
badge: isZh ? '推荐' : 'Recommended',
|
||||
description: isZh
|
||||
? '自动生成 Base 钱包,默认接入 Claw402 + GLM,最快完成首次启动。'
|
||||
: 'Generate a Base wallet automatically and start with Claw402 + GLM by default.',
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
title: isZh ? '老手模式' : 'Advanced Mode',
|
||||
description: isZh
|
||||
? '保持现在的完整配置流程,你自己决定模型、钱包和交易所。'
|
||||
: 'Keep the full manual flow and configure models, wallets, and exchanges yourself.',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-zinc-400">
|
||||
{isZh ? '使用模式' : 'Experience'}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{options.map((option) => {
|
||||
const selected = option.id === mode
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onChange(option.id)}
|
||||
className={`w-full rounded-xl border px-4 py-3 text-left transition-all ${
|
||||
selected
|
||||
? 'border-nofx-gold/60 bg-nofx-gold/10 shadow-[0_0_0_1px_rgba(240,185,11,0.15)]'
|
||||
: 'border-zinc-800 bg-zinc-950/60 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<span>{option.title}</span>
|
||||
{option.badge ? (
|
||||
<span className="rounded-full bg-nofx-gold px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-black">
|
||||
{option.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-zinc-400">
|
||||
{option.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export function AdvancedChart({
|
||||
try {
|
||||
const limit = 1500
|
||||
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}&exchange=${exchange}`
|
||||
const result = await httpClient.get(klineUrl)
|
||||
const result = await httpClient.request(klineUrl, { silent: true })
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error('Failed to fetch kline data')
|
||||
@@ -243,7 +243,10 @@ export function AdvancedChart({
|
||||
try {
|
||||
console.log('[AdvancedChart] Fetching orders for trader:', traderID, 'symbol:', symbol)
|
||||
// Fetch filled orders, up to 200 for more history
|
||||
const result = await httpClient.get(`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=200`)
|
||||
const result = await httpClient.request(
|
||||
`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=200`,
|
||||
{ silent: true }
|
||||
)
|
||||
|
||||
console.log('[AdvancedChart] Orders API response:', result)
|
||||
|
||||
@@ -326,7 +329,10 @@ export function AdvancedChart({
|
||||
const fetchOpenOrders = async (traderID: string, symbol: string): Promise<OpenOrder[]> => {
|
||||
try {
|
||||
console.log('[AdvancedChart] Fetching open orders for trader:', traderID, 'symbol:', symbol)
|
||||
const result = await httpClient.get(`/api/open-orders?trader_id=${traderID}&symbol=${symbol}`)
|
||||
const result = await httpClient.request(
|
||||
`/api/open-orders?trader_id=${traderID}&symbol=${symbol}`,
|
||||
{ silent: true }
|
||||
)
|
||||
|
||||
console.log('[AdvancedChart] Open orders API response:', result)
|
||||
|
||||
|
||||
@@ -123,7 +123,6 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
// Auto-switch to kline chart when symbol selected externally
|
||||
useEffect(() => {
|
||||
if (selectedSymbol) {
|
||||
console.log('[ChartTabs] Symbol selected:', selectedSymbol, 'updateKey:', updateKey)
|
||||
setChartSymbol(selectedSymbol)
|
||||
setActiveTab('kline')
|
||||
}
|
||||
@@ -143,8 +142,6 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[ChartTabs] rendering, activeTab:', activeTab)
|
||||
|
||||
return (
|
||||
<div className={`nofx-glass rounded-lg border border-white/5 relative z-10 w-full flex flex-col transition-all duration-300 ${typeof window !== 'undefined' && window.innerWidth < 768 ? 'h-[500px]' : 'h-[600px]'
|
||||
}`}>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function ChartWithOrders({
|
||||
const limit = 2000 // Fetch recent 2000 candles (more historical data)
|
||||
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}&exchange=${exchange}`
|
||||
|
||||
const result = await httpClient.get(klineUrl)
|
||||
const result = await httpClient.request(klineUrl, { silent: true })
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error('Failed to fetch kline data from our service')
|
||||
@@ -142,7 +142,10 @@ export function ChartWithOrders({
|
||||
const fetchOrders = async (traderID: string, symbol: string): Promise<OrderMarker[]> => {
|
||||
try {
|
||||
// Fetch filled orders for this trader from backend API
|
||||
const result = await httpClient.get(`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=50`)
|
||||
const result = await httpClient.request(
|
||||
`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=50`,
|
||||
{ silent: true }
|
||||
)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
console.warn('Failed to fetch orders:', result.message)
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ChartWithOrdersSimple({
|
||||
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}`
|
||||
|
||||
console.log('[ChartSimple] Fetching klines from our service:', klineUrl)
|
||||
const klineResult = await httpClient.get(klineUrl)
|
||||
const klineResult = await httpClient.request(klineUrl, { silent: true })
|
||||
|
||||
if (!klineResult.success || !klineResult.data) {
|
||||
throw new Error('Failed to fetch klines from our service')
|
||||
@@ -44,7 +44,7 @@ export function ChartWithOrdersSimple({
|
||||
if (traderID) {
|
||||
const tradesUrl = `/api/trades?trader_id=${traderID}&symbol=${symbol}&limit=100`
|
||||
console.log('[ChartSimple] Fetching trades from:', tradesUrl)
|
||||
const tradesResult = await httpClient.get(tradesUrl)
|
||||
const tradesResult = await httpClient.request(tradesUrl, { silent: true })
|
||||
|
||||
if (tradesResult.success && tradesResult.data) {
|
||||
console.log('[ChartSimple] Received trades:', tradesResult.data.length)
|
||||
|
||||
@@ -43,7 +43,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
|
||||
const { data: history, error, isLoading } = useSWR<EquityPoint[]>(
|
||||
user && token && traderId ? `equity-history-${traderId}` : null,
|
||||
() => api.getEquityHistory(traderId),
|
||||
() => api.getEquityHistory(traderId, true),
|
||||
{
|
||||
refreshInterval: 30000, // 30秒刷新(历史数据更新频率较低)
|
||||
revalidateOnFocus: false,
|
||||
@@ -53,7 +53,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
|
||||
const { data: account } = useSWR(
|
||||
user && token && traderId ? `account-${traderId}` : null,
|
||||
() => api.getAccount(traderId),
|
||||
() => api.getAccount(traderId, true),
|
||||
{
|
||||
refreshInterval: 15000, // 15秒刷新(配合后端缓存)
|
||||
revalidateOnFocus: false,
|
||||
|
||||
@@ -9,27 +9,35 @@ interface DeepVoidBackgroundProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
export function DeepVoidBackground({ children, className = '', disableAnimation = false, ...props }: DeepVoidBackgroundProps) {
|
||||
return (
|
||||
<div className={`relative w-full min-h-screen bg-nofx-bg text-nofx-text overflow-hidden flex flex-col ${className}`} {...props}>
|
||||
{/* BACKGROUND LAYERS */}
|
||||
{/* Background layers: use a much lighter static stack when animations are disabled */}
|
||||
{disableAnimation ? (
|
||||
<>
|
||||
<div className="absolute inset-0 pointer-events-none z-0 bg-[radial-gradient(circle_at_top,rgba(240,185,11,0.08),transparent_38%),linear-gradient(180deg,rgba(12,14,20,0.98),rgba(8,10,15,1))]"></div>
|
||||
<div className="absolute inset-0 pointer-events-none z-0 opacity-[0.035] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:36px_36px]"></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 1. Grain/Noise Texture */}
|
||||
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 mix-blend-soft-light pointer-events-none fixed z-0"></div>
|
||||
|
||||
{/* 1. Grain/Noise Texture */}
|
||||
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 mix-blend-soft-light pointer-events-none fixed z-0"></div>
|
||||
{/* 2. Grid System */}
|
||||
<div className="absolute inset-0 pointer-events-none fixed z-0">
|
||||
<div className="absolute inset-x-0 bottom-0 h-[50vh] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] opacity-50" style={{ transform: 'perspective(500px) rotateX(60deg) translateY(100px) scale(2)' }}></div>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.03]"></div>
|
||||
</div>
|
||||
|
||||
{/* 2. Grid System */}
|
||||
<div className="absolute inset-0 pointer-events-none fixed z-0">
|
||||
<div className="absolute inset-x-0 bottom-0 h-[50vh] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] opacity-50" style={{ transform: 'perspective(500px) rotateX(60deg) translateY(100px) scale(2)' }}></div>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.03]"></div>
|
||||
</div>
|
||||
{/* 3. Ambient Glow Spots */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none fixed z-0">
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-nofx-gold/10 rounded-full blur-[120px] mix-blend-screen animate-pulse-slow"></div>
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40vw] h-[40vw] bg-nofx-accent/5 rounded-full blur-[120px] mix-blend-screen animate-pulse-slow" style={{ animationDelay: '2s' }}></div>
|
||||
</div>
|
||||
|
||||
{/* 3. Ambient Glow Spots */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none fixed z-0">
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-nofx-gold/10 rounded-full blur-[120px] mix-blend-screen animate-pulse-slow"></div>
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40vw] h-[40vw] bg-nofx-accent/5 rounded-full blur-[120px] mix-blend-screen animate-pulse-slow" style={{ animationDelay: '2s' }}></div>
|
||||
</div>
|
||||
|
||||
{/* 4. CRT/Scanline Overlay */}
|
||||
<div className="absolute inset-0 pointer-events-none fixed z-[9999] opacity-40">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,255,0.06))] bg-[length:100%_4px,3px_100%] pointer-events-none"></div>
|
||||
</div>
|
||||
{/* 4. CRT/Scanline Overlay */}
|
||||
<div className="absolute inset-0 pointer-events-none fixed z-[9999] opacity-40">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,255,0.06))] bg-[length:100%_4px,3px_100%] pointer-events-none"></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content Layer */}
|
||||
<div className="relative z-10 flex-1 flex flex-col h-full w-full">
|
||||
|
||||
@@ -4,6 +4,12 @@ import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Menu, X, ChevronDown, Settings } from 'lucide-react'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
import { OFFICIAL_LINKS } from '../../constants/branding'
|
||||
import {
|
||||
getPostAuthPath,
|
||||
getUserMode,
|
||||
setUserMode,
|
||||
type UserMode,
|
||||
} from '../../lib/onboarding'
|
||||
|
||||
type Page =
|
||||
| 'competition'
|
||||
@@ -44,8 +50,21 @@ export default function HeaderBar({
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const userDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const navigateInApp = (path: string) => {
|
||||
navigate(path)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const handleSwitchMode = (nextMode: UserMode) => {
|
||||
setUserMode(nextMode)
|
||||
setUserModeState(nextMode)
|
||||
setUserDropdownOpen(false)
|
||||
navigateInApp(getPostAuthPath(nextMode))
|
||||
}
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -216,6 +235,15 @@ export default function HeaderBar({
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSwitchMode(userMode === 'beginner' ? 'advanced' : 'beginner')}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
{userMode === 'beginner'
|
||||
? language === 'zh' ? '切到老手模式' : 'Switch to Advanced'
|
||||
: language === 'zh' ? '切到新手模式' : 'Switch to Beginner'}
|
||||
</button>
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
33
web/src/components/common/LanguageSwitcher.tsx
Normal file
33
web/src/components/common/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Globe } from 'lucide-react'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import type { Language } from '../../i18n/translations'
|
||||
|
||||
const languages: { code: Language; label: string }[] = [
|
||||
{ code: 'zh', label: '中文' },
|
||||
{ code: 'en', label: 'EN' },
|
||||
{ code: 'id', label: 'ID' },
|
||||
]
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { language, setLanguage } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 right-4 z-50 flex items-center gap-1 rounded-lg p-1 border border-white/10 bg-white/5 backdrop-blur-sm">
|
||||
<Globe size={14} className="text-zinc-500 ml-1.5 mr-0.5" />
|
||||
{languages.map(({ code, label }) => (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
onClick={() => setLanguage(code)}
|
||||
className={`px-2.5 py-1 rounded text-xs font-semibold transition-all ${
|
||||
language === code
|
||||
? 'bg-nofx-gold/15 text-nofx-gold'
|
||||
: 'text-zinc-500 hover:text-zinc-300 bg-transparent'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +1,163 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||
import { invalidateSystemConfig } from '../../lib/config'
|
||||
import { OnboardingModeSelector } from '../auth/OnboardingModeSelector'
|
||||
import type { UserMode } from '../../lib/onboarding'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { LanguageSwitcher } from '../common/LanguageSwitcher'
|
||||
|
||||
const labels = {
|
||||
zh: {
|
||||
welcome: '欢迎使用 NOFX',
|
||||
subtitle: '创建账号开始使用',
|
||||
email: '邮箱',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
password: '密码',
|
||||
passwordPlaceholder: '至少 8 个字符',
|
||||
passwordError: '密码至少需要 8 个字符',
|
||||
submit: '开始使用',
|
||||
submitting: '创建中...',
|
||||
setupFailed: '创建失败,请重试',
|
||||
singleUser: '单用户系统 — 这是唯一的账号',
|
||||
},
|
||||
en: {
|
||||
welcome: 'Welcome to NOFX',
|
||||
subtitle: 'Create your account to get started',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'At least 8 characters',
|
||||
passwordError: 'Password must be at least 8 characters',
|
||||
submit: 'Get Started',
|
||||
submitting: 'Creating account...',
|
||||
setupFailed: 'Setup failed, please try again',
|
||||
singleUser: 'Single-user system — this is the only account',
|
||||
},
|
||||
id: {
|
||||
welcome: 'Selamat Datang di NOFX',
|
||||
subtitle: 'Buat akun untuk memulai',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
password: 'Kata Sandi',
|
||||
passwordPlaceholder: 'Minimal 8 karakter',
|
||||
passwordError: 'Kata sandi minimal 8 karakter',
|
||||
submit: 'Mulai',
|
||||
submitting: 'Membuat akun...',
|
||||
setupFailed: 'Gagal membuat akun, coba lagi',
|
||||
singleUser: 'Sistem pengguna tunggal — ini satu-satunya akun',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function SetupPage() {
|
||||
const { language } = useLanguage()
|
||||
const { register } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [mode, setMode] = useState<UserMode>('beginner')
|
||||
|
||||
// Clean up any stale auth/onboarding state on setup page load
|
||||
useEffect(() => {
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
localStorage.removeItem('user_id')
|
||||
localStorage.removeItem('nofx_beginner_onboarding_completed')
|
||||
localStorage.removeItem('nofx_beginner_wallet_address')
|
||||
}, [])
|
||||
|
||||
const l = labels[language as keyof typeof labels] || labels.en
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
setError(l.passwordError)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
const result = await register(email, password)
|
||||
const result = await register(email, password, undefined, mode)
|
||||
setLoading(false)
|
||||
if (result.success) {
|
||||
invalidateSystemConfig()
|
||||
window.location.href = '/traders'
|
||||
} else {
|
||||
setError(result.message || 'Setup failed, please try again')
|
||||
setError(result.message || l.setupFailed)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DeepVoidBackground disableAnimation>
|
||||
<div className="flex-1 flex items-center justify-center px-4 py-16">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="relative min-h-screen w-full overflow-hidden bg-[#0a0a0f]">
|
||||
{/* Decorative background - simulates the main app behind a modal */}
|
||||
|
||||
{/* Grid */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute inset-x-0 bottom-0 h-[60vh] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] opacity-40" style={{ transform: 'perspective(500px) rotateX(60deg) translateY(80px) scale(2)' }} />
|
||||
</div>
|
||||
|
||||
{/* Glow spots */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-[10%] left-[15%] w-[500px] h-[500px] bg-nofx-gold/8 rounded-full blur-[150px]" />
|
||||
<div className="absolute bottom-[5%] right-[10%] w-[400px] h-[400px] bg-indigo-500/6 rounded-full blur-[140px]" />
|
||||
<div className="absolute top-[40%] right-[30%] w-[300px] h-[300px] bg-emerald-500/4 rounded-full blur-[120px]" />
|
||||
</div>
|
||||
|
||||
{/* Faux UI elements in background to simulate the app */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.06]">
|
||||
{/* Fake header bar */}
|
||||
<div className="h-14 border-b border-white/20 flex items-center px-6 gap-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-white/40" />
|
||||
<div className="h-3 w-20 rounded bg-white/30" />
|
||||
<div className="h-3 w-16 rounded bg-white/20 ml-4" />
|
||||
<div className="h-3 w-16 rounded bg-white/20" />
|
||||
<div className="h-3 w-16 rounded bg-white/20" />
|
||||
</div>
|
||||
{/* Fake content cards */}
|
||||
<div className="p-6 grid grid-cols-4 gap-4 mt-2">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-24 rounded-xl border border-white/15 bg-white/5" />
|
||||
))}
|
||||
</div>
|
||||
<div className="px-6 mt-2">
|
||||
<div className="h-64 rounded-xl border border-white/15 bg-white/5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blur overlay */}
|
||||
<div className="absolute inset-0 backdrop-blur-md bg-black/60" />
|
||||
|
||||
<LanguageSwitcher />
|
||||
|
||||
{/* Modal card */}
|
||||
<div className="relative z-10 flex min-h-screen items-center justify-center px-4 py-16">
|
||||
<div className="w-full max-w-sm animate-[fadeInUp_0.4s_ease-out]">
|
||||
|
||||
{/* Logo + Title */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="flex justify-center mb-5">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-3 bg-nofx-gold/15 rounded-full blur-2xl" />
|
||||
<img src="/icons/nofx.svg" alt="NOFX" className="w-14 h-14 relative z-10" />
|
||||
<div className="absolute -inset-4 bg-nofx-gold/20 rounded-full blur-2xl" />
|
||||
<img src="/icons/nofx.svg" alt="NOFX" className="w-14 h-14 relative z-10 drop-shadow-[0_0_15px_rgba(240,185,11,0.3)]" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1.5">Welcome to NOFX</h1>
|
||||
<p className="text-zinc-500 text-sm">Create your account to get started</p>
|
||||
<h1 className="text-2xl font-bold text-white mb-1.5">{l.welcome}</h1>
|
||||
<p className="text-zinc-500 text-sm">{l.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-8 shadow-2xl">
|
||||
<div className="bg-zinc-900/80 backdrop-blur-2xl border border-white/10 rounded-2xl p-8 shadow-[0_25px_60px_-15px_rgba(0,0,0,0.5),0_0_40px_-10px_rgba(240,185,11,0.08)]">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">Email</label>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">{l.email}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
|
||||
placeholder="you@example.com"
|
||||
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
|
||||
placeholder={l.emailPlaceholder}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
@@ -67,14 +165,14 @@ export function SetupPage() {
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">Password</label>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">{l.password}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
|
||||
placeholder="At least 8 characters"
|
||||
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
|
||||
placeholder={l.passwordPlaceholder}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
@@ -87,6 +185,12 @@ export function SetupPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnboardingModeSelector
|
||||
language={language}
|
||||
mode={mode}
|
||||
onChange={setMode}
|
||||
/>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
@@ -98,18 +202,25 @@ export function SetupPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-nofx-gold hover:bg-yellow-400 active:scale-[0.98] text-black font-semibold py-3 rounded-xl text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-2"
|
||||
className="w-full bg-nofx-gold hover:bg-yellow-400 active:scale-[0.98] text-black font-semibold py-3 rounded-xl text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-2 shadow-[0_0_20px_rgba(240,185,11,0.2)]"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Get Started'}
|
||||
{loading ? l.submitting : l.submit}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-zinc-600 mt-6">
|
||||
Single-user system — this is the only account
|
||||
{l.singleUser}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react'
|
||||
import type { CoinSourceConfig } from '../../types'
|
||||
import { coinSource, ts } from '../../i18n/strategy-translations'
|
||||
import { NofxSelect } from '../ui/select'
|
||||
|
||||
interface CoinSourceEditorProps {
|
||||
config: CoinSourceConfig
|
||||
@@ -24,7 +25,6 @@ export function CoinSourceEditor({
|
||||
{ value: 'ai500', icon: Database, color: '#F0B90B' },
|
||||
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
||||
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
|
||||
{ value: 'mixed', icon: Shuffle, color: '#60a5fa' },
|
||||
] as const
|
||||
|
||||
// Calculate mixed mode summary
|
||||
@@ -33,16 +33,16 @@ export function CoinSourceEditor({
|
||||
let totalLimit = 0
|
||||
|
||||
if (config.use_ai500) {
|
||||
sources.push(`AI500(${config.ai500_limit || 10})`)
|
||||
totalLimit += config.ai500_limit || 10
|
||||
sources.push(`AI500(${config.ai500_limit || 3})`)
|
||||
totalLimit += config.ai500_limit || 3
|
||||
}
|
||||
if (config.use_oi_top) {
|
||||
sources.push(`${ts(coinSource.oiIncreaseShort, language)}(${config.oi_top_limit || 10})`)
|
||||
totalLimit += config.oi_top_limit || 10
|
||||
sources.push(`${ts(coinSource.oiIncreaseShort, language)}(${config.oi_top_limit || 3})`)
|
||||
totalLimit += config.oi_top_limit || 3
|
||||
}
|
||||
if (config.use_oi_low) {
|
||||
sources.push(`${ts(coinSource.oiDecreaseShort, language)}(${config.oi_low_limit || 10})`)
|
||||
totalLimit += config.oi_low_limit || 10
|
||||
sources.push(`${ts(coinSource.oiDecreaseShort, language)}(${config.oi_low_limit || 3})`)
|
||||
totalLimit += config.oi_low_limit || 3
|
||||
}
|
||||
if ((config.static_coins || []).length > 0) {
|
||||
sources.push(`${ts(coinSource.custom, language)}(${config.static_coins?.length || 0})`)
|
||||
@@ -71,8 +71,26 @@ export function CoinSourceEditor({
|
||||
return xyzDexAssets.has(base)
|
||||
}
|
||||
|
||||
const MAX_STATIC_COINS = 10
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
const toast = document.createElement('div')
|
||||
toast.textContent = msg
|
||||
toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg'
|
||||
toast.style.cssText = 'background:#F6465D;color:#fff;'
|
||||
document.body.appendChild(toast)
|
||||
setTimeout(() => toast.remove(), 2000)
|
||||
}
|
||||
|
||||
const handleAddCoin = () => {
|
||||
if (!newCoin.trim()) return
|
||||
|
||||
const currentCoins = config.static_coins || []
|
||||
if (currentCoins.length >= MAX_STATIC_COINS) {
|
||||
showToast(language === 'zh' ? `最多添加 ${MAX_STATIC_COINS} 个币种` : `Maximum ${MAX_STATIC_COINS} coins allowed`)
|
||||
return
|
||||
}
|
||||
|
||||
const symbol = newCoin.toUpperCase().trim()
|
||||
|
||||
// For xyz dex assets (stocks, forex, commodities), use xyz: prefix without USDT
|
||||
@@ -85,7 +103,6 @@ export function CoinSourceEditor({
|
||||
formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT`
|
||||
}
|
||||
|
||||
const currentCoins = config.static_coins || []
|
||||
if (!currentCoins.includes(formattedSymbol)) {
|
||||
onChange({
|
||||
...config,
|
||||
@@ -148,7 +165,7 @@ export function CoinSourceEditor({
|
||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||
{ts(coinSource.sourceType, language)}
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{sourceTypes.map(({ value, icon: Icon, color }) => (
|
||||
<button
|
||||
key={value}
|
||||
@@ -309,19 +326,16 @@ export function CoinSourceEditor({
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{ts(coinSource.ai500Limit, language)}:
|
||||
</span>
|
||||
<select
|
||||
value={config.ai500_limit || 10}
|
||||
onChange={(e) =>
|
||||
<NofxSelect
|
||||
value={config.ai500_limit || 3}
|
||||
onChange={(val) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
|
||||
onChange({ ...config, ai500_limit: parseInt(val) || 3 })
|
||||
}
|
||||
disabled={disabled}
|
||||
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].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"
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -366,19 +380,16 @@ export function CoinSourceEditor({
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{ts(coinSource.oiTopLimit, language)}:
|
||||
</span>
|
||||
<select
|
||||
value={config.oi_top_limit || 10}
|
||||
onChange={(e) =>
|
||||
<NofxSelect
|
||||
value={config.oi_top_limit || 3}
|
||||
onChange={(val) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
||||
onChange({ ...config, oi_top_limit: parseInt(val) || 3 })
|
||||
}
|
||||
disabled={disabled}
|
||||
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].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"
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -423,19 +434,16 @@ export function CoinSourceEditor({
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{ts(coinSource.oiLowLimit, language)}:
|
||||
</span>
|
||||
<select
|
||||
value={config.oi_low_limit || 10}
|
||||
onChange={(e) =>
|
||||
<NofxSelect
|
||||
value={config.oi_low_limit || 3}
|
||||
onChange={(val) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
||||
onChange({ ...config, oi_low_limit: parseInt(val) || 3 })
|
||||
}
|
||||
disabled={disabled}
|
||||
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].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"
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -483,20 +491,13 @@ export function CoinSourceEditor({
|
||||
{config.use_ai500 && (
|
||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||
<select
|
||||
value={config.ai500_limit || 10}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
!disabled && onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
|
||||
}}
|
||||
<NofxSelect
|
||||
value={config.ai500_limit || 3}
|
||||
onChange={(val) => !disabled && onChange({ ...config, ai500_limit: parseInt(val) || 3 })}
|
||||
disabled={disabled}
|
||||
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -530,20 +531,13 @@ export function CoinSourceEditor({
|
||||
{config.use_oi_top && (
|
||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||
<select
|
||||
value={config.oi_top_limit || 10}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
!disabled && onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
||||
}}
|
||||
<NofxSelect
|
||||
value={config.oi_top_limit || 3}
|
||||
onChange={(val) => !disabled && onChange({ ...config, oi_top_limit: parseInt(val) || 3 })}
|
||||
disabled={disabled}
|
||||
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -577,20 +571,13 @@ export function CoinSourceEditor({
|
||||
{config.use_oi_low && (
|
||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||
<select
|
||||
value={config.oi_low_limit || 10}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
!disabled && onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
||||
}}
|
||||
<NofxSelect
|
||||
value={config.oi_low_limit || 3}
|
||||
onChange={(val) => !disabled && onChange({ ...config, oi_low_limit: parseInt(val) || 3 })}
|
||||
disabled={disabled}
|
||||
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react'
|
||||
import type { GridStrategyConfig } from '../../types'
|
||||
import { gridConfig, ts } from '../../i18n/strategy-translations'
|
||||
import { NofxSelect } from '../ui/select'
|
||||
|
||||
interface GridConfigEditorProps {
|
||||
config: GridStrategyConfig
|
||||
@@ -74,20 +75,21 @@ export function GridConfigEditor({
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{ts(gridConfig.symbolDesc, language)}
|
||||
</p>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={config.symbol}
|
||||
onChange={(e) => updateField('symbol', e.target.value)}
|
||||
onChange={(val) => updateField('symbol', val)}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="BTCUSDT">BTC/USDT</option>
|
||||
<option value="ETHUSDT">ETH/USDT</option>
|
||||
<option value="SOLUSDT">SOL/USDT</option>
|
||||
<option value="BNBUSDT">BNB/USDT</option>
|
||||
<option value="XRPUSDT">XRP/USDT</option>
|
||||
<option value="DOGEUSDT">DOGE/USDT</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: 'BTCUSDT', label: 'BTC/USDT' },
|
||||
{ value: 'ETHUSDT', label: 'ETH/USDT' },
|
||||
{ value: 'SOLUSDT', label: 'SOL/USDT' },
|
||||
{ value: 'BNBUSDT', label: 'BNB/USDT' },
|
||||
{ value: 'XRPUSDT', label: 'XRP/USDT' },
|
||||
{ value: 'DOGEUSDT', label: 'DOGE/USDT' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Investment */}
|
||||
@@ -170,17 +172,18 @@ export function GridConfigEditor({
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{ts(gridConfig.distributionDesc, language)}
|
||||
</p>
|
||||
<select
|
||||
<NofxSelect
|
||||
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}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="uniform">{ts(gridConfig.uniform, language)}</option>
|
||||
<option value="gaussian">{ts(gridConfig.gaussian, language)}</option>
|
||||
<option value="pyramid">{ts(gridConfig.pyramid, language)}</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: 'uniform', label: ts(gridConfig.uniform, language) },
|
||||
{ value: 'gaussian', label: ts(gridConfig.gaussian, language) },
|
||||
{ value: 'pyramid', label: ts(gridConfig.pyramid, language) },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Clock, Activity, TrendingUp, BarChart2, Info, Lock, ExternalLink, Zap, Check, AlertCircle, Key } from 'lucide-react'
|
||||
import type { IndicatorConfig } from '../../types'
|
||||
import { indicator, ts } from '../../i18n/strategy-translations'
|
||||
import { NofxSelect } from '../ui/select'
|
||||
|
||||
// Default NofxOS API Key
|
||||
const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c'
|
||||
@@ -60,6 +61,16 @@ export function IndicatorEditor({
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (current.length >= 4) {
|
||||
// Show toast notification
|
||||
const toast = document.createElement('div')
|
||||
toast.textContent = language === 'zh' ? '最多选择 4 个时间维度' : 'Maximum 4 timeframes allowed'
|
||||
toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg'
|
||||
toast.style.cssText = 'background:#F6465D;color:#fff;'
|
||||
document.body.appendChild(toast)
|
||||
setTimeout(() => toast.remove(), 2000)
|
||||
return
|
||||
}
|
||||
current.push(tf)
|
||||
onChange({
|
||||
...config,
|
||||
@@ -299,26 +310,22 @@ export function IndicatorEditor({
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.oiRankingDesc, language)}</p>
|
||||
{config.enable_oi_ranking && (
|
||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<select
|
||||
<NofxSelect
|
||||
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}
|
||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<option value="1h">1h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="24h">24h</option>
|
||||
</select>
|
||||
<select
|
||||
options={[{ value: '1h', label: '1h' }, { value: '4h', label: '4h' }, { value: '24h', label: '24h' }]}
|
||||
/>
|
||||
<NofxSelect
|
||||
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}
|
||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -359,26 +366,22 @@ export function IndicatorEditor({
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.netflowRankingDesc, language)}</p>
|
||||
{config.enable_netflow_ranking && (
|
||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<select
|
||||
<NofxSelect
|
||||
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}
|
||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<option value="1h">1h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="24h">24h</option>
|
||||
</select>
|
||||
<select
|
||||
options={[{ value: '1h', label: '1h' }, { value: '4h', label: '4h' }, { value: '24h', label: '24h' }]}
|
||||
/>
|
||||
<NofxSelect
|
||||
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}
|
||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -419,27 +422,27 @@ export function IndicatorEditor({
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.priceRankingDesc, language)}</p>
|
||||
{config.enable_price_ranking && (
|
||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<select
|
||||
<NofxSelect
|
||||
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}
|
||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<option value="1h">1h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="24h">24h</option>
|
||||
<option value="1h,4h,24h">{ts(indicator.priceRankingMulti, language)}</option>
|
||||
</select>
|
||||
<select
|
||||
options={[
|
||||
{ value: '1h', label: '1h' },
|
||||
{ value: '4h', label: '4h' },
|
||||
{ value: '24h', label: '24h' },
|
||||
{ value: '1h,4h,24h', label: ts(indicator.priceRankingMulti, language) },
|
||||
]}
|
||||
/>
|
||||
<NofxSelect
|
||||
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}
|
||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -515,7 +518,7 @@ export function IndicatorEditor({
|
||||
}
|
||||
disabled={disabled}
|
||||
min={10}
|
||||
max={200}
|
||||
max={30}
|
||||
className="w-16 px-2 py-1 rounded text-xs text-center"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function RiskControlEditor({
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={10}
|
||||
max={3}
|
||||
className="w-32 px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
|
||||
122
web/src/components/strategy/TokenEstimateBar.tsx
Normal file
122
web/src/components/strategy/TokenEstimateBar.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
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
|
||||
onTokenCountChange?: (total: number) => void
|
||||
}
|
||||
|
||||
export function TokenEstimateBar({ config, language, onTokenCountChange }: 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)
|
||||
onTokenCountChange?.(data.total)
|
||||
}
|
||||
} catch {
|
||||
// silently ignore — non-critical UI element
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
}
|
||||
}, [config])
|
||||
|
||||
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
|
||||
|
||||
// Display based on 200K reference
|
||||
const pct = Math.round(estimate.total * 100 / 200000)
|
||||
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'
|
||||
}
|
||||
|
||||
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')} (~{estimate.total.toLocaleString()} / 200K)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CreateTraderRequest,
|
||||
AIModel,
|
||||
Exchange,
|
||||
ExchangeAccountState,
|
||||
} from '../../types'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
@@ -18,13 +19,22 @@ import { TelegramConfigModal } from './TelegramConfigModal'
|
||||
import { ModelConfigModal } from './ModelConfigModal'
|
||||
import { ConfigStatusGrid } from './ConfigStatusGrid'
|
||||
import { TradersList } from './TradersList'
|
||||
import { BeginnerGuideCards } from './BeginnerGuideCards'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Plus,
|
||||
MessageCircle,
|
||||
} from 'lucide-react'
|
||||
import { confirmToast } from '../../lib/notify'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
getBeginnerWalletAddress,
|
||||
getUserMode,
|
||||
setBeginnerWalletAddress as persistBeginnerWalletAddress,
|
||||
} from '../../lib/onboarding'
|
||||
import type { Strategy } from '../../types'
|
||||
import { ApiError } from '../../lib/httpClient'
|
||||
|
||||
interface AITradersPageProps {
|
||||
onTraderSelect?: (traderId: string) => void
|
||||
@@ -48,6 +58,197 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
|
||||
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
const [quickSetupLoading, setQuickSetupLoading] = useState(false)
|
||||
const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<string | null>(() => getBeginnerWalletAddress())
|
||||
const isBeginnerMode = getUserMode() === 'beginner'
|
||||
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (error instanceof Error && error.message.trim() !== '') {
|
||||
return error.message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
const formatActionableDescriptionByKey = (
|
||||
errorKey: string,
|
||||
params: Record<string, string> = {},
|
||||
fallback: string
|
||||
) => {
|
||||
const traderName = params.trader_name || params.traderName || 'this trader'
|
||||
const modelName = params.model_name || params.modelName || 'selected model'
|
||||
const exchangeName = params.exchange_name || params.exchangeName || 'selected exchange account'
|
||||
const reason = localizeTraderReason(params.reason_key, params.reason || fallback)
|
||||
const symbol = params.symbol || ''
|
||||
|
||||
const zh = language === 'zh'
|
||||
|
||||
switch (errorKey) {
|
||||
case 'trader.create.invalid_request':
|
||||
return zh ? '提交的信息不完整,或者格式不正确。请检查后重新提交。' : 'The submitted information is incomplete or invalid. Please review it and try again.'
|
||||
case 'trader.create.invalid_btc_eth_leverage':
|
||||
return zh ? 'BTC/ETH 杠杆倍数需要在 1 到 50 倍之间。' : 'BTC/ETH leverage must be between 1x and 50x.'
|
||||
case 'trader.create.invalid_altcoin_leverage':
|
||||
return zh ? '山寨币杠杆倍数需要在 1 到 20 倍之间。' : 'Altcoin leverage must be between 1x and 20x.'
|
||||
case 'trader.create.invalid_symbol':
|
||||
return zh ? `交易对 ${symbol} 的格式不正确,目前只支持以 USDT 结尾的合约交易对。` : `Trading pair ${symbol} is invalid. Only perpetual pairs ending with USDT are supported.`
|
||||
case 'trader.create.model_not_found':
|
||||
return zh ? '还没有找到你选择的 AI 模型。请先到「设置 > 模型配置」添加并启用一个可用模型。' : 'The selected AI model was not found. Please add and enable a valid model in Settings > Model Config.'
|
||||
case 'trader.create.model_disabled':
|
||||
return zh ? `AI 模型「${modelName}」目前还没有启用。请先启用它再创建机器人。` : `AI model "${modelName}" is currently disabled. Please enable it before creating a trader.`
|
||||
case 'trader.create.model_missing_credentials':
|
||||
return zh ? `AI 模型「${modelName}」缺少 API Key 或支付凭证。请先补全模型配置。` : `AI model "${modelName}" is missing API credentials or payment setup. Please complete the model configuration first.`
|
||||
case 'trader.create.strategy_required':
|
||||
return zh ? '你还没有选择交易策略。请先选择一个策略,再继续创建机器人。' : 'No trading strategy is selected yet. Please choose a strategy before creating a trader.'
|
||||
case 'trader.create.strategy_not_found':
|
||||
return zh ? '你选择的策略不存在,或者已经被删除了。请重新选择一个可用策略。' : 'The selected strategy no longer exists. Please choose another available strategy.'
|
||||
case 'trader.create.exchange_not_found':
|
||||
return zh ? '还没有找到你选择的交易所账户。请先到「设置 > 交易所配置」添加一个可用账户。' : 'The selected exchange account was not found. Please add an exchange account in Settings > Exchange Config.'
|
||||
case 'trader.create.exchange_disabled':
|
||||
return zh ? `交易所账户「${exchangeName}」目前处于未启用状态。请先启用它。` : `Exchange account "${exchangeName}" is currently disabled. Please enable it first.`
|
||||
case 'trader.create.exchange_missing_fields':
|
||||
return zh ? `交易所账户「${exchangeName}」的配置还不完整。请先补全必填信息。` : `Exchange account "${exchangeName}" is incomplete. Please fill in the required fields first.`
|
||||
case 'trader.create.exchange_unsupported':
|
||||
return zh ? `交易所账户「${exchangeName}」当前类型暂不支持机器人创建。` : `Exchange account "${exchangeName}" uses a type that is not supported for trader creation.`
|
||||
case 'trader.create.exchange_probe_failed':
|
||||
return zh ? `交易所账户「${exchangeName}」没有通过初始化校验,原因是:${reason}` : `Exchange account "${exchangeName}" failed initialization checks: ${reason}`
|
||||
case 'trader.start.strategy_missing':
|
||||
return zh ? `机器人「${traderName}」缺少有效的交易策略配置。` : `Trader "${traderName}" does not have a valid strategy configuration.`
|
||||
case 'trader.start.model_not_found':
|
||||
return zh ? `机器人「${traderName}」关联的 AI 模型不存在。请检查模型配置。` : `Trader "${traderName}" references an AI model that no longer exists. Please check the model configuration.`
|
||||
case 'trader.start.model_disabled':
|
||||
return zh ? `机器人「${traderName}」关联的 AI 模型「${modelName}」目前还没有启用。` : `Trader "${traderName}" uses AI model "${modelName}", which is currently disabled.`
|
||||
case 'trader.start.exchange_not_found':
|
||||
return zh ? `机器人「${traderName}」关联的交易所账户不存在。请检查交易所配置。` : `Trader "${traderName}" references an exchange account that no longer exists. Please check the exchange configuration.`
|
||||
case 'trader.start.exchange_disabled':
|
||||
return zh ? `机器人「${traderName}」关联的交易所账户「${exchangeName}」目前还没有启用。` : `Trader "${traderName}" uses exchange account "${exchangeName}", which is currently disabled.`
|
||||
case 'trader.start.setup_invalid':
|
||||
case 'trader.start.load_failed':
|
||||
return zh ? `机器人「${traderName}」暂时还不能启动,原因是:${reason}` : `Trader "${traderName}" cannot be started yet because ${reason}`
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
const localizeTraderReason = (reasonKey?: string, fallback?: string) => {
|
||||
const zh = language === 'zh'
|
||||
|
||||
switch (reasonKey) {
|
||||
case 'trader.reason.strategy_config_invalid':
|
||||
return zh ? '当前策略配置内容已损坏,系统暂时无法解析' : 'the current strategy configuration is corrupted and cannot be parsed'
|
||||
case 'trader.reason.strategy_missing':
|
||||
return zh ? '当前机器人缺少有效的交易策略配置' : 'the trader is missing a valid strategy configuration'
|
||||
case 'trader.reason.private_key_invalid':
|
||||
return zh ? '私钥格式不正确,系统无法识别' : 'the private key format is invalid and cannot be recognized'
|
||||
case 'trader.reason.hyperliquid_init_failed':
|
||||
return zh ? 'Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确' : 'Hyperliquid account initialization failed. Please verify the private key, main wallet address, and Agent Wallet configuration'
|
||||
case 'trader.reason.aster_init_failed':
|
||||
return zh ? 'Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确' : 'Aster account initialization failed. Please verify the Aster User, Signer, and private key'
|
||||
case 'trader.reason.exchange_meta_unavailable':
|
||||
return zh ? '系统暂时无法从交易所读取账户元信息' : 'the system could not read account metadata from the exchange'
|
||||
case 'trader.reason.hyperliquid_agent_balance_too_high':
|
||||
return zh ? 'Hyperliquid Agent Wallet 余额过高,不符合当前安全要求' : 'the Hyperliquid Agent Wallet balance is too high for the current safety requirements'
|
||||
case 'trader.reason.exchange_account_init_failed':
|
||||
return zh ? '交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配' : 'exchange account initialization failed. Please verify that the wallet address and API key match'
|
||||
case 'trader.reason.exchange_unsupported':
|
||||
return zh ? '当前交易所类型暂不支持机器人初始化' : 'the selected exchange type is not currently supported for trader initialization'
|
||||
case 'trader.reason.exchange_balance_unavailable':
|
||||
return zh ? '系统暂时无法从交易所读取账户余额' : 'the system could not read the account balance from the exchange'
|
||||
case 'trader.reason.exchange_service_unreachable':
|
||||
return zh ? '系统暂时无法连接交易所服务' : 'the system could not reach the exchange service right now'
|
||||
default:
|
||||
return fallback || (zh ? '系统返回了一个未知错误' : 'an unknown error was returned by the system')
|
||||
}
|
||||
}
|
||||
const normalizeActionableDescription = (error: unknown, message: string, title: string) => {
|
||||
if (error instanceof ApiError && error.errorKey) {
|
||||
return formatActionableDescriptionByKey(error.errorKey, error.errorParams, message)
|
||||
}
|
||||
|
||||
const prefixes = [
|
||||
'这次未能创建机器人:',
|
||||
'机器人创建失败:',
|
||||
'这次未能更新机器人:',
|
||||
'机器人更新失败:',
|
||||
'这次未能启动机器人:',
|
||||
'Failed to create trader:',
|
||||
'Failed to update trader:',
|
||||
'Unable to create trader:',
|
||||
'Unable to update trader:',
|
||||
'Unable to start trader:',
|
||||
]
|
||||
|
||||
let description = message.trim()
|
||||
if (description === title) return ''
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
if (description.startsWith(prefix)) {
|
||||
description = description.slice(prefix.length).trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return description
|
||||
}
|
||||
const showActionableError = (title: string, error: unknown) => {
|
||||
const message = getErrorMessage(error, title)
|
||||
const description = normalizeActionableDescription(error, message, title)
|
||||
|
||||
if (description === '') {
|
||||
toast.error(title)
|
||||
return
|
||||
}
|
||||
|
||||
toast.error(title, {
|
||||
description,
|
||||
})
|
||||
}
|
||||
const parseBalanceUsdc = (balance?: string) => {
|
||||
if (!balance) return null
|
||||
const parsed = Number.parseFloat(balance)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
const getClaw402BalanceMessage = (balance: number, blocking: boolean) => {
|
||||
if (language === 'zh') {
|
||||
return blocking
|
||||
? `当前 Claw402 钱包余额为 ${balance.toFixed(6)} USDC,AI 调用无法执行。请先为这个钱包充值,再重新点击启动。`
|
||||
: `当前 Claw402 钱包余额仅剩 ${balance.toFixed(6)} USDC,虽然还能尝试启动,但很快可能因为 AI 调用费用不足而停止。建议先补一点 USDC。`
|
||||
}
|
||||
|
||||
return blocking
|
||||
? `Your Claw402 wallet balance is ${balance.toFixed(6)} USDC. AI calls cannot run with zero balance. Please top up this wallet before starting again.`
|
||||
: `Your Claw402 wallet balance is only ${balance.toFixed(6)} USDC. You can still try to start, but AI calls may stop soon due to insufficient funds.`
|
||||
}
|
||||
const getClaw402BalanceIssue = (traderId: string) => {
|
||||
const trader = traders?.find((item) => item.trader_id === traderId)
|
||||
if (!trader) return null
|
||||
|
||||
const model =
|
||||
allModels.find((item) => item.id === trader.ai_model) ||
|
||||
allModels.find((item) => item.provider === trader.ai_model)
|
||||
|
||||
if (!model || model.provider !== 'claw402') return null
|
||||
|
||||
const balance = parseBalanceUsdc(model.balanceUsdc)
|
||||
if (balance === null) return null
|
||||
if (balance <= 0) {
|
||||
return {
|
||||
blocking: true,
|
||||
title: language === 'zh' ? '启动失败' : 'Start failed',
|
||||
description: getClaw402BalanceMessage(balance, true),
|
||||
}
|
||||
}
|
||||
if (balance < 1) {
|
||||
return {
|
||||
blocking: false,
|
||||
title: language === 'zh' ? 'Claw402 余额偏低' : 'Low Claw402 balance',
|
||||
description: getClaw402BalanceMessage(balance, false),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const navigateInApp = (path: string) => {
|
||||
navigate(path)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
// Toggle wallet address visibility for a trader
|
||||
const toggleTraderAddressVisibility = (traderId: string) => {
|
||||
@@ -91,6 +292,23 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
api.getTraders,
|
||||
{ refreshInterval: 5000 }
|
||||
)
|
||||
const {
|
||||
data: exchangeAccountStateData,
|
||||
mutate: mutateExchangeAccountStates,
|
||||
isLoading: isExchangeAccountStatesLoading,
|
||||
} = useSWR<{ states: Record<string, ExchangeAccountState> }>(
|
||||
user && token ? 'exchange-account-state' : null,
|
||||
api.getExchangeAccountState,
|
||||
{
|
||||
refreshInterval: 30000,
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
)
|
||||
const { data: strategies } = useSWR<Strategy[]>(
|
||||
user && token ? 'strategies' : null,
|
||||
api.getStrategies,
|
||||
{ refreshInterval: 30000 }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfigs = async () => {
|
||||
@@ -115,6 +333,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
api.getSupportedModels(),
|
||||
])
|
||||
setAllModels(modelConfigs)
|
||||
const clawWalletAddress =
|
||||
modelConfigs.find((model) => model.provider === 'claw402')?.walletAddress || null
|
||||
if (clawWalletAddress) {
|
||||
setBeginnerWalletAddress(clawWalletAddress)
|
||||
persistBeginnerWalletAddress(clawWalletAddress)
|
||||
}
|
||||
setAllExchanges(exchangeConfigs)
|
||||
setSupportedModels(models)
|
||||
} catch (error) {
|
||||
@@ -141,6 +365,23 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}) || []
|
||||
|
||||
const enabledModels = allModels?.filter((m) => m.enabled) || []
|
||||
const enabledClaw402Model = enabledModels.find((model) => model.provider === 'claw402') || null
|
||||
const enabledClaw402Balance = parseBalanceUsdc(enabledClaw402Model?.balanceUsdc)
|
||||
const claw402BalanceAlert =
|
||||
enabledClaw402Model && enabledClaw402Balance !== null && enabledClaw402Balance < 1
|
||||
? {
|
||||
blocking: enabledClaw402Balance <= 0,
|
||||
title:
|
||||
language === 'zh'
|
||||
? enabledClaw402Balance <= 0
|
||||
? 'Claw402 钱包余额为 0'
|
||||
: 'Claw402 钱包余额偏低'
|
||||
: enabledClaw402Balance <= 0
|
||||
? 'Claw402 wallet balance is zero'
|
||||
: 'Claw402 wallet balance is low',
|
||||
description: getClaw402BalanceMessage(enabledClaw402Balance, enabledClaw402Balance <= 0),
|
||||
}
|
||||
: null
|
||||
const enabledExchanges =
|
||||
allExchanges?.filter((e) => {
|
||||
if (!e.enabled) return false
|
||||
@@ -198,26 +439,19 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
const handleCreateTrader = async (data: CreateTraderRequest) => {
|
||||
try {
|
||||
const model = allModels?.find((m) => m.id === data.ai_model_id)
|
||||
const exchange = allExchanges?.find((e) => e.id === data.exchange_id)
|
||||
|
||||
if (!model?.enabled) {
|
||||
toast.error(t('modelNotConfigured', language))
|
||||
return
|
||||
const createdTrader = await api.createTrader(data)
|
||||
if (createdTrader.startup_warning) {
|
||||
toast.success(t('aiTradersToast.created', language), {
|
||||
description: createdTrader.startup_warning,
|
||||
})
|
||||
} else {
|
||||
toast.success(t('aiTradersToast.created', language))
|
||||
}
|
||||
|
||||
if (!exchange?.enabled) {
|
||||
toast.error(t('exchangeNotConfigured', language))
|
||||
return
|
||||
}
|
||||
|
||||
await api.createTrader(data)
|
||||
toast.success(t('aiTradersToast.created', language))
|
||||
setShowCreateModal(false)
|
||||
await mutateTraders()
|
||||
} catch (error) {
|
||||
console.error('Failed to create trader:', error)
|
||||
toast.error(t('createTraderFailed', language))
|
||||
showActionableError(t('createTraderFailed', language), error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +467,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}
|
||||
|
||||
const handleSaveEditTrader = async (data: CreateTraderRequest) => {
|
||||
console.log('🔥🔥🔥 handleSaveEditTrader CALLED with data:', data)
|
||||
if (!editingTrader) return
|
||||
|
||||
try {
|
||||
@@ -261,10 +494,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
show_in_competition: data.show_in_competition,
|
||||
}
|
||||
|
||||
console.log('🔥 handleSaveEditTrader - data:', data)
|
||||
console.log('🔥 handleSaveEditTrader - data.strategy_id:', data.strategy_id)
|
||||
console.log('🔥 handleSaveEditTrader - request:', request)
|
||||
|
||||
await api.updateTrader(editingTrader.trader_id, request)
|
||||
toast.success(t('aiTradersToast.saved', language))
|
||||
setShowEditModal(false)
|
||||
@@ -272,7 +501,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
await mutateTraders()
|
||||
} catch (error) {
|
||||
console.error('Failed to update trader:', error)
|
||||
toast.error(t('updateTraderFailed', language))
|
||||
showActionableError(t('updateTraderFailed', language), error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,16 +526,31 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
try {
|
||||
if (running) {
|
||||
await api.stopTrader(traderId)
|
||||
toast.success(t('aiTradersToast.stopped', language))
|
||||
toast.success(t('aiTradersToast.stopped', language))
|
||||
} else {
|
||||
const claw402Issue = getClaw402BalanceIssue(traderId)
|
||||
if (claw402Issue?.blocking) {
|
||||
toast.error(claw402Issue.title, {
|
||||
description: claw402Issue.description,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (claw402Issue && !claw402Issue.blocking) {
|
||||
toast.warning(claw402Issue.title, {
|
||||
description: claw402Issue.description,
|
||||
})
|
||||
}
|
||||
await api.startTrader(traderId)
|
||||
toast.success(t('aiTradersToast.started', language))
|
||||
toast.success(t('aiTradersToast.started', language))
|
||||
}
|
||||
|
||||
await mutateTraders()
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle trader:', error)
|
||||
toast.error(t('operationFailed', language))
|
||||
showActionableError(
|
||||
running ? t('aiTradersToast.stopFailed', language) : t('aiTradersToast.startFailed', language),
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +760,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
const refreshedExchanges = await api.getExchangeConfigs()
|
||||
setAllExchanges(refreshedExchanges)
|
||||
await mutateExchangeAccountStates()
|
||||
|
||||
setShowExchangeModal(false)
|
||||
setEditingExchange(null)
|
||||
@@ -597,6 +842,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
|
||||
const refreshedExchanges = await api.getExchangeConfigs()
|
||||
setAllExchanges(refreshedExchanges)
|
||||
await mutateExchangeAccountStates()
|
||||
|
||||
setShowExchangeModal(false)
|
||||
setEditingExchange(null)
|
||||
@@ -616,6 +862,37 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
setShowExchangeModal(true)
|
||||
}
|
||||
|
||||
const handleQuickSetupClaw402 = async () => {
|
||||
if (quickSetupLoading) return
|
||||
|
||||
try {
|
||||
setQuickSetupLoading(true)
|
||||
const result = await api.prepareBeginnerOnboarding()
|
||||
setBeginnerWalletAddress(result.address)
|
||||
const refreshedModels = await api.getModelConfigs()
|
||||
setAllModels(refreshedModels)
|
||||
toast.success(
|
||||
language === 'zh'
|
||||
? 'Claw402 已默认配置为 DeepSeek'
|
||||
: 'Claw402 is configured with DeepSeek by default'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to quick setup claw402:', error)
|
||||
toast.error(
|
||||
language === 'zh'
|
||||
? '一键配置 Claw402 失败'
|
||||
: 'Failed to quick setup Claw402'
|
||||
)
|
||||
} finally {
|
||||
setQuickSetupLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const claw402Configured = configuredModels.some((model) => model.provider === 'claw402')
|
||||
const hasStrategies = (strategies?.length || 0) > 0
|
||||
const hasCreatedTrader = (traders?.length || 0) > 0
|
||||
const canCreateTrader = configuredModels.length > 0 && configuredExchanges.length > 0
|
||||
|
||||
return (
|
||||
<DeepVoidBackground className="py-8" disableAnimation>
|
||||
<div className="w-full px-4 md:px-8 space-y-8 animate-fade-in">
|
||||
@@ -687,10 +964,74 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBeginnerMode ? (
|
||||
<BeginnerGuideCards
|
||||
language={language}
|
||||
claw402Ready={claw402Configured}
|
||||
exchangeReady={configuredExchanges.length > 0}
|
||||
strategyReady={hasStrategies}
|
||||
traderReady={hasCreatedTrader}
|
||||
canCreateTrader={canCreateTrader}
|
||||
walletAddress={beginnerWalletAddress}
|
||||
onQuickSetupClaw402={handleQuickSetupClaw402}
|
||||
onOpenExchange={handleAddExchange}
|
||||
onOpenStrategy={() => navigateInApp('/strategy')}
|
||||
onCreateTrader={() => setShowCreateModal(true)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{claw402BalanceAlert ? (
|
||||
<div
|
||||
className="mb-6 rounded-xl border px-4 py-4 md:px-5 md:py-4 flex flex-col md:flex-row md:items-start md:justify-between gap-3"
|
||||
style={{
|
||||
borderColor: claw402BalanceAlert.blocking ? 'rgba(239, 68, 68, 0.55)' : 'rgba(245, 158, 11, 0.45)',
|
||||
background: claw402BalanceAlert.blocking ? 'rgba(127, 29, 29, 0.22)' : 'rgba(120, 53, 15, 0.18)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="mt-0.5 rounded-full p-2"
|
||||
style={{
|
||||
background: claw402BalanceAlert.blocking ? 'rgba(239, 68, 68, 0.16)' : 'rgba(245, 158, 11, 0.14)',
|
||||
color: claw402BalanceAlert.blocking ? '#F87171' : '#FBBF24',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A' }}
|
||||
>
|
||||
{claw402BalanceAlert.title}
|
||||
</div>
|
||||
<div className="text-sm mt-1 leading-6" style={{ color: '#D4D4D8' }}>
|
||||
{claw402BalanceAlert.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => enabledClaw402Model && handleModelClick(enabledClaw402Model.id)}
|
||||
className="px-4 py-2 rounded text-xs font-mono uppercase tracking-wider border whitespace-nowrap self-start"
|
||||
style={{
|
||||
borderColor: claw402BalanceAlert.blocking ? 'rgba(248, 113, 113, 0.45)' : 'rgba(251, 191, 36, 0.35)',
|
||||
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
|
||||
background: 'rgba(0, 0, 0, 0.18)',
|
||||
}}
|
||||
>
|
||||
{language === 'zh' ? '查看 AI 钱包' : 'Open AI wallet'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Configuration Status Grid */}
|
||||
<ConfigStatusGrid
|
||||
configuredModels={configuredModels}
|
||||
configuredExchanges={configuredExchanges}
|
||||
exchangeAccountStates={exchangeAccountStateData?.states}
|
||||
isExchangeAccountStatesLoading={isExchangeAccountStatesLoading}
|
||||
visibleExchangeAddresses={visibleExchangeAddresses}
|
||||
copiedId={copiedId}
|
||||
language={language}
|
||||
@@ -715,7 +1056,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
copiedId={copiedId}
|
||||
language={language}
|
||||
onTraderSelect={onTraderSelect}
|
||||
onNavigate={(path) => navigate(path)}
|
||||
onNavigate={navigateInApp}
|
||||
onEditTrader={handleEditTrader}
|
||||
onToggleTrader={handleToggleTrader}
|
||||
onToggleCompetition={handleToggleCompetition}
|
||||
|
||||
211
web/src/components/trader/BeginnerGuideCards.tsx
Normal file
211
web/src/components/trader/BeginnerGuideCards.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Brain, Landmark, Rocket, Sparkles } from 'lucide-react'
|
||||
|
||||
interface BeginnerGuideCardsProps {
|
||||
language: string
|
||||
claw402Ready: boolean
|
||||
exchangeReady: boolean
|
||||
strategyReady: boolean
|
||||
traderReady: boolean
|
||||
canCreateTrader: boolean
|
||||
walletAddress?: string | null
|
||||
onQuickSetupClaw402: () => void
|
||||
onOpenExchange: () => void
|
||||
onOpenStrategy: () => void
|
||||
onCreateTrader: () => void
|
||||
}
|
||||
|
||||
function truncateAddress(address: string) {
|
||||
if (address.length <= 12) return address
|
||||
return `${address.slice(0, 6)}...${address.slice(-4)}`
|
||||
}
|
||||
|
||||
export function BeginnerGuideCards({
|
||||
language,
|
||||
claw402Ready,
|
||||
exchangeReady,
|
||||
strategyReady,
|
||||
traderReady,
|
||||
canCreateTrader,
|
||||
walletAddress,
|
||||
onQuickSetupClaw402,
|
||||
onOpenExchange,
|
||||
onOpenStrategy,
|
||||
onCreateTrader,
|
||||
}: BeginnerGuideCardsProps) {
|
||||
const isZh = language === 'zh'
|
||||
|
||||
const cards = [
|
||||
{
|
||||
key: 'model',
|
||||
icon: Brain,
|
||||
title: isZh ? '1. 极速模型' : '1. Fast AI',
|
||||
desc: isZh
|
||||
? '默认就是 Claw402 + DeepSeek。第一次不用挑模型,先跑起来。'
|
||||
: 'Start with Claw402 + DeepSeek. No model picking needed for the first run.',
|
||||
meta: walletAddress
|
||||
? isZh
|
||||
? `钱包 ${truncateAddress(walletAddress)}`
|
||||
: `Wallet ${truncateAddress(walletAddress)}`
|
||||
: isZh
|
||||
? 'Base 链 USDC 按次付费'
|
||||
: 'Pay per call with Base USDC',
|
||||
ready: claw402Ready,
|
||||
actionLabel: claw402Ready
|
||||
? isZh
|
||||
? '已配置'
|
||||
: 'Configured'
|
||||
: isZh
|
||||
? '一键配置'
|
||||
: 'One-click setup',
|
||||
onAction: onQuickSetupClaw402,
|
||||
disabled: claw402Ready,
|
||||
},
|
||||
{
|
||||
key: 'exchange',
|
||||
icon: Landmark,
|
||||
title: isZh ? '2. 连接交易所' : '2. Add Exchange',
|
||||
desc: isZh
|
||||
? '交易所接好以后,AI 才能真正下单。'
|
||||
: 'Connect an exchange so the AI can actually place trades.',
|
||||
meta: exchangeReady
|
||||
? isZh
|
||||
? '已准备好'
|
||||
: 'Ready'
|
||||
: isZh
|
||||
? 'Binance / OKX / Bybit / Hyperliquid'
|
||||
: 'Binance / OKX / Bybit / Hyperliquid',
|
||||
ready: exchangeReady,
|
||||
actionLabel: exchangeReady
|
||||
? isZh
|
||||
? '继续管理'
|
||||
: 'Manage'
|
||||
: isZh
|
||||
? '去配置'
|
||||
: 'Configure',
|
||||
onAction: onOpenExchange,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
key: 'strategy',
|
||||
icon: Sparkles,
|
||||
title: isZh ? '3. 选择策略' : '3. Pick Strategy',
|
||||
desc: isZh
|
||||
? '先用默认策略也可以,后面再慢慢细调。'
|
||||
: 'You can start with a default strategy and fine-tune later.',
|
||||
meta: strategyReady
|
||||
? isZh
|
||||
? '已有策略可用'
|
||||
: 'Strategy ready'
|
||||
: isZh
|
||||
? '可选,但建议提前看一眼'
|
||||
: 'Optional, but worth a quick look',
|
||||
ready: strategyReady,
|
||||
actionLabel: isZh ? '打开策略页' : 'Open strategy',
|
||||
onAction: onOpenStrategy,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
key: 'trader',
|
||||
icon: Rocket,
|
||||
title: isZh ? '4. 创建 Trader' : '4. Create Trader',
|
||||
desc: isZh
|
||||
? '最后一步,把模型和交易所绑在一起,就能开始运行。'
|
||||
: 'Last step: bind your model and exchange, then start running.',
|
||||
meta: traderReady
|
||||
? isZh
|
||||
? '已创建 Trader,可继续添加'
|
||||
: 'Trader created, you can add more'
|
||||
: canCreateTrader
|
||||
? isZh
|
||||
? '已经可以创建'
|
||||
: 'Ready to create'
|
||||
: isZh
|
||||
? '先完成前三步'
|
||||
: 'Finish the first three steps first',
|
||||
ready: traderReady,
|
||||
actionLabel: traderReady
|
||||
? isZh
|
||||
? '继续创建'
|
||||
: 'Create another'
|
||||
: isZh
|
||||
? '立即创建'
|
||||
: 'Create now',
|
||||
onAction: onCreateTrader,
|
||||
disabled: !canCreateTrader,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="space-y-4 rounded-[28px] border border-white/10 bg-zinc-950/60 p-5 backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-nofx-gold/80">
|
||||
{isZh ? '新手引导' : 'Quickstart'}
|
||||
</div>
|
||||
<h2 className="mt-1 text-xl font-bold text-white">
|
||||
{isZh
|
||||
? '先按这 4 步走,最快上手'
|
||||
: 'Follow these 4 steps to get started fast'}
|
||||
</h2>
|
||||
</div>
|
||||
{/* <div className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-zinc-400">
|
||||
{isZh ? '老手模式不会看到这块' : 'Hidden in advanced mode'}
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon
|
||||
return (
|
||||
<div
|
||||
key={card.key}
|
||||
className="rounded-[22px] border border-white/8 bg-black/25 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white/6 text-nofx-gold">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.22em] ${
|
||||
card.ready
|
||||
? 'bg-emerald-500/15 text-emerald-300'
|
||||
: 'bg-zinc-800 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{card.ready
|
||||
? isZh
|
||||
? '已就绪'
|
||||
: 'Ready'
|
||||
: isZh
|
||||
? '待完成'
|
||||
: 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="mt-4 text-base font-semibold text-white">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="mt-2 min-h-[72px] text-sm leading-6 text-zinc-400">
|
||||
{card.desc}
|
||||
</p>
|
||||
<div className="mt-3 text-xs text-zinc-500">{card.meta}</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={card.onAction}
|
||||
disabled={card.disabled}
|
||||
className={`mt-5 w-full rounded-2xl px-4 py-3 text-sm font-semibold transition ${
|
||||
card.disabled
|
||||
? 'cursor-not-allowed bg-zinc-900 text-zinc-500'
|
||||
: 'bg-nofx-gold text-black hover:bg-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{card.actionLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -281,14 +281,14 @@ export function CompetitionPage() {
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
<div className="text-right min-w-[60px] md:min-w-[80px]">
|
||||
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
|
||||
{t('equity', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs md:text-sm font-bold mono"
|
||||
className="text-sm md:text-base font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.total_equity?.toFixed(2) || '0.00'}
|
||||
@@ -297,11 +297,11 @@ export function CompetitionPage() {
|
||||
|
||||
{/* P&L */}
|
||||
<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)}
|
||||
</div>
|
||||
<div
|
||||
className="text-base md:text-lg font-bold mono"
|
||||
className="text-sm md:text-base font-bold mono"
|
||||
style={{
|
||||
color:
|
||||
(trader.total_pnl ?? 0) >= 0
|
||||
@@ -313,7 +313,7 @@ export function CompetitionPage() {
|
||||
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mono"
|
||||
className="text-[10px] mono"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||
@@ -322,17 +322,17 @@ export function CompetitionPage() {
|
||||
</div>
|
||||
|
||||
{/* Positions */}
|
||||
<div className="text-right">
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
<div className="text-right min-w-[40px] md:min-w-[50px]">
|
||||
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
|
||||
{t('pos', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs md:text-sm font-bold mono"
|
||||
className="text-sm md:text-base font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{trader.position_count}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
<div className="text-[10px]" style={{ color: '#848E9C' }}>
|
||||
{trader.margin_used_pct.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Copy,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
import type { AIModel, Exchange } from '../../types'
|
||||
import type { AIModel, Exchange, ExchangeAccountState } from '../../types'
|
||||
import type { Language } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { getModelIcon } from '../common/ModelIcons'
|
||||
@@ -25,6 +25,8 @@ interface UsageInfo {
|
||||
interface ConfigStatusGridProps {
|
||||
configuredModels: AIModel[]
|
||||
configuredExchanges: Exchange[]
|
||||
exchangeAccountStates?: Record<string, ExchangeAccountState>
|
||||
isExchangeAccountStatesLoading?: boolean
|
||||
visibleExchangeAddresses: Set<string>
|
||||
copiedId: string | null
|
||||
language: Language
|
||||
@@ -41,6 +43,8 @@ interface ConfigStatusGridProps {
|
||||
export function ConfigStatusGrid({
|
||||
configuredModels,
|
||||
configuredExchanges,
|
||||
exchangeAccountStates,
|
||||
isExchangeAccountStatesLoading,
|
||||
visibleExchangeAddresses,
|
||||
copiedId,
|
||||
language,
|
||||
@@ -53,6 +57,48 @@ export function ConfigStatusGrid({
|
||||
onToggleExchangeAddress,
|
||||
onCopyAddress,
|
||||
}: ConfigStatusGridProps) {
|
||||
const getExchangeStateMeta = (state: ExchangeAccountState | undefined) => {
|
||||
if (!state) {
|
||||
return {
|
||||
label: language === 'zh' ? '未检查' : 'NOT CHECKED',
|
||||
className: 'text-zinc-400 border-zinc-700/80 bg-zinc-900/40',
|
||||
}
|
||||
}
|
||||
|
||||
switch (state.status) {
|
||||
case 'ok':
|
||||
return {
|
||||
label: state.display_balance || '0',
|
||||
className: 'text-emerald-300 border-emerald-500/20 bg-emerald-500/10',
|
||||
}
|
||||
case 'disabled':
|
||||
return {
|
||||
label: language === 'zh' ? '已禁用' : 'DISABLED',
|
||||
className: 'text-zinc-400 border-zinc-700/80 bg-zinc-900/40',
|
||||
}
|
||||
case 'missing_credentials':
|
||||
return {
|
||||
label: language === 'zh' ? '配置不完整' : 'INCOMPLETE',
|
||||
className: 'text-amber-300 border-amber-500/20 bg-amber-500/10',
|
||||
}
|
||||
case 'invalid_credentials':
|
||||
return {
|
||||
label: language === 'zh' ? '密钥无效' : 'INVALID KEYS',
|
||||
className: 'text-rose-300 border-rose-500/20 bg-rose-500/10',
|
||||
}
|
||||
case 'permission_denied':
|
||||
return {
|
||||
label: language === 'zh' ? '无余额权限' : 'NO PERMISSION',
|
||||
className: 'text-orange-300 border-orange-500/20 bg-orange-500/10',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
label: language === 'zh' ? '暂时无法获取' : 'UNAVAILABLE',
|
||||
className: 'text-zinc-300 border-zinc-600/60 bg-zinc-800/50',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* AI Models Card */}
|
||||
@@ -92,6 +138,20 @@ export function ConfigStatusGrid({
|
||||
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
|
||||
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
|
||||
</div>
|
||||
{model.provider === 'claw402' && (model.balanceUsdc || model.walletAddress) ? (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-[10px] font-mono">
|
||||
{model.balanceUsdc ? (
|
||||
<span className="rounded border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0.5 text-emerald-400">
|
||||
{model.balanceUsdc} USDC
|
||||
</span>
|
||||
) : null}
|
||||
{model.walletAddress ? (
|
||||
<span className="rounded border border-sky-500/20 bg-sky-500/10 px-1.5 py-0.5 text-sky-400">
|
||||
{truncateAddress(model.walletAddress)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,6 +195,8 @@ export function ConfigStatusGrid({
|
||||
{configuredExchanges.map((exchange) => {
|
||||
const inUse = isExchangeInUse(exchange.id)
|
||||
const usageInfo = getExchangeUsageInfo(exchange.id)
|
||||
const state = exchangeAccountStates?.[exchange.id]
|
||||
const stateMeta = getExchangeStateMeta(state)
|
||||
return (
|
||||
<div
|
||||
key={exchange.id}
|
||||
@@ -160,6 +222,18 @@ export function ConfigStatusGrid({
|
||||
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
|
||||
{exchange.type?.toUpperCase() || 'CEX'}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px] font-mono">
|
||||
<span className={`rounded border px-1.5 py-0.5 ${stateMeta.className}`}>
|
||||
{isExchangeAccountStatesLoading && !state
|
||||
? (language === 'zh' ? '检查中...' : 'CHECKING...')
|
||||
: stateMeta.label}
|
||||
</span>
|
||||
{state?.status !== 'ok' && state?.error_message ? (
|
||||
<span className="text-zinc-500 truncate max-w-[220px]">
|
||||
{state.error_message}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react'
|
||||
import type { AIModel } from '../../types'
|
||||
import type { Language } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { api } from '../../lib/api'
|
||||
import { getModelIcon } from '../common/ModelIcons'
|
||||
import { ModelStepIndicator } from './ModelStepIndicator'
|
||||
import { ModelCard } from './ModelCard'
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
AI_PROVIDER_CONFIG,
|
||||
getShortName,
|
||||
} from './model-constants'
|
||||
import { getBeginnerWalletAddress, getUserMode } from '../../lib/onboarding'
|
||||
|
||||
interface ModelConfigModalProps {
|
||||
allModels: AIModel[]
|
||||
@@ -42,20 +44,22 @@ export function ModelConfigModal({
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [modelName, setModelName] = useState('')
|
||||
const configuredModel =
|
||||
configuredModels?.find((model) => model.id === selectedModelId) || null
|
||||
|
||||
// Always prefer allModels (supportedModels) for provider/id lookup;
|
||||
// fall back to configuredModels for edit mode details (apiKey etc.)
|
||||
const selectedModel =
|
||||
allModels?.find((m) => m.id === selectedModelId) ||
|
||||
configuredModels?.find((m) => m.id === selectedModelId)
|
||||
allModels?.find((m) => m.id === selectedModelId) || configuredModel
|
||||
|
||||
useEffect(() => {
|
||||
if (editingModelId && selectedModel) {
|
||||
setApiKey(selectedModel.apiKey || '')
|
||||
setBaseUrl(selectedModel.customApiUrl || '')
|
||||
setModelName(selectedModel.customModelName || '')
|
||||
const modelDetails = configuredModel || selectedModel
|
||||
if (editingModelId && modelDetails) {
|
||||
setApiKey(modelDetails.apiKey || '')
|
||||
setBaseUrl(modelDetails.customApiUrl || '')
|
||||
setModelName(modelDetails.customModelName || '')
|
||||
}
|
||||
}, [editingModelId, selectedModel])
|
||||
}, [editingModelId, configuredModel, selectedModel])
|
||||
|
||||
const handleSelectModel = (modelId: string) => {
|
||||
setSelectedModelId(modelId)
|
||||
@@ -79,7 +83,19 @@ export function ModelConfigModal({
|
||||
|
||||
const availableModels = allModels || []
|
||||
const configuredIds = new Set(configuredModels?.map(m => m.id) || [])
|
||||
const stepLabels = [t('modelConfig.selectModel', language), t('modelConfig.configureApi', language)]
|
||||
const isClaw402Selected = selectedModel?.provider === 'claw402' || selectedModel?.id === 'claw402'
|
||||
const isBeginnerDefaultModel = isClaw402Selected && getUserMode() === 'beginner'
|
||||
const stepLabels = [
|
||||
t('modelConfig.selectModel', language),
|
||||
t(
|
||||
!selectedModel
|
||||
? 'modelConfig.configure'
|
||||
: isClaw402Selected
|
||||
? 'modelConfig.configureWallet'
|
||||
: 'modelConfig.configure',
|
||||
language
|
||||
),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
|
||||
@@ -102,7 +118,7 @@ export function ModelConfigModal({
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{editingModelId && (
|
||||
{editingModelId && !isBeginnerDefaultModel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(editingModelId)}
|
||||
@@ -143,6 +159,7 @@ export function ModelConfigModal({
|
||||
<Claw402ConfigForm
|
||||
apiKey={apiKey}
|
||||
modelName={modelName}
|
||||
configuredModel={configuredModel}
|
||||
editingModelId={editingModelId}
|
||||
onApiKeyChange={setApiKey}
|
||||
onModelNameChange={setModelName}
|
||||
@@ -189,6 +206,10 @@ function ModelSelectionStep({
|
||||
onSelectModel: (modelId: string) => void
|
||||
language: Language
|
||||
}) {
|
||||
const [showOtherProviders, setShowOtherProviders] = useState(false)
|
||||
const claw402Model = availableModels.find((m) => m.provider === 'claw402')
|
||||
const otherProviders = availableModels.filter((m) => m.provider !== 'claw402')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
@@ -196,12 +217,11 @@ function ModelSelectionStep({
|
||||
</div>
|
||||
|
||||
{/* Claw402 Featured Card */}
|
||||
{availableModels.some(m => m.provider === 'claw402') && (
|
||||
{claw402Model && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const claw = availableModels.find(m => m.provider === 'claw402')
|
||||
if (claw) onSelectModel(claw.id)
|
||||
onSelectModel(claw402Model.id)
|
||||
}}
|
||||
className="w-full p-5 rounded-xl text-left transition-all hover:scale-[1.01]"
|
||||
style={{ background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)', border: '1.5px solid rgba(37, 99, 235, 0.4)' }}
|
||||
@@ -222,7 +242,7 @@ function ModelSelectionStep({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{configuredIds.has(availableModels.find(m => m.provider === 'claw402')?.id || '') && (
|
||||
{configuredIds.has(claw402Model.id) && (
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#00E096' }} />
|
||||
)}
|
||||
<div className="px-3 py-1.5 rounded-full text-xs font-bold" style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff' }}>
|
||||
@@ -235,23 +255,57 @@ function ModelSelectionStep({
|
||||
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 ml-[52px] text-[11px]" style={{ color: '#A0AEC0' }}>
|
||||
{t('modelConfig.claw402EntryDesc', language)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||
{availableModels.filter(m => m.provider !== 'claw402').map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
selected={selectedModelId === model.id}
|
||||
onClick={() => onSelectModel(model.id)}
|
||||
configured={configuredIds.has(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-center pt-2" style={{ color: '#848E9C' }}>
|
||||
{t('modelConfig.modelsConfigured', language)}
|
||||
</div>
|
||||
{otherProviders.length > 0 && (
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOtherProviders((prev) => !prev)}
|
||||
className="w-full flex items-center justify-between px-4 py-4 text-left transition-all hover:bg-white/5"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{t('modelConfig.otherApiEntry', language)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('modelConfig.otherApiEntryDesc', language)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rounded-full border border-white/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.2em]" style={{ color: '#A0AEC0' }}>
|
||||
{otherProviders.length} API
|
||||
</span>
|
||||
<span className="text-sm" style={{ color: '#60A5FA' }}>
|
||||
{showOtherProviders ? '−' : '+'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{showOtherProviders && (
|
||||
<div className="border-t border-white/5 px-4 py-4">
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||
{otherProviders.map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
selected={selectedModelId === model.id}
|
||||
onClick={() => onSelectModel(model.id)}
|
||||
configured={configuredIds.has(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-center pt-3" style={{ color: '#848E9C' }}>
|
||||
{t('modelConfig.modelsConfigured', language)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -259,6 +313,7 @@ function ModelSelectionStep({
|
||||
function Claw402ConfigForm({
|
||||
apiKey,
|
||||
modelName,
|
||||
configuredModel,
|
||||
editingModelId,
|
||||
onApiKeyChange,
|
||||
onModelNameChange,
|
||||
@@ -268,6 +323,7 @@ function Claw402ConfigForm({
|
||||
}: {
|
||||
apiKey: string
|
||||
modelName: string
|
||||
configuredModel: AIModel | null
|
||||
editingModelId: string | null
|
||||
onApiKeyChange: (value: string) => void
|
||||
onModelNameChange: (value: string) => void
|
||||
@@ -278,14 +334,21 @@ function Claw402ConfigForm({
|
||||
const [walletAddress, setWalletAddress] = useState('')
|
||||
const [copiedAddr, setCopiedAddr] = useState(false)
|
||||
const [showDeposit, setShowDeposit] = useState(false)
|
||||
const [showNewWalletBackup, setShowNewWalletBackup] = useState(false)
|
||||
const [newWalletKey, setNewWalletKey] = useState('')
|
||||
const [usdcBalance, setUsdcBalance] = useState<string | null>(null)
|
||||
const [keyError, setKeyError] = useState('')
|
||||
const [validating, setValidating] = useState(false)
|
||||
const [claw402Status, setClaw402Status] = useState<string | null>(null)
|
||||
const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [serverWalletAddress, setServerWalletAddress] = useState('')
|
||||
const [serverWalletBalance, setServerWalletBalance] = useState<string | null>(null)
|
||||
const localWalletAddress = getBeginnerWalletAddress()?.trim() || ''
|
||||
const configuredWalletAddress =
|
||||
configuredModel?.walletAddress?.trim() || localWalletAddress || serverWalletAddress
|
||||
const resolvedWalletAddress = walletAddress || configuredWalletAddress
|
||||
const resolvedUsdcBalance =
|
||||
usdcBalance ?? configuredModel?.balanceUsdc ?? serverWalletBalance ?? null
|
||||
const hasExistingWallet = Boolean(configuredWalletAddress)
|
||||
|
||||
// Client-side validation helper
|
||||
const getClientError = (key: string): string => {
|
||||
@@ -298,8 +361,36 @@ function Claw402ConfigForm({
|
||||
|
||||
const isKeyValid = apiKey.length === 66 && apiKey.startsWith('0x') && /^0x[0-9a-fA-F]{64}$/.test(apiKey)
|
||||
|
||||
// Truncate address for display
|
||||
useEffect(() => {
|
||||
if (hasExistingWallet) {
|
||||
setShowDeposit(true)
|
||||
}
|
||||
}, [hasExistingWallet])
|
||||
|
||||
useEffect(() => {
|
||||
if (configuredModel?.walletAddress || localWalletAddress || serverWalletAddress) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
void api
|
||||
.getCurrentBeginnerWallet()
|
||||
.then((result) => {
|
||||
setClaw402Status(result.claw402_status || 'unknown')
|
||||
if (cancelled || !result.found || !result.address) {
|
||||
return
|
||||
}
|
||||
setServerWalletAddress(result.address)
|
||||
setServerWalletBalance(result.balance_usdc || null)
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore silently: this is a best-effort fallback for showing the current wallet.
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [configuredModel?.walletAddress, localWalletAddress, serverWalletAddress])
|
||||
|
||||
// Debounced validation when apiKey changes
|
||||
useEffect(() => {
|
||||
@@ -347,6 +438,23 @@ function Claw402ConfigForm({
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
if (!apiKey && hasExistingWallet) {
|
||||
const result = await api.getCurrentBeginnerWallet()
|
||||
setClaw402Status(result.claw402_status || 'unknown')
|
||||
if (result.found && result.address) {
|
||||
setWalletAddress(result.address)
|
||||
setUsdcBalance(result.balance_usdc || '0.00')
|
||||
setShowDeposit(true)
|
||||
}
|
||||
setTestResult({
|
||||
status: result.claw402_status === 'ok' ? 'ok' : 'error',
|
||||
message: result.claw402_status === 'ok'
|
||||
? t('modelConfig.claw402Connected', language)
|
||||
: t('modelConfig.claw402Unreachable', language),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch('/api/wallet/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -374,7 +482,7 @@ function Claw402ConfigForm({
|
||||
}
|
||||
}
|
||||
|
||||
const balanceNum = usdcBalance ? parseFloat(usdcBalance) : 0
|
||||
const balanceNum = resolvedUsdcBalance ? parseFloat(resolvedUsdcBalance) : 0
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-5">
|
||||
@@ -396,6 +504,25 @@ function Claw402ConfigForm({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || (!hasExistingWallet && !isKeyValid)}
|
||||
className="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-semibold transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
|
||||
>
|
||||
<span>🔗</span>
|
||||
{testing ? t('modelConfig.testingConnection', language) : t('modelConfig.testConnection', language)}
|
||||
</button>
|
||||
{claw402Status ? (
|
||||
<div className="text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#F59E0B' }}>
|
||||
{claw402Status === 'ok'
|
||||
? t('modelConfig.claw402Connected', language)
|
||||
: t('modelConfig.claw402Unreachable', language)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Select AI Model */}
|
||||
@@ -409,7 +536,7 @@ function Claw402ConfigForm({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{CLAW402_MODELS.map((m) => {
|
||||
const isSelected = (modelName || 'deepseek') === m.id
|
||||
const isSelected = (modelName || 'glm-5') === m.id
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
@@ -467,6 +594,33 @@ function Claw402ConfigForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasExistingWallet && (
|
||||
<div className="p-3 rounded-xl" style={{ background: 'rgba(0, 224, 150, 0.05)', border: '1px solid rgba(0, 224, 150, 0.18)' }}>
|
||||
<div className="text-xs font-semibold mb-1.5" style={{ color: '#00E096' }}>
|
||||
{language === 'zh' ? '已自动提取当前钱包' : 'Current wallet loaded automatically'}
|
||||
</div>
|
||||
<div className="text-[11px] leading-5" style={{ color: '#A0AEC0' }}>
|
||||
{language === 'zh'
|
||||
? '你现在可以直接查看当前钱包地址、余额和充值二维码。只有在想更换钱包时,才需要重新输入新的私钥。'
|
||||
: 'You can view the current wallet address, balance, and deposit QR code right away. Only enter a new private key if you want to replace this wallet.'}
|
||||
</div>
|
||||
{!configuredModel?.walletAddress && localWalletAddress ? (
|
||||
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '当前地址来自本地已保存的新手钱包。'
|
||||
: 'This address comes from the locally saved beginner wallet.'}
|
||||
</div>
|
||||
) : null}
|
||||
{!configuredModel?.walletAddress && !localWalletAddress && serverWalletAddress ? (
|
||||
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '当前地址来自后端保存的钱包配置。'
|
||||
: 'This address comes from the wallet saved on the server.'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
|
||||
{t('modelConfig.walletPrivateKey', language)}
|
||||
@@ -476,72 +630,30 @@ function Claw402ConfigForm({
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
||||
placeholder="0x..."
|
||||
placeholder={
|
||||
hasExistingWallet
|
||||
? language === 'zh'
|
||||
? '如需切换钱包,请手动输入新的私钥'
|
||||
: 'Enter a new private key only if you want to switch wallets'
|
||||
: '0x...'
|
||||
}
|
||||
className="flex-1 px-4 py-3 rounded-xl font-mono text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
required
|
||||
required={!hasExistingWallet}
|
||||
/>
|
||||
{!apiKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch('/api/wallet/generate', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.private_key) {
|
||||
onApiKeyChange(data.private_key)
|
||||
setShowNewWalletBackup(true)
|
||||
setNewWalletKey(data.private_key)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
className="shrink-0 px-3 py-3 rounded-xl text-xs font-semibold transition-all hover:scale-[1.02]"
|
||||
style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{language === 'zh' ? '🔑 创建钱包' : '🔑 Create Wallet'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New wallet backup warning */}
|
||||
{showNewWalletBackup && newWalletKey && (
|
||||
<div className="p-3 rounded-xl" style={{ background: 'rgba(239, 68, 68, 0.08)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
|
||||
<div className="text-xs font-bold mb-2" style={{ color: '#EF4444' }}>
|
||||
🚨 {language === 'zh' ? '重要:请立即备份私钥!' : 'Important: Backup your private key NOW!'}
|
||||
</div>
|
||||
<div className="text-[11px] mb-2" style={{ color: '#F87171' }}>
|
||||
{language === 'zh'
|
||||
? '这是你的钱包私钥,丢失后无法恢复,钱包里的资产将永久丢失。请复制并安全保存。'
|
||||
: 'This is your wallet private key. If lost, it cannot be recovered and all assets will be permanently lost. Copy and save it securely.'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<code className="text-[10px] font-mono break-all select-all flex-1 p-2 rounded" style={{ background: '#0B0E11', color: '#F87171' }}>
|
||||
{newWalletKey}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(newWalletKey)
|
||||
setCopiedAddr(true)
|
||||
setTimeout(() => setCopiedAddr(false), 2000)
|
||||
}}
|
||||
className="shrink-0 text-[10px] px-2 py-1 rounded"
|
||||
style={{ background: 'rgba(239,68,68,0.15)', color: '#F87171', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{copiedAddr ? '✅ Copied' : '📋 Copy Key'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] space-y-1" style={{ color: '#848E9C' }}>
|
||||
<div>✅ {language === 'zh' ? '建议保存到密码管理器(1Password / Bitwarden)' : 'Save to a password manager (1Password / Bitwarden)'}</div>
|
||||
<div>✅ {language === 'zh' ? '或抄在纸上放安全的地方' : 'Or write it down and store it safely'}</div>
|
||||
<div>❌ {language === 'zh' ? '不要截图发给别人' : 'Do NOT screenshot or share with anyone'}</div>
|
||||
</div>
|
||||
{hasExistingWallet && !apiKey ? (
|
||||
<div className="text-[11px] leading-5" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '后续这里只使用你第一次创建并保存的钱包;如果你要换钱包,请手动填写新的私钥。'
|
||||
: 'This screen keeps using the wallet created and saved the first time. Enter a new private key manually only if you want to switch wallets.'}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
|
||||
<span className="mt-px">🔒</span>
|
||||
@@ -552,7 +664,7 @@ function Claw402ConfigForm({
|
||||
</div>
|
||||
|
||||
{/* Wallet Validation Results */}
|
||||
{apiKey && (
|
||||
{(apiKey || hasExistingWallet) && (
|
||||
<div className="space-y-2 pl-1">
|
||||
{/* Validating spinner */}
|
||||
{validating && (
|
||||
@@ -571,7 +683,7 @@ function Claw402ConfigForm({
|
||||
)}
|
||||
|
||||
{/* Success: address + balance + status */}
|
||||
{walletAddress && !validating && !keyError && (
|
||||
{resolvedWalletAddress && !validating && !keyError && (
|
||||
<>
|
||||
<div className="p-2.5 rounded-lg" style={{ background: 'rgba(96,165,250,0.06)', border: '1px solid rgba(96,165,250,0.15)' }}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
@@ -581,7 +693,7 @@ function Claw402ConfigForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(walletAddress)
|
||||
navigator.clipboard.writeText(resolvedWalletAddress)
|
||||
setCopiedAddr(true)
|
||||
setTimeout(() => setCopiedAddr(false), 2000)
|
||||
}}
|
||||
@@ -591,16 +703,16 @@ function Claw402ConfigForm({
|
||||
{copiedAddr ? '✅' : '📋'}
|
||||
</button>
|
||||
</div>
|
||||
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{walletAddress}</code>
|
||||
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{resolvedWalletAddress}</code>
|
||||
<div className="text-[10px] mt-1.5" style={{ color: '#F59E0B' }}>
|
||||
⚠️ {language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
|
||||
</div>
|
||||
</div>
|
||||
{usdcBalance !== null && (
|
||||
{resolvedUsdcBalance !== null && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span>💰</span>
|
||||
<span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
|
||||
{t('modelConfig.usdcBalance', language)}: ${usdcBalance}
|
||||
{t('modelConfig.usdcBalance', language)}: ${resolvedUsdcBalance}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -621,17 +733,17 @@ function Claw402ConfigForm({
|
||||
</div>
|
||||
<div className="flex gap-3 items-start mb-3">
|
||||
<div className="shrink-0 p-1.5 rounded-lg" style={{ background: '#fff' }}>
|
||||
<QRCodeSVG value={walletAddress} size={80} level="M" />
|
||||
<QRCodeSVG value={resolvedWalletAddress} size={80} level="M" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}>
|
||||
{language === 'zh' ? '扫码或复制地址转账' : 'Scan QR or copy address to transfer'}
|
||||
</div>
|
||||
<code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{walletAddress}</code>
|
||||
<code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{resolvedWalletAddress}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(walletAddress)
|
||||
navigator.clipboard.writeText(resolvedWalletAddress)
|
||||
setCopiedAddr(true)
|
||||
setTimeout(() => setCopiedAddr(false), 2000)
|
||||
}}
|
||||
@@ -650,6 +762,13 @@ function Claw402ConfigForm({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!apiKey && hasExistingWallet && (
|
||||
<div className="text-[11px]" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '当前正在使用这个钱包充值。若要切换钱包,再输入新的私钥并保存即可。'
|
||||
: 'This wallet is currently used for funding. Enter a new private key only if you want to switch wallets.'}
|
||||
</div>
|
||||
)}
|
||||
{claw402Status && (
|
||||
<div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}>
|
||||
<span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span>
|
||||
@@ -662,11 +781,11 @@ function Claw402ConfigForm({
|
||||
)}
|
||||
|
||||
{/* Test Connection button */}
|
||||
{isKeyValid && !validating && (
|
||||
{(isKeyValid || hasExistingWallet) && !validating && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing}
|
||||
disabled={testing || (!hasExistingWallet && !isKeyValid)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all hover:scale-[1.02] disabled:opacity-50"
|
||||
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
import { MetricTooltip } from '../common/MetricTooltip'
|
||||
import { formatPrice, formatQuantity } from '../../utils/format'
|
||||
import { NofxSelect } from '../ui/select'
|
||||
import type {
|
||||
HistoricalPosition,
|
||||
TraderStats,
|
||||
@@ -358,7 +359,11 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
// Fetch more data than needed to support filtering, but respect pageSize for initial load
|
||||
const data = await api.getPositionHistory(traderId, Math.max(200, pageSize * 5))
|
||||
const data = await api.getPositionHistory(
|
||||
traderId,
|
||||
Math.max(200, pageSize * 5),
|
||||
true
|
||||
)
|
||||
setPositions(data.positions || [])
|
||||
setStats(data.stats)
|
||||
setSymbolStats(data.symbol_stats || [])
|
||||
@@ -664,23 +669,20 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('positionHistory.symbol', language)}:
|
||||
</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
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"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
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 className="flex items-center gap-2">
|
||||
@@ -708,28 +710,26 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('positionHistory.sort', language)}:
|
||||
</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={`${sortBy}-${sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [by, order] = e.target.value.split('-') as [
|
||||
'time' | 'pnl' | 'pnl_pct',
|
||||
'asc' | 'desc',
|
||||
]
|
||||
onChange={(val) => {
|
||||
const [by, order] = val.split('-') as ['time' | 'pnl' | 'pnl_pct', 'asc' | 'desc']
|
||||
setSortBy(by)
|
||||
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"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
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>
|
||||
|
||||
@@ -841,20 +841,21 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '每页' : 'Per page'}:
|
||||
</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
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"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Page navigation */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
||||
import { api } from '../../lib/api'
|
||||
import type { TelegramConfig, AIModel } from '../../types'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
import { NofxSelect } from '../ui/select'
|
||||
|
||||
// Step indicator (reused pattern from ExchangeConfigModal)
|
||||
function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) {
|
||||
@@ -133,23 +134,20 @@ export function TelegramConfigModal({ onClose, language }: TelegramConfigModalPr
|
||||
{t('telegram.noEnabledModels', language)}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
<NofxSelect
|
||||
value={selectedModelId}
|
||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl text-sm appearance-none"
|
||||
onChange={(val) => setSelectedModelId(val)}
|
||||
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={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
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' }}>
|
||||
{t('telegram.autoUseEnabled', language)}
|
||||
@@ -489,23 +487,20 @@ function BoundModelSelector({
|
||||
{t('telegram.aiModelLabel', language)}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
<NofxSelect
|
||||
value={modelId}
|
||||
onChange={(e) => setModelId(e.target.value)}
|
||||
className="flex-1 px-3 py-2.5 rounded-xl text-sm appearance-none"
|
||||
onChange={(val) => setModelId(val)}
|
||||
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={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
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
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || modelId === currentModelId}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { AIModel, Exchange, CreateTraderRequest, Strategy } from '../../types'
|
||||
import type { AIModel, Exchange, CreateTraderRequest, ExchangeAccountStateResponse, Strategy } from '../../types'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { toast } from 'sonner'
|
||||
import { Pencil, Plus, X as IconX, Sparkles, ExternalLink, UserPlus } from 'lucide-react'
|
||||
import { httpClient } from '../../lib/httpClient'
|
||||
import { NofxSelect } from '../ui/select'
|
||||
|
||||
// 提取下划线后面的名称部分
|
||||
function getShortName(fullName: string): string {
|
||||
@@ -123,32 +124,62 @@ export function TraderConfigModal({
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleExchangeChange = (exchangeId: string) => {
|
||||
setBalanceFetchError('')
|
||||
setFormData((prev) => {
|
||||
if (prev.exchange_id === exchangeId) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const next: FormState = { ...prev, exchange_id: exchangeId }
|
||||
|
||||
// Exchange balance belongs to the selected exchange, not the trader record.
|
||||
// Clear the old baseline so we don't carry Exchange B's balance into Exchange A.
|
||||
if (isEditMode) {
|
||||
next.initial_balance = undefined
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleFetchCurrentBalance = async () => {
|
||||
if (!isEditMode || !traderData?.trader_id) {
|
||||
if (!isEditMode) {
|
||||
setBalanceFetchError(t('fetchBalanceEditModeOnly', language))
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.exchange_id) {
|
||||
setBalanceFetchError(t('balanceFetchFailed', language))
|
||||
return
|
||||
}
|
||||
|
||||
setIsFetchingBalance(true)
|
||||
setBalanceFetchError('')
|
||||
|
||||
try {
|
||||
const result = await httpClient.get<{
|
||||
total_equity?: number
|
||||
balance?: number
|
||||
}>(`/api/account?trader_id=${traderData.trader_id}`)
|
||||
const result = await httpClient.get<ExchangeAccountStateResponse>('/api/exchanges/account-state')
|
||||
|
||||
if (result.success && result.data) {
|
||||
const selectedState = result.data?.states?.[formData.exchange_id]
|
||||
if (result.success && selectedState?.status === 'ok') {
|
||||
const currentBalance =
|
||||
result.data.total_equity || result.data.balance || 0
|
||||
selectedState.total_equity ??
|
||||
selectedState.available_balance ??
|
||||
0
|
||||
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
|
||||
toast.success(t('balanceFetched', language))
|
||||
} else {
|
||||
throw new Error(result.message || t('balanceFetchFailed', language))
|
||||
setBalanceFetchError(
|
||||
selectedState?.error_message || result.message || t('balanceFetchFailed', language)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(t('balanceFetchFailed', language) + ':', error)
|
||||
setBalanceFetchError(t('balanceFetchNetworkError', language))
|
||||
setBalanceFetchError(
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: t('balanceFetchNetworkError', language)
|
||||
)
|
||||
} finally {
|
||||
setIsFetchingBalance(false)
|
||||
}
|
||||
@@ -175,8 +206,6 @@ export function TraderConfigModal({
|
||||
}
|
||||
|
||||
await onSave(saveData)
|
||||
toast.success(t('saveSuccess', language))
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error(t('saveFailed', language) + ':', error)
|
||||
} finally {
|
||||
@@ -250,38 +279,32 @@ export function TraderConfigModal({
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{t('aiModelRequired', language)}
|
||||
</label>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={formData.ai_model}
|
||||
onChange={(e) =>
|
||||
handleInputChange('ai_model', e.target.value)
|
||||
onChange={(val) =>
|
||||
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"
|
||||
>
|
||||
{availableModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{getShortName(model.name || model.id).toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
|
||||
options={availableModels.map((model) => ({
|
||||
value: model.id,
|
||||
label: getShortName(model.name || model.id).toUpperCase(),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{t('exchangeRequired', language)}
|
||||
</label>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={formData.exchange_id}
|
||||
onChange={(e) =>
|
||||
handleInputChange('exchange_id', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
>
|
||||
{availableExchanges.map((exchange) => (
|
||||
<option key={exchange.id} value={exchange.id}>
|
||||
{getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()}
|
||||
{exchange.account_name ? ` - ${exchange.account_name}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={handleExchangeChange}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
|
||||
options={availableExchanges.map((exchange) => ({
|
||||
value: exchange.id,
|
||||
label: getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()
|
||||
+ (exchange.account_name ? ` - ${exchange.account_name}` : ''),
|
||||
}))}
|
||||
/>
|
||||
{/* Exchange Registration Link */}
|
||||
{formData.exchange_id && (() => {
|
||||
// Find the selected exchange to get its type
|
||||
@@ -323,22 +346,20 @@ export function TraderConfigModal({
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{t('useStrategy', language)}
|
||||
</label>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={formData.strategy_id}
|
||||
onChange={(e) =>
|
||||
handleInputChange('strategy_id', e.target.value)
|
||||
onChange={(val) =>
|
||||
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"
|
||||
>
|
||||
<option value="">{t('noStrategyManual', language)}</option>
|
||||
{strategies.map((strategy) => (
|
||||
<option key={strategy.id} value={strategy.id}>
|
||||
{strategy.name}
|
||||
{strategy.is_active ? t('strategyActive', language) : ''}
|
||||
{strategy.is_default ? t('strategyDefault', language) : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
|
||||
options={[
|
||||
{ value: '', label: t('noStrategyManual', language) },
|
||||
...strategies.map((strategy) => ({
|
||||
value: strategy.id,
|
||||
label: strategy.name + (strategy.is_active ? t('strategyActive', language) : '') + (strategy.is_default ? t('strategyDefault', language) : ''),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
{strategies.length === 0 && (
|
||||
<p className="text-xs text-[#848E9C] mt-2">
|
||||
{t('noStrategyHint', language)}
|
||||
|
||||
@@ -91,12 +91,12 @@ export const AI_PROVIDER_CONFIG: Record<string, AIProviderConfig> = {
|
||||
apiName: 'Moonshot',
|
||||
},
|
||||
minimax: {
|
||||
defaultModel: 'MiniMax-M2.5',
|
||||
defaultModel: 'MiniMax-M2.7',
|
||||
apiUrl: 'https://platform.minimax.io',
|
||||
apiName: 'MiniMax',
|
||||
},
|
||||
claw402: {
|
||||
defaultModel: 'deepseek',
|
||||
defaultModel: 'glm-5',
|
||||
apiUrl: 'https://claw402.ai',
|
||||
apiName: 'Claw402',
|
||||
},
|
||||
|
||||
102
web/src/components/ui/select.tsx
Normal file
102
web/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useRef, useState, useLayoutEffect, 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 })
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
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 = (e: Event) => {
|
||||
if (dropdownRef.current?.contains(e.target as Node)) return
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { getSystemConfig } from '../lib/config'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { getSystemConfig, invalidateSystemConfig } from '../lib/config'
|
||||
import { reset401Flag, httpClient } from '../lib/httpClient'
|
||||
import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding'
|
||||
import { useLanguage } from './LanguageContext'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
@@ -12,7 +15,8 @@ interface AuthContextType {
|
||||
token: string | null
|
||||
login: (
|
||||
email: string,
|
||||
password: string
|
||||
password: string,
|
||||
mode?: UserMode
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
@@ -24,7 +28,8 @@ interface AuthContextType {
|
||||
register: (
|
||||
email: string,
|
||||
password: string,
|
||||
betaCode?: string
|
||||
betaCode?: string,
|
||||
mode?: UserMode
|
||||
) => Promise<{ success: boolean; message?: string }>
|
||||
resetPassword: (
|
||||
email: string,
|
||||
@@ -37,6 +42,7 @@ interface AuthContextType {
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useLanguage()
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -89,7 +95,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const handlePostAuthSuccess = (
|
||||
authToken: string,
|
||||
userInfo: User,
|
||||
mode?: UserMode
|
||||
) => {
|
||||
reset401Flag()
|
||||
|
||||
if (mode) {
|
||||
setUserMode(mode)
|
||||
}
|
||||
|
||||
localStorage.setItem('auth_token', authToken)
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||
localStorage.setItem('user_id', userInfo.id)
|
||||
flushSync(() => {
|
||||
setToken(authToken)
|
||||
setUser(userInfo)
|
||||
})
|
||||
|
||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||
const nextPath = returnUrl || getPostAuthPath(mode)
|
||||
if (returnUrl) {
|
||||
sessionStorage.removeItem('returnUrl')
|
||||
}
|
||||
|
||||
window.history.pushState({}, '', nextPath)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const login = async (email: string, password: string, mode?: UserMode) => {
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
@@ -103,26 +138,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (response.ok) {
|
||||
if (data.token) {
|
||||
// Reset 401 flag on successful login
|
||||
reset401Flag()
|
||||
|
||||
const userInfo = { id: data.user_id, email: data.email }
|
||||
setToken(data.token)
|
||||
setUser(userInfo)
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||
|
||||
// Check and redirect to returnUrl if exists
|
||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||
if (returnUrl) {
|
||||
sessionStorage.removeItem('returnUrl')
|
||||
window.history.pushState({}, '', returnUrl)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
} else {
|
||||
// Redirect to config page
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
handlePostAuthSuccess(data.token, userInfo, mode)
|
||||
|
||||
return { success: true, message: data.message }
|
||||
}
|
||||
@@ -156,10 +173,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
id: data.user_id || 'admin',
|
||||
email: data.email || 'admin@localhost',
|
||||
}
|
||||
setToken(data.token)
|
||||
setUser(userInfo)
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||
flushSync(() => {
|
||||
setToken(data.token)
|
||||
setUser(userInfo)
|
||||
})
|
||||
|
||||
// Check and redirect to returnUrl if exists
|
||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||
@@ -184,13 +203,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const register = async (
|
||||
email: string,
|
||||
password: string,
|
||||
betaCode?: string
|
||||
betaCode?: string,
|
||||
mode?: UserMode
|
||||
) => {
|
||||
const requestBody: {
|
||||
email: string
|
||||
password: string
|
||||
beta_code?: string
|
||||
} = { email, password }
|
||||
lang?: string
|
||||
} = { email, password, lang: language }
|
||||
if (betaCode) {
|
||||
requestBody.beta_code = betaCode
|
||||
}
|
||||
@@ -204,26 +225,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}>('/api/register', requestBody)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Reset 401 flag on successful login
|
||||
reset401Flag()
|
||||
// Clear stale onboarding state so new users always see the welcome flow
|
||||
localStorage.removeItem('nofx_beginner_onboarding_completed')
|
||||
localStorage.removeItem('nofx_beginner_wallet_address')
|
||||
|
||||
const userInfo = { id: result.data.user_id, email: result.data.email }
|
||||
setToken(result.data.token)
|
||||
setUser(userInfo)
|
||||
localStorage.setItem('auth_token', result.data.token)
|
||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||
|
||||
// Check and redirect to returnUrl if exists
|
||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||
if (returnUrl) {
|
||||
sessionStorage.removeItem('returnUrl')
|
||||
window.history.pushState({}, '', returnUrl)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
} else {
|
||||
// Redirect to config page
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
handlePostAuthSuccess(result.data.token, userInfo, mode)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -287,6 +294,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setToken(null)
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
invalidateSystemConfig()
|
||||
window.history.pushState({}, '', '/')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getSystemConfig, type SystemConfig } from '../lib/config'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { getSystemConfig, invalidateSystemConfig, type SystemConfig } from '../lib/config'
|
||||
|
||||
export function useSystemConfig() {
|
||||
const [config, setConfig] = useState<SystemConfig | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [fetchKey, setFetchKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
setLoading(true)
|
||||
getSystemConfig()
|
||||
.then((data) => {
|
||||
if (!mounted) return
|
||||
@@ -23,7 +25,18 @@ export function useSystemConfig() {
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [fetchKey])
|
||||
|
||||
// Listen for invalidation events and re-fetch automatically
|
||||
useEffect(() => {
|
||||
const handler = () => setFetchKey((k) => k + 1)
|
||||
window.addEventListener('system-config-invalidated', handler)
|
||||
return () => window.removeEventListener('system-config-invalidated', handler)
|
||||
}, [])
|
||||
|
||||
return { config, loading, error }
|
||||
const refresh = useCallback(() => {
|
||||
invalidateSystemConfig()
|
||||
}, [])
|
||||
|
||||
return { config, loading, error, refresh }
|
||||
}
|
||||
|
||||
@@ -493,6 +493,9 @@ export const translations = {
|
||||
registerNow: 'Sign up now',
|
||||
loginNow: 'Sign in now',
|
||||
forgotPassword: 'Forgot password?',
|
||||
forgotAccount: 'Forgot account?',
|
||||
forgotAccountConfirm: 'This will clear all account data and allow you to register a new account. Continue?',
|
||||
forgotAccountSuccess: 'Account reset successful! You can now register a new account.',
|
||||
rememberMe: 'Remember me',
|
||||
resetPassword: 'Reset Password',
|
||||
resetPasswordTitle: 'Reset your password',
|
||||
@@ -1080,11 +1083,16 @@ export const translations = {
|
||||
public: 'Public',
|
||||
addDescription: 'Add strategy description...',
|
||||
unsaved: 'Unsaved',
|
||||
discardChanges: 'Discard',
|
||||
selectOrCreate: 'Select or create a strategy',
|
||||
customPromptDesc: 'Extra prompt appended to System Prompt for personalized trading style',
|
||||
customPromptPlaceholder: 'Enter custom prompt...',
|
||||
generatePromptPreview: 'Click to generate prompt preview',
|
||||
runAiTestHint: 'Click to run AI test',
|
||||
tokenEstimate: 'Token Estimate',
|
||||
tokenExceedWarning: 'Token estimate exceeds 128K. AI requests may fail for some models.',
|
||||
tokenEstimating: 'Estimating...',
|
||||
tokenTooltip: 'Based on 200K context',
|
||||
},
|
||||
|
||||
// Metric Tooltip
|
||||
@@ -1162,6 +1170,9 @@ export const translations = {
|
||||
close: 'Close',
|
||||
showingPositions: 'Showing {shown} of {total} positions',
|
||||
perPage: 'Per page',
|
||||
accountFetchFailed: 'DATA_FETCH::FAILED — Account data unavailable, check connection',
|
||||
positionsFetchFailed: 'Position data unavailable',
|
||||
decisionsFetchFailed: 'Decision data unavailable',
|
||||
},
|
||||
|
||||
// AITradersPage toast messages
|
||||
@@ -1205,8 +1216,13 @@ export const translations = {
|
||||
// ModelConfigModal
|
||||
modelConfig: {
|
||||
selectModel: 'Select Model',
|
||||
configure: 'Configure',
|
||||
configureApi: 'Configure API',
|
||||
configureWallet: 'Configure Wallet',
|
||||
chooseProvider: 'Choose Your AI Provider',
|
||||
claw402EntryDesc: 'Recommended default path. Use Base USDC pay-per-call instead of managing API keys.',
|
||||
otherApiEntry: 'Other API Providers',
|
||||
otherApiEntryDesc: 'Use your own API key for OpenAI, Claude, Gemini, DeepSeek, and more.',
|
||||
payPerCall: 'Pay-per-call USDC · All AI Models · No API Key',
|
||||
recommended: 'Best',
|
||||
allModelsClaw: 'Pay-per-call with USDC — supports all major AI models',
|
||||
@@ -1806,6 +1822,9 @@ export const translations = {
|
||||
registerNow: '立即注册',
|
||||
loginNow: '立即登录',
|
||||
forgotPassword: '忘记密码?',
|
||||
forgotAccount: '忘记账户?',
|
||||
forgotAccountConfirm: '这将清除所有账户数据,允许您重新注册新账户。是否继续?',
|
||||
forgotAccountSuccess: '账户已重置!现在可以注册新账户了。',
|
||||
rememberMe: '记住我',
|
||||
resetPassword: '重置密码',
|
||||
resetPasswordTitle: '重置您的密码',
|
||||
@@ -2371,11 +2390,16 @@ export const translations = {
|
||||
public: '公开',
|
||||
addDescription: '添加策略简介...',
|
||||
unsaved: '未保存',
|
||||
discardChanges: '撤销',
|
||||
selectOrCreate: '选择或创建策略',
|
||||
customPromptDesc: '附加在 System Prompt 末尾的额外提示,用于补充个性化交易风格',
|
||||
customPromptPlaceholder: '输入自定义提示词...',
|
||||
generatePromptPreview: '点击生成 Prompt 预览',
|
||||
runAiTestHint: '点击运行 AI 测试',
|
||||
tokenEstimate: 'Token 预估',
|
||||
tokenExceedWarning: 'Token 估算超过 128K,部分模型请求可能失败',
|
||||
tokenEstimating: '预估中...',
|
||||
tokenTooltip: '基于 200K 上下文计算',
|
||||
},
|
||||
|
||||
// Metric Tooltip
|
||||
@@ -2452,6 +2476,9 @@ export const translations = {
|
||||
close: '平仓',
|
||||
showingPositions: '显示 {shown} / {total} 个持仓',
|
||||
perPage: '每页',
|
||||
accountFetchFailed: 'DATA_FETCH::FAILED — 账户数据请求失败,请检查连接',
|
||||
positionsFetchFailed: '持仓数据请求失败',
|
||||
decisionsFetchFailed: '决策记录请求失败',
|
||||
},
|
||||
|
||||
aiTradersToast: {
|
||||
@@ -2493,8 +2520,13 @@ export const translations = {
|
||||
|
||||
modelConfig: {
|
||||
selectModel: '选择模型',
|
||||
configure: '配置',
|
||||
configureApi: '配置 API',
|
||||
configureWallet: '配置钱包',
|
||||
chooseProvider: '选择 AI 模型提供商',
|
||||
claw402EntryDesc: '默认推荐走这条路。直接用 Base USDC 按次付费,不需要自己管理 API Key。',
|
||||
otherApiEntry: '其他 API 模型',
|
||||
otherApiEntryDesc: '如果你已经有自己的 OpenAI、Claude、Gemini、DeepSeek 等 API Key,再从这里进入。',
|
||||
payPerCall: 'USDC 按次付费 · 支持全部 AI 模型 · 无需 API Key',
|
||||
recommended: '推荐',
|
||||
allModelsClaw: '用 USDC 按次付费,支持所有主流 AI 模型',
|
||||
@@ -3054,6 +3086,9 @@ export const translations = {
|
||||
registerNow: 'Daftar sekarang',
|
||||
loginNow: 'Masuk sekarang',
|
||||
forgotPassword: 'Lupa kata sandi?',
|
||||
forgotAccount: 'Lupa akun?',
|
||||
forgotAccountConfirm: 'Ini akan menghapus semua data akun dan memungkinkan Anda mendaftar akun baru. Lanjutkan?',
|
||||
forgotAccountSuccess: 'Akun berhasil direset! Anda sekarang dapat mendaftar akun baru.',
|
||||
rememberMe: 'Ingat saya',
|
||||
resetPassword: 'Reset Kata Sandi',
|
||||
resetPasswordTitle: 'Reset kata sandi Anda',
|
||||
@@ -3464,11 +3499,16 @@ export const translations = {
|
||||
public: 'Publik',
|
||||
addDescription: 'Tambah deskripsi strategi...',
|
||||
unsaved: 'Belum Disimpan',
|
||||
discardChanges: 'Buang',
|
||||
selectOrCreate: 'Pilih atau buat strategi',
|
||||
customPromptDesc: 'Prompt tambahan di akhir System Prompt untuk gaya trading personal',
|
||||
customPromptPlaceholder: 'Masukkan prompt kustom...',
|
||||
generatePromptPreview: 'Klik untuk generate pratinjau prompt',
|
||||
runAiTestHint: 'Klik untuk menjalankan uji AI',
|
||||
tokenEstimate: 'Estimasi Token',
|
||||
tokenExceedWarning: 'Estimasi token melebihi 128K. Permintaan AI mungkin gagal untuk beberapa model.',
|
||||
tokenEstimating: 'Mengestimasi...',
|
||||
tokenTooltip: 'Berdasarkan konteks 200K',
|
||||
},
|
||||
|
||||
// Metric Tooltip
|
||||
@@ -3545,6 +3585,9 @@ export const translations = {
|
||||
close: 'Tutup',
|
||||
showingPositions: 'Menampilkan {shown} dari {total} posisi',
|
||||
perPage: 'Per halaman',
|
||||
accountFetchFailed: 'DATA_FETCH::FAILED — Data akun tidak tersedia, periksa koneksi',
|
||||
positionsFetchFailed: 'Data posisi tidak tersedia',
|
||||
decisionsFetchFailed: 'Data keputusan tidak tersedia',
|
||||
},
|
||||
|
||||
aiTradersToast: {
|
||||
@@ -3586,8 +3629,13 @@ export const translations = {
|
||||
|
||||
modelConfig: {
|
||||
selectModel: 'Pilih Model',
|
||||
configure: 'Konfigurasi',
|
||||
configureApi: 'Konfigurasi API',
|
||||
configureWallet: 'Konfigurasi Wallet',
|
||||
chooseProvider: 'Pilih Penyedia AI Anda',
|
||||
claw402EntryDesc: 'Jalur default yang direkomendasikan. Gunakan Base USDC bayar per panggilan tanpa mengelola API key.',
|
||||
otherApiEntry: 'Penyedia API Lain',
|
||||
otherApiEntryDesc: 'Gunakan API key Anda sendiri untuk OpenAI, Claude, Gemini, DeepSeek, dan lainnya.',
|
||||
payPerCall: 'Bayar per panggilan USDC · Semua Model AI · Tanpa API Key',
|
||||
recommended: 'Terbaik',
|
||||
allModelsClaw: 'Bayar per panggilan dengan USDC — mendukung semua model AI utama',
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type {
|
||||
AIModel,
|
||||
Exchange,
|
||||
ExchangeAccountStateResponse,
|
||||
UpdateModelConfigRequest,
|
||||
UpdateExchangeConfigRequest,
|
||||
CreateExchangeRequest,
|
||||
BeginnerOnboardingResponse,
|
||||
CurrentBeginnerWalletResponse,
|
||||
} from '../../types'
|
||||
import { API_BASE, httpClient, CryptoService } from './helpers'
|
||||
|
||||
@@ -71,6 +74,16 @@ export const configApi = {
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getExchangeAccountState(): Promise<ExchangeAccountStateResponse> {
|
||||
const result = await httpClient.get<ExchangeAccountStateResponse>(
|
||||
`${API_BASE}/exchanges/account-state`
|
||||
)
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error('Failed to fetch exchange account states')
|
||||
}
|
||||
return result.data
|
||||
},
|
||||
|
||||
async getSupportedExchanges(): Promise<Exchange[]> {
|
||||
const result = await httpClient.get<Exchange[]>(
|
||||
`${API_BASE}/supported-exchanges`
|
||||
@@ -183,4 +196,24 @@ export const configApi = {
|
||||
if (!result.success) throw new Error('Failed to fetch server IP')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async prepareBeginnerOnboarding(): Promise<BeginnerOnboardingResponse> {
|
||||
const result = await httpClient.post<BeginnerOnboardingResponse>(
|
||||
`${API_BASE}/onboarding/beginner`
|
||||
)
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || 'Failed to prepare beginner onboarding')
|
||||
}
|
||||
return result.data
|
||||
},
|
||||
|
||||
async getCurrentBeginnerWallet(): Promise<CurrentBeginnerWalletResponse> {
|
||||
const result = await httpClient.get<CurrentBeginnerWalletResponse>(
|
||||
`${API_BASE}/onboarding/beginner/current`
|
||||
)
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || 'Failed to fetch current beginner wallet')
|
||||
}
|
||||
return result.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,29 +10,29 @@ import type {
|
||||
import { API_BASE, httpClient } from './helpers'
|
||||
|
||||
export const dataApi = {
|
||||
async getStatus(traderId?: string): Promise<SystemStatus> {
|
||||
async getStatus(traderId?: string, silent?: boolean): Promise<SystemStatus> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/status?trader_id=${traderId}`
|
||||
: `${API_BASE}/status`
|
||||
const result = await httpClient.get<SystemStatus>(url)
|
||||
const result = await httpClient.request<SystemStatus>(url, { silent })
|
||||
if (!result.success) throw new Error('Failed to fetch system status')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getAccount(traderId?: string): Promise<AccountInfo> {
|
||||
async getAccount(traderId?: string, silent?: boolean): Promise<AccountInfo> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/account?trader_id=${traderId}`
|
||||
: `${API_BASE}/account`
|
||||
const result = await httpClient.get<AccountInfo>(url)
|
||||
const result = await httpClient.request<AccountInfo>(url, { silent })
|
||||
if (!result.success) throw new Error('Failed to fetch account info')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getPositions(traderId?: string): Promise<Position[]> {
|
||||
async getPositions(traderId?: string, silent?: boolean): Promise<Position[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/positions?trader_id=${traderId}`
|
||||
: `${API_BASE}/positions`
|
||||
const result = await httpClient.get<Position[]>(url)
|
||||
const result = await httpClient.request<Position[]>(url, { silent })
|
||||
if (!result.success) throw new Error('Failed to fetch positions')
|
||||
return result.data!
|
||||
},
|
||||
@@ -48,7 +48,8 @@ export const dataApi = {
|
||||
|
||||
async getLatestDecisions(
|
||||
traderId?: string,
|
||||
limit: number = 5
|
||||
limit: number = 5,
|
||||
silent?: boolean
|
||||
): Promise<DecisionRecord[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (traderId) {
|
||||
@@ -56,27 +57,28 @@ export const dataApi = {
|
||||
}
|
||||
params.append('limit', limit.toString())
|
||||
|
||||
const result = await httpClient.get<DecisionRecord[]>(
|
||||
`${API_BASE}/decisions/latest?${params}`
|
||||
const result = await httpClient.request<DecisionRecord[]>(
|
||||
`${API_BASE}/decisions/latest?${params}`,
|
||||
{ silent }
|
||||
)
|
||||
if (!result.success) throw new Error('Failed to fetch latest decisions')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getStatistics(traderId?: string): Promise<Statistics> {
|
||||
async getStatistics(traderId?: string, silent?: boolean): Promise<Statistics> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/statistics?trader_id=${traderId}`
|
||||
: `${API_BASE}/statistics`
|
||||
const result = await httpClient.get<Statistics>(url)
|
||||
const result = await httpClient.request<Statistics>(url, { silent })
|
||||
if (!result.success) throw new Error('Failed to fetch statistics')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getEquityHistory(traderId?: string): Promise<any[]> {
|
||||
async getEquityHistory(traderId?: string, silent?: boolean): Promise<any[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/equity-history?trader_id=${traderId}`
|
||||
: `${API_BASE}/equity-history`
|
||||
const result = await httpClient.get<any[]>(url)
|
||||
const result = await httpClient.request<any[]>(url, { silent })
|
||||
if (!result.success) throw new Error('Failed to fetch equity history')
|
||||
return result.data!
|
||||
},
|
||||
@@ -112,9 +114,14 @@ export const dataApi = {
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getPositionHistory(traderId: string, limit: number = 100): Promise<PositionHistoryResponse> {
|
||||
const result = await httpClient.get<PositionHistoryResponse>(
|
||||
`${API_BASE}/positions/history?trader_id=${traderId}&limit=${limit}`
|
||||
async getPositionHistory(
|
||||
traderId: string,
|
||||
limit: number = 100,
|
||||
silent?: boolean
|
||||
): Promise<PositionHistoryResponse> {
|
||||
const result = await httpClient.request<PositionHistoryResponse>(
|
||||
`${API_BASE}/positions/history?trader_id=${traderId}&limit=${limit}`,
|
||||
{ silent }
|
||||
)
|
||||
if (!result.success) throw new Error('Failed to fetch position history')
|
||||
return result.data!
|
||||
|
||||
@@ -4,10 +4,23 @@ import type {
|
||||
CreateTraderRequest,
|
||||
} from '../../types'
|
||||
import { API_BASE, httpClient } from './helpers'
|
||||
import { ApiError } from '../httpClient'
|
||||
|
||||
function throwApiError(
|
||||
message: string,
|
||||
errorKey?: string,
|
||||
errorParams?: Record<string, string>,
|
||||
statusCode?: number
|
||||
): never {
|
||||
throw new ApiError(message, errorKey, errorParams, statusCode)
|
||||
}
|
||||
|
||||
export const traderApi = {
|
||||
async getTraders(): Promise<TraderInfo[]> {
|
||||
const result = await httpClient.get<TraderInfo[]>(`${API_BASE}/my-traders`)
|
||||
async getTraders(silent?: boolean): Promise<TraderInfo[]> {
|
||||
const result = await httpClient.request<TraderInfo[]>(
|
||||
`${API_BASE}/my-traders`,
|
||||
{ silent }
|
||||
)
|
||||
if (!result.success) throw new Error('Failed to fetch trader list')
|
||||
return Array.isArray(result.data) ? result.data : []
|
||||
},
|
||||
@@ -23,7 +36,14 @@ export const traderApi = {
|
||||
`${API_BASE}/traders`,
|
||||
request
|
||||
)
|
||||
if (!result.success) throw new Error('Failed to create trader')
|
||||
if (!result.success) {
|
||||
throwApiError(
|
||||
result.message || 'Failed to create trader',
|
||||
result.errorKey,
|
||||
result.errorParams,
|
||||
result.statusCode
|
||||
)
|
||||
}
|
||||
return result.data!
|
||||
},
|
||||
|
||||
@@ -36,7 +56,14 @@ export const traderApi = {
|
||||
const result = await httpClient.post(
|
||||
`${API_BASE}/traders/${traderId}/start`
|
||||
)
|
||||
if (!result.success) throw new Error('Failed to start trader')
|
||||
if (!result.success) {
|
||||
throwApiError(
|
||||
result.message || 'Failed to start trader',
|
||||
result.errorKey,
|
||||
result.errorParams,
|
||||
result.statusCode
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
async stopTrader(traderId: string): Promise<void> {
|
||||
@@ -88,7 +115,14 @@ export const traderApi = {
|
||||
`${API_BASE}/traders/${traderId}`,
|
||||
request
|
||||
)
|
||||
if (!result.success) throw new Error('Failed to update trader')
|
||||
if (!result.success) {
|
||||
throwApiError(
|
||||
result.message || 'Failed to update trader',
|
||||
result.errorKey,
|
||||
result.errorParams,
|
||||
result.statusCode
|
||||
)
|
||||
}
|
||||
return result.data!
|
||||
},
|
||||
}
|
||||
|
||||
@@ -26,4 +26,5 @@ export function getSystemConfig(): Promise<SystemConfig> {
|
||||
export function invalidateSystemConfig() {
|
||||
cachedConfig = null
|
||||
configPromise = null
|
||||
window.dispatchEvent(new Event('system-config-invalidated'))
|
||||
}
|
||||
|
||||
@@ -19,6 +19,28 @@ export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
data?: T
|
||||
message?: string
|
||||
errorKey?: string
|
||||
errorParams?: Record<string, string>
|
||||
statusCode?: number
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
errorKey?: string
|
||||
errorParams?: Record<string, string>
|
||||
statusCode?: number
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
errorKey?: string,
|
||||
errorParams?: Record<string, string>,
|
||||
statusCode?: number
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.errorKey = errorKey
|
||||
this.errorParams = errorParams
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,18 +107,27 @@ export class HttpClient {
|
||||
* Only business errors are returned to caller
|
||||
*/
|
||||
private async handleError(error: AxiosError): Promise<any> {
|
||||
const isSilent = (error.config as any)?.silentError === true
|
||||
const errorData = error.response?.data as {
|
||||
error?: string
|
||||
message?: string
|
||||
error_key?: string
|
||||
error_params?: Record<string, string>
|
||||
} | undefined
|
||||
const serverMessage = errorData?.error || errorData?.message
|
||||
|
||||
// Network error (no response from server)
|
||||
if (!error.response) {
|
||||
toast.error('Network error - Please check your connection', {
|
||||
description: 'Unable to reach the server',
|
||||
})
|
||||
if (!isSilent) {
|
||||
toast.error('Network error - Please check your connection', {
|
||||
id: 'network-error',
|
||||
description: 'Unable to reach the server',
|
||||
})
|
||||
}
|
||||
throw new Error('Network error')
|
||||
}
|
||||
|
||||
const { status } = error.response as AxiosResponse<{
|
||||
error?: string
|
||||
message?: string
|
||||
}>
|
||||
const status = error.response?.status ?? 0
|
||||
|
||||
// Handle 401 Unauthorized
|
||||
if (status === 401) {
|
||||
@@ -132,25 +163,37 @@ export class HttpClient {
|
||||
|
||||
// Handle 403 Forbidden - system error
|
||||
if (status === 403) {
|
||||
toast.error('Permission Denied', {
|
||||
description: 'You do not have permission to access this resource',
|
||||
})
|
||||
if (!isSilent) {
|
||||
toast.error('Permission Denied', {
|
||||
id: 'permission-denied',
|
||||
description: 'You do not have permission to access this resource',
|
||||
})
|
||||
}
|
||||
throw new Error('Permission denied')
|
||||
}
|
||||
|
||||
// Handle 404 Not Found - system error
|
||||
if (status === 404) {
|
||||
toast.error('API Not Found', {
|
||||
description: 'The requested endpoint does not exist (404)',
|
||||
})
|
||||
if (!isSilent) {
|
||||
toast.error('API Not Found', {
|
||||
id: `404-${(error.config as any)?.url || 'unknown'}`,
|
||||
description: 'The requested endpoint does not exist (404)',
|
||||
})
|
||||
}
|
||||
throw new Error('API not found')
|
||||
}
|
||||
|
||||
// Handle 500+ Server Error - system error
|
||||
if (status >= 500) {
|
||||
toast.error('Server Error', {
|
||||
description: 'Please try again later or contact support',
|
||||
})
|
||||
if (serverMessage) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
if (!isSilent) {
|
||||
toast.error('Server Error', {
|
||||
id: 'server-error',
|
||||
description: 'Please try again later or contact support',
|
||||
})
|
||||
}
|
||||
throw new Error('Server error')
|
||||
}
|
||||
|
||||
@@ -171,6 +214,7 @@ export class HttpClient {
|
||||
data?: any
|
||||
params?: any
|
||||
headers?: Record<string, string>
|
||||
silent?: boolean
|
||||
} = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
@@ -180,6 +224,7 @@ export class HttpClient {
|
||||
data: options.data,
|
||||
params: options.params,
|
||||
headers: options.headers,
|
||||
...(options.silent && { silentError: true }),
|
||||
})
|
||||
|
||||
// Success
|
||||
@@ -196,6 +241,9 @@ export class HttpClient {
|
||||
return {
|
||||
success: false,
|
||||
message: errorData?.error || errorData?.message || 'Operation failed',
|
||||
errorKey: errorData?.error_key,
|
||||
errorParams: errorData?.error_params,
|
||||
statusCode: error.response.status,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
web/src/lib/onboarding.ts
Normal file
37
web/src/lib/onboarding.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type UserMode = 'beginner' | 'advanced'
|
||||
|
||||
const USER_MODE_KEY = 'nofx_user_mode'
|
||||
const BEGINNER_WALLET_ADDRESS_KEY = 'nofx_beginner_wallet_address'
|
||||
const BEGINNER_ONBOARDING_COMPLETED_KEY = 'nofx_beginner_onboarding_completed'
|
||||
|
||||
export function getUserMode(): UserMode | null {
|
||||
const value = localStorage.getItem(USER_MODE_KEY)
|
||||
if (value === 'beginner' || value === 'advanced') {
|
||||
return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function setUserMode(mode: UserMode) {
|
||||
localStorage.setItem(USER_MODE_KEY, mode)
|
||||
}
|
||||
|
||||
export function getPostAuthPath(mode: UserMode | null | undefined): string {
|
||||
return mode === 'beginner' ? '/welcome' : '/traders'
|
||||
}
|
||||
|
||||
export function setBeginnerWalletAddress(address: string) {
|
||||
localStorage.setItem(BEGINNER_WALLET_ADDRESS_KEY, address)
|
||||
}
|
||||
|
||||
export function getBeginnerWalletAddress(): string | null {
|
||||
return localStorage.getItem(BEGINNER_WALLET_ADDRESS_KEY)
|
||||
}
|
||||
|
||||
export function hasCompletedBeginnerOnboarding(): boolean {
|
||||
return localStorage.getItem(BEGINNER_ONBOARDING_COMPLETED_KEY) === 'true'
|
||||
}
|
||||
|
||||
export function markBeginnerOnboardingCompleted() {
|
||||
localStorage.setItem(BEGINNER_ONBOARDING_COMPLETED_KEY, 'true')
|
||||
}
|
||||
269
web/src/pages/BeginnerOnboardingPage.tsx
Normal file
269
web/src/pages/BeginnerOnboardingPage.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowRight,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Wallet,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { api } from '../lib/api'
|
||||
import type { BeginnerOnboardingResponse } from '../types'
|
||||
import { setBeginnerWalletAddress, markBeginnerOnboardingCompleted } from '../lib/onboarding'
|
||||
|
||||
export function BeginnerOnboardingPage() {
|
||||
const { language } = useLanguage()
|
||||
const [data, setData] = useState<BeginnerOnboardingResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [refreshingBalance, setRefreshingBalance] = useState(false)
|
||||
const hasRequestedRef = useRef(false)
|
||||
const isZh = language === 'zh'
|
||||
|
||||
const loadOnboarding = async (showLoading: boolean) => {
|
||||
if (showLoading) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
setRefreshingBalance(true)
|
||||
}
|
||||
|
||||
setError('')
|
||||
try {
|
||||
const result = await api.prepareBeginnerOnboarding()
|
||||
setData(result)
|
||||
setBeginnerWalletAddress(result.address)
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: isZh
|
||||
? '新手钱包准备失败'
|
||||
: 'Failed to prepare beginner wallet'
|
||||
)
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setLoading(false)
|
||||
} else {
|
||||
setRefreshingBalance(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRequestedRef.current) {
|
||||
return
|
||||
}
|
||||
hasRequestedRef.current = true
|
||||
void loadOnboarding(true)
|
||||
}, [])
|
||||
|
||||
const noticeText = useMemo(
|
||||
() =>
|
||||
isZh
|
||||
? '此钱包仅用于大模型调用费用,不会自动充到交易所。私钥丢失后无法恢复,只充 Base 链 USDC。'
|
||||
: 'This wallet only pays for model calls. It does not fund your exchange automatically. The private key cannot be recovered, and you should only deposit Base USDC.',
|
||||
[isZh]
|
||||
)
|
||||
|
||||
const copyText = async (value: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast.success(isZh ? `${label}已复制` : `${label} copied`)
|
||||
} catch {
|
||||
toast.error(isZh ? '复制失败' : 'Copy failed')
|
||||
}
|
||||
}
|
||||
|
||||
const handleContinue = () => {
|
||||
markBeginnerOnboardingCompleted()
|
||||
window.history.pushState({}, '', '/traders')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80]">
|
||||
<div className="absolute inset-0 bg-black/58 backdrop-blur-[2px]" />
|
||||
<div className="relative flex min-h-screen items-center justify-center px-4 py-10 sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
className="absolute right-6 top-6 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/5 text-zinc-400 transition hover:border-white/20 hover:bg-white/10 hover:text-white"
|
||||
aria-label={isZh ? '跳过' : 'Skip'}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="w-full max-w-[1120px]">
|
||||
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-[22px] border border-nofx-gold/20 bg-nofx-gold/8 text-nofx-gold shadow-[0_0_30px_rgba(240,185,11,0.12)]">
|
||||
<Shield className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={`font-semibold uppercase text-nofx-gold/80 ${
|
||||
isZh ? 'text-[11px] tracking-[0.34em]' : 'text-[10px] tracking-[0.2em]'
|
||||
}`}
|
||||
>
|
||||
{isZh ? '新手保护' : 'Beginner Guard'}
|
||||
</div>
|
||||
<h1
|
||||
className={`mt-2 font-bold leading-[1.04] text-white ${
|
||||
isZh
|
||||
? 'text-[34px] tracking-tight sm:text-[44px] xl:text-[52px] xl:whitespace-nowrap'
|
||||
: 'max-w-[720px] text-[27px] tracking-[-0.03em] sm:text-[35px] xl:text-[42px]'
|
||||
}`}
|
||||
>
|
||||
{isZh ? '钱包已经帮你准备好了' : 'Your wallet is ready'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`pb-2 text-zinc-500 lg:text-right ${
|
||||
isZh
|
||||
? 'text-sm tracking-[0.18em] lg:whitespace-nowrap'
|
||||
: 'text-[13px] tracking-[0.12em] lg:whitespace-nowrap'
|
||||
}`}
|
||||
>
|
||||
Claw402 + DeepSeek <span className="mx-2 text-zinc-700">·</span>
|
||||
{isZh ? '按次付费' : 'Pay per call'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(8,11,16,0.94),rgba(5,7,10,0.88))] shadow-[0_24px_120px_rgba(0,0,0,0.58)] backdrop-blur-2xl">
|
||||
{loading ? (
|
||||
<div className="flex min-h-[390px] items-center justify-center px-6 text-sm text-zinc-400">
|
||||
{isZh ? '正在准备你的 Base 钱包...' : 'Preparing your Base wallet...'}
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="grid lg:grid-cols-[0.82fr_1.18fr]">
|
||||
<section className="flex flex-col justify-center px-8 py-7 sm:px-9 lg:min-h-[430px]">
|
||||
<div className="mx-auto w-full max-w-[248px] text-center">
|
||||
<div className="mx-auto inline-flex rounded-[28px] border border-black/10 bg-white p-4 shadow-[0_20px_60px_rgba(255,255,255,0.08)]">
|
||||
<QRCodeSVG value={data.address} size={164} level="M" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-[15px] font-medium text-zinc-300">
|
||||
{isZh ? '充值地址(Base USDC)' : 'Deposit address (Base USDC)'}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3 rounded-[24px] border border-emerald-400/20 bg-emerald-500/7 px-5 py-3.5 shadow-[0_0_0_1px_rgba(16,185,129,0.08)]">
|
||||
<div className="text-left">
|
||||
<div className="flex items-baseline gap-3 font-mono font-bold tracking-tight text-emerald-300">
|
||||
<span className="text-[22px]">{data.balance_usdc}</span>
|
||||
<span className="text-[20px]">USDC</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadOnboarding(false)}
|
||||
disabled={refreshingBalance}
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-emerald-300/20 bg-black/20 text-emerald-300 transition hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-label={isZh ? '刷新余额' : 'Refresh balance'}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshingBalance ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-zinc-500">
|
||||
{isZh ? '$5-$10 可以用很久' : '$5-$10 usually lasts a long time'}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-t border-white/8 px-8 py-7 lg:border-l lg:border-t-0 lg:px-9">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-nofx-gold">
|
||||
<Wallet className="h-4 w-4" />
|
||||
<span>{isZh ? '钱包地址' : 'Wallet address'}</span>
|
||||
</div>
|
||||
<div className="flex items-stretch gap-3">
|
||||
<div className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-5 py-3 font-mono text-[14px] text-zinc-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
|
||||
<div className="break-all">{data.address}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(data.address, isZh ? '地址' : 'Address')}
|
||||
className="inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-zinc-300 transition hover:border-white/20 hover:bg-white/10 hover:text-white"
|
||||
aria-label={isZh ? '复制地址' : 'Copy address'}
|
||||
>
|
||||
<Copy className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-nofx-gold">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>{isZh ? '私钥,请立即备份' : 'Private key, back it up now'}</span>
|
||||
</div>
|
||||
<div className="flex items-stretch gap-3">
|
||||
<div className="min-w-0 flex-1 rounded-[24px] border border-nofx-gold/20 bg-[linear-gradient(180deg,rgba(32,25,7,0.44),rgba(14,10,3,0.28))] px-5 py-3 font-mono text-[13px] leading-6 text-amber-100 shadow-[0_0_0_1px_rgba(240,185,11,0.05)]">
|
||||
<div className="overflow-x-auto whitespace-nowrap">{data.private_key}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(data.private_key, isZh ? '私钥' : 'Private key')}
|
||||
className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-nofx-gold/20 bg-nofx-gold/10 text-nofx-gold transition hover:bg-nofx-gold/15"
|
||||
aria-label={isZh ? '复制私钥' : 'Copy private key'}
|
||||
>
|
||||
<Copy className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-[24px] border border-white/15 bg-black/18 px-5 py-3.5 text-zinc-500 ${
|
||||
isZh ? 'text-xs lg:whitespace-nowrap' : 'text-[11px] leading-6'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2 text-zinc-600">•</span>
|
||||
{noticeText}
|
||||
</div>
|
||||
|
||||
{data.env_warning ? (
|
||||
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
|
||||
{data.env_warning}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
className={`mt-1 flex w-full items-center justify-center gap-3 rounded-[24px] bg-nofx-gold px-5 py-3.5 font-bold text-black shadow-[0_10px_40px_rgba(240,185,11,0.22)] transition hover:bg-yellow-400 ${
|
||||
isZh ? 'text-[20px]' : 'text-[16px] sm:text-[18px]'
|
||||
}`}
|
||||
>
|
||||
<span>{isZh ? '我已保存,进入下一步' : 'I saved it, continue'}</span>
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{data.env_saved ? (
|
||||
<div className="pt-1 text-xs text-zinc-600">
|
||||
{isZh
|
||||
? `钱包信息已同步保存到 ${data.env_path || '.env'}`
|
||||
: `Wallet details were also saved to ${data.env_path || '.env'}`}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, P
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { api } from '../lib/api'
|
||||
import {
|
||||
getPostAuthPath,
|
||||
getUserMode,
|
||||
setUserMode,
|
||||
type UserMode,
|
||||
} from '../lib/onboarding'
|
||||
import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal'
|
||||
import { TelegramConfigModal } from '../components/trader/TelegramConfigModal'
|
||||
import { ModelConfigModal } from '../components/trader/ModelConfigModal'
|
||||
@@ -15,6 +21,7 @@ export function SettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const { language } = useLanguage()
|
||||
const [activeTab, setActiveTab] = useState<Tab>('account')
|
||||
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
|
||||
|
||||
// Account state
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
@@ -81,6 +88,26 @@ export function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSwitchMode = (nextMode: UserMode) => {
|
||||
if (nextMode === userMode) {
|
||||
return
|
||||
}
|
||||
|
||||
setUserMode(nextMode)
|
||||
setUserModeState(nextMode)
|
||||
toast.success(
|
||||
language === 'zh'
|
||||
? `已切换到${nextMode === 'beginner' ? '新手模式' : '老手模式'}`
|
||||
: nextMode === 'beginner'
|
||||
? 'Switched to beginner mode'
|
||||
: 'Switched to advanced mode'
|
||||
)
|
||||
|
||||
const nextPath = getPostAuthPath(nextMode)
|
||||
window.history.pushState({}, '', nextPath)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const handleSaveModel = async (
|
||||
modelId: string,
|
||||
apiKey: string,
|
||||
@@ -281,6 +308,66 @@ export function SettingsPage() {
|
||||
<p className="text-sm text-white font-medium">{user?.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-800 pt-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{language === 'zh' ? '使用模式' : 'Usage Mode'}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{language === 'zh'
|
||||
? '新手模式会显示钱包引导和 4 步卡片;老手模式保持原来的专业界面。'
|
||||
: 'Beginner mode shows wallet onboarding and quickstart cards. Advanced mode keeps the original pro workflow.'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-nofx-gold/20 bg-nofx-gold/10 px-3 py-1 text-xs font-semibold text-nofx-gold">
|
||||
{userMode === 'beginner'
|
||||
? language === 'zh' ? '当前:新手模式' : 'Current: Beginner'
|
||||
: language === 'zh' ? '当前:老手模式' : 'Current: Advanced'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitchMode('beginner')}
|
||||
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
|
||||
userMode === 'beginner'
|
||||
? 'border-nofx-gold bg-nofx-gold/10'
|
||||
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{language === 'zh' ? '新手模式' : 'Beginner Mode'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{language === 'zh'
|
||||
? '更简单,优先显示钱包、充值和快速上手引导。'
|
||||
: 'Simpler flow with wallet, funding, and quickstart guidance first.'}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitchMode('advanced')}
|
||||
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
|
||||
userMode === 'advanced'
|
||||
? 'border-nofx-gold bg-nofx-gold/10'
|
||||
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{language === 'zh' ? '老手模式' : 'Advanced Mode'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{language === 'zh'
|
||||
? '保持原来的配置与交易流程,不展示新手引导。'
|
||||
: 'Keeps the original configuration and trading workflow without beginner hints.'}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-800 pt-6">
|
||||
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
|
||||
@@ -38,6 +38,7 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
|
||||
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
|
||||
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
|
||||
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
|
||||
import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar'
|
||||
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
@@ -52,6 +53,7 @@ export function StrategyStudioPage() {
|
||||
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [estimatedTokens, setEstimatedTokens] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
@@ -247,6 +249,24 @@ export function StrategyStudioPage() {
|
||||
const handleDeleteStrategy = async (id: string) => {
|
||||
if (!token) return
|
||||
|
||||
// Check if strategy is in use by any trader before showing dialog
|
||||
try {
|
||||
const tradersResp = await fetch(`${API_BASE}/api/my-traders`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (tradersResp.ok) {
|
||||
const traderList = await tradersResp.json()
|
||||
const using = traderList.filter((t: any) => t.strategy_id === id)
|
||||
if (using.length > 0) {
|
||||
const names = using.map((t: any) => t.trader_name).join(', ')
|
||||
notify.error(`Strategy is in use by: ${names}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fetch failed — proceed, backend will guard
|
||||
}
|
||||
|
||||
const confirmed = await confirmToast(
|
||||
tr('confirmDeleteStrategy'),
|
||||
{
|
||||
@@ -262,9 +282,12 @@ export function StrategyStudioPage() {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to delete strategy')
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
notify.error(data.error || 'Failed to delete strategy')
|
||||
return
|
||||
}
|
||||
notify.success(tr('strategyDeleted'))
|
||||
// Clear selection if deleted strategy was selected
|
||||
if (selectedStrategy?.id === id) {
|
||||
setSelectedStrategy(null)
|
||||
setEditingConfig(null)
|
||||
@@ -272,9 +295,7 @@ export function StrategyStudioPage() {
|
||||
}
|
||||
await fetchStrategies()
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Unknown error'
|
||||
setError(errorMsg)
|
||||
notify.error(errorMsg)
|
||||
notify.error(err instanceof Error ? err.message : 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +399,10 @@ export function StrategyStudioPage() {
|
||||
// Save strategy
|
||||
const handleSaveStrategy = async () => {
|
||||
if (!token || !selectedStrategy || !editingConfig) return
|
||||
if (estimatedTokens >= 128000 && currentStrategyType === 'ai_trading') {
|
||||
notify.warning(tr('tokenExceedWarning'))
|
||||
// continue with save
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
// Always sync the config language with the current interface language
|
||||
@@ -681,7 +706,7 @@ export function StrategyStudioPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2">
|
||||
{strategies.map((strategy) => (
|
||||
<div
|
||||
key={strategy.id}
|
||||
@@ -694,11 +719,11 @@ export function StrategyStudioPage() {
|
||||
}}
|
||||
className={`group px-2 py-2 rounded-lg cursor-pointer transition-all ${selectedStrategy?.id === strategy.id
|
||||
? 'ring-1 ring-nofx-gold/50 bg-nofx-gold/10 shadow-[0_0_15px_rgba(240,185,11,0.1)]'
|
||||
: 'hover:bg-nofx-bg-lighter/60 hover:ring-1 hover:ring-nofx-gold/20 bg-transparent'
|
||||
: 'hover:bg-nofx-bg-lighter/60 ring-1 ring-white/10 hover:ring-nofx-gold/20 bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm truncate text-nofx-text">{strategy.name}</span>
|
||||
<div className="flex items-start justify-between">
|
||||
<span className={`line-clamp-2 text-nofx-text ${language === 'zh' ? 'text-sm' : 'text-xs'}`}>{strategy.name}</span>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleExportStrategy(strategy) }}
|
||||
@@ -807,6 +832,13 @@ export function StrategyStudioPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Estimate Bar */}
|
||||
{currentStrategyType === 'ai_trading' && (
|
||||
<div className="mb-4">
|
||||
<TokenEstimateBar config={editingConfig} language={language} onTokenCountChange={setEstimatedTokens} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Strategy Type Selector */}
|
||||
{editingConfig && (
|
||||
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { formatPrice, formatQuantity } from '../utils/format'
|
||||
import { t, type Language } from '../i18n/translations'
|
||||
import { LogOut, Loader2, Eye, EyeOff, Copy, Check } from 'lucide-react'
|
||||
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
||||
import { NofxSelect } from '../components/ui/select'
|
||||
import { GridRiskPanel } from '../components/strategy/GridRiskPanel'
|
||||
import type {
|
||||
SystemStatus,
|
||||
@@ -102,8 +103,11 @@ interface TraderDashboardPageProps {
|
||||
onNavigateToTraders: () => void
|
||||
status?: SystemStatus
|
||||
account?: AccountInfo
|
||||
accountFailed?: boolean
|
||||
positions?: Position[]
|
||||
positionsFailed?: boolean
|
||||
decisions?: DecisionRecord[]
|
||||
decisionsFailed?: boolean
|
||||
decisionsLimit: number
|
||||
onDecisionsLimitChange: (limit: number) => void
|
||||
stats?: Statistics
|
||||
@@ -116,8 +120,11 @@ export function TraderDashboardPage({
|
||||
selectedTrader,
|
||||
status,
|
||||
account,
|
||||
accountFailed,
|
||||
positions,
|
||||
positionsFailed,
|
||||
decisions,
|
||||
decisionsFailed,
|
||||
decisionsLimit,
|
||||
onDecisionsLimitChange,
|
||||
lastUpdate,
|
||||
@@ -376,17 +383,12 @@ export function TraderDashboardPage({
|
||||
{/* Trader Selector */}
|
||||
{traders && traders.length > 0 && (
|
||||
<div className="flex items-center gap-2 nofx-glass px-1 py-1 rounded-lg border border-white/5">
|
||||
<select
|
||||
value={selectedTraderId}
|
||||
onChange={(e) => onTraderSelect(e.target.value)}
|
||||
className="bg-transparent text-sm font-medium cursor-pointer transition-colors text-nofx-text-main focus:outline-none px-2 py-1"
|
||||
>
|
||||
{traders.map((trader) => (
|
||||
<option key={trader.trader_id} value={trader.trader_id} className="bg-[#0B0E11]">
|
||||
{trader.trader_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<NofxSelect
|
||||
value={selectedTraderId || ''}
|
||||
onChange={(val) => onTraderSelect(val)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -484,48 +486,60 @@ export function TraderDashboardPage({
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<span>SYSTEM_STATUS::ONLINE</span>
|
||||
<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>
|
||||
{account ? (
|
||||
<div className="flex gap-4">
|
||||
<span>LAST_UPDATE::{lastUpdate}</span>
|
||||
<span>EQ::{account?.total_equity?.toFixed(2)}</span>
|
||||
<span>PNL::{account?.total_pnl?.toFixed(2)}</span>
|
||||
<span>EQ::{account.total_equity?.toFixed(2)}</span>
|
||||
<span>PNL::{account.total_pnl?.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : accountFailed ? (
|
||||
<span style={{ color: '#F6465D' }}>{t('traderDashboard.accountFetchFailed', language)}</span>
|
||||
) : (
|
||||
<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 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
title={t('totalEquity', language)}
|
||||
value={`${account?.total_equity?.toFixed(2) || '0.00'}`}
|
||||
value={accountFailed && !account ? '--' : `${account?.total_equity?.toFixed(2) ?? '--'}`}
|
||||
unit="USDT"
|
||||
change={account?.total_pnl_pct || 0}
|
||||
change={account ? (account.total_pnl_pct || 0) : undefined}
|
||||
positive={(account?.total_pnl ?? 0) > 0}
|
||||
icon="💰"
|
||||
loading={!account && !accountFailed}
|
||||
/>
|
||||
<StatCard
|
||||
title={t('availableBalance', language)}
|
||||
value={`${account?.available_balance?.toFixed(2) || '0.00'}`}
|
||||
value={accountFailed && !account ? '--' : `${account?.available_balance?.toFixed(2) ?? '--'}`}
|
||||
unit="USDT"
|
||||
subtitle={`${account?.available_balance && account?.total_equity ? ((account.available_balance / account.total_equity) * 100).toFixed(1) : '0.0'}% ${t('free', language)}`}
|
||||
subtitle={accountFailed && !account ? '--' : `${account?.available_balance && account?.total_equity ? ((account.available_balance / account.total_equity) * 100).toFixed(1) : '--'}% ${t('free', language)}`}
|
||||
icon="💳"
|
||||
loading={!account && !accountFailed}
|
||||
/>
|
||||
<StatCard
|
||||
title={t('totalPnL', language)}
|
||||
value={`${account?.total_pnl !== undefined && account.total_pnl >= 0 ? '+' : ''}${account?.total_pnl?.toFixed(2) || '0.00'}`}
|
||||
value={accountFailed && !account ? '--' : `${account?.total_pnl !== undefined && account.total_pnl >= 0 ? '+' : ''}${account?.total_pnl?.toFixed(2) ?? '--'}`}
|
||||
unit="USDT"
|
||||
change={account?.total_pnl_pct || 0}
|
||||
change={account ? (account.total_pnl_pct || 0) : undefined}
|
||||
positive={(account?.total_pnl ?? 0) >= 0}
|
||||
icon="📈"
|
||||
loading={!account && !accountFailed}
|
||||
/>
|
||||
<StatCard
|
||||
title={t('positions', language)}
|
||||
value={`${account?.position_count || 0}`}
|
||||
value={accountFailed && !account ? '--' : `${account?.position_count ?? '--'}`}
|
||||
unit="ACTIVE"
|
||||
subtitle={`${t('margin', language)}: ${account?.margin_used_pct?.toFixed(1) || '0.0'}%`}
|
||||
subtitle={accountFailed && !account ? `${t('margin', language)}: --` : `${t('margin', language)}: ${account?.margin_used_pct?.toFixed(1) ?? '--'}%`}
|
||||
icon="📊"
|
||||
loading={!account && !accountFailed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -671,15 +685,12 @@ export function TraderDashboardPage({
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t('traderDashboard.perPage', language)}:</span>
|
||||
<select
|
||||
<NofxSelect
|
||||
value={positionsPageSize}
|
||||
onChange={(e) => setPositionsPageSize(Number(e.target.value))}
|
||||
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"
|
||||
>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
onChange={(val) => setPositionsPageSize(Number(val))}
|
||||
options={[{ value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]}
|
||||
className="bg-black/40 border border-white/10 rounded px-2 py-1 text-xs text-nofx-text-main transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{totalPositionPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -716,6 +727,11 @@ export function TraderDashboardPage({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : positionsFailed ? (
|
||||
<div className="text-center py-16 text-nofx-text-muted opacity-60">
|
||||
<div className="text-4xl mb-4">⚠️</div>
|
||||
<div className="text-lg font-semibold mb-2">{t('traderDashboard.positionsFetchFailed', language)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-nofx-text-muted opacity-60">
|
||||
<div className="text-6xl mb-4 opacity-50 grayscale">📊</div>
|
||||
@@ -752,17 +768,12 @@ export function TraderDashboardPage({
|
||||
)}
|
||||
</div>
|
||||
{/* Limit Selector */}
|
||||
<select
|
||||
<NofxSelect
|
||||
value={decisionsLimit}
|
||||
onChange={(e) => onDecisionsLimitChange(Number(e.target.value))}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
onChange={(val) => onDecisionsLimitChange(Number(val))}
|
||||
options={[{ value: 5, label: '5' }, { value: 10, label: '10' }, { value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer transition-all bg-black/40 text-nofx-text-main border border-white/10 hover:border-nofx-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Decisions List - Scrollable */}
|
||||
@@ -774,6 +785,11 @@ export function TraderDashboardPage({
|
||||
decisions.map((decision, i) => (
|
||||
<DecisionCard key={i} decision={decision} language={language} onSymbolClick={handleSymbolClick} />
|
||||
))
|
||||
) : decisionsFailed ? (
|
||||
<div className="py-16 text-center text-nofx-text-muted opacity-60">
|
||||
<div className="text-4xl mb-4">⚠️</div>
|
||||
<div className="text-lg font-semibold mb-2">{t('traderDashboard.decisionsFetchFailed', language)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-16 text-center text-nofx-text-muted opacity-60">
|
||||
<div className="text-6xl mb-4 opacity-30 grayscale">🧠</div>
|
||||
@@ -818,6 +834,7 @@ function StatCard({
|
||||
positive,
|
||||
subtitle,
|
||||
icon,
|
||||
loading,
|
||||
}: {
|
||||
title: string
|
||||
value: string
|
||||
@@ -826,6 +843,7 @@ function StatCard({
|
||||
positive?: boolean
|
||||
subtitle?: string
|
||||
icon?: string
|
||||
loading?: boolean
|
||||
}) {
|
||||
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">
|
||||
@@ -835,27 +853,35 @@ function StatCard({
|
||||
<div className="text-xs mb-2 font-mono uppercase tracking-wider text-nofx-text-muted flex items-center gap-2">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 mb-1">
|
||||
<div className="text-2xl font-bold font-mono text-nofx-text-main tracking-tight group-hover:text-white transition-colors">
|
||||
{value}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
{unit && <span className="text-xs font-mono text-nofx-text-muted opacity-60">{unit}</span>}
|
||||
</div>
|
||||
|
||||
{change !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={`text-sm mono font-bold flex items-center gap-1 ${positive ? 'text-nofx-green' : 'text-nofx-red'}`}
|
||||
>
|
||||
<span>{positive ? '▲' : '▼'}</span>
|
||||
<span>{positive ? '+' : ''}{change.toFixed(2)}%</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-baseline gap-1 mb-1">
|
||||
<div className="text-2xl font-bold font-mono text-nofx-text-main tracking-tight group-hover:text-white transition-colors">
|
||||
{value}
|
||||
</div>
|
||||
{unit && <span className="text-xs font-mono text-nofx-text-muted opacity-60">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className="text-xs mt-2 mono text-nofx-text-muted opacity-80">
|
||||
{subtitle}
|
||||
</div>
|
||||
{change !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={`text-sm mono font-bold flex items-center gap-1 ${positive ? 'text-nofx-green' : 'text-nofx-red'}`}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface AIModel {
|
||||
apiKey?: string
|
||||
customApiUrl?: string
|
||||
customModelName?: string
|
||||
walletAddress?: string
|
||||
balanceUsdc?: string
|
||||
}
|
||||
|
||||
export interface TelegramConfig {
|
||||
@@ -39,6 +41,30 @@ export interface Exchange {
|
||||
lighterApiKeyIndex?: number
|
||||
}
|
||||
|
||||
export type ExchangeAccountStatus =
|
||||
| 'ok'
|
||||
| 'disabled'
|
||||
| 'missing_credentials'
|
||||
| 'invalid_credentials'
|
||||
| 'permission_denied'
|
||||
| 'unavailable'
|
||||
|
||||
export interface ExchangeAccountState {
|
||||
exchange_id: string
|
||||
status: ExchangeAccountStatus
|
||||
display_balance?: string
|
||||
asset?: string
|
||||
total_equity?: number
|
||||
available_balance?: number
|
||||
checked_at: string
|
||||
error_code?: string
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export interface ExchangeAccountStateResponse {
|
||||
states: Record<string, ExchangeAccountState>
|
||||
}
|
||||
|
||||
export interface CreateExchangeRequest {
|
||||
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
account_name: string // User-defined account name
|
||||
@@ -110,3 +136,26 @@ export interface UpdateExchangeConfigRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface BeginnerOnboardingResponse {
|
||||
address: string
|
||||
private_key: string
|
||||
chain: string
|
||||
asset: string
|
||||
provider: string
|
||||
default_model: string
|
||||
configured_model_id: string
|
||||
balance_usdc: string
|
||||
env_saved: boolean
|
||||
env_path?: string
|
||||
reused_existing: boolean
|
||||
env_warning?: string
|
||||
}
|
||||
|
||||
export interface CurrentBeginnerWalletResponse {
|
||||
found: boolean
|
||||
address?: string
|
||||
balance_usdc?: string
|
||||
source?: string
|
||||
claw402_status?: string
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface TraderInfo {
|
||||
ai_model: string
|
||||
exchange_id?: string
|
||||
is_running?: boolean
|
||||
startup_warning?: string
|
||||
show_in_competition?: boolean
|
||||
strategy_id?: string
|
||||
strategy_name?: string
|
||||
|
||||
Reference in New Issue
Block a user