mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-19 02:14:35 +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
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.backup
|
||||||
|
.cache/
|
||||||
|
.gh-config/
|
||||||
|
|
||||||
# 环境变量
|
# 环境变量
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ No accounts. No API keys. No prepaid credits. One wallet, every model.
|
|||||||
|
|
||||||
| Feature | Description |
|
| 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 |
|
| **Multi-Exchange** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||||
| **Strategy Studio** | Visual builder — coin sources, indicators, risk controls |
|
| **Strategy Studio** | Visual builder — coin sources, indicators, risk controls |
|
||||||
| **AI Competition** | AIs compete in real-time, leaderboard ranks performance |
|
| **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/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/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/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)
|
### AI Models (x402 Mode — No API Key)
|
||||||
|
|
||||||
@@ -211,6 +212,10 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
|||||||
|
|
||||||
## Setup
|
## 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
|
1. **AI** — Add API keys or configure x402 wallet
|
||||||
2. **Exchange** — Connect exchange API credentials
|
2. **Exchange** — Connect exchange API credentials
|
||||||
3. **Strategy** — Build in Strategy Studio
|
3. **Strategy** — Build in Strategy Studio
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ import (
|
|||||||
"nofx/logger"
|
"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
|
// 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
|
// 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) {
|
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)
|
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
|
// SafeInternalError logs internal error and returns a generic message
|
||||||
func SafeInternalError(c *gin.Context, operation string, err error) {
|
func SafeInternalError(c *gin.Context, operation string, err error) {
|
||||||
logger.Errorf("[Internal Error] %s: %v", operation, err)
|
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
|
// SafeBadRequest returns a safe bad request error
|
||||||
// For validation errors, we can be more specific since they're about user input
|
// For validation errors, we can be more specific since they're about user input
|
||||||
func SafeBadRequest(c *gin.Context, msg string) {
|
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
|
// SafeNotFound returns a generic not found error
|
||||||
func SafeNotFound(c *gin.Context, resource string) {
|
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
|
// SafeUnauthorized returns unauthorized error
|
||||||
func SafeUnauthorized(c *gin.Context) {
|
func SafeUnauthorized(c *gin.Context) {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
writeAPIError(c, http.StatusUnauthorized, "Unauthorized", "", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SafeForbidden returns forbidden error
|
// SafeForbidden returns forbidden error
|
||||||
func SafeForbidden(c *gin.Context, msg string) {
|
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
|
// 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/crypto"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/security"
|
"nofx/security"
|
||||||
|
"nofx/wallet"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -31,6 +32,8 @@ type SafeModelConfig struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
|
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
|
||||||
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
|
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
|
||||||
|
WalletAddress string `json:"walletAddress,omitempty"`
|
||||||
|
BalanceUSDC string `json:"balanceUsdc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateModelConfigRequest struct {
|
type UpdateModelConfigRequest struct {
|
||||||
@@ -75,7 +78,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
|||||||
// Convert to safe response structure, remove sensitive information
|
// Convert to safe response structure, remove sensitive information
|
||||||
safeModels := make([]SafeModelConfig, len(models))
|
safeModels := make([]SafeModelConfig, len(models))
|
||||||
for i, model := range models {
|
for i, model := range models {
|
||||||
safeModels[i] = SafeModelConfig{
|
safeModel := SafeModelConfig{
|
||||||
ID: model.ID,
|
ID: model.ID,
|
||||||
Name: model.Name,
|
Name: model.Name,
|
||||||
Provider: model.Provider,
|
Provider: model.Provider,
|
||||||
@@ -83,6 +86,19 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
|||||||
CustomAPIURL: model.CustomAPIURL,
|
CustomAPIURL: model.CustomAPIURL,
|
||||||
CustomModelName: model.CustomModelName,
|
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)
|
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": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
|
||||||
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
|
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
|
||||||
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
|
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
|
||||||
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.5"},
|
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
|
||||||
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek"},
|
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "glm-5"},
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, supportedModels)
|
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
|
// Remove affected traders from memory BEFORE reloading to pick up new config
|
||||||
for traderID := range tradersToReload {
|
for traderID := range tradersToReload {
|
||||||
logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.exchangeAccountStateCache.Invalidate(userID)
|
||||||
|
|
||||||
logger.Infof("✓ Created exchange account: type=%s, name=%s, id=%s", req.ExchangeType, req.AccountName, id)
|
logger.Infof("✓ Created exchange account: type=%s, name=%s, id=%s", req.ExchangeType, req.AccountName, id)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "Exchange account created",
|
"message": "Exchange account created",
|
||||||
@@ -327,6 +331,8 @@ func (s *Server) handleDeleteExchange(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.exchangeAccountStateCache.Invalidate(userID)
|
||||||
|
|
||||||
logger.Infof("✓ Deleted exchange account: id=%s", exchangeID)
|
logger.Infof("✓ Deleted exchange account: id=%s", exchangeID)
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Exchange account deleted"})
|
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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -8,18 +9,9 @@ import (
|
|||||||
|
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/store"
|
"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"
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AI trader management related structures
|
// AI trader management related structures
|
||||||
@@ -62,22 +54,265 @@ type UpdateTraderRequest struct {
|
|||||||
SystemPromptTemplate string `json:"system_prompt_template"`
|
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
|
// handleCreateTrader Create new AI trader
|
||||||
func (s *Server) handleCreateTrader(c *gin.Context) {
|
func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
var req CreateTraderRequest
|
var req CreateTraderRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
SafeBadRequest(c, "Invalid request parameters")
|
SafeBadRequestWithDetails(c, traderCreationRequestError("提交的信息不完整,或者格式不正确"), "trader.create.invalid_request", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate leverage values
|
// Validate leverage values
|
||||||
if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 {
|
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
|
return
|
||||||
}
|
}
|
||||||
if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,12 +322,61 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
for _, symbol := range symbols {
|
for _, symbol := range symbols {
|
||||||
symbol = strings.TrimSpace(symbol)
|
symbol = strings.TrimSpace(symbol)
|
||||||
if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") {
|
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
|
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)
|
// Generate trader ID (use short UUID prefix for readability)
|
||||||
exchangeIDShort := req.ExchangeID
|
exchangeIDShort := req.ExchangeID
|
||||||
if len(exchangeIDShort) > 8 {
|
if len(exchangeIDShort) > 8 {
|
||||||
@@ -137,7 +421,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
actualBalance := req.InitialBalance // Default to use user input
|
actualBalance := req.InitialBalance // Default to use user input
|
||||||
exchanges, err := s.store.Exchange().List(userID)
|
exchanges, err := s.store.Exchange().List(userID)
|
||||||
if err != nil {
|
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
|
// Find matching exchange configuration
|
||||||
@@ -149,97 +437,32 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if exchangeCfg == nil {
|
if exchangeMsg, exchangeErrorKey, exchangeErrorParams := validateExchangeForTraderCreation(exchangeCfg); exchangeMsg != "" {
|
||||||
logger.Infof("⚠️ Exchange %s configuration not found, using user input for initial balance", req.ExchangeID)
|
SafeBadRequestWithDetails(c, exchangeMsg, exchangeErrorKey, exchangeErrorParams)
|
||||||
} else if !exchangeCfg.Enabled {
|
return
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
|
||||||
if createErr != nil {
|
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 {
|
} else if tempTrader != nil {
|
||||||
// Query actual balance
|
// Query actual balance
|
||||||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
balanceInfo, balanceErr := tempTrader.GetBalance()
|
||||||
if balanceErr != nil {
|
if balanceErr != nil {
|
||||||
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
|
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
|
||||||
} else {
|
} else {
|
||||||
// Extract total equity (account total value = wallet balance + unrealized PnL)
|
if extractedBalance, found := extractExchangeTotalEquity(balanceInfo); found {
|
||||||
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
|
actualBalance = extractedBalance
|
||||||
// Note: Must use total_equity (not availableBalance) for accurate P&L calculation
|
logger.Infof("✓ Queried exchange total equity: %.2f %s (user input: %.2f)",
|
||||||
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
|
actualBalance, accountAssetForExchange(exchangeCfg.ExchangeType), req.InitialBalance)
|
||||||
for _, key := range balanceKeys {
|
} else {
|
||||||
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 {
|
|
||||||
logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
|
logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,20 +498,40 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
err = s.store.Trader().Create(traderRecord)
|
err = s.store.Trader().Create(traderRecord)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("❌ Failed to create trader: %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
|
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
|
||||||
|
|
||||||
// Immediately load new trader into TraderManager
|
// Immediately load new trader into TraderManager
|
||||||
logger.Infof("🔧 DEBUG: Preparing to call LoadUserTraders")
|
logger.Infof("🔧 DEBUG: Preparing to call LoadUserTraders")
|
||||||
|
startupWarning := ""
|
||||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("⚠️ Failed to load user traders into memory: %v", err)
|
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")
|
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)
|
logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
@@ -296,6 +539,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
"trader_name": req.Name,
|
"trader_name": req.Name,
|
||||||
"ai_model": req.AIModelID,
|
"ai_model": req.AIModelID,
|
||||||
"is_running": false,
|
"is_running": false,
|
||||||
|
"startup_warning": startupWarning,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +617,17 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
|||||||
strategyID = existingTrader.StrategyID
|
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
|
// Update trader configuration
|
||||||
traderRecord := &store.Trader{
|
traderRecord := &store.Trader{
|
||||||
ID: traderID,
|
ID: traderID,
|
||||||
@@ -381,7 +636,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
|||||||
AIModelID: req.AIModelID,
|
AIModelID: req.AIModelID,
|
||||||
ExchangeID: req.ExchangeID,
|
ExchangeID: req.ExchangeID,
|
||||||
StrategyID: strategyID, // Associated strategy ID
|
StrategyID: strategyID, // Associated strategy ID
|
||||||
InitialBalance: req.InitialBalance,
|
InitialBalance: initialBalance,
|
||||||
BTCETHLeverage: btcEthLeverage,
|
BTCETHLeverage: btcEthLeverage,
|
||||||
AltcoinLeverage: altcoinLeverage,
|
AltcoinLeverage: altcoinLeverage,
|
||||||
TradingSymbols: req.TradingSymbols,
|
TradingSymbols: req.TradingSymbols,
|
||||||
@@ -413,6 +668,14 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
|||||||
return
|
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)
|
// Remove old trader from memory first (this also stops if running)
|
||||||
s.traderManager.RemoveTrader(traderID)
|
s.traderManager.RemoveTrader(traderID)
|
||||||
|
|
||||||
@@ -478,11 +741,15 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
|||||||
traderID := c.Param("id")
|
traderID := c.Param("id")
|
||||||
|
|
||||||
// Verify trader belongs to current user
|
// Verify trader belongs to current user
|
||||||
_, err := s.store.Trader().GetFullConfig(userID, traderID)
|
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
|
||||||
return
|
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
|
// Check if trader exists in memory and if it's running
|
||||||
existingTrader, _ := s.traderManager.GetTrader(traderID)
|
existingTrader, _ := s.traderManager.GetTrader(traderID)
|
||||||
@@ -501,45 +768,49 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
|||||||
logger.Infof("🔄 Loading trader %s from database...", traderID)
|
logger.Infof("🔄 Loading trader %s from database...", traderID)
|
||||||
if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil {
|
if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil {
|
||||||
logger.Infof("❌ Failed to load user traders: %v", loadErr)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check detailed reason
|
|
||||||
fullCfg, _ := s.store.Trader().GetFullConfig(userID, traderID)
|
|
||||||
if fullCfg != nil && fullCfg.Trader != nil {
|
if fullCfg != nil && fullCfg.Trader != nil {
|
||||||
// Check strategy
|
// Check strategy
|
||||||
if fullCfg.Strategy == nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
// Check AI model
|
// Check AI model
|
||||||
if fullCfg.AIModel == nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if !fullCfg.AIModel.Enabled {
|
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
|
return
|
||||||
}
|
}
|
||||||
// Check exchange
|
// Check exchange
|
||||||
if fullCfg.Exchange == nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if !fullCfg.Exchange.Enabled {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Check if there's a specific load error
|
// Check if there's a specific load error
|
||||||
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
|
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
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,73 +58,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create temporary trader to query balance
|
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
||||||
SafeInternalError(c, "Failed to connect to exchange", 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)
|
// Extract total equity (for P&L calculation, we need total account value, not available balance)
|
||||||
var actualBalance float64
|
actualBalance, found := extractExchangeTotalEquity(balanceInfo)
|
||||||
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
|
if !found {
|
||||||
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 {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.exchangeAccountStateCache.Invalidate(userID)
|
||||||
|
|
||||||
oldBalance := traderConfig.InitialBalance
|
oldBalance := traderConfig.InitialBalance
|
||||||
|
|
||||||
// Smart balance change detection
|
// Smart balance change detection
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleLogout Add current token to blacklist
|
// handleLogout Add current token to blacklist
|
||||||
@@ -59,6 +61,7 @@ func (s *Server) handleRegister(c *gin.Context) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
Password string `json:"password" binding:"required,min=6"`
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
|
Lang string `json:"lang"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -66,6 +69,11 @@ func (s *Server) handleRegister(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lang := req.Lang
|
||||||
|
if lang != "zh" && lang != "id" {
|
||||||
|
lang = "en"
|
||||||
|
}
|
||||||
|
|
||||||
// Check if email already exists
|
// Check if email already exists
|
||||||
_, err = s.store.User().GetByEmail(req.Email)
|
_, err = s.store.User().GetByEmail(req.Email)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -94,6 +102,10 @@ func (s *Server) handleRegister(c *gin.Context) {
|
|||||||
return
|
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
|
// Generate JWT token
|
||||||
token, err := auth.GenerateJWT(user.ID, user.Email)
|
token, err := auth.GenerateJWT(user.ID, user.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -102,7 +114,7 @@ func (s *Server) handleRegister(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize default model and exchange configs for user
|
// Initialize default model and exchange configs for user
|
||||||
err = s.initUserDefaultConfigs(user.ID)
|
err = s.initUserDefaultConfigs(user.ID, lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("Failed to initialize user default configs: %v", err)
|
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"})
|
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
|
// handleResetAccount clears user authentication data so the system returns to
|
||||||
func (s *Server) initUserDefaultConfigs(userID string) error {
|
// uninitialized state for re-registration. Wallet keys (ai_models) are preserved
|
||||||
// Commented out auto-creation of default configs, let users add manually
|
// so funds are not lost — they will be adopted by the new account during onboarding.
|
||||||
// This way new users won't have config items automatically after registration
|
func (s *Server) handleResetAccount(c *gin.Context) {
|
||||||
logger.Infof("User %s registration completed, waiting for manual AI model and exchange configuration", userID)
|
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
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Server struct {
|
|||||||
traderManager *manager.TraderManager
|
traderManager *manager.TraderManager
|
||||||
store *store.Store
|
store *store.Store
|
||||||
cryptoHandler *CryptoHandler
|
cryptoHandler *CryptoHandler
|
||||||
|
exchangeAccountStateCache *ExchangeAccountStateCache
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
port int
|
port int
|
||||||
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
|
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
|
||||||
@@ -45,6 +46,7 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
|
|||||||
traderManager: traderManager,
|
traderManager: traderManager,
|
||||||
store: st,
|
store: st,
|
||||||
cryptoHandler: cryptoHandler,
|
cryptoHandler: cryptoHandler,
|
||||||
|
exchangeAccountStateCache: NewExchangeAccountStateCache(),
|
||||||
port: port,
|
port: port,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,17 +112,21 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
// Public strategy market (no authentication required)
|
// Public strategy market (no authentication required)
|
||||||
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
|
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
|
||||||
|
s.route(api, "POST", "/strategies/estimate-tokens", "Estimate token usage for a strategy config", s.handleEstimateTokens)
|
||||||
|
|
||||||
// Authentication related routes (no authentication required)
|
// Authentication related routes (no authentication required)
|
||||||
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
|
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
|
||||||
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
|
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-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
|
// Routes requiring authentication
|
||||||
protected := api.Group("/", s.authMiddleware())
|
protected := api.Group("/", s.authMiddleware())
|
||||||
{
|
{
|
||||||
// Logout (add to blacklist)
|
// Logout (add to blacklist)
|
||||||
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
|
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
|
// User account management
|
||||||
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
|
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>}]
|
`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.`,
|
CRITICAL: Always use the "id" field for exchange_id. Do not use "exchange_type" as an id.`,
|
||||||
s.handleGetExchangeConfigs)
|
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",
|
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>"}
|
`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)
|
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
|
return warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation)
|
||||||
|
func (s *Server) handleEstimateTokens(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
estimate := req.Config.EstimateTokens()
|
||||||
|
c.JSON(http.StatusOK, estimate)
|
||||||
|
}
|
||||||
|
|
||||||
// handlePublicStrategies Get public strategies for strategy market (no auth required)
|
// handlePublicStrategies Get public strategies for strategy market (no auth required)
|
||||||
func (s *Server) handlePublicStrategies(c *gin.Context) {
|
func (s *Server) handlePublicStrategies(c *gin.Context) {
|
||||||
strategies, err := s.store.Strategy().ListPublic()
|
strategies, err := s.store.Strategy().ListPublic()
|
||||||
@@ -289,6 +303,25 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token overflow check — block save if all models exceed context limits
|
||||||
|
if mergedConfig.StrategyType == "" || mergedConfig.StrategyType == "ai_trading" {
|
||||||
|
estimate := mergedConfig.EstimateTokens()
|
||||||
|
allExceed := true
|
||||||
|
for _, ml := range estimate.ModelLimits {
|
||||||
|
if ml.UsagePct <= 100 {
|
||||||
|
allExceed = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allExceed && len(estimate.ModelLimits) > 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("Estimated %d tokens exceeds all known model context limits. Reduce coins, timeframes, or K-line count.", estimate.Total),
|
||||||
|
"token_estimate": estimate,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate merged configuration and collect warnings
|
// Validate merged configuration and collect warnings
|
||||||
warnings := validateStrategyConfig(&mergedConfig)
|
warnings := validateStrategyConfig(&mergedConfig)
|
||||||
|
|
||||||
@@ -311,7 +344,7 @@ func (s *Server) handleDeleteStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.Strategy().Delete(userID, strategyID); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,4 +697,3 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
|||||||
|
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ services:
|
|||||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||||
- "6060:6060" # pprof profiling
|
- "6060:6060" # pprof profiling
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./.env:/app/.env
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -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 |
|
| **マルチ取引所** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||||
| **ストラテジースタジオ** | ビジュアルビルダー — コインソース、インジケーター、リスク管理 |
|
| **ストラテジースタジオ** | ビジュアルビルダー — コインソース、インジケーター、リスク管理 |
|
||||||
| **AI ディベートアリーナ** | 複数 AI が取引を議論(ブル vs ベア vs アナリスト)、投票、実行 |
|
| **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/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/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/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 キー不要)
|
### 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 |
|
| **멀티 거래소** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||||
| **전략 스튜디오** | 비주얼 빌더 — 코인 소스, 지표, 리스크 관리 |
|
| **전략 스튜디오** | 비주얼 빌더 — 코인 소스, 지표, 리스크 관리 |
|
||||||
| **AI 토론 아레나** | 여러 AI가 거래 토론 (강세 vs 약세 vs 분석가), 투표, 실행 |
|
| **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/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/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/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 키 불필요)
|
### 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 |
|
| **Мульти-биржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||||
| **Студия стратегий** | Визуальный конструктор — источники монет, индикаторы, контроль рисков |
|
| **Студия стратегий** | Визуальный конструктор — источники монет, индикаторы, контроль рисков |
|
||||||
| **AI Арена дебатов** | Несколько AI обсуждают сделки (Бык vs Медведь vs Аналитик), голосуют, исполняют |
|
| **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/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/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/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 ключей)
|
### 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 |
|
| **Мульти-біржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||||
| **Студія стратегій** | Візуальний конструктор — джерела монет, індикатори, контроль ризиків |
|
| **Студія стратегій** | Візуальний конструктор — джерела монет, індикатори, контроль ризиків |
|
||||||
| **AI Арена дебатів** | Кілька AI обговорюють угоди, голосують, виконують |
|
| **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/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/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/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 ключів)
|
### 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ả |
|
| 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 |
|
| **Đ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 |
|
| **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 |
|
| **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/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/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/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)
|
### 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 |
|
| **多交易所** | Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster、Lighter |
|
||||||
| **策略工作室** | 可视化构建器 — 币种来源、指标、风控 |
|
| **策略工作室** | 可视化构建器 — 币种来源、指标、风控 |
|
||||||
| **AI 竞赛** | AI 实时竞争,排行榜排名 |
|
| **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/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/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/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)
|
### 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 钱包
|
1. **AI** — 添加 API Key 或配置 x402 钱包
|
||||||
2. **交易所** — 连接交易所 API 凭证
|
2. **交易所** — 连接交易所 API 凭证
|
||||||
3. **策略** — 在策略工作室构建
|
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"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/provider/hyperliquid"
|
"nofx/provider/hyperliquid"
|
||||||
@@ -185,8 +186,9 @@ type StrategyEngine struct {
|
|||||||
nofxosClient *nofxos.Client
|
nofxosClient *nofxos.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStrategyEngine creates strategy execution engine
|
// NewStrategyEngine creates strategy execution engine.
|
||||||
func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine {
|
// 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
|
// Create NofxOS client with API key from config
|
||||||
apiKey := config.Indicators.NofxOSAPIKey
|
apiKey := config.Indicators.NofxOSAPIKey
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
@@ -194,6 +196,28 @@ func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine {
|
|||||||
}
|
}
|
||||||
client := nofxos.NewClient(nofxos.DefaultBaseURL, apiKey)
|
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{
|
return &StrategyEngine{
|
||||||
config: config,
|
config: config,
|
||||||
nofxosClient: client,
|
nofxosClient: client,
|
||||||
|
|||||||
@@ -51,6 +51,33 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
|
|||||||
engine = NewStrategyEngine(&defaultConfig)
|
engine = NewStrategyEngine(&defaultConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clamp strategy limits to prevent token overflow
|
||||||
|
engineConfig := engine.GetConfig()
|
||||||
|
engineConfig.ClampLimits()
|
||||||
|
|
||||||
|
// Token estimation check — 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
|
// 1. Fetch market data using strategy config
|
||||||
if len(ctx.MarketDataMap) == 0 {
|
if len(ctx.MarketDataMap) == 0 {
|
||||||
if err := fetchMarketDataWithStrategy(ctx, engine); err != nil {
|
if err := fetchMarketDataWithStrategy(ctx, engine); err != nil {
|
||||||
|
|||||||
6
main.go
6
main.go
@@ -118,8 +118,12 @@ func main() {
|
|||||||
if t.IsRunning {
|
if t.IsRunning {
|
||||||
status = "✅ Running"
|
status = "✅ Running"
|
||||||
}
|
}
|
||||||
|
idShort := t.ID
|
||||||
|
if len(idShort) > 8 {
|
||||||
|
idShort = idShort[:8]
|
||||||
|
}
|
||||||
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
|
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
|
||||||
t.Name, t.ID[:8], status, t.AIModelID, t.ExchangeID)
|
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()
|
ts := time.Now().UnixMilli()
|
||||||
// Use "To" side to search backward from current time (get historical klines)
|
// 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)
|
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
|
||||||
if err != nil {
|
if err != nil || len(coinankKlines) == 0 {
|
||||||
// If exchange-specific data fails, fallback to Binance
|
// If exchange-specific data fails or returns empty, fallback to Binance
|
||||||
if coinankExchange != coinank_enum.Binance {
|
if coinankExchange != coinank_enum.Binance {
|
||||||
|
if err != nil {
|
||||||
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
|
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)
|
coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("CoinAnk API error (fallback): %w", err)
|
return nil, fmt.Errorf("CoinAnk API error (fallback): %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else if err != nil {
|
||||||
return nil, fmt.Errorf("CoinAnk API error: %w", err)
|
return nil, fmt.Errorf("CoinAnk API error: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,6 +392,9 @@ func (client *Client) String() string {
|
|||||||
client.Provider, client.Model)
|
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.)
|
// IsRetryableError determines if error is retryable (network errors, timeouts, etc.)
|
||||||
func (client *Client) IsRetryableError(err error) bool {
|
func (client *Client) IsRetryableError(err error) bool {
|
||||||
errStr := err.Error()
|
errStr := err.Error()
|
||||||
@@ -760,10 +763,21 @@ func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string
|
|||||||
} `json:"delta"`
|
} `json:"delta"`
|
||||||
FinishReason *string `json:"finish_reason"`
|
FinishReason *string `json:"finish_reason"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
|
Usage *struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
} `json:"usage,omitempty"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||||
continue // skip malformed chunks
|
continue // skip malformed chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if chunk.Usage != nil && chunk.Usage.TotalTokens > 0 {
|
||||||
|
fmt.Printf("📊 [TokenUsage] prompt=%d, completion=%d, total=%d\n",
|
||||||
|
chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.TotalTokens)
|
||||||
|
}
|
||||||
|
|
||||||
if len(chunk.Choices) == 0 {
|
if len(chunk.Choices) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultClaw402URL = "https://claw402.ai"
|
DefaultClaw402URL = "https://claw402.ai"
|
||||||
DefaultClaw402Model = "deepseek"
|
DefaultClaw402Model = "glm-5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// claw402ModelEndpoints maps user-friendly model names to claw402 API paths.
|
// 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",
|
"gemini-3.1-pro": "/api/v1/ai/gemini/chat/3.1-pro",
|
||||||
// Kimi
|
// Kimi
|
||||||
"kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5",
|
"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() {
|
func init() {
|
||||||
|
|||||||
@@ -81,6 +81,13 @@ func X402DecodeHeader(b64 string) ([]byte, error) {
|
|||||||
return decoded, nil
|
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
|
// SignBasePaymentHeader decodes a base64 x402 header, parses it, and signs with
|
||||||
// EIP-712 (USDC TransferWithAuthorization).
|
// EIP-712 (USDC TransferWithAuthorization).
|
||||||
func SignBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) {
|
func SignBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
|
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
|
||||||
DefaultMiniMaxModel = "MiniMax-M2.5"
|
DefaultMiniMaxModel = "MiniMax-M2.7"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
@@ -25,5 +25,5 @@ const (
|
|||||||
|
|
||||||
// Default MiniMax configuration (used by WithMiniMaxConfig convenience option)
|
// Default MiniMax configuration (used by WithMiniMaxConfig convenience option)
|
||||||
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
|
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
|
AuthKey string
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
claw402 *Claw402DataClient // If set, routes requests through claw402
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
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
|
// SetConfig updates client configuration
|
||||||
func (c *Client) SetConfig(baseURL, authKey string) {
|
func (c *Client) SetConfig(baseURL, authKey string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -85,14 +93,21 @@ func (c *Client) GetAuthKey() string {
|
|||||||
return c.AuthKey
|
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) {
|
func (c *Client) doRequest(endpoint string) ([]byte, error) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
|
claw402Client := c.claw402
|
||||||
baseURL := c.BaseURL
|
baseURL := c.BaseURL
|
||||||
authKey := c.AuthKey
|
authKey := c.AuthKey
|
||||||
timeout := c.Timeout
|
timeout := c.Timeout
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
// Route through claw402 if configured
|
||||||
|
if claw402Client != nil {
|
||||||
|
return claw402Client.DoRequest(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
url := baseURL + endpoint
|
url := baseURL + endpoint
|
||||||
if !strings.Contains(url, "auth=") {
|
if !strings.Contains(url, "auth=") {
|
||||||
if strings.Contains(url, "?") {
|
if strings.Contains(url, "?") {
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ func (s *AIModelStore) initDefaultData() error {
|
|||||||
return nil
|
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
|
// List retrieves user's AI model list
|
||||||
func (s *AIModelStore) List(userID string) ([]*AIModel, error) {
|
func (s *AIModelStore) List(userID string) ([]*AIModel, error) {
|
||||||
var models []*AIModel
|
var models []*AIModel
|
||||||
|
|||||||
@@ -3,11 +3,63 @@ package store
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Hard limits to prevent token explosion in AI requests
|
||||||
|
const (
|
||||||
|
MaxCandidateCoins = 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
|
// StrategyStore strategy storage
|
||||||
type StrategyStore struct {
|
type StrategyStore struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
@@ -260,20 +312,20 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
|||||||
CoinSource: CoinSourceConfig{
|
CoinSource: CoinSourceConfig{
|
||||||
SourceType: "ai500",
|
SourceType: "ai500",
|
||||||
UseAI500: true,
|
UseAI500: true,
|
||||||
AI500Limit: 10,
|
AI500Limit: 3,
|
||||||
UseOITop: false,
|
UseOITop: false,
|
||||||
OITopLimit: 10,
|
OITopLimit: 3,
|
||||||
UseOILow: false,
|
UseOILow: false,
|
||||||
OILowLimit: 10,
|
OILowLimit: 3,
|
||||||
},
|
},
|
||||||
Indicators: IndicatorConfig{
|
Indicators: IndicatorConfig{
|
||||||
Klines: KlineConfig{
|
Klines: KlineConfig{
|
||||||
PrimaryTimeframe: "5m",
|
PrimaryTimeframe: "5m",
|
||||||
PrimaryCount: 30,
|
PrimaryCount: 20,
|
||||||
LongerTimeframe: "4h",
|
LongerTimeframe: "4h",
|
||||||
LongerCount: 10,
|
LongerCount: 10,
|
||||||
EnableMultiTimeframe: true,
|
EnableMultiTimeframe: true,
|
||||||
SelectedTimeframes: []string{"5m", "15m", "1h", "4h"},
|
SelectedTimeframes: []string{"5m", "15m", "1h"},
|
||||||
},
|
},
|
||||||
EnableRawKlines: true, // Required - raw OHLCV data for AI analysis
|
EnableRawKlines: true, // Required - raw OHLCV data for AI analysis
|
||||||
EnableEMA: false,
|
EnableEMA: false,
|
||||||
@@ -388,9 +440,19 @@ func (s *StrategyStore) Update(strategy *Strategy) error {
|
|||||||
func (s *StrategyStore) Delete(userID, id string) error {
|
func (s *StrategyStore) Delete(userID, id string) error {
|
||||||
// do not allow deleting system default strategy
|
// do not allow deleting system default strategy
|
||||||
var st Strategy
|
var st Strategy
|
||||||
if err := s.db.Where("id = ?", id).First(&st).Error; err == nil && st.IsDefault {
|
if err := s.db.Where("id = ?", id).First(&st).Error; err == nil {
|
||||||
|
if st.IsDefault {
|
||||||
return fmt.Errorf("cannot delete system default strategy")
|
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
|
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)
|
s.Config = string(data)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Token Estimation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// TokenEstimate holds the result of token estimation
|
||||||
|
type TokenEstimate struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Breakdown TokenBreakdown `json:"breakdown"`
|
||||||
|
ModelLimits []ModelLimit `json:"model_limits"`
|
||||||
|
Suggestions []string `json:"suggestions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenBreakdown shows estimated tokens per component
|
||||||
|
type TokenBreakdown struct {
|
||||||
|
SystemPrompt int `json:"system_prompt"`
|
||||||
|
MarketData int `json:"market_data"`
|
||||||
|
RankingData int `json:"ranking_data"`
|
||||||
|
QuantData int `json:"quant_data"`
|
||||||
|
FixedOverhead int `json:"fixed_overhead"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelLimit shows token usage against a specific model's context limit
|
||||||
|
type ModelLimit struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ContextLimit int `json:"context_limit"`
|
||||||
|
UsagePct int `json:"usage_pct"`
|
||||||
|
Level string `json:"level"` // "ok" | "warning" | "danger"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
}).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
|
// EnsureAdmin ensures admin user exists
|
||||||
func (s *UserStore) EnsureAdmin() error {
|
func (s *UserStore) EnsureAdmin() error {
|
||||||
var count int64
|
var count int64
|
||||||
|
|||||||
@@ -333,7 +333,13 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
|||||||
if config.StrategyConfig == nil {
|
if config.StrategyConfig == nil {
|
||||||
return nil, fmt.Errorf("[%s] strategy not configured", config.Name)
|
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)
|
logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name)
|
||||||
|
|
||||||
return &AutoTrader{
|
return &AutoTrader{
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ func (at *AutoTrader) checkPositionDrawdown() {
|
|||||||
quantity = -quantity // Short position quantity is negative, convert to positive
|
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
|
// Calculate current P&L percentage
|
||||||
leverage := 10 // Default value
|
leverage := 10 // Default value
|
||||||
if lev, ok := pos["leverage"].(float64); ok {
|
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": {
|
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1021,9 +1021,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1084,9 +1084,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1105,9 +1105,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
|
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1728,9 +1728,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
|
||||||
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
|
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1742,9 +1742,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
|
||||||
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
|
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1756,9 +1756,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
|
||||||
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
|
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1770,9 +1770,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
|
||||||
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
|
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1784,9 +1784,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
|
||||||
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
|
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1798,9 +1798,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
|
||||||
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
|
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1812,9 +1812,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
|
||||||
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
|
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1826,9 +1826,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
|
||||||
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
|
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1840,9 +1840,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
|
||||||
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
|
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1854,9 +1854,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
|
||||||
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
|
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1868,9 +1868,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
|
||||||
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
|
"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": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -1882,9 +1896,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
|
||||||
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
|
"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": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1896,9 +1924,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
|
||||||
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
|
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1910,9 +1938,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
|
||||||
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
|
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1924,9 +1952,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
|
||||||
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
|
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1938,9 +1966,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
|
||||||
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
|
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1952,9 +1980,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
|
||||||
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
|
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1965,10 +1993,24 @@
|
|||||||
"linux"
|
"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": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
|
||||||
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
|
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1980,9 +2022,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
|
||||||
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
|
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1994,9 +2036,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
|
||||||
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
|
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -2008,9 +2050,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
|
||||||
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
|
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2022,9 +2064,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
|
||||||
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
|
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2694,9 +2736,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3029,13 +3071,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.6",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.11",
|
||||||
"form-data": "^4.0.4",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3070,9 +3112,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4285,9 +4327,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
|
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4296,9 +4338,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-react/node_modules/minimatch": {
|
"node_modules/eslint-plugin-react/node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4349,9 +4391,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/brace-expansion": {
|
"node_modules/eslint/node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4383,9 +4425,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/minimatch": {
|
"node_modules/eslint/node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4640,9 +4682,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.3",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -5933,9 +5975,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
@@ -6095,13 +6137,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@@ -6497,9 +6539,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -6968,9 +7010,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.10.1",
|
"version": "7.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
|
||||||
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==",
|
"integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -6990,12 +7032,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.10.1",
|
"version": "7.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz",
|
||||||
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==",
|
"integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.10.1"
|
"react-router": "7.13.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -7247,9 +7289,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.53.3",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
|
||||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -7263,28 +7305,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.53.3",
|
"@rollup/rollup-android-arm-eabi": "4.60.0",
|
||||||
"@rollup/rollup-android-arm64": "4.53.3",
|
"@rollup/rollup-android-arm64": "4.60.0",
|
||||||
"@rollup/rollup-darwin-arm64": "4.53.3",
|
"@rollup/rollup-darwin-arm64": "4.60.0",
|
||||||
"@rollup/rollup-darwin-x64": "4.53.3",
|
"@rollup/rollup-darwin-x64": "4.60.0",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.53.3",
|
"@rollup/rollup-freebsd-arm64": "4.60.0",
|
||||||
"@rollup/rollup-freebsd-x64": "4.53.3",
|
"@rollup/rollup-freebsd-x64": "4.60.0",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
|
"@rollup/rollup-linux-arm-musleabihf": "4.60.0",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
|
"@rollup/rollup-linux-arm64-gnu": "4.60.0",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.53.3",
|
"@rollup/rollup-linux-arm64-musl": "4.60.0",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
|
"@rollup/rollup-linux-loong64-gnu": "4.60.0",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
|
"@rollup/rollup-linux-loong64-musl": "4.60.0",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
|
"@rollup/rollup-linux-ppc64-gnu": "4.60.0",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
|
"@rollup/rollup-linux-ppc64-musl": "4.60.0",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
|
"@rollup/rollup-linux-riscv64-gnu": "4.60.0",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.53.3",
|
"@rollup/rollup-linux-riscv64-musl": "4.60.0",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.53.3",
|
"@rollup/rollup-linux-s390x-gnu": "4.60.0",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.53.3",
|
"@rollup/rollup-linux-x64-gnu": "4.60.0",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
|
"@rollup/rollup-linux-x64-musl": "4.60.0",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
|
"@rollup/rollup-openbsd-x64": "4.60.0",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.53.3",
|
"@rollup/rollup-openharmony-arm64": "4.60.0",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.53.3",
|
"@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"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -8094,9 +8139,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -8541,9 +8586,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/picomatch": {
|
"node_modules/vite/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -8632,9 +8677,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest/node_modules/picomatch": {
|
"node_modules/vitest/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -8932,9 +8977,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.2",
|
"version": "2.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
155
web/src/App.tsx
155
web/src/App.tsx
@@ -15,6 +15,7 @@ import { FAQPage } from './pages/FAQPage'
|
|||||||
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
||||||
import { StrategyMarketPage } from './pages/StrategyMarketPage'
|
import { StrategyMarketPage } from './pages/StrategyMarketPage'
|
||||||
import { DataPage } from './pages/DataPage'
|
import { DataPage } from './pages/DataPage'
|
||||||
|
import { BeginnerOnboardingPage } from './pages/BeginnerOnboardingPage'
|
||||||
import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay'
|
import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay'
|
||||||
import HeaderBar from './components/common/HeaderBar'
|
import HeaderBar from './components/common/HeaderBar'
|
||||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||||
@@ -22,6 +23,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext'
|
|||||||
import { ConfirmDialogProvider } from './components/common/ConfirmDialog'
|
import { ConfirmDialogProvider } from './components/common/ConfirmDialog'
|
||||||
import { t } from './i18n/translations'
|
import { t } from './i18n/translations'
|
||||||
import { useSystemConfig } from './hooks/useSystemConfig'
|
import { useSystemConfig } from './hooks/useSystemConfig'
|
||||||
|
import { getUserMode, hasCompletedBeginnerOnboarding } from './lib/onboarding'
|
||||||
|
|
||||||
import { OFFICIAL_LINKS } from './constants/branding'
|
import { OFFICIAL_LINKS } from './constants/branding'
|
||||||
import type {
|
import type {
|
||||||
@@ -53,16 +55,12 @@ function App() {
|
|||||||
const { config: systemConfig, loading: configLoading } = useSystemConfig()
|
const { config: systemConfig, loading: configLoading } = useSystemConfig()
|
||||||
const [route, setRoute] = useState(window.location.pathname)
|
const [route, setRoute] = useState(window.location.pathname)
|
||||||
|
|
||||||
// Debug log
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('[App] Mounted. Route:', window.location.pathname);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 从URL路径读取初始页面状态(支持刷新保持页面)
|
// 从URL路径读取初始页面状态(支持刷新保持页面)
|
||||||
const getInitialPage = (): Page => {
|
const getInitialPage = (): Page => {
|
||||||
const path = window.location.pathname
|
const path = window.location.pathname
|
||||||
const hash = window.location.hash.slice(1) // 去掉 #
|
const hash = window.location.hash.slice(1) // 去掉 #
|
||||||
|
|
||||||
|
if (path === '/welcome') return 'traders'
|
||||||
if (path === '/traders' || hash === 'traders') return 'traders'
|
if (path === '/traders' || hash === 'traders') return 'traders'
|
||||||
if (path === '/strategy' || hash === 'strategy') return 'strategy'
|
if (path === '/strategy' || hash === 'strategy') return 'strategy'
|
||||||
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
|
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
|
||||||
@@ -132,6 +130,20 @@ function App() {
|
|||||||
}
|
}
|
||||||
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
|
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
|
||||||
const [decisionsLimit, setDecisionsLimit] = useState<number>(5)
|
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变化,同步页面状态
|
// 监听URL变化,同步页面状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,7 +153,9 @@ function App() {
|
|||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const traderParam = params.get('trader')
|
const traderParam = params.get('trader')
|
||||||
|
|
||||||
if (path === '/traders' || hash === 'traders') {
|
if (path === '/welcome') {
|
||||||
|
setCurrentPage('traders')
|
||||||
|
} else if (path === '/traders' || hash === 'traders') {
|
||||||
setCurrentPage('traders')
|
setCurrentPage('traders')
|
||||||
} else if (path === '/strategy' || hash === 'strategy') {
|
} else if (path === '/strategy' || hash === 'strategy') {
|
||||||
setCurrentPage('strategy')
|
setCurrentPage('strategy')
|
||||||
@@ -156,9 +170,7 @@ function App() {
|
|||||||
) {
|
) {
|
||||||
setCurrentPage('trader')
|
setCurrentPage('trader')
|
||||||
// 如果 URL 中有 trader 参数(slug 格式),更新选中的 trader
|
// 如果 URL 中有 trader 参数(slug 格式),更新选中的 trader
|
||||||
if (traderParam) {
|
setSelectedTraderSlug(traderParam || undefined)
|
||||||
setSelectedTraderSlug(traderParam)
|
|
||||||
}
|
|
||||||
} else if (
|
} else if (
|
||||||
path === '/competition' ||
|
path === '/competition' ||
|
||||||
hash === 'competition' ||
|
hash === 'competition' ||
|
||||||
@@ -186,7 +198,7 @@ function App() {
|
|||||||
// 获取trader列表(仅在用户登录时)
|
// 获取trader列表(仅在用户登录时)
|
||||||
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
|
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
|
||||||
user && token ? 'traders' : null,
|
user && token ? 'traders' : null,
|
||||||
api.getTraders,
|
() => api.getTraders(currentPage === 'trader'),
|
||||||
{
|
{
|
||||||
refreshInterval: 10000,
|
refreshInterval: 10000,
|
||||||
shouldRetryOnError: false, // 避免在后端未运行时无限重试
|
shouldRetryOnError: false, // 避免在后端未运行时无限重试
|
||||||
@@ -205,19 +217,22 @@ function App() {
|
|||||||
|
|
||||||
// 当获取到traders后,根据 URL 中的 trader slug 设置选中的 trader,或默认选中第一个
|
// 当获取到traders后,根据 URL 中的 trader slug 设置选中的 trader,或默认选中第一个
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (traders && traders.length > 0 && !selectedTraderId) {
|
if (!traders || traders.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedTraderSlug) {
|
if (selectedTraderSlug) {
|
||||||
// 通过 slug 找到对应的 trader
|
// 通过 slug 找到对应的 trader
|
||||||
const trader = findTraderBySlug(selectedTraderSlug, traders)
|
const trader = findTraderBySlug(selectedTraderSlug, traders)
|
||||||
if (trader) {
|
const nextTraderId = trader?.trader_id || traders[0].trader_id
|
||||||
setSelectedTraderId(trader.trader_id)
|
if (nextTraderId !== selectedTraderId) {
|
||||||
} else {
|
setSelectedTraderId(nextTraderId)
|
||||||
// 如果找不到,选中第一个
|
|
||||||
setSelectedTraderId(traders[0].trader_id)
|
|
||||||
}
|
}
|
||||||
} else {
|
return
|
||||||
setSelectedTraderId(traders[0].trader_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!selectedTraderId) {
|
||||||
|
setSelectedTraderId(traders[0].trader_id)
|
||||||
}
|
}
|
||||||
}, [traders, selectedTraderId, selectedTraderSlug])
|
}, [traders, selectedTraderId, selectedTraderSlug])
|
||||||
|
|
||||||
@@ -226,7 +241,7 @@ function App() {
|
|||||||
currentPage === 'trader' && selectedTraderId
|
currentPage === 'trader' && selectedTraderId
|
||||||
? `status-${selectedTraderId}`
|
? `status-${selectedTraderId}`
|
||||||
: null,
|
: null,
|
||||||
() => api.getStatus(selectedTraderId),
|
() => api.getStatus(selectedTraderId, true),
|
||||||
{
|
{
|
||||||
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
|
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
|
||||||
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
|
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
|
||||||
@@ -238,11 +253,16 @@ function App() {
|
|||||||
currentPage === 'trader' && selectedTraderId
|
currentPage === 'trader' && selectedTraderId
|
||||||
? `account-${selectedTraderId}`
|
? `account-${selectedTraderId}`
|
||||||
: null,
|
: null,
|
||||||
() => api.getAccount(selectedTraderId),
|
() => api.getAccount(selectedTraderId, true),
|
||||||
{
|
{
|
||||||
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
|
refreshInterval: accountPollOff ? 0 : 15000,
|
||||||
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
|
revalidateOnFocus: false,
|
||||||
dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求
|
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
|
currentPage === 'trader' && selectedTraderId
|
||||||
? `positions-${selectedTraderId}`
|
? `positions-${selectedTraderId}`
|
||||||
: null,
|
: null,
|
||||||
() => api.getPositions(selectedTraderId),
|
() => api.getPositions(selectedTraderId, true),
|
||||||
{
|
{
|
||||||
refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存)
|
refreshInterval: positionsPollOff ? 0 : 15000,
|
||||||
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
|
revalidateOnFocus: false,
|
||||||
dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求
|
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
|
currentPage === 'trader' && selectedTraderId
|
||||||
? `decisions/latest-${selectedTraderId}-${decisionsLimit}`
|
? `decisions/latest-${selectedTraderId}-${decisionsLimit}`
|
||||||
: null,
|
: null,
|
||||||
() => api.getLatestDecisions(selectedTraderId, decisionsLimit),
|
() => api.getLatestDecisions(selectedTraderId, decisionsLimit, true),
|
||||||
{
|
{
|
||||||
refreshInterval: 30000, // 30秒刷新(决策更新频率较低)
|
refreshInterval: decisionsPollOff ? 0 : 30000,
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
dedupingInterval: 20000,
|
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
|
currentPage === 'trader' && selectedTraderId
|
||||||
? `statistics-${selectedTraderId}`
|
? `statistics-${selectedTraderId}`
|
||||||
: null,
|
: null,
|
||||||
() => api.getStatistics(selectedTraderId),
|
() => api.getStatistics(selectedTraderId, true),
|
||||||
{
|
{
|
||||||
refreshInterval: 30000, // 30秒刷新(统计数据更新频率较低)
|
refreshInterval: 30000, // 30秒刷新(统计数据更新频率较低)
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
@@ -291,6 +321,10 @@ function App() {
|
|||||||
|
|
||||||
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId)
|
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId)
|
||||||
|
|
||||||
|
const effectiveAccount = account
|
||||||
|
const effectivePositions = positions
|
||||||
|
const effectiveDecisions = decisions
|
||||||
|
|
||||||
// Handle routing
|
// Handle routing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
@@ -302,7 +336,9 @@ function App() {
|
|||||||
|
|
||||||
// Set current page based on route for consistent navigation state
|
// Set current page based on route for consistent navigation state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (route === '/competition') {
|
if (route === '/welcome') {
|
||||||
|
setCurrentPage('traders')
|
||||||
|
} else if (route === '/competition') {
|
||||||
setCurrentPage('competition')
|
setCurrentPage('competition')
|
||||||
} else if (route === '/traders') {
|
} else if (route === '/traders') {
|
||||||
setCurrentPage('traders')
|
setCurrentPage('traders')
|
||||||
@@ -311,6 +347,9 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [route])
|
}, [route])
|
||||||
|
|
||||||
|
const showBeginnerOnboarding =
|
||||||
|
route === '/welcome' && (!!user || hasPersistedAuth) && getUserMode() === 'beginner' && !hasCompletedBeginnerOnboarding()
|
||||||
|
|
||||||
// Show loading spinner while checking auth or config
|
// Show loading spinner while checking auth or config
|
||||||
if (isLoading || configLoading) {
|
if (isLoading || configLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -347,6 +386,16 @@ function App() {
|
|||||||
}
|
}
|
||||||
return <SetupPage />
|
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') {
|
if (route === '/faq') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -376,7 +425,7 @@ function App() {
|
|||||||
return <ResetPasswordPage />
|
return <ResetPasswordPage />
|
||||||
}
|
}
|
||||||
if (route === '/settings') {
|
if (route === '/settings') {
|
||||||
if (!user || !token) {
|
if ((!user || !token) && !hasPersistedAuth) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -398,19 +447,7 @@ function App() {
|
|||||||
// Data page - publicly accessible with embedded dashboard
|
// Data page - publicly accessible with embedded dashboard
|
||||||
if (route === '/data') {
|
if (route === '/data') {
|
||||||
const dataPageNavigate = (page: Page) => {
|
const dataPageNavigate = (page: Page) => {
|
||||||
const pathMap: Record<string, string> = {
|
navigateToPage(page)
|
||||||
'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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -484,7 +521,18 @@ function App() {
|
|||||||
<AITradersPage
|
<AITradersPage
|
||||||
onTraderSelect={(traderId) => {
|
onTraderSelect={(traderId) => {
|
||||||
setSelectedTraderId(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')
|
setRoute('/dashboard')
|
||||||
setCurrentPage('trader')
|
setCurrentPage('trader')
|
||||||
}}
|
}}
|
||||||
@@ -495,9 +543,12 @@ function App() {
|
|||||||
<TraderDashboardPage
|
<TraderDashboardPage
|
||||||
selectedTrader={selectedTrader}
|
selectedTrader={selectedTrader}
|
||||||
status={status}
|
status={status}
|
||||||
account={account}
|
account={effectiveAccount}
|
||||||
positions={positions}
|
accountFailed={accountPollOff}
|
||||||
decisions={decisions}
|
positions={effectivePositions}
|
||||||
|
positionsFailed={positionsPollOff}
|
||||||
|
decisions={effectiveDecisions}
|
||||||
|
decisionsFailed={decisionsPollOff}
|
||||||
decisionsLimit={decisionsLimit}
|
decisionsLimit={decisionsLimit}
|
||||||
onDecisionsLimitChange={setDecisionsLimit}
|
onDecisionsLimitChange={setDecisionsLimit}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
@@ -511,8 +562,10 @@ function App() {
|
|||||||
// 更新 URL 参数(使用 slug: name-id前4位)
|
// 更新 URL 参数(使用 slug: name-id前4位)
|
||||||
const trader = traders?.find(t => t.trader_id === traderId)
|
const trader = traders?.find(t => t.trader_id === traderId)
|
||||||
if (trader) {
|
if (trader) {
|
||||||
|
const slug = getTraderSlug(trader)
|
||||||
|
setSelectedTraderSlug(slug)
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.set('trader', getTraderSlug(trader))
|
url.searchParams.set('trader', slug)
|
||||||
window.history.replaceState({}, '', url.toString())
|
window.history.replaceState({}, '', url.toString())
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -646,6 +699,8 @@ function App() {
|
|||||||
onClose={() => setLoginOverlayOpen(false)}
|
onClose={() => setLoginOverlayOpen(false)}
|
||||||
featureName={loginOverlayFeature}
|
featureName={loginOverlayFeature}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showBeginnerOnboarding && <BeginnerOnboardingPage />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { useAuth } from '../../contexts/AuthContext'
|
|||||||
import { useLanguage } from '../../contexts/LanguageContext'
|
import { useLanguage } from '../../contexts/LanguageContext'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
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() {
|
export function LoginPage() {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
@@ -15,7 +19,16 @@ export function LoginPage() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
if (sessionStorage.getItem('from401') === 'true') {
|
if (sessionStorage.getItem('from401') === 'true') {
|
||||||
const id = toast.warning(t('sessionExpired', language), { duration: Infinity })
|
const id = toast.warning(t('sessionExpired', language), { duration: Infinity })
|
||||||
@@ -24,11 +37,32 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [language])
|
}, [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) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await login(email, password)
|
const result = await login(email, password, mode)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (expiredToastId) toast.dismiss(expiredToastId)
|
if (expiredToastId) toast.dismiss(expiredToastId)
|
||||||
@@ -41,6 +75,8 @@ export function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DeepVoidBackground disableAnimation>
|
<DeepVoidBackground disableAnimation>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
|
||||||
<div className="flex-1 flex items-center justify-center px-4 py-16">
|
<div className="flex-1 flex items-center justify-center px-4 py-16">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
|
|
||||||
@@ -109,6 +145,12 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OnboardingModeSelector
|
||||||
|
language={language}
|
||||||
|
mode={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
<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'}
|
{loading ? t('loggingIn', language) || 'Signing in...' : t('signIn', language) || 'Sign In'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
||||||
|
|
||||||
</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 {
|
try {
|
||||||
const limit = 1500
|
const limit = 1500
|
||||||
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}&exchange=${exchange}`
|
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) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error('Failed to fetch kline data')
|
throw new Error('Failed to fetch kline data')
|
||||||
@@ -243,7 +243,10 @@ export function AdvancedChart({
|
|||||||
try {
|
try {
|
||||||
console.log('[AdvancedChart] Fetching orders for trader:', traderID, 'symbol:', symbol)
|
console.log('[AdvancedChart] Fetching orders for trader:', traderID, 'symbol:', symbol)
|
||||||
// Fetch filled orders, up to 200 for more history
|
// 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)
|
console.log('[AdvancedChart] Orders API response:', result)
|
||||||
|
|
||||||
@@ -326,7 +329,10 @@ export function AdvancedChart({
|
|||||||
const fetchOpenOrders = async (traderID: string, symbol: string): Promise<OpenOrder[]> => {
|
const fetchOpenOrders = async (traderID: string, symbol: string): Promise<OpenOrder[]> => {
|
||||||
try {
|
try {
|
||||||
console.log('[AdvancedChart] Fetching open orders for trader:', traderID, 'symbol:', symbol)
|
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)
|
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
|
// Auto-switch to kline chart when symbol selected externally
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSymbol) {
|
if (selectedSymbol) {
|
||||||
console.log('[ChartTabs] Symbol selected:', selectedSymbol, 'updateKey:', updateKey)
|
|
||||||
setChartSymbol(selectedSymbol)
|
setChartSymbol(selectedSymbol)
|
||||||
setActiveTab('kline')
|
setActiveTab('kline')
|
||||||
}
|
}
|
||||||
@@ -143,8 +142,6 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[ChartTabs] rendering, activeTab:', activeTab)
|
|
||||||
|
|
||||||
return (
|
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]'
|
<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 limit = 2000 // Fetch recent 2000 candles (more historical data)
|
||||||
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}&exchange=${exchange}`
|
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) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error('Failed to fetch kline data from our service')
|
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[]> => {
|
const fetchOrders = async (traderID: string, symbol: string): Promise<OrderMarker[]> => {
|
||||||
try {
|
try {
|
||||||
// Fetch filled orders for this trader from backend API
|
// 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) {
|
if (!result.success || !result.data) {
|
||||||
console.warn('Failed to fetch orders:', result.message)
|
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}`
|
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}`
|
||||||
|
|
||||||
console.log('[ChartSimple] Fetching klines from our service:', klineUrl)
|
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) {
|
if (!klineResult.success || !klineResult.data) {
|
||||||
throw new Error('Failed to fetch klines from our service')
|
throw new Error('Failed to fetch klines from our service')
|
||||||
@@ -44,7 +44,7 @@ export function ChartWithOrdersSimple({
|
|||||||
if (traderID) {
|
if (traderID) {
|
||||||
const tradesUrl = `/api/trades?trader_id=${traderID}&symbol=${symbol}&limit=100`
|
const tradesUrl = `/api/trades?trader_id=${traderID}&symbol=${symbol}&limit=100`
|
||||||
console.log('[ChartSimple] Fetching trades from:', tradesUrl)
|
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) {
|
if (tradesResult.success && tradesResult.data) {
|
||||||
console.log('[ChartSimple] Received trades:', tradesResult.data.length)
|
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[]>(
|
const { data: history, error, isLoading } = useSWR<EquityPoint[]>(
|
||||||
user && token && traderId ? `equity-history-${traderId}` : null,
|
user && token && traderId ? `equity-history-${traderId}` : null,
|
||||||
() => api.getEquityHistory(traderId),
|
() => api.getEquityHistory(traderId, true),
|
||||||
{
|
{
|
||||||
refreshInterval: 30000, // 30秒刷新(历史数据更新频率较低)
|
refreshInterval: 30000, // 30秒刷新(历史数据更新频率较低)
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
@@ -53,7 +53,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
|||||||
|
|
||||||
const { data: account } = useSWR(
|
const { data: account } = useSWR(
|
||||||
user && token && traderId ? `account-${traderId}` : null,
|
user && token && traderId ? `account-${traderId}` : null,
|
||||||
() => api.getAccount(traderId),
|
() => api.getAccount(traderId, true),
|
||||||
{
|
{
|
||||||
refreshInterval: 15000, // 15秒刷新(配合后端缓存)
|
refreshInterval: 15000, // 15秒刷新(配合后端缓存)
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
|
|||||||
@@ -9,8 +9,14 @@ interface DeepVoidBackgroundProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
export function DeepVoidBackground({ children, className = '', disableAnimation = false, ...props }: DeepVoidBackgroundProps) {
|
export function DeepVoidBackground({ children, className = '', disableAnimation = false, ...props }: DeepVoidBackgroundProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`relative w-full min-h-screen bg-nofx-bg text-nofx-text overflow-hidden flex flex-col ${className}`} {...props}>
|
<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 */}
|
{/* 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>
|
<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>
|
||||||
|
|
||||||
@@ -30,6 +36,8 @@ export function DeepVoidBackground({ children, className = '', disableAnimation
|
|||||||
<div className="absolute inset-0 pointer-events-none fixed z-[9999] opacity-40">
|
<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 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>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content Layer */}
|
{/* Content Layer */}
|
||||||
<div className="relative z-10 flex-1 flex flex-col h-full w-full">
|
<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 { Menu, X, ChevronDown, Settings } from 'lucide-react'
|
||||||
import { t, type Language } from '../../i18n/translations'
|
import { t, type Language } from '../../i18n/translations'
|
||||||
import { OFFICIAL_LINKS } from '../../constants/branding'
|
import { OFFICIAL_LINKS } from '../../constants/branding'
|
||||||
|
import {
|
||||||
|
getPostAuthPath,
|
||||||
|
getUserMode,
|
||||||
|
setUserMode,
|
||||||
|
type UserMode,
|
||||||
|
} from '../../lib/onboarding'
|
||||||
|
|
||||||
type Page =
|
type Page =
|
||||||
| 'competition'
|
| 'competition'
|
||||||
@@ -44,8 +50,21 @@ export default function HeaderBar({
|
|||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
||||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||||
|
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const userDropdownRef = 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
|
// Close dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
@@ -216,6 +235,15 @@ export default function HeaderBar({
|
|||||||
<Settings className="w-3.5 h-3.5" />
|
<Settings className="w-3.5 h-3.5" />
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</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 && (
|
{onLogout && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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 { Eye, EyeOff } from 'lucide-react'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
|
||||||
import { invalidateSystemConfig } from '../../lib/config'
|
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() {
|
export function SetupPage() {
|
||||||
|
const { language } = useLanguage()
|
||||||
const { register } = useAuth()
|
const { register } = useAuth()
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
setError('Password must be at least 8 characters')
|
setError(l.passwordError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await register(email, password)
|
const result = await register(email, password, undefined, mode)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
invalidateSystemConfig()
|
invalidateSystemConfig()
|
||||||
window.location.href = '/traders'
|
|
||||||
} else {
|
} else {
|
||||||
setError(result.message || 'Setup failed, please try again')
|
setError(result.message || l.setupFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DeepVoidBackground disableAnimation>
|
<div className="relative min-h-screen w-full overflow-hidden bg-[#0a0a0f]">
|
||||||
<div className="flex-1 flex items-center justify-center px-4 py-16">
|
{/* Decorative background - simulates the main app behind a modal */}
|
||||||
<div className="w-full max-w-sm">
|
|
||||||
|
{/* 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 */}
|
{/* Logo + Title */}
|
||||||
<div className="text-center mb-10">
|
<div className="text-center mb-8">
|
||||||
<div className="flex justify-center mb-5">
|
<div className="flex justify-center mb-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute -inset-3 bg-nofx-gold/15 rounded-full blur-2xl" />
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-white mb-1.5">Welcome to NOFX</h1>
|
<h1 className="text-2xl font-bold text-white mb-1.5">{l.welcome}</h1>
|
||||||
<p className="text-zinc-500 text-sm">Create your account to get started</p>
|
<p className="text-zinc-500 text-sm">{l.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card */}
|
{/* 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">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div>
|
<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
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
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"
|
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="you@example.com"
|
placeholder={l.emailPlaceholder}
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -67,14 +165,14 @@ export function SetupPage() {
|
|||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
<div>
|
<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">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
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"
|
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="At least 8 characters"
|
placeholder={l.passwordPlaceholder}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -87,6 +185,12 @@ export function SetupPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OnboardingModeSelector
|
||||||
|
language={language}
|
||||||
|
mode={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
<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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-xs text-zinc-600 mt-6">
|
<p className="text-center text-xs text-zinc-600 mt-6">
|
||||||
Single-user system — this is the only account
|
{l.singleUser}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react'
|
||||||
import type { CoinSourceConfig } from '../../types'
|
import type { CoinSourceConfig } from '../../types'
|
||||||
import { coinSource, ts } from '../../i18n/strategy-translations'
|
import { coinSource, ts } from '../../i18n/strategy-translations'
|
||||||
|
import { NofxSelect } from '../ui/select'
|
||||||
|
|
||||||
interface CoinSourceEditorProps {
|
interface CoinSourceEditorProps {
|
||||||
config: CoinSourceConfig
|
config: CoinSourceConfig
|
||||||
@@ -24,7 +25,6 @@ export function CoinSourceEditor({
|
|||||||
{ value: 'ai500', icon: Database, color: '#F0B90B' },
|
{ value: 'ai500', icon: Database, color: '#F0B90B' },
|
||||||
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
||||||
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
|
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
|
||||||
{ value: 'mixed', icon: Shuffle, color: '#60a5fa' },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
// Calculate mixed mode summary
|
// Calculate mixed mode summary
|
||||||
@@ -33,16 +33,16 @@ export function CoinSourceEditor({
|
|||||||
let totalLimit = 0
|
let totalLimit = 0
|
||||||
|
|
||||||
if (config.use_ai500) {
|
if (config.use_ai500) {
|
||||||
sources.push(`AI500(${config.ai500_limit || 10})`)
|
sources.push(`AI500(${config.ai500_limit || 3})`)
|
||||||
totalLimit += config.ai500_limit || 10
|
totalLimit += config.ai500_limit || 3
|
||||||
}
|
}
|
||||||
if (config.use_oi_top) {
|
if (config.use_oi_top) {
|
||||||
sources.push(`${ts(coinSource.oiIncreaseShort, language)}(${config.oi_top_limit || 10})`)
|
sources.push(`${ts(coinSource.oiIncreaseShort, language)}(${config.oi_top_limit || 3})`)
|
||||||
totalLimit += config.oi_top_limit || 10
|
totalLimit += config.oi_top_limit || 3
|
||||||
}
|
}
|
||||||
if (config.use_oi_low) {
|
if (config.use_oi_low) {
|
||||||
sources.push(`${ts(coinSource.oiDecreaseShort, language)}(${config.oi_low_limit || 10})`)
|
sources.push(`${ts(coinSource.oiDecreaseShort, language)}(${config.oi_low_limit || 3})`)
|
||||||
totalLimit += config.oi_low_limit || 10
|
totalLimit += config.oi_low_limit || 3
|
||||||
}
|
}
|
||||||
if ((config.static_coins || []).length > 0) {
|
if ((config.static_coins || []).length > 0) {
|
||||||
sources.push(`${ts(coinSource.custom, language)}(${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)
|
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 = () => {
|
const handleAddCoin = () => {
|
||||||
if (!newCoin.trim()) return
|
if (!newCoin.trim()) return
|
||||||
|
|
||||||
|
const currentCoins = config.static_coins || []
|
||||||
|
if (currentCoins.length >= MAX_STATIC_COINS) {
|
||||||
|
showToast(language === 'zh' ? `最多添加 ${MAX_STATIC_COINS} 个币种` : `Maximum ${MAX_STATIC_COINS} coins allowed`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const symbol = newCoin.toUpperCase().trim()
|
const symbol = newCoin.toUpperCase().trim()
|
||||||
|
|
||||||
// For xyz dex assets (stocks, forex, commodities), use xyz: prefix without USDT
|
// For xyz dex assets (stocks, forex, commodities), use xyz: prefix without USDT
|
||||||
@@ -85,7 +103,6 @@ export function CoinSourceEditor({
|
|||||||
formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT`
|
formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT`
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentCoins = config.static_coins || []
|
|
||||||
if (!currentCoins.includes(formattedSymbol)) {
|
if (!currentCoins.includes(formattedSymbol)) {
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
@@ -148,7 +165,7 @@ export function CoinSourceEditor({
|
|||||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||||
{ts(coinSource.sourceType, language)}
|
{ts(coinSource.sourceType, language)}
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{sourceTypes.map(({ value, icon: Icon, color }) => (
|
{sourceTypes.map(({ value, icon: Icon, color }) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
@@ -309,19 +326,16 @@ export function CoinSourceEditor({
|
|||||||
<span className="text-sm text-nofx-text-muted">
|
<span className="text-sm text-nofx-text-muted">
|
||||||
{ts(coinSource.ai500Limit, language)}:
|
{ts(coinSource.ai500Limit, language)}:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.ai500_limit || 10}
|
value={config.ai500_limit || 3}
|
||||||
onChange={(e) =>
|
onChange={(val) =>
|
||||||
!disabled &&
|
!disabled &&
|
||||||
onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
|
onChange({ ...config, ai500_limit: parseInt(val) || 3 })
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
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"
|
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
>
|
/>
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -366,19 +380,16 @@ export function CoinSourceEditor({
|
|||||||
<span className="text-sm text-nofx-text-muted">
|
<span className="text-sm text-nofx-text-muted">
|
||||||
{ts(coinSource.oiTopLimit, language)}:
|
{ts(coinSource.oiTopLimit, language)}:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.oi_top_limit || 10}
|
value={config.oi_top_limit || 3}
|
||||||
onChange={(e) =>
|
onChange={(val) =>
|
||||||
!disabled &&
|
!disabled &&
|
||||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
onChange({ ...config, oi_top_limit: parseInt(val) || 3 })
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
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"
|
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
>
|
/>
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -423,19 +434,16 @@ export function CoinSourceEditor({
|
|||||||
<span className="text-sm text-nofx-text-muted">
|
<span className="text-sm text-nofx-text-muted">
|
||||||
{ts(coinSource.oiLowLimit, language)}:
|
{ts(coinSource.oiLowLimit, language)}:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.oi_low_limit || 10}
|
value={config.oi_low_limit || 3}
|
||||||
onChange={(e) =>
|
onChange={(val) =>
|
||||||
!disabled &&
|
!disabled &&
|
||||||
onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
onChange({ ...config, oi_low_limit: parseInt(val) || 3 })
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
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"
|
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
>
|
/>
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -483,20 +491,13 @@ export function CoinSourceEditor({
|
|||||||
{config.use_ai500 && (
|
{config.use_ai500 && (
|
||||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.ai500_limit || 10}
|
value={config.ai500_limit || 3}
|
||||||
onChange={(e) => {
|
onChange={(val) => !disabled && onChange({ ...config, ai500_limit: parseInt(val) || 3 })}
|
||||||
e.stopPropagation()
|
|
||||||
!disabled && onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
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"
|
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
onClick={(e) => e.stopPropagation()}
|
/>
|
||||||
>
|
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -530,20 +531,13 @@ export function CoinSourceEditor({
|
|||||||
{config.use_oi_top && (
|
{config.use_oi_top && (
|
||||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.oi_top_limit || 10}
|
value={config.oi_top_limit || 3}
|
||||||
onChange={(e) => {
|
onChange={(val) => !disabled && onChange({ ...config, oi_top_limit: parseInt(val) || 3 })}
|
||||||
e.stopPropagation()
|
|
||||||
!disabled && onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
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"
|
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
onClick={(e) => e.stopPropagation()}
|
/>
|
||||||
>
|
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -577,20 +571,13 @@ export function CoinSourceEditor({
|
|||||||
{config.use_oi_low && (
|
{config.use_oi_low && (
|
||||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
<div className="flex items-center gap-2 mt-2 pl-6">
|
||||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.oi_low_limit || 10}
|
value={config.oi_low_limit || 3}
|
||||||
onChange={(e) => {
|
onChange={(val) => !disabled && onChange({ ...config, oi_low_limit: parseInt(val) || 3 })}
|
||||||
e.stopPropagation()
|
|
||||||
!disabled && onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
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"
|
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
onClick={(e) => e.stopPropagation()}
|
/>
|
||||||
>
|
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react'
|
import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react'
|
||||||
import type { GridStrategyConfig } from '../../types'
|
import type { GridStrategyConfig } from '../../types'
|
||||||
import { gridConfig, ts } from '../../i18n/strategy-translations'
|
import { gridConfig, ts } from '../../i18n/strategy-translations'
|
||||||
|
import { NofxSelect } from '../ui/select'
|
||||||
|
|
||||||
interface GridConfigEditorProps {
|
interface GridConfigEditorProps {
|
||||||
config: GridStrategyConfig
|
config: GridStrategyConfig
|
||||||
@@ -74,20 +75,21 @@ export function GridConfigEditor({
|
|||||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||||
{ts(gridConfig.symbolDesc, language)}
|
{ts(gridConfig.symbolDesc, language)}
|
||||||
</p>
|
</p>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.symbol}
|
value={config.symbol}
|
||||||
onChange={(e) => updateField('symbol', e.target.value)}
|
onChange={(val) => updateField('symbol', val)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-full px-3 py-2 rounded"
|
className="w-full px-3 py-2 rounded"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
>
|
options={[
|
||||||
<option value="BTCUSDT">BTC/USDT</option>
|
{ value: 'BTCUSDT', label: 'BTC/USDT' },
|
||||||
<option value="ETHUSDT">ETH/USDT</option>
|
{ value: 'ETHUSDT', label: 'ETH/USDT' },
|
||||||
<option value="SOLUSDT">SOL/USDT</option>
|
{ value: 'SOLUSDT', label: 'SOL/USDT' },
|
||||||
<option value="BNBUSDT">BNB/USDT</option>
|
{ value: 'BNBUSDT', label: 'BNB/USDT' },
|
||||||
<option value="XRPUSDT">XRP/USDT</option>
|
{ value: 'XRPUSDT', label: 'XRP/USDT' },
|
||||||
<option value="DOGEUSDT">DOGE/USDT</option>
|
{ value: 'DOGEUSDT', label: 'DOGE/USDT' },
|
||||||
</select>
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Investment */}
|
{/* Investment */}
|
||||||
@@ -170,17 +172,18 @@ export function GridConfigEditor({
|
|||||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||||
{ts(gridConfig.distributionDesc, language)}
|
{ts(gridConfig.distributionDesc, language)}
|
||||||
</p>
|
</p>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.distribution}
|
value={config.distribution}
|
||||||
onChange={(e) => updateField('distribution', e.target.value as 'uniform' | 'gaussian' | 'pyramid')}
|
onChange={(val) => updateField('distribution', val as 'uniform' | 'gaussian' | 'pyramid')}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-full px-3 py-2 rounded"
|
className="w-full px-3 py-2 rounded"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
>
|
options={[
|
||||||
<option value="uniform">{ts(gridConfig.uniform, language)}</option>
|
{ value: 'uniform', label: ts(gridConfig.uniform, language) },
|
||||||
<option value="gaussian">{ts(gridConfig.gaussian, language)}</option>
|
{ value: 'gaussian', label: ts(gridConfig.gaussian, language) },
|
||||||
<option value="pyramid">{ts(gridConfig.pyramid, language)}</option>
|
{ value: 'pyramid', label: ts(gridConfig.pyramid, language) },
|
||||||
</select>
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Clock, Activity, TrendingUp, BarChart2, Info, Lock, ExternalLink, Zap, Check, AlertCircle, Key } from 'lucide-react'
|
import { Clock, Activity, TrendingUp, BarChart2, Info, Lock, ExternalLink, Zap, Check, AlertCircle, Key } from 'lucide-react'
|
||||||
import type { IndicatorConfig } from '../../types'
|
import type { IndicatorConfig } from '../../types'
|
||||||
import { indicator, ts } from '../../i18n/strategy-translations'
|
import { indicator, ts } from '../../i18n/strategy-translations'
|
||||||
|
import { NofxSelect } from '../ui/select'
|
||||||
|
|
||||||
// Default NofxOS API Key
|
// Default NofxOS API Key
|
||||||
const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c'
|
const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c'
|
||||||
@@ -60,6 +61,16 @@ export function IndicatorEditor({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (current.length >= 4) {
|
||||||
|
// Show toast notification
|
||||||
|
const toast = document.createElement('div')
|
||||||
|
toast.textContent = language === 'zh' ? '最多选择 4 个时间维度' : 'Maximum 4 timeframes allowed'
|
||||||
|
toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg'
|
||||||
|
toast.style.cssText = 'background:#F6465D;color:#fff;'
|
||||||
|
document.body.appendChild(toast)
|
||||||
|
setTimeout(() => toast.remove(), 2000)
|
||||||
|
return
|
||||||
|
}
|
||||||
current.push(tf)
|
current.push(tf)
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
@@ -299,26 +310,22 @@ export function IndicatorEditor({
|
|||||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.oiRankingDesc, language)}</p>
|
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.oiRankingDesc, language)}</p>
|
||||||
{config.enable_oi_ranking && (
|
{config.enable_oi_ranking && (
|
||||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.oi_ranking_duration || '1h'}
|
value={config.oi_ranking_duration || '1h'}
|
||||||
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_duration: e.target.value })}
|
onChange={(val) => !disabled && onChange({ ...config, oi_ranking_duration: val })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
>
|
options={[{ value: '1h', label: '1h' }, { value: '4h', label: '4h' }, { value: '24h', label: '24h' }]}
|
||||||
<option value="1h">1h</option>
|
/>
|
||||||
<option value="4h">4h</option>
|
<NofxSelect
|
||||||
<option value="24h">24h</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={config.oi_ranking_limit || 10}
|
value={config.oi_ranking_limit || 10}
|
||||||
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(e.target.value) })}
|
onChange={(val) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(val) })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
>
|
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
|
||||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
/>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -359,26 +366,22 @@ export function IndicatorEditor({
|
|||||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.netflowRankingDesc, language)}</p>
|
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.netflowRankingDesc, language)}</p>
|
||||||
{config.enable_netflow_ranking && (
|
{config.enable_netflow_ranking && (
|
||||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.netflow_ranking_duration || '1h'}
|
value={config.netflow_ranking_duration || '1h'}
|
||||||
onChange={(e) => !disabled && onChange({ ...config, netflow_ranking_duration: e.target.value })}
|
onChange={(val) => !disabled && onChange({ ...config, netflow_ranking_duration: val })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
>
|
options={[{ value: '1h', label: '1h' }, { value: '4h', label: '4h' }, { value: '24h', label: '24h' }]}
|
||||||
<option value="1h">1h</option>
|
/>
|
||||||
<option value="4h">4h</option>
|
<NofxSelect
|
||||||
<option value="24h">24h</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={config.netflow_ranking_limit || 10}
|
value={config.netflow_ranking_limit || 10}
|
||||||
onChange={(e) => !disabled && onChange({ ...config, netflow_ranking_limit: parseInt(e.target.value) })}
|
onChange={(val) => !disabled && onChange({ ...config, netflow_ranking_limit: parseInt(val) })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
>
|
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
|
||||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
/>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -419,27 +422,27 @@ export function IndicatorEditor({
|
|||||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.priceRankingDesc, language)}</p>
|
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.priceRankingDesc, language)}</p>
|
||||||
{config.enable_price_ranking && (
|
{config.enable_price_ranking && (
|
||||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<select
|
<NofxSelect
|
||||||
value={config.price_ranking_duration || '1h,4h,24h'}
|
value={config.price_ranking_duration || '1h,4h,24h'}
|
||||||
onChange={(e) => !disabled && onChange({ ...config, price_ranking_duration: e.target.value })}
|
onChange={(val) => !disabled && onChange({ ...config, price_ranking_duration: val })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex-1 px-2 py-1 rounded text-[10px]"
|
className="flex-1 px-2 py-1 rounded text-[10px]"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
>
|
options={[
|
||||||
<option value="1h">1h</option>
|
{ value: '1h', label: '1h' },
|
||||||
<option value="4h">4h</option>
|
{ value: '4h', label: '4h' },
|
||||||
<option value="24h">24h</option>
|
{ value: '24h', label: '24h' },
|
||||||
<option value="1h,4h,24h">{ts(indicator.priceRankingMulti, language)}</option>
|
{ value: '1h,4h,24h', label: ts(indicator.priceRankingMulti, language) },
|
||||||
</select>
|
]}
|
||||||
<select
|
/>
|
||||||
|
<NofxSelect
|
||||||
value={config.price_ranking_limit || 10}
|
value={config.price_ranking_limit || 10}
|
||||||
onChange={(e) => !disabled && onChange({ ...config, price_ranking_limit: parseInt(e.target.value) })}
|
onChange={(val) => !disabled && onChange({ ...config, price_ranking_limit: parseInt(val) })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-14 px-2 py-1 rounded text-[10px]"
|
className="w-14 px-2 py-1 rounded text-[10px]"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
>
|
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
|
||||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
/>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -515,7 +518,7 @@ export function IndicatorEditor({
|
|||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={10}
|
min={10}
|
||||||
max={200}
|
max={30}
|
||||||
className="w-16 px-2 py-1 rounded text-xs text-center"
|
className="w-16 px-2 py-1 rounded text-xs text-center"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function RiskControlEditor({
|
|||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={3}
|
||||||
className="w-32 px-3 py-2 rounded"
|
className="w-32 px-3 py-2 rounded"
|
||||||
style={{
|
style={{
|
||||||
background: '#1E2329',
|
background: '#1E2329',
|
||||||
|
|||||||
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,
|
CreateTraderRequest,
|
||||||
AIModel,
|
AIModel,
|
||||||
Exchange,
|
Exchange,
|
||||||
|
ExchangeAccountState,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import { useLanguage } from '../../contexts/LanguageContext'
|
import { useLanguage } from '../../contexts/LanguageContext'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
@@ -18,13 +19,22 @@ import { TelegramConfigModal } from './TelegramConfigModal'
|
|||||||
import { ModelConfigModal } from './ModelConfigModal'
|
import { ModelConfigModal } from './ModelConfigModal'
|
||||||
import { ConfigStatusGrid } from './ConfigStatusGrid'
|
import { ConfigStatusGrid } from './ConfigStatusGrid'
|
||||||
import { TradersList } from './TradersList'
|
import { TradersList } from './TradersList'
|
||||||
|
import { BeginnerGuideCards } from './BeginnerGuideCards'
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
Bot,
|
Bot,
|
||||||
Plus,
|
Plus,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { confirmToast } from '../../lib/notify'
|
import { confirmToast } from '../../lib/notify'
|
||||||
import { toast } from 'sonner'
|
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 {
|
interface AITradersPageProps {
|
||||||
onTraderSelect?: (traderId: string) => void
|
onTraderSelect?: (traderId: string) => void
|
||||||
@@ -48,6 +58,197 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
|
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
|
||||||
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
|
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
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
|
// Toggle wallet address visibility for a trader
|
||||||
const toggleTraderAddressVisibility = (traderId: string) => {
|
const toggleTraderAddressVisibility = (traderId: string) => {
|
||||||
@@ -91,6 +292,23 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
api.getTraders,
|
api.getTraders,
|
||||||
{ refreshInterval: 5000 }
|
{ 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(() => {
|
useEffect(() => {
|
||||||
const loadConfigs = async () => {
|
const loadConfigs = async () => {
|
||||||
@@ -115,6 +333,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
api.getSupportedModels(),
|
api.getSupportedModels(),
|
||||||
])
|
])
|
||||||
setAllModels(modelConfigs)
|
setAllModels(modelConfigs)
|
||||||
|
const clawWalletAddress =
|
||||||
|
modelConfigs.find((model) => model.provider === 'claw402')?.walletAddress || null
|
||||||
|
if (clawWalletAddress) {
|
||||||
|
setBeginnerWalletAddress(clawWalletAddress)
|
||||||
|
persistBeginnerWalletAddress(clawWalletAddress)
|
||||||
|
}
|
||||||
setAllExchanges(exchangeConfigs)
|
setAllExchanges(exchangeConfigs)
|
||||||
setSupportedModels(models)
|
setSupportedModels(models)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -141,6 +365,23 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
}) || []
|
}) || []
|
||||||
|
|
||||||
const enabledModels = allModels?.filter((m) => m.enabled) || []
|
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 =
|
const enabledExchanges =
|
||||||
allExchanges?.filter((e) => {
|
allExchanges?.filter((e) => {
|
||||||
if (!e.enabled) return false
|
if (!e.enabled) return false
|
||||||
@@ -198,26 +439,19 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
|
|
||||||
const handleCreateTrader = async (data: CreateTraderRequest) => {
|
const handleCreateTrader = async (data: CreateTraderRequest) => {
|
||||||
try {
|
try {
|
||||||
const model = allModels?.find((m) => m.id === data.ai_model_id)
|
const createdTrader = await api.createTrader(data)
|
||||||
const exchange = allExchanges?.find((e) => e.id === data.exchange_id)
|
if (createdTrader.startup_warning) {
|
||||||
|
toast.success(t('aiTradersToast.created', language), {
|
||||||
if (!model?.enabled) {
|
description: createdTrader.startup_warning,
|
||||||
toast.error(t('modelNotConfigured', language))
|
})
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
if (!exchange?.enabled) {
|
|
||||||
toast.error(t('exchangeNotConfigured', language))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.createTrader(data)
|
|
||||||
toast.success(t('aiTradersToast.created', language))
|
toast.success(t('aiTradersToast.created', language))
|
||||||
|
}
|
||||||
setShowCreateModal(false)
|
setShowCreateModal(false)
|
||||||
await mutateTraders()
|
await mutateTraders()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create trader:', 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) => {
|
const handleSaveEditTrader = async (data: CreateTraderRequest) => {
|
||||||
console.log('🔥🔥🔥 handleSaveEditTrader CALLED with data:', data)
|
|
||||||
if (!editingTrader) return
|
if (!editingTrader) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -261,10 +494,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
show_in_competition: data.show_in_competition,
|
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)
|
await api.updateTrader(editingTrader.trader_id, request)
|
||||||
toast.success(t('aiTradersToast.saved', language))
|
toast.success(t('aiTradersToast.saved', language))
|
||||||
setShowEditModal(false)
|
setShowEditModal(false)
|
||||||
@@ -272,7 +501,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
await mutateTraders()
|
await mutateTraders()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update trader:', error)
|
console.error('Failed to update trader:', error)
|
||||||
toast.error(t('updateTraderFailed', language))
|
showActionableError(t('updateTraderFailed', language), error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +528,18 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
await api.stopTrader(traderId)
|
await api.stopTrader(traderId)
|
||||||
toast.success(t('aiTradersToast.stopped', language))
|
toast.success(t('aiTradersToast.stopped', language))
|
||||||
} else {
|
} 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)
|
await api.startTrader(traderId)
|
||||||
toast.success(t('aiTradersToast.started', language))
|
toast.success(t('aiTradersToast.started', language))
|
||||||
}
|
}
|
||||||
@@ -306,7 +547,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
await mutateTraders()
|
await mutateTraders()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle trader:', 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()
|
const refreshedExchanges = await api.getExchangeConfigs()
|
||||||
setAllExchanges(refreshedExchanges)
|
setAllExchanges(refreshedExchanges)
|
||||||
|
await mutateExchangeAccountStates()
|
||||||
|
|
||||||
setShowExchangeModal(false)
|
setShowExchangeModal(false)
|
||||||
setEditingExchange(null)
|
setEditingExchange(null)
|
||||||
@@ -597,6 +842,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
|
|
||||||
const refreshedExchanges = await api.getExchangeConfigs()
|
const refreshedExchanges = await api.getExchangeConfigs()
|
||||||
setAllExchanges(refreshedExchanges)
|
setAllExchanges(refreshedExchanges)
|
||||||
|
await mutateExchangeAccountStates()
|
||||||
|
|
||||||
setShowExchangeModal(false)
|
setShowExchangeModal(false)
|
||||||
setEditingExchange(null)
|
setEditingExchange(null)
|
||||||
@@ -616,6 +862,37 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
setShowExchangeModal(true)
|
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 (
|
return (
|
||||||
<DeepVoidBackground className="py-8" disableAnimation>
|
<DeepVoidBackground className="py-8" disableAnimation>
|
||||||
<div className="w-full px-4 md:px-8 space-y-8 animate-fade-in">
|
<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>
|
||||||
</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 */}
|
{/* Configuration Status Grid */}
|
||||||
<ConfigStatusGrid
|
<ConfigStatusGrid
|
||||||
configuredModels={configuredModels}
|
configuredModels={configuredModels}
|
||||||
configuredExchanges={configuredExchanges}
|
configuredExchanges={configuredExchanges}
|
||||||
|
exchangeAccountStates={exchangeAccountStateData?.states}
|
||||||
|
isExchangeAccountStatesLoading={isExchangeAccountStatesLoading}
|
||||||
visibleExchangeAddresses={visibleExchangeAddresses}
|
visibleExchangeAddresses={visibleExchangeAddresses}
|
||||||
copiedId={copiedId}
|
copiedId={copiedId}
|
||||||
language={language}
|
language={language}
|
||||||
@@ -715,7 +1056,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
copiedId={copiedId}
|
copiedId={copiedId}
|
||||||
language={language}
|
language={language}
|
||||||
onTraderSelect={onTraderSelect}
|
onTraderSelect={onTraderSelect}
|
||||||
onNavigate={(path) => navigate(path)}
|
onNavigate={navigateInApp}
|
||||||
onEditTrader={handleEditTrader}
|
onEditTrader={handleEditTrader}
|
||||||
onToggleTrader={handleToggleTrader}
|
onToggleTrader={handleToggleTrader}
|
||||||
onToggleCompetition={handleToggleCompetition}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap">
|
<div className="flex items-center gap-4 md:gap-6">
|
||||||
{/* Total Equity */}
|
{/* Total Equity */}
|
||||||
<div className="text-right">
|
<div className="text-right min-w-[60px] md:min-w-[80px]">
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
|
||||||
{t('equity', language)}
|
{t('equity', language)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-xs md:text-sm font-bold mono"
|
className="text-sm md:text-base font-bold mono"
|
||||||
style={{ color: '#EAECEF' }}
|
style={{ color: '#EAECEF' }}
|
||||||
>
|
>
|
||||||
{trader.total_equity?.toFixed(2) || '0.00'}
|
{trader.total_equity?.toFixed(2) || '0.00'}
|
||||||
@@ -297,11 +297,11 @@ export function CompetitionPage() {
|
|||||||
|
|
||||||
{/* P&L */}
|
{/* P&L */}
|
||||||
<div className="text-right min-w-[70px] md:min-w-[90px]">
|
<div className="text-right min-w-[70px] md:min-w-[90px]">
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
|
||||||
{t('pnl', language)}
|
{t('pnl', language)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-base md:text-lg font-bold mono"
|
className="text-sm md:text-base font-bold mono"
|
||||||
style={{
|
style={{
|
||||||
color:
|
color:
|
||||||
(trader.total_pnl ?? 0) >= 0
|
(trader.total_pnl ?? 0) >= 0
|
||||||
@@ -313,7 +313,7 @@ export function CompetitionPage() {
|
|||||||
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-xs mono"
|
className="text-[10px] mono"
|
||||||
style={{ color: '#848E9C' }}
|
style={{ color: '#848E9C' }}
|
||||||
>
|
>
|
||||||
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
|
||||||
@@ -322,17 +322,17 @@ export function CompetitionPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Positions */}
|
{/* Positions */}
|
||||||
<div className="text-right">
|
<div className="text-right min-w-[40px] md:min-w-[50px]">
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
|
||||||
{t('pos', language)}
|
{t('pos', language)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-xs md:text-sm font-bold mono"
|
className="text-sm md:text-base font-bold mono"
|
||||||
style={{ color: '#EAECEF' }}
|
style={{ color: '#EAECEF' }}
|
||||||
>
|
>
|
||||||
{trader.position_count}
|
{trader.position_count}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
<div className="text-[10px]" style={{ color: '#848E9C' }}>
|
||||||
{trader.margin_used_pct.toFixed(1)}%
|
{trader.margin_used_pct.toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { AIModel, Exchange } from '../../types'
|
import type { AIModel, Exchange, ExchangeAccountState } from '../../types'
|
||||||
import type { Language } from '../../i18n/translations'
|
import type { Language } from '../../i18n/translations'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
import { getModelIcon } from '../common/ModelIcons'
|
import { getModelIcon } from '../common/ModelIcons'
|
||||||
@@ -25,6 +25,8 @@ interface UsageInfo {
|
|||||||
interface ConfigStatusGridProps {
|
interface ConfigStatusGridProps {
|
||||||
configuredModels: AIModel[]
|
configuredModels: AIModel[]
|
||||||
configuredExchanges: Exchange[]
|
configuredExchanges: Exchange[]
|
||||||
|
exchangeAccountStates?: Record<string, ExchangeAccountState>
|
||||||
|
isExchangeAccountStatesLoading?: boolean
|
||||||
visibleExchangeAddresses: Set<string>
|
visibleExchangeAddresses: Set<string>
|
||||||
copiedId: string | null
|
copiedId: string | null
|
||||||
language: Language
|
language: Language
|
||||||
@@ -41,6 +43,8 @@ interface ConfigStatusGridProps {
|
|||||||
export function ConfigStatusGrid({
|
export function ConfigStatusGrid({
|
||||||
configuredModels,
|
configuredModels,
|
||||||
configuredExchanges,
|
configuredExchanges,
|
||||||
|
exchangeAccountStates,
|
||||||
|
isExchangeAccountStatesLoading,
|
||||||
visibleExchangeAddresses,
|
visibleExchangeAddresses,
|
||||||
copiedId,
|
copiedId,
|
||||||
language,
|
language,
|
||||||
@@ -53,6 +57,48 @@ export function ConfigStatusGrid({
|
|||||||
onToggleExchangeAddress,
|
onToggleExchangeAddress,
|
||||||
onCopyAddress,
|
onCopyAddress,
|
||||||
}: ConfigStatusGridProps) {
|
}: 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 (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* AI Models Card */}
|
{/* AI Models Card */}
|
||||||
@@ -92,6 +138,20 @@ export function ConfigStatusGrid({
|
|||||||
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
|
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
|
||||||
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
|
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,6 +195,8 @@ export function ConfigStatusGrid({
|
|||||||
{configuredExchanges.map((exchange) => {
|
{configuredExchanges.map((exchange) => {
|
||||||
const inUse = isExchangeInUse(exchange.id)
|
const inUse = isExchangeInUse(exchange.id)
|
||||||
const usageInfo = getExchangeUsageInfo(exchange.id)
|
const usageInfo = getExchangeUsageInfo(exchange.id)
|
||||||
|
const state = exchangeAccountStates?.[exchange.id]
|
||||||
|
const stateMeta = getExchangeStateMeta(state)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={exchange.id}
|
key={exchange.id}
|
||||||
@@ -160,6 +222,18 @@ export function ConfigStatusGrid({
|
|||||||
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
|
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
|
||||||
{exchange.type?.toUpperCase() || 'CEX'}
|
{exchange.type?.toUpperCase() || 'CEX'}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react'
|
|||||||
import type { AIModel } from '../../types'
|
import type { AIModel } from '../../types'
|
||||||
import type { Language } from '../../i18n/translations'
|
import type { Language } from '../../i18n/translations'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
|
import { api } from '../../lib/api'
|
||||||
import { getModelIcon } from '../common/ModelIcons'
|
import { getModelIcon } from '../common/ModelIcons'
|
||||||
import { ModelStepIndicator } from './ModelStepIndicator'
|
import { ModelStepIndicator } from './ModelStepIndicator'
|
||||||
import { ModelCard } from './ModelCard'
|
import { ModelCard } from './ModelCard'
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
AI_PROVIDER_CONFIG,
|
AI_PROVIDER_CONFIG,
|
||||||
getShortName,
|
getShortName,
|
||||||
} from './model-constants'
|
} from './model-constants'
|
||||||
|
import { getBeginnerWalletAddress, getUserMode } from '../../lib/onboarding'
|
||||||
|
|
||||||
interface ModelConfigModalProps {
|
interface ModelConfigModalProps {
|
||||||
allModels: AIModel[]
|
allModels: AIModel[]
|
||||||
@@ -42,20 +44,22 @@ export function ModelConfigModal({
|
|||||||
const [apiKey, setApiKey] = useState('')
|
const [apiKey, setApiKey] = useState('')
|
||||||
const [baseUrl, setBaseUrl] = useState('')
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
const [modelName, setModelName] = useState('')
|
const [modelName, setModelName] = useState('')
|
||||||
|
const configuredModel =
|
||||||
|
configuredModels?.find((model) => model.id === selectedModelId) || null
|
||||||
|
|
||||||
// Always prefer allModels (supportedModels) for provider/id lookup;
|
// Always prefer allModels (supportedModels) for provider/id lookup;
|
||||||
// fall back to configuredModels for edit mode details (apiKey etc.)
|
// fall back to configuredModels for edit mode details (apiKey etc.)
|
||||||
const selectedModel =
|
const selectedModel =
|
||||||
allModels?.find((m) => m.id === selectedModelId) ||
|
allModels?.find((m) => m.id === selectedModelId) || configuredModel
|
||||||
configuredModels?.find((m) => m.id === selectedModelId)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingModelId && selectedModel) {
|
const modelDetails = configuredModel || selectedModel
|
||||||
setApiKey(selectedModel.apiKey || '')
|
if (editingModelId && modelDetails) {
|
||||||
setBaseUrl(selectedModel.customApiUrl || '')
|
setApiKey(modelDetails.apiKey || '')
|
||||||
setModelName(selectedModel.customModelName || '')
|
setBaseUrl(modelDetails.customApiUrl || '')
|
||||||
|
setModelName(modelDetails.customModelName || '')
|
||||||
}
|
}
|
||||||
}, [editingModelId, selectedModel])
|
}, [editingModelId, configuredModel, selectedModel])
|
||||||
|
|
||||||
const handleSelectModel = (modelId: string) => {
|
const handleSelectModel = (modelId: string) => {
|
||||||
setSelectedModelId(modelId)
|
setSelectedModelId(modelId)
|
||||||
@@ -79,7 +83,19 @@ export function ModelConfigModal({
|
|||||||
|
|
||||||
const availableModels = allModels || []
|
const availableModels = allModels || []
|
||||||
const configuredIds = new Set(configuredModels?.map(m => m.id) || [])
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{editingModelId && (
|
{editingModelId && !isBeginnerDefaultModel && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDelete(editingModelId)}
|
onClick={() => onDelete(editingModelId)}
|
||||||
@@ -143,6 +159,7 @@ export function ModelConfigModal({
|
|||||||
<Claw402ConfigForm
|
<Claw402ConfigForm
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
modelName={modelName}
|
modelName={modelName}
|
||||||
|
configuredModel={configuredModel}
|
||||||
editingModelId={editingModelId}
|
editingModelId={editingModelId}
|
||||||
onApiKeyChange={setApiKey}
|
onApiKeyChange={setApiKey}
|
||||||
onModelNameChange={setModelName}
|
onModelNameChange={setModelName}
|
||||||
@@ -189,6 +206,10 @@ function ModelSelectionStep({
|
|||||||
onSelectModel: (modelId: string) => void
|
onSelectModel: (modelId: string) => void
|
||||||
language: Language
|
language: Language
|
||||||
}) {
|
}) {
|
||||||
|
const [showOtherProviders, setShowOtherProviders] = useState(false)
|
||||||
|
const claw402Model = availableModels.find((m) => m.provider === 'claw402')
|
||||||
|
const otherProviders = availableModels.filter((m) => m.provider !== 'claw402')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||||
@@ -196,12 +217,11 @@ function ModelSelectionStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Claw402 Featured Card */}
|
{/* Claw402 Featured Card */}
|
||||||
{availableModels.some(m => m.provider === 'claw402') && (
|
{claw402Model && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const claw = availableModels.find(m => m.provider === 'claw402')
|
onSelectModel(claw402Model.id)
|
||||||
if (claw) onSelectModel(claw.id)
|
|
||||||
}}
|
}}
|
||||||
className="w-full p-5 rounded-xl text-left transition-all hover:scale-[1.01]"
|
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)' }}
|
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>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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="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' }}>
|
<div className="px-3 py-1.5 rounded-full text-xs font-bold" style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff' }}>
|
||||||
@@ -235,11 +255,41 @@ function ModelSelectionStep({
|
|||||||
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
|
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4 ml-[52px] text-[11px]" style={{ color: '#A0AEC0' }}>
|
||||||
|
{t('modelConfig.claw402EntryDesc', language)}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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">
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||||
{availableModels.filter(m => m.provider !== 'claw402').map((model) => (
|
{otherProviders.map((model) => (
|
||||||
<ModelCard
|
<ModelCard
|
||||||
key={model.id}
|
key={model.id}
|
||||||
model={model}
|
model={model}
|
||||||
@@ -249,16 +299,21 @@ function ModelSelectionStep({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-center pt-2" style={{ color: '#848E9C' }}>
|
<div className="text-xs text-center pt-3" style={{ color: '#848E9C' }}>
|
||||||
{t('modelConfig.modelsConfigured', language)}
|
{t('modelConfig.modelsConfigured', language)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Claw402ConfigForm({
|
function Claw402ConfigForm({
|
||||||
apiKey,
|
apiKey,
|
||||||
modelName,
|
modelName,
|
||||||
|
configuredModel,
|
||||||
editingModelId,
|
editingModelId,
|
||||||
onApiKeyChange,
|
onApiKeyChange,
|
||||||
onModelNameChange,
|
onModelNameChange,
|
||||||
@@ -268,6 +323,7 @@ function Claw402ConfigForm({
|
|||||||
}: {
|
}: {
|
||||||
apiKey: string
|
apiKey: string
|
||||||
modelName: string
|
modelName: string
|
||||||
|
configuredModel: AIModel | null
|
||||||
editingModelId: string | null
|
editingModelId: string | null
|
||||||
onApiKeyChange: (value: string) => void
|
onApiKeyChange: (value: string) => void
|
||||||
onModelNameChange: (value: string) => void
|
onModelNameChange: (value: string) => void
|
||||||
@@ -278,14 +334,21 @@ function Claw402ConfigForm({
|
|||||||
const [walletAddress, setWalletAddress] = useState('')
|
const [walletAddress, setWalletAddress] = useState('')
|
||||||
const [copiedAddr, setCopiedAddr] = useState(false)
|
const [copiedAddr, setCopiedAddr] = useState(false)
|
||||||
const [showDeposit, setShowDeposit] = useState(false)
|
const [showDeposit, setShowDeposit] = useState(false)
|
||||||
const [showNewWalletBackup, setShowNewWalletBackup] = useState(false)
|
|
||||||
const [newWalletKey, setNewWalletKey] = useState('')
|
|
||||||
const [usdcBalance, setUsdcBalance] = useState<string | null>(null)
|
const [usdcBalance, setUsdcBalance] = useState<string | null>(null)
|
||||||
const [keyError, setKeyError] = useState('')
|
const [keyError, setKeyError] = useState('')
|
||||||
const [validating, setValidating] = useState(false)
|
const [validating, setValidating] = useState(false)
|
||||||
const [claw402Status, setClaw402Status] = useState<string | null>(null)
|
const [claw402Status, setClaw402Status] = useState<string | null>(null)
|
||||||
const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null)
|
const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null)
|
||||||
const [testing, setTesting] = useState(false)
|
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
|
// Client-side validation helper
|
||||||
const getClientError = (key: string): string => {
|
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)
|
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
|
// Debounced validation when apiKey changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -347,6 +438,23 @@ function Claw402ConfigForm({
|
|||||||
setTesting(true)
|
setTesting(true)
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
try {
|
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', {
|
const res = await fetch('/api/wallet/validate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -374,7 +482,7 @@ function Claw402ConfigForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const balanceNum = usdcBalance ? parseFloat(usdcBalance) : 0
|
const balanceNum = resolvedUsdcBalance ? parseFloat(resolvedUsdcBalance) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit} className="space-y-5">
|
<form onSubmit={onSubmit} className="space-y-5">
|
||||||
@@ -396,6 +504,25 @@ function Claw402ConfigForm({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Step 1: Select AI Model */}
|
{/* Step 1: Select AI Model */}
|
||||||
@@ -409,7 +536,7 @@ function Claw402ConfigForm({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
{CLAW402_MODELS.map((m) => {
|
{CLAW402_MODELS.map((m) => {
|
||||||
const isSelected = (modelName || 'deepseek') === m.id
|
const isSelected = (modelName || 'glm-5') === m.id
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
@@ -467,6 +594,33 @@ function Claw402ConfigForm({
|
|||||||
</div>
|
</div>
|
||||||
</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="space-y-1.5">
|
||||||
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
|
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
|
||||||
{t('modelConfig.walletPrivateKey', language)}
|
{t('modelConfig.walletPrivateKey', language)}
|
||||||
@@ -476,72 +630,30 @@ function Claw402ConfigForm({
|
|||||||
type="password"
|
type="password"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
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"
|
className="flex-1 px-4 py-3 rounded-xl font-mono text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
|
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
|
||||||
color: '#EAECEF',
|
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>
|
</div>
|
||||||
|
|
||||||
{/* New wallet backup warning */}
|
{hasExistingWallet && !apiKey ? (
|
||||||
{showNewWalletBackup && newWalletKey && (
|
<div className="text-[11px] leading-5" style={{ color: '#848E9C' }}>
|
||||||
<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'
|
{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.'}
|
: '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>
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
) : null}
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
|
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
|
||||||
<span className="mt-px">🔒</span>
|
<span className="mt-px">🔒</span>
|
||||||
@@ -552,7 +664,7 @@ function Claw402ConfigForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wallet Validation Results */}
|
{/* Wallet Validation Results */}
|
||||||
{apiKey && (
|
{(apiKey || hasExistingWallet) && (
|
||||||
<div className="space-y-2 pl-1">
|
<div className="space-y-2 pl-1">
|
||||||
{/* Validating spinner */}
|
{/* Validating spinner */}
|
||||||
{validating && (
|
{validating && (
|
||||||
@@ -571,7 +683,7 @@ function Claw402ConfigForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Success: address + balance + status */}
|
{/* 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="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">
|
<div className="flex items-center justify-between mb-1">
|
||||||
@@ -581,7 +693,7 @@ function Claw402ConfigForm({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(walletAddress)
|
navigator.clipboard.writeText(resolvedWalletAddress)
|
||||||
setCopiedAddr(true)
|
setCopiedAddr(true)
|
||||||
setTimeout(() => setCopiedAddr(false), 2000)
|
setTimeout(() => setCopiedAddr(false), 2000)
|
||||||
}}
|
}}
|
||||||
@@ -591,16 +703,16 @@ function Claw402ConfigForm({
|
|||||||
{copiedAddr ? '✅' : '📋'}
|
{copiedAddr ? '✅' : '📋'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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' }}>
|
<div className="text-[10px] mt-1.5" style={{ color: '#F59E0B' }}>
|
||||||
⚠️ {language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
|
⚠️ {language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{usdcBalance !== null && (
|
{resolvedUsdcBalance !== null && (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span>💰</span>
|
<span>💰</span>
|
||||||
<span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
|
<span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
|
||||||
{t('modelConfig.usdcBalance', language)}: ${usdcBalance}
|
{t('modelConfig.usdcBalance', language)}: ${resolvedUsdcBalance}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -621,17 +733,17 @@ function Claw402ConfigForm({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 items-start mb-3">
|
<div className="flex gap-3 items-start mb-3">
|
||||||
<div className="shrink-0 p-1.5 rounded-lg" style={{ background: '#fff' }}>
|
<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>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}>
|
<div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}>
|
||||||
{language === 'zh' ? '扫码或复制地址转账' : 'Scan QR or copy address to transfer'}
|
{language === 'zh' ? '扫码或复制地址转账' : 'Scan QR or copy address to transfer'}
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(walletAddress)
|
navigator.clipboard.writeText(resolvedWalletAddress)
|
||||||
setCopiedAddr(true)
|
setCopiedAddr(true)
|
||||||
setTimeout(() => setCopiedAddr(false), 2000)
|
setTimeout(() => setCopiedAddr(false), 2000)
|
||||||
}}
|
}}
|
||||||
@@ -650,6 +762,13 @@ function Claw402ConfigForm({
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{claw402Status && (
|
||||||
<div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}>
|
<div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}>
|
||||||
<span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span>
|
<span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span>
|
||||||
@@ -662,11 +781,11 @@ function Claw402ConfigForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Test Connection button */}
|
{/* Test Connection button */}
|
||||||
{isKeyValid && !validating && (
|
{(isKeyValid || hasExistingWallet) && !validating && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTestConnection}
|
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"
|
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' }}
|
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 { t, type Language } from '../../i18n/translations'
|
||||||
import { MetricTooltip } from '../common/MetricTooltip'
|
import { MetricTooltip } from '../common/MetricTooltip'
|
||||||
import { formatPrice, formatQuantity } from '../../utils/format'
|
import { formatPrice, formatQuantity } from '../../utils/format'
|
||||||
|
import { NofxSelect } from '../ui/select'
|
||||||
import type {
|
import type {
|
||||||
HistoricalPosition,
|
HistoricalPosition,
|
||||||
TraderStats,
|
TraderStats,
|
||||||
@@ -358,7 +359,11 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
// Fetch more data than needed to support filtering, but respect pageSize for initial load
|
// 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 || [])
|
setPositions(data.positions || [])
|
||||||
setStats(data.stats)
|
setStats(data.stats)
|
||||||
setSymbolStats(data.symbol_stats || [])
|
setSymbolStats(data.symbol_stats || [])
|
||||||
@@ -664,23 +669,20 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||||
{t('positionHistory.symbol', language)}:
|
{t('positionHistory.symbol', language)}:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={filterSymbol}
|
value={filterSymbol}
|
||||||
onChange={(e) => setFilterSymbol(e.target.value)}
|
onChange={(val) => setFilterSymbol(val)}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: t('positionHistory.allSymbols', language) },
|
||||||
|
...uniqueSymbols.map(s => ({ value: s, label: (s || '').replace('USDT', '') }))
|
||||||
|
]}
|
||||||
className="rounded px-3 py-1.5 text-sm"
|
className="rounded px-3 py-1.5 text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
border: '1px solid #2B3139',
|
border: '1px solid #2B3139',
|
||||||
color: '#EAECEF',
|
color: '#EAECEF',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="all">{t('positionHistory.allSymbols', language)}</option>
|
|
||||||
{uniqueSymbols.map((symbol) => (
|
|
||||||
<option key={symbol} value={symbol}>
|
|
||||||
{(symbol || '').replace('USDT', '')}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -708,28 +710,26 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||||
{t('positionHistory.sort', language)}:
|
{t('positionHistory.sort', language)}:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={`${sortBy}-${sortOrder}`}
|
value={`${sortBy}-${sortOrder}`}
|
||||||
onChange={(e) => {
|
onChange={(val) => {
|
||||||
const [by, order] = e.target.value.split('-') as [
|
const [by, order] = val.split('-') as ['time' | 'pnl' | 'pnl_pct', 'asc' | 'desc']
|
||||||
'time' | 'pnl' | 'pnl_pct',
|
|
||||||
'asc' | 'desc',
|
|
||||||
]
|
|
||||||
setSortBy(by)
|
setSortBy(by)
|
||||||
setSortOrder(order)
|
setSortOrder(order)
|
||||||
}}
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'time-desc', label: t('positionHistory.latestFirst', language) },
|
||||||
|
{ value: 'time-asc', label: t('positionHistory.oldestFirst', language) },
|
||||||
|
{ value: 'pnl-desc', label: t('positionHistory.highestPnL', language) },
|
||||||
|
{ value: 'pnl-asc', label: t('positionHistory.lowestPnL', language) },
|
||||||
|
]}
|
||||||
className="rounded px-3 py-1.5 text-sm"
|
className="rounded px-3 py-1.5 text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
border: '1px solid #2B3139',
|
border: '1px solid #2B3139',
|
||||||
color: '#EAECEF',
|
color: '#EAECEF',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="time-desc">{t('positionHistory.latestFirst', language)}</option>
|
|
||||||
<option value="time-asc">{t('positionHistory.oldestFirst', language)}</option>
|
|
||||||
<option value="pnl-desc">{t('positionHistory.highestPnL', language)}</option>
|
|
||||||
<option value="pnl-asc">{t('positionHistory.lowestPnL', language)}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -841,20 +841,21 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||||
{language === 'zh' ? '每页' : 'Per page'}:
|
{language === 'zh' ? '每页' : 'Per page'}:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
onChange={(val) => setPageSize(Number(val))}
|
||||||
|
options={[
|
||||||
|
{ value: 20, label: '20' },
|
||||||
|
{ value: 50, label: '50' },
|
||||||
|
{ value: 100, label: '100' },
|
||||||
|
]}
|
||||||
className="rounded px-2 py-1 text-sm"
|
className="rounded px-2 py-1 text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
border: '1px solid #2B3139',
|
border: '1px solid #2B3139',
|
||||||
color: '#EAECEF',
|
color: '#EAECEF',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value={20}>20</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page navigation */}
|
{/* Page navigation */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
|
|||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import type { TelegramConfig, AIModel } from '../../types'
|
import type { TelegramConfig, AIModel } from '../../types'
|
||||||
import { t, type Language } from '../../i18n/translations'
|
import { t, type Language } from '../../i18n/translations'
|
||||||
|
import { NofxSelect } from '../ui/select'
|
||||||
|
|
||||||
// Step indicator (reused pattern from ExchangeConfigModal)
|
// Step indicator (reused pattern from ExchangeConfigModal)
|
||||||
function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) {
|
function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) {
|
||||||
@@ -133,23 +134,20 @@ export function TelegramConfigModal({ onClose, language }: TelegramConfigModalPr
|
|||||||
{t('telegram.noEnabledModels', language)}
|
{t('telegram.noEnabledModels', language)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<NofxSelect
|
||||||
value={selectedModelId}
|
value={selectedModelId}
|
||||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
onChange={(val) => setSelectedModelId(val)}
|
||||||
className="w-full px-4 py-3 rounded-xl text-sm appearance-none"
|
options={[
|
||||||
|
{ value: '', label: t('telegram.autoSelect', language) },
|
||||||
|
...models.map(m => ({ value: m.id, label: `${m.name} (${m.provider}${m.customModelName ? ` · ${m.customModelName}` : ''})` }))
|
||||||
|
]}
|
||||||
|
className="w-full px-4 py-3 rounded-xl text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
border: '1px solid #2B3139',
|
border: '1px solid #2B3139',
|
||||||
color: selectedModelId ? '#EAECEF' : '#848E9C',
|
color: selectedModelId ? '#EAECEF' : '#848E9C',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="">{t('telegram.autoSelect', language)}</option>
|
|
||||||
{models.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.name} ({m.provider}{m.customModelName ? ` · ${m.customModelName}` : ''})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||||
{t('telegram.autoUseEnabled', language)}
|
{t('telegram.autoUseEnabled', language)}
|
||||||
@@ -489,23 +487,20 @@ function BoundModelSelector({
|
|||||||
{t('telegram.aiModelLabel', language)}
|
{t('telegram.aiModelLabel', language)}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select
|
<NofxSelect
|
||||||
value={modelId}
|
value={modelId}
|
||||||
onChange={(e) => setModelId(e.target.value)}
|
onChange={(val) => setModelId(val)}
|
||||||
className="flex-1 px-3 py-2.5 rounded-xl text-sm appearance-none"
|
options={[
|
||||||
|
{ value: '', label: t('telegram.aiModelAutoSelect', language) },
|
||||||
|
...models.map(m => ({ value: m.id, label: `${m.name}${m.customModelName ? ` · ${m.customModelName}` : ''}` }))
|
||||||
|
]}
|
||||||
|
className="flex-1 px-3 py-2.5 rounded-xl text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
border: '1px solid #2B3139',
|
border: '1px solid #2B3139',
|
||||||
color: modelId ? '#EAECEF' : '#848E9C',
|
color: modelId ? '#EAECEF' : '#848E9C',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="">{t('telegram.aiModelAutoSelect', language)}</option>
|
|
||||||
{models.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.name}{m.customModelName ? ` · ${m.customModelName}` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving || modelId === currentModelId}
|
disabled={isSaving || modelId === currentModelId}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { useLanguage } from '../../contexts/LanguageContext'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Pencil, Plus, X as IconX, Sparkles, ExternalLink, UserPlus } from 'lucide-react'
|
import { Pencil, Plus, X as IconX, Sparkles, ExternalLink, UserPlus } from 'lucide-react'
|
||||||
import { httpClient } from '../../lib/httpClient'
|
import { httpClient } from '../../lib/httpClient'
|
||||||
|
import { NofxSelect } from '../ui/select'
|
||||||
|
|
||||||
// 提取下划线后面的名称部分
|
// 提取下划线后面的名称部分
|
||||||
function getShortName(fullName: string): string {
|
function getShortName(fullName: string): string {
|
||||||
@@ -123,32 +124,62 @@ export function TraderConfigModal({
|
|||||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
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 () => {
|
const handleFetchCurrentBalance = async () => {
|
||||||
if (!isEditMode || !traderData?.trader_id) {
|
if (!isEditMode) {
|
||||||
setBalanceFetchError(t('fetchBalanceEditModeOnly', language))
|
setBalanceFetchError(t('fetchBalanceEditModeOnly', language))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!formData.exchange_id) {
|
||||||
|
setBalanceFetchError(t('balanceFetchFailed', language))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsFetchingBalance(true)
|
setIsFetchingBalance(true)
|
||||||
setBalanceFetchError('')
|
setBalanceFetchError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await httpClient.get<{
|
const result = await httpClient.get<ExchangeAccountStateResponse>('/api/exchanges/account-state')
|
||||||
total_equity?: number
|
|
||||||
balance?: number
|
|
||||||
}>(`/api/account?trader_id=${traderData.trader_id}`)
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
const selectedState = result.data?.states?.[formData.exchange_id]
|
||||||
|
if (result.success && selectedState?.status === 'ok') {
|
||||||
const currentBalance =
|
const currentBalance =
|
||||||
result.data.total_equity || result.data.balance || 0
|
selectedState.total_equity ??
|
||||||
|
selectedState.available_balance ??
|
||||||
|
0
|
||||||
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
|
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
|
||||||
toast.success(t('balanceFetched', language))
|
toast.success(t('balanceFetched', language))
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.message || t('balanceFetchFailed', language))
|
setBalanceFetchError(
|
||||||
|
selectedState?.error_message || result.message || t('balanceFetchFailed', language)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t('balanceFetchFailed', language) + ':', error)
|
console.error(t('balanceFetchFailed', language) + ':', error)
|
||||||
setBalanceFetchError(t('balanceFetchNetworkError', language))
|
setBalanceFetchError(
|
||||||
|
error instanceof Error && error.message
|
||||||
|
? error.message
|
||||||
|
: t('balanceFetchNetworkError', language)
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetchingBalance(false)
|
setIsFetchingBalance(false)
|
||||||
}
|
}
|
||||||
@@ -175,8 +206,6 @@ export function TraderConfigModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await onSave(saveData)
|
await onSave(saveData)
|
||||||
toast.success(t('saveSuccess', language))
|
|
||||||
onClose()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(t('saveFailed', language) + ':', error)
|
console.error(t('saveFailed', language) + ':', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -250,38 +279,32 @@ export function TraderConfigModal({
|
|||||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||||
{t('aiModelRequired', language)}
|
{t('aiModelRequired', language)}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<NofxSelect
|
||||||
value={formData.ai_model}
|
value={formData.ai_model}
|
||||||
onChange={(e) =>
|
onChange={(val) =>
|
||||||
handleInputChange('ai_model', e.target.value)
|
handleInputChange('ai_model', val)
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
|
||||||
>
|
options={availableModels.map((model) => ({
|
||||||
{availableModels.map((model) => (
|
value: model.id,
|
||||||
<option key={model.id} value={model.id}>
|
label: getShortName(model.name || model.id).toUpperCase(),
|
||||||
{getShortName(model.name || model.id).toUpperCase()}
|
}))}
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||||
{t('exchangeRequired', language)}
|
{t('exchangeRequired', language)}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<NofxSelect
|
||||||
value={formData.exchange_id}
|
value={formData.exchange_id}
|
||||||
onChange={(e) =>
|
onChange={handleExchangeChange}
|
||||||
handleInputChange('exchange_id', e.target.value)
|
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
|
||||||
}
|
options={availableExchanges.map((exchange) => ({
|
||||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
value: exchange.id,
|
||||||
>
|
label: getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()
|
||||||
{availableExchanges.map((exchange) => (
|
+ (exchange.account_name ? ` - ${exchange.account_name}` : ''),
|
||||||
<option key={exchange.id} value={exchange.id}>
|
}))}
|
||||||
{getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()}
|
/>
|
||||||
{exchange.account_name ? ` - ${exchange.account_name}` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{/* Exchange Registration Link */}
|
{/* Exchange Registration Link */}
|
||||||
{formData.exchange_id && (() => {
|
{formData.exchange_id && (() => {
|
||||||
// Find the selected exchange to get its type
|
// Find the selected exchange to get its type
|
||||||
@@ -323,22 +346,20 @@ export function TraderConfigModal({
|
|||||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||||
{t('useStrategy', language)}
|
{t('useStrategy', language)}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<NofxSelect
|
||||||
value={formData.strategy_id}
|
value={formData.strategy_id}
|
||||||
onChange={(e) =>
|
onChange={(val) =>
|
||||||
handleInputChange('strategy_id', e.target.value)
|
handleInputChange('strategy_id', val)
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
|
||||||
>
|
options={[
|
||||||
<option value="">{t('noStrategyManual', language)}</option>
|
{ value: '', label: t('noStrategyManual', language) },
|
||||||
{strategies.map((strategy) => (
|
...strategies.map((strategy) => ({
|
||||||
<option key={strategy.id} value={strategy.id}>
|
value: strategy.id,
|
||||||
{strategy.name}
|
label: strategy.name + (strategy.is_active ? t('strategyActive', language) : '') + (strategy.is_default ? t('strategyDefault', language) : ''),
|
||||||
{strategy.is_active ? t('strategyActive', language) : ''}
|
})),
|
||||||
{strategy.is_default ? t('strategyDefault', language) : ''}
|
]}
|
||||||
</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{strategies.length === 0 && (
|
{strategies.length === 0 && (
|
||||||
<p className="text-xs text-[#848E9C] mt-2">
|
<p className="text-xs text-[#848E9C] mt-2">
|
||||||
{t('noStrategyHint', language)}
|
{t('noStrategyHint', language)}
|
||||||
|
|||||||
@@ -91,12 +91,12 @@ export const AI_PROVIDER_CONFIG: Record<string, AIProviderConfig> = {
|
|||||||
apiName: 'Moonshot',
|
apiName: 'Moonshot',
|
||||||
},
|
},
|
||||||
minimax: {
|
minimax: {
|
||||||
defaultModel: 'MiniMax-M2.5',
|
defaultModel: 'MiniMax-M2.7',
|
||||||
apiUrl: 'https://platform.minimax.io',
|
apiUrl: 'https://platform.minimax.io',
|
||||||
apiName: 'MiniMax',
|
apiName: 'MiniMax',
|
||||||
},
|
},
|
||||||
claw402: {
|
claw402: {
|
||||||
defaultModel: 'deepseek',
|
defaultModel: 'glm-5',
|
||||||
apiUrl: 'https://claw402.ai',
|
apiUrl: 'https://claw402.ai',
|
||||||
apiName: 'Claw402',
|
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 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 { reset401Flag, httpClient } from '../lib/httpClient'
|
||||||
|
import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding'
|
||||||
|
import { useLanguage } from './LanguageContext'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,7 +15,8 @@ interface AuthContextType {
|
|||||||
token: string | null
|
token: string | null
|
||||||
login: (
|
login: (
|
||||||
email: string,
|
email: string,
|
||||||
password: string
|
password: string,
|
||||||
|
mode?: UserMode
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
message?: string
|
message?: string
|
||||||
@@ -24,7 +28,8 @@ interface AuthContextType {
|
|||||||
register: (
|
register: (
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
betaCode?: string
|
betaCode?: string,
|
||||||
|
mode?: UserMode
|
||||||
) => Promise<{ success: boolean; message?: string }>
|
) => Promise<{ success: boolean; message?: string }>
|
||||||
resetPassword: (
|
resetPassword: (
|
||||||
email: string,
|
email: string,
|
||||||
@@ -37,6 +42,7 @@ interface AuthContextType {
|
|||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { language } = useLanguage()
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [token, setToken] = useState<string | null>(null)
|
const [token, setToken] = useState<string | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
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 {
|
try {
|
||||||
const response = await fetch('/api/login', {
|
const response = await fetch('/api/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -103,26 +138,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
// Reset 401 flag on successful login
|
|
||||||
reset401Flag()
|
|
||||||
|
|
||||||
const userInfo = { id: data.user_id, email: data.email }
|
const userInfo = { id: data.user_id, email: data.email }
|
||||||
setToken(data.token)
|
handlePostAuthSuccess(data.token, userInfo, mode)
|
||||||
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'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, message: data.message }
|
return { success: true, message: data.message }
|
||||||
}
|
}
|
||||||
@@ -156,10 +173,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
id: data.user_id || 'admin',
|
id: data.user_id || 'admin',
|
||||||
email: data.email || 'admin@localhost',
|
email: data.email || 'admin@localhost',
|
||||||
}
|
}
|
||||||
setToken(data.token)
|
|
||||||
setUser(userInfo)
|
|
||||||
localStorage.setItem('auth_token', data.token)
|
localStorage.setItem('auth_token', data.token)
|
||||||
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
localStorage.setItem('auth_user', JSON.stringify(userInfo))
|
||||||
|
flushSync(() => {
|
||||||
|
setToken(data.token)
|
||||||
|
setUser(userInfo)
|
||||||
|
})
|
||||||
|
|
||||||
// Check and redirect to returnUrl if exists
|
// Check and redirect to returnUrl if exists
|
||||||
const returnUrl = sessionStorage.getItem('returnUrl')
|
const returnUrl = sessionStorage.getItem('returnUrl')
|
||||||
@@ -184,13 +203,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const register = async (
|
const register = async (
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
betaCode?: string
|
betaCode?: string,
|
||||||
|
mode?: UserMode
|
||||||
) => {
|
) => {
|
||||||
const requestBody: {
|
const requestBody: {
|
||||||
email: string
|
email: string
|
||||||
password: string
|
password: string
|
||||||
beta_code?: string
|
beta_code?: string
|
||||||
} = { email, password }
|
lang?: string
|
||||||
|
} = { email, password, lang: language }
|
||||||
if (betaCode) {
|
if (betaCode) {
|
||||||
requestBody.beta_code = betaCode
|
requestBody.beta_code = betaCode
|
||||||
}
|
}
|
||||||
@@ -204,26 +225,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}>('/api/register', requestBody)
|
}>('/api/register', requestBody)
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
// Reset 401 flag on successful login
|
// Clear stale onboarding state so new users always see the welcome flow
|
||||||
reset401Flag()
|
localStorage.removeItem('nofx_beginner_onboarding_completed')
|
||||||
|
localStorage.removeItem('nofx_beginner_wallet_address')
|
||||||
|
|
||||||
const userInfo = { id: result.data.user_id, email: result.data.email }
|
const userInfo = { id: result.data.user_id, email: result.data.email }
|
||||||
setToken(result.data.token)
|
handlePostAuthSuccess(result.data.token, userInfo, mode)
|
||||||
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'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -287,6 +294,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setToken(null)
|
setToken(null)
|
||||||
localStorage.removeItem('auth_token')
|
localStorage.removeItem('auth_token')
|
||||||
localStorage.removeItem('auth_user')
|
localStorage.removeItem('auth_user')
|
||||||
|
invalidateSystemConfig()
|
||||||
|
window.history.pushState({}, '', '/')
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { getSystemConfig, type SystemConfig } from '../lib/config'
|
import { getSystemConfig, invalidateSystemConfig, type SystemConfig } from '../lib/config'
|
||||||
|
|
||||||
export function useSystemConfig() {
|
export function useSystemConfig() {
|
||||||
const [config, setConfig] = useState<SystemConfig | null>(null)
|
const [config, setConfig] = useState<SystemConfig | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [fetchKey, setFetchKey] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true
|
let mounted = true
|
||||||
|
setLoading(true)
|
||||||
getSystemConfig()
|
getSystemConfig()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
@@ -23,7 +25,18 @@ export function useSystemConfig() {
|
|||||||
return () => {
|
return () => {
|
||||||
mounted = false
|
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',
|
registerNow: 'Sign up now',
|
||||||
loginNow: 'Sign in now',
|
loginNow: 'Sign in now',
|
||||||
forgotPassword: 'Forgot password?',
|
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',
|
rememberMe: 'Remember me',
|
||||||
resetPassword: 'Reset Password',
|
resetPassword: 'Reset Password',
|
||||||
resetPasswordTitle: 'Reset your password',
|
resetPasswordTitle: 'Reset your password',
|
||||||
@@ -1080,11 +1083,16 @@ export const translations = {
|
|||||||
public: 'Public',
|
public: 'Public',
|
||||||
addDescription: 'Add strategy description...',
|
addDescription: 'Add strategy description...',
|
||||||
unsaved: 'Unsaved',
|
unsaved: 'Unsaved',
|
||||||
|
discardChanges: 'Discard',
|
||||||
selectOrCreate: 'Select or create a strategy',
|
selectOrCreate: 'Select or create a strategy',
|
||||||
customPromptDesc: 'Extra prompt appended to System Prompt for personalized trading style',
|
customPromptDesc: 'Extra prompt appended to System Prompt for personalized trading style',
|
||||||
customPromptPlaceholder: 'Enter custom prompt...',
|
customPromptPlaceholder: 'Enter custom prompt...',
|
||||||
generatePromptPreview: 'Click to generate prompt preview',
|
generatePromptPreview: 'Click to generate prompt preview',
|
||||||
runAiTestHint: 'Click to run AI test',
|
runAiTestHint: 'Click to run AI test',
|
||||||
|
tokenEstimate: 'Token Estimate',
|
||||||
|
tokenExceedWarning: 'Token estimate exceeds 128K. AI requests may fail for some models.',
|
||||||
|
tokenEstimating: 'Estimating...',
|
||||||
|
tokenTooltip: 'Based on 200K context',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Metric Tooltip
|
// Metric Tooltip
|
||||||
@@ -1162,6 +1170,9 @@ export const translations = {
|
|||||||
close: 'Close',
|
close: 'Close',
|
||||||
showingPositions: 'Showing {shown} of {total} positions',
|
showingPositions: 'Showing {shown} of {total} positions',
|
||||||
perPage: 'Per page',
|
perPage: 'Per page',
|
||||||
|
accountFetchFailed: 'DATA_FETCH::FAILED — Account data unavailable, check connection',
|
||||||
|
positionsFetchFailed: 'Position data unavailable',
|
||||||
|
decisionsFetchFailed: 'Decision data unavailable',
|
||||||
},
|
},
|
||||||
|
|
||||||
// AITradersPage toast messages
|
// AITradersPage toast messages
|
||||||
@@ -1205,8 +1216,13 @@ export const translations = {
|
|||||||
// ModelConfigModal
|
// ModelConfigModal
|
||||||
modelConfig: {
|
modelConfig: {
|
||||||
selectModel: 'Select Model',
|
selectModel: 'Select Model',
|
||||||
|
configure: 'Configure',
|
||||||
configureApi: 'Configure API',
|
configureApi: 'Configure API',
|
||||||
|
configureWallet: 'Configure Wallet',
|
||||||
chooseProvider: 'Choose Your AI Provider',
|
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',
|
payPerCall: 'Pay-per-call USDC · All AI Models · No API Key',
|
||||||
recommended: 'Best',
|
recommended: 'Best',
|
||||||
allModelsClaw: 'Pay-per-call with USDC — supports all major AI models',
|
allModelsClaw: 'Pay-per-call with USDC — supports all major AI models',
|
||||||
@@ -1806,6 +1822,9 @@ export const translations = {
|
|||||||
registerNow: '立即注册',
|
registerNow: '立即注册',
|
||||||
loginNow: '立即登录',
|
loginNow: '立即登录',
|
||||||
forgotPassword: '忘记密码?',
|
forgotPassword: '忘记密码?',
|
||||||
|
forgotAccount: '忘记账户?',
|
||||||
|
forgotAccountConfirm: '这将清除所有账户数据,允许您重新注册新账户。是否继续?',
|
||||||
|
forgotAccountSuccess: '账户已重置!现在可以注册新账户了。',
|
||||||
rememberMe: '记住我',
|
rememberMe: '记住我',
|
||||||
resetPassword: '重置密码',
|
resetPassword: '重置密码',
|
||||||
resetPasswordTitle: '重置您的密码',
|
resetPasswordTitle: '重置您的密码',
|
||||||
@@ -2371,11 +2390,16 @@ export const translations = {
|
|||||||
public: '公开',
|
public: '公开',
|
||||||
addDescription: '添加策略简介...',
|
addDescription: '添加策略简介...',
|
||||||
unsaved: '未保存',
|
unsaved: '未保存',
|
||||||
|
discardChanges: '撤销',
|
||||||
selectOrCreate: '选择或创建策略',
|
selectOrCreate: '选择或创建策略',
|
||||||
customPromptDesc: '附加在 System Prompt 末尾的额外提示,用于补充个性化交易风格',
|
customPromptDesc: '附加在 System Prompt 末尾的额外提示,用于补充个性化交易风格',
|
||||||
customPromptPlaceholder: '输入自定义提示词...',
|
customPromptPlaceholder: '输入自定义提示词...',
|
||||||
generatePromptPreview: '点击生成 Prompt 预览',
|
generatePromptPreview: '点击生成 Prompt 预览',
|
||||||
runAiTestHint: '点击运行 AI 测试',
|
runAiTestHint: '点击运行 AI 测试',
|
||||||
|
tokenEstimate: 'Token 预估',
|
||||||
|
tokenExceedWarning: 'Token 估算超过 128K,部分模型请求可能失败',
|
||||||
|
tokenEstimating: '预估中...',
|
||||||
|
tokenTooltip: '基于 200K 上下文计算',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Metric Tooltip
|
// Metric Tooltip
|
||||||
@@ -2452,6 +2476,9 @@ export const translations = {
|
|||||||
close: '平仓',
|
close: '平仓',
|
||||||
showingPositions: '显示 {shown} / {total} 个持仓',
|
showingPositions: '显示 {shown} / {total} 个持仓',
|
||||||
perPage: '每页',
|
perPage: '每页',
|
||||||
|
accountFetchFailed: 'DATA_FETCH::FAILED — 账户数据请求失败,请检查连接',
|
||||||
|
positionsFetchFailed: '持仓数据请求失败',
|
||||||
|
decisionsFetchFailed: '决策记录请求失败',
|
||||||
},
|
},
|
||||||
|
|
||||||
aiTradersToast: {
|
aiTradersToast: {
|
||||||
@@ -2493,8 +2520,13 @@ export const translations = {
|
|||||||
|
|
||||||
modelConfig: {
|
modelConfig: {
|
||||||
selectModel: '选择模型',
|
selectModel: '选择模型',
|
||||||
|
configure: '配置',
|
||||||
configureApi: '配置 API',
|
configureApi: '配置 API',
|
||||||
|
configureWallet: '配置钱包',
|
||||||
chooseProvider: '选择 AI 模型提供商',
|
chooseProvider: '选择 AI 模型提供商',
|
||||||
|
claw402EntryDesc: '默认推荐走这条路。直接用 Base USDC 按次付费,不需要自己管理 API Key。',
|
||||||
|
otherApiEntry: '其他 API 模型',
|
||||||
|
otherApiEntryDesc: '如果你已经有自己的 OpenAI、Claude、Gemini、DeepSeek 等 API Key,再从这里进入。',
|
||||||
payPerCall: 'USDC 按次付费 · 支持全部 AI 模型 · 无需 API Key',
|
payPerCall: 'USDC 按次付费 · 支持全部 AI 模型 · 无需 API Key',
|
||||||
recommended: '推荐',
|
recommended: '推荐',
|
||||||
allModelsClaw: '用 USDC 按次付费,支持所有主流 AI 模型',
|
allModelsClaw: '用 USDC 按次付费,支持所有主流 AI 模型',
|
||||||
@@ -3054,6 +3086,9 @@ export const translations = {
|
|||||||
registerNow: 'Daftar sekarang',
|
registerNow: 'Daftar sekarang',
|
||||||
loginNow: 'Masuk sekarang',
|
loginNow: 'Masuk sekarang',
|
||||||
forgotPassword: 'Lupa kata sandi?',
|
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',
|
rememberMe: 'Ingat saya',
|
||||||
resetPassword: 'Reset Kata Sandi',
|
resetPassword: 'Reset Kata Sandi',
|
||||||
resetPasswordTitle: 'Reset kata sandi Anda',
|
resetPasswordTitle: 'Reset kata sandi Anda',
|
||||||
@@ -3464,11 +3499,16 @@ export const translations = {
|
|||||||
public: 'Publik',
|
public: 'Publik',
|
||||||
addDescription: 'Tambah deskripsi strategi...',
|
addDescription: 'Tambah deskripsi strategi...',
|
||||||
unsaved: 'Belum Disimpan',
|
unsaved: 'Belum Disimpan',
|
||||||
|
discardChanges: 'Buang',
|
||||||
selectOrCreate: 'Pilih atau buat strategi',
|
selectOrCreate: 'Pilih atau buat strategi',
|
||||||
customPromptDesc: 'Prompt tambahan di akhir System Prompt untuk gaya trading personal',
|
customPromptDesc: 'Prompt tambahan di akhir System Prompt untuk gaya trading personal',
|
||||||
customPromptPlaceholder: 'Masukkan prompt kustom...',
|
customPromptPlaceholder: 'Masukkan prompt kustom...',
|
||||||
generatePromptPreview: 'Klik untuk generate pratinjau prompt',
|
generatePromptPreview: 'Klik untuk generate pratinjau prompt',
|
||||||
runAiTestHint: 'Klik untuk menjalankan uji AI',
|
runAiTestHint: 'Klik untuk menjalankan uji AI',
|
||||||
|
tokenEstimate: 'Estimasi Token',
|
||||||
|
tokenExceedWarning: 'Estimasi token melebihi 128K. Permintaan AI mungkin gagal untuk beberapa model.',
|
||||||
|
tokenEstimating: 'Mengestimasi...',
|
||||||
|
tokenTooltip: 'Berdasarkan konteks 200K',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Metric Tooltip
|
// Metric Tooltip
|
||||||
@@ -3545,6 +3585,9 @@ export const translations = {
|
|||||||
close: 'Tutup',
|
close: 'Tutup',
|
||||||
showingPositions: 'Menampilkan {shown} dari {total} posisi',
|
showingPositions: 'Menampilkan {shown} dari {total} posisi',
|
||||||
perPage: 'Per halaman',
|
perPage: 'Per halaman',
|
||||||
|
accountFetchFailed: 'DATA_FETCH::FAILED — Data akun tidak tersedia, periksa koneksi',
|
||||||
|
positionsFetchFailed: 'Data posisi tidak tersedia',
|
||||||
|
decisionsFetchFailed: 'Data keputusan tidak tersedia',
|
||||||
},
|
},
|
||||||
|
|
||||||
aiTradersToast: {
|
aiTradersToast: {
|
||||||
@@ -3586,8 +3629,13 @@ export const translations = {
|
|||||||
|
|
||||||
modelConfig: {
|
modelConfig: {
|
||||||
selectModel: 'Pilih Model',
|
selectModel: 'Pilih Model',
|
||||||
|
configure: 'Konfigurasi',
|
||||||
configureApi: 'Konfigurasi API',
|
configureApi: 'Konfigurasi API',
|
||||||
|
configureWallet: 'Konfigurasi Wallet',
|
||||||
chooseProvider: 'Pilih Penyedia AI Anda',
|
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',
|
payPerCall: 'Bayar per panggilan USDC · Semua Model AI · Tanpa API Key',
|
||||||
recommended: 'Terbaik',
|
recommended: 'Terbaik',
|
||||||
allModelsClaw: 'Bayar per panggilan dengan USDC — mendukung semua model AI utama',
|
allModelsClaw: 'Bayar per panggilan dengan USDC — mendukung semua model AI utama',
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type {
|
import type {
|
||||||
AIModel,
|
AIModel,
|
||||||
Exchange,
|
Exchange,
|
||||||
|
ExchangeAccountStateResponse,
|
||||||
UpdateModelConfigRequest,
|
UpdateModelConfigRequest,
|
||||||
UpdateExchangeConfigRequest,
|
UpdateExchangeConfigRequest,
|
||||||
CreateExchangeRequest,
|
CreateExchangeRequest,
|
||||||
|
BeginnerOnboardingResponse,
|
||||||
|
CurrentBeginnerWalletResponse,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import { API_BASE, httpClient, CryptoService } from './helpers'
|
import { API_BASE, httpClient, CryptoService } from './helpers'
|
||||||
|
|
||||||
@@ -71,6 +74,16 @@ export const configApi = {
|
|||||||
return result.data!
|
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[]> {
|
async getSupportedExchanges(): Promise<Exchange[]> {
|
||||||
const result = await httpClient.get<Exchange[]>(
|
const result = await httpClient.get<Exchange[]>(
|
||||||
`${API_BASE}/supported-exchanges`
|
`${API_BASE}/supported-exchanges`
|
||||||
@@ -183,4 +196,24 @@ export const configApi = {
|
|||||||
if (!result.success) throw new Error('Failed to fetch server IP')
|
if (!result.success) throw new Error('Failed to fetch server IP')
|
||||||
return result.data!
|
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'
|
import { API_BASE, httpClient } from './helpers'
|
||||||
|
|
||||||
export const dataApi = {
|
export const dataApi = {
|
||||||
async getStatus(traderId?: string): Promise<SystemStatus> {
|
async getStatus(traderId?: string, silent?: boolean): Promise<SystemStatus> {
|
||||||
const url = traderId
|
const url = traderId
|
||||||
? `${API_BASE}/status?trader_id=${traderId}`
|
? `${API_BASE}/status?trader_id=${traderId}`
|
||||||
: `${API_BASE}/status`
|
: `${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')
|
if (!result.success) throw new Error('Failed to fetch system status')
|
||||||
return result.data!
|
return result.data!
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAccount(traderId?: string): Promise<AccountInfo> {
|
async getAccount(traderId?: string, silent?: boolean): Promise<AccountInfo> {
|
||||||
const url = traderId
|
const url = traderId
|
||||||
? `${API_BASE}/account?trader_id=${traderId}`
|
? `${API_BASE}/account?trader_id=${traderId}`
|
||||||
: `${API_BASE}/account`
|
: `${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')
|
if (!result.success) throw new Error('Failed to fetch account info')
|
||||||
return result.data!
|
return result.data!
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPositions(traderId?: string): Promise<Position[]> {
|
async getPositions(traderId?: string, silent?: boolean): Promise<Position[]> {
|
||||||
const url = traderId
|
const url = traderId
|
||||||
? `${API_BASE}/positions?trader_id=${traderId}`
|
? `${API_BASE}/positions?trader_id=${traderId}`
|
||||||
: `${API_BASE}/positions`
|
: `${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')
|
if (!result.success) throw new Error('Failed to fetch positions')
|
||||||
return result.data!
|
return result.data!
|
||||||
},
|
},
|
||||||
@@ -48,7 +48,8 @@ export const dataApi = {
|
|||||||
|
|
||||||
async getLatestDecisions(
|
async getLatestDecisions(
|
||||||
traderId?: string,
|
traderId?: string,
|
||||||
limit: number = 5
|
limit: number = 5,
|
||||||
|
silent?: boolean
|
||||||
): Promise<DecisionRecord[]> {
|
): Promise<DecisionRecord[]> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (traderId) {
|
if (traderId) {
|
||||||
@@ -56,27 +57,28 @@ export const dataApi = {
|
|||||||
}
|
}
|
||||||
params.append('limit', limit.toString())
|
params.append('limit', limit.toString())
|
||||||
|
|
||||||
const result = await httpClient.get<DecisionRecord[]>(
|
const result = await httpClient.request<DecisionRecord[]>(
|
||||||
`${API_BASE}/decisions/latest?${params}`
|
`${API_BASE}/decisions/latest?${params}`,
|
||||||
|
{ silent }
|
||||||
)
|
)
|
||||||
if (!result.success) throw new Error('Failed to fetch latest decisions')
|
if (!result.success) throw new Error('Failed to fetch latest decisions')
|
||||||
return result.data!
|
return result.data!
|
||||||
},
|
},
|
||||||
|
|
||||||
async getStatistics(traderId?: string): Promise<Statistics> {
|
async getStatistics(traderId?: string, silent?: boolean): Promise<Statistics> {
|
||||||
const url = traderId
|
const url = traderId
|
||||||
? `${API_BASE}/statistics?trader_id=${traderId}`
|
? `${API_BASE}/statistics?trader_id=${traderId}`
|
||||||
: `${API_BASE}/statistics`
|
: `${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')
|
if (!result.success) throw new Error('Failed to fetch statistics')
|
||||||
return result.data!
|
return result.data!
|
||||||
},
|
},
|
||||||
|
|
||||||
async getEquityHistory(traderId?: string): Promise<any[]> {
|
async getEquityHistory(traderId?: string, silent?: boolean): Promise<any[]> {
|
||||||
const url = traderId
|
const url = traderId
|
||||||
? `${API_BASE}/equity-history?trader_id=${traderId}`
|
? `${API_BASE}/equity-history?trader_id=${traderId}`
|
||||||
: `${API_BASE}/equity-history`
|
: `${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')
|
if (!result.success) throw new Error('Failed to fetch equity history')
|
||||||
return result.data!
|
return result.data!
|
||||||
},
|
},
|
||||||
@@ -112,9 +114,14 @@ export const dataApi = {
|
|||||||
return result.data!
|
return result.data!
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPositionHistory(traderId: string, limit: number = 100): Promise<PositionHistoryResponse> {
|
async getPositionHistory(
|
||||||
const result = await httpClient.get<PositionHistoryResponse>(
|
traderId: string,
|
||||||
`${API_BASE}/positions/history?trader_id=${traderId}&limit=${limit}`
|
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')
|
if (!result.success) throw new Error('Failed to fetch position history')
|
||||||
return result.data!
|
return result.data!
|
||||||
|
|||||||
@@ -4,10 +4,23 @@ import type {
|
|||||||
CreateTraderRequest,
|
CreateTraderRequest,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import { API_BASE, httpClient } from './helpers'
|
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 = {
|
export const traderApi = {
|
||||||
async getTraders(): Promise<TraderInfo[]> {
|
async getTraders(silent?: boolean): Promise<TraderInfo[]> {
|
||||||
const result = await httpClient.get<TraderInfo[]>(`${API_BASE}/my-traders`)
|
const result = await httpClient.request<TraderInfo[]>(
|
||||||
|
`${API_BASE}/my-traders`,
|
||||||
|
{ silent }
|
||||||
|
)
|
||||||
if (!result.success) throw new Error('Failed to fetch trader list')
|
if (!result.success) throw new Error('Failed to fetch trader list')
|
||||||
return Array.isArray(result.data) ? result.data : []
|
return Array.isArray(result.data) ? result.data : []
|
||||||
},
|
},
|
||||||
@@ -23,7 +36,14 @@ export const traderApi = {
|
|||||||
`${API_BASE}/traders`,
|
`${API_BASE}/traders`,
|
||||||
request
|
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!
|
return result.data!
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -36,7 +56,14 @@ export const traderApi = {
|
|||||||
const result = await httpClient.post(
|
const result = await httpClient.post(
|
||||||
`${API_BASE}/traders/${traderId}/start`
|
`${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> {
|
async stopTrader(traderId: string): Promise<void> {
|
||||||
@@ -88,7 +115,14 @@ export const traderApi = {
|
|||||||
`${API_BASE}/traders/${traderId}`,
|
`${API_BASE}/traders/${traderId}`,
|
||||||
request
|
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!
|
return result.data!
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ export function getSystemConfig(): Promise<SystemConfig> {
|
|||||||
export function invalidateSystemConfig() {
|
export function invalidateSystemConfig() {
|
||||||
cachedConfig = null
|
cachedConfig = null
|
||||||
configPromise = null
|
configPromise = null
|
||||||
|
window.dispatchEvent(new Event('system-config-invalidated'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,28 @@ export interface ApiResponse<T = any> {
|
|||||||
success: boolean
|
success: boolean
|
||||||
data?: T
|
data?: T
|
||||||
message?: string
|
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
|
* Only business errors are returned to caller
|
||||||
*/
|
*/
|
||||||
private async handleError(error: AxiosError): Promise<any> {
|
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)
|
// Network error (no response from server)
|
||||||
if (!error.response) {
|
if (!error.response) {
|
||||||
|
if (!isSilent) {
|
||||||
toast.error('Network error - Please check your connection', {
|
toast.error('Network error - Please check your connection', {
|
||||||
|
id: 'network-error',
|
||||||
description: 'Unable to reach the server',
|
description: 'Unable to reach the server',
|
||||||
})
|
})
|
||||||
|
}
|
||||||
throw new Error('Network error')
|
throw new Error('Network error')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status } = error.response as AxiosResponse<{
|
const status = error.response?.status ?? 0
|
||||||
error?: string
|
|
||||||
message?: string
|
|
||||||
}>
|
|
||||||
|
|
||||||
// Handle 401 Unauthorized
|
// Handle 401 Unauthorized
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
@@ -132,25 +163,37 @@ export class HttpClient {
|
|||||||
|
|
||||||
// Handle 403 Forbidden - system error
|
// Handle 403 Forbidden - system error
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
|
if (!isSilent) {
|
||||||
toast.error('Permission Denied', {
|
toast.error('Permission Denied', {
|
||||||
|
id: 'permission-denied',
|
||||||
description: 'You do not have permission to access this resource',
|
description: 'You do not have permission to access this resource',
|
||||||
})
|
})
|
||||||
|
}
|
||||||
throw new Error('Permission denied')
|
throw new Error('Permission denied')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 404 Not Found - system error
|
// Handle 404 Not Found - system error
|
||||||
if (status === 404) {
|
if (status === 404) {
|
||||||
|
if (!isSilent) {
|
||||||
toast.error('API Not Found', {
|
toast.error('API Not Found', {
|
||||||
|
id: `404-${(error.config as any)?.url || 'unknown'}`,
|
||||||
description: 'The requested endpoint does not exist (404)',
|
description: 'The requested endpoint does not exist (404)',
|
||||||
})
|
})
|
||||||
|
}
|
||||||
throw new Error('API not found')
|
throw new Error('API not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 500+ Server Error - system error
|
// Handle 500+ Server Error - system error
|
||||||
if (status >= 500) {
|
if (status >= 500) {
|
||||||
|
if (serverMessage) {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
if (!isSilent) {
|
||||||
toast.error('Server Error', {
|
toast.error('Server Error', {
|
||||||
|
id: 'server-error',
|
||||||
description: 'Please try again later or contact support',
|
description: 'Please try again later or contact support',
|
||||||
})
|
})
|
||||||
|
}
|
||||||
throw new Error('Server error')
|
throw new Error('Server error')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +214,7 @@ export class HttpClient {
|
|||||||
data?: any
|
data?: any
|
||||||
params?: any
|
params?: any
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
|
silent?: boolean
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
try {
|
try {
|
||||||
@@ -180,6 +224,7 @@ export class HttpClient {
|
|||||||
data: options.data,
|
data: options.data,
|
||||||
params: options.params,
|
params: options.params,
|
||||||
headers: options.headers,
|
headers: options.headers,
|
||||||
|
...(options.silent && { silentError: true }),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Success
|
// Success
|
||||||
@@ -196,6 +241,9 @@ export class HttpClient {
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: errorData?.error || errorData?.message || 'Operation failed',
|
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 { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
|
import {
|
||||||
|
getPostAuthPath,
|
||||||
|
getUserMode,
|
||||||
|
setUserMode,
|
||||||
|
type UserMode,
|
||||||
|
} from '../lib/onboarding'
|
||||||
import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal'
|
import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal'
|
||||||
import { TelegramConfigModal } from '../components/trader/TelegramConfigModal'
|
import { TelegramConfigModal } from '../components/trader/TelegramConfigModal'
|
||||||
import { ModelConfigModal } from '../components/trader/ModelConfigModal'
|
import { ModelConfigModal } from '../components/trader/ModelConfigModal'
|
||||||
@@ -15,6 +21,7 @@ export function SettingsPage() {
|
|||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('account')
|
const [activeTab, setActiveTab] = useState<Tab>('account')
|
||||||
|
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
|
||||||
|
|
||||||
// Account state
|
// Account state
|
||||||
const [newPassword, setNewPassword] = useState('')
|
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 (
|
const handleSaveModel = async (
|
||||||
modelId: string,
|
modelId: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
@@ -281,6 +308,66 @@ export function SettingsPage() {
|
|||||||
<p className="text-sm text-white font-medium">{user?.email}</p>
|
<p className="text-sm text-white font-medium">{user?.email}</p>
|
||||||
</div>
|
</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">
|
<div className="border-t border-zinc-800 pt-6">
|
||||||
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
|
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
|
||||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
|
|||||||
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
|
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
|
||||||
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
|
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
|
||||||
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
|
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
|
||||||
|
import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar'
|
||||||
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
||||||
import { t } from '../i18n/translations'
|
import { t } from '../i18n/translations'
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export function StrategyStudioPage() {
|
|||||||
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
|
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [estimatedTokens, setEstimatedTokens] = useState(0)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [hasChanges, setHasChanges] = useState(false)
|
const [hasChanges, setHasChanges] = useState(false)
|
||||||
|
|
||||||
@@ -247,6 +249,24 @@ export function StrategyStudioPage() {
|
|||||||
const handleDeleteStrategy = async (id: string) => {
|
const handleDeleteStrategy = async (id: string) => {
|
||||||
if (!token) return
|
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(
|
const confirmed = await confirmToast(
|
||||||
tr('confirmDeleteStrategy'),
|
tr('confirmDeleteStrategy'),
|
||||||
{
|
{
|
||||||
@@ -262,9 +282,12 @@ export function StrategyStudioPage() {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
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'))
|
notify.success(tr('strategyDeleted'))
|
||||||
// Clear selection if deleted strategy was selected
|
|
||||||
if (selectedStrategy?.id === id) {
|
if (selectedStrategy?.id === id) {
|
||||||
setSelectedStrategy(null)
|
setSelectedStrategy(null)
|
||||||
setEditingConfig(null)
|
setEditingConfig(null)
|
||||||
@@ -272,9 +295,7 @@ export function StrategyStudioPage() {
|
|||||||
}
|
}
|
||||||
await fetchStrategies()
|
await fetchStrategies()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMsg = err instanceof Error ? err.message : 'Unknown error'
|
notify.error(err instanceof Error ? err.message : 'Unknown error')
|
||||||
setError(errorMsg)
|
|
||||||
notify.error(errorMsg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +399,10 @@ export function StrategyStudioPage() {
|
|||||||
// Save strategy
|
// Save strategy
|
||||||
const handleSaveStrategy = async () => {
|
const handleSaveStrategy = async () => {
|
||||||
if (!token || !selectedStrategy || !editingConfig) return
|
if (!token || !selectedStrategy || !editingConfig) return
|
||||||
|
if (estimatedTokens >= 128000 && currentStrategyType === 'ai_trading') {
|
||||||
|
notify.warning(tr('tokenExceedWarning'))
|
||||||
|
// continue with save
|
||||||
|
}
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
// Always sync the config language with the current interface language
|
// Always sync the config language with the current interface language
|
||||||
@@ -681,7 +706,7 @@ export function StrategyStudioPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
{strategies.map((strategy) => (
|
{strategies.map((strategy) => (
|
||||||
<div
|
<div
|
||||||
key={strategy.id}
|
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
|
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)]'
|
? '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">
|
<div className="flex items-start justify-between">
|
||||||
<span className="text-sm truncate text-nofx-text">{strategy.name}</span>
|
<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">
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleExportStrategy(strategy) }}
|
onClick={(e) => { e.stopPropagation(); handleExportStrategy(strategy) }}
|
||||||
@@ -807,6 +832,13 @@ export function StrategyStudioPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Token Estimate Bar */}
|
||||||
|
{currentStrategyType === 'ai_trading' && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<TokenEstimateBar config={editingConfig} language={language} onTokenCountChange={setEstimatedTokens} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Strategy Type Selector */}
|
{/* Strategy Type Selector */}
|
||||||
{editingConfig && (
|
{editingConfig && (
|
||||||
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">
|
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { formatPrice, formatQuantity } from '../utils/format'
|
|||||||
import { t, type Language } from '../i18n/translations'
|
import { t, type Language } from '../i18n/translations'
|
||||||
import { LogOut, Loader2, Eye, EyeOff, Copy, Check } from 'lucide-react'
|
import { LogOut, Loader2, Eye, EyeOff, Copy, Check } from 'lucide-react'
|
||||||
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
|
||||||
|
import { NofxSelect } from '../components/ui/select'
|
||||||
import { GridRiskPanel } from '../components/strategy/GridRiskPanel'
|
import { GridRiskPanel } from '../components/strategy/GridRiskPanel'
|
||||||
import type {
|
import type {
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
@@ -102,8 +103,11 @@ interface TraderDashboardPageProps {
|
|||||||
onNavigateToTraders: () => void
|
onNavigateToTraders: () => void
|
||||||
status?: SystemStatus
|
status?: SystemStatus
|
||||||
account?: AccountInfo
|
account?: AccountInfo
|
||||||
|
accountFailed?: boolean
|
||||||
positions?: Position[]
|
positions?: Position[]
|
||||||
|
positionsFailed?: boolean
|
||||||
decisions?: DecisionRecord[]
|
decisions?: DecisionRecord[]
|
||||||
|
decisionsFailed?: boolean
|
||||||
decisionsLimit: number
|
decisionsLimit: number
|
||||||
onDecisionsLimitChange: (limit: number) => void
|
onDecisionsLimitChange: (limit: number) => void
|
||||||
stats?: Statistics
|
stats?: Statistics
|
||||||
@@ -116,8 +120,11 @@ export function TraderDashboardPage({
|
|||||||
selectedTrader,
|
selectedTrader,
|
||||||
status,
|
status,
|
||||||
account,
|
account,
|
||||||
|
accountFailed,
|
||||||
positions,
|
positions,
|
||||||
|
positionsFailed,
|
||||||
decisions,
|
decisions,
|
||||||
|
decisionsFailed,
|
||||||
decisionsLimit,
|
decisionsLimit,
|
||||||
onDecisionsLimitChange,
|
onDecisionsLimitChange,
|
||||||
lastUpdate,
|
lastUpdate,
|
||||||
@@ -376,17 +383,12 @@ export function TraderDashboardPage({
|
|||||||
{/* Trader Selector */}
|
{/* Trader Selector */}
|
||||||
{traders && traders.length > 0 && (
|
{traders && traders.length > 0 && (
|
||||||
<div className="flex items-center gap-2 nofx-glass px-1 py-1 rounded-lg border border-white/5">
|
<div className="flex items-center gap-2 nofx-glass px-1 py-1 rounded-lg border border-white/5">
|
||||||
<select
|
<NofxSelect
|
||||||
value={selectedTraderId}
|
value={selectedTraderId || ''}
|
||||||
onChange={(e) => onTraderSelect(e.target.value)}
|
onChange={(val) => onTraderSelect(val)}
|
||||||
className="bg-transparent text-sm font-medium cursor-pointer transition-colors text-nofx-text-main focus:outline-none px-2 py-1"
|
options={traders.map(t => ({ value: t.trader_id, label: t.trader_name }))}
|
||||||
>
|
className="bg-transparent text-sm font-medium cursor-pointer transition-colors text-nofx-text-main px-2 py-1"
|
||||||
{traders.map((trader) => (
|
/>
|
||||||
<option key={trader.trader_id} value={trader.trader_id} className="bg-[#0B0E11]">
|
|
||||||
{trader.trader_name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -484,48 +486,60 @@ export function TraderDashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Debug Info */}
|
{/* Debug Info */}
|
||||||
{account && (
|
|
||||||
<div className="mb-4 px-3 py-1.5 rounded bg-black/40 border border-white/5 text-[10px] font-mono text-nofx-text-muted flex justify-between items-center opacity-60 hover:opacity-100 transition-opacity">
|
<div className="mb-4 px-3 py-1.5 rounded bg-black/40 border border-white/5 text-[10px] font-mono text-nofx-text-muted flex justify-between items-center opacity-60 hover:opacity-100 transition-opacity">
|
||||||
<span>SYSTEM_STATUS::ONLINE</span>
|
<span style={{ color: '#0ECB81' }}>SYSTEM_STATUS::ONLINE</span>
|
||||||
|
{account ? (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<span>LAST_UPDATE::{lastUpdate}</span>
|
<span>LAST_UPDATE::{lastUpdate}</span>
|
||||||
<span>EQ::{account?.total_equity?.toFixed(2)}</span>
|
<span>EQ::{account.total_equity?.toFixed(2)}</span>
|
||||||
<span>PNL::{account?.total_pnl?.toFixed(2)}</span>
|
<span>PNL::{account.total_pnl?.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : 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>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Account Overview */}
|
{/* Account Overview */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t('totalEquity', language)}
|
title={t('totalEquity', language)}
|
||||||
value={`${account?.total_equity?.toFixed(2) || '0.00'}`}
|
value={accountFailed && !account ? '--' : `${account?.total_equity?.toFixed(2) ?? '--'}`}
|
||||||
unit="USDT"
|
unit="USDT"
|
||||||
change={account?.total_pnl_pct || 0}
|
change={account ? (account.total_pnl_pct || 0) : undefined}
|
||||||
positive={(account?.total_pnl ?? 0) > 0}
|
positive={(account?.total_pnl ?? 0) > 0}
|
||||||
icon="💰"
|
icon="💰"
|
||||||
|
loading={!account && !accountFailed}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t('availableBalance', language)}
|
title={t('availableBalance', language)}
|
||||||
value={`${account?.available_balance?.toFixed(2) || '0.00'}`}
|
value={accountFailed && !account ? '--' : `${account?.available_balance?.toFixed(2) ?? '--'}`}
|
||||||
unit="USDT"
|
unit="USDT"
|
||||||
subtitle={`${account?.available_balance && account?.total_equity ? ((account.available_balance / account.total_equity) * 100).toFixed(1) : '0.0'}% ${t('free', language)}`}
|
subtitle={accountFailed && !account ? '--' : `${account?.available_balance && account?.total_equity ? ((account.available_balance / account.total_equity) * 100).toFixed(1) : '--'}% ${t('free', language)}`}
|
||||||
icon="💳"
|
icon="💳"
|
||||||
|
loading={!account && !accountFailed}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t('totalPnL', language)}
|
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"
|
unit="USDT"
|
||||||
change={account?.total_pnl_pct || 0}
|
change={account ? (account.total_pnl_pct || 0) : undefined}
|
||||||
positive={(account?.total_pnl ?? 0) >= 0}
|
positive={(account?.total_pnl ?? 0) >= 0}
|
||||||
icon="📈"
|
icon="📈"
|
||||||
|
loading={!account && !accountFailed}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t('positions', language)}
|
title={t('positions', language)}
|
||||||
value={`${account?.position_count || 0}`}
|
value={accountFailed && !account ? '--' : `${account?.position_count ?? '--'}`}
|
||||||
unit="ACTIVE"
|
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="📊"
|
icon="📊"
|
||||||
|
loading={!account && !accountFailed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -671,15 +685,12 @@ export function TraderDashboardPage({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{t('traderDashboard.perPage', language)}:</span>
|
<span>{t('traderDashboard.perPage', language)}:</span>
|
||||||
<select
|
<NofxSelect
|
||||||
value={positionsPageSize}
|
value={positionsPageSize}
|
||||||
onChange={(e) => setPositionsPageSize(Number(e.target.value))}
|
onChange={(val) => setPositionsPageSize(Number(val))}
|
||||||
className="bg-black/40 border border-white/10 rounded px-2 py-1 text-xs text-nofx-text-main focus:outline-none focus:border-nofx-gold/50 transition-colors"
|
options={[{ value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]}
|
||||||
>
|
className="bg-black/40 border border-white/10 rounded px-2 py-1 text-xs text-nofx-text-main transition-colors"
|
||||||
<option value={20}>20</option>
|
/>
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
{totalPositionPages > 1 && (
|
{totalPositionPages > 1 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -716,6 +727,11 @@ export function TraderDashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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-center py-16 text-nofx-text-muted opacity-60">
|
||||||
<div className="text-6xl mb-4 opacity-50 grayscale">📊</div>
|
<div className="text-6xl mb-4 opacity-50 grayscale">📊</div>
|
||||||
@@ -752,17 +768,12 @@ export function TraderDashboardPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Limit Selector */}
|
{/* Limit Selector */}
|
||||||
<select
|
<NofxSelect
|
||||||
value={decisionsLimit}
|
value={decisionsLimit}
|
||||||
onChange={(e) => onDecisionsLimitChange(Number(e.target.value))}
|
onChange={(val) => onDecisionsLimitChange(Number(val))}
|
||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer transition-all bg-black/40 text-nofx-text-main border border-white/10 hover:border-nofx-accent focus:outline-none"
|
options={[{ value: 5, label: '5' }, { value: 10, label: '10' }, { value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]}
|
||||||
>
|
className="px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer transition-all bg-black/40 text-nofx-text-main border border-white/10 hover:border-nofx-accent"
|
||||||
<option value={5}>5</option>
|
/>
|
||||||
<option value={10}>10</option>
|
|
||||||
<option value={20}>20</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Decisions List - Scrollable */}
|
{/* Decisions List - Scrollable */}
|
||||||
@@ -774,6 +785,11 @@ export function TraderDashboardPage({
|
|||||||
decisions.map((decision, i) => (
|
decisions.map((decision, i) => (
|
||||||
<DecisionCard key={i} decision={decision} language={language} onSymbolClick={handleSymbolClick} />
|
<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="py-16 text-center text-nofx-text-muted opacity-60">
|
||||||
<div className="text-6xl mb-4 opacity-30 grayscale">🧠</div>
|
<div className="text-6xl mb-4 opacity-30 grayscale">🧠</div>
|
||||||
@@ -818,6 +834,7 @@ function StatCard({
|
|||||||
positive,
|
positive,
|
||||||
subtitle,
|
subtitle,
|
||||||
icon,
|
icon,
|
||||||
|
loading,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
value: string
|
value: string
|
||||||
@@ -826,6 +843,7 @@ function StatCard({
|
|||||||
positive?: boolean
|
positive?: boolean
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
|
loading?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="group nofx-glass p-5 rounded-lg transition-all duration-300 hover:bg-white/5 hover:translate-y-[-2px] border border-white/5 hover:border-nofx-gold/20 relative overflow-hidden">
|
<div className="group nofx-glass p-5 rounded-lg transition-all duration-300 hover:bg-white/5 hover:translate-y-[-2px] border border-white/5 hover:border-nofx-gold/20 relative overflow-hidden">
|
||||||
@@ -835,13 +853,19 @@ function StatCard({
|
|||||||
<div className="text-xs mb-2 font-mono uppercase tracking-wider text-nofx-text-muted flex items-center gap-2">
|
<div className="text-xs mb-2 font-mono uppercase tracking-wider text-nofx-text-muted flex items-center gap-2">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex items-baseline gap-1 mb-1">
|
<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">
|
<div className="text-2xl font-bold font-mono text-nofx-text-main tracking-tight group-hover:text-white transition-colors">
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
{unit && <span className="text-xs font-mono text-nofx-text-muted opacity-60">{unit}</span>}
|
{unit && <span className="text-xs font-mono text-nofx-text-muted opacity-60">{unit}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{change !== undefined && (
|
{change !== undefined && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div
|
<div
|
||||||
@@ -857,6 +881,8 @@ function StatCard({
|
|||||||
{subtitle}
|
{subtitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface AIModel {
|
|||||||
apiKey?: string
|
apiKey?: string
|
||||||
customApiUrl?: string
|
customApiUrl?: string
|
||||||
customModelName?: string
|
customModelName?: string
|
||||||
|
walletAddress?: string
|
||||||
|
balanceUsdc?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TelegramConfig {
|
export interface TelegramConfig {
|
||||||
@@ -39,6 +41,30 @@ export interface Exchange {
|
|||||||
lighterApiKeyIndex?: number
|
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 {
|
export interface CreateExchangeRequest {
|
||||||
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||||
account_name: string // User-defined account name
|
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
|
ai_model: string
|
||||||
exchange_id?: string
|
exchange_id?: string
|
||||||
is_running?: boolean
|
is_running?: boolean
|
||||||
|
startup_warning?: string
|
||||||
show_in_competition?: boolean
|
show_in_competition?: boolean
|
||||||
strategy_id?: string
|
strategy_id?: string
|
||||||
strategy_name?: string
|
strategy_name?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user