31 Commits

Author SHA1 Message Date
shinchan-zhai
0537ff3961 feat: forgot account reset flow + frontend default model fix to GLM
- Add forgot account reset flow with wallet preservation
- Update frontend default model references from DeepSeek to GLM
- Add reset-account API endpoint
- Add orphan record adoption for wallet/exchange preservation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:30:42 +08:00
deanokk
a99718ac60 fix(dashboard): preserve trader selection in URL and silence background requests (#1459)
* refactor: streamline trader selection logic and URL handling in App component

* refactor: update API request handling across components to use silent mode for improved error management

---------

Co-authored-by: Dean <afei.wuhao@gmail.com>
2026-04-04 23:39:05 +08:00
shinchan-zhai
b7635b0238 feat: change claw402 default model from deepseek to glm-5 2026-04-04 23:39:05 +08:00
Zavier
d353d8aed9 fix: improve trader error feedback, stale balance cleanup, and claw402 warnings (#1452)
* fix: improve trader error handling and balance validation

* fix: localize structured trader failure reasons

---------

Co-authored-by: apple <apple@MacbookPro-zbh.local>
2026-04-04 23:39:05 +08:00
deanokk
085ae3c875 feat: add exchange account states and refine beginner trader creation flow (#1450)
* feat: implement exchange account state management and UI updates

- Added functionality to invalidate exchange account state cache on exchange config updates, creation, and deletion.
- Introduced new API endpoint to fetch exchange account states.
- Updated frontend components to display exchange account states, including status and balance information.
- Enhanced user experience by refreshing exchange account states after relevant actions.

* feat: enhance trader creation readiness in AITradersPage and BeginnerGuideCards

---------

Co-authored-by: Dean <afei.wuhao@gmail.com>
2026-04-04 23:39:05 +08:00
shinchan-zhai
649ef50e44 docs: add MiniMax to AI models and beginner mode to setup across all i18n READMEs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:39:05 +08:00
Zavier
47fb1c4675 perf: reduce frontend login and dashboard friction (#1447)
Co-authored-by: apple <apple@MacbookPro-zbh.local>
2026-04-04 23:39:05 +08:00
shinchan-zhai
fa048b44ac fix: auto re-fetch system config after invalidation
- invalidateSystemConfig() now dispatches a custom event
- useSystemConfig() listens for the event and re-fetches automatically
- Fixes stale initialized=false after register/logout causing
  incorrect redirect to SetupPage
2026-04-04 23:39:05 +08:00
shinchan-zhai
620eca08ec fix: clean stale auth state on login/setup, unify language switcher
- LoginPage/SetupPage: clear localStorage auth tokens on mount
- AuthContext: clear onboarding state on register, invalidate config on logout
- Extract shared LanguageSwitcher component for consistent UI
- Merge duplicate config import in AuthContext
2026-04-04 23:39:05 +08:00
deanokk
d7461c739a feat(beginner): protect default AI model and prevent repeated onboarding (#1444)
Co-authored-by: Dean <afei.wuhao@gmail.com>
2026-04-04 23:39:05 +08:00
shinchan-zhai
796606a8a8 fix: division by zero guard, logout redirect, onboarding close button
- auto_trader_risk: skip drawdown check when entryPrice <= 0
- AuthContext: redirect to / on logout
- App.tsx: simplify data page navigation
- BeginnerOnboardingPage: add close button to overlay
2026-04-04 23:39:05 +08:00
Zavier
4173c7678a feat: refine beginner wallet onboarding modal (#1438)
Co-authored-by: Codex <codex@openai.com>
2026-04-04 23:39:05 +08:00
shinchan-zhai
886650cc0e fix: guard short trader ID, i18n setup page, simplify onboarding UX
- main.go: prevent panic when trader ID < 8 chars
- SetupPage: add zh/en i18n labels
- BeginnerOnboardingPage: show private key by default, simplify code
2026-04-04 23:39:05 +08:00
shinchan-zhai
7ea813a46b fix(deps): resolve 11 npm vulnerabilities in frontend dependencies
Update react-router, rollup, picomatch, and yaml to patched versions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:39:05 +08:00
Dean
9a64d0f485 docs: update token estimation values for candidate coins in Chinese documentation 2026-04-04 23:39:05 +08:00
Dean
07fbe6d053 fix: update token limits and error handling in Trader Dashboard 2026-04-04 23:39:05 +08:00
Dean
c50b964fd1 fix: reduce candidate coin limit to 10, fix Select scroll and flash
- Lower MaxCandidateCoins from 50 to 10 (backend)
- Update CoinSourceEditor: options 1-10, default 3, max static coins 10
- Fix NofxSelect dropdown closing on internal scroll
- Fix NofxSelect position flash on open (useLayoutEffect)
2026-04-04 23:39:05 +08:00
Dean
a8057f6e9e style: apply gofmt to api/strategy.go and store/strategy.go 2026-04-04 23:39:05 +08:00
Dean
8c6ca75e93 fix: update error handling for account data fetch on Trader Dashboard 2026-04-04 23:39:05 +08:00
Dean
b9f6a32ad6 feat: localize default strategy names by UI language at registration
- Pass `lang` from register request body to createDefaultStrategies
- Support zh/en/id locales for strategy names and descriptions
- Wrap strategy creation in a transaction to prevent partial writes
- Frontend sends current UI language in register request body
- Strategy list UI: 2-line clamp, unselected border, larger spacing, smaller font for non-zh
2026-04-04 23:39:05 +08:00
Dean
eae05c5715 docs: add token estimation analysis for candidate coin limits 2026-04-04 23:39:05 +08:00
Dean
d6e3088998 fix: show -- instead of 0 when account data fetch fails on dashboard
Replace zero-value fallback with undefined, pass accountFailed prop to
distinguish load failure from initial loading skeleton.
2026-04-04 23:39:05 +08:00
Dean
4a483eaca6 feat: implement default strategy creation for new users 2026-04-04 23:39:05 +08:00
Dean
655b49450e feat: enhance token estimation and context limit handling in strategy configurations 2026-04-04 23:39:05 +08:00
Dean
85e1f7f963 feat: enhance strategy deletion process with user feedback and validation checks 2026-04-04 23:39:05 +08:00
Zavier
b9a33ce809 feat: improve user onboarding and setup UX (#1436)
* feat: add beginner onboarding and mode switching flow

* chore: ignore local gh auth config

* fix: restore kline fallback and align onboarding language

---------

Co-authored-by: zavier-bin <zhaobbbhhh@gmail.com>
2026-04-04 23:39:05 +08:00
shinchan-zhai
39b05b1a09 fix: fallback to Binance kline when coinank returns empty data for non-Binance exchanges
CoinAnk recently stopped providing free kline data for OKX/Bitget/Gate
exchanges (returns success but empty array). This caused '3-minute
k-line data is empty' errors for all users on those exchanges.

Fix: detect empty kline response and automatically fallback to Binance
kline data, which is always available.
2026-04-04 23:39:05 +08:00
deanokk
79a3be1874 fix: prevent DeepSeek token overflow with product-level limits (#1431)
* feat: enforce strategy limits to prevent token overflow

* fix: tune token limits after real-world testing

- Relax kline max 20→30, timeframes 3→4 (tested ~41K tokens, safe under 131K)
- Restore ranking limits to original [5,10,15,20] options (only ~1.5K token impact)
- Add static coins limit (max 3) with toast notification
- Add timeframe limit toast when exceeding 4
- Log SSE token usage (prompt/completion/total) from API response
- Fix nil logger crash in claw402 data client (engine.go)

* feat: add token estimation functionality for strategy configurations

* feat: add discard changes button in Strategy Studio for unsaved modifications

* feat: retain selected strategy after saving in Strategy Studio

* feat: enhance strategy display in Strategy Studio with improved layout and sorting of token limits

* refactor: improve layout and styling of stats display in CompetitionPage

* refactor: replace select elements with NofxSelect component for improved consistency in strategy configuration forms

* style: update NofxSelect component to use smaller text size for improved readability

* feat: implement token overflow handling in strategy updates and UI

---------

Co-authored-by: Dean <afei.wuhao@gmail.com>
2026-04-04 23:39:05 +08:00
shinchan-zhai
b705810ec2 feat: auto-reuse claw402 wallet for nofxos data — no extra config needed
When a trader uses claw402 as AI provider, the same wallet private key
is now automatically used to route nofxos data API calls (AI500, OI,
NetFlow, etc.) through claw402 payment as well.

Users don't need to configure anything extra — if they already set up
claw402 for AI, data APIs automatically go through claw402 too.
2026-04-04 23:39:05 +08:00
shinchan-zhai
a942c5312f feat: route nofxos data API calls through claw402 x402 payment
When CLAW402_WALLET_KEY env var is set, all nofxos.ai data API calls
(AI500, OI rankings, NetFlow, price rankings) are automatically routed
through claw402.ai with x402 USDC micropayment.

- provider/nofxos/claw402.go: x402 GET request client for data APIs
- provider/nofxos/client.go: claw402 mode support in doRequest()
- kernel/engine.go: auto-detect CLAW402_WALLET_KEY and enable routing
- mcp/payment/x402.go: MakeClaw402SignFunc helper

Without CLAW402_WALLET_KEY, falls back to direct nofxos.ai (backward compat).
2026-04-04 23:39:04 +08:00
Hansen1018
c18d3d5682 feat: update default MiniMax model to M2.7 (#1428) 2026-04-04 23:39:04 +08:00
80 changed files with 5223 additions and 1065 deletions

2
.gitignore vendored
View File

@@ -27,6 +27,8 @@ Thumbs.db
*.tmp
*.bak
*.backup
.cache/
.gh-config/
# 环境变量
.env

View File

@@ -69,7 +69,7 @@ No accounts. No API keys. No prepaid credits. One wallet, every model.
| Feature | Description |
|:--------|:------------|
| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — switch anytime |
| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — switch anytime |
| **Multi-Exchange** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **Strategy Studio** | Visual builder — coin sources, indicators, risk controls |
| **AI Competition** | AIs compete in real-time, leaderboard ranks performance |
@@ -110,6 +110,7 @@ Crypto · US Stocks · Forex · Metals
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
| <img src="web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Get API Key](https://platform.minimaxi.com) |
### AI Models (x402 Mode — No API Key)
@@ -211,6 +212,10 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
## Setup
**Beginner mode**: First-time users get a guided onboarding flow — select beginner mode at registration and the system walks you through AI, exchange, and strategy setup step by step.
**Advanced mode**:
1. **AI** — Add API keys or configure x402 wallet
2. **Exchange** — Connect exchange API credentials
3. **Strategy** — Build in Strategy Studio

View File

@@ -8,6 +8,25 @@ import (
"nofx/logger"
)
type APIErrorResponse struct {
Error string `json:"error"`
ErrorKey string `json:"error_key,omitempty"`
ErrorParams map[string]string `json:"error_params,omitempty"`
}
func writeAPIError(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string) {
resp := APIErrorResponse{
Error: publicMsg,
}
if errorKey != "" {
resp.ErrorKey = errorKey
}
if len(errorParams) > 0 {
resp.ErrorParams = errorParams
}
c.JSON(statusCode, resp)
}
// SafeError returns a safe error message without exposing internal details
// It logs the actual error for debugging but returns a generic message to the client
func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr error) {
@@ -16,34 +35,46 @@ func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr err
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
}
c.JSON(statusCode, gin.H{"error": publicMsg})
writeAPIError(c, statusCode, publicMsg, "", nil)
}
func SafeErrorWithDetails(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string, internalErr error) {
if internalErr != nil {
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
}
writeAPIError(c, statusCode, publicMsg, errorKey, errorParams)
}
// SafeInternalError logs internal error and returns a generic message
func SafeInternalError(c *gin.Context, operation string, err error) {
logger.Errorf("[Internal Error] %s: %v", operation, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": operation + " failed"})
writeAPIError(c, http.StatusInternalServerError, operation+" failed", "", nil)
}
// SafeBadRequest returns a safe bad request error
// For validation errors, we can be more specific since they're about user input
func SafeBadRequest(c *gin.Context, msg string) {
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
writeAPIError(c, http.StatusBadRequest, msg, "", nil)
}
func SafeBadRequestWithDetails(c *gin.Context, msg, errorKey string, errorParams map[string]string) {
writeAPIError(c, http.StatusBadRequest, msg, errorKey, errorParams)
}
// SafeNotFound returns a generic not found error
func SafeNotFound(c *gin.Context, resource string) {
c.JSON(http.StatusNotFound, gin.H{"error": resource + " not found"})
writeAPIError(c, http.StatusNotFound, resource+" not found", "", nil)
}
// SafeUnauthorized returns unauthorized error
func SafeUnauthorized(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
writeAPIError(c, http.StatusUnauthorized, "Unauthorized", "", nil)
}
// SafeForbidden returns forbidden error
func SafeForbidden(c *gin.Context, msg string) {
c.JSON(http.StatusForbidden, gin.H{"error": msg})
writeAPIError(c, http.StatusForbidden, msg, "", nil)
}
// IsSensitiveError checks if an error message contains sensitive information

View 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] + "..."
}

View File

@@ -10,6 +10,7 @@ import (
"nofx/crypto"
"nofx/logger"
"nofx/security"
"nofx/wallet"
"github.com/gin-gonic/gin"
)
@@ -31,6 +32,8 @@ type SafeModelConfig struct {
Enabled bool `json:"enabled"`
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
WalletAddress string `json:"walletAddress,omitempty"`
BalanceUSDC string `json:"balanceUsdc,omitempty"`
}
type UpdateModelConfigRequest struct {
@@ -75,7 +78,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
// Convert to safe response structure, remove sensitive information
safeModels := make([]SafeModelConfig, len(models))
for i, model := range models {
safeModels[i] = SafeModelConfig{
safeModel := SafeModelConfig{
ID: model.ID,
Name: model.Name,
Provider: model.Provider,
@@ -83,6 +86,19 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
CustomAPIURL: model.CustomAPIURL,
CustomModelName: model.CustomModelName,
}
if model.Provider == "claw402" {
if privateKey := strings.TrimSpace(model.APIKey.String()); privateKey != "" {
if walletAddress, addrErr := walletAddressFromPrivateKey(privateKey); addrErr == nil {
safeModel.WalletAddress = walletAddress
safeModel.BalanceUSDC = wallet.QueryUSDCBalanceStr(walletAddress)
} else {
logger.Warnf("⚠️ Failed to derive claw402 wallet address for model %s: %v", model.ID, addrErr)
}
}
}
safeModels[i] = safeModel
}
c.JSON(http.StatusOK, safeModels)
@@ -201,8 +217,8 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.5"},
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek"},
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "glm-5"},
}
c.JSON(http.StatusOK, supportedModels)

View File

@@ -192,6 +192,8 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
}
}
s.exchangeAccountStateCache.Invalidate(userID)
// Remove affected traders from memory BEFORE reloading to pick up new config
for traderID := range tradersToReload {
logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID)
@@ -284,6 +286,8 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
return
}
s.exchangeAccountStateCache.Invalidate(userID)
logger.Infof("✓ Created exchange account: type=%s, name=%s, id=%s", req.ExchangeType, req.AccountName, id)
c.JSON(http.StatusOK, gin.H{
"message": "Exchange account created",
@@ -327,6 +331,8 @@ func (s *Server) handleDeleteExchange(c *gin.Context) {
return
}
s.exchangeAccountStateCache.Invalidate(userID)
logger.Infof("✓ Deleted exchange account: id=%s", exchangeID)
c.JSON(http.StatusOK, gin.H{"message": "Exchange account deleted"})
}

343
api/handler_onboarding.go Normal file
View 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
}

View File

@@ -1,6 +1,7 @@
package api
import (
"errors"
"fmt"
"net/http"
"strings"
@@ -8,18 +9,9 @@ import (
"nofx/logger"
"nofx/store"
"nofx/trader"
"nofx/trader/aster"
"nofx/trader/binance"
"nofx/trader/bitget"
"nofx/trader/bybit"
"nofx/trader/gate"
hyperliquidtrader "nofx/trader/hyperliquid"
"nofx/trader/kucoin"
"nofx/trader/lighter"
"nofx/trader/okx"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AI trader management related structures
@@ -62,22 +54,265 @@ type UpdateTraderRequest struct {
SystemPromptTemplate string `json:"system_prompt_template"`
}
func formatTraderCreationError(reason, nextStep string) string {
if nextStep == "" {
return fmt.Sprintf("这次未能创建机器人:%s。", reason)
}
return fmt.Sprintf("这次未能创建机器人:%s。%s。", reason, nextStep)
}
func traderCreationRequestError(reason string) string {
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
}
func exchangeDisplayName(exchange *store.Exchange) string {
if exchange == nil {
return "所选交易所账户"
}
if exchange.AccountName != "" {
return fmt.Sprintf("%s%s", exchange.Name, exchange.AccountName)
}
if exchange.Name != "" {
return exchange.Name
}
return "所选交易所账户"
}
func missingExchangeFields(exchange *store.Exchange) []string {
if exchange == nil {
return nil
}
var missing []string
switch exchange.ExchangeType {
case "binance", "bybit", "gate", "indodax":
if exchange.APIKey == "" {
missing = append(missing, "API Key")
}
if exchange.SecretKey == "" {
missing = append(missing, "Secret Key")
}
case "okx", "bitget", "kucoin":
if exchange.APIKey == "" {
missing = append(missing, "API Key")
}
if exchange.SecretKey == "" {
missing = append(missing, "Secret Key")
}
if exchange.Passphrase == "" {
missing = append(missing, "Passphrase")
}
case "hyperliquid":
if exchange.APIKey == "" {
missing = append(missing, "私钥")
}
if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" {
missing = append(missing, "钱包地址")
}
case "aster":
if strings.TrimSpace(exchange.AsterUser) == "" {
missing = append(missing, "Aster User")
}
if strings.TrimSpace(exchange.AsterSigner) == "" {
missing = append(missing, "Aster Signer")
}
if exchange.AsterPrivateKey == "" {
missing = append(missing, "Aster Private Key")
}
case "lighter":
if strings.TrimSpace(exchange.LighterWalletAddr) == "" {
missing = append(missing, "钱包地址")
}
if exchange.LighterAPIKeyPrivateKey == "" {
missing = append(missing, "API Key Private Key")
}
}
return missing
}
func mapStringPairs(kv ...string) map[string]string {
if len(kv) == 0 {
return nil
}
params := make(map[string]string, len(kv)/2)
for i := 0; i+1 < len(kv); i += 2 {
params[kv[i]] = kv[i+1]
}
return params
}
func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string, map[string]string) {
if exchange == nil {
return formatTraderCreationError("还没有找到你选择的交易所账户", "请前往「设置 > 交易所配置」先添加一个可用账户,再回来创建机器人"),
"trader.create.exchange_not_found", nil
}
if !exchange.Enabled {
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」目前处于未启用状态", exchangeDisplayName(exchange)),
"请前往「设置 > 交易所配置」启用该账户后,再重新创建机器人",
), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange))
}
missing := missingExchangeFields(exchange)
if len(missing) > 0 {
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」的配置还不完整缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
), "trader.create.exchange_missing_fields", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"missing_fields", strings.Join(missing, ", "),
)
}
switch exchange.ExchangeType {
case "binance", "bybit", "okx", "bitget", "gate", "kucoin", "hyperliquid", "aster", "lighter", "indodax":
return "", "", nil
default:
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
"请改用当前版本支持的交易所账户后,再重新创建机器人",
), "trader.create.exchange_unsupported", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"exchange_type", exchange.ExchangeType,
)
}
}
func classifyTraderSetupReason(reason string) (string, string) {
trimmed := strings.TrimSpace(reason)
if trimmed == "" {
return "", ""
}
lower := strings.ToLower(trimmed)
switch {
case strings.Contains(lower, "failed to parse strategy config"),
strings.Contains(lower, "failed to parse strategy configuration"):
return "trader.reason.strategy_config_invalid", "当前策略配置内容已损坏,系统暂时无法解析"
case strings.Contains(lower, "has no strategy configured"):
return "trader.reason.strategy_missing", "当前机器人缺少有效的交易策略配置"
case strings.Contains(lower, "failed to parse private key"),
(strings.Contains(lower, "invalid hex character") && strings.Contains(lower, "private key")):
return "trader.reason.private_key_invalid", "私钥格式不正确,系统无法识别"
case strings.Contains(lower, "failed to initialize hyperliquid trader"):
return "trader.reason.hyperliquid_init_failed", "Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确"
case strings.Contains(lower, "failed to initialize aster trader"):
return "trader.reason.aster_init_failed", "Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确"
case strings.Contains(lower, "failed to get meta information"):
return "trader.reason.exchange_meta_unavailable", "系统暂时无法从交易所读取账户元信息"
case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"):
return "trader.reason.hyperliquid_agent_balance_too_high", "Hyperliquid Agent Wallet 余额过高,不符合当前安全要求"
case strings.Contains(lower, "failed to initialize account"):
return "trader.reason.exchange_account_init_failed", "交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配"
case strings.Contains(lower, "unsupported trading platform"):
return "trader.reason.exchange_unsupported", "当前交易所类型暂不支持机器人初始化"
case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"):
return "trader.reason.exchange_balance_unavailable", "系统暂时无法从交易所读取账户余额"
case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"):
return "trader.reason.exchange_service_unreachable", "系统暂时无法连接交易所服务"
default:
return "trader.reason.unknown", trimmed
}
}
func humanizeTraderSetupReason(reason string) string {
_, message := classifyTraderSetupReason(reason)
return message
}
func traderSetupReasonParams(err error, fallback string, kv ...string) map[string]string {
params := mapStringPairs(kv...)
rawReason := SanitizeError(err, fallback)
reasonKey, reasonMessage := classifyTraderSetupReason(rawReason)
if reasonMessage == "" && fallback != "" {
reasonMessage = fallback
}
if reasonMessage != "" {
if params == nil {
params = map[string]string{}
}
params["reason"] = reasonMessage
}
if reasonKey != "" {
if params == nil {
params = map[string]string{}
}
params["reason_key"] = reasonKey
}
return params
}
func describeTraderLoadError(traderName string, err error) string {
if err == nil {
return formatTraderCreationError("机器人配置虽然保存了,但运行实例没有成功初始化", "请检查模型、策略和交易所配置是否完整,然后再试一次")
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return formatTraderCreationError(
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动", traderName),
"请检查模型、策略和交易所配置是否完整,然后再试一次",
)
}
return formatTraderCreationError(
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动原因是%s", traderName, reason),
"请检查模型、策略和交易所配置是否完整,然后再试一次",
)
}
func describeTraderCreationWarning(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("机器人「%s」已经保存但当前还没有通过启动前校验。请先检查模型、策略和交易所配置修正后再点击启动。", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("机器人「%s」已经保存但当前暂时还不能启动。请先检查模型、策略和交易所配置修正后再点击启动。", traderName)
}
return fmt.Sprintf("机器人「%s」已经保存但当前暂时还不能启动原因是%s。请先检查模型、策略和交易所配置修正后再点击启动。", traderName, reason)
}
func describeTraderStartError(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后再重新点击启动。", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后再重新点击启动。", traderName)
}
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动原因是%s。请检查模型、策略和交易所配置后再重新点击启动。", traderName, reason)
}
func formatTraderStartError(reason, nextStep string) string {
if nextStep == "" {
return fmt.Sprintf("这次未能启动机器人:%s。", reason)
}
return fmt.Sprintf("这次未能启动机器人:%s。%s。", reason, nextStep)
}
// handleCreateTrader Create new AI trader
func (s *Server) handleCreateTrader(c *gin.Context) {
userID := c.GetString("user_id")
var req CreateTraderRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
SafeBadRequestWithDetails(c, traderCreationRequestError("提交的信息不完整,或者格式不正确"), "trader.create.invalid_request", nil)
return
}
// Validate leverage values
if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 {
c.JSON(http.StatusBadRequest, gin.H{"error": "BTC/ETH leverage must be between 1-50x"})
SafeBadRequestWithDetails(c, traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 50 倍之间"), "trader.create.invalid_btc_eth_leverage", nil)
return
}
if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Altcoin leverage must be between 1-20x"})
SafeBadRequestWithDetails(c, traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage", nil)
return
}
@@ -87,12 +322,61 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
for _, symbol := range symbols {
symbol = strings.TrimSpace(symbol)
if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid symbol format: %s, must end with USDT", symbol)})
SafeBadRequestWithDetails(c, traderCreationRequestError(
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持以 USDT 结尾的合约交易对", symbol),
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
return
}
}
}
model, err := s.store.AIModel().Get(userID, req.AIModelID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
SafeBadRequestWithDetails(c, formatTraderCreationError("还没有找到你选择的 AI 模型", "请前往「设置 > 模型配置」先添加并启用一个可用模型,再回来创建机器人"), "trader.create.model_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你的 AI 模型配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
err,
)
return
}
if !model.Enabled {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI 模型「%s」目前还没有启用", model.Name),
"请前往「设置 > 模型配置」启用它后,再重新创建机器人",
), "trader.create.model_disabled", mapStringPairs("model_name", model.Name))
return
}
if model.APIKey == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI 模型「%s」缺少 API Key 或支付凭证", model.Name),
"请前往「设置 > 模型配置」补全模型凭证后,再重新创建机器人",
), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name))
return
}
if req.StrategyID == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError("你还没有选择交易策略", "请先选择一个策略,再继续创建机器人"), "trader.create.strategy_required", nil)
return
}
if req.StrategyID != "" {
_, err = s.store.Strategy().Get(userID, req.StrategyID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
SafeBadRequestWithDetails(c, formatTraderCreationError("你选择的策略不存在,或者已经被删除了", "请重新选择一个可用策略后,再继续创建机器人"), "trader.create.strategy_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你选择的策略配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
err,
)
return
}
}
// Generate trader ID (use short UUID prefix for readability)
exchangeIDShort := req.ExchangeID
if len(exchangeIDShort) > 8 {
@@ -137,7 +421,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
actualBalance := req.InitialBalance // Default to use user input
exchanges, err := s.store.Exchange().List(userID)
if err != nil {
logger.Infof("⚠️ Failed to get exchange config, using user input for initial balance: %v", err)
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你的交易所配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
err,
)
return
}
// Find matching exchange configuration
@@ -149,97 +437,32 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
}
}
if exchangeCfg == nil {
logger.Infof("⚠️ Exchange %s configuration not found, using user input for initial balance", req.ExchangeID)
} else if !exchangeCfg.Enabled {
logger.Infof("⚠️ Exchange %s not enabled, using user input for initial balance", req.ExchangeID)
} else {
// Create temporary trader based on exchange type to query balance
var tempTrader trader.Trader
var createErr error
// Use ExchangeType (e.g., "binance") instead of ID (UUID)
// Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType {
case "binance":
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid":
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
string(exchangeCfg.APIKey), // private key
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
)
case "aster":
tempTrader, createErr = aster.NewAsterTrader(
exchangeCfg.AsterUser,
exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey),
)
case "bybit":
tempTrader = bybit.NewBybitTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "okx":
tempTrader = okx.NewOKXTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "bitget":
tempTrader = bitget.NewBitgetTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "gate":
tempTrader = gate.NewGateTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "kucoin":
tempTrader = kucoin.NewKuCoinTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet
tempTrader, createErr = lighter.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex,
false, // Always use mainnet for Lighter
)
} else {
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
}
default:
logger.Infof("⚠️ Unsupported exchange type: %s, using user input for initial balance", exchangeCfg.ExchangeType)
}
if exchangeMsg, exchangeErrorKey, exchangeErrorParams := validateExchangeForTraderCreation(exchangeCfg); exchangeMsg != "" {
SafeBadRequestWithDetails(c, exchangeMsg, exchangeErrorKey, exchangeErrorParams)
return
}
{
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
if createErr != nil {
logger.Infof("⚠️ Failed to create temporary trader, using user input for initial balance: %v", createErr)
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」没有通过初始化校验原因是%s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "配置校验未通过"))),
"请前往「设置 > 交易所配置」检查这个账户的密钥、地址和账户信息是否填写正确",
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "配置校验未通过",
"exchange_name", exchangeDisplayName(exchangeCfg),
))
return
} else if tempTrader != nil {
// Query actual balance
balanceInfo, balanceErr := tempTrader.GetBalance()
if balanceErr != nil {
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
} else {
// Extract total equity (account total value = wallet balance + unrealized PnL)
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
// Note: Must use total_equity (not availableBalance) for accurate P&L calculation
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
for _, key := range balanceKeys {
if balance, ok := balanceInfo[key].(float64); ok && balance > 0 {
actualBalance = balance
logger.Infof("✓ Queried exchange total equity (%s): %.2f USDT (user input: %.2f USDT)", key, actualBalance, req.InitialBalance)
break
}
}
if actualBalance <= 0 {
if extractedBalance, found := extractExchangeTotalEquity(balanceInfo); found {
actualBalance = extractedBalance
logger.Infof("✓ Queried exchange total equity: %.2f %s (user input: %.2f)",
actualBalance, accountAssetForExchange(exchangeCfg.ExchangeType), req.InitialBalance)
} else {
logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
}
}
@@ -275,27 +498,48 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
err = s.store.Trader().Create(traderRecord)
if err != nil {
logger.Infof("❌ Failed to create trader: %v", err)
SafeInternalError(c, "Failed to create trader", err)
publicMsg := SanitizeError(err, formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次"))
statusCode := http.StatusBadRequest
if publicMsg == formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次") {
statusCode = http.StatusInternalServerError
}
SafeError(c, statusCode, publicMsg, err)
return
}
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
// Immediately load new trader into TraderManager
logger.Infof("🔧 DEBUG: Preparing to call LoadUserTraders")
startupWarning := ""
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to load user traders into memory: %v", err)
// Don't return error here since trader was successfully created in database
startupWarning = describeTraderCreationWarning(req.Name, err)
}
logger.Infof("🔧 DEBUG: LoadUserTraders completed")
if startupWarning == "" {
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr)
startupWarning = describeTraderCreationWarning(req.Name, loadErr)
}
}
if startupWarning == "" {
if _, getErr := s.traderManager.GetTrader(traderID); getErr != nil {
logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr)
startupWarning = describeTraderCreationWarning(req.Name, getErr)
}
}
logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
c.JSON(http.StatusCreated, gin.H{
"trader_id": traderID,
"trader_name": req.Name,
"ai_model": req.AIModelID,
"is_running": false,
"trader_id": traderID,
"trader_name": req.Name,
"ai_model": req.AIModelID,
"is_running": false,
"startup_warning": startupWarning,
})
}
@@ -373,6 +617,17 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
strategyID = existingTrader.StrategyID
}
exchangeChanged := req.ExchangeID != "" && req.ExchangeID != existingTrader.ExchangeID
resetInitialBalance := exchangeChanged && req.InitialBalance <= 0
initialBalance := existingTrader.InitialBalance
if req.InitialBalance > 0 {
initialBalance = req.InitialBalance
}
if resetInitialBalance {
initialBalance = 0
}
// Update trader configuration
traderRecord := &store.Trader{
ID: traderID,
@@ -381,7 +636,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
AIModelID: req.AIModelID,
ExchangeID: req.ExchangeID,
StrategyID: strategyID, // Associated strategy ID
InitialBalance: req.InitialBalance,
InitialBalance: initialBalance,
BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage,
TradingSymbols: req.TradingSymbols,
@@ -413,6 +668,14 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
return
}
if resetInitialBalance {
logger.Infof("🔄 Exchange changed for trader %s, resetting stale initial_balance to 0", traderID)
if err := s.store.Trader().UpdateInitialBalance(userID, traderID, 0); err != nil {
SafeInternalError(c, "Failed to reset trader initial balance", err)
return
}
}
// Remove old trader from memory first (this also stops if running)
s.traderManager.RemoveTrader(traderID)
@@ -478,11 +741,15 @@ func (s *Server) handleStartTrader(c *gin.Context) {
traderID := c.Param("id")
// Verify trader belongs to current user
_, err := s.store.Trader().GetFullConfig(userID, traderID)
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
return
}
traderName := traderID
if fullCfg != nil && fullCfg.Trader != nil && fullCfg.Trader.Name != "" {
traderName = fullCfg.Trader.Name
}
// Check if trader exists in memory and if it's running
existingTrader, _ := s.traderManager.GetTrader(traderID)
@@ -501,45 +768,49 @@ func (s *Server) handleStartTrader(c *gin.Context) {
logger.Infof("🔄 Loading trader %s from database...", traderID)
if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil {
logger.Infof("❌ Failed to load user traders: %v", loadErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load trader: " + loadErr.Error()})
SafeErrorWithDetails(c, http.StatusInternalServerError, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName), loadErr)
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
// Check detailed reason
fullCfg, _ := s.store.Trader().GetFullConfig(userID, traderID)
if fullCfg != nil && fullCfg.Trader != nil {
// Check strategy
if fullCfg.Strategy == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader has no strategy configured, please create a strategy in Strategy Studio and associate it with the trader"})
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, fmt.Errorf("trader has no strategy configured")), "trader.start.strategy_missing", mapStringPairs("trader_name", traderName))
return
}
// Check AI model
if fullCfg.AIModel == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model does not exist, please check AI model configuration"})
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的 AI 模型不存在", "请前往「设置 > 模型配置」检查后,再重新点击启动"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.AIModel.Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model is not enabled, please enable the AI model first"})
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」关联的 AI 模型「%s」目前还没有启用", traderName, fullCfg.AIModel.Name),
"请前往「设置 > 模型配置」启用它后,再重新点击启动",
), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name))
return
}
// Check exchange
if fullCfg.Exchange == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange does not exist, please check exchange configuration"})
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的交易所账户不存在", "请前往「设置 > 交易所配置」检查后,再重新点击启动"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.Exchange.Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange is not enabled, please enable the exchange first"})
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」关联的交易所账户「%s」目前还没有启用", traderName, exchangeDisplayName(fullCfg.Exchange)),
"请前往「设置 > 交易所配置」启用它后,再重新点击启动",
), "trader.start.exchange_disabled", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
return
}
}
// Check if there's a specific load error
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load trader: " + loadErr.Error()})
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName))
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "Failed to load trader, please check AI model, exchange and strategy configuration"})
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, err), "trader.start.setup_invalid", traderSetupReasonParams(err, "", "trader_name", traderName))
return
}

View File

@@ -58,73 +58,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
return
}
// Create temporary trader to query balance
var tempTrader trader.Trader
var createErr error
// Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID)
// Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType {
case "binance":
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid":
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
)
case "aster":
tempTrader, createErr = aster.NewAsterTrader(
exchangeCfg.AsterUser,
exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey),
)
case "bybit":
tempTrader = bybit.NewBybitTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "okx":
tempTrader = okx.NewOKXTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "bitget":
tempTrader = bitget.NewBitgetTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "gate":
tempTrader = gate.NewGateTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "kucoin":
tempTrader = kucoin.NewKuCoinTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet
tempTrader, createErr = lighter.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex,
false, // Always use mainnet for Lighter
)
} else {
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
return
}
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
if createErr != nil {
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
SafeInternalError(c, "Failed to connect to exchange", createErr)
@@ -140,20 +74,14 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
}
// Extract total equity (for P&L calculation, we need total account value, not available balance)
var actualBalance float64
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
for _, key := range balanceKeys {
if balance, ok := balanceInfo[key].(float64); ok && balance > 0 {
actualBalance = balance
break
}
}
if actualBalance <= 0 {
actualBalance, found := extractExchangeTotalEquity(balanceInfo)
if !found {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
return
}
s.exchangeAccountStateCache.Invalidate(userID)
oldBalance := traderConfig.InitialBalance
// Smart balance change detection

View File

@@ -1,6 +1,7 @@
package api
import (
"fmt"
"net/http"
"strings"
"time"
@@ -11,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
// handleLogout Add current token to blacklist
@@ -59,6 +61,7 @@ func (s *Server) handleRegister(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Lang string `json:"lang"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -66,6 +69,11 @@ func (s *Server) handleRegister(c *gin.Context) {
return
}
lang := req.Lang
if lang != "zh" && lang != "id" {
lang = "en"
}
// Check if email already exists
_, err = s.store.User().GetByEmail(req.Email)
if err == nil {
@@ -94,6 +102,10 @@ func (s *Server) handleRegister(c *gin.Context) {
return
}
// Adopt orphan records from previous account (e.g. after account reset)
// This preserves wallet keys and exchange configs so funds are not lost.
s.adoptOrphanRecords(userID)
// Generate JWT token
token, err := auth.GenerateJWT(user.ID, user.Email)
if err != nil {
@@ -102,7 +114,7 @@ func (s *Server) handleRegister(c *gin.Context) {
}
// Initialize default model and exchange configs for user
err = s.initUserDefaultConfigs(user.ID)
err = s.initUserDefaultConfigs(user.ID, lang)
if err != nil {
logger.Infof("Failed to initialize user default configs: %v", err)
}
@@ -214,10 +226,172 @@ func (s *Server) handleResetPassword(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"})
}
// initUserDefaultConfigs Initialize default model and exchange configs for new user
func (s *Server) initUserDefaultConfigs(userID string) error {
// Commented out auto-creation of default configs, let users add manually
// This way new users won't have config items automatically after registration
logger.Infof("User %s registration completed, waiting for manual AI model and exchange configuration", userID)
// handleResetAccount clears user authentication data so the system returns to
// uninitialized state for re-registration. Wallet keys (ai_models) are preserved
// so funds are not lost — they will be adopted by the new account during onboarding.
func (s *Server) handleResetAccount(c *gin.Context) {
err := s.store.Transaction(func(tx *gorm.DB) error {
// Delete traders and strategies (config, not funds)
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
// Delete users — ai_models and exchanges are intentionally kept
// so wallet private keys and exchange configs survive re-registration
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
return fmt.Errorf("failed to delete users: %w", err)
}
return nil
})
if err != nil {
SafeInternalError(c, "Failed to reset account", err)
return
}
logger.Infof("✓ User accounts cleared (wallets preserved) — system reset to uninitialized")
c.JSON(http.StatusOK, gin.H{"message": "Account reset successful, you can now register a new account"})
}
// adoptOrphanRecords re-assigns ai_models and exchanges whose user_id no longer
// exists in the users table. This happens after account reset so the new user
// inherits the previous wallet keys and exchange configurations.
func (s *Server) adoptOrphanRecords(newUserID string) {
db := s.store.GormDB()
result := db.Model(&store.AIModel{}).
Where("user_id NOT IN (SELECT id FROM users)").
Update("user_id", newUserID)
if result.RowsAffected > 0 {
logger.Infof("✓ Adopted %d orphan ai_model(s) for new user %s", result.RowsAffected, newUserID)
}
result = db.Model(&store.Exchange{}).
Where("user_id NOT IN (SELECT id FROM users)").
Update("user_id", newUserID)
if result.RowsAffected > 0 {
logger.Infof("✓ Adopted %d orphan exchange(s) for new user %s", result.RowsAffected, newUserID)
}
}
// initUserDefaultConfigs Initialize default configs for new user
func (s *Server) initUserDefaultConfigs(userID string, lang string) error {
if err := s.createDefaultStrategies(userID, lang); err != nil {
logger.Warnf("Failed to create default strategies for user %s: %v", userID, err)
// Non-fatal: user can create strategies manually
}
logger.Infof("✓ User %s registration completed with default strategies", userID)
return nil
}
func (s *Server) createDefaultStrategies(userID string, lang string) error {
type strategyI18n struct {
name, description string
}
type strategyLocale struct {
balanced, conservative, aggressive strategyI18n
}
locales := map[string]strategyLocale{
"zh": {
balanced: strategyI18n{"均衡策略", "系统默认策略。均衡风险收益适合大多数市场环境。5倍杠杆最多3个仓位。"},
conservative: strategyI18n{"稳健策略", "系统默认策略。低杠杆保守操作优先保护本金。3倍杠杆专注主流资产。"},
aggressive: strategyI18n{"积极策略", "系统默认策略。高杠杆主动交易更广泛的币种选择适合经验丰富的交易者。10倍杠杆最多5个仓位。"},
},
"en": {
balanced: strategyI18n{"Balanced Strategy", "System default strategy. Balanced risk-reward, suitable for most market conditions. 5x leverage, up to 3 positions."},
conservative: strategyI18n{"Conservative Strategy", "System default strategy. Low-leverage conservative trading, capital preservation first. 3x leverage, focused on major assets."},
aggressive: strategyI18n{"Aggressive Strategy", "System default strategy. High-leverage active trading, wider asset selection, for experienced traders. 10x leverage, up to 5 positions."},
},
"id": {
balanced: strategyI18n{"Strategi Seimbang", "Strategi default sistem. Risiko-reward seimbang, cocok untuk sebagian besar kondisi pasar. Leverage 5x, hingga 3 posisi."},
conservative: strategyI18n{"Strategi Konservatif", "Strategi default sistem. Trading konservatif leverage rendah, utamakan perlindungan modal. Leverage 3x, fokus aset utama."},
aggressive: strategyI18n{"Strategi Agresif", "Strategi default sistem. Trading aktif leverage tinggi, pilihan aset lebih luas, untuk trader berpengalaman. Leverage 10x, hingga 5 posisi."},
},
}
locale, ok := locales[lang]
if !ok {
locale = locales["en"]
}
type strategyDef struct {
name string
description string
isActive bool
applyConfig func(*store.StrategyConfig)
}
definitions := []strategyDef{
{
name: locale.balanced.name,
description: locale.balanced.description,
isActive: true,
applyConfig: func(c *store.StrategyConfig) {
// Uses default config as-is
},
},
{
name: locale.conservative.name,
description: locale.conservative.description,
isActive: false,
applyConfig: func(c *store.StrategyConfig) {
c.RiskControl.BTCETHMaxLeverage = 3
c.RiskControl.AltcoinMaxLeverage = 3
c.RiskControl.BTCETHMaxPositionValueRatio = 3.0
c.RiskControl.AltcoinMaxPositionValueRatio = 0.5
c.RiskControl.MinConfidence = 80
c.RiskControl.MinRiskRewardRatio = 4.0
c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"}
c.Indicators.Klines.PrimaryTimeframe = "15m"
},
},
{
name: locale.aggressive.name,
description: locale.aggressive.description,
isActive: false,
applyConfig: func(c *store.StrategyConfig) {
c.RiskControl.BTCETHMaxLeverage = 10
c.RiskControl.AltcoinMaxLeverage = 7
c.RiskControl.MaxPositions = 5
c.RiskControl.AltcoinMaxPositionValueRatio = 2.0
c.RiskControl.MinConfidence = 70
c.CoinSource.AI500Limit = 5
c.CoinSource.UseOITop = true
c.CoinSource.OITopLimit = 5
c.Indicators.Klines.SelectedTimeframes = []string{"3m", "15m", "1h"}
c.Indicators.Klines.PrimaryTimeframe = "3m"
},
},
}
// GetDefaultStrategyConfig only supports zh/en; map id -> en
configLang := lang
if lang == "id" {
configLang = "en"
}
// Pre-build all strategy objects before opening the transaction
var strategies []*store.Strategy
for _, def := range definitions {
config := store.GetDefaultStrategyConfig(configLang)
def.applyConfig(&config)
strategy := &store.Strategy{
ID: uuid.New().String(),
UserID: userID,
Name: def.name,
Description: def.description,
IsActive: def.isActive,
IsDefault: false,
}
if err := strategy.SetConfig(&config); err != nil {
return fmt.Errorf("failed to set config for strategy %q: %w", def.name, err)
}
strategies = append(strategies, strategy)
}
return s.store.Transaction(func(tx *gorm.DB) error {
for _, strategy := range strategies {
if err := tx.Create(strategy).Error; err != nil {
return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err)
}
logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive)
}
return nil
})
}

View File

@@ -18,13 +18,14 @@ import (
// Server HTTP API server
type Server struct {
router *gin.Engine
traderManager *manager.TraderManager
store *store.Store
cryptoHandler *CryptoHandler
httpServer *http.Server
port int
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
router *gin.Engine
traderManager *manager.TraderManager
store *store.Store
cryptoHandler *CryptoHandler
exchangeAccountStateCache *ExchangeAccountStateCache
httpServer *http.Server
port int
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
}
// NewServer Creates API server
@@ -41,11 +42,12 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
cryptoHandler := NewCryptoHandler(cryptoService)
s := &Server{
router: router,
traderManager: traderManager,
store: st,
cryptoHandler: cryptoHandler,
port: port,
router: router,
traderManager: traderManager,
store: st,
cryptoHandler: cryptoHandler,
exchangeAccountStateCache: NewExchangeAccountStateCache(),
port: port,
}
// Setup routes
@@ -110,17 +112,21 @@ func (s *Server) setupRoutes() {
// Public strategy market (no authentication required)
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
s.route(api, "POST", "/strategies/estimate-tokens", "Estimate token usage for a strategy config", s.handleEstimateTokens)
// Authentication related routes (no authentication required)
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
s.route(api, "POST", "/reset-password", "Reset password", s.handleResetPassword)
s.route(api, "POST", "/reset-account", "Clear all users and reset system to allow re-registration", s.handleResetAccount)
// Routes requiring authentication
protected := api.Group("/", s.authMiddleware())
{
// Logout (add to blacklist)
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding)
s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet)
// User account management
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
@@ -194,6 +200,10 @@ Defaults when custom fields empty: openai→api.openai.com/v1, deepseek→api.de
`Returns: [{"id":"<EXACT id — use this as exchange_id when creating/updating a trader>","exchange_type":"<e.g. okx, binance>","account_name":"<user label>","enabled":<bool>}]
CRITICAL: Always use the "id" field for exchange_id. Do not use "exchange_type" as an id.`,
s.handleGetExchangeConfigs)
s.routeWithSchema(protected, "GET", "/exchanges/account-state", "Get connection and balance state for each exchange account",
`Returns: {"states":{"<exchange_id>":{"status":"ok|disabled|missing_credentials|invalid_credentials|permission_denied|unavailable","display_balance":"<string>","total_equity":<number>,"available_balance":<number>,"asset":"USDT|USDC","checked_at":"<RFC3339>","error_code":"<string>","error_message":"<string>"}}}
Use this endpoint to show balance and health in the exchange list without depending on traders.`,
s.handleGetExchangeAccountStates)
s.routeWithSchema(protected, "POST", "/exchanges", "Create a new exchange account",
`Body: {"exchange_type":"<string>","account_name":"<string, user label>","enabled":true,"api_key":"<string>","secret_key":"<string>","passphrase":"<string, required for okx/gate/kucoin>"}
exchange_type values: "binance","bybit","okx","bitget","gate","kucoin","indodax" (CEX) | "hyperliquid","aster","lighter" (DEX)

View File

@@ -31,6 +31,20 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
return warnings
}
// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation)
func (s *Server) handleEstimateTokens(c *gin.Context) {
var req struct {
Config store.StrategyConfig `json:"config" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
estimate := req.Config.EstimateTokens()
c.JSON(http.StatusOK, estimate)
}
// handlePublicStrategies Get public strategies for strategy market (no auth required)
func (s *Server) handlePublicStrategies(c *gin.Context) {
strategies, err := s.store.Strategy().ListPublic()
@@ -150,8 +164,8 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -289,6 +303,25 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
return
}
// Token overflow check — block save if all models exceed context limits
if mergedConfig.StrategyType == "" || mergedConfig.StrategyType == "ai_trading" {
estimate := mergedConfig.EstimateTokens()
allExceed := true
for _, ml := range estimate.ModelLimits {
if ml.UsagePct <= 100 {
allExceed = false
break
}
}
if allExceed && len(estimate.ModelLimits) > 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Estimated %d tokens exceeds all known model context limits. Reduce coins, timeframes, or K-line count.", estimate.Total),
"token_estimate": estimate,
})
return
}
}
// Validate merged configuration and collect warnings
warnings := validateStrategyConfig(&mergedConfig)
@@ -311,7 +344,7 @@ func (s *Server) handleDeleteStrategy(c *gin.Context) {
}
if err := s.store.Strategy().Delete(userID, strategyID); err != nil {
SafeInternalError(c, "Failed to delete strategy", err)
c.JSON(http.StatusBadRequest, gin.H{"error": SanitizeError(err, "Failed to delete strategy")})
return
}
@@ -419,9 +452,9 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
}
var req struct {
Config store.StrategyConfig `json:"config" binding:"required"`
AccountEquity float64 `json:"account_equity"`
PromptVariant string `json:"prompt_variant"`
Config store.StrategyConfig `json:"config" binding:"required"`
AccountEquity float64 `json:"account_equity"`
PromptVariant string `json:"prompt_variant"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -664,4 +697,3 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
return response, nil
}

View File

@@ -11,6 +11,7 @@ services:
- "${NOFX_BACKEND_PORT:-8080}:8080"
- "6060:6060" # pprof profiling
volumes:
- ./.env:/app/.env
- ./data:/app/data
- /etc/localtime:/etc/localtime:ro
env_file:
@@ -49,4 +50,4 @@ services:
networks:
nofx-network:
driver: bridge
driver: bridge

View 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 |
| **ストラテジースタジオ** | ビジュアルビルダー — コインソース、インジケーター、リスク管理 |
| **AI ディベートアリーナ** | 複数 AI が取引を議論(ブル vs ベア vs アナリスト)、投票、実行 |
@@ -112,6 +112,7 @@ x402 フロー:
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API キー取得](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API キー取得](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API キー取得](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API キー取得](https://platform.minimaxi.com) |
### AI モデル (x402 モード — API キー不要)

View 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 |
| **전략 스튜디오** | 비주얼 빌더 — 코인 소스, 지표, 리스크 관리 |
| **AI 토론 아레나** | 여러 AI가 거래 토론 (강세 vs 약세 vs 분석가), 투표, 실행 |
@@ -112,6 +112,7 @@ x402 플로우:
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API 키 받기](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API 키 받기](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API 키 받기](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API 키 받기](https://platform.minimaxi.com) |
### AI 모델 (x402 모드 — API 키 불필요)

View 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 |
| **Студия стратегий** | Визуальный конструктор — источники монет, индикаторы, контроль рисков |
| **AI Арена дебатов** | Несколько AI обсуждают сделки (Бык vs Медведь vs Аналитик), голосуют, исполняют |
@@ -112,6 +112,7 @@ x402 процесс:
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Получить](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Получить](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Получить](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Получить](https://platform.minimaxi.com) |
### AI Модели (Режим x402 — без API ключей)

View 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 |
| **Студія стратегій** | Візуальний конструктор — джерела монет, індикатори, контроль ризиків |
| **AI Арена дебатів** | Кілька AI обговорюють угоди, голосують, виконують |
@@ -112,6 +112,7 @@ x402 процес:
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Отримати](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Отримати](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Отримати](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Отримати](https://platform.minimaxi.com) |
### AI Моделі (Режим x402 — без API ключів)

View File

@@ -69,7 +69,7 @@ Không tài khoản. Không API key. Không trả trước. Một ví, tất c
| Tính năng | Mô tả |
|:--------|:------------|
| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — chuyển đổi bất cứ lúc nào |
| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — chuyển đổi bất cứ lúc nào |
| **Đa Sàn** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **Strategy Studio** | Trình xây dựng trực quan — nguồn coin, chỉ báo, kiểm soát rủi ro |
| **AI Competition** | AI cạnh tranh thời gian thực, bảng xếp hạng hiệu suất |
@@ -110,6 +110,7 @@ Crypto · Cổ phiếu Mỹ · Forex · Kim loại
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Lấy API Key](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Lấy API Key](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Lấy API Key](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Lấy API Key](https://platform.minimaxi.com) |
### Mô hình AI (Chế độ x402 — Không cần API Key)

View File

@@ -71,7 +71,7 @@ x402 流程:
| 功能 | 描述 |
|:--------|:------------|
| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi — 随时切换 |
| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi、MiniMax — 随时切换 |
| **多交易所** | Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster、Lighter |
| **策略工作室** | 可视化构建器 — 币种来源、指标、风控 |
| **AI 竞赛** | AI 实时竞争,排行榜排名 |
@@ -113,6 +113,7 @@ x402 流程:
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [获取 API Key](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [获取 API Key](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [获取 API Key](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [获取 API Key](https://platform.minimaxi.com) |
### AI 模型 (x402 模式 — 无需 API Key)
@@ -170,6 +171,10 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
## 配置
**新手模式**:首次使用的用户可以在注册时选择新手模式,系统会引导你逐步完成 AI、交易所和策略的配置。
**进阶模式**
1. **AI** — 添加 API Key 或配置 x402 钱包
2. **交易所** — 连接交易所 API 凭证
3. **策略** — 在策略工作室构建

View 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 / 2zh或 / 4en
baseChars ≈ 3000zh/ 4000en+ 自定义提示段落长度
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仅VolumeQuantOI+Netflow | 3 | 20 | 10 | 600 | **1525** |
| **最大**4TF全部指标全量化 | 4 | 30 | 115 | 600 | **6025** |
### 各模型下的最大安全币数
| 模型上限 | 最小配置 | 默认配置 | 最大配置 |
| ------------------------------ | ------------ | ------------ | ----------- |
| 131KDeepSeek / Grok / Qwen | ≥10封顶 | ≥10封顶 | **14** |
| 128KOpenAI GPT-4 | ≥10封顶 | ≥10封顶 | **14** |
| 200KClaude | ≥10封顶 | ≥10封顶 | ≥10封顶 |
| 1MGemini / 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 枚 |

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"os"
"nofx/logger"
"nofx/market"
"nofx/provider/hyperliquid"
@@ -185,8 +186,9 @@ type StrategyEngine struct {
nofxosClient *nofxos.Client
}
// NewStrategyEngine creates strategy execution engine
func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine {
// NewStrategyEngine creates strategy execution engine.
// claw402WalletKey is optional — if provided, nofxos data requests are routed through claw402.
func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string) *StrategyEngine {
// Create NofxOS client with API key from config
apiKey := config.Indicators.NofxOSAPIKey
if apiKey == "" {
@@ -194,6 +196,28 @@ func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine {
}
client := nofxos.NewClient(nofxos.DefaultBaseURL, apiKey)
// If claw402 wallet key is provided (from trader's AI config), route through claw402
walletKey := ""
if len(claw402WalletKey) > 0 {
walletKey = claw402WalletKey[0]
}
if walletKey == "" {
walletKey = os.Getenv("CLAW402_WALLET_KEY")
}
if walletKey != "" {
claw402URL := os.Getenv("CLAW402_URL")
if claw402URL == "" {
claw402URL = "https://claw402.ai"
}
claw402Client, err := nofxos.NewClaw402DataClient(claw402URL, walletKey, &logger.MCPLogger{})
if err == nil {
client.SetClaw402(claw402Client)
logger.Infof("🔗 NofxOS data routed through claw402 (%s)", claw402URL)
} else {
logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err)
}
}
return &StrategyEngine{
config: config,
nofxosClient: client,

View File

@@ -51,6 +51,33 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
engine = NewStrategyEngine(&defaultConfig)
}
// Clamp strategy limits to prevent token overflow
engineConfig := engine.GetConfig()
engineConfig.ClampLimits()
// Token estimation check — block if exceeding the specific model's context limit
estimate := engineConfig.EstimateTokens()
// Determine context limit for the specific model being used
contextLimit := 131072 // safe default (strictest common limit)
var providerName string
if embedder, ok := mcpClient.(mcp.ClientEmbedder); ok {
base := embedder.BaseClient()
providerName = base.Provider
contextLimit = store.GetContextLimitForClient(base.Provider, base.Model)
}
if estimate.Total > contextLimit {
logger.Errorf("🚫 Token estimate %d exceeds %s context limit %d — blocking analysis",
estimate.Total, providerName, contextLimit)
return nil, fmt.Errorf("estimated %d tokens exceeds model context limit of %d; reduce coins, timeframes, or K-line count",
estimate.Total, contextLimit)
}
if estimate.Total*100/contextLimit >= 80 {
logger.Infof("⚠️ Token estimate %d — approaching %s context limit %d",
estimate.Total, providerName, contextLimit)
}
// 1. Fetch market data using strategy config
if len(ctx.MarketDataMap) == 0 {
if err := fetchMarketDataWithStrategy(ctx, engine); err != nil {

View File

@@ -118,8 +118,12 @@ func main() {
if t.IsRunning {
status = "✅ Running"
}
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
t.Name, t.ID[:8], status, t.AIModelID, t.ExchangeID)
idShort := t.ID
if len(idShort) > 8 {
idShort = idShort[:8]
}
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
t.Name, idShort, status, t.AIModelID, t.ExchangeID)
}
}

View File

@@ -78,15 +78,19 @@ func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline
ts := time.Now().UnixMilli()
// Use "To" side to search backward from current time (get historical klines)
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
if err != nil {
// If exchange-specific data fails, fallback to Binance
if err != nil || len(coinankKlines) == 0 {
// If exchange-specific data fails or returns empty, fallback to Binance
if coinankExchange != coinank_enum.Binance {
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
if err != nil {
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
} else {
logger.Warnf("⚠️ CoinAnk %s %s data empty for %s, falling back to Binance", exchange, interval, symbol)
}
coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
if err != nil {
return nil, fmt.Errorf("CoinAnk API error (fallback): %w", err)
}
} else {
} else if err != nil {
return nil, fmt.Errorf("CoinAnk API error: %w", err)
}
}

View File

@@ -392,6 +392,9 @@ func (client *Client) String() string {
client.Provider, client.Model)
}
// BaseClient returns the underlying *Client (satisfies ClientEmbedder interface).
func (c *Client) BaseClient() *Client { return c }
// IsRetryableError determines if error is retryable (network errors, timeouts, etc.)
func (client *Client) IsRetryableError(err error) bool {
errStr := err.Error()
@@ -760,10 +763,21 @@ func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage,omitempty"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue // skip malformed chunks
}
if chunk.Usage != nil && chunk.Usage.TotalTokens > 0 {
fmt.Printf("📊 [TokenUsage] prompt=%d, completion=%d, total=%d\n",
chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.TotalTokens)
}
if len(chunk.Choices) == 0 {
continue
}

View File

@@ -13,7 +13,7 @@ import (
const (
DefaultClaw402URL = "https://claw402.ai"
DefaultClaw402Model = "deepseek"
DefaultClaw402Model = "glm-5"
)
// claw402ModelEndpoints maps user-friendly model names to claw402 API paths.
@@ -39,6 +39,9 @@ var claw402ModelEndpoints = map[string]string{
"gemini-3.1-pro": "/api/v1/ai/gemini/chat/3.1-pro",
// Kimi
"kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5",
// Z.AI (智谱)
"glm-5": "/api/v1/ai/zhipu/chat",
"glm-5-turbo": "/api/v1/ai/zhipu/chat/turbo",
}
func init() {

View File

@@ -81,6 +81,13 @@ func X402DecodeHeader(b64 string) ([]byte, error) {
return decoded, nil
}
// MakeClaw402SignFunc creates an X402SignFunc from a private key for claw402 payments.
func MakeClaw402SignFunc(privateKey *ecdsa.PrivateKey) X402SignFunc {
return func(paymentHeaderB64 string) (string, error) {
return SignBasePaymentHeader(privateKey, paymentHeaderB64, "Claw402")
}
}
// SignBasePaymentHeader decodes a base64 x402 header, parses it, and signs with
// EIP-712 (USDC TransferWithAuthorization).
func SignBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) {

View File

@@ -8,7 +8,7 @@ import (
const (
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
DefaultMiniMaxModel = "MiniMax-M2.5"
DefaultMiniMaxModel = "MiniMax-M2.7"
)
func init() {

View File

@@ -25,5 +25,5 @@ const (
// Default MiniMax configuration (used by WithMiniMaxConfig convenience option)
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
DefaultMiniMaxModel = "MiniMax-M2.5"
DefaultMiniMaxModel = "MiniMax-M2.7"
)

112
provider/nofxos/claw402.go Normal file
View 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
}

View File

@@ -25,6 +25,7 @@ type Client struct {
AuthKey string
Timeout time.Duration
mu sync.RWMutex
claw402 *Claw402DataClient // If set, routes requests through claw402
}
var (
@@ -59,6 +60,13 @@ func NewClient(baseURL, authKey string) *Client {
}
}
// SetClaw402 enables routing requests through claw402 payment gateway.
func (c *Client) SetClaw402(claw402Client *Claw402DataClient) {
c.mu.Lock()
defer c.mu.Unlock()
c.claw402 = claw402Client
}
// SetConfig updates client configuration
func (c *Client) SetConfig(baseURL, authKey string) {
c.mu.Lock()
@@ -85,14 +93,21 @@ func (c *Client) GetAuthKey() string {
return c.AuthKey
}
// doRequest performs an HTTP GET request with authentication
// doRequest performs an HTTP GET request with authentication.
// If claw402 client is configured, routes through claw402 payment gateway instead.
func (c *Client) doRequest(endpoint string) ([]byte, error) {
c.mu.RLock()
claw402Client := c.claw402
baseURL := c.BaseURL
authKey := c.AuthKey
timeout := c.Timeout
c.mu.RUnlock()
// Route through claw402 if configured
if claw402Client != nil {
return claw402Client.DoRequest(endpoint)
}
url := baseURL + endpoint
if !strings.Contains(url, "auth=") {
if strings.Contains(url, "?") {

View File

@@ -54,6 +54,24 @@ func (s *AIModelStore) initDefaultData() error {
return nil
}
// FindOrphanClaw402 finds a claw402 model whose user_id no longer exists in the users table.
// Used to recover wallets after account reset.
func (s *AIModelStore) FindOrphanClaw402() (*AIModel, error) {
var model AIModel
err := s.db.Where("provider = ? AND api_key != '' AND user_id NOT IN (SELECT id FROM users)", "claw402").
First(&model).Error
if err != nil {
return nil, err
}
return &model, nil
}
// AdoptModel re-assigns an existing model to a new user.
func (s *AIModelStore) AdoptModel(modelID, newUserID string) error {
return s.db.Model(&AIModel{}).Where("id = ?", modelID).
Update("user_id", newUserID).Error
}
// List retrieves user's AI model list
func (s *AIModelStore) List(userID string) ([]*AIModel, error) {
var models []*AIModel

View File

@@ -3,11 +3,63 @@ package store
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"gorm.io/gorm"
)
// Hard limits to prevent token explosion in AI requests
const (
MaxCandidateCoins = 10
MaxPositions = 3
MaxTimeframes = 4
MinKlineCount = 10
MaxKlineCount = 30
)
// ClampLimits enforces product-level limits on strategy config to prevent token overflow.
func (c *StrategyConfig) ClampLimits() {
// Clamp coin source limits
if c.CoinSource.AI500Limit > MaxCandidateCoins {
c.CoinSource.AI500Limit = MaxCandidateCoins
}
if c.CoinSource.OITopLimit > MaxCandidateCoins {
c.CoinSource.OITopLimit = MaxCandidateCoins
}
if c.CoinSource.OILowLimit > MaxCandidateCoins {
c.CoinSource.OILowLimit = MaxCandidateCoins
}
// Clamp static coins
if len(c.CoinSource.StaticCoins) > MaxCandidateCoins {
c.CoinSource.StaticCoins = c.CoinSource.StaticCoins[:MaxCandidateCoins]
}
// Clamp kline count
if c.Indicators.Klines.PrimaryCount < MinKlineCount {
c.Indicators.Klines.PrimaryCount = MinKlineCount
}
if c.Indicators.Klines.PrimaryCount > MaxKlineCount {
c.Indicators.Klines.PrimaryCount = MaxKlineCount
}
if c.Indicators.Klines.LongerCount > MaxKlineCount {
c.Indicators.Klines.LongerCount = MaxKlineCount
}
// Clamp timeframes
if len(c.Indicators.Klines.SelectedTimeframes) > MaxTimeframes {
c.Indicators.Klines.SelectedTimeframes = c.Indicators.Klines.SelectedTimeframes[:MaxTimeframes]
}
// Clamp max positions
if c.RiskControl.MaxPositions > MaxPositions {
c.RiskControl.MaxPositions = MaxPositions
}
}
// StrategyStore strategy storage
type StrategyStore struct {
db *gorm.DB
@@ -21,8 +73,8 @@ type Strategy struct {
Description string `gorm:"default:''" json:"description"`
IsActive bool `gorm:"column:is_active;default:false;index" json:"is_active"`
IsDefault bool `gorm:"column:is_default;default:false" json:"is_default"`
IsPublic bool `gorm:"column:is_public;default:false;index" json:"is_public"` // whether visible in strategy market
ConfigVisible bool `gorm:"column:config_visible;default:true" json:"config_visible"` // whether config details are visible
IsPublic bool `gorm:"column:is_public;default:false;index" json:"is_public"` // whether visible in strategy market
ConfigVisible bool `gorm:"column:config_visible;default:true" json:"config_visible"` // whether config details are visible
Config string `gorm:"not null;default:'{}'" json:"config"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -139,7 +191,7 @@ type IndicatorConfig struct {
EnableMACD bool `json:"enable_macd"`
EnableRSI bool `json:"enable_rsi"`
EnableATR bool `json:"enable_atr"`
EnableBOLL bool `json:"enable_boll"` // Bollinger Bands
EnableBOLL bool `json:"enable_boll"` // Bollinger Bands
EnableVolume bool `json:"enable_volume"`
EnableOI bool `json:"enable_oi"` // open interest
EnableFundingRate bool `json:"enable_funding_rate"` // funding rate
@@ -197,10 +249,10 @@ type KlineConfig struct {
// ExternalDataSource external data source configuration
type ExternalDataSource struct {
Name string `json:"name"` // data source name
Type string `json:"type"` // type: "api" | "webhook"
URL string `json:"url"` // API URL
Method string `json:"method"` // HTTP method
Name string `json:"name"` // data source name
Type string `json:"type"` // type: "api" | "webhook"
URL string `json:"url"` // API URL
Method string `json:"method"` // HTTP method
Headers map[string]string `json:"headers,omitempty"`
DataPath string `json:"data_path,omitempty"` // JSON data path
RefreshSecs int `json:"refresh_secs,omitempty"` // refresh interval (seconds)
@@ -260,20 +312,20 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
CoinSource: CoinSourceConfig{
SourceType: "ai500",
UseAI500: true,
AI500Limit: 10,
AI500Limit: 3,
UseOITop: false,
OITopLimit: 10,
OITopLimit: 3,
UseOILow: false,
OILowLimit: 10,
OILowLimit: 3,
},
Indicators: IndicatorConfig{
Klines: KlineConfig{
PrimaryTimeframe: "5m",
PrimaryCount: 30,
PrimaryCount: 20,
LongerTimeframe: "4h",
LongerCount: 10,
EnableMultiTimeframe: true,
SelectedTimeframes: []string{"5m", "15m", "1h", "4h"},
SelectedTimeframes: []string{"5m", "15m", "1h"},
},
EnableRawKlines: true, // Required - raw OHLCV data for AI analysis
EnableEMA: false,
@@ -308,15 +360,15 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
PriceRankingLimit: 10,
},
RiskControl: RiskControlConfig{
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided)
AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided)
BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED)
AltcoinMaxPositionValueRatio: 1.0, // Altcoin: max position = 1x equity (CODE ENFORCED)
MaxMarginUsage: 0.9, // Max 90% margin usage (CODE ENFORCED)
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
MinConfidence: 75, // Min 75% confidence (AI guided)
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided)
AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided)
BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED)
AltcoinMaxPositionValueRatio: 1.0, // Altcoin: max position = 1x equity (CODE ENFORCED)
MaxMarginUsage: 0.9, // Max 90% margin usage (CODE ENFORCED)
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
MinConfidence: 75, // Min 75% confidence (AI guided)
},
}
@@ -388,8 +440,18 @@ func (s *StrategyStore) Update(strategy *Strategy) error {
func (s *StrategyStore) Delete(userID, id string) error {
// do not allow deleting system default strategy
var st Strategy
if err := s.db.Where("id = ?", id).First(&st).Error; err == nil && st.IsDefault {
return fmt.Errorf("cannot delete system default strategy")
if err := s.db.Where("id = ?", id).First(&st).Error; err == nil {
if st.IsDefault {
return fmt.Errorf("cannot delete system default strategy")
}
}
// Check if any trader references this strategy
var count int64
if err := s.db.Model(&Trader{}).
Where("user_id = ? AND strategy_id = ?", userID, id).
Count(&count).Error; err == nil && count > 0 {
return fmt.Errorf("cannot delete strategy in use by %d trader(s) - reassign those traders first", count)
}
return s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&Strategy{}).Error
@@ -510,3 +572,308 @@ func (s *Strategy) SetConfig(config *StrategyConfig) error {
s.Config = string(data)
return nil
}
// ============================================================================
// Token Estimation
// ============================================================================
// TokenEstimate holds the result of token estimation
type TokenEstimate struct {
Total int `json:"total"`
Breakdown TokenBreakdown `json:"breakdown"`
ModelLimits []ModelLimit `json:"model_limits"`
Suggestions []string `json:"suggestions"`
}
// TokenBreakdown shows estimated tokens per component
type TokenBreakdown struct {
SystemPrompt int `json:"system_prompt"`
MarketData int `json:"market_data"`
RankingData int `json:"ranking_data"`
QuantData int `json:"quant_data"`
FixedOverhead int `json:"fixed_overhead"`
}
// ModelLimit shows token usage against a specific model's context limit
type ModelLimit struct {
Name string `json:"name"`
ContextLimit int `json:"context_limit"`
UsagePct int `json:"usage_pct"`
Level string `json:"level"` // "ok" | "warning" | "danger"
}
// Context window sizes (tokens) for each model family
const (
contextLimitDeepSeek = 131_072 // 128K
contextLimitOpenAI = 128_000 // 128K
contextLimitClaude = 200_000 // 200K
contextLimitQwen = 131_072 // 128K
contextLimitGemini = 1_000_000 // 1M
contextLimitGrok = 131_072 // 128K
contextLimitKimi = 131_072 // 128K
contextLimitMinimax = 1_000_000 // 1M
)
// ModelContextLimits maps provider names to their context window sizes (in tokens)
var ModelContextLimits = map[string]int{
"deepseek": contextLimitDeepSeek,
"openai": contextLimitOpenAI,
"claude": contextLimitClaude,
"qwen": contextLimitQwen,
"gemini": contextLimitGemini,
"grok": contextLimitGrok,
"kimi": contextLimitKimi,
"minimax": contextLimitMinimax,
}
// GetContextLimit returns the context limit for a given provider
func GetContextLimit(provider string) int {
if limit, ok := ModelContextLimits[provider]; ok {
return limit
}
return contextLimitDeepSeek // safe default
}
// GetContextLimitForClient returns context limit for a provider+model pair.
// For claw402, the underlying model is inferred from the model name prefix.
func GetContextLimitForClient(provider, model string) int {
if provider == "claw402" {
switch {
case strings.HasPrefix(model, "claude"):
return ModelContextLimits["claude"]
case strings.HasPrefix(model, "gpt"), strings.HasPrefix(model, "o1"), strings.HasPrefix(model, "o3"):
return ModelContextLimits["openai"]
case strings.HasPrefix(model, "gemini"):
return ModelContextLimits["gemini"]
case strings.HasPrefix(model, "grok"):
return ModelContextLimits["grok"]
case strings.HasPrefix(model, "kimi"):
return ModelContextLimits["kimi"]
case strings.HasPrefix(model, "qwen"):
return ModelContextLimits["qwen"]
case strings.HasPrefix(model, "minimax"):
return ModelContextLimits["minimax"]
case strings.HasPrefix(model, "deepseek"):
return ModelContextLimits["deepseek"]
default:
return ModelContextLimits["deepseek"]
}
}
return GetContextLimit(provider)
}
// EstimateTokens estimates the total token count for a strategy configuration.
// This is a pure computation based on config fields — no network calls.
func (c *StrategyConfig) EstimateTokens() TokenEstimate {
breakdown := TokenBreakdown{}
// --- System Prompt ---
// Base system prompt: schema + role + rules + output format
baseChars := 4000 // English default
if c.Language == "zh" {
baseChars = 3000
}
// Add prompt sections
baseChars += len(c.PromptSections.RoleDefinition)
baseChars += len(c.PromptSections.TradingFrequency)
baseChars += len(c.PromptSections.EntryStandards)
baseChars += len(c.PromptSections.DecisionProcess)
baseChars += len(c.CustomPrompt)
if c.Language == "zh" {
breakdown.SystemPrompt = baseChars / 2 // CJK: ~2 chars per token
} else {
breakdown.SystemPrompt = baseChars / 4 // English: ~4 chars per token
}
// --- Fixed Overhead ---
// Time, BTC price, account info, section headers
breakdown.FixedOverhead = 800 / 4 // ~200 tokens
// --- Market Data ---
numCoins := c.getEffectiveCoinCount()
numTimeframes := c.getEffectiveTimeframeCount()
klineCount := c.Indicators.Klines.PrimaryCount
if klineCount <= 0 {
klineCount = 20
}
// Per coin per timeframe: kline OHLCV rows
charsPerCoinTF := klineCount * 80 // each OHLCV line ~80 chars
// Add enabled indicator overhead per timeframe
indicatorCharsPerLine := 0
if c.Indicators.EnableEMA {
indicatorCharsPerLine += 20 // EMA values appended
}
if c.Indicators.EnableMACD {
indicatorCharsPerLine += 30
}
if c.Indicators.EnableRSI {
indicatorCharsPerLine += 15
}
if c.Indicators.EnableATR {
indicatorCharsPerLine += 15
}
if c.Indicators.EnableBOLL {
indicatorCharsPerLine += 25
}
if c.Indicators.EnableVolume {
indicatorCharsPerLine += 10
}
charsPerCoinTF += klineCount * indicatorCharsPerLine
totalMarketChars := numCoins * numTimeframes * charsPerCoinTF
// OI + Funding per coin
if c.Indicators.EnableOI || c.Indicators.EnableFundingRate {
totalMarketChars += numCoins * 100
}
breakdown.MarketData = totalMarketChars / 4 // numeric data: ~4 chars per token
// --- Quant Data ---
if c.Indicators.EnableQuantData {
quantCharsPerCoin := 0
if c.Indicators.EnableQuantOI {
quantCharsPerCoin += 300
}
if c.Indicators.EnableQuantNetflow {
quantCharsPerCoin += 300
}
breakdown.QuantData = (numCoins * quantCharsPerCoin) / 4
}
// --- Ranking Data ---
rankingChars := 0
if c.Indicators.EnableOIRanking {
limit := c.Indicators.OIRankingLimit
if limit <= 0 {
limit = 10
}
rankingChars += limit * 60
}
if c.Indicators.EnableNetFlowRanking {
limit := c.Indicators.NetFlowRankingLimit
if limit <= 0 {
limit = 10
}
rankingChars += limit * 80
}
if c.Indicators.EnablePriceRanking {
limit := c.Indicators.PriceRankingLimit
if limit <= 0 {
limit = 10
}
// Count durations (comma-separated)
numDurations := 1
if c.Indicators.PriceRankingDuration != "" {
numDurations = len(strings.Split(c.Indicators.PriceRankingDuration, ","))
}
rankingChars += limit * numDurations * 40
}
breakdown.RankingData = rankingChars / 4
// --- Total with 15% safety margin ---
subtotal := breakdown.SystemPrompt + breakdown.MarketData + breakdown.RankingData + breakdown.QuantData + breakdown.FixedOverhead
total := subtotal * 115 / 100
// --- Model limits ---
modelLimits := make([]ModelLimit, 0, len(ModelContextLimits))
for name, limit := range ModelContextLimits {
pct := total * 100 / limit
level := "ok"
if pct >= 100 {
level = "danger"
} else if pct >= 80 {
level = "warning"
}
modelLimits = append(modelLimits, ModelLimit{
Name: name,
ContextLimit: limit,
UsagePct: pct,
Level: level,
})
}
// Sort by usage_pct desc, then name asc for deterministic order
sort.Slice(modelLimits, func(i, j int) bool {
if modelLimits[i].UsagePct != modelLimits[j].UsagePct {
return modelLimits[i].UsagePct > modelLimits[j].UsagePct
}
return modelLimits[i].Name < modelLimits[j].Name
})
// --- Suggestions ---
var suggestions []string
// Find the strictest model (smallest context)
minLimit := 0
for _, limit := range ModelContextLimits {
if minLimit == 0 || limit < minLimit {
minLimit = limit
}
}
if minLimit > 0 && total > minLimit {
if numTimeframes > 1 {
savedPerTF := (numCoins * klineCount * (80 + indicatorCharsPerLine)) / 4 * 115 / 100
suggestions = append(suggestions, fmt.Sprintf("Reduce 1 timeframe to save ~%d tokens", savedPerTF))
}
if numCoins > 1 {
savedPerCoin := (numTimeframes * klineCount * (80 + indicatorCharsPerLine)) / 4 * 115 / 100
suggestions = append(suggestions, fmt.Sprintf("Reduce 1 coin to save ~%d tokens", savedPerCoin))
}
if klineCount > 15 {
suggestions = append(suggestions, "Reduce K-line count to 15 to save tokens")
}
}
return TokenEstimate{
Total: total,
Breakdown: breakdown,
ModelLimits: modelLimits,
Suggestions: suggestions,
}
}
// getEffectiveCoinCount returns the estimated number of coins that will be analyzed
func (c *StrategyConfig) getEffectiveCoinCount() int {
count := 0
switch c.CoinSource.SourceType {
case "static":
count = len(c.CoinSource.StaticCoins)
case "ai500":
count = c.CoinSource.AI500Limit
case "oi_top":
count = c.CoinSource.OITopLimit
case "oi_low":
count = c.CoinSource.OILowLimit
case "mixed":
if c.CoinSource.UseAI500 {
count += c.CoinSource.AI500Limit
}
if c.CoinSource.UseOITop {
count += c.CoinSource.OITopLimit
}
if c.CoinSource.UseOILow {
count += c.CoinSource.OILowLimit
}
default:
count = c.CoinSource.AI500Limit
}
if count <= 0 {
count = 3
}
return count
}
// getEffectiveTimeframeCount returns the number of timeframes that will be used
func (c *StrategyConfig) getEffectiveTimeframeCount() int {
if len(c.Indicators.Klines.SelectedTimeframes) > 0 {
return len(c.Indicators.Klines.SelectedTimeframes)
}
count := 1
if c.Indicators.Klines.LongerTimeframe != "" {
count++
}
return count
}

View 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)
}
}

View File

@@ -112,6 +112,11 @@ func (s *UserStore) UpdatePassword(userID, passwordHash string) error {
}).Error
}
// DeleteAll deletes all users (reset system to uninitialized state)
func (s *UserStore) DeleteAll() error {
return s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&User{}).Error
}
// EnsureAdmin ensures admin user exists
func (s *UserStore) EnsureAdmin() error {
var count int64

View File

@@ -333,7 +333,13 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
if config.StrategyConfig == nil {
return nil, fmt.Errorf("[%s] strategy not configured", config.Name)
}
strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig)
// Pass claw402 wallet key to strategy engine so nofxos data requests
// are routed through claw402 (reuses the same wallet as AI calls)
var claw402Key string
if config.AIModel == "claw402" && config.CustomAPIKey != "" {
claw402Key = config.CustomAPIKey
}
strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig, claw402Key)
logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name)
return &AutoTrader{

View File

@@ -49,6 +49,12 @@ func (at *AutoTrader) checkPositionDrawdown() {
quantity = -quantity // Short position quantity is negative, convert to positive
}
// Guard: skip if entry price is zero (prevents division by zero panic)
if entryPrice <= 0 {
logger.Warnf("⚠️ Drawdown monitoring: %s %s has zero entry price, skipping", symbol, side)
continue
}
// Calculate current P&L percentage
leverage := 10 // Default value
if lev, ok := pos["leverage"].(float64); ok {

361
web/package-lock.json generated
View File

@@ -1010,9 +1010,9 @@
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1021,9 +1021,9 @@
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -1084,9 +1084,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1105,9 +1105,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -1728,9 +1728,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
"cpu": [
"arm"
],
@@ -1742,9 +1742,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
"cpu": [
"arm64"
],
@@ -1756,9 +1756,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
"cpu": [
"arm64"
],
@@ -1770,9 +1770,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
"cpu": [
"x64"
],
@@ -1784,9 +1784,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
"cpu": [
"arm64"
],
@@ -1798,9 +1798,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
"cpu": [
"x64"
],
@@ -1812,9 +1812,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
"cpu": [
"arm"
],
@@ -1826,9 +1826,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
"cpu": [
"arm"
],
@@ -1840,9 +1840,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
"cpu": [
"arm64"
],
@@ -1854,9 +1854,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
"cpu": [
"arm64"
],
@@ -1868,9 +1868,23 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
"integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
"integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
"cpu": [
"loong64"
],
@@ -1882,9 +1896,23 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
"integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
"integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
"cpu": [
"ppc64"
],
@@ -1896,9 +1924,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
"cpu": [
"riscv64"
],
@@ -1910,9 +1938,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
"cpu": [
"riscv64"
],
@@ -1924,9 +1952,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
"cpu": [
"s390x"
],
@@ -1938,9 +1966,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
"cpu": [
"x64"
],
@@ -1952,9 +1980,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
"cpu": [
"x64"
],
@@ -1965,10 +1993,24 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
"integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
"cpu": [
"arm64"
],
@@ -1980,9 +2022,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
"cpu": [
"arm64"
],
@@ -1994,9 +2036,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
"cpu": [
"ia32"
],
@@ -2008,9 +2050,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
"cpu": [
"x64"
],
@@ -2022,9 +2064,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
"cpu": [
"x64"
],
@@ -2694,9 +2736,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3029,13 +3071,13 @@
}
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -3070,9 +3112,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4285,9 +4327,9 @@
}
},
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4296,9 +4338,9 @@
}
},
"node_modules/eslint-plugin-react/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -4349,9 +4391,9 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4383,9 +4425,9 @@
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -4640,9 +4682,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -5933,9 +5975,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.merge": {
@@ -6095,13 +6137,13 @@
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -6497,9 +6539,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6968,9 +7010,9 @@
}
},
"node_modules/react-router": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz",
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==",
"version": "7.13.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
"integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -6990,12 +7032,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz",
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==",
"version": "7.13.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz",
"integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==",
"license": "MIT",
"dependencies": {
"react-router": "7.10.1"
"react-router": "7.13.2"
},
"engines": {
"node": ">=20.0.0"
@@ -7247,9 +7289,9 @@
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"version": "4.60.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7263,28 +7305,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.53.3",
"@rollup/rollup-android-arm64": "4.53.3",
"@rollup/rollup-darwin-arm64": "4.53.3",
"@rollup/rollup-darwin-x64": "4.53.3",
"@rollup/rollup-freebsd-arm64": "4.53.3",
"@rollup/rollup-freebsd-x64": "4.53.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
"@rollup/rollup-linux-arm64-musl": "4.53.3",
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
"@rollup/rollup-linux-x64-gnu": "4.53.3",
"@rollup/rollup-linux-x64-musl": "4.53.3",
"@rollup/rollup-openharmony-arm64": "4.53.3",
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
"@rollup/rollup-win32-x64-gnu": "4.53.3",
"@rollup/rollup-win32-x64-msvc": "4.53.3",
"@rollup/rollup-android-arm-eabi": "4.60.0",
"@rollup/rollup-android-arm64": "4.60.0",
"@rollup/rollup-darwin-arm64": "4.60.0",
"@rollup/rollup-darwin-x64": "4.60.0",
"@rollup/rollup-freebsd-arm64": "4.60.0",
"@rollup/rollup-freebsd-x64": "4.60.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
"@rollup/rollup-linux-arm-musleabihf": "4.60.0",
"@rollup/rollup-linux-arm64-gnu": "4.60.0",
"@rollup/rollup-linux-arm64-musl": "4.60.0",
"@rollup/rollup-linux-loong64-gnu": "4.60.0",
"@rollup/rollup-linux-loong64-musl": "4.60.0",
"@rollup/rollup-linux-ppc64-gnu": "4.60.0",
"@rollup/rollup-linux-ppc64-musl": "4.60.0",
"@rollup/rollup-linux-riscv64-gnu": "4.60.0",
"@rollup/rollup-linux-riscv64-musl": "4.60.0",
"@rollup/rollup-linux-s390x-gnu": "4.60.0",
"@rollup/rollup-linux-x64-gnu": "4.60.0",
"@rollup/rollup-linux-x64-musl": "4.60.0",
"@rollup/rollup-openbsd-x64": "4.60.0",
"@rollup/rollup-openharmony-arm64": "4.60.0",
"@rollup/rollup-win32-arm64-msvc": "4.60.0",
"@rollup/rollup-win32-ia32-msvc": "4.60.0",
"@rollup/rollup-win32-x64-gnu": "4.60.0",
"@rollup/rollup-win32-x64-msvc": "4.60.0",
"fsevents": "~2.3.2"
}
},
@@ -8094,9 +8139,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8541,9 +8586,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8632,9 +8677,9 @@
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8932,9 +8977,9 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"bin": {

View File

@@ -15,6 +15,7 @@ import { FAQPage } from './pages/FAQPage'
import { StrategyStudioPage } from './pages/StrategyStudioPage'
import { StrategyMarketPage } from './pages/StrategyMarketPage'
import { DataPage } from './pages/DataPage'
import { BeginnerOnboardingPage } from './pages/BeginnerOnboardingPage'
import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay'
import HeaderBar from './components/common/HeaderBar'
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
@@ -22,6 +23,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext'
import { ConfirmDialogProvider } from './components/common/ConfirmDialog'
import { t } from './i18n/translations'
import { useSystemConfig } from './hooks/useSystemConfig'
import { getUserMode, hasCompletedBeginnerOnboarding } from './lib/onboarding'
import { OFFICIAL_LINKS } from './constants/branding'
import type {
@@ -53,16 +55,12 @@ function App() {
const { config: systemConfig, loading: configLoading } = useSystemConfig()
const [route, setRoute] = useState(window.location.pathname)
// Debug log
useEffect(() => {
console.log('[App] Mounted. Route:', window.location.pathname);
}, []);
// 从URL路径读取初始页面状态支持刷新保持页面
const getInitialPage = (): Page => {
const path = window.location.pathname
const hash = window.location.hash.slice(1) // 去掉 #
if (path === '/welcome') return 'traders'
if (path === '/traders' || hash === 'traders') return 'traders'
if (path === '/strategy' || hash === 'strategy') return 'strategy'
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
@@ -132,6 +130,20 @@ function App() {
}
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--')
const [decisionsLimit, setDecisionsLimit] = useState<number>(5)
const hasPersistedAuth =
!!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user')
// Poll-off states: stop polling after 3 consecutive failures
const [accountPollOff, setAccountPollOff] = useState(false)
const [positionsPollOff, setPositionsPollOff] = useState(false)
const [decisionsPollOff, setDecisionsPollOff] = useState(false)
// Reset poll-off states when trader changes
useEffect(() => {
setAccountPollOff(false)
setPositionsPollOff(false)
setDecisionsPollOff(false)
}, [selectedTraderId])
// 监听URL变化同步页面状态
useEffect(() => {
@@ -141,7 +153,9 @@ function App() {
const params = new URLSearchParams(window.location.search)
const traderParam = params.get('trader')
if (path === '/traders' || hash === 'traders') {
if (path === '/welcome') {
setCurrentPage('traders')
} else if (path === '/traders' || hash === 'traders') {
setCurrentPage('traders')
} else if (path === '/strategy' || hash === 'strategy') {
setCurrentPage('strategy')
@@ -156,9 +170,7 @@ function App() {
) {
setCurrentPage('trader')
// 如果 URL 中有 trader 参数slug 格式),更新选中的 trader
if (traderParam) {
setSelectedTraderSlug(traderParam)
}
setSelectedTraderSlug(traderParam || undefined)
} else if (
path === '/competition' ||
hash === 'competition' ||
@@ -186,7 +198,7 @@ function App() {
// 获取trader列表仅在用户登录时
const { data: traders, error: tradersError } = useSWR<TraderInfo[]>(
user && token ? 'traders' : null,
api.getTraders,
() => api.getTraders(currentPage === 'trader'),
{
refreshInterval: 10000,
shouldRetryOnError: false, // 避免在后端未运行时无限重试
@@ -205,19 +217,22 @@ function App() {
// 当获取到traders后根据 URL 中的 trader slug 设置选中的 trader或默认选中第一个
useEffect(() => {
if (traders && traders.length > 0 && !selectedTraderId) {
if (selectedTraderSlug) {
// 通过 slug 找到对应的 trader
const trader = findTraderBySlug(selectedTraderSlug, traders)
if (trader) {
setSelectedTraderId(trader.trader_id)
} else {
// 如果找不到,选中第一个
setSelectedTraderId(traders[0].trader_id)
}
} else {
setSelectedTraderId(traders[0].trader_id)
if (!traders || traders.length === 0) {
return
}
if (selectedTraderSlug) {
// 通过 slug 找到对应的 trader
const trader = findTraderBySlug(selectedTraderSlug, traders)
const nextTraderId = trader?.trader_id || traders[0].trader_id
if (nextTraderId !== selectedTraderId) {
setSelectedTraderId(nextTraderId)
}
return
}
if (!selectedTraderId) {
setSelectedTraderId(traders[0].trader_id)
}
}, [traders, selectedTraderId, selectedTraderSlug])
@@ -226,7 +241,7 @@ function App() {
currentPage === 'trader' && selectedTraderId
? `status-${selectedTraderId}`
: null,
() => api.getStatus(selectedTraderId),
() => api.getStatus(selectedTraderId, true),
{
refreshInterval: 15000, // 15秒刷新配合后端15秒缓存
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
@@ -238,11 +253,16 @@ function App() {
currentPage === 'trader' && selectedTraderId
? `account-${selectedTraderId}`
: null,
() => api.getAccount(selectedTraderId),
() => api.getAccount(selectedTraderId, true),
{
refreshInterval: 15000, // 15秒刷新配合后端15秒缓存
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
dedupingInterval: 10000, // 10秒去重防止短时间内重复请求
refreshInterval: accountPollOff ? 0 : 15000,
revalidateOnFocus: false,
dedupingInterval: 10000,
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
if (retryCount >= 2) { setAccountPollOff(true); return }
setTimeout(() => revalidate({ retryCount }), 500)
},
onSuccess: () => { if (accountPollOff) setAccountPollOff(false) },
}
)
@@ -250,11 +270,16 @@ function App() {
currentPage === 'trader' && selectedTraderId
? `positions-${selectedTraderId}`
: null,
() => api.getPositions(selectedTraderId),
() => api.getPositions(selectedTraderId, true),
{
refreshInterval: 15000, // 15秒刷新配合后端15秒缓存
revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求
dedupingInterval: 10000, // 10秒去重防止短时间内重复请求
refreshInterval: positionsPollOff ? 0 : 15000,
revalidateOnFocus: false,
dedupingInterval: 10000,
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
if (retryCount >= 2) { setPositionsPollOff(true); return }
setTimeout(() => revalidate({ retryCount }), 500)
},
onSuccess: () => { if (positionsPollOff) setPositionsPollOff(false) },
}
)
@@ -262,11 +287,16 @@ function App() {
currentPage === 'trader' && selectedTraderId
? `decisions/latest-${selectedTraderId}-${decisionsLimit}`
: null,
() => api.getLatestDecisions(selectedTraderId, decisionsLimit),
() => api.getLatestDecisions(selectedTraderId, decisionsLimit, true),
{
refreshInterval: 30000, // 30秒刷新决策更新频率较低
refreshInterval: decisionsPollOff ? 0 : 30000,
revalidateOnFocus: false,
dedupingInterval: 20000,
onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => {
if (retryCount >= 2) { setDecisionsPollOff(true); return }
setTimeout(() => revalidate({ retryCount }), 500)
},
onSuccess: () => { if (decisionsPollOff) setDecisionsPollOff(false) },
}
)
@@ -274,7 +304,7 @@ function App() {
currentPage === 'trader' && selectedTraderId
? `statistics-${selectedTraderId}`
: null,
() => api.getStatistics(selectedTraderId),
() => api.getStatistics(selectedTraderId, true),
{
refreshInterval: 30000, // 30秒刷新统计数据更新频率较低
revalidateOnFocus: false,
@@ -291,6 +321,10 @@ function App() {
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId)
const effectiveAccount = account
const effectivePositions = positions
const effectiveDecisions = decisions
// Handle routing
useEffect(() => {
const handlePopState = () => {
@@ -302,7 +336,9 @@ function App() {
// Set current page based on route for consistent navigation state
useEffect(() => {
if (route === '/competition') {
if (route === '/welcome') {
setCurrentPage('traders')
} else if (route === '/competition') {
setCurrentPage('competition')
} else if (route === '/traders') {
setCurrentPage('traders')
@@ -311,6 +347,9 @@ function App() {
}
}, [route])
const showBeginnerOnboarding =
route === '/welcome' && (!!user || hasPersistedAuth) && getUserMode() === 'beginner' && !hasCompletedBeginnerOnboarding()
// Show loading spinner while checking auth or config
if (isLoading || configLoading) {
return (
@@ -347,6 +386,16 @@ function App() {
}
return <SetupPage />
}
if (route === '/welcome') {
if ((!user || !token) && !hasPersistedAuth) {
window.location.href = '/login'
return null
}
if (getUserMode() !== 'beginner') {
window.location.href = '/traders'
return null
}
}
if (route === '/faq') {
return (
<div
@@ -376,7 +425,7 @@ function App() {
return <ResetPasswordPage />
}
if (route === '/settings') {
if (!user || !token) {
if ((!user || !token) && !hasPersistedAuth) {
window.location.href = '/login'
return null
}
@@ -398,19 +447,7 @@ function App() {
// Data page - publicly accessible with embedded dashboard
if (route === '/data') {
const dataPageNavigate = (page: Page) => {
const pathMap: Record<string, string> = {
'data': '/data',
'competition': '/competition',
'strategy-market': '/strategy-market',
'traders': '/traders',
'trader': '/dashboard',
'strategy': '/strategy',
'faq': '/faq',
}
const path = pathMap[page]
if (path) {
window.location.href = path
}
navigateToPage(page)
}
return (
<div
@@ -484,7 +521,18 @@ function App() {
<AITradersPage
onTraderSelect={(traderId) => {
setSelectedTraderId(traderId)
window.history.pushState({}, '', '/dashboard')
const trader = traders?.find((item) => item.trader_id === traderId)
const url = new URL(window.location.href)
url.pathname = '/dashboard'
if (trader) {
const slug = getTraderSlug(trader)
url.searchParams.set('trader', slug)
setSelectedTraderSlug(slug)
} else {
url.searchParams.delete('trader')
setSelectedTraderSlug(undefined)
}
window.history.pushState({}, '', url.toString())
setRoute('/dashboard')
setCurrentPage('trader')
}}
@@ -495,9 +543,12 @@ function App() {
<TraderDashboardPage
selectedTrader={selectedTrader}
status={status}
account={account}
positions={positions}
decisions={decisions}
account={effectiveAccount}
accountFailed={accountPollOff}
positions={effectivePositions}
positionsFailed={positionsPollOff}
decisions={effectiveDecisions}
decisionsFailed={decisionsPollOff}
decisionsLimit={decisionsLimit}
onDecisionsLimitChange={setDecisionsLimit}
stats={stats}
@@ -511,8 +562,10 @@ function App() {
// 更新 URL 参数(使用 slug: name-id前4位
const trader = traders?.find(t => t.trader_id === traderId)
if (trader) {
const slug = getTraderSlug(trader)
setSelectedTraderSlug(slug)
const url = new URL(window.location.href)
url.searchParams.set('trader', getTraderSlug(trader))
url.searchParams.set('trader', slug)
window.history.replaceState({}, '', url.toString())
}
}}
@@ -646,6 +699,8 @@ function App() {
onClose={() => setLoginOverlayOpen(false)}
featureName={loginOverlayFeature}
/>
{showBeginnerOnboarding && <BeginnerOnboardingPage />}
</div>
)
}

View File

@@ -5,6 +5,10 @@ import { useAuth } from '../../contexts/AuthContext'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
import { DeepVoidBackground } from '../common/DeepVoidBackground'
import { LanguageSwitcher } from '../common/LanguageSwitcher'
import { OnboardingModeSelector } from './OnboardingModeSelector'
import type { UserMode } from '../../lib/onboarding'
import { invalidateSystemConfig } from '../../lib/config'
export function LoginPage() {
const { language } = useLanguage()
@@ -15,7 +19,16 @@ export function LoginPage() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
const [mode, setMode] = useState<UserMode>('beginner')
// Clean up stale auth state once on mount
useEffect(() => {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
localStorage.removeItem('user_id')
}, [])
// Show session-expired toast (re-runs on language change to update text)
useEffect(() => {
if (sessionStorage.getItem('from401') === 'true') {
const id = toast.warning(t('sessionExpired', language), { duration: Infinity })
@@ -24,11 +37,32 @@ export function LoginPage() {
}
}, [language])
const handleResetAccount = async () => {
if (!window.confirm(t('forgotAccountConfirm', language))) return
try {
const res = await fetch('/api/reset-account', { method: 'POST' })
if (res.ok) {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
localStorage.removeItem('user_id')
sessionStorage.removeItem('from401')
invalidateSystemConfig()
toast.success(t('forgotAccountSuccess', language))
setTimeout(() => { window.location.href = '/setup' }, 1500)
} else {
const data = await res.json()
toast.error(data.error || 'Reset failed')
}
} catch {
toast.error('Network error')
}
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
const result = await login(email, password)
const result = await login(email, password, mode)
setLoading(false)
if (result.success) {
if (expiredToastId) toast.dismiss(expiredToastId)
@@ -41,6 +75,8 @@ export function LoginPage() {
return (
<DeepVoidBackground disableAnimation>
<LanguageSwitcher />
<div className="flex-1 flex items-center justify-center px-4 py-16">
<div className="w-full max-w-sm">
@@ -109,6 +145,12 @@ export function LoginPage() {
</div>
</div>
<OnboardingModeSelector
language={language}
mode={mode}
onChange={setMode}
/>
{/* Error */}
{error && (
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
@@ -125,6 +167,16 @@ export function LoginPage() {
{loading ? t('loggingIn', language) || 'Signing in...' : t('signIn', language) || 'Sign In'}
</button>
</form>
<div className="mt-4 text-center">
<button
type="button"
onClick={handleResetAccount}
className="text-xs text-zinc-600 hover:text-red-400 transition-colors"
>
{t('forgotAccount', language)}
</button>
</div>
</div>
</div>

View 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>
)
}

View File

@@ -153,7 +153,7 @@ export function AdvancedChart({
try {
const limit = 1500
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}&exchange=${exchange}`
const result = await httpClient.get(klineUrl)
const result = await httpClient.request(klineUrl, { silent: true })
if (!result.success || !result.data) {
throw new Error('Failed to fetch kline data')
@@ -243,7 +243,10 @@ export function AdvancedChart({
try {
console.log('[AdvancedChart] Fetching orders for trader:', traderID, 'symbol:', symbol)
// Fetch filled orders, up to 200 for more history
const result = await httpClient.get(`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=200`)
const result = await httpClient.request(
`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=200`,
{ silent: true }
)
console.log('[AdvancedChart] Orders API response:', result)
@@ -326,7 +329,10 @@ export function AdvancedChart({
const fetchOpenOrders = async (traderID: string, symbol: string): Promise<OpenOrder[]> => {
try {
console.log('[AdvancedChart] Fetching open orders for trader:', traderID, 'symbol:', symbol)
const result = await httpClient.get(`/api/open-orders?trader_id=${traderID}&symbol=${symbol}`)
const result = await httpClient.request(
`/api/open-orders?trader_id=${traderID}&symbol=${symbol}`,
{ silent: true }
)
console.log('[AdvancedChart] Open orders API response:', result)

View File

@@ -123,7 +123,6 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
// Auto-switch to kline chart when symbol selected externally
useEffect(() => {
if (selectedSymbol) {
console.log('[ChartTabs] Symbol selected:', selectedSymbol, 'updateKey:', updateKey)
setChartSymbol(selectedSymbol)
setActiveTab('kline')
}
@@ -143,8 +142,6 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
}
}
console.log('[ChartTabs] rendering, activeTab:', activeTab)
return (
<div className={`nofx-glass rounded-lg border border-white/5 relative z-10 w-full flex flex-col transition-all duration-300 ${typeof window !== 'undefined' && window.innerWidth < 768 ? 'h-[500px]' : 'h-[600px]'
}`}>

View File

@@ -114,7 +114,7 @@ export function ChartWithOrders({
const limit = 2000 // Fetch recent 2000 candles (more historical data)
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}&exchange=${exchange}`
const result = await httpClient.get(klineUrl)
const result = await httpClient.request(klineUrl, { silent: true })
if (!result.success || !result.data) {
throw new Error('Failed to fetch kline data from our service')
@@ -142,7 +142,10 @@ export function ChartWithOrders({
const fetchOrders = async (traderID: string, symbol: string): Promise<OrderMarker[]> => {
try {
// Fetch filled orders for this trader from backend API
const result = await httpClient.get(`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=50`)
const result = await httpClient.request(
`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=50`,
{ silent: true }
)
if (!result.success || !result.data) {
console.warn('Failed to fetch orders:', result.message)

View File

@@ -31,7 +31,7 @@ export function ChartWithOrdersSimple({
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}`
console.log('[ChartSimple] Fetching klines from our service:', klineUrl)
const klineResult = await httpClient.get(klineUrl)
const klineResult = await httpClient.request(klineUrl, { silent: true })
if (!klineResult.success || !klineResult.data) {
throw new Error('Failed to fetch klines from our service')
@@ -44,7 +44,7 @@ export function ChartWithOrdersSimple({
if (traderID) {
const tradesUrl = `/api/trades?trader_id=${traderID}&symbol=${symbol}&limit=100`
console.log('[ChartSimple] Fetching trades from:', tradesUrl)
const tradesResult = await httpClient.get(tradesUrl)
const tradesResult = await httpClient.request(tradesUrl, { silent: true })
if (tradesResult.success && tradesResult.data) {
console.log('[ChartSimple] Received trades:', tradesResult.data.length)

View File

@@ -43,7 +43,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
const { data: history, error, isLoading } = useSWR<EquityPoint[]>(
user && token && traderId ? `equity-history-${traderId}` : null,
() => api.getEquityHistory(traderId),
() => api.getEquityHistory(traderId, true),
{
refreshInterval: 30000, // 30秒刷新历史数据更新频率较低
revalidateOnFocus: false,
@@ -53,7 +53,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
const { data: account } = useSWR(
user && token && traderId ? `account-${traderId}` : null,
() => api.getAccount(traderId),
() => api.getAccount(traderId, true),
{
refreshInterval: 15000, // 15秒刷新配合后端缓存
revalidateOnFocus: false,

View File

@@ -9,27 +9,35 @@ interface DeepVoidBackgroundProps extends React.HTMLAttributes<HTMLDivElement> {
export function DeepVoidBackground({ children, className = '', disableAnimation = false, ...props }: DeepVoidBackgroundProps) {
return (
<div className={`relative w-full min-h-screen bg-nofx-bg text-nofx-text overflow-hidden flex flex-col ${className}`} {...props}>
{/* BACKGROUND LAYERS */}
{/* Background layers: use a much lighter static stack when animations are disabled */}
{disableAnimation ? (
<>
<div className="absolute inset-0 pointer-events-none z-0 bg-[radial-gradient(circle_at_top,rgba(240,185,11,0.08),transparent_38%),linear-gradient(180deg,rgba(12,14,20,0.98),rgba(8,10,15,1))]"></div>
<div className="absolute inset-0 pointer-events-none z-0 opacity-[0.035] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:36px_36px]"></div>
</>
) : (
<>
{/* 1. Grain/Noise Texture */}
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 mix-blend-soft-light pointer-events-none fixed z-0"></div>
{/* 1. Grain/Noise Texture */}
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 mix-blend-soft-light pointer-events-none fixed z-0"></div>
{/* 2. Grid System */}
<div className="absolute inset-0 pointer-events-none fixed z-0">
<div className="absolute inset-x-0 bottom-0 h-[50vh] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] opacity-50" style={{ transform: 'perspective(500px) rotateX(60deg) translateY(100px) scale(2)' }}></div>
<div className="absolute inset-0 bg-grid-pattern opacity-[0.03]"></div>
</div>
{/* 2. Grid System */}
<div className="absolute inset-0 pointer-events-none fixed z-0">
<div className="absolute inset-x-0 bottom-0 h-[50vh] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] opacity-50" style={{ transform: 'perspective(500px) rotateX(60deg) translateY(100px) scale(2)' }}></div>
<div className="absolute inset-0 bg-grid-pattern opacity-[0.03]"></div>
</div>
{/* 3. Ambient Glow Spots */}
<div className="absolute inset-0 overflow-hidden pointer-events-none fixed z-0">
<div className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-nofx-gold/10 rounded-full blur-[120px] mix-blend-screen animate-pulse-slow"></div>
<div className="absolute bottom-[-10%] right-[-10%] w-[40vw] h-[40vw] bg-nofx-accent/5 rounded-full blur-[120px] mix-blend-screen animate-pulse-slow" style={{ animationDelay: '2s' }}></div>
</div>
{/* 3. Ambient Glow Spots */}
<div className="absolute inset-0 overflow-hidden pointer-events-none fixed z-0">
<div className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-nofx-gold/10 rounded-full blur-[120px] mix-blend-screen animate-pulse-slow"></div>
<div className="absolute bottom-[-10%] right-[-10%] w-[40vw] h-[40vw] bg-nofx-accent/5 rounded-full blur-[120px] mix-blend-screen animate-pulse-slow" style={{ animationDelay: '2s' }}></div>
</div>
{/* 4. CRT/Scanline Overlay */}
<div className="absolute inset-0 pointer-events-none fixed z-[9999] opacity-40">
<div className="absolute inset-0 bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,255,0.06))] bg-[length:100%_4px,3px_100%] pointer-events-none"></div>
</div>
{/* 4. CRT/Scanline Overlay */}
<div className="absolute inset-0 pointer-events-none fixed z-[9999] opacity-40">
<div className="absolute inset-0 bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,255,0.06))] bg-[length:100%_4px,3px_100%] pointer-events-none"></div>
</div>
</>
)}
{/* Content Layer */}
<div className="relative z-10 flex-1 flex flex-col h-full w-full">

View File

@@ -4,6 +4,12 @@ import { motion, AnimatePresence } from 'framer-motion'
import { Menu, X, ChevronDown, Settings } from 'lucide-react'
import { t, type Language } from '../../i18n/translations'
import { OFFICIAL_LINKS } from '../../constants/branding'
import {
getPostAuthPath,
getUserMode,
setUserMode,
type UserMode,
} from '../../lib/onboarding'
type Page =
| 'competition'
@@ -44,8 +50,21 @@ export default function HeaderBar({
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
const dropdownRef = useRef<HTMLDivElement>(null)
const userDropdownRef = useRef<HTMLDivElement>(null)
const navigateInApp = (path: string) => {
navigate(path)
window.dispatchEvent(new PopStateEvent('popstate'))
}
const handleSwitchMode = (nextMode: UserMode) => {
setUserMode(nextMode)
setUserModeState(nextMode)
setUserDropdownOpen(false)
navigateInApp(getPostAuthPath(nextMode))
}
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
@@ -216,6 +235,15 @@ export default function HeaderBar({
<Settings className="w-3.5 h-3.5" />
Settings
</button>
<button
onClick={() => handleSwitchMode(userMode === 'beginner' ? 'advanced' : 'beginner')}
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
>
<Settings className="w-3.5 h-3.5" />
{userMode === 'beginner'
? language === 'zh' ? '切到老手模式' : 'Switch to Advanced'
: language === 'zh' ? '切到新手模式' : 'Switch to Beginner'}
</button>
{onLogout && (
<button
onClick={() => {

View 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>
)
}

View File

@@ -1,65 +1,163 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
import { DeepVoidBackground } from '../common/DeepVoidBackground'
import { invalidateSystemConfig } from '../../lib/config'
import { OnboardingModeSelector } from '../auth/OnboardingModeSelector'
import type { UserMode } from '../../lib/onboarding'
import { useLanguage } from '../../contexts/LanguageContext'
import { LanguageSwitcher } from '../common/LanguageSwitcher'
const labels = {
zh: {
welcome: '欢迎使用 NOFX',
subtitle: '创建账号开始使用',
email: '邮箱',
emailPlaceholder: 'you@example.com',
password: '密码',
passwordPlaceholder: '至少 8 个字符',
passwordError: '密码至少需要 8 个字符',
submit: '开始使用',
submitting: '创建中...',
setupFailed: '创建失败,请重试',
singleUser: '单用户系统 — 这是唯一的账号',
},
en: {
welcome: 'Welcome to NOFX',
subtitle: 'Create your account to get started',
email: 'Email',
emailPlaceholder: 'you@example.com',
password: 'Password',
passwordPlaceholder: 'At least 8 characters',
passwordError: 'Password must be at least 8 characters',
submit: 'Get Started',
submitting: 'Creating account...',
setupFailed: 'Setup failed, please try again',
singleUser: 'Single-user system — this is the only account',
},
id: {
welcome: 'Selamat Datang di NOFX',
subtitle: 'Buat akun untuk memulai',
email: 'Email',
emailPlaceholder: 'you@example.com',
password: 'Kata Sandi',
passwordPlaceholder: 'Minimal 8 karakter',
passwordError: 'Kata sandi minimal 8 karakter',
submit: 'Mulai',
submitting: 'Membuat akun...',
setupFailed: 'Gagal membuat akun, coba lagi',
singleUser: 'Sistem pengguna tunggal — ini satu-satunya akun',
},
} as const
export function SetupPage() {
const { language } = useLanguage()
const { register } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [mode, setMode] = useState<UserMode>('beginner')
// Clean up any stale auth/onboarding state on setup page load
useEffect(() => {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
localStorage.removeItem('user_id')
localStorage.removeItem('nofx_beginner_onboarding_completed')
localStorage.removeItem('nofx_beginner_wallet_address')
}, [])
const l = labels[language as keyof typeof labels] || labels.en
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (password.length < 8) {
setError('Password must be at least 8 characters')
setError(l.passwordError)
return
}
setLoading(true)
const result = await register(email, password)
const result = await register(email, password, undefined, mode)
setLoading(false)
if (result.success) {
invalidateSystemConfig()
window.location.href = '/traders'
} else {
setError(result.message || 'Setup failed, please try again')
setError(result.message || l.setupFailed)
}
}
return (
<DeepVoidBackground disableAnimation>
<div className="flex-1 flex items-center justify-center px-4 py-16">
<div className="w-full max-w-sm">
<div className="relative min-h-screen w-full overflow-hidden bg-[#0a0a0f]">
{/* Decorative background - simulates the main app behind a modal */}
{/* Grid */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-x-0 bottom-0 h-[60vh] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] opacity-40" style={{ transform: 'perspective(500px) rotateX(60deg) translateY(80px) scale(2)' }} />
</div>
{/* Glow spots */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-[10%] left-[15%] w-[500px] h-[500px] bg-nofx-gold/8 rounded-full blur-[150px]" />
<div className="absolute bottom-[5%] right-[10%] w-[400px] h-[400px] bg-indigo-500/6 rounded-full blur-[140px]" />
<div className="absolute top-[40%] right-[30%] w-[300px] h-[300px] bg-emerald-500/4 rounded-full blur-[120px]" />
</div>
{/* Faux UI elements in background to simulate the app */}
<div className="absolute inset-0 pointer-events-none opacity-[0.06]">
{/* Fake header bar */}
<div className="h-14 border-b border-white/20 flex items-center px-6 gap-4">
<div className="w-8 h-8 rounded-lg bg-white/40" />
<div className="h-3 w-20 rounded bg-white/30" />
<div className="h-3 w-16 rounded bg-white/20 ml-4" />
<div className="h-3 w-16 rounded bg-white/20" />
<div className="h-3 w-16 rounded bg-white/20" />
</div>
{/* Fake content cards */}
<div className="p-6 grid grid-cols-4 gap-4 mt-2">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-24 rounded-xl border border-white/15 bg-white/5" />
))}
</div>
<div className="px-6 mt-2">
<div className="h-64 rounded-xl border border-white/15 bg-white/5" />
</div>
</div>
{/* Blur overlay */}
<div className="absolute inset-0 backdrop-blur-md bg-black/60" />
<LanguageSwitcher />
{/* Modal card */}
<div className="relative z-10 flex min-h-screen items-center justify-center px-4 py-16">
<div className="w-full max-w-sm animate-[fadeInUp_0.4s_ease-out]">
{/* Logo + Title */}
<div className="text-center mb-10">
<div className="flex justify-center mb-5">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="relative">
<div className="absolute -inset-3 bg-nofx-gold/15 rounded-full blur-2xl" />
<img src="/icons/nofx.svg" alt="NOFX" className="w-14 h-14 relative z-10" />
<div className="absolute -inset-4 bg-nofx-gold/20 rounded-full blur-2xl" />
<img src="/icons/nofx.svg" alt="NOFX" className="w-14 h-14 relative z-10 drop-shadow-[0_0_15px_rgba(240,185,11,0.3)]" />
</div>
</div>
<h1 className="text-2xl font-bold text-white mb-1.5">Welcome to NOFX</h1>
<p className="text-zinc-500 text-sm">Create your account to get started</p>
<h1 className="text-2xl font-bold text-white mb-1.5">{l.welcome}</h1>
<p className="text-zinc-500 text-sm">{l.subtitle}</p>
</div>
{/* Card */}
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-8 shadow-2xl">
<div className="bg-zinc-900/80 backdrop-blur-2xl border border-white/10 rounded-2xl p-8 shadow-[0_25px_60px_-15px_rgba(0,0,0,0.5),0_0_40px_-10px_rgba(240,185,11,0.08)]">
<form onSubmit={handleSubmit} className="space-y-5">
{/* Email */}
<div>
<label className="block text-xs font-medium text-zinc-400 mb-2">Email</label>
<label className="block text-xs font-medium text-zinc-400 mb-2">{l.email}</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
placeholder="you@example.com"
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
placeholder={l.emailPlaceholder}
required
autoFocus
/>
@@ -67,14 +165,14 @@ export function SetupPage() {
{/* Password */}
<div>
<label className="block text-xs font-medium text-zinc-400 mb-2">Password</label>
<label className="block text-xs font-medium text-zinc-400 mb-2">{l.password}</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
placeholder="At least 8 characters"
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
placeholder={l.passwordPlaceholder}
required
/>
<button
@@ -87,6 +185,12 @@ export function SetupPage() {
</div>
</div>
<OnboardingModeSelector
language={language}
mode={mode}
onChange={setMode}
/>
{/* Error */}
{error && (
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
@@ -98,18 +202,25 @@ export function SetupPage() {
<button
type="submit"
disabled={loading}
className="w-full bg-nofx-gold hover:bg-yellow-400 active:scale-[0.98] text-black font-semibold py-3 rounded-xl text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-2"
className="w-full bg-nofx-gold hover:bg-yellow-400 active:scale-[0.98] text-black font-semibold py-3 rounded-xl text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-2 shadow-[0_0_20px_rgba(240,185,11,0.2)]"
>
{loading ? 'Creating account...' : 'Get Started'}
{loading ? l.submitting : l.submit}
</button>
</form>
</div>
<p className="text-center text-xs text-zinc-600 mt-6">
Single-user system &mdash; this is the only account
{l.singleUser}
</p>
</div>
</div>
</DeepVoidBackground>
<style>{`
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react'
import type { CoinSourceConfig } from '../../types'
import { coinSource, ts } from '../../i18n/strategy-translations'
import { NofxSelect } from '../ui/select'
interface CoinSourceEditorProps {
config: CoinSourceConfig
@@ -24,7 +25,6 @@ export function CoinSourceEditor({
{ value: 'ai500', icon: Database, color: '#F0B90B' },
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
{ value: 'mixed', icon: Shuffle, color: '#60a5fa' },
] as const
// Calculate mixed mode summary
@@ -33,16 +33,16 @@ export function CoinSourceEditor({
let totalLimit = 0
if (config.use_ai500) {
sources.push(`AI500(${config.ai500_limit || 10})`)
totalLimit += config.ai500_limit || 10
sources.push(`AI500(${config.ai500_limit || 3})`)
totalLimit += config.ai500_limit || 3
}
if (config.use_oi_top) {
sources.push(`${ts(coinSource.oiIncreaseShort, language)}(${config.oi_top_limit || 10})`)
totalLimit += config.oi_top_limit || 10
sources.push(`${ts(coinSource.oiIncreaseShort, language)}(${config.oi_top_limit || 3})`)
totalLimit += config.oi_top_limit || 3
}
if (config.use_oi_low) {
sources.push(`${ts(coinSource.oiDecreaseShort, language)}(${config.oi_low_limit || 10})`)
totalLimit += config.oi_low_limit || 10
sources.push(`${ts(coinSource.oiDecreaseShort, language)}(${config.oi_low_limit || 3})`)
totalLimit += config.oi_low_limit || 3
}
if ((config.static_coins || []).length > 0) {
sources.push(`${ts(coinSource.custom, language)}(${config.static_coins?.length || 0})`)
@@ -71,8 +71,26 @@ export function CoinSourceEditor({
return xyzDexAssets.has(base)
}
const MAX_STATIC_COINS = 10
const showToast = (msg: string) => {
const toast = document.createElement('div')
toast.textContent = msg
toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg'
toast.style.cssText = 'background:#F6465D;color:#fff;'
document.body.appendChild(toast)
setTimeout(() => toast.remove(), 2000)
}
const handleAddCoin = () => {
if (!newCoin.trim()) return
const currentCoins = config.static_coins || []
if (currentCoins.length >= MAX_STATIC_COINS) {
showToast(language === 'zh' ? `最多添加 ${MAX_STATIC_COINS} 个币种` : `Maximum ${MAX_STATIC_COINS} coins allowed`)
return
}
const symbol = newCoin.toUpperCase().trim()
// For xyz dex assets (stocks, forex, commodities), use xyz: prefix without USDT
@@ -85,7 +103,6 @@ export function CoinSourceEditor({
formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT`
}
const currentCoins = config.static_coins || []
if (!currentCoins.includes(formattedSymbol)) {
onChange({
...config,
@@ -148,7 +165,7 @@ export function CoinSourceEditor({
<label className="block text-sm font-medium mb-3 text-nofx-text">
{ts(coinSource.sourceType, language)}
</label>
<div className="grid grid-cols-5 gap-2">
<div className="grid grid-cols-4 gap-2">
{sourceTypes.map(({ value, icon: Icon, color }) => (
<button
key={value}
@@ -309,19 +326,16 @@ export function CoinSourceEditor({
<span className="text-sm text-nofx-text-muted">
{ts(coinSource.ai500Limit, language)}:
</span>
<select
value={config.ai500_limit || 10}
onChange={(e) =>
<NofxSelect
value={config.ai500_limit || 3}
onChange={(val) =>
!disabled &&
onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
onChange({ ...config, ai500_limit: parseInt(val) || 3 })
}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))}
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
>
{[5, 10, 15, 20, 30, 50].map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
/>
</div>
)}
@@ -366,19 +380,16 @@ export function CoinSourceEditor({
<span className="text-sm text-nofx-text-muted">
{ts(coinSource.oiTopLimit, language)}:
</span>
<select
value={config.oi_top_limit || 10}
onChange={(e) =>
<NofxSelect
value={config.oi_top_limit || 3}
onChange={(val) =>
!disabled &&
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
onChange({ ...config, oi_top_limit: parseInt(val) || 3 })
}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))}
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
>
{[5, 10, 15, 20, 30, 50].map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
/>
</div>
)}
@@ -423,19 +434,16 @@ export function CoinSourceEditor({
<span className="text-sm text-nofx-text-muted">
{ts(coinSource.oiLowLimit, language)}:
</span>
<select
value={config.oi_low_limit || 10}
onChange={(e) =>
<NofxSelect
value={config.oi_low_limit || 3}
onChange={(val) =>
!disabled &&
onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
onChange({ ...config, oi_low_limit: parseInt(val) || 3 })
}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))}
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
>
{[5, 10, 15, 20, 30, 50].map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
/>
</div>
)}
@@ -483,20 +491,13 @@ export function CoinSourceEditor({
{config.use_ai500 && (
<div className="flex items-center gap-2 mt-2 pl-6">
<span className="text-xs text-nofx-text-muted">Limit:</span>
<select
value={config.ai500_limit || 10}
onChange={(e) => {
e.stopPropagation()
!disabled && onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
}}
<NofxSelect
value={config.ai500_limit || 3}
onChange={(val) => !disabled && onChange({ ...config, ai500_limit: parseInt(val) || 3 })}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))}
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
onClick={(e) => e.stopPropagation()}
>
{[5, 10, 15, 20, 30, 50].map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
/>
</div>
)}
</div>
@@ -530,20 +531,13 @@ export function CoinSourceEditor({
{config.use_oi_top && (
<div className="flex items-center gap-2 mt-2 pl-6">
<span className="text-xs text-nofx-text-muted">Limit:</span>
<select
value={config.oi_top_limit || 10}
onChange={(e) => {
e.stopPropagation()
!disabled && onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
}}
<NofxSelect
value={config.oi_top_limit || 3}
onChange={(val) => !disabled && onChange({ ...config, oi_top_limit: parseInt(val) || 3 })}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))}
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
onClick={(e) => e.stopPropagation()}
>
{[5, 10, 15, 20, 30, 50].map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
/>
</div>
)}
</div>
@@ -577,20 +571,13 @@ export function CoinSourceEditor({
{config.use_oi_low && (
<div className="flex items-center gap-2 mt-2 pl-6">
<span className="text-xs text-nofx-text-muted">Limit:</span>
<select
value={config.oi_low_limit || 10}
onChange={(e) => {
e.stopPropagation()
!disabled && onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
}}
<NofxSelect
value={config.oi_low_limit || 3}
onChange={(val) => !disabled && onChange({ ...config, oi_low_limit: parseInt(val) || 3 })}
disabled={disabled}
options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))}
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
onClick={(e) => e.stopPropagation()}
>
{[5, 10, 15, 20, 30, 50].map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
/>
</div>
)}
</div>

View File

@@ -1,6 +1,7 @@
import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react'
import type { GridStrategyConfig } from '../../types'
import { gridConfig, ts } from '../../i18n/strategy-translations'
import { NofxSelect } from '../ui/select'
interface GridConfigEditorProps {
config: GridStrategyConfig
@@ -74,20 +75,21 @@ export function GridConfigEditor({
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(gridConfig.symbolDesc, language)}
</p>
<select
<NofxSelect
value={config.symbol}
onChange={(e) => updateField('symbol', e.target.value)}
onChange={(val) => updateField('symbol', val)}
disabled={disabled}
className="w-full px-3 py-2 rounded"
style={inputStyle}
>
<option value="BTCUSDT">BTC/USDT</option>
<option value="ETHUSDT">ETH/USDT</option>
<option value="SOLUSDT">SOL/USDT</option>
<option value="BNBUSDT">BNB/USDT</option>
<option value="XRPUSDT">XRP/USDT</option>
<option value="DOGEUSDT">DOGE/USDT</option>
</select>
options={[
{ value: 'BTCUSDT', label: 'BTC/USDT' },
{ value: 'ETHUSDT', label: 'ETH/USDT' },
{ value: 'SOLUSDT', label: 'SOL/USDT' },
{ value: 'BNBUSDT', label: 'BNB/USDT' },
{ value: 'XRPUSDT', label: 'XRP/USDT' },
{ value: 'DOGEUSDT', label: 'DOGE/USDT' },
]}
/>
</div>
{/* Investment */}
@@ -170,17 +172,18 @@ export function GridConfigEditor({
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(gridConfig.distributionDesc, language)}
</p>
<select
<NofxSelect
value={config.distribution}
onChange={(e) => updateField('distribution', e.target.value as 'uniform' | 'gaussian' | 'pyramid')}
onChange={(val) => updateField('distribution', val as 'uniform' | 'gaussian' | 'pyramid')}
disabled={disabled}
className="w-full px-3 py-2 rounded"
style={inputStyle}
>
<option value="uniform">{ts(gridConfig.uniform, language)}</option>
<option value="gaussian">{ts(gridConfig.gaussian, language)}</option>
<option value="pyramid">{ts(gridConfig.pyramid, language)}</option>
</select>
options={[
{ value: 'uniform', label: ts(gridConfig.uniform, language) },
{ value: 'gaussian', label: ts(gridConfig.gaussian, language) },
{ value: 'pyramid', label: ts(gridConfig.pyramid, language) },
]}
/>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { Clock, Activity, TrendingUp, BarChart2, Info, Lock, ExternalLink, Zap, Check, AlertCircle, Key } from 'lucide-react'
import type { IndicatorConfig } from '../../types'
import { indicator, ts } from '../../i18n/strategy-translations'
import { NofxSelect } from '../ui/select'
// Default NofxOS API Key
const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c'
@@ -60,6 +61,16 @@ export function IndicatorEditor({
})
}
} else {
if (current.length >= 4) {
// Show toast notification
const toast = document.createElement('div')
toast.textContent = language === 'zh' ? '最多选择 4 个时间维度' : 'Maximum 4 timeframes allowed'
toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg'
toast.style.cssText = 'background:#F6465D;color:#fff;'
document.body.appendChild(toast)
setTimeout(() => toast.remove(), 2000)
return
}
current.push(tf)
onChange({
...config,
@@ -299,26 +310,22 @@ export function IndicatorEditor({
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.oiRankingDesc, language)}</p>
{config.enable_oi_ranking && (
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
<select
<NofxSelect
value={config.oi_ranking_duration || '1h'}
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_duration: e.target.value })}
onChange={(val) => !disabled && onChange({ ...config, oi_ranking_duration: val })}
disabled={disabled}
className="flex-1 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
<option value="1h">1h</option>
<option value="4h">4h</option>
<option value="24h">24h</option>
</select>
<select
options={[{ value: '1h', label: '1h' }, { value: '4h', label: '4h' }, { value: '24h', label: '24h' }]}
/>
<NofxSelect
value={config.oi_ranking_limit || 10}
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(e.target.value) })}
onChange={(val) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(val) })}
disabled={disabled}
className="w-14 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
</select>
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
/>
</div>
)}
</div>
@@ -359,26 +366,22 @@ export function IndicatorEditor({
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.netflowRankingDesc, language)}</p>
{config.enable_netflow_ranking && (
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
<select
<NofxSelect
value={config.netflow_ranking_duration || '1h'}
onChange={(e) => !disabled && onChange({ ...config, netflow_ranking_duration: e.target.value })}
onChange={(val) => !disabled && onChange({ ...config, netflow_ranking_duration: val })}
disabled={disabled}
className="flex-1 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
<option value="1h">1h</option>
<option value="4h">4h</option>
<option value="24h">24h</option>
</select>
<select
options={[{ value: '1h', label: '1h' }, { value: '4h', label: '4h' }, { value: '24h', label: '24h' }]}
/>
<NofxSelect
value={config.netflow_ranking_limit || 10}
onChange={(e) => !disabled && onChange({ ...config, netflow_ranking_limit: parseInt(e.target.value) })}
onChange={(val) => !disabled && onChange({ ...config, netflow_ranking_limit: parseInt(val) })}
disabled={disabled}
className="w-14 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
</select>
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
/>
</div>
)}
</div>
@@ -419,27 +422,27 @@ export function IndicatorEditor({
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.priceRankingDesc, language)}</p>
{config.enable_price_ranking && (
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
<select
<NofxSelect
value={config.price_ranking_duration || '1h,4h,24h'}
onChange={(e) => !disabled && onChange({ ...config, price_ranking_duration: e.target.value })}
onChange={(val) => !disabled && onChange({ ...config, price_ranking_duration: val })}
disabled={disabled}
className="flex-1 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
<option value="1h">1h</option>
<option value="4h">4h</option>
<option value="24h">24h</option>
<option value="1h,4h,24h">{ts(indicator.priceRankingMulti, language)}</option>
</select>
<select
options={[
{ value: '1h', label: '1h' },
{ value: '4h', label: '4h' },
{ value: '24h', label: '24h' },
{ value: '1h,4h,24h', label: ts(indicator.priceRankingMulti, language) },
]}
/>
<NofxSelect
value={config.price_ranking_limit || 10}
onChange={(e) => !disabled && onChange({ ...config, price_ranking_limit: parseInt(e.target.value) })}
onChange={(val) => !disabled && onChange({ ...config, price_ranking_limit: parseInt(val) })}
disabled={disabled}
className="w-14 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
</select>
options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))}
/>
</div>
)}
</div>
@@ -515,7 +518,7 @@ export function IndicatorEditor({
}
disabled={disabled}
min={10}
max={200}
max={30}
className="w-16 px-2 py-1 rounded text-xs text-center"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
/>

View File

@@ -54,7 +54,7 @@ export function RiskControlEditor({
}
disabled={disabled}
min={1}
max={10}
max={3}
className="w-32 px-3 py-2 rounded"
style={{
background: '#1E2329',

View 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>
)
}

View File

@@ -7,6 +7,7 @@ import type {
CreateTraderRequest,
AIModel,
Exchange,
ExchangeAccountState,
} from '../../types'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
@@ -18,13 +19,22 @@ import { TelegramConfigModal } from './TelegramConfigModal'
import { ModelConfigModal } from './ModelConfigModal'
import { ConfigStatusGrid } from './ConfigStatusGrid'
import { TradersList } from './TradersList'
import { BeginnerGuideCards } from './BeginnerGuideCards'
import {
AlertTriangle,
Bot,
Plus,
MessageCircle,
} from 'lucide-react'
import { confirmToast } from '../../lib/notify'
import { toast } from 'sonner'
import {
getBeginnerWalletAddress,
getUserMode,
setBeginnerWalletAddress as persistBeginnerWalletAddress,
} from '../../lib/onboarding'
import type { Strategy } from '../../types'
import { ApiError } from '../../lib/httpClient'
interface AITradersPageProps {
onTraderSelect?: (traderId: string) => void
@@ -48,6 +58,197 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
const [copiedId, setCopiedId] = useState<string | null>(null)
const [quickSetupLoading, setQuickSetupLoading] = useState(false)
const [beginnerWalletAddress, setBeginnerWalletAddress] = useState<string | null>(() => getBeginnerWalletAddress())
const isBeginnerMode = getUserMode() === 'beginner'
const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message.trim() !== '') {
return error.message
}
return fallback
}
const formatActionableDescriptionByKey = (
errorKey: string,
params: Record<string, string> = {},
fallback: string
) => {
const traderName = params.trader_name || params.traderName || 'this trader'
const modelName = params.model_name || params.modelName || 'selected model'
const exchangeName = params.exchange_name || params.exchangeName || 'selected exchange account'
const reason = localizeTraderReason(params.reason_key, params.reason || fallback)
const symbol = params.symbol || ''
const zh = language === 'zh'
switch (errorKey) {
case 'trader.create.invalid_request':
return zh ? '提交的信息不完整,或者格式不正确。请检查后重新提交。' : 'The submitted information is incomplete or invalid. Please review it and try again.'
case 'trader.create.invalid_btc_eth_leverage':
return zh ? 'BTC/ETH 杠杆倍数需要在 1 到 50 倍之间。' : 'BTC/ETH leverage must be between 1x and 50x.'
case 'trader.create.invalid_altcoin_leverage':
return zh ? '山寨币杠杆倍数需要在 1 到 20 倍之间。' : 'Altcoin leverage must be between 1x and 20x.'
case 'trader.create.invalid_symbol':
return zh ? `交易对 ${symbol} 的格式不正确,目前只支持以 USDT 结尾的合约交易对。` : `Trading pair ${symbol} is invalid. Only perpetual pairs ending with USDT are supported.`
case 'trader.create.model_not_found':
return zh ? '还没有找到你选择的 AI 模型。请先到「设置 > 模型配置」添加并启用一个可用模型。' : 'The selected AI model was not found. Please add and enable a valid model in Settings > Model Config.'
case 'trader.create.model_disabled':
return zh ? `AI 模型「${modelName}」目前还没有启用。请先启用它再创建机器人。` : `AI model "${modelName}" is currently disabled. Please enable it before creating a trader.`
case 'trader.create.model_missing_credentials':
return zh ? `AI 模型「${modelName}」缺少 API Key 或支付凭证。请先补全模型配置。` : `AI model "${modelName}" is missing API credentials or payment setup. Please complete the model configuration first.`
case 'trader.create.strategy_required':
return zh ? '你还没有选择交易策略。请先选择一个策略,再继续创建机器人。' : 'No trading strategy is selected yet. Please choose a strategy before creating a trader.'
case 'trader.create.strategy_not_found':
return zh ? '你选择的策略不存在,或者已经被删除了。请重新选择一个可用策略。' : 'The selected strategy no longer exists. Please choose another available strategy.'
case 'trader.create.exchange_not_found':
return zh ? '还没有找到你选择的交易所账户。请先到「设置 > 交易所配置」添加一个可用账户。' : 'The selected exchange account was not found. Please add an exchange account in Settings > Exchange Config.'
case 'trader.create.exchange_disabled':
return zh ? `交易所账户「${exchangeName}」目前处于未启用状态。请先启用它。` : `Exchange account "${exchangeName}" is currently disabled. Please enable it first.`
case 'trader.create.exchange_missing_fields':
return zh ? `交易所账户「${exchangeName}」的配置还不完整。请先补全必填信息。` : `Exchange account "${exchangeName}" is incomplete. Please fill in the required fields first.`
case 'trader.create.exchange_unsupported':
return zh ? `交易所账户「${exchangeName}」当前类型暂不支持机器人创建。` : `Exchange account "${exchangeName}" uses a type that is not supported for trader creation.`
case 'trader.create.exchange_probe_failed':
return zh ? `交易所账户「${exchangeName}」没有通过初始化校验,原因是:${reason}` : `Exchange account "${exchangeName}" failed initialization checks: ${reason}`
case 'trader.start.strategy_missing':
return zh ? `机器人「${traderName}」缺少有效的交易策略配置。` : `Trader "${traderName}" does not have a valid strategy configuration.`
case 'trader.start.model_not_found':
return zh ? `机器人「${traderName}」关联的 AI 模型不存在。请检查模型配置。` : `Trader "${traderName}" references an AI model that no longer exists. Please check the model configuration.`
case 'trader.start.model_disabled':
return zh ? `机器人「${traderName}」关联的 AI 模型「${modelName}」目前还没有启用。` : `Trader "${traderName}" uses AI model "${modelName}", which is currently disabled.`
case 'trader.start.exchange_not_found':
return zh ? `机器人「${traderName}」关联的交易所账户不存在。请检查交易所配置。` : `Trader "${traderName}" references an exchange account that no longer exists. Please check the exchange configuration.`
case 'trader.start.exchange_disabled':
return zh ? `机器人「${traderName}」关联的交易所账户「${exchangeName}」目前还没有启用。` : `Trader "${traderName}" uses exchange account "${exchangeName}", which is currently disabled.`
case 'trader.start.setup_invalid':
case 'trader.start.load_failed':
return zh ? `机器人「${traderName}」暂时还不能启动,原因是:${reason}` : `Trader "${traderName}" cannot be started yet because ${reason}`
default:
return fallback
}
}
const localizeTraderReason = (reasonKey?: string, fallback?: string) => {
const zh = language === 'zh'
switch (reasonKey) {
case 'trader.reason.strategy_config_invalid':
return zh ? '当前策略配置内容已损坏,系统暂时无法解析' : 'the current strategy configuration is corrupted and cannot be parsed'
case 'trader.reason.strategy_missing':
return zh ? '当前机器人缺少有效的交易策略配置' : 'the trader is missing a valid strategy configuration'
case 'trader.reason.private_key_invalid':
return zh ? '私钥格式不正确,系统无法识别' : 'the private key format is invalid and cannot be recognized'
case 'trader.reason.hyperliquid_init_failed':
return zh ? 'Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确' : 'Hyperliquid account initialization failed. Please verify the private key, main wallet address, and Agent Wallet configuration'
case 'trader.reason.aster_init_failed':
return zh ? 'Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确' : 'Aster account initialization failed. Please verify the Aster User, Signer, and private key'
case 'trader.reason.exchange_meta_unavailable':
return zh ? '系统暂时无法从交易所读取账户元信息' : 'the system could not read account metadata from the exchange'
case 'trader.reason.hyperliquid_agent_balance_too_high':
return zh ? 'Hyperliquid Agent Wallet 余额过高,不符合当前安全要求' : 'the Hyperliquid Agent Wallet balance is too high for the current safety requirements'
case 'trader.reason.exchange_account_init_failed':
return zh ? '交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配' : 'exchange account initialization failed. Please verify that the wallet address and API key match'
case 'trader.reason.exchange_unsupported':
return zh ? '当前交易所类型暂不支持机器人初始化' : 'the selected exchange type is not currently supported for trader initialization'
case 'trader.reason.exchange_balance_unavailable':
return zh ? '系统暂时无法从交易所读取账户余额' : 'the system could not read the account balance from the exchange'
case 'trader.reason.exchange_service_unreachable':
return zh ? '系统暂时无法连接交易所服务' : 'the system could not reach the exchange service right now'
default:
return fallback || (zh ? '系统返回了一个未知错误' : 'an unknown error was returned by the system')
}
}
const normalizeActionableDescription = (error: unknown, message: string, title: string) => {
if (error instanceof ApiError && error.errorKey) {
return formatActionableDescriptionByKey(error.errorKey, error.errorParams, message)
}
const prefixes = [
'这次未能创建机器人:',
'机器人创建失败:',
'这次未能更新机器人:',
'机器人更新失败:',
'这次未能启动机器人:',
'Failed to create trader:',
'Failed to update trader:',
'Unable to create trader:',
'Unable to update trader:',
'Unable to start trader:',
]
let description = message.trim()
if (description === title) return ''
for (const prefix of prefixes) {
if (description.startsWith(prefix)) {
description = description.slice(prefix.length).trim()
break
}
}
return description
}
const showActionableError = (title: string, error: unknown) => {
const message = getErrorMessage(error, title)
const description = normalizeActionableDescription(error, message, title)
if (description === '') {
toast.error(title)
return
}
toast.error(title, {
description,
})
}
const parseBalanceUsdc = (balance?: string) => {
if (!balance) return null
const parsed = Number.parseFloat(balance)
return Number.isFinite(parsed) ? parsed : null
}
const getClaw402BalanceMessage = (balance: number, blocking: boolean) => {
if (language === 'zh') {
return blocking
? `当前 Claw402 钱包余额为 ${balance.toFixed(6)} USDCAI 调用无法执行。请先为这个钱包充值,再重新点击启动。`
: `当前 Claw402 钱包余额仅剩 ${balance.toFixed(6)} USDC虽然还能尝试启动但很快可能因为 AI 调用费用不足而停止。建议先补一点 USDC。`
}
return blocking
? `Your Claw402 wallet balance is ${balance.toFixed(6)} USDC. AI calls cannot run with zero balance. Please top up this wallet before starting again.`
: `Your Claw402 wallet balance is only ${balance.toFixed(6)} USDC. You can still try to start, but AI calls may stop soon due to insufficient funds.`
}
const getClaw402BalanceIssue = (traderId: string) => {
const trader = traders?.find((item) => item.trader_id === traderId)
if (!trader) return null
const model =
allModels.find((item) => item.id === trader.ai_model) ||
allModels.find((item) => item.provider === trader.ai_model)
if (!model || model.provider !== 'claw402') return null
const balance = parseBalanceUsdc(model.balanceUsdc)
if (balance === null) return null
if (balance <= 0) {
return {
blocking: true,
title: language === 'zh' ? '启动失败' : 'Start failed',
description: getClaw402BalanceMessage(balance, true),
}
}
if (balance < 1) {
return {
blocking: false,
title: language === 'zh' ? 'Claw402 余额偏低' : 'Low Claw402 balance',
description: getClaw402BalanceMessage(balance, false),
}
}
return null
}
const navigateInApp = (path: string) => {
navigate(path)
window.dispatchEvent(new PopStateEvent('popstate'))
}
// Toggle wallet address visibility for a trader
const toggleTraderAddressVisibility = (traderId: string) => {
@@ -91,6 +292,23 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
api.getTraders,
{ refreshInterval: 5000 }
)
const {
data: exchangeAccountStateData,
mutate: mutateExchangeAccountStates,
isLoading: isExchangeAccountStatesLoading,
} = useSWR<{ states: Record<string, ExchangeAccountState> }>(
user && token ? 'exchange-account-state' : null,
api.getExchangeAccountState,
{
refreshInterval: 30000,
shouldRetryOnError: false,
}
)
const { data: strategies } = useSWR<Strategy[]>(
user && token ? 'strategies' : null,
api.getStrategies,
{ refreshInterval: 30000 }
)
useEffect(() => {
const loadConfigs = async () => {
@@ -115,6 +333,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
api.getSupportedModels(),
])
setAllModels(modelConfigs)
const clawWalletAddress =
modelConfigs.find((model) => model.provider === 'claw402')?.walletAddress || null
if (clawWalletAddress) {
setBeginnerWalletAddress(clawWalletAddress)
persistBeginnerWalletAddress(clawWalletAddress)
}
setAllExchanges(exchangeConfigs)
setSupportedModels(models)
} catch (error) {
@@ -141,6 +365,23 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}) || []
const enabledModels = allModels?.filter((m) => m.enabled) || []
const enabledClaw402Model = enabledModels.find((model) => model.provider === 'claw402') || null
const enabledClaw402Balance = parseBalanceUsdc(enabledClaw402Model?.balanceUsdc)
const claw402BalanceAlert =
enabledClaw402Model && enabledClaw402Balance !== null && enabledClaw402Balance < 1
? {
blocking: enabledClaw402Balance <= 0,
title:
language === 'zh'
? enabledClaw402Balance <= 0
? 'Claw402 钱包余额为 0'
: 'Claw402 钱包余额偏低'
: enabledClaw402Balance <= 0
? 'Claw402 wallet balance is zero'
: 'Claw402 wallet balance is low',
description: getClaw402BalanceMessage(enabledClaw402Balance, enabledClaw402Balance <= 0),
}
: null
const enabledExchanges =
allExchanges?.filter((e) => {
if (!e.enabled) return false
@@ -198,26 +439,19 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const handleCreateTrader = async (data: CreateTraderRequest) => {
try {
const model = allModels?.find((m) => m.id === data.ai_model_id)
const exchange = allExchanges?.find((e) => e.id === data.exchange_id)
if (!model?.enabled) {
toast.error(t('modelNotConfigured', language))
return
const createdTrader = await api.createTrader(data)
if (createdTrader.startup_warning) {
toast.success(t('aiTradersToast.created', language), {
description: createdTrader.startup_warning,
})
} else {
toast.success(t('aiTradersToast.created', language))
}
if (!exchange?.enabled) {
toast.error(t('exchangeNotConfigured', language))
return
}
await api.createTrader(data)
toast.success(t('aiTradersToast.created', language))
setShowCreateModal(false)
await mutateTraders()
} catch (error) {
console.error('Failed to create trader:', error)
toast.error(t('createTraderFailed', language))
showActionableError(t('createTraderFailed', language), error)
}
}
@@ -233,7 +467,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
const handleSaveEditTrader = async (data: CreateTraderRequest) => {
console.log('🔥🔥🔥 handleSaveEditTrader CALLED with data:', data)
if (!editingTrader) return
try {
@@ -261,10 +494,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
show_in_competition: data.show_in_competition,
}
console.log('🔥 handleSaveEditTrader - data:', data)
console.log('🔥 handleSaveEditTrader - data.strategy_id:', data.strategy_id)
console.log('🔥 handleSaveEditTrader - request:', request)
await api.updateTrader(editingTrader.trader_id, request)
toast.success(t('aiTradersToast.saved', language))
setShowEditModal(false)
@@ -272,7 +501,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
await mutateTraders()
} catch (error) {
console.error('Failed to update trader:', error)
toast.error(t('updateTraderFailed', language))
showActionableError(t('updateTraderFailed', language), error)
}
}
@@ -297,16 +526,31 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
try {
if (running) {
await api.stopTrader(traderId)
toast.success(t('aiTradersToast.stopped', language))
toast.success(t('aiTradersToast.stopped', language))
} else {
const claw402Issue = getClaw402BalanceIssue(traderId)
if (claw402Issue?.blocking) {
toast.error(claw402Issue.title, {
description: claw402Issue.description,
})
return
}
if (claw402Issue && !claw402Issue.blocking) {
toast.warning(claw402Issue.title, {
description: claw402Issue.description,
})
}
await api.startTrader(traderId)
toast.success(t('aiTradersToast.started', language))
toast.success(t('aiTradersToast.started', language))
}
await mutateTraders()
} catch (error) {
console.error('Failed to toggle trader:', error)
toast.error(t('operationFailed', language))
showActionableError(
running ? t('aiTradersToast.stopFailed', language) : t('aiTradersToast.startFailed', language),
error
)
}
}
@@ -516,6 +760,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const refreshedExchanges = await api.getExchangeConfigs()
setAllExchanges(refreshedExchanges)
await mutateExchangeAccountStates()
setShowExchangeModal(false)
setEditingExchange(null)
@@ -597,6 +842,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const refreshedExchanges = await api.getExchangeConfigs()
setAllExchanges(refreshedExchanges)
await mutateExchangeAccountStates()
setShowExchangeModal(false)
setEditingExchange(null)
@@ -616,6 +862,37 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
setShowExchangeModal(true)
}
const handleQuickSetupClaw402 = async () => {
if (quickSetupLoading) return
try {
setQuickSetupLoading(true)
const result = await api.prepareBeginnerOnboarding()
setBeginnerWalletAddress(result.address)
const refreshedModels = await api.getModelConfigs()
setAllModels(refreshedModels)
toast.success(
language === 'zh'
? 'Claw402 已默认配置为 DeepSeek'
: 'Claw402 is configured with DeepSeek by default'
)
} catch (error) {
console.error('Failed to quick setup claw402:', error)
toast.error(
language === 'zh'
? '一键配置 Claw402 失败'
: 'Failed to quick setup Claw402'
)
} finally {
setQuickSetupLoading(false)
}
}
const claw402Configured = configuredModels.some((model) => model.provider === 'claw402')
const hasStrategies = (strategies?.length || 0) > 0
const hasCreatedTrader = (traders?.length || 0) > 0
const canCreateTrader = configuredModels.length > 0 && configuredExchanges.length > 0
return (
<DeepVoidBackground className="py-8" disableAnimation>
<div className="w-full px-4 md:px-8 space-y-8 animate-fade-in">
@@ -687,10 +964,74 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
</div>
</div>
{isBeginnerMode ? (
<BeginnerGuideCards
language={language}
claw402Ready={claw402Configured}
exchangeReady={configuredExchanges.length > 0}
strategyReady={hasStrategies}
traderReady={hasCreatedTrader}
canCreateTrader={canCreateTrader}
walletAddress={beginnerWalletAddress}
onQuickSetupClaw402={handleQuickSetupClaw402}
onOpenExchange={handleAddExchange}
onOpenStrategy={() => navigateInApp('/strategy')}
onCreateTrader={() => setShowCreateModal(true)}
/>
) : null}
{claw402BalanceAlert ? (
<div
className="mb-6 rounded-xl border px-4 py-4 md:px-5 md:py-4 flex flex-col md:flex-row md:items-start md:justify-between gap-3"
style={{
borderColor: claw402BalanceAlert.blocking ? 'rgba(239, 68, 68, 0.55)' : 'rgba(245, 158, 11, 0.45)',
background: claw402BalanceAlert.blocking ? 'rgba(127, 29, 29, 0.22)' : 'rgba(120, 53, 15, 0.18)',
}}
>
<div className="flex items-start gap-3">
<div
className="mt-0.5 rounded-full p-2"
style={{
background: claw402BalanceAlert.blocking ? 'rgba(239, 68, 68, 0.16)' : 'rgba(245, 158, 11, 0.14)',
color: claw402BalanceAlert.blocking ? '#F87171' : '#FBBF24',
}}
>
<AlertTriangle className="w-4 h-4" />
</div>
<div>
<div
className="text-sm font-semibold"
style={{ color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A' }}
>
{claw402BalanceAlert.title}
</div>
<div className="text-sm mt-1 leading-6" style={{ color: '#D4D4D8' }}>
{claw402BalanceAlert.description}
</div>
</div>
</div>
<button
type="button"
onClick={() => enabledClaw402Model && handleModelClick(enabledClaw402Model.id)}
className="px-4 py-2 rounded text-xs font-mono uppercase tracking-wider border whitespace-nowrap self-start"
style={{
borderColor: claw402BalanceAlert.blocking ? 'rgba(248, 113, 113, 0.45)' : 'rgba(251, 191, 36, 0.35)',
color: claw402BalanceAlert.blocking ? '#FCA5A5' : '#FDE68A',
background: 'rgba(0, 0, 0, 0.18)',
}}
>
{language === 'zh' ? '查看 AI 钱包' : 'Open AI wallet'}
</button>
</div>
) : null}
{/* Configuration Status Grid */}
<ConfigStatusGrid
configuredModels={configuredModels}
configuredExchanges={configuredExchanges}
exchangeAccountStates={exchangeAccountStateData?.states}
isExchangeAccountStatesLoading={isExchangeAccountStatesLoading}
visibleExchangeAddresses={visibleExchangeAddresses}
copiedId={copiedId}
language={language}
@@ -715,7 +1056,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
copiedId={copiedId}
language={language}
onTraderSelect={onTraderSelect}
onNavigate={(path) => navigate(path)}
onNavigate={navigateInApp}
onEditTrader={handleEditTrader}
onToggleTrader={handleToggleTrader}
onToggleCompetition={handleToggleCompetition}

View 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>
)
}

View File

@@ -281,14 +281,14 @@ export function CompetitionPage() {
</div>
{/* Stats */}
<div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap">
<div className="flex items-center gap-4 md:gap-6">
{/* Total Equity */}
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>
<div className="text-right min-w-[60px] md:min-w-[80px]">
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
{t('equity', language)}
</div>
<div
className="text-xs md:text-sm font-bold mono"
className="text-sm md:text-base font-bold mono"
style={{ color: '#EAECEF' }}
>
{trader.total_equity?.toFixed(2) || '0.00'}
@@ -297,11 +297,11 @@ export function CompetitionPage() {
{/* P&L */}
<div className="text-right min-w-[70px] md:min-w-[90px]">
<div className="text-xs" style={{ color: '#848E9C' }}>
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
{t('pnl', language)}
</div>
<div
className="text-base md:text-lg font-bold mono"
className="text-sm md:text-base font-bold mono"
style={{
color:
(trader.total_pnl ?? 0) >= 0
@@ -313,7 +313,7 @@ export function CompetitionPage() {
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
</div>
<div
className="text-xs mono"
className="text-[10px] mono"
style={{ color: '#848E9C' }}
>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
@@ -322,17 +322,17 @@ export function CompetitionPage() {
</div>
{/* Positions */}
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>
<div className="text-right min-w-[40px] md:min-w-[50px]">
<div className="text-[10px] mb-0.5" style={{ color: '#848E9C' }}>
{t('pos', language)}
</div>
<div
className="text-xs md:text-sm font-bold mono"
className="text-sm md:text-base font-bold mono"
style={{ color: '#EAECEF' }}
>
{trader.position_count}
</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
<div className="text-[10px]" style={{ color: '#848E9C' }}>
{trader.margin_used_pct.toFixed(1)}%
</div>
</div>

View File

@@ -6,7 +6,7 @@ import {
Copy,
Check,
} from 'lucide-react'
import type { AIModel, Exchange } from '../../types'
import type { AIModel, Exchange, ExchangeAccountState } from '../../types'
import type { Language } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { getModelIcon } from '../common/ModelIcons'
@@ -25,6 +25,8 @@ interface UsageInfo {
interface ConfigStatusGridProps {
configuredModels: AIModel[]
configuredExchanges: Exchange[]
exchangeAccountStates?: Record<string, ExchangeAccountState>
isExchangeAccountStatesLoading?: boolean
visibleExchangeAddresses: Set<string>
copiedId: string | null
language: Language
@@ -41,6 +43,8 @@ interface ConfigStatusGridProps {
export function ConfigStatusGrid({
configuredModels,
configuredExchanges,
exchangeAccountStates,
isExchangeAccountStatesLoading,
visibleExchangeAddresses,
copiedId,
language,
@@ -53,6 +57,48 @@ export function ConfigStatusGrid({
onToggleExchangeAddress,
onCopyAddress,
}: ConfigStatusGridProps) {
const getExchangeStateMeta = (state: ExchangeAccountState | undefined) => {
if (!state) {
return {
label: language === 'zh' ? '未检查' : 'NOT CHECKED',
className: 'text-zinc-400 border-zinc-700/80 bg-zinc-900/40',
}
}
switch (state.status) {
case 'ok':
return {
label: state.display_balance || '0',
className: 'text-emerald-300 border-emerald-500/20 bg-emerald-500/10',
}
case 'disabled':
return {
label: language === 'zh' ? '已禁用' : 'DISABLED',
className: 'text-zinc-400 border-zinc-700/80 bg-zinc-900/40',
}
case 'missing_credentials':
return {
label: language === 'zh' ? '配置不完整' : 'INCOMPLETE',
className: 'text-amber-300 border-amber-500/20 bg-amber-500/10',
}
case 'invalid_credentials':
return {
label: language === 'zh' ? '密钥无效' : 'INVALID KEYS',
className: 'text-rose-300 border-rose-500/20 bg-rose-500/10',
}
case 'permission_denied':
return {
label: language === 'zh' ? '无余额权限' : 'NO PERMISSION',
className: 'text-orange-300 border-orange-500/20 bg-orange-500/10',
}
default:
return {
label: language === 'zh' ? '暂时无法获取' : 'UNAVAILABLE',
className: 'text-zinc-300 border-zinc-600/60 bg-zinc-800/50',
}
}
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* AI Models Card */}
@@ -92,6 +138,20 @@ export function ConfigStatusGrid({
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
</div>
{model.provider === 'claw402' && (model.balanceUsdc || model.walletAddress) ? (
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-[10px] font-mono">
{model.balanceUsdc ? (
<span className="rounded border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0.5 text-emerald-400">
{model.balanceUsdc} USDC
</span>
) : null}
{model.walletAddress ? (
<span className="rounded border border-sky-500/20 bg-sky-500/10 px-1.5 py-0.5 text-sky-400">
{truncateAddress(model.walletAddress)}
</span>
) : null}
</div>
) : null}
</div>
</div>
@@ -135,6 +195,8 @@ export function ConfigStatusGrid({
{configuredExchanges.map((exchange) => {
const inUse = isExchangeInUse(exchange.id)
const usageInfo = getExchangeUsageInfo(exchange.id)
const state = exchangeAccountStates?.[exchange.id]
const stateMeta = getExchangeStateMeta(state)
return (
<div
key={exchange.id}
@@ -160,6 +222,18 @@ export function ConfigStatusGrid({
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
{exchange.type?.toUpperCase() || 'CEX'}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px] font-mono">
<span className={`rounded border px-1.5 py-0.5 ${stateMeta.className}`}>
{isExchangeAccountStatesLoading && !state
? (language === 'zh' ? '检查中...' : 'CHECKING...')
: stateMeta.label}
</span>
{state?.status !== 'ok' && state?.error_message ? (
<span className="text-zinc-500 truncate max-w-[220px]">
{state.error_message}
</span>
) : null}
</div>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react'
import type { AIModel } from '../../types'
import type { Language } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { api } from '../../lib/api'
import { getModelIcon } from '../common/ModelIcons'
import { ModelStepIndicator } from './ModelStepIndicator'
import { ModelCard } from './ModelCard'
@@ -12,6 +13,7 @@ import {
AI_PROVIDER_CONFIG,
getShortName,
} from './model-constants'
import { getBeginnerWalletAddress, getUserMode } from '../../lib/onboarding'
interface ModelConfigModalProps {
allModels: AIModel[]
@@ -42,20 +44,22 @@ export function ModelConfigModal({
const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [modelName, setModelName] = useState('')
const configuredModel =
configuredModels?.find((model) => model.id === selectedModelId) || null
// Always prefer allModels (supportedModels) for provider/id lookup;
// fall back to configuredModels for edit mode details (apiKey etc.)
const selectedModel =
allModels?.find((m) => m.id === selectedModelId) ||
configuredModels?.find((m) => m.id === selectedModelId)
allModels?.find((m) => m.id === selectedModelId) || configuredModel
useEffect(() => {
if (editingModelId && selectedModel) {
setApiKey(selectedModel.apiKey || '')
setBaseUrl(selectedModel.customApiUrl || '')
setModelName(selectedModel.customModelName || '')
const modelDetails = configuredModel || selectedModel
if (editingModelId && modelDetails) {
setApiKey(modelDetails.apiKey || '')
setBaseUrl(modelDetails.customApiUrl || '')
setModelName(modelDetails.customModelName || '')
}
}, [editingModelId, selectedModel])
}, [editingModelId, configuredModel, selectedModel])
const handleSelectModel = (modelId: string) => {
setSelectedModelId(modelId)
@@ -79,7 +83,19 @@ export function ModelConfigModal({
const availableModels = allModels || []
const configuredIds = new Set(configuredModels?.map(m => m.id) || [])
const stepLabels = [t('modelConfig.selectModel', language), t('modelConfig.configureApi', language)]
const isClaw402Selected = selectedModel?.provider === 'claw402' || selectedModel?.id === 'claw402'
const isBeginnerDefaultModel = isClaw402Selected && getUserMode() === 'beginner'
const stepLabels = [
t('modelConfig.selectModel', language),
t(
!selectedModel
? 'modelConfig.configure'
: isClaw402Selected
? 'modelConfig.configureWallet'
: 'modelConfig.configure',
language
),
]
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
@@ -102,7 +118,7 @@ export function ModelConfigModal({
</h3>
</div>
<div className="flex items-center gap-2">
{editingModelId && (
{editingModelId && !isBeginnerDefaultModel && (
<button
type="button"
onClick={() => onDelete(editingModelId)}
@@ -143,6 +159,7 @@ export function ModelConfigModal({
<Claw402ConfigForm
apiKey={apiKey}
modelName={modelName}
configuredModel={configuredModel}
editingModelId={editingModelId}
onApiKeyChange={setApiKey}
onModelNameChange={setModelName}
@@ -189,6 +206,10 @@ function ModelSelectionStep({
onSelectModel: (modelId: string) => void
language: Language
}) {
const [showOtherProviders, setShowOtherProviders] = useState(false)
const claw402Model = availableModels.find((m) => m.provider === 'claw402')
const otherProviders = availableModels.filter((m) => m.provider !== 'claw402')
return (
<div className="space-y-4">
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
@@ -196,12 +217,11 @@ function ModelSelectionStep({
</div>
{/* Claw402 Featured Card */}
{availableModels.some(m => m.provider === 'claw402') && (
{claw402Model && (
<button
type="button"
onClick={() => {
const claw = availableModels.find(m => m.provider === 'claw402')
if (claw) onSelectModel(claw.id)
onSelectModel(claw402Model.id)
}}
className="w-full p-5 rounded-xl text-left transition-all hover:scale-[1.01]"
style={{ background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)', border: '1.5px solid rgba(37, 99, 235, 0.4)' }}
@@ -222,7 +242,7 @@ function ModelSelectionStep({
</div>
</div>
<div className="flex items-center gap-2">
{configuredIds.has(availableModels.find(m => m.provider === 'claw402')?.id || '') && (
{configuredIds.has(claw402Model.id) && (
<div className="w-2 h-2 rounded-full" style={{ background: '#00E096' }} />
)}
<div className="px-3 py-1.5 rounded-full text-xs font-bold" style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff' }}>
@@ -235,23 +255,57 @@ function ModelSelectionStep({
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
</span>
</div>
<div className="mt-4 ml-[52px] text-[11px]" style={{ color: '#A0AEC0' }}>
{t('modelConfig.claw402EntryDesc', language)}
</div>
</button>
)}
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{availableModels.filter(m => m.provider !== 'claw402').map((model) => (
<ModelCard
key={model.id}
model={model}
selected={selectedModelId === model.id}
onClick={() => onSelectModel(model.id)}
configured={configuredIds.has(model.id)}
/>
))}
</div>
<div className="text-xs text-center pt-2" style={{ color: '#848E9C' }}>
{t('modelConfig.modelsConfigured', language)}
</div>
{otherProviders.length > 0 && (
<div className="rounded-xl border border-white/10 bg-black/20 overflow-hidden">
<button
type="button"
onClick={() => setShowOtherProviders((prev) => !prev)}
className="w-full flex items-center justify-between px-4 py-4 text-left transition-all hover:bg-white/5"
>
<div>
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
{t('modelConfig.otherApiEntry', language)}
</div>
<div className="mt-1 text-xs" style={{ color: '#848E9C' }}>
{t('modelConfig.otherApiEntryDesc', language)}
</div>
</div>
<div className="flex items-center gap-3">
<span className="rounded-full border border-white/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.2em]" style={{ color: '#A0AEC0' }}>
{otherProviders.length} API
</span>
<span className="text-sm" style={{ color: '#60A5FA' }}>
{showOtherProviders ? '' : '+'}
</span>
</div>
</button>
{showOtherProviders && (
<div className="border-t border-white/5 px-4 py-4">
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{otherProviders.map((model) => (
<ModelCard
key={model.id}
model={model}
selected={selectedModelId === model.id}
onClick={() => onSelectModel(model.id)}
configured={configuredIds.has(model.id)}
/>
))}
</div>
<div className="text-xs text-center pt-3" style={{ color: '#848E9C' }}>
{t('modelConfig.modelsConfigured', language)}
</div>
</div>
)}
</div>
)}
</div>
)
}
@@ -259,6 +313,7 @@ function ModelSelectionStep({
function Claw402ConfigForm({
apiKey,
modelName,
configuredModel,
editingModelId,
onApiKeyChange,
onModelNameChange,
@@ -268,6 +323,7 @@ function Claw402ConfigForm({
}: {
apiKey: string
modelName: string
configuredModel: AIModel | null
editingModelId: string | null
onApiKeyChange: (value: string) => void
onModelNameChange: (value: string) => void
@@ -278,14 +334,21 @@ function Claw402ConfigForm({
const [walletAddress, setWalletAddress] = useState('')
const [copiedAddr, setCopiedAddr] = useState(false)
const [showDeposit, setShowDeposit] = useState(false)
const [showNewWalletBackup, setShowNewWalletBackup] = useState(false)
const [newWalletKey, setNewWalletKey] = useState('')
const [usdcBalance, setUsdcBalance] = useState<string | null>(null)
const [keyError, setKeyError] = useState('')
const [validating, setValidating] = useState(false)
const [claw402Status, setClaw402Status] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null)
const [testing, setTesting] = useState(false)
const [serverWalletAddress, setServerWalletAddress] = useState('')
const [serverWalletBalance, setServerWalletBalance] = useState<string | null>(null)
const localWalletAddress = getBeginnerWalletAddress()?.trim() || ''
const configuredWalletAddress =
configuredModel?.walletAddress?.trim() || localWalletAddress || serverWalletAddress
const resolvedWalletAddress = walletAddress || configuredWalletAddress
const resolvedUsdcBalance =
usdcBalance ?? configuredModel?.balanceUsdc ?? serverWalletBalance ?? null
const hasExistingWallet = Boolean(configuredWalletAddress)
// Client-side validation helper
const getClientError = (key: string): string => {
@@ -298,8 +361,36 @@ function Claw402ConfigForm({
const isKeyValid = apiKey.length === 66 && apiKey.startsWith('0x') && /^0x[0-9a-fA-F]{64}$/.test(apiKey)
// Truncate address for display
useEffect(() => {
if (hasExistingWallet) {
setShowDeposit(true)
}
}, [hasExistingWallet])
useEffect(() => {
if (configuredModel?.walletAddress || localWalletAddress || serverWalletAddress) {
return
}
let cancelled = false
void api
.getCurrentBeginnerWallet()
.then((result) => {
setClaw402Status(result.claw402_status || 'unknown')
if (cancelled || !result.found || !result.address) {
return
}
setServerWalletAddress(result.address)
setServerWalletBalance(result.balance_usdc || null)
})
.catch(() => {
// Ignore silently: this is a best-effort fallback for showing the current wallet.
})
return () => {
cancelled = true
}
}, [configuredModel?.walletAddress, localWalletAddress, serverWalletAddress])
// Debounced validation when apiKey changes
useEffect(() => {
@@ -347,6 +438,23 @@ function Claw402ConfigForm({
setTesting(true)
setTestResult(null)
try {
if (!apiKey && hasExistingWallet) {
const result = await api.getCurrentBeginnerWallet()
setClaw402Status(result.claw402_status || 'unknown')
if (result.found && result.address) {
setWalletAddress(result.address)
setUsdcBalance(result.balance_usdc || '0.00')
setShowDeposit(true)
}
setTestResult({
status: result.claw402_status === 'ok' ? 'ok' : 'error',
message: result.claw402_status === 'ok'
? t('modelConfig.claw402Connected', language)
: t('modelConfig.claw402Unreachable', language),
})
return
}
const res = await fetch('/api/wallet/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -374,7 +482,7 @@ function Claw402ConfigForm({
}
}
const balanceNum = usdcBalance ? parseFloat(usdcBalance) : 0
const balanceNum = resolvedUsdcBalance ? parseFloat(resolvedUsdcBalance) : 0
return (
<form onSubmit={onSubmit} className="space-y-5">
@@ -396,6 +504,25 @@ function Claw402ConfigForm({
</span>
))}
</div>
<div className="mt-4 flex items-center justify-center gap-3 flex-wrap">
<button
type="button"
onClick={handleTestConnection}
disabled={testing || (!hasExistingWallet && !isKeyValid)}
className="inline-flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-semibold transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
>
<span>🔗</span>
{testing ? t('modelConfig.testingConnection', language) : t('modelConfig.testConnection', language)}
</button>
{claw402Status ? (
<div className="text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#F59E0B' }}>
{claw402Status === 'ok'
? t('modelConfig.claw402Connected', language)
: t('modelConfig.claw402Unreachable', language)}
</div>
) : null}
</div>
</div>
{/* Step 1: Select AI Model */}
@@ -409,7 +536,7 @@ function Claw402ConfigForm({
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{CLAW402_MODELS.map((m) => {
const isSelected = (modelName || 'deepseek') === m.id
const isSelected = (modelName || 'glm-5') === m.id
return (
<button
key={m.id}
@@ -467,6 +594,33 @@ function Claw402ConfigForm({
</div>
</div>
{hasExistingWallet && (
<div className="p-3 rounded-xl" style={{ background: 'rgba(0, 224, 150, 0.05)', border: '1px solid rgba(0, 224, 150, 0.18)' }}>
<div className="text-xs font-semibold mb-1.5" style={{ color: '#00E096' }}>
{language === 'zh' ? '已自动提取当前钱包' : 'Current wallet loaded automatically'}
</div>
<div className="text-[11px] leading-5" style={{ color: '#A0AEC0' }}>
{language === 'zh'
? '你现在可以直接查看当前钱包地址、余额和充值二维码。只有在想更换钱包时,才需要重新输入新的私钥。'
: 'You can view the current wallet address, balance, and deposit QR code right away. Only enter a new private key if you want to replace this wallet.'}
</div>
{!configuredModel?.walletAddress && localWalletAddress ? (
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前地址来自本地已保存的新手钱包。'
: 'This address comes from the locally saved beginner wallet.'}
</div>
) : null}
{!configuredModel?.walletAddress && !localWalletAddress && serverWalletAddress ? (
<div className="mt-2 text-[10px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前地址来自后端保存的钱包配置。'
: 'This address comes from the wallet saved on the server.'}
</div>
) : null}
</div>
)}
<div className="space-y-1.5">
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
{t('modelConfig.walletPrivateKey', language)}
@@ -476,72 +630,30 @@ function Claw402ConfigForm({
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder="0x..."
placeholder={
hasExistingWallet
? language === 'zh'
? '如需切换钱包,请手动输入新的私钥'
: 'Enter a new private key only if you want to switch wallets'
: '0x...'
}
className="flex-1 px-4 py-3 rounded-xl font-mono text-sm"
style={{
background: '#0B0E11',
border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139',
color: '#EAECEF',
}}
required
required={!hasExistingWallet}
/>
{!apiKey && (
<button
type="button"
onClick={async () => {
try {
const res = await fetch('/api/wallet/generate', { method: 'POST' })
const data = await res.json()
if (data.private_key) {
onApiKeyChange(data.private_key)
setShowNewWalletBackup(true)
setNewWalletKey(data.private_key)
}
} catch { /* ignore */ }
}}
className="shrink-0 px-3 py-3 rounded-xl text-xs font-semibold transition-all hover:scale-[1.02]"
style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff', border: 'none', cursor: 'pointer' }}
>
{language === 'zh' ? '🔑 创建钱包' : '🔑 Create Wallet'}
</button>
)}
</div>
{/* New wallet backup warning */}
{showNewWalletBackup && newWalletKey && (
<div className="p-3 rounded-xl" style={{ background: 'rgba(239, 68, 68, 0.08)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
<div className="text-xs font-bold mb-2" style={{ color: '#EF4444' }}>
🚨 {language === 'zh' ? '重要:请立即备份私钥!' : 'Important: Backup your private key NOW!'}
</div>
<div className="text-[11px] mb-2" style={{ color: '#F87171' }}>
{language === 'zh'
? '这是你的钱包私钥,丢失后无法恢复,钱包里的资产将永久丢失。请复制并安全保存。'
: 'This is your wallet private key. If lost, it cannot be recovered and all assets will be permanently lost. Copy and save it securely.'}
</div>
<div className="flex items-center gap-2 mb-2">
<code className="text-[10px] font-mono break-all select-all flex-1 p-2 rounded" style={{ background: '#0B0E11', color: '#F87171' }}>
{newWalletKey}
</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(newWalletKey)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
className="shrink-0 text-[10px] px-2 py-1 rounded"
style={{ background: 'rgba(239,68,68,0.15)', color: '#F87171', border: 'none', cursor: 'pointer' }}
>
{copiedAddr ? '✅ Copied' : '📋 Copy Key'}
</button>
</div>
<div className="text-[10px] space-y-1" style={{ color: '#848E9C' }}>
<div> {language === 'zh' ? '建议保存到密码管理器1Password / Bitwarden' : 'Save to a password manager (1Password / Bitwarden)'}</div>
<div> {language === 'zh' ? '或抄在纸上放安全的地方' : 'Or write it down and store it safely'}</div>
<div> {language === 'zh' ? '不要截图发给别人' : 'Do NOT screenshot or share with anyone'}</div>
</div>
{hasExistingWallet && !apiKey ? (
<div className="text-[11px] leading-5" style={{ color: '#848E9C' }}>
{language === 'zh'
? '后续这里只使用你第一次创建并保存的钱包;如果你要换钱包,请手动填写新的私钥。'
: 'This screen keeps using the wallet created and saved the first time. Enter a new private key manually only if you want to switch wallets.'}
</div>
)}
) : null}
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
<span className="mt-px">🔒</span>
@@ -552,7 +664,7 @@ function Claw402ConfigForm({
</div>
{/* Wallet Validation Results */}
{apiKey && (
{(apiKey || hasExistingWallet) && (
<div className="space-y-2 pl-1">
{/* Validating spinner */}
{validating && (
@@ -571,7 +683,7 @@ function Claw402ConfigForm({
)}
{/* Success: address + balance + status */}
{walletAddress && !validating && !keyError && (
{resolvedWalletAddress && !validating && !keyError && (
<>
<div className="p-2.5 rounded-lg" style={{ background: 'rgba(96,165,250,0.06)', border: '1px solid rgba(96,165,250,0.15)' }}>
<div className="flex items-center justify-between mb-1">
@@ -581,7 +693,7 @@ function Claw402ConfigForm({
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(walletAddress)
navigator.clipboard.writeText(resolvedWalletAddress)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
@@ -591,16 +703,16 @@ function Claw402ConfigForm({
{copiedAddr ? '✅' : '📋'}
</button>
</div>
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{walletAddress}</code>
<code className="text-[11px] font-mono block select-all" style={{ color: '#60A5FA' }}>{resolvedWalletAddress}</code>
<div className="text-[10px] mt-1.5" style={{ color: '#F59E0B' }}>
{language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
</div>
</div>
{usdcBalance !== null && (
{resolvedUsdcBalance !== null && (
<div className="flex items-center gap-2 text-xs">
<span>💰</span>
<span style={{ color: balanceNum > 0 ? '#00E096' : '#F59E0B' }}>
{t('modelConfig.usdcBalance', language)}: ${usdcBalance}
{t('modelConfig.usdcBalance', language)}: ${resolvedUsdcBalance}
</span>
<button
type="button"
@@ -621,17 +733,17 @@ function Claw402ConfigForm({
</div>
<div className="flex gap-3 items-start mb-3">
<div className="shrink-0 p-1.5 rounded-lg" style={{ background: '#fff' }}>
<QRCodeSVG value={walletAddress} size={80} level="M" />
<QRCodeSVG value={resolvedWalletAddress} size={80} level="M" />
</div>
<div className="flex-1 min-w-0">
<div className="text-[11px] mb-1" style={{ color: '#A0AEC0' }}>
{language === 'zh' ? '扫码或复制地址转账' : 'Scan QR or copy address to transfer'}
</div>
<code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{walletAddress}</code>
<code className="text-[10px] font-mono break-all select-all block mb-1.5" style={{ color: '#60A5FA' }}>{resolvedWalletAddress}</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(walletAddress)
navigator.clipboard.writeText(resolvedWalletAddress)
setCopiedAddr(true)
setTimeout(() => setCopiedAddr(false), 2000)
}}
@@ -650,6 +762,13 @@ function Claw402ConfigForm({
</div>
</div>
)}
{!apiKey && hasExistingWallet && (
<div className="text-[11px]" style={{ color: '#848E9C' }}>
{language === 'zh'
? '当前正在使用这个钱包充值。若要切换钱包,再输入新的私钥并保存即可。'
: 'This wallet is currently used for funding. Enter a new private key only if you want to switch wallets.'}
</div>
)}
{claw402Status && (
<div className="flex items-center gap-2 text-xs" style={{ color: claw402Status === 'ok' ? '#00E096' : '#EF4444' }}>
<span>{claw402Status === 'ok' ? '🟢' : '🔴'}</span>
@@ -662,11 +781,11 @@ function Claw402ConfigForm({
)}
{/* Test Connection button */}
{isKeyValid && !validating && (
{(isKeyValid || hasExistingWallet) && !validating && (
<button
type="button"
onClick={handleTestConnection}
disabled={testing}
disabled={testing || (!hasExistingWallet && !isKeyValid)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all hover:scale-[1.02] disabled:opacity-50"
style={{ background: 'rgba(37, 99, 235, 0.15)', border: '1px solid rgba(37, 99, 235, 0.3)', color: '#60A5FA' }}
>

View File

@@ -4,6 +4,7 @@ import { useLanguage } from '../../contexts/LanguageContext'
import { t, type Language } from '../../i18n/translations'
import { MetricTooltip } from '../common/MetricTooltip'
import { formatPrice, formatQuantity } from '../../utils/format'
import { NofxSelect } from '../ui/select'
import type {
HistoricalPosition,
TraderStats,
@@ -358,7 +359,11 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
setLoading(true)
setError(null)
// Fetch more data than needed to support filtering, but respect pageSize for initial load
const data = await api.getPositionHistory(traderId, Math.max(200, pageSize * 5))
const data = await api.getPositionHistory(
traderId,
Math.max(200, pageSize * 5),
true
)
setPositions(data.positions || [])
setStats(data.stats)
setSymbolStats(data.symbol_stats || [])
@@ -664,23 +669,20 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
<span className="text-sm" style={{ color: '#848E9C' }}>
{t('positionHistory.symbol', language)}:
</span>
<select
<NofxSelect
value={filterSymbol}
onChange={(e) => setFilterSymbol(e.target.value)}
onChange={(val) => setFilterSymbol(val)}
options={[
{ value: 'all', label: t('positionHistory.allSymbols', language) },
...uniqueSymbols.map(s => ({ value: s, label: (s || '').replace('USDT', '') }))
]}
className="rounded px-3 py-1.5 text-sm"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
>
<option value="all">{t('positionHistory.allSymbols', language)}</option>
{uniqueSymbols.map((symbol) => (
<option key={symbol} value={symbol}>
{(symbol || '').replace('USDT', '')}
</option>
))}
</select>
/>
</div>
<div className="flex items-center gap-2">
@@ -708,28 +710,26 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
<span className="text-sm" style={{ color: '#848E9C' }}>
{t('positionHistory.sort', language)}:
</span>
<select
<NofxSelect
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [by, order] = e.target.value.split('-') as [
'time' | 'pnl' | 'pnl_pct',
'asc' | 'desc',
]
onChange={(val) => {
const [by, order] = val.split('-') as ['time' | 'pnl' | 'pnl_pct', 'asc' | 'desc']
setSortBy(by)
setSortOrder(order)
}}
options={[
{ value: 'time-desc', label: t('positionHistory.latestFirst', language) },
{ value: 'time-asc', label: t('positionHistory.oldestFirst', language) },
{ value: 'pnl-desc', label: t('positionHistory.highestPnL', language) },
{ value: 'pnl-asc', label: t('positionHistory.lowestPnL', language) },
]}
className="rounded px-3 py-1.5 text-sm"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
>
<option value="time-desc">{t('positionHistory.latestFirst', language)}</option>
<option value="time-asc">{t('positionHistory.oldestFirst', language)}</option>
<option value="pnl-desc">{t('positionHistory.highestPnL', language)}</option>
<option value="pnl-asc">{t('positionHistory.lowestPnL', language)}</option>
</select>
/>
</div>
</div>
@@ -841,20 +841,21 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
<span className="text-xs" style={{ color: '#848E9C' }}>
{language === 'zh' ? '每页' : 'Per page'}:
</span>
<select
<NofxSelect
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
onChange={(val) => setPageSize(Number(val))}
options={[
{ value: 20, label: '20' },
{ value: 50, label: '50' },
{ value: 100, label: '100' },
]}
className="rounded px-2 py-1 text-sm"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
/>
</div>
{/* Page navigation */}

View File

@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { api } from '../../lib/api'
import type { TelegramConfig, AIModel } from '../../types'
import { t, type Language } from '../../i18n/translations'
import { NofxSelect } from '../ui/select'
// Step indicator (reused pattern from ExchangeConfigModal)
function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) {
@@ -133,23 +134,20 @@ export function TelegramConfigModal({ onClose, language }: TelegramConfigModalPr
{t('telegram.noEnabledModels', language)}
</div>
) : (
<select
<NofxSelect
value={selectedModelId}
onChange={(e) => setSelectedModelId(e.target.value)}
className="w-full px-4 py-3 rounded-xl text-sm appearance-none"
onChange={(val) => setSelectedModelId(val)}
options={[
{ value: '', label: t('telegram.autoSelect', language) },
...models.map(m => ({ value: m.id, label: `${m.name} (${m.provider}${m.customModelName ? ` · ${m.customModelName}` : ''})` }))
]}
className="w-full px-4 py-3 rounded-xl text-sm"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: selectedModelId ? '#EAECEF' : '#848E9C',
}}
>
<option value="">{t('telegram.autoSelect', language)}</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.provider}{m.customModelName ? ` · ${m.customModelName}` : ''})
</option>
))}
</select>
/>
)}
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('telegram.autoUseEnabled', language)}
@@ -489,23 +487,20 @@ function BoundModelSelector({
{t('telegram.aiModelLabel', language)}
</label>
<div className="flex gap-2">
<select
<NofxSelect
value={modelId}
onChange={(e) => setModelId(e.target.value)}
className="flex-1 px-3 py-2.5 rounded-xl text-sm appearance-none"
onChange={(val) => setModelId(val)}
options={[
{ value: '', label: t('telegram.aiModelAutoSelect', language) },
...models.map(m => ({ value: m.id, label: `${m.name}${m.customModelName ? ` · ${m.customModelName}` : ''}` }))
]}
className="flex-1 px-3 py-2.5 rounded-xl text-sm"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: modelId ? '#EAECEF' : '#848E9C',
}}
>
<option value="">{t('telegram.aiModelAutoSelect', language)}</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name}{m.customModelName ? ` · ${m.customModelName}` : ''}
</option>
))}
</select>
/>
<button
onClick={handleSave}
disabled={isSaving || modelId === currentModelId}

View File

@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'
import type { AIModel, Exchange, CreateTraderRequest, Strategy } from '../../types'
import type { AIModel, Exchange, CreateTraderRequest, ExchangeAccountStateResponse, Strategy } from '../../types'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
import { toast } from 'sonner'
import { Pencil, Plus, X as IconX, Sparkles, ExternalLink, UserPlus } from 'lucide-react'
import { httpClient } from '../../lib/httpClient'
import { NofxSelect } from '../ui/select'
// 提取下划线后面的名称部分
function getShortName(fullName: string): string {
@@ -123,32 +124,62 @@ export function TraderConfigModal({
setFormData((prev) => ({ ...prev, [field]: value }))
}
const handleExchangeChange = (exchangeId: string) => {
setBalanceFetchError('')
setFormData((prev) => {
if (prev.exchange_id === exchangeId) {
return prev
}
const next: FormState = { ...prev, exchange_id: exchangeId }
// Exchange balance belongs to the selected exchange, not the trader record.
// Clear the old baseline so we don't carry Exchange B's balance into Exchange A.
if (isEditMode) {
next.initial_balance = undefined
}
return next
})
}
const handleFetchCurrentBalance = async () => {
if (!isEditMode || !traderData?.trader_id) {
if (!isEditMode) {
setBalanceFetchError(t('fetchBalanceEditModeOnly', language))
return
}
if (!formData.exchange_id) {
setBalanceFetchError(t('balanceFetchFailed', language))
return
}
setIsFetchingBalance(true)
setBalanceFetchError('')
try {
const result = await httpClient.get<{
total_equity?: number
balance?: number
}>(`/api/account?trader_id=${traderData.trader_id}`)
const result = await httpClient.get<ExchangeAccountStateResponse>('/api/exchanges/account-state')
if (result.success && result.data) {
const selectedState = result.data?.states?.[formData.exchange_id]
if (result.success && selectedState?.status === 'ok') {
const currentBalance =
result.data.total_equity || result.data.balance || 0
selectedState.total_equity ??
selectedState.available_balance ??
0
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
toast.success(t('balanceFetched', language))
} else {
throw new Error(result.message || t('balanceFetchFailed', language))
setBalanceFetchError(
selectedState?.error_message || result.message || t('balanceFetchFailed', language)
)
}
} catch (error) {
console.error(t('balanceFetchFailed', language) + ':', error)
setBalanceFetchError(t('balanceFetchNetworkError', language))
setBalanceFetchError(
error instanceof Error && error.message
? error.message
: t('balanceFetchNetworkError', language)
)
} finally {
setIsFetchingBalance(false)
}
@@ -175,8 +206,6 @@ export function TraderConfigModal({
}
await onSave(saveData)
toast.success(t('saveSuccess', language))
onClose()
} catch (error) {
console.error(t('saveFailed', language) + ':', error)
} finally {
@@ -250,38 +279,32 @@ export function TraderConfigModal({
<label className="text-sm text-[#EAECEF] block mb-2">
{t('aiModelRequired', language)}
</label>
<select
<NofxSelect
value={formData.ai_model}
onChange={(e) =>
handleInputChange('ai_model', e.target.value)
onChange={(val) =>
handleInputChange('ai_model', val)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{availableModels.map((model) => (
<option key={model.id} value={model.id}>
{getShortName(model.name || model.id).toUpperCase()}
</option>
))}
</select>
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
options={availableModels.map((model) => ({
value: model.id,
label: getShortName(model.name || model.id).toUpperCase(),
}))}
/>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('exchangeRequired', language)}
</label>
<select
<NofxSelect
value={formData.exchange_id}
onChange={(e) =>
handleInputChange('exchange_id', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{availableExchanges.map((exchange) => (
<option key={exchange.id} value={exchange.id}>
{getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()}
{exchange.account_name ? ` - ${exchange.account_name}` : ''}
</option>
))}
</select>
onChange={handleExchangeChange}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
options={availableExchanges.map((exchange) => ({
value: exchange.id,
label: getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()
+ (exchange.account_name ? ` - ${exchange.account_name}` : ''),
}))}
/>
{/* Exchange Registration Link */}
{formData.exchange_id && (() => {
// Find the selected exchange to get its type
@@ -323,22 +346,20 @@ export function TraderConfigModal({
<label className="text-sm text-[#EAECEF] block mb-2">
{t('useStrategy', language)}
</label>
<select
<NofxSelect
value={formData.strategy_id}
onChange={(e) =>
handleInputChange('strategy_id', e.target.value)
onChange={(val) =>
handleInputChange('strategy_id', val)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
<option value="">{t('noStrategyManual', language)}</option>
{strategies.map((strategy) => (
<option key={strategy.id} value={strategy.id}>
{strategy.name}
{strategy.is_active ? t('strategyActive', language) : ''}
{strategy.is_default ? t('strategyDefault', language) : ''}
</option>
))}
</select>
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
options={[
{ value: '', label: t('noStrategyManual', language) },
...strategies.map((strategy) => ({
value: strategy.id,
label: strategy.name + (strategy.is_active ? t('strategyActive', language) : '') + (strategy.is_default ? t('strategyDefault', language) : ''),
})),
]}
/>
{strategies.length === 0 && (
<p className="text-xs text-[#848E9C] mt-2">
{t('noStrategyHint', language)}

View File

@@ -91,12 +91,12 @@ export const AI_PROVIDER_CONFIG: Record<string, AIProviderConfig> = {
apiName: 'Moonshot',
},
minimax: {
defaultModel: 'MiniMax-M2.5',
defaultModel: 'MiniMax-M2.7',
apiUrl: 'https://platform.minimax.io',
apiName: 'MiniMax',
},
claw402: {
defaultModel: 'deepseek',
defaultModel: 'glm-5',
apiUrl: 'https://claw402.ai',
apiName: 'Claw402',
},

View 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>
)
}

View File

@@ -1,6 +1,9 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import { getSystemConfig } from '../lib/config'
import { flushSync } from 'react-dom'
import { getSystemConfig, invalidateSystemConfig } from '../lib/config'
import { reset401Flag, httpClient } from '../lib/httpClient'
import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding'
import { useLanguage } from './LanguageContext'
interface User {
id: string
@@ -12,7 +15,8 @@ interface AuthContextType {
token: string | null
login: (
email: string,
password: string
password: string,
mode?: UserMode
) => Promise<{
success: boolean
message?: string
@@ -24,7 +28,8 @@ interface AuthContextType {
register: (
email: string,
password: string,
betaCode?: string
betaCode?: string,
mode?: UserMode
) => Promise<{ success: boolean; message?: string }>
resetPassword: (
email: string,
@@ -37,6 +42,7 @@ interface AuthContextType {
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { language } = useLanguage()
const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
@@ -89,7 +95,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}, [])
const login = async (email: string, password: string) => {
const handlePostAuthSuccess = (
authToken: string,
userInfo: User,
mode?: UserMode
) => {
reset401Flag()
if (mode) {
setUserMode(mode)
}
localStorage.setItem('auth_token', authToken)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
localStorage.setItem('user_id', userInfo.id)
flushSync(() => {
setToken(authToken)
setUser(userInfo)
})
const returnUrl = sessionStorage.getItem('returnUrl')
const nextPath = returnUrl || getPostAuthPath(mode)
if (returnUrl) {
sessionStorage.removeItem('returnUrl')
}
window.history.pushState({}, '', nextPath)
window.dispatchEvent(new PopStateEvent('popstate'))
}
const login = async (email: string, password: string, mode?: UserMode) => {
try {
const response = await fetch('/api/login', {
method: 'POST',
@@ -103,26 +138,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (response.ok) {
if (data.token) {
// Reset 401 flag on successful login
reset401Flag()
const userInfo = { id: data.user_id, email: data.email }
setToken(data.token)
setUser(userInfo)
localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
// Check and redirect to returnUrl if exists
const returnUrl = sessionStorage.getItem('returnUrl')
if (returnUrl) {
sessionStorage.removeItem('returnUrl')
window.history.pushState({}, '', returnUrl)
window.dispatchEvent(new PopStateEvent('popstate'))
} else {
// Redirect to config page
window.history.pushState({}, '', '/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
}
handlePostAuthSuccess(data.token, userInfo, mode)
return { success: true, message: data.message }
}
@@ -156,10 +173,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
id: data.user_id || 'admin',
email: data.email || 'admin@localhost',
}
setToken(data.token)
setUser(userInfo)
localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
flushSync(() => {
setToken(data.token)
setUser(userInfo)
})
// Check and redirect to returnUrl if exists
const returnUrl = sessionStorage.getItem('returnUrl')
@@ -184,13 +203,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const register = async (
email: string,
password: string,
betaCode?: string
betaCode?: string,
mode?: UserMode
) => {
const requestBody: {
email: string
password: string
beta_code?: string
} = { email, password }
lang?: string
} = { email, password, lang: language }
if (betaCode) {
requestBody.beta_code = betaCode
}
@@ -204,26 +225,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}>('/api/register', requestBody)
if (result.success && result.data) {
// Reset 401 flag on successful login
reset401Flag()
// Clear stale onboarding state so new users always see the welcome flow
localStorage.removeItem('nofx_beginner_onboarding_completed')
localStorage.removeItem('nofx_beginner_wallet_address')
const userInfo = { id: result.data.user_id, email: result.data.email }
setToken(result.data.token)
setUser(userInfo)
localStorage.setItem('auth_token', result.data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
// Check and redirect to returnUrl if exists
const returnUrl = sessionStorage.getItem('returnUrl')
if (returnUrl) {
sessionStorage.removeItem('returnUrl')
window.history.pushState({}, '', returnUrl)
window.dispatchEvent(new PopStateEvent('popstate'))
} else {
// Redirect to config page
window.history.pushState({}, '', '/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
}
handlePostAuthSuccess(result.data.token, userInfo, mode)
return {
success: true,
@@ -287,6 +294,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setToken(null)
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
invalidateSystemConfig()
window.history.pushState({}, '', '/')
window.dispatchEvent(new PopStateEvent('popstate'))
}
return (

View File

@@ -1,13 +1,15 @@
import { useEffect, useState } from 'react'
import { getSystemConfig, type SystemConfig } from '../lib/config'
import { useEffect, useState, useCallback } from 'react'
import { getSystemConfig, invalidateSystemConfig, type SystemConfig } from '../lib/config'
export function useSystemConfig() {
const [config, setConfig] = useState<SystemConfig | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [fetchKey, setFetchKey] = useState(0)
useEffect(() => {
let mounted = true
setLoading(true)
getSystemConfig()
.then((data) => {
if (!mounted) return
@@ -23,7 +25,18 @@ export function useSystemConfig() {
return () => {
mounted = false
}
}, [fetchKey])
// Listen for invalidation events and re-fetch automatically
useEffect(() => {
const handler = () => setFetchKey((k) => k + 1)
window.addEventListener('system-config-invalidated', handler)
return () => window.removeEventListener('system-config-invalidated', handler)
}, [])
return { config, loading, error }
const refresh = useCallback(() => {
invalidateSystemConfig()
}, [])
return { config, loading, error, refresh }
}

View File

@@ -493,6 +493,9 @@ export const translations = {
registerNow: 'Sign up now',
loginNow: 'Sign in now',
forgotPassword: 'Forgot password?',
forgotAccount: 'Forgot account?',
forgotAccountConfirm: 'This will clear all account data and allow you to register a new account. Continue?',
forgotAccountSuccess: 'Account reset successful! You can now register a new account.',
rememberMe: 'Remember me',
resetPassword: 'Reset Password',
resetPasswordTitle: 'Reset your password',
@@ -1080,11 +1083,16 @@ export const translations = {
public: 'Public',
addDescription: 'Add strategy description...',
unsaved: 'Unsaved',
discardChanges: 'Discard',
selectOrCreate: 'Select or create a strategy',
customPromptDesc: 'Extra prompt appended to System Prompt for personalized trading style',
customPromptPlaceholder: 'Enter custom prompt...',
generatePromptPreview: 'Click to generate prompt preview',
runAiTestHint: 'Click to run AI test',
tokenEstimate: 'Token Estimate',
tokenExceedWarning: 'Token estimate exceeds 128K. AI requests may fail for some models.',
tokenEstimating: 'Estimating...',
tokenTooltip: 'Based on 200K context',
},
// Metric Tooltip
@@ -1162,6 +1170,9 @@ export const translations = {
close: 'Close',
showingPositions: 'Showing {shown} of {total} positions',
perPage: 'Per page',
accountFetchFailed: 'DATA_FETCH::FAILED — Account data unavailable, check connection',
positionsFetchFailed: 'Position data unavailable',
decisionsFetchFailed: 'Decision data unavailable',
},
// AITradersPage toast messages
@@ -1205,8 +1216,13 @@ export const translations = {
// ModelConfigModal
modelConfig: {
selectModel: 'Select Model',
configure: 'Configure',
configureApi: 'Configure API',
configureWallet: 'Configure Wallet',
chooseProvider: 'Choose Your AI Provider',
claw402EntryDesc: 'Recommended default path. Use Base USDC pay-per-call instead of managing API keys.',
otherApiEntry: 'Other API Providers',
otherApiEntryDesc: 'Use your own API key for OpenAI, Claude, Gemini, DeepSeek, and more.',
payPerCall: 'Pay-per-call USDC · All AI Models · No API Key',
recommended: 'Best',
allModelsClaw: 'Pay-per-call with USDC — supports all major AI models',
@@ -1806,6 +1822,9 @@ export const translations = {
registerNow: '立即注册',
loginNow: '立即登录',
forgotPassword: '忘记密码?',
forgotAccount: '忘记账户?',
forgotAccountConfirm: '这将清除所有账户数据,允许您重新注册新账户。是否继续?',
forgotAccountSuccess: '账户已重置!现在可以注册新账户了。',
rememberMe: '记住我',
resetPassword: '重置密码',
resetPasswordTitle: '重置您的密码',
@@ -2371,11 +2390,16 @@ export const translations = {
public: '公开',
addDescription: '添加策略简介...',
unsaved: '未保存',
discardChanges: '撤销',
selectOrCreate: '选择或创建策略',
customPromptDesc: '附加在 System Prompt 末尾的额外提示,用于补充个性化交易风格',
customPromptPlaceholder: '输入自定义提示词...',
generatePromptPreview: '点击生成 Prompt 预览',
runAiTestHint: '点击运行 AI 测试',
tokenEstimate: 'Token 预估',
tokenExceedWarning: 'Token 估算超过 128K部分模型请求可能失败',
tokenEstimating: '预估中...',
tokenTooltip: '基于 200K 上下文计算',
},
// Metric Tooltip
@@ -2452,6 +2476,9 @@ export const translations = {
close: '平仓',
showingPositions: '显示 {shown} / {total} 个持仓',
perPage: '每页',
accountFetchFailed: 'DATA_FETCH::FAILED — 账户数据请求失败,请检查连接',
positionsFetchFailed: '持仓数据请求失败',
decisionsFetchFailed: '决策记录请求失败',
},
aiTradersToast: {
@@ -2493,8 +2520,13 @@ export const translations = {
modelConfig: {
selectModel: '选择模型',
configure: '配置',
configureApi: '配置 API',
configureWallet: '配置钱包',
chooseProvider: '选择 AI 模型提供商',
claw402EntryDesc: '默认推荐走这条路。直接用 Base USDC 按次付费,不需要自己管理 API Key。',
otherApiEntry: '其他 API 模型',
otherApiEntryDesc: '如果你已经有自己的 OpenAI、Claude、Gemini、DeepSeek 等 API Key再从这里进入。',
payPerCall: 'USDC 按次付费 · 支持全部 AI 模型 · 无需 API Key',
recommended: '推荐',
allModelsClaw: '用 USDC 按次付费,支持所有主流 AI 模型',
@@ -3054,6 +3086,9 @@ export const translations = {
registerNow: 'Daftar sekarang',
loginNow: 'Masuk sekarang',
forgotPassword: 'Lupa kata sandi?',
forgotAccount: 'Lupa akun?',
forgotAccountConfirm: 'Ini akan menghapus semua data akun dan memungkinkan Anda mendaftar akun baru. Lanjutkan?',
forgotAccountSuccess: 'Akun berhasil direset! Anda sekarang dapat mendaftar akun baru.',
rememberMe: 'Ingat saya',
resetPassword: 'Reset Kata Sandi',
resetPasswordTitle: 'Reset kata sandi Anda',
@@ -3464,11 +3499,16 @@ export const translations = {
public: 'Publik',
addDescription: 'Tambah deskripsi strategi...',
unsaved: 'Belum Disimpan',
discardChanges: 'Buang',
selectOrCreate: 'Pilih atau buat strategi',
customPromptDesc: 'Prompt tambahan di akhir System Prompt untuk gaya trading personal',
customPromptPlaceholder: 'Masukkan prompt kustom...',
generatePromptPreview: 'Klik untuk generate pratinjau prompt',
runAiTestHint: 'Klik untuk menjalankan uji AI',
tokenEstimate: 'Estimasi Token',
tokenExceedWarning: 'Estimasi token melebihi 128K. Permintaan AI mungkin gagal untuk beberapa model.',
tokenEstimating: 'Mengestimasi...',
tokenTooltip: 'Berdasarkan konteks 200K',
},
// Metric Tooltip
@@ -3545,6 +3585,9 @@ export const translations = {
close: 'Tutup',
showingPositions: 'Menampilkan {shown} dari {total} posisi',
perPage: 'Per halaman',
accountFetchFailed: 'DATA_FETCH::FAILED — Data akun tidak tersedia, periksa koneksi',
positionsFetchFailed: 'Data posisi tidak tersedia',
decisionsFetchFailed: 'Data keputusan tidak tersedia',
},
aiTradersToast: {
@@ -3586,8 +3629,13 @@ export const translations = {
modelConfig: {
selectModel: 'Pilih Model',
configure: 'Konfigurasi',
configureApi: 'Konfigurasi API',
configureWallet: 'Konfigurasi Wallet',
chooseProvider: 'Pilih Penyedia AI Anda',
claw402EntryDesc: 'Jalur default yang direkomendasikan. Gunakan Base USDC bayar per panggilan tanpa mengelola API key.',
otherApiEntry: 'Penyedia API Lain',
otherApiEntryDesc: 'Gunakan API key Anda sendiri untuk OpenAI, Claude, Gemini, DeepSeek, dan lainnya.',
payPerCall: 'Bayar per panggilan USDC · Semua Model AI · Tanpa API Key',
recommended: 'Terbaik',
allModelsClaw: 'Bayar per panggilan dengan USDC — mendukung semua model AI utama',

View File

@@ -1,9 +1,12 @@
import type {
AIModel,
Exchange,
ExchangeAccountStateResponse,
UpdateModelConfigRequest,
UpdateExchangeConfigRequest,
CreateExchangeRequest,
BeginnerOnboardingResponse,
CurrentBeginnerWalletResponse,
} from '../../types'
import { API_BASE, httpClient, CryptoService } from './helpers'
@@ -71,6 +74,16 @@ export const configApi = {
return result.data!
},
async getExchangeAccountState(): Promise<ExchangeAccountStateResponse> {
const result = await httpClient.get<ExchangeAccountStateResponse>(
`${API_BASE}/exchanges/account-state`
)
if (!result.success || !result.data) {
throw new Error('Failed to fetch exchange account states')
}
return result.data
},
async getSupportedExchanges(): Promise<Exchange[]> {
const result = await httpClient.get<Exchange[]>(
`${API_BASE}/supported-exchanges`
@@ -183,4 +196,24 @@ export const configApi = {
if (!result.success) throw new Error('Failed to fetch server IP')
return result.data!
},
async prepareBeginnerOnboarding(): Promise<BeginnerOnboardingResponse> {
const result = await httpClient.post<BeginnerOnboardingResponse>(
`${API_BASE}/onboarding/beginner`
)
if (!result.success || !result.data) {
throw new Error(result.message || 'Failed to prepare beginner onboarding')
}
return result.data
},
async getCurrentBeginnerWallet(): Promise<CurrentBeginnerWalletResponse> {
const result = await httpClient.get<CurrentBeginnerWalletResponse>(
`${API_BASE}/onboarding/beginner/current`
)
if (!result.success || !result.data) {
throw new Error(result.message || 'Failed to fetch current beginner wallet')
}
return result.data
},
}

View File

@@ -10,29 +10,29 @@ import type {
import { API_BASE, httpClient } from './helpers'
export const dataApi = {
async getStatus(traderId?: string): Promise<SystemStatus> {
async getStatus(traderId?: string, silent?: boolean): Promise<SystemStatus> {
const url = traderId
? `${API_BASE}/status?trader_id=${traderId}`
: `${API_BASE}/status`
const result = await httpClient.get<SystemStatus>(url)
const result = await httpClient.request<SystemStatus>(url, { silent })
if (!result.success) throw new Error('Failed to fetch system status')
return result.data!
},
async getAccount(traderId?: string): Promise<AccountInfo> {
async getAccount(traderId?: string, silent?: boolean): Promise<AccountInfo> {
const url = traderId
? `${API_BASE}/account?trader_id=${traderId}`
: `${API_BASE}/account`
const result = await httpClient.get<AccountInfo>(url)
const result = await httpClient.request<AccountInfo>(url, { silent })
if (!result.success) throw new Error('Failed to fetch account info')
return result.data!
},
async getPositions(traderId?: string): Promise<Position[]> {
async getPositions(traderId?: string, silent?: boolean): Promise<Position[]> {
const url = traderId
? `${API_BASE}/positions?trader_id=${traderId}`
: `${API_BASE}/positions`
const result = await httpClient.get<Position[]>(url)
const result = await httpClient.request<Position[]>(url, { silent })
if (!result.success) throw new Error('Failed to fetch positions')
return result.data!
},
@@ -48,7 +48,8 @@ export const dataApi = {
async getLatestDecisions(
traderId?: string,
limit: number = 5
limit: number = 5,
silent?: boolean
): Promise<DecisionRecord[]> {
const params = new URLSearchParams()
if (traderId) {
@@ -56,27 +57,28 @@ export const dataApi = {
}
params.append('limit', limit.toString())
const result = await httpClient.get<DecisionRecord[]>(
`${API_BASE}/decisions/latest?${params}`
const result = await httpClient.request<DecisionRecord[]>(
`${API_BASE}/decisions/latest?${params}`,
{ silent }
)
if (!result.success) throw new Error('Failed to fetch latest decisions')
return result.data!
},
async getStatistics(traderId?: string): Promise<Statistics> {
async getStatistics(traderId?: string, silent?: boolean): Promise<Statistics> {
const url = traderId
? `${API_BASE}/statistics?trader_id=${traderId}`
: `${API_BASE}/statistics`
const result = await httpClient.get<Statistics>(url)
const result = await httpClient.request<Statistics>(url, { silent })
if (!result.success) throw new Error('Failed to fetch statistics')
return result.data!
},
async getEquityHistory(traderId?: string): Promise<any[]> {
async getEquityHistory(traderId?: string, silent?: boolean): Promise<any[]> {
const url = traderId
? `${API_BASE}/equity-history?trader_id=${traderId}`
: `${API_BASE}/equity-history`
const result = await httpClient.get<any[]>(url)
const result = await httpClient.request<any[]>(url, { silent })
if (!result.success) throw new Error('Failed to fetch equity history')
return result.data!
},
@@ -112,9 +114,14 @@ export const dataApi = {
return result.data!
},
async getPositionHistory(traderId: string, limit: number = 100): Promise<PositionHistoryResponse> {
const result = await httpClient.get<PositionHistoryResponse>(
`${API_BASE}/positions/history?trader_id=${traderId}&limit=${limit}`
async getPositionHistory(
traderId: string,
limit: number = 100,
silent?: boolean
): Promise<PositionHistoryResponse> {
const result = await httpClient.request<PositionHistoryResponse>(
`${API_BASE}/positions/history?trader_id=${traderId}&limit=${limit}`,
{ silent }
)
if (!result.success) throw new Error('Failed to fetch position history')
return result.data!

View File

@@ -4,10 +4,23 @@ import type {
CreateTraderRequest,
} from '../../types'
import { API_BASE, httpClient } from './helpers'
import { ApiError } from '../httpClient'
function throwApiError(
message: string,
errorKey?: string,
errorParams?: Record<string, string>,
statusCode?: number
): never {
throw new ApiError(message, errorKey, errorParams, statusCode)
}
export const traderApi = {
async getTraders(): Promise<TraderInfo[]> {
const result = await httpClient.get<TraderInfo[]>(`${API_BASE}/my-traders`)
async getTraders(silent?: boolean): Promise<TraderInfo[]> {
const result = await httpClient.request<TraderInfo[]>(
`${API_BASE}/my-traders`,
{ silent }
)
if (!result.success) throw new Error('Failed to fetch trader list')
return Array.isArray(result.data) ? result.data : []
},
@@ -23,7 +36,14 @@ export const traderApi = {
`${API_BASE}/traders`,
request
)
if (!result.success) throw new Error('Failed to create trader')
if (!result.success) {
throwApiError(
result.message || 'Failed to create trader',
result.errorKey,
result.errorParams,
result.statusCode
)
}
return result.data!
},
@@ -36,7 +56,14 @@ export const traderApi = {
const result = await httpClient.post(
`${API_BASE}/traders/${traderId}/start`
)
if (!result.success) throw new Error('Failed to start trader')
if (!result.success) {
throwApiError(
result.message || 'Failed to start trader',
result.errorKey,
result.errorParams,
result.statusCode
)
}
},
async stopTrader(traderId: string): Promise<void> {
@@ -88,7 +115,14 @@ export const traderApi = {
`${API_BASE}/traders/${traderId}`,
request
)
if (!result.success) throw new Error('Failed to update trader')
if (!result.success) {
throwApiError(
result.message || 'Failed to update trader',
result.errorKey,
result.errorParams,
result.statusCode
)
}
return result.data!
},
}

View File

@@ -26,4 +26,5 @@ export function getSystemConfig(): Promise<SystemConfig> {
export function invalidateSystemConfig() {
cachedConfig = null
configPromise = null
window.dispatchEvent(new Event('system-config-invalidated'))
}

View File

@@ -19,6 +19,28 @@ export interface ApiResponse<T = any> {
success: boolean
data?: T
message?: string
errorKey?: string
errorParams?: Record<string, string>
statusCode?: number
}
export class ApiError extends Error {
errorKey?: string
errorParams?: Record<string, string>
statusCode?: number
constructor(
message: string,
errorKey?: string,
errorParams?: Record<string, string>,
statusCode?: number
) {
super(message)
this.name = 'ApiError'
this.errorKey = errorKey
this.errorParams = errorParams
this.statusCode = statusCode
}
}
/**
@@ -85,18 +107,27 @@ export class HttpClient {
* Only business errors are returned to caller
*/
private async handleError(error: AxiosError): Promise<any> {
const isSilent = (error.config as any)?.silentError === true
const errorData = error.response?.data as {
error?: string
message?: string
error_key?: string
error_params?: Record<string, string>
} | undefined
const serverMessage = errorData?.error || errorData?.message
// Network error (no response from server)
if (!error.response) {
toast.error('Network error - Please check your connection', {
description: 'Unable to reach the server',
})
if (!isSilent) {
toast.error('Network error - Please check your connection', {
id: 'network-error',
description: 'Unable to reach the server',
})
}
throw new Error('Network error')
}
const { status } = error.response as AxiosResponse<{
error?: string
message?: string
}>
const status = error.response?.status ?? 0
// Handle 401 Unauthorized
if (status === 401) {
@@ -132,25 +163,37 @@ export class HttpClient {
// Handle 403 Forbidden - system error
if (status === 403) {
toast.error('Permission Denied', {
description: 'You do not have permission to access this resource',
})
if (!isSilent) {
toast.error('Permission Denied', {
id: 'permission-denied',
description: 'You do not have permission to access this resource',
})
}
throw new Error('Permission denied')
}
// Handle 404 Not Found - system error
if (status === 404) {
toast.error('API Not Found', {
description: 'The requested endpoint does not exist (404)',
})
if (!isSilent) {
toast.error('API Not Found', {
id: `404-${(error.config as any)?.url || 'unknown'}`,
description: 'The requested endpoint does not exist (404)',
})
}
throw new Error('API not found')
}
// Handle 500+ Server Error - system error
if (status >= 500) {
toast.error('Server Error', {
description: 'Please try again later or contact support',
})
if (serverMessage) {
return Promise.reject(error)
}
if (!isSilent) {
toast.error('Server Error', {
id: 'server-error',
description: 'Please try again later or contact support',
})
}
throw new Error('Server error')
}
@@ -171,6 +214,7 @@ export class HttpClient {
data?: any
params?: any
headers?: Record<string, string>
silent?: boolean
} = {}
): Promise<ApiResponse<T>> {
try {
@@ -180,6 +224,7 @@ export class HttpClient {
data: options.data,
params: options.params,
headers: options.headers,
...(options.silent && { silentError: true }),
})
// Success
@@ -196,6 +241,9 @@ export class HttpClient {
return {
success: false,
message: errorData?.error || errorData?.message || 'Operation failed',
errorKey: errorData?.error_key,
errorParams: errorData?.error_params,
statusCode: error.response.status,
}
}

37
web/src/lib/onboarding.ts Normal file
View 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')
}

View 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>
)
}

View File

@@ -4,6 +4,12 @@ import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, P
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import { api } from '../lib/api'
import {
getPostAuthPath,
getUserMode,
setUserMode,
type UserMode,
} from '../lib/onboarding'
import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal'
import { TelegramConfigModal } from '../components/trader/TelegramConfigModal'
import { ModelConfigModal } from '../components/trader/ModelConfigModal'
@@ -15,6 +21,7 @@ export function SettingsPage() {
const { user } = useAuth()
const { language } = useLanguage()
const [activeTab, setActiveTab] = useState<Tab>('account')
const [userMode, setUserModeState] = useState<UserMode>(() => getUserMode() ?? 'advanced')
// Account state
const [newPassword, setNewPassword] = useState('')
@@ -81,6 +88,26 @@ export function SettingsPage() {
}
}
const handleSwitchMode = (nextMode: UserMode) => {
if (nextMode === userMode) {
return
}
setUserMode(nextMode)
setUserModeState(nextMode)
toast.success(
language === 'zh'
? `已切换到${nextMode === 'beginner' ? '新手模式' : '老手模式'}`
: nextMode === 'beginner'
? 'Switched to beginner mode'
: 'Switched to advanced mode'
)
const nextPath = getPostAuthPath(nextMode)
window.history.pushState({}, '', nextPath)
window.dispatchEvent(new PopStateEvent('popstate'))
}
const handleSaveModel = async (
modelId: string,
apiKey: string,
@@ -281,6 +308,66 @@ export function SettingsPage() {
<p className="text-sm text-white font-medium">{user?.email}</p>
</div>
<div className="border-t border-zinc-800 pt-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-white">
{language === 'zh' ? '使用模式' : 'Usage Mode'}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '新手模式会显示钱包引导和 4 步卡片;老手模式保持原来的专业界面。'
: 'Beginner mode shows wallet onboarding and quickstart cards. Advanced mode keeps the original pro workflow.'}
</p>
</div>
<span className="rounded-full border border-nofx-gold/20 bg-nofx-gold/10 px-3 py-1 text-xs font-semibold text-nofx-gold">
{userMode === 'beginner'
? language === 'zh' ? '当前:新手模式' : 'Current: Beginner'
: language === 'zh' ? '当前:老手模式' : 'Current: Advanced'}
</span>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => handleSwitchMode('beginner')}
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
userMode === 'beginner'
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
}`}
>
<div className="text-sm font-semibold text-white">
{language === 'zh' ? '新手模式' : 'Beginner Mode'}
</div>
<div className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '更简单,优先显示钱包、充值和快速上手引导。'
: 'Simpler flow with wallet, funding, and quickstart guidance first.'}
</div>
</button>
<button
type="button"
onClick={() => handleSwitchMode('advanced')}
className={`rounded-2xl border px-4 py-4 text-left transition-all ${
userMode === 'advanced'
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-zinc-800 bg-zinc-950/70 hover:border-zinc-700'
}`}
>
<div className="text-sm font-semibold text-white">
{language === 'zh' ? '老手模式' : 'Advanced Mode'}
</div>
<div className="mt-1 text-xs text-zinc-500">
{language === 'zh'
? '保持原来的配置与交易流程,不展示新手引导。'
: 'Keeps the original configuration and trading workflow without beginner hints.'}
</div>
</button>
</div>
</div>
<div className="border-t border-zinc-800 pt-6">
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
<form onSubmit={handleChangePassword} className="space-y-4">

View File

@@ -38,6 +38,7 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar'
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
import { t } from '../i18n/translations'
@@ -52,6 +53,7 @@ export function StrategyStudioPage() {
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [estimatedTokens, setEstimatedTokens] = useState(0)
const [error, setError] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false)
@@ -247,6 +249,24 @@ export function StrategyStudioPage() {
const handleDeleteStrategy = async (id: string) => {
if (!token) return
// Check if strategy is in use by any trader before showing dialog
try {
const tradersResp = await fetch(`${API_BASE}/api/my-traders`, {
headers: { Authorization: `Bearer ${token}` },
})
if (tradersResp.ok) {
const traderList = await tradersResp.json()
const using = traderList.filter((t: any) => t.strategy_id === id)
if (using.length > 0) {
const names = using.map((t: any) => t.trader_name).join(', ')
notify.error(`Strategy is in use by: ${names}`)
return
}
}
} catch {
// fetch failed — proceed, backend will guard
}
const confirmed = await confirmToast(
tr('confirmDeleteStrategy'),
{
@@ -262,9 +282,12 @@ export function StrategyStudioPage() {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) throw new Error('Failed to delete strategy')
if (!response.ok) {
const data = await response.json().catch(() => ({}))
notify.error(data.error || 'Failed to delete strategy')
return
}
notify.success(tr('strategyDeleted'))
// Clear selection if deleted strategy was selected
if (selectedStrategy?.id === id) {
setSelectedStrategy(null)
setEditingConfig(null)
@@ -272,9 +295,7 @@ export function StrategyStudioPage() {
}
await fetchStrategies()
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error'
setError(errorMsg)
notify.error(errorMsg)
notify.error(err instanceof Error ? err.message : 'Unknown error')
}
}
@@ -378,6 +399,10 @@ export function StrategyStudioPage() {
// Save strategy
const handleSaveStrategy = async () => {
if (!token || !selectedStrategy || !editingConfig) return
if (estimatedTokens >= 128000 && currentStrategyType === 'ai_trading') {
notify.warning(tr('tokenExceedWarning'))
// continue with save
}
setIsSaving(true)
try {
// Always sync the config language with the current interface language
@@ -681,7 +706,7 @@ export function StrategyStudioPage() {
</button>
</div>
</div>
<div className="space-y-1">
<div className="space-y-2">
{strategies.map((strategy) => (
<div
key={strategy.id}
@@ -694,11 +719,11 @@ export function StrategyStudioPage() {
}}
className={`group px-2 py-2 rounded-lg cursor-pointer transition-all ${selectedStrategy?.id === strategy.id
? 'ring-1 ring-nofx-gold/50 bg-nofx-gold/10 shadow-[0_0_15px_rgba(240,185,11,0.1)]'
: 'hover:bg-nofx-bg-lighter/60 hover:ring-1 hover:ring-nofx-gold/20 bg-transparent'
: 'hover:bg-nofx-bg-lighter/60 ring-1 ring-white/10 hover:ring-nofx-gold/20 bg-transparent'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm truncate text-nofx-text">{strategy.name}</span>
<div className="flex items-start justify-between">
<span className={`line-clamp-2 text-nofx-text ${language === 'zh' ? 'text-sm' : 'text-xs'}`}>{strategy.name}</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); handleExportStrategy(strategy) }}
@@ -807,6 +832,13 @@ export function StrategyStudioPage() {
</div>
</div>
{/* Token Estimate Bar */}
{currentStrategyType === 'ai_trading' && (
<div className="mb-4">
<TokenEstimateBar config={editingConfig} language={language} onTokenCountChange={setEstimatedTokens} />
</div>
)}
{/* Strategy Type Selector */}
{editingConfig && (
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">

View File

@@ -10,6 +10,7 @@ import { formatPrice, formatQuantity } from '../utils/format'
import { t, type Language } from '../i18n/translations'
import { LogOut, Loader2, Eye, EyeOff, Copy, Check } from 'lucide-react'
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
import { NofxSelect } from '../components/ui/select'
import { GridRiskPanel } from '../components/strategy/GridRiskPanel'
import type {
SystemStatus,
@@ -102,8 +103,11 @@ interface TraderDashboardPageProps {
onNavigateToTraders: () => void
status?: SystemStatus
account?: AccountInfo
accountFailed?: boolean
positions?: Position[]
positionsFailed?: boolean
decisions?: DecisionRecord[]
decisionsFailed?: boolean
decisionsLimit: number
onDecisionsLimitChange: (limit: number) => void
stats?: Statistics
@@ -116,8 +120,11 @@ export function TraderDashboardPage({
selectedTrader,
status,
account,
accountFailed,
positions,
positionsFailed,
decisions,
decisionsFailed,
decisionsLimit,
onDecisionsLimitChange,
lastUpdate,
@@ -376,17 +383,12 @@ export function TraderDashboardPage({
{/* Trader Selector */}
{traders && traders.length > 0 && (
<div className="flex items-center gap-2 nofx-glass px-1 py-1 rounded-lg border border-white/5">
<select
value={selectedTraderId}
onChange={(e) => onTraderSelect(e.target.value)}
className="bg-transparent text-sm font-medium cursor-pointer transition-colors text-nofx-text-main focus:outline-none px-2 py-1"
>
{traders.map((trader) => (
<option key={trader.trader_id} value={trader.trader_id} className="bg-[#0B0E11]">
{trader.trader_name}
</option>
))}
</select>
<NofxSelect
value={selectedTraderId || ''}
onChange={(val) => onTraderSelect(val)}
options={traders.map(t => ({ value: t.trader_id, label: t.trader_name }))}
className="bg-transparent text-sm font-medium cursor-pointer transition-colors text-nofx-text-main px-2 py-1"
/>
</div>
)}
@@ -484,48 +486,60 @@ export function TraderDashboardPage({
</div>
{/* Debug Info */}
{account && (
<div className="mb-4 px-3 py-1.5 rounded bg-black/40 border border-white/5 text-[10px] font-mono text-nofx-text-muted flex justify-between items-center opacity-60 hover:opacity-100 transition-opacity">
<span>SYSTEM_STATUS::ONLINE</span>
<div className="mb-4 px-3 py-1.5 rounded bg-black/40 border border-white/5 text-[10px] font-mono text-nofx-text-muted flex justify-between items-center opacity-60 hover:opacity-100 transition-opacity">
<span style={{ color: '#0ECB81' }}>SYSTEM_STATUS::ONLINE</span>
{account ? (
<div className="flex gap-4">
<span>LAST_UPDATE::{lastUpdate}</span>
<span>EQ::{account?.total_equity?.toFixed(2)}</span>
<span>PNL::{account?.total_pnl?.toFixed(2)}</span>
<span>EQ::{account.total_equity?.toFixed(2)}</span>
<span>PNL::{account.total_pnl?.toFixed(2)}</span>
</div>
</div>
)}
) : accountFailed ? (
<span style={{ color: '#F6465D' }}>{t('traderDashboard.accountFetchFailed', language)}</span>
) : (
<div className="flex gap-4">
<span className="inline-block w-32 h-3 rounded bg-white/5 animate-pulse" />
<span className="inline-block w-16 h-3 rounded bg-white/5 animate-pulse" />
<span className="inline-block w-16 h-3 rounded bg-white/5 animate-pulse" />
</div>
)}
</div>
{/* Account Overview */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<StatCard
title={t('totalEquity', language)}
value={`${account?.total_equity?.toFixed(2) || '0.00'}`}
value={accountFailed && !account ? '--' : `${account?.total_equity?.toFixed(2) ?? '--'}`}
unit="USDT"
change={account?.total_pnl_pct || 0}
change={account ? (account.total_pnl_pct || 0) : undefined}
positive={(account?.total_pnl ?? 0) > 0}
icon="💰"
loading={!account && !accountFailed}
/>
<StatCard
title={t('availableBalance', language)}
value={`${account?.available_balance?.toFixed(2) || '0.00'}`}
value={accountFailed && !account ? '--' : `${account?.available_balance?.toFixed(2) ?? '--'}`}
unit="USDT"
subtitle={`${account?.available_balance && account?.total_equity ? ((account.available_balance / account.total_equity) * 100).toFixed(1) : '0.0'}% ${t('free', language)}`}
subtitle={accountFailed && !account ? '--' : `${account?.available_balance && account?.total_equity ? ((account.available_balance / account.total_equity) * 100).toFixed(1) : '--'}% ${t('free', language)}`}
icon="💳"
loading={!account && !accountFailed}
/>
<StatCard
title={t('totalPnL', language)}
value={`${account?.total_pnl !== undefined && account.total_pnl >= 0 ? '+' : ''}${account?.total_pnl?.toFixed(2) || '0.00'}`}
value={accountFailed && !account ? '--' : `${account?.total_pnl !== undefined && account.total_pnl >= 0 ? '+' : ''}${account?.total_pnl?.toFixed(2) ?? '--'}`}
unit="USDT"
change={account?.total_pnl_pct || 0}
change={account ? (account.total_pnl_pct || 0) : undefined}
positive={(account?.total_pnl ?? 0) >= 0}
icon="📈"
loading={!account && !accountFailed}
/>
<StatCard
title={t('positions', language)}
value={`${account?.position_count || 0}`}
value={accountFailed && !account ? '--' : `${account?.position_count ?? '--'}`}
unit="ACTIVE"
subtitle={`${t('margin', language)}: ${account?.margin_used_pct?.toFixed(1) || '0.0'}%`}
subtitle={accountFailed && !account ? `${t('margin', language)}: --` : `${t('margin', language)}: ${account?.margin_used_pct?.toFixed(1) ?? '--'}%`}
icon="📊"
loading={!account && !accountFailed}
/>
</div>
@@ -671,15 +685,12 @@ export function TraderDashboardPage({
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span>{t('traderDashboard.perPage', language)}:</span>
<select
<NofxSelect
value={positionsPageSize}
onChange={(e) => setPositionsPageSize(Number(e.target.value))}
className="bg-black/40 border border-white/10 rounded px-2 py-1 text-xs text-nofx-text-main focus:outline-none focus:border-nofx-gold/50 transition-colors"
>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
onChange={(val) => setPositionsPageSize(Number(val))}
options={[{ value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]}
className="bg-black/40 border border-white/10 rounded px-2 py-1 text-xs text-nofx-text-main transition-colors"
/>
</div>
{totalPositionPages > 1 && (
<div className="flex items-center gap-1">
@@ -716,6 +727,11 @@ export function TraderDashboardPage({
</div>
)}
</div>
) : positionsFailed ? (
<div className="text-center py-16 text-nofx-text-muted opacity-60">
<div className="text-4xl mb-4"></div>
<div className="text-lg font-semibold mb-2">{t('traderDashboard.positionsFetchFailed', language)}</div>
</div>
) : (
<div className="text-center py-16 text-nofx-text-muted opacity-60">
<div className="text-6xl mb-4 opacity-50 grayscale">📊</div>
@@ -752,17 +768,12 @@ export function TraderDashboardPage({
)}
</div>
{/* Limit Selector */}
<select
<NofxSelect
value={decisionsLimit}
onChange={(e) => onDecisionsLimitChange(Number(e.target.value))}
className="px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer transition-all bg-black/40 text-nofx-text-main border border-white/10 hover:border-nofx-accent focus:outline-none"
>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
onChange={(val) => onDecisionsLimitChange(Number(val))}
options={[{ value: 5, label: '5' }, { value: 10, label: '10' }, { value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]}
className="px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer transition-all bg-black/40 text-nofx-text-main border border-white/10 hover:border-nofx-accent"
/>
</div>
{/* Decisions List - Scrollable */}
@@ -774,6 +785,11 @@ export function TraderDashboardPage({
decisions.map((decision, i) => (
<DecisionCard key={i} decision={decision} language={language} onSymbolClick={handleSymbolClick} />
))
) : decisionsFailed ? (
<div className="py-16 text-center text-nofx-text-muted opacity-60">
<div className="text-4xl mb-4"></div>
<div className="text-lg font-semibold mb-2">{t('traderDashboard.decisionsFetchFailed', language)}</div>
</div>
) : (
<div className="py-16 text-center text-nofx-text-muted opacity-60">
<div className="text-6xl mb-4 opacity-30 grayscale">🧠</div>
@@ -818,6 +834,7 @@ function StatCard({
positive,
subtitle,
icon,
loading,
}: {
title: string
value: string
@@ -826,6 +843,7 @@ function StatCard({
positive?: boolean
subtitle?: string
icon?: string
loading?: boolean
}) {
return (
<div className="group nofx-glass p-5 rounded-lg transition-all duration-300 hover:bg-white/5 hover:translate-y-[-2px] border border-white/5 hover:border-nofx-gold/20 relative overflow-hidden">
@@ -835,27 +853,35 @@ function StatCard({
<div className="text-xs mb-2 font-mono uppercase tracking-wider text-nofx-text-muted flex items-center gap-2">
{title}
</div>
<div className="flex items-baseline gap-1 mb-1">
<div className="text-2xl font-bold font-mono text-nofx-text-main tracking-tight group-hover:text-white transition-colors">
{value}
{loading ? (
<div className="space-y-2">
<div className="h-7 w-24 rounded bg-white/5 animate-pulse" />
<div className="h-3 w-16 rounded bg-white/5 animate-pulse" />
</div>
{unit && <span className="text-xs font-mono text-nofx-text-muted opacity-60">{unit}</span>}
</div>
{change !== undefined && (
<div className="flex items-center gap-1">
<div
className={`text-sm mono font-bold flex items-center gap-1 ${positive ? 'text-nofx-green' : 'text-nofx-red'}`}
>
<span>{positive ? '▲' : '▼'}</span>
<span>{positive ? '+' : ''}{change.toFixed(2)}%</span>
) : (
<>
<div className="flex items-baseline gap-1 mb-1">
<div className="text-2xl font-bold font-mono text-nofx-text-main tracking-tight group-hover:text-white transition-colors">
{value}
</div>
{unit && <span className="text-xs font-mono text-nofx-text-muted opacity-60">{unit}</span>}
</div>
</div>
)}
{subtitle && (
<div className="text-xs mt-2 mono text-nofx-text-muted opacity-80">
{subtitle}
</div>
{change !== undefined && (
<div className="flex items-center gap-1">
<div
className={`text-sm mono font-bold flex items-center gap-1 ${positive ? 'text-nofx-green' : 'text-nofx-red'}`}
>
<span>{positive ? '▲' : '▼'}</span>
<span>{positive ? '+' : ''}{change.toFixed(2)}%</span>
</div>
</div>
)}
{subtitle && (
<div className="text-xs mt-2 mono text-nofx-text-muted opacity-80">
{subtitle}
</div>
)}
</>
)}
</div>
)

View File

@@ -6,6 +6,8 @@ export interface AIModel {
apiKey?: string
customApiUrl?: string
customModelName?: string
walletAddress?: string
balanceUsdc?: string
}
export interface TelegramConfig {
@@ -39,6 +41,30 @@ export interface Exchange {
lighterApiKeyIndex?: number
}
export type ExchangeAccountStatus =
| 'ok'
| 'disabled'
| 'missing_credentials'
| 'invalid_credentials'
| 'permission_denied'
| 'unavailable'
export interface ExchangeAccountState {
exchange_id: string
status: ExchangeAccountStatus
display_balance?: string
asset?: string
total_equity?: number
available_balance?: number
checked_at: string
error_code?: string
error_message?: string
}
export interface ExchangeAccountStateResponse {
states: Record<string, ExchangeAccountState>
}
export interface CreateExchangeRequest {
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
account_name: string // User-defined account name
@@ -110,3 +136,26 @@ export interface UpdateExchangeConfigRequest {
}
}
}
export interface BeginnerOnboardingResponse {
address: string
private_key: string
chain: string
asset: string
provider: string
default_model: string
configured_model_id: string
balance_usdc: string
env_saved: boolean
env_path?: string
reused_existing: boolean
env_warning?: string
}
export interface CurrentBeginnerWalletResponse {
found: boolean
address?: string
balance_usdc?: string
source?: string
claw402_status?: string
}

View File

@@ -97,6 +97,7 @@ export interface TraderInfo {
ai_model: string
exchange_id?: string
is_running?: boolean
startup_warning?: string
show_in_competition?: boolean
strategy_id?: string
strategy_name?: string