26 Commits

Author SHA1 Message Date
tinkle-community
7b9a0740c1 fix(security): block SSRF via custom AI model URL
Apply security.ValidateURL() to custom_api_url in PUT /api/models before
storing — blocks private IPs, cloud metadata endpoints, and localhost.
Replace plain http.Client in mcp/config.go with security.SafeHTTPClient()
for defense-in-depth (DialContext blocks private IPs, CheckRedirect
validates targets). Add SSRF warning to WithHTTPClient() docs.
2026-03-10 00:14:01 +08:00
ximi
8406f2f998 feat: add MiniMax provider support (#1406)
Add MiniMax as a new AI model provider with OpenAI-compatible API.

Supported models:
- MiniMax-M2.5 (default) - Peak Performance, Ultimate Value
- MiniMax-M2.5-highspeed - Same performance, faster and more agile

Changes:
- Add MiniMax client (mcp/minimax_client.go) with OpenAI-compatible API
- Add comprehensive unit tests (mcp/minimax_client_test.go)
- Add WithMiniMaxConfig option (mcp/options.go)
- Register MiniMax provider in trader, debate engine, backtest, and API
- Add MiniMax to frontend provider config and model icons
- Add MiniMax SVG icon

API Base URL: https://api.minimax.io/v1
2026-03-09 23:18:51 +08:00
tinkle-community
79a21890d8 fix(web): fix TypeScript build errors in AuthContext and translations 2026-03-08 00:30:34 +08:00
Hansen1018
bbd72c778c Update OpenAI default model from gpt-5.2 to gpt-5.4 (#1402) 2026-03-08 00:22:16 +08:00
tinkle-community
73f1fe105d refactor(auth): remove OTP flows from login/register/reset 2026-03-05 18:55:36 +08:00
tinkle-community
fa664ccae3 docs: use main OFFICIAL_ACCOUNTS.md 2026-03-05 18:35:27 +08:00
tinkle-community
0210d0e4b5 Merge branch 'main' into dev
# Conflicts:
#	docs/community/OFFICIAL_ACCOUNTS.md
2026-03-05 18:31:32 +08:00
Muhammad Syaiful Anwar
27a7491cd1 feat(trader): add Indodax exchange integration (#1400)
* feat(trader): add Indodax exchange integration

- Add IndodaxTrader implementing types.Trader interface for spot trading
- Support HMAC-SHA512 authentication with Key/Sign headers
- Map spot buy/sell to OpenLong/CloseLong, stub futures-only methods
- Wire up auto_trader.go, trader_manager.go, store/exchange.go
- Add Indodax to frontend ExchangeConfigModal and ExchangeIcons
- Add integration tests with env-var based credentials
- Add Indodax logo assets (PNG + SVG)

* fix: type validation at server.go for indodax exchange
2026-03-03 18:41:50 +08:00
Muhammad Syaiful Anwar
3358c5a53e feat(i18n): add Indonesian (Bahasa Indonesia) language support (#1399)
- Add 'id' to Language type in translations.ts
- Add ~1000 Indonesian translation keys covering all UI sections
- Update LanguageContext to persist 'id' in localStorage
- Add ID button to Header.tsx language toggle
- Add �� option to HeaderBar.tsx desktop dropdown and mobile toggle
- Add Indonesian translations to inline text objects in
  LoginRequiredOverlay, StrategyMarketPage, PositionHistory

Closes #XX
2026-03-03 18:39:09 +08:00
Minho Yi
285053b7a4 fix(web): cycle number not showing (#1398) 2026-03-03 18:30:46 +08:00
tinkle-community
c7039e6b4a Official Accounts 2026-02-28 04:10:50 +08:00
Hao Fu
06d6080751 feat(hyperliquid): Add Unified Account support for Spot as Perp collateral (#1387)
This PR adds support for Hyperliquid's Unified Account mode where Spot USDC
balance can be used as collateral for Perpetual trading.

Changes:
- Add HyperliquidUnifiedAcct field to Exchange config (default: true)
- Update HyperliquidTrader to support unified account mode
- When enabled, Spot USDC balance is added to available trading balance
- Update API request/response structs for unified account toggle
- Update trader config propagation from exchange config

This aligns with Hyperliquid's roadmap to make Unified Account the default.
2026-02-22 17:03:21 +08:00
Hao Fu
64935b9d47 feat(strategy): Add Hyperliquid coin sources (hyper_all, hyper_main) (#1388)
Add two new coin source options for Hyperliquid trading:

- hyper_all: All available Hyperliquid perpetual coins (229 coins)
- hyper_main: Top N coins by 24h volume (default 20)

Changes:
- Add CoinSourceConfig fields: UseHyperAll, UseHyperMain, HyperMainLimit
- Add provider/hyperliquid/coins.go with caching (24h) and volume-based sorting
- Add source types 'hyper_all' and 'hyper_main' to GetCandidateCoins()
- Support mixing with other sources in 'mixed' mode
- Add source tag formatting for UI display

This ensures traders using Hyperliquid can select coins that are actually
available on the exchange, avoiding 'symbol not found' errors.
2026-02-22 17:03:05 +08:00
tinkle-community
0000bc7f32 docs: move and optimize OFFICIAL_ACCOUNTS.md to docs/community
- Moved from project root to docs/community/ for better organization
- Added Telegram community channel to official accounts list
- Made all links clickable with proper markdown formatting
- Added direct GitHub issue link for impersonation reports
- Added navigation links and reference from community README
2026-02-20 23:03:50 +08:00
tinkle-community
bdb2744845 docs: move and optimize OFFICIAL_ACCOUNTS.md to docs/community
- Moved from project root to docs/community/ for better organization
- Added Telegram community channel to official accounts list
- Made all links clickable with proper markdown formatting
- Added direct GitHub issue link for impersonation reports
- Added navigation links and reference from community README
2026-02-20 23:03:01 +08:00
Alxy Savin
4c525c19c6 feat(i18n): add consolidated translation keys for strategy components (#1375)
* feat(i18n): add 42 translation keys for TraderConfigModal

- Add new translation keys for all hardcoded Chinese strings
- Replace hardcoded UI text with t('key', language) calls
- Support both English and Chinese languages

Modified files:
- web/src/i18n/translations.ts: +88 lines (42 new keys)
- web/src/components/TraderConfigModal.tsx: replaced 48 hardcoded strings

* feat(i18n): add consolidated translation keys (en + zh + es)

- 275+ translation keys from 8 strategy components
- 3 languages: English, Chinese, Spanish
- Ready for integration into translations.ts
- Pre-aggregated exports for zhStrategy, enStrategy, esStrategy

Related to PR #1343 (maker95) and #1374 (xsa-dev)
2026-02-09 10:48:17 +08:00
Alxy Savin
95daa39f0b feat(i18n): add 42 translation keys for TraderConfigModal (#1374)
- Add new translation keys for all hardcoded Chinese strings
- Replace hardcoded UI text with t('key', language) calls
- Support both English and Chinese languages

Modified files:
- web/src/i18n/translations.ts: +88 lines (42 new keys)
- web/src/components/TraderConfigModal.tsx: replaced 48 hardcoded strings
2026-02-09 10:46:30 +08:00
tinkle-community
24700d3a73 chore: upgraded Claude model to Opus 4.6
- Update mcp/claude_client.go default model
- Update api/server.go supported models list
- Update web AITradersPage.tsx default model
2026-02-08 14:06:39 +08:00
tinkle-community
ec582a6ec4 feat(web): improve grid direction adjustment UI clarity
- Rename 'Bias Ratio' to 'Bias Strength' (偏向强度)
- Add direction modes explanation (neutral/long/short/long_bias/short_bias)
- Show actual buy/sell ratios for both long_bias and short_bias modes
- Add bilingual support (Chinese/English)
- Clarify that X% applies differently to long_bias vs short_bias
2026-02-06 14:59:12 +08:00
tinkle-community
9bfa56e226 chore: update GitHub stats defaults (stars 10,500+, forks 2,800+, community 6,600+) 2026-02-06 02:32:29 +08:00
tinkle-community
9ef67bdcd8 chore: update AI model display to Claude Opus 4.6 2026-02-06 02:22:12 +08:00
tinkle-community
77d45690a6 chore: update AI model to Claude Opus 4.6 2026-02-06 02:20:43 +08:00
tinkle-community
b70b047f75 feat(web): add Agent Terminal panel to landing page
- Add AgentTerminal component with trading dashboard UI
- Display Portfolio PnL, metrics, order book, positions
- macOS-style terminal header with window controls
- Integrate into TerminalHero right column
- Remove unnecessary glow effects for cleaner look
2026-02-06 02:13:13 +08:00
tinkle-community
8896de2642 chore: remove broken test file 2026-02-06 00:47:51 +08:00
tinkle-community
eb89a49b58 test: add unit tests for Gate trade expansion 2026-02-06 00:47:25 +08:00
tinkle-community
0b4f43d72b fix: adaptive price precision for meme coins
- Add adaptivePriceRound() in store/position.go for database storage
- Update position_builder.go to use adaptive precision for entry/exit prices
- Add Gate to OrderSync skip list in auto_trader.go
- Add debug logging in gate/order_sync.go for price parsing issues
- Create web/src/utils/format.ts with formatPrice() for frontend display
- Update TraderDashboardPage.tsx and PositionHistory.tsx to use adaptive formatting

Fixes issue where meme coin prices (e.g. 0.000000166) displayed as 0.0000
2026-02-06 00:46:48 +08:00
59 changed files with 4429 additions and 1528 deletions

View File

@@ -832,6 +832,8 @@ func (s *Server) hydrateBacktestAIConfig(cfg *backtest.BacktestConfig) error {
provider = "google"
} else if strings.Contains(modelNameLower, "deepseek") {
provider = "deepseek"
} else if strings.Contains(modelNameLower, "minimax") {
provider = "minimax"
} else if model.CustomAPIURL != "" {
provider = "custom"
} else {

View File

@@ -1,252 +0,0 @@
package api
import (
"testing"
)
// MockUser Mock user structure
type MockUser struct {
ID int
Email string
OTPSecret string
OTPVerified bool
}
// TestOTPRefetchLogic Test OTP refetch logic
func TestOTPRefetchLogic(t *testing.T) {
tests := []struct {
name string
existingUser *MockUser
userExists bool
expectedAction string // "allow_refetch", "reject_duplicate", "create_new"
expectedMessage string
}{
{
name: "New user registration - email does not exist",
existingUser: nil,
userExists: false,
expectedAction: "create_new",
expectedMessage: "Create new user",
},
{
name: "Incomplete OTP verification - allow refetch",
existingUser: &MockUser{
ID: 1,
Email: "test@example.com",
OTPSecret: "SECRET123",
OTPVerified: false,
},
userExists: true,
expectedAction: "allow_refetch",
expectedMessage: "Incomplete registration detected, please continue OTP setup",
},
{
name: "Completed OTP verification - reject duplicate registration",
existingUser: &MockUser{
ID: 2,
Email: "verified@example.com",
OTPSecret: "SECRET456",
OTPVerified: true,
},
userExists: true,
expectedAction: "reject_duplicate",
expectedMessage: "Email already registered",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate logic processing flow
var actualAction string
var actualMessage string
if !tt.userExists {
// User does not exist, create new user
actualAction = "create_new"
actualMessage = "Create new user"
} else {
// User exists, check OTP verification status
if !tt.existingUser.OTPVerified {
// OTP verification incomplete, allow refetch
actualAction = "allow_refetch"
actualMessage = "Incomplete registration detected, please continue OTP setup"
} else {
// Verification completed, reject duplicate registration
actualAction = "reject_duplicate"
actualMessage = "Email already registered"
}
}
// Verify results
if actualAction != tt.expectedAction {
t.Errorf("Action mismatch: got %s, want %s", actualAction, tt.expectedAction)
}
if actualMessage != tt.expectedMessage {
t.Errorf("Message mismatch: got %s, want %s", actualMessage, tt.expectedMessage)
}
})
}
}
// TestOTPVerificationStates Test OTP verification state determination
func TestOTPVerificationStates(t *testing.T) {
tests := []struct {
name string
otpVerified bool
shouldAllowRefetch bool
}{
{
name: "OTP verified - disallow refetch",
otpVerified: true,
shouldAllowRefetch: false,
},
{
name: "OTP not verified - allow refetch",
otpVerified: false,
shouldAllowRefetch: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate verification logic
allowRefetch := !tt.otpVerified
if allowRefetch != tt.shouldAllowRefetch {
t.Errorf("Refetch logic error: OTPVerified=%v, allowRefetch=%v, expected=%v",
tt.otpVerified, allowRefetch, tt.shouldAllowRefetch)
}
})
}
}
// TestRegistrationFlow Test complete registration flow logic branches
func TestRegistrationFlow(t *testing.T) {
tests := []struct {
name string
scenario string
userExists bool
otpVerified bool
expectHTTPCode int // Simulated HTTP status code
expectResponse string
}{
{
name: "Scenario 1: New user first registration",
scenario: "New user first accesses registration endpoint",
userExists: false,
otpVerified: false,
expectHTTPCode: 200,
expectResponse: "Create user and return OTP setup information",
},
{
name: "Scenario 2: User re-accesses after interrupting registration",
scenario: "User registered previously but did not complete OTP setup, now re-accessing",
userExists: true,
otpVerified: false,
expectHTTPCode: 200,
expectResponse: "Return existing user's OTP information, allow continuation",
},
{
name: "Scenario 3: Registered user attempts duplicate registration",
scenario: "User already completed registration, attempts to register again with same email",
userExists: true,
otpVerified: true,
expectHTTPCode: 409, // Conflict
expectResponse: "Email already registered",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate registration flow logic
var actualHTTPCode int
var actualResponse string
if !tt.userExists {
// New user, create and return OTP information
actualHTTPCode = 200
actualResponse = "Create user and return OTP setup information"
} else {
// User exists
if !tt.otpVerified {
// OTP verification incomplete, allow refetch
actualHTTPCode = 200
actualResponse = "Return existing user's OTP information, allow continuation"
} else {
// Verification completed, reject duplicate registration
actualHTTPCode = 409
actualResponse = "Email already registered"
}
}
// Verify
if actualHTTPCode != tt.expectHTTPCode {
t.Errorf("HTTP code mismatch: got %d, want %d (scenario: %s)",
actualHTTPCode, tt.expectHTTPCode, tt.scenario)
}
if actualResponse != tt.expectResponse {
t.Errorf("Response mismatch: got %s, want %s (scenario: %s)",
actualResponse, tt.expectResponse, tt.scenario)
}
t.Logf("✓ %s: HTTP %d, %s", tt.scenario, actualHTTPCode, actualResponse)
})
}
}
// TestEdgeCases Test edge cases
func TestEdgeCases(t *testing.T) {
tests := []struct {
name string
user *MockUser
expectAllow bool
description string
}{
{
name: "User ID is 0 - treated as new user",
user: &MockUser{
ID: 0,
Email: "new@example.com",
OTPVerified: false,
},
expectAllow: true,
description: "ID of 0 usually indicates user has not been created yet",
},
{
name: "OTPSecret is empty - still can refetch",
user: &MockUser{
ID: 1,
Email: "test@example.com",
OTPSecret: "",
OTPVerified: false,
},
expectAllow: true,
description: "Even if OTPSecret is empty, as long as not verified, refetch is allowed",
},
{
name: "OTPSecret exists but already verified - not allowed",
user: &MockUser{
ID: 2,
Email: "verified@example.com",
OTPSecret: "SECRET789",
OTPVerified: true,
},
expectAllow: false,
description: "Users with verified OTP cannot refetch",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Core logic: as long as OTPVerified is false, refetch is allowed
allowRefetch := !tt.user.OTPVerified
if allowRefetch != tt.expectAllow {
t.Errorf("Edge case failed: %s\nUser: ID=%d, OTPVerified=%v\nExpected allow=%v, got=%v",
tt.description, tt.user.ID, tt.user.OTPVerified, tt.expectAllow, allowRefetch)
}
t.Logf("✓ %s", tt.description)
})
}
}

View File

@@ -12,6 +12,7 @@ import (
"nofx/crypto"
"nofx/logger"
"nofx/manager"
"nofx/security"
"nofx/market"
"nofx/provider/alpaca"
"nofx/provider/coinank/coinank_api"
@@ -142,8 +143,6 @@ func (s *Server) setupRoutes() {
// Authentication related routes (no authentication required)
api.POST("/register", s.handleRegister)
api.POST("/login", s.handleLogin)
api.POST("/verify-otp", s.handleVerifyOTP)
api.POST("/complete-registration", s.handleCompleteRegistration)
// Routes requiring authentication
protected := api.Group("/", s.authMiddleware())
@@ -484,6 +483,7 @@ type UpdateExchangeConfigRequest struct {
Passphrase string `json:"passphrase"` // OKX specific
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
@@ -600,6 +600,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
string(exchangeCfg.APIKey), // private key
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
)
case "aster":
tempTrader, createErr = aster.NewAsterTrader(
@@ -1169,6 +1170,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
)
case "aster":
tempTrader, createErr = aster.NewAsterTrader(
@@ -1332,6 +1334,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
)
case "aster":
tempTrader, createErr = aster.NewAsterTrader(
@@ -1681,6 +1684,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false},
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false},
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false},
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false},
}
c.JSON(http.StatusOK, defaultModels)
return
@@ -1767,6 +1771,15 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
// Update each model's configuration and track traders that need reload
tradersToReload := make(map[string]bool)
for modelID, modelData := range req.Models {
// SSRF protection: validate custom_api_url before storing
if modelData.CustomAPIURL != "" {
cleanURL := strings.TrimSuffix(modelData.CustomAPIURL, "#")
if err := security.ValidateURL(cleanURL); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid custom_api_url for model %s: %s", modelID, err.Error())})
return
}
}
// Find traders using this AI model BEFORE updating
traders, _ := s.store.Trader().ListByAIModelID(userID, modelID)
for _, t := range traders {
@@ -1906,7 +1919,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
tradersToReload[t.ID] = true
}
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
if err != nil {
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
return
@@ -1940,6 +1953,7 @@ type CreateExchangeRequest struct {
Passphrase string `json:"passphrase"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
@@ -2003,7 +2017,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
// Validate exchange type
validTypes := map[string]bool{
"binance": true, "bybit": true, "okx": true, "bitget": true,
"hyperliquid": true, "aster": true, "lighter": true, "gate": true, "kucoin": true,
"hyperliquid": true, "aster": true, "lighter": true, "gate": true, "kucoin": true, "indodax": true,
}
if !validTypes[req.ExchangeType] {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
@@ -2014,7 +2028,8 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
id, err := s.store.Exchange().Create(
userID, req.ExchangeType, req.AccountName, req.Enabled,
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
req.HyperliquidWalletAddr, req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
)
if err != nil {
@@ -3089,29 +3104,9 @@ func (s *Server) handleRegister(c *gin.Context) {
return
}
// Check if email already exists (must check before maxUsers to allow incomplete OTP users)
existingUser, err := s.store.User().GetByEmail(req.Email)
// Check if email already exists
_, err := s.store.User().GetByEmail(req.Email)
if err == nil {
// User exists, check OTP verification status
if !existingUser.OTPVerified {
// OTP not verified, verify password first for security
if !auth.CheckPassword(req.Password, existingUser.PasswordHash) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
return
}
// Password correct, allow user to continue OTP setup
// Return existing OTP information
qrCodeURL := auth.GetOTPQRCodeURL(existingUser.OTPSecret, req.Email)
c.JSON(http.StatusOK, gin.H{
"user_id": existingUser.ID,
"email": existingUser.Email,
"otp_secret": existingUser.OTPSecret,
"qr_code_url": qrCodeURL,
"message": "Incomplete registration detected, please continue OTP setup",
})
return
}
// OTP already verified, reject duplicate registration
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
return
}
@@ -3137,21 +3132,12 @@ func (s *Server) handleRegister(c *gin.Context) {
return
}
// Generate OTP secret
otpSecret, err := auth.GenerateOTPSecret()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "OTP secret generation failed"})
return
}
// Create user (unverified OTP status)
// Create user
userID := uuid.New().String()
user := &store.User{
ID: userID,
Email: req.Email,
PasswordHash: passwordHash,
OTPSecret: otpSecret,
OTPVerified: false,
}
err = s.store.User().Create(user)
@@ -3160,49 +3146,6 @@ func (s *Server) handleRegister(c *gin.Context) {
return
}
// Return OTP setup information
qrCodeURL := auth.GetOTPQRCodeURL(otpSecret, req.Email)
c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"email": req.Email,
"otp_secret": otpSecret,
"qr_code_url": qrCodeURL,
"message": "Please scan the QR code with Google Authenticator and verify OTP",
})
}
// handleCompleteRegistration Complete registration (verify OTP)
func (s *Server) handleCompleteRegistration(c *gin.Context) {
var req struct {
UserID string `json:"user_id" binding:"required"`
OTPCode string `json:"otp_code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Get user information
user, err := s.store.User().GetByID(req.UserID)
if err != nil {
SafeNotFound(c, "User")
return
}
// Verify OTP
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": "OTP code error"})
return
}
// Update user OTP verified status
err = s.store.User().UpdateOTPVerified(req.UserID, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"})
return
}
// Generate JWT token
token, err := auth.GenerateJWT(user.ID, user.Email)
if err != nil {
@@ -3220,7 +3163,7 @@ func (s *Server) handleCompleteRegistration(c *gin.Context) {
"token": token,
"user_id": user.ID,
"email": user.Email,
"message": "Registration completed",
"message": "Registration successful",
})
}
@@ -3249,56 +3192,7 @@ func (s *Server) handleLogin(c *gin.Context) {
return
}
// Check if OTP is verified
if !user.OTPVerified {
// Return OTP info so user can complete setup
qrCodeURL := auth.GetOTPQRCodeURL(user.OTPSecret, user.Email)
c.JSON(http.StatusOK, gin.H{
"user_id": user.ID,
"email": user.Email,
"otp_secret": user.OTPSecret,
"qr_code_url": qrCodeURL,
"requires_otp_setup": true,
"message": "Please complete OTP setup first",
})
return
}
// Return status requiring OTP verification
c.JSON(http.StatusOK, gin.H{
"user_id": user.ID,
"email": user.Email,
"message": "Please enter Google Authenticator code",
"requires_otp": true,
})
}
// handleVerifyOTP Verify OTP and complete login
func (s *Server) handleVerifyOTP(c *gin.Context) {
var req struct {
UserID string `json:"user_id" binding:"required"`
OTPCode string `json:"otp_code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Get user information
user, err := s.store.User().GetByID(req.UserID)
if err != nil {
SafeNotFound(c, "User")
return
}
// Verify OTP
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Verification code error"})
return
}
// Generate JWT token
// Issue token directly after password verification.
token, err := auth.GenerateJWT(user.ID, user.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
@@ -3313,12 +3207,11 @@ func (s *Server) handleVerifyOTP(c *gin.Context) {
})
}
// handleResetPassword Reset password (via email + OTP verification)
// handleResetPassword Reset password via email and new password
func (s *Server) handleResetPassword(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
NewPassword string `json:"new_password" binding:"required,min=6"`
OTPCode string `json:"otp_code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -3333,12 +3226,6 @@ func (s *Server) handleResetPassword(c *gin.Context) {
return
}
// Verify OTP
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Google Authenticator code error"})
return
}
// Generate new password hash
newPasswordHash, err := auth.HashPassword(req.NewPassword)
if err != nil {
@@ -3372,10 +3259,11 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
{"id": "deepseek", "name": "DeepSeek", "provider": "deepseek", "defaultModel": "deepseek-chat"},
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-5-20251101"},
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
{"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"},
}
c.JSON(http.StatusOK, supportedModels)

View File

@@ -625,6 +625,9 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
case "openai":
aiClient = mcp.NewOpenAIClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
case "minimax":
aiClient = mcp.NewMiniMaxClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
default:
// Use generic client
aiClient = mcp.NewClient()

View File

@@ -1,15 +1,12 @@
package auth
import (
"crypto/rand"
"fmt"
"log"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
)
@@ -25,9 +22,6 @@ var tokenBlacklist = struct {
// maxBlacklistEntries is the maximum capacity threshold for blacklist
const maxBlacklistEntries = 100_000
// OTPIssuer is the OTP issuer name
const OTPIssuer = "nofxAI"
// SetJWTSecret sets the JWT secret key
func SetJWTSecret(secret string) {
JWTSecret = []byte(secret)
@@ -87,30 +81,6 @@ func CheckPassword(password, hash string) bool {
return err == nil
}
// GenerateOTPSecret generates OTP secret
func GenerateOTPSecret() (string, error) {
secret := make([]byte, 20)
_, err := rand.Read(secret)
if err != nil {
return "", err
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: OTPIssuer,
AccountName: uuid.New().String(),
})
if err != nil {
return "", err
}
return key.Secret(), nil
}
// VerifyOTP verifies OTP code
func VerifyOTP(secret, code string) bool {
return totp.Validate(code, secret)
}
// GenerateJWT generates JWT token
func GenerateJWT(userID, email string) (string, error) {
claims := Claims{
@@ -147,8 +117,3 @@ func ValidateJWT(tokenString string) (*Claims, error) {
return nil, fmt.Errorf("invalid token")
}
// GetOTPQRCodeURL gets OTP QR code URL
func GetOTPQRCodeURL(secret, email string) string {
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", OTPIssuer, email, secret, OTPIssuer)
}

View File

@@ -71,6 +71,13 @@ func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, er
oaiC := mcp.NewOpenAIClientWithOptions()
oaiC.(*mcp.OpenAIClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
return oaiC, nil
case "minimax":
if cfg.AICfg.APIKey == "" {
return nil, fmt.Errorf("minimax provider requires api key")
}
mmC := mcp.NewMiniMaxClientWithOptions()
mmC.(*mcp.MiniMaxClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
return mmC, nil
case "custom":
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
return nil, fmt.Errorf("custom provider requires base_url, api key and model")
@@ -125,6 +132,11 @@ func cloneBaseClient(base mcp.AIClient) *mcp.Client {
cp := *c.Client
return &cp
}
case *mcp.MiniMaxClient:
if c != nil && c.Client != nil {
cp := *c.Client
return &cp
}
}
// Fall back to a new default client
return mcp.NewClient().(*mcp.Client)

View File

@@ -97,6 +97,8 @@ func (e *DebateEngine) InitializeClients(participants []*store.DebateParticipant
client = mcp.NewGrokClient()
case "kimi":
client = mcp.NewKimiClient()
case "minimax":
client = mcp.NewMiniMaxClient()
default:
client = mcp.New()
}

View File

@@ -0,0 +1,50 @@
# ⚠️ Official Accounts & Anti-Impersonation Notice
## Legal Entity
| Field | Details |
|-------|---------|
| Company Name | **Cryonic Holdings Limited** |
| Company No. | 2193977 |
| Jurisdiction | British Virgin Islands |
| Address | Mandar House, 3rd Floor, P.O. Box 2196, Johnson's Ghut, Tortola, BVI |
| Contact Email | 0xccfelix@gmail.com |
## Official Social Media & Channels
| Platform | Official Account | Link | Status |
|----------|-----------------|------|--------|
| Twitter/X | **@nofx_official** | https://x.com/nofx_official | ✅ Official |
| Twitter/X | **@Web3Tinkle** | https://x.com/Web3Tinkle | ✅ Founder |
| GitHub | **NoFxAiOS** | https://github.com/NoFxAiOS | ✅ Official |
| Website | **nofxai.com** | https://nofxai.com | ✅ Official |
| Dashboard | **nofxos.ai** | https://nofxos.ai | ✅ Official |
## ⛔ Known Impersonation Accounts
The following accounts are **NOT affiliated** with the NoFx project:
| Platform | Account | Status |
|----------|---------|--------|
| Twitter/X | @nofx_ai | ❌ **NOT OFFICIAL** — Not affiliated with this project |
> **Warning:** Any account claiming to represent NoFx that is not listed above is unauthorized. Please verify through this page before trusting any account claiming to be associated with NoFx.
## How to Verify Authenticity
1. Check this page (OFFICIAL_ACCOUNTS.md) in our official GitHub repository
2. Our GitHub repository sidebar links directly to our official Twitter
3. Our README.md lists all official accounts under "Core Team" and "Official Links"
4. Our operating entity is Cryonic Holdings Limited (BVI No. 2193977)
5. Official contact email: 0xccfelix@gmail.com
## Report Impersonation
If you encounter accounts impersonating NoFx, please:
1. Report them on the respective platform
2. Open an issue in this repository to notify our team
---
*Last updated: 2026-03-01*
*This document is maintained by Cryonic Holdings Limited in the official NoFx GitHub repository (10,500+ ⭐)*

View File

@@ -241,6 +241,7 @@ NOFX offers bounties for valuable contributions:
- **Want to claim bounty?** → [Bounty Guide](bounty-guide.md)
- **Found a security issue?** → [Security Policy](../../SECURITY.md)
- **Have questions?** → [Telegram Community](https://t.me/nofx_dev_community)
- **Verify official accounts?** → [Official Accounts & Anti-Impersonation](OFFICIAL_ACCOUNTS.md)
---

1
go.mod
View File

@@ -12,7 +12,6 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/pquerna/otp v1.4.0
github.com/rs/zerolog v1.34.0
github.com/sirupsen/logrus v1.9.3
github.com/sonirico/go-hyperliquid v0.26.0

2
go.sum
View File

@@ -186,8 +186,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=

View File

@@ -1,6 +1,7 @@
package kernel
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -8,6 +9,7 @@ import (
"nofx/logger"
"nofx/market"
"nofx/mcp"
"nofx/provider/hyperliquid"
"nofx/provider/nofxos"
"nofx/security"
"nofx/store"
@@ -490,6 +492,44 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
// 空列表是正常情况,直接返回
return e.filterExcludedCoins(coins), nil
case "hyper_all":
// All Hyperliquid perp coins
if !coinSource.UseHyperAll {
logger.Infof("⚠️ source_type is 'hyper_all' but use_hyper_all is false, falling back to static coins")
for _, symbol := range coinSource.StaticCoins {
symbol = market.Normalize(symbol)
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"static"},
})
}
return e.filterExcludedCoins(candidates), nil
}
coins, err := e.getHyperAllCoins()
if err != nil {
return nil, err
}
return e.filterExcludedCoins(coins), nil
case "hyper_main":
// Top N Hyperliquid coins by 24h volume
if !coinSource.UseHyperMain {
logger.Infof("⚠️ source_type is 'hyper_main' but use_hyper_main is false, falling back to static coins")
for _, symbol := range coinSource.StaticCoins {
symbol = market.Normalize(symbol)
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"static"},
})
}
return e.filterExcludedCoins(candidates), nil
}
coins, err := e.getHyperMainCoins(coinSource.HyperMainLimit)
if err != nil {
return nil, err
}
return e.filterExcludedCoins(coins), nil
case "mixed":
if coinSource.UseAI500 {
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
@@ -524,6 +564,28 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
}
}
if coinSource.UseHyperAll {
hyperCoins, err := e.getHyperAllCoins()
if err != nil {
logger.Infof("⚠️ Failed to get Hyperliquid All coins: %v", err)
} else {
for _, coin := range hyperCoins {
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "hyper_all")
}
}
}
if coinSource.UseHyperMain {
hyperMainCoins, err := e.getHyperMainCoins(coinSource.HyperMainLimit)
if err != nil {
logger.Infof("⚠️ Failed to get Hyperliquid Main coins: %v", err)
} else {
for _, coin := range hyperMainCoins {
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "hyper_main")
}
}
}
for _, symbol := range coinSource.StaticCoins {
symbol = market.Normalize(symbol)
if _, exists := symbolSources[symbol]; !exists {
@@ -640,6 +702,52 @@ func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) {
return candidates, nil
}
// getHyperAllCoins returns all available Hyperliquid perpetual coins
func (e *StrategyEngine) getHyperAllCoins() ([]CandidateCoin, error) {
ctx := context.Background()
symbols, err := hyperliquid.GetAllCoinSymbols(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get Hyperliquid coins: %w", err)
}
var candidates []CandidateCoin
for _, symbol := range symbols {
// Add USDT suffix for compatibility
normalizedSymbol := market.Normalize(symbol + "USDT")
candidates = append(candidates, CandidateCoin{
Symbol: normalizedSymbol,
Sources: []string{"hyper_all"},
})
}
logger.Infof("✅ Loaded %d Hyperliquid coins (hyper_all)", len(candidates))
return candidates, nil
}
// getHyperMainCoins returns top N Hyperliquid coins by 24h volume
func (e *StrategyEngine) getHyperMainCoins(limit int) ([]CandidateCoin, error) {
if limit <= 0 {
limit = 20
}
ctx := context.Background()
symbols, err := hyperliquid.GetMainCoinSymbols(ctx, limit)
if err != nil {
return nil, fmt.Errorf("failed to get Hyperliquid main coins: %w", err)
}
var candidates []CandidateCoin
for _, symbol := range symbols {
// Add USDT suffix for compatibility
normalizedSymbol := market.Normalize(symbol + "USDT")
candidates = append(candidates, CandidateCoin{
Symbol: normalizedSymbol,
Sources: []string{"hyper_main"},
})
}
logger.Infof("✅ Loaded %d Hyperliquid main coins (hyper_main) by 24h volume", len(candidates))
return candidates, nil
}
// ============================================================================
// External & Quant Data
// ============================================================================
@@ -1350,6 +1458,8 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
hasAI500 := false
hasOITop := false
hasOILow := false
hasHyperAll := false
hasHyperMain := false
for _, s := range sources {
switch s {
case "ai500":
@@ -1358,6 +1468,10 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
hasOITop = true
case "oi_low":
hasOILow = true
case "hyper_all":
hasHyperAll = true
case "hyper_main":
hasHyperMain = true
}
}
if hasAI500 && hasOITop {
@@ -1369,6 +1483,12 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
if hasOITop && hasOILow {
return " (OI_Top+OI_Low)"
}
if hasHyperMain && hasAI500 {
return " (HyperMain+AI500)"
}
if hasHyperAll || hasHyperMain {
return " (Hyperliquid)"
}
return " (Multiple sources)"
} else if len(sources) == 1 {
switch sources[0] {
@@ -1380,6 +1500,10 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
return " (OI_Low 持仓减少)"
case "static":
return " (Manual selection)"
case "hyper_all":
return " (Hyperliquid All)"
case "hyper_main":
return " (Hyperliquid Top20)"
}
}
return ""

View File

@@ -407,7 +407,6 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) {
return result, nil
}
// RemoveTrader removes a trader from memory (does not affect database)
// Used to force reload when updating trader configuration
// If the trader is running, it will be stopped first
@@ -664,11 +663,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
QwenKey: "",
CustomAPIURL: aiModelCfg.CustomAPIURL,
CustomModelName: aiModelCfg.CustomModelName,
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
InitialBalance: traderCfg.InitialBalance,
IsCrossMargin: traderCfg.IsCrossMargin,
ShowInCompetition: traderCfg.ShowInCompetition,
StrategyConfig: strategyConfig,
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
InitialBalance: traderCfg.InitialBalance,
IsCrossMargin: traderCfg.IsCrossMargin,
ShowInCompetition: traderCfg.ShowInCompetition,
StrategyConfig: strategyConfig,
}
logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v",
@@ -700,6 +699,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
case "hyperliquid":
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
traderConfig.HyperliquidUnifiedAcct = exchangeCfg.HyperliquidUnifiedAcct
case "aster":
traderConfig.AsterUser = exchangeCfg.AsterUser
traderConfig.AsterSigner = exchangeCfg.AsterSigner
@@ -710,6 +710,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
traderConfig.LighterAPIKeyPrivateKey = string(exchangeCfg.LighterAPIKeyPrivateKey)
traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex
traderConfig.LighterTestnet = exchangeCfg.Testnet
case "indodax":
traderConfig.IndodaxAPIKey = string(exchangeCfg.APIKey)
traderConfig.IndodaxSecretKey = string(exchangeCfg.SecretKey)
}
// Set API keys based on AI model (convert EncryptedString to string)

View File

@@ -9,7 +9,7 @@ import (
const (
ProviderClaude = "claude"
DefaultClaudeBaseURL = "https://api.anthropic.com/v1"
DefaultClaudeModel = "claude-opus-4-5-20251101"
DefaultClaudeModel = "claude-opus-4-6"
)
type ClaudeClient struct {

View File

@@ -7,6 +7,7 @@ import (
"time"
"nofx/logger"
"nofx/security"
)
// Config client configuration (centralized management of all configurations)
@@ -48,7 +49,7 @@ func DefaultConfig() *Config {
// Default dependencies (use global logger)
Logger: logger.NewMCPLogger(),
HTTPClient: &http.Client{Timeout: DefaultTimeout},
HTTPClient: security.SafeHTTPClient(DefaultTimeout),
}
}

83
mcp/minimax_client.go Normal file
View File

@@ -0,0 +1,83 @@
package mcp
import (
"net/http"
)
const (
ProviderMiniMax = "minimax"
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
DefaultMiniMaxModel = "MiniMax-M2.5"
)
type MiniMaxClient struct {
*Client
}
// NewMiniMaxClient creates MiniMax client (backward compatible)
func NewMiniMaxClient() AIClient {
return NewMiniMaxClientWithOptions()
}
// NewMiniMaxClientWithOptions creates MiniMax client (supports options pattern)
//
// Usage examples:
//
// // Basic usage
// client := mcp.NewMiniMaxClientWithOptions()
//
// // Custom configuration
// client := mcp.NewMiniMaxClientWithOptions(
// mcp.WithAPIKey("sk-xxx"),
// mcp.WithLogger(customLogger),
// mcp.WithTimeout(60*time.Second),
// )
func NewMiniMaxClientWithOptions(opts ...ClientOption) AIClient {
// 1. Create MiniMax preset options
minimaxOpts := []ClientOption{
WithProvider(ProviderMiniMax),
WithModel(DefaultMiniMaxModel),
WithBaseURL(DefaultMiniMaxBaseURL),
}
// 2. Merge user options (user options have higher priority)
allOpts := append(minimaxOpts, opts...)
// 3. Create base client
baseClient := NewClient(allOpts...).(*Client)
// 4. Create MiniMax client
minimaxClient := &MiniMaxClient{
Client: baseClient,
}
// 5. Set hooks to point to MiniMaxClient (implement dynamic dispatch)
baseClient.hooks = minimaxClient
return minimaxClient
}
func (c *MiniMaxClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.logger.Infof("🔧 [MCP] MiniMax API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.logger.Infof("🔧 [MCP] MiniMax using custom BaseURL: %s", customURL)
} else {
c.logger.Infof("🔧 [MCP] MiniMax using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] MiniMax using custom Model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] MiniMax using default Model: %s", c.Model)
}
}
// MiniMax uses standard OpenAI-compatible API with Bearer auth
func (c *MiniMaxClient) setAuthHeader(reqHeaders http.Header) {
c.Client.setAuthHeader(reqHeaders)
}

272
mcp/minimax_client_test.go Normal file
View File

@@ -0,0 +1,272 @@
package mcp
import (
"testing"
"time"
)
// ============================================================
// Test MiniMaxClient Creation and Configuration
// ============================================================
func TestNewMiniMaxClient_Default(t *testing.T) {
client := NewMiniMaxClient()
if client == nil {
t.Fatal("client should not be nil")
}
// Type assertion check
mmClient, ok := client.(*MiniMaxClient)
if !ok {
t.Fatal("client should be *MiniMaxClient")
}
// Verify default values
if mmClient.Provider != ProviderMiniMax {
t.Errorf("Provider should be '%s', got '%s'", ProviderMiniMax, mmClient.Provider)
}
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
t.Errorf("BaseURL should be '%s', got '%s'", DefaultMiniMaxBaseURL, mmClient.BaseURL)
}
if mmClient.Model != DefaultMiniMaxModel {
t.Errorf("Model should be '%s', got '%s'", DefaultMiniMaxModel, mmClient.Model)
}
if mmClient.logger == nil {
t.Error("logger should not be nil")
}
if mmClient.httpClient == nil {
t.Error("httpClient should not be nil")
}
}
func TestNewMiniMaxClientWithOptions(t *testing.T) {
mockLogger := NewMockLogger()
customModel := "MiniMax-M2.5-highspeed"
customAPIKey := "sk-custom-key"
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
WithModel(customModel),
WithAPIKey(customAPIKey),
WithMaxTokens(4000),
)
mmClient := client.(*MiniMaxClient)
// Verify custom options are applied
if mmClient.logger != mockLogger {
t.Error("logger should be set from option")
}
if mmClient.Model != customModel {
t.Error("Model should be set from option")
}
if mmClient.APIKey != customAPIKey {
t.Error("APIKey should be set from option")
}
if mmClient.MaxTokens != 4000 {
t.Error("MaxTokens should be 4000")
}
// Verify MiniMax default values are retained
if mmClient.Provider != ProviderMiniMax {
t.Errorf("Provider should still be '%s'", ProviderMiniMax)
}
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
t.Errorf("BaseURL should still be '%s'", DefaultMiniMaxBaseURL)
}
}
// ============================================================
// Test SetAPIKey
// ============================================================
func TestMiniMaxClient_SetAPIKey(t *testing.T) {
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
)
mmClient := client.(*MiniMaxClient)
// Test setting API Key (default URL and Model)
mmClient.SetAPIKey("sk-test-key-12345678", "", "")
if mmClient.APIKey != "sk-test-key-12345678" {
t.Errorf("APIKey should be 'sk-test-key-12345678', got '%s'", mmClient.APIKey)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
if len(logs) == 0 {
t.Error("should have logged API key setting")
}
// Verify BaseURL and Model remain default
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
t.Error("BaseURL should remain default")
}
if mmClient.Model != DefaultMiniMaxModel {
t.Error("Model should remain default")
}
}
func TestMiniMaxClient_SetAPIKey_WithCustomURL(t *testing.T) {
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
)
mmClient := client.(*MiniMaxClient)
customURL := "https://api.minimaxi.com/v1"
mmClient.SetAPIKey("sk-test-key-12345678", customURL, "")
if mmClient.BaseURL != customURL {
t.Errorf("BaseURL should be '%s', got '%s'", customURL, mmClient.BaseURL)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
hasCustomURLLog := false
for _, log := range logs {
if log.Format == "🔧 [MCP] MiniMax using custom BaseURL: %s" {
hasCustomURLLog = true
break
}
}
if !hasCustomURLLog {
t.Error("should have logged custom BaseURL")
}
}
func TestMiniMaxClient_SetAPIKey_WithCustomModel(t *testing.T) {
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
)
mmClient := client.(*MiniMaxClient)
customModel := "MiniMax-M2.5-highspeed"
mmClient.SetAPIKey("sk-test-key-12345678", "", customModel)
if mmClient.Model != customModel {
t.Errorf("Model should be '%s', got '%s'", customModel, mmClient.Model)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
hasCustomModelLog := false
for _, log := range logs {
if log.Format == "🔧 [MCP] MiniMax using custom Model: %s" {
hasCustomModelLog = true
break
}
}
if !hasCustomModelLog {
t.Error("should have logged custom Model")
}
}
// ============================================================
// Test Integration Features
// ============================================================
func TestMiniMaxClient_CallWithMessages_Success(t *testing.T) {
mockHTTP := NewMockHTTPClient()
mockHTTP.SetSuccessResponse("MiniMax AI response")
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithHTTPClient(mockHTTP.ToHTTPClient()),
WithLogger(mockLogger),
WithAPIKey("sk-test-key"),
)
result, err := client.CallWithMessages("system prompt", "user prompt")
if err != nil {
t.Fatalf("should not error: %v", err)
}
if result != "MiniMax AI response" {
t.Errorf("expected 'MiniMax AI response', got '%s'", result)
}
// Verify request
requests := mockHTTP.GetRequests()
if len(requests) != 1 {
t.Fatalf("expected 1 request, got %d", len(requests))
}
req := requests[0]
// Verify URL
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
if req.URL.String() != expectedURL {
t.Errorf("expected URL '%s', got '%s'", expectedURL, req.URL.String())
}
// Verify Authorization header
authHeader := req.Header.Get("Authorization")
if authHeader != "Bearer sk-test-key" {
t.Errorf("expected 'Bearer sk-test-key', got '%s'", authHeader)
}
// Verify Content-Type
if req.Header.Get("Content-Type") != "application/json" {
t.Error("Content-Type should be application/json")
}
}
func TestMiniMaxClient_Timeout(t *testing.T) {
client := NewMiniMaxClientWithOptions(
WithTimeout(30 * time.Second),
)
mmClient := client.(*MiniMaxClient)
if mmClient.httpClient.Timeout != 30*time.Second {
t.Errorf("expected timeout 30s, got %v", mmClient.httpClient.Timeout)
}
// Test SetTimeout
client.SetTimeout(60 * time.Second)
if mmClient.httpClient.Timeout != 60*time.Second {
t.Errorf("expected timeout 60s after SetTimeout, got %v", mmClient.httpClient.Timeout)
}
}
// ============================================================
// Test hooks Mechanism
// ============================================================
func TestMiniMaxClient_HooksIntegration(t *testing.T) {
client := NewMiniMaxClientWithOptions()
mmClient := client.(*MiniMaxClient)
// Verify hooks point to mmClient itself (implements polymorphism)
if mmClient.hooks != mmClient {
t.Error("hooks should point to mmClient for polymorphism")
}
// Verify buildUrl uses MiniMax configuration
url := mmClient.buildUrl()
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
if url != expectedURL {
t.Errorf("expected URL '%s', got '%s'", expectedURL, url)
}
}

View File

@@ -7,7 +7,7 @@ import (
const (
ProviderOpenAI = "openai"
DefaultOpenAIBaseURL = "https://api.openai.com/v1"
DefaultOpenAIModel = "gpt-5.2"
DefaultOpenAIModel = "gpt-5.4"
)
type OpenAIClient struct {

View File

@@ -22,7 +22,11 @@ func WithLogger(logger Logger) ClientOption {
}
}
// WithHTTPClient sets custom HTTP client
// WithHTTPClient sets custom HTTP client.
//
// WARNING: The default client uses security.SafeHTTPClient() with SSRF protection
// (blocks private IPs, cloud metadata, validates redirects). Overriding it bypasses
// these protections. Only use in tests or with a client providing equivalent safeguards.
//
// Usage example:
// httpClient := &http.Client{Timeout: 60 * time.Second}
@@ -160,3 +164,17 @@ func WithQwenConfig(apiKey string) ClientOption {
c.Model = DefaultQwenModel
}
}
// WithMiniMaxConfig sets MiniMax configuration
//
// Usage example:
//
// client := mcp.NewClient(mcp.WithMiniMaxConfig("sk-xxx"))
func WithMiniMaxConfig(apiKey string) ClientOption {
return func(c *Config) {
c.Provider = ProviderMiniMax
c.APIKey = apiKey
c.BaseURL = DefaultMiniMaxBaseURL
c.Model = DefaultMiniMaxModel
}
}

View File

@@ -0,0 +1,223 @@
package hyperliquid
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"nofx/logger"
"sort"
"sync"
"time"
)
const (
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
cacheDuration = 24 * time.Hour // Cache for 24 hours
)
// CoinInfo represents basic coin information
type CoinInfo struct {
Symbol string `json:"symbol"`
Volume24h float64 `json:"volume_24h"` // 24h volume in USD
}
// CoinProvider provides Hyperliquid coin lists
type CoinProvider struct {
mu sync.RWMutex
allCoins []CoinInfo
mainCoins []CoinInfo
lastUpdated time.Time
httpClient *http.Client
}
var (
defaultProvider *CoinProvider
providerOnce sync.Once
)
// GetProvider returns the singleton CoinProvider instance
func GetProvider() *CoinProvider {
providerOnce.Do(func() {
defaultProvider = &CoinProvider{
httpClient: &http.Client{Timeout: 30 * time.Second},
}
})
return defaultProvider
}
// metaResponse represents the response from Hyperliquid meta endpoint
type metaResponse struct {
Universe []struct {
Name string `json:"name"`
} `json:"universe"`
}
// assetCtx represents asset context with volume data
type assetCtx struct {
DayNtlVlm string `json:"dayNtlVlm"` // 24h notional volume
}
// fetchCoins fetches all coins from Hyperliquid API and sorts by volume
func (p *CoinProvider) fetchCoins(ctx context.Context) error {
// Request metaAndAssetCtxs to get both coin names and volume data
reqBody := []byte(`{"type": "metaAndAssetCtxs"}`)
req, err := http.NewRequestWithContext(ctx, "POST", hyperliquidInfoURL,
bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch coin data: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API returned status %d", resp.StatusCode)
}
// Response is an array: [meta, [assetCtxs...]]
var rawResp []json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&rawResp); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if len(rawResp) < 2 {
return fmt.Errorf("unexpected response format")
}
// Parse meta
var meta metaResponse
if err := json.Unmarshal(rawResp[0], &meta); err != nil {
return fmt.Errorf("failed to parse meta: %w", err)
}
// Parse asset contexts
var ctxs []assetCtx
if err := json.Unmarshal(rawResp[1], &ctxs); err != nil {
return fmt.Errorf("failed to parse asset contexts: %w", err)
}
// Build coin list with volume
var coins []CoinInfo
for i, u := range meta.Universe {
var vol float64
if i < len(ctxs) {
fmt.Sscanf(ctxs[i].DayNtlVlm, "%f", &vol)
}
coins = append(coins, CoinInfo{
Symbol: u.Name,
Volume24h: vol,
})
}
// Sort by volume descending
sort.Slice(coins, func(i, j int) bool {
return coins[i].Volume24h > coins[j].Volume24h
})
p.mu.Lock()
defer p.mu.Unlock()
p.allCoins = coins
// Main coins are top 20 by volume
if len(coins) > 20 {
p.mainCoins = coins[:20]
} else {
p.mainCoins = coins
}
p.lastUpdated = time.Now()
logger.Infof("✅ Hyperliquid coin list updated: %d total coins, top 20 by volume cached", len(coins))
return nil
}
// ensureUpdated checks if cache is stale and refreshes if needed
func (p *CoinProvider) ensureUpdated(ctx context.Context) error {
p.mu.RLock()
needsUpdate := time.Since(p.lastUpdated) > cacheDuration || len(p.allCoins) == 0
p.mu.RUnlock()
if needsUpdate {
return p.fetchCoins(ctx)
}
return nil
}
// GetAllCoins returns all available Hyperliquid perp coins
func (p *CoinProvider) GetAllCoins(ctx context.Context) ([]CoinInfo, error) {
if err := p.ensureUpdated(ctx); err != nil {
return nil, err
}
p.mu.RLock()
defer p.mu.RUnlock()
// Return a copy to avoid mutation
result := make([]CoinInfo, len(p.allCoins))
copy(result, p.allCoins)
return result, nil
}
// GetMainCoins returns top N coins by 24h volume
func (p *CoinProvider) GetMainCoins(ctx context.Context, limit int) ([]CoinInfo, error) {
if err := p.ensureUpdated(ctx); err != nil {
return nil, err
}
p.mu.RLock()
defer p.mu.RUnlock()
if limit <= 0 {
limit = 20
}
// Return top N coins
count := limit
if count > len(p.allCoins) {
count = len(p.allCoins)
}
result := make([]CoinInfo, count)
copy(result, p.allCoins[:count])
return result, nil
}
// GetCoinSymbols returns just the symbol names (for compatibility)
func GetAllCoinSymbols(ctx context.Context) ([]string, error) {
coins, err := GetProvider().GetAllCoins(ctx)
if err != nil {
return nil, err
}
symbols := make([]string, len(coins))
for i, c := range coins {
symbols[i] = c.Symbol
}
return symbols, nil
}
// GetMainCoinSymbols returns top N coin symbols by volume
func GetMainCoinSymbols(ctx context.Context, limit int) ([]string, error) {
coins, err := GetProvider().GetMainCoins(ctx, limit)
if err != nil {
return nil, err
}
symbols := make([]string, len(coins))
for i, c := range coins {
symbols[i] = c.Symbol
}
return symbols, nil
}
// ForceRefresh forces a refresh of the coin cache
func (p *CoinProvider) ForceRefresh(ctx context.Context) error {
return p.fetchCoins(ctx)
}

View File

@@ -17,27 +17,28 @@ type ExchangeStore struct {
// Exchange exchange configuration
type Exchange struct {
ID string `gorm:"primaryKey" json:"id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"`
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
Enabled bool `gorm:"default:false" json:"enabled"`
ID string `gorm:"primaryKey" json:"id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"`
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
Enabled bool `gorm:"default:false" json:"enabled"`
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"`
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
Testnet bool `gorm:"default:false" json:"testnet"`
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
Testnet bool `gorm:"default:false" json:"testnet"`
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
HyperliquidUnifiedAcct bool `gorm:"column:hyperliquid_unified_account;default:true" json:"hyperliquidUnifiedAccount"` // Unified Account mode (Spot as collateral)
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"`
LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"`
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Exchange) TableName() string { return "exchanges" }
@@ -173,6 +174,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
return "Aster DEX", "dex"
case "lighter":
return "LIGHTER DEX", "dex"
case "indodax":
return "Indodax", "cex"
default:
return exchangeType + " Exchange", "cex"
}
@@ -181,7 +184,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
// Create creates a new exchange account with UUID
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey,
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
asterUser, asterSigner, asterPrivateKey,
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
id := uuid.New().String()
@@ -207,6 +211,7 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
Passphrase: crypto.EncryptedString(passphrase),
Testnet: testnet,
HyperliquidWalletAddr: hyperliquidWalletAddr,
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
AsterUser: asterUser,
AsterSigner: asterSigner,
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
@@ -224,19 +229,21 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
// Update updates exchange configuration by UUID
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled)
updates := map[string]interface{}{
"enabled": enabled,
"testnet": testnet,
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
"aster_user": asterUser,
"aster_signer": asterSigner,
"lighter_wallet_addr": lighterWalletAddr,
"lighter_api_key_index": lighterApiKeyIndex,
"updated_at": time.Now().UTC(),
"enabled": enabled,
"testnet": testnet,
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
"aster_user": asterUser,
"aster_signer": asterSigner,
"lighter_wallet_addr": lighterWalletAddr,
"lighter_api_key_index": lighterApiKeyIndex,
"updated_at": time.Now().UTC(),
}
// Only update encrypted fields if not empty
@@ -307,7 +314,8 @@ func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool,
// Check if this is an old-style ID (exchange type as ID)
if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" {
_, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
hyperliquidWalletAddr, true, // Default to Unified Account mode
asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
return err
}

View File

@@ -3,12 +3,63 @@ package store
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
// adaptivePriceRound rounds a price based on its magnitude to preserve meaningful precision.
// For small prices (like meme coins), it preserves more decimal places.
// It detects the number of decimal places needed from the reference price(s).
func adaptivePriceRound(price float64, referencePrices ...float64) float64 {
if price == 0 {
return 0
}
// Find the minimum magnitude among all prices (including the price itself)
minMagnitude := math.Abs(price)
for _, ref := range referencePrices {
if ref > 0 && ref < minMagnitude {
minMagnitude = ref
}
}
// Determine decimal places needed based on price magnitude
// For price 0.000000541, we need ~15 decimal places
// For price 0.0001, we need ~8 decimal places
// For price 1.0, we need ~4 decimal places
var multiplier float64
switch {
case minMagnitude < 0.000001: // Ultra small (meme coins like CHEEMS, SHIB)
multiplier = 1e15 // 15 decimal places
case minMagnitude < 0.0001: // Very small (PEPE, FLOKI)
multiplier = 1e12 // 12 decimal places
case minMagnitude < 0.01: // Small
multiplier = 1e10 // 10 decimal places
case minMagnitude < 1: // Medium
multiplier = 1e8 // 8 decimal places
default: // Large
multiplier = 1e6 // 6 decimal places
}
return math.Round(price*multiplier) / multiplier
}
// getPriceDecimalPlaces returns the number of decimal places in a price string
func getPriceDecimalPlaces(price float64) int {
if price == 0 {
return 0
}
s := strconv.FormatFloat(price, 'f', -1, 64)
idx := strings.Index(s, ".")
if idx == -1 {
return 0
}
return len(s) - idx - 1
}
// TraderStats trading statistics metrics
type TraderStats struct {
TotalTrades int `json:"total_trades"`
@@ -156,8 +207,8 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
newQty := math.Round((pos.Quantity+addQty)*10000) / 10000
newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
// Use 8 decimal places for price precision (crypto standard)
newEntryPrice = math.Round(newEntryPrice*100000000) / 100000000
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
newEntryPrice = adaptivePriceRound(newEntryPrice, pos.EntryPrice, addPrice)
newFee := pos.Fee + addFee
nowMs := time.Now().UTC().UnixMilli()
@@ -188,8 +239,8 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
var newExitPrice float64
if newClosedQty > 0 {
newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
// Use 8 decimal places for price precision (crypto standard)
newExitPrice = math.Round(newExitPrice*100000000) / 100000000
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
newExitPrice = adaptivePriceRound(newExitPrice, pos.ExitPrice, exitPrice, pos.EntryPrice)
}
nowMs := time.Now().UTC().UnixMilli()

View File

@@ -147,8 +147,8 @@ func (pb *PositionBuilder) handleClose(
var finalExitPrice float64
if totalClosed > 0 {
finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed
// Use 8 decimal places for price precision (crypto standard)
finalExitPrice = math.Round(finalExitPrice*100000000) / 100000000
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
finalExitPrice = adaptivePriceRound(finalExitPrice, position.ExitPrice, price, position.EntryPrice)
} else {
finalExitPrice = price
}

View File

@@ -119,6 +119,12 @@ type CoinSourceConfig struct {
UseOILow bool `json:"use_oi_low"`
// OI Low maximum count
OILowLimit int `json:"oi_low_limit,omitempty"`
// whether to use Hyperliquid All coins (all available perp pairs)
UseHyperAll bool `json:"use_hyper_all"`
// whether to use Hyperliquid Main coins (top N by 24h volume)
UseHyperMain bool `json:"use_hyper_main"`
// Hyperliquid Main maximum count (default 20)
HyperMainLimit int `json:"hyper_main_limit,omitempty"`
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
}

View File

@@ -1,8 +1,6 @@
package store
import (
"crypto/rand"
"encoding/base32"
"time"
"gorm.io/gorm"
@@ -18,24 +16,12 @@ type User struct {
ID string `gorm:"primaryKey" json:"id"`
Email string `gorm:"uniqueIndex:idx_users_email;not null" json:"email"`
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
OTPSecret string `gorm:"column:otp_secret" json:"-"`
OTPVerified bool `gorm:"column:otp_verified;default:false" json:"otp_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (User) TableName() string { return "users" }
// GenerateOTPSecret generates OTP secret
func GenerateOTPSecret() (string, error) {
secret := make([]byte, 20)
_, err := rand.Read(secret)
if err != nil {
return "", err
}
return base32.StdEncoding.EncodeToString(secret), nil
}
// NewUserStore creates a new UserStore
func NewUserStore(db *gorm.DB) *UserStore {
return &UserStore{db: db}
@@ -54,9 +40,6 @@ func (s *UserStore) initTables() error {
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT NOT NULL DEFAULT ''`)
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`)
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`)
// OTP columns (added later)
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS otp_secret TEXT DEFAULT ''`)
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS otp_verified BOOLEAN DEFAULT FALSE`)
// Ensure unique index exists on email (don't care about the name)
var indexExists int64
@@ -114,11 +97,6 @@ func (s *UserStore) GetAllIDs() ([]string, error) {
return userIDs, err
}
// UpdateOTPVerified updates OTP verification status
func (s *UserStore) UpdateOTPVerified(userID string, verified bool) error {
return s.db.Model(&User{}).Where("id = ?", userID).Update("otp_verified", verified).Error
}
// UpdatePassword updates password
func (s *UserStore) UpdatePassword(userID, passwordHash string) error {
return s.db.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
@@ -138,7 +116,5 @@ func (s *UserStore) EnsureAdmin() error {
ID: "admin",
Email: "admin@localhost",
PasswordHash: "",
OTPSecret: "",
OTPVerified: true,
})
}

View File

@@ -16,6 +16,7 @@ import (
"nofx/trader/bybit"
"nofx/trader/gate"
"nofx/trader/hyperliquid"
"nofx/trader/indodax"
"nofx/trader/kucoin"
"nofx/trader/lighter"
"nofx/trader/okx"
@@ -44,13 +45,13 @@ type AutoTraderConfig struct {
BybitSecretKey string
// OKX API configuration
OKXAPIKey string
OKXSecretKey string
OKXAPIKey string
OKXSecretKey string
OKXPassphrase string
// Bitget API configuration
BitgetAPIKey string
BitgetSecretKey string
BitgetAPIKey string
BitgetSecretKey string
BitgetPassphrase string
// Gate API configuration
@@ -58,14 +59,19 @@ type AutoTraderConfig struct {
GateSecretKey string
// KuCoin API configuration
KuCoinAPIKey string
KuCoinSecretKey string
KuCoinAPIKey string
KuCoinSecretKey string
KuCoinPassphrase string
// Indodax API configuration
IndodaxAPIKey string
IndodaxSecretKey string
// Hyperliquid configuration
HyperliquidPrivateKey string
HyperliquidWalletAddr string
HyperliquidTestnet bool
HyperliquidPrivateKey string
HyperliquidWalletAddr string
HyperliquidTestnet bool
HyperliquidUnifiedAcct bool // Unified Account mode: Spot USDC as Perp collateral
// Aster configuration
AsterUser string // Aster main wallet address
@@ -121,9 +127,9 @@ type AutoTrader struct {
config AutoTraderConfig
trader Trader // Use Trader interface (supports multiple platforms)
mcpClient mcp.AIClient
store *store.Store // Data storage (decision records, etc.)
store *store.Store // Data storage (decision records, etc.)
strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration)
cycleNumber int // Current cycle number
cycleNumber int // Current cycle number
initialBalance float64
dailyPnL float64
customPrompt string // Custom trading strategy prompt
@@ -195,6 +201,11 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using OpenAI", config.Name)
case "minimax":
mcpClient = mcp.NewMiniMaxClient()
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using MiniMax AI", config.Name)
case "qwen":
mcpClient = mcp.NewQwenClient()
apiKey := config.QwenKey
@@ -260,7 +271,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
trader = kucoin.NewKuCoinTrader(config.KuCoinAPIKey, config.KuCoinSecretKey, config.KuCoinPassphrase)
case "hyperliquid":
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name)
trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet)
trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet, config.HyperliquidUnifiedAcct)
if err != nil {
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
}
@@ -288,6 +299,9 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
return nil, fmt.Errorf("failed to initialize LIGHTER trader: %w", err)
}
logger.Infof("✓ LIGHTER trader initialized successfully")
case "indodax":
logger.Infof("🏦 [%s] Using Indodax Spot trading", config.Name)
trader = indodax.NewIndodaxTrader(config.IndodaxAPIKey, config.IndodaxSecretKey)
default:
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
}
@@ -1982,7 +1996,7 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
// This ensures accurate data from GetTrades API and avoids duplicate records
switch at.exchange {
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin":
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin", "gate":
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
return
}
@@ -2180,22 +2194,22 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb
normalizedSymbol := market.Normalize(symbol)
fill := &store.TraderFill{
TraderID: at.id,
ExchangeID: at.exchangeID,
ExchangeType: at.exchange,
OrderID: orderRecordID,
ExchangeOrderID: exchangeOrderID,
ExchangeTradeID: tradeID,
Symbol: normalizedSymbol,
Side: side,
Price: price,
Quantity: quantity,
QuoteQuantity: price * quantity,
Commission: fee,
CommissionAsset: "USDT",
RealizedPnL: 0, // Will be calculated for close orders
IsMaker: false, // Market orders are usually taker
CreatedAt: time.Now().UTC().UnixMilli(),
TraderID: at.id,
ExchangeID: at.exchangeID,
ExchangeType: at.exchange,
OrderID: orderRecordID,
ExchangeOrderID: exchangeOrderID,
ExchangeTradeID: tradeID,
Symbol: normalizedSymbol,
Side: side,
Price: price,
Quantity: quantity,
QuoteQuantity: price * quantity,
Commission: fee,
CommissionAsset: "USDT",
RealizedPnL: 0, // Will be calculated for close orders
IsMaker: false, // Market orders are usually taker
CreatedAt: time.Now().UTC().UnixMilli(),
}
// Calculate realized PnL for close orders
@@ -2323,4 +2337,3 @@ func getSideFromAction(action string) string {
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
return at.trader.GetOpenOrders(symbol)
}

View File

@@ -60,7 +60,11 @@ func (t *GateTrader) GetTrades(startTime time.Time, limit int) ([]GateTrade, err
continue
}
fillPrice, _ := strconv.ParseFloat(trade.Price, 64)
fillPrice, err := strconv.ParseFloat(trade.Price, 64)
if err != nil || fillPrice == 0 {
logger.Infof("⚠️ Gate trade %d: fillPrice parse issue - raw='%s' parsed=%.8f err=%v",
trade.Id, trade.Price, fillPrice, err)
}
// Get quanto_multiplier for this contract to convert size to base currency
quantoMultiplier := 1.0
@@ -176,12 +180,6 @@ func (t *GateTrader) SyncOrdersFromGate(traderID string, exchangeID string, exch
syncedCount := 0
for _, trade := range trades {
// Check if trade already exists (use exchangeID which is UUID, not exchange type)
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
if err == nil && existing != nil {
continue // Order already exists, skip
}
// Normalize symbol (Gate uses BTC_USDT, normalize to BTCUSDT)
symbol := market.Normalize(strings.ReplaceAll(trade.Symbol, "_", ""))
@@ -191,11 +189,30 @@ func (t *GateTrader) SyncOrdersFromGate(traderID string, exchangeID string, exch
positionSide = "SHORT"
}
execTimeMs := trade.ExecTime.UTC().UnixMilli()
// Check if trade already exists (use exchangeID which is UUID, not exchange type)
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
if err == nil && existing != nil {
// Order exists, but still try to update position for close trades
// This handles the case where order was created but position update failed
if strings.HasPrefix(trade.OrderAction, "close_") && trade.FillPrice > 0 {
if err := posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType,
symbol, positionSide, trade.OrderAction,
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
execTimeMs, trade.TradeID,
); err != nil {
logger.Infof(" ⚠️ Retry position update for existing trade %s failed: %v", trade.TradeID, err)
}
}
continue
}
// Normalize side for storage
side := strings.ToUpper(trade.Side)
// Create order record - use UTC time in milliseconds to avoid timezone issues
execTimeMs := trade.ExecTime.UTC().UnixMilli()
// Create order record
orderRecord := &store.TraderOrder{
TraderID: traderID,
ExchangeID: exchangeID, // UUID
@@ -248,15 +265,20 @@ func (t *GateTrader) SyncOrdersFromGate(traderID string, exchangeID string, exch
}
// Create/update position record using PositionBuilder
if err := posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType,
symbol, positionSide, trade.OrderAction,
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
execTimeMs, trade.TradeID,
); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
// Debug: Log the price being passed to ensure it's not 0
if trade.FillPrice <= 0 {
logger.Infof(" ⚠️ WARNING: trade %s has FillPrice=%.10f (invalid), skipping position update", trade.TradeID, trade.FillPrice)
} else {
logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f)", trade.TradeID, trade.OrderAction, trade.FillQty)
if err := posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType,
symbol, positionSide, trade.OrderAction,
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
execTimeMs, trade.TradeID,
); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else {
logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f, price: %.10f)", trade.TradeID, trade.OrderAction, trade.FillQty, trade.FillPrice)
}
}
syncedCount++

View File

@@ -21,12 +21,13 @@ import (
// HyperliquidTrader Hyperliquid trader
type HyperliquidTrader struct {
exchange *hyperliquid.Exchange
ctx context.Context
walletAddr string
meta *hyperliquid.Meta // Cache meta information (including precision)
metaMutex sync.RWMutex // Protect concurrent access to meta field
isCrossMargin bool // Whether to use cross margin mode
exchange *hyperliquid.Exchange
ctx context.Context
walletAddr string
meta *hyperliquid.Meta // Cache meta information (including precision)
metaMutex sync.RWMutex // Protect concurrent access to meta field
isCrossMargin bool // Whether to use cross margin mode
isUnifiedAccount bool // Whether to use Unified Account mode (Spot as collateral for Perps)
// xyz dex support (stocks, forex, commodities)
xyzMeta *xyzDexMeta
xyzMetaMutex sync.RWMutex
@@ -80,7 +81,8 @@ func isXyzDexAsset(symbol string) bool {
}
// NewHyperliquidTrader creates a Hyperliquid trader
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) (*HyperliquidTrader, error) {
// unifiedAccount: when true, Spot USDC balance is used as collateral for Perp trading
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool, unifiedAccount bool) (*HyperliquidTrader, error) {
// Remove 0x prefix from private key (if present, case-insensitive)
privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x")
@@ -175,14 +177,19 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool)
}
}
if unifiedAccount {
logger.Infof("✓ Unified Account mode enabled: Spot USDC will be used as collateral for Perp trading")
}
return &HyperliquidTrader{
exchange: exchange,
ctx: ctx,
walletAddr: walletAddr,
meta: meta,
isCrossMargin: true, // Use cross margin mode by default
privateKey: privateKey,
isTestnet: testnet,
exchange: exchange,
ctx: ctx,
walletAddr: walletAddr,
meta: meta,
isCrossMargin: true, // Use cross margin mode by default
isUnifiedAccount: unifiedAccount, // Unified Account: Spot as Perp collateral
privateKey: privateKey,
isTestnet: testnet,
}, nil
}
@@ -304,9 +311,18 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
// Note: totalWalletBalance + totalUnrealizedPnlAll should equal this
totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue
// ✅ Step 7: Unified Account mode - Spot USDC is used as collateral for Perps
// In this mode, available balance includes Spot USDC since it can be used for Perp margin
if t.isUnifiedAccount && spotUSDCBalance > 0 {
// Add Spot balance to available balance for trading
availableBalance = availableBalance + spotUSDCBalance
logger.Infof("✓ Unified Account: Spot %.2f USDC added to available balance (total: %.2f)",
spotUSDCBalance, availableBalance)
}
result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized
result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV
result["availableBalance"] = availableBalance // Available balance (Perpetuals only)
result["availableBalance"] = availableBalance // Available balance (Perp + Spot if unified)
result["totalUnrealizedProfit"] = totalUnrealizedPnlAll // Unrealized PnL (Perpetuals + xyz)
result["spotBalance"] = spotUSDCBalance // Spot balance
result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities)

878
trader/indodax/trader.go Normal file
View File

@@ -0,0 +1,878 @@
package indodax
import (
"crypto/hmac"
"crypto/sha512"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"sync"
"time"
)
// Indodax API endpoints
const (
indodaxBaseURL = "https://indodax.com"
indodaxPublicAPI = "/api"
indodaxPrivateAPI = "/tapi"
)
// IndodaxTrader implements types.Trader interface for Indodax Spot Exchange
// Indodax is Indonesia's largest crypto exchange, supporting IDR (Indonesian Rupiah) pairs.
// Since Indodax is spot-only, futures-specific methods (OpenShort, CloseShort, leverage, etc.)
// are gracefully stubbed.
type IndodaxTrader struct {
apiKey string
secretKey string
httpClient *http.Client
nonce int64
nonceMutex sync.Mutex
// Cache for pair info
pairCache map[string]*IndodaxPair
pairCacheMutex sync.RWMutex
pairCacheTime time.Time
// Cache for balance
cachedBalance map[string]interface{}
cachedPositions []map[string]interface{}
balanceCacheTime time.Time
positionCacheTime time.Time
cacheDuration time.Duration
cacheMutex sync.RWMutex
}
// IndodaxPair represents a trading pair on Indodax
type IndodaxPair struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
BaseCurrency string `json:"base_currency"`
TradedCurrency string `json:"traded_currency"`
TradedCurrencyUnit string `json:"traded_currency_unit"`
Description string `json:"description"`
TickerID string `json:"ticker_id"`
VolumePrecision int `json:"volume_precision"`
PricePrecision float64 `json:"price_precision"`
PriceRound int `json:"price_round"`
Pricescale float64 `json:"pricescale"`
TradeMinBaseCurrency float64 `json:"trade_min_base_currency"`
TradeMinTradedCurrency float64 `json:"trade_min_traded_currency"`
}
// IndodaxResponse represents the standard Indodax private API response
type IndodaxResponse struct {
Success int `json:"success"`
Return json.RawMessage `json:"return,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
// IndodaxTicker represents ticker data
type IndodaxTicker struct {
High string `json:"high"`
Low string `json:"low"`
Last string `json:"last"`
Buy string `json:"buy"`
Sell string `json:"sell"`
ServerTime int64 `json:"server_time"`
}
// IndodaxTickerResponse wraps ticker response
type IndodaxTickerResponse struct {
Ticker IndodaxTicker `json:"ticker"`
}
// NewIndodaxTrader creates a new Indodax trader instance
func NewIndodaxTrader(apiKey, secretKey string) *IndodaxTrader {
return &IndodaxTrader{
apiKey: apiKey,
secretKey: secretKey,
httpClient: &http.Client{Timeout: 30 * time.Second},
nonce: time.Now().UnixMilli(),
pairCache: make(map[string]*IndodaxPair),
cacheDuration: 15 * time.Second,
}
}
// getNonce returns a unique incrementing nonce for each request
func (t *IndodaxTrader) getNonce() int64 {
t.nonceMutex.Lock()
defer t.nonceMutex.Unlock()
t.nonce++
return t.nonce
}
// sign generates HMAC-SHA512 signature for request body
func (t *IndodaxTrader) sign(body string) string {
mac := hmac.New(sha512.New, []byte(t.secretKey))
mac.Write([]byte(body))
return hex.EncodeToString(mac.Sum(nil))
}
// doPublicRequest makes a public API GET request
func (t *IndodaxTrader) doPublicRequest(path string) ([]byte, error) {
reqURL := indodaxBaseURL + indodaxPublicAPI + path
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
}
return data, nil
}
// doPrivateRequest makes a signed private API POST request
func (t *IndodaxTrader) doPrivateRequest(params url.Values) ([]byte, error) {
reqURL := indodaxBaseURL + indodaxPrivateAPI
// Add nonce
params.Set("nonce", strconv.FormatInt(t.getNonce(), 10))
body := params.Encode()
signature := t.sign(body)
req, err := http.NewRequest("POST", reqURL, strings.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Key", t.apiKey)
req.Header.Set("Sign", signature)
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode == http.StatusTooManyRequests {
return nil, fmt.Errorf("rate limit exceeded, please try again later")
}
// Parse response to check success
var apiResp IndodaxResponse
if err := json.Unmarshal(data, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(data))
}
if apiResp.Success != 1 {
return nil, fmt.Errorf("API error: %s (code: %s)", apiResp.Error, apiResp.ErrorCode)
}
return apiResp.Return, nil
}
// convertSymbol converts standard symbol to Indodax format
// e.g. BTCIDR -> btc_idr, ETHIDR -> eth_idr
func (t *IndodaxTrader) convertSymbol(symbol string) string {
s := strings.ToLower(symbol)
// Already in Indodax format (contains underscore)
if strings.Contains(s, "_") {
return s
}
// Try to split by known base currencies
for _, base := range []string{"idr", "btc", "usdt"} {
if strings.HasSuffix(s, base) {
traded := strings.TrimSuffix(s, base)
if traded != "" {
return traded + "_" + base
}
}
}
return s
}
// convertSymbolBack converts Indodax format back to standard
// e.g. btc_idr -> BTCIDR
func (t *IndodaxTrader) convertSymbolBack(indodaxSymbol string) string {
return strings.ToUpper(strings.ReplaceAll(indodaxSymbol, "_", ""))
}
// getCoinFromSymbol extracts the traded currency from a symbol
// e.g. btc_idr -> btc, eth_idr -> eth
func (t *IndodaxTrader) getCoinFromSymbol(symbol string) string {
pair := t.convertSymbol(symbol)
parts := strings.Split(pair, "_")
if len(parts) >= 1 {
return parts[0]
}
return strings.ToLower(symbol)
}
// loadPairs loads trading pair information from the public API
func (t *IndodaxTrader) loadPairs() error {
t.pairCacheMutex.RLock()
if len(t.pairCache) > 0 && time.Since(t.pairCacheTime) < 5*time.Minute {
t.pairCacheMutex.RUnlock()
return nil
}
t.pairCacheMutex.RUnlock()
data, err := t.doPublicRequest("/pairs")
if err != nil {
return fmt.Errorf("failed to load pairs: %w", err)
}
var pairs []IndodaxPair
if err := json.Unmarshal(data, &pairs); err != nil {
return fmt.Errorf("failed to parse pairs: %w", err)
}
t.pairCacheMutex.Lock()
defer t.pairCacheMutex.Unlock()
t.pairCache = make(map[string]*IndodaxPair)
for i := range pairs {
p := pairs[i]
t.pairCache[p.TickerID] = &p
// Also index by ID (e.g. "btcidr")
t.pairCache[p.ID] = &p
}
t.pairCacheTime = time.Now()
logger.Infof("[Indodax] Loaded %d trading pairs", len(pairs))
return nil
}
// getPair gets pair info for a symbol
func (t *IndodaxTrader) getPair(symbol string) (*IndodaxPair, error) {
if err := t.loadPairs(); err != nil {
return nil, err
}
pairID := t.convertSymbol(symbol)
t.pairCacheMutex.RLock()
defer t.pairCacheMutex.RUnlock()
if pair, ok := t.pairCache[pairID]; ok {
return pair, nil
}
// Try without underscore
noUnderscore := strings.ReplaceAll(pairID, "_", "")
if pair, ok := t.pairCache[noUnderscore]; ok {
return pair, nil
}
return nil, fmt.Errorf("pair not found: %s", symbol)
}
// clearCache clears cached data
func (t *IndodaxTrader) clearCache() {
t.cacheMutex.Lock()
defer t.cacheMutex.Unlock()
t.cachedBalance = nil
t.cachedPositions = nil
}
// ============================================================
// types.Trader interface implementation
// ============================================================
// GetBalance gets account balance from Indodax
func (t *IndodaxTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.cacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
cached := t.cachedBalance
t.cacheMutex.RUnlock()
return cached, nil
}
t.cacheMutex.RUnlock()
params := url.Values{}
params.Set("method", "getInfo")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get account info: %w", err)
}
var result struct {
ServerTime int64 `json:"server_time"`
Balance map[string]interface{} `json:"balance"`
BalanceHold map[string]interface{} `json:"balance_hold"`
UserID string `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse balance: %w", err)
}
// Calculate total balance in IDR
idrBalance := parseFloat(result.Balance["idr"])
idrHold := parseFloat(result.BalanceHold["idr"])
totalIDR := idrBalance + idrHold
balance := map[string]interface{}{
"totalWalletBalance": totalIDR,
"availableBalance": idrBalance,
"totalUnrealizedProfit": 0.0,
"totalEquity": totalIDR,
"balance": totalIDR,
"idr_balance": idrBalance,
"idr_hold": idrHold,
"currency": "IDR",
"user_id": result.UserID,
"server_time": result.ServerTime,
}
// Add individual crypto balances
for currency, amount := range result.Balance {
if currency != "idr" {
balance["balance_"+currency] = parseFloat(amount)
}
}
for currency, amount := range result.BalanceHold {
if currency != "idr" {
balance["hold_"+currency] = parseFloat(amount)
}
}
// Update cache
t.cacheMutex.Lock()
t.cachedBalance = balance
t.balanceCacheTime = time.Now()
t.cacheMutex.Unlock()
return balance, nil
}
// GetPositions returns currently held crypto balances as "positions"
// Since Indodax is spot-only, each non-zero crypto balance is treated as a position
func (t *IndodaxTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.cacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionCacheTime) < t.cacheDuration {
cached := t.cachedPositions
t.cacheMutex.RUnlock()
return cached, nil
}
t.cacheMutex.RUnlock()
params := url.Values{}
params.Set("method", "getInfo")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result struct {
Balance map[string]interface{} `json:"balance"`
BalanceHold map[string]interface{} `json:"balance_hold"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse positions: %w", err)
}
var positions []map[string]interface{}
for currency, amountRaw := range result.Balance {
if currency == "idr" {
continue
}
amount := parseFloat(amountRaw)
holdAmount := parseFloat(result.BalanceHold[currency])
totalAmount := amount + holdAmount
if totalAmount <= 0 {
continue
}
// Get market price for this coin
markPrice, _ := t.GetMarketPrice(strings.ToUpper(currency) + "IDR")
// Calculate position value in IDR
notionalValue := totalAmount * markPrice
position := map[string]interface{}{
"symbol": strings.ToUpper(currency) + "IDR",
"side": "LONG",
"positionAmt": totalAmount,
"entryPrice": markPrice, // Spot doesn't track entry price
"markPrice": markPrice,
"unRealizedProfit": 0.0, // Spot doesn't track unrealized PnL
"leverage": 1.0,
"mgnMode": "spot",
"notionalValue": notionalValue,
"currency": currency,
"available": amount,
"hold": holdAmount,
}
positions = append(positions, position)
}
// Update cache
t.cacheMutex.Lock()
t.cachedPositions = positions
t.positionCacheTime = time.Now()
t.cacheMutex.Unlock()
return positions, nil
}
// OpenLong opens a spot buy order
func (t *IndodaxTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
t.clearCache()
pair := t.convertSymbol(symbol)
coin := t.getCoinFromSymbol(symbol)
// Get market price to calculate IDR amount
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get market price: %w", err)
}
params := url.Values{}
params.Set("method", "trade")
params.Set("pair", pair)
params.Set("type", "buy")
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
params.Set("order_type", "limit")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to place buy order: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse trade response: %w", err)
}
logger.Infof("[Indodax] Buy order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
return map[string]interface{}{
"orderId": result["order_id"],
"symbol": symbol,
"side": "BUY",
"price": price,
"qty": quantity,
"status": "NEW",
}, nil
}
// OpenShort is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
}
// CloseLong closes a spot position by selling
func (t *IndodaxTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
t.clearCache()
pair := t.convertSymbol(symbol)
coin := t.getCoinFromSymbol(symbol)
// If quantity is 0, sell all available balance
if quantity <= 0 {
balance, err := t.GetBalance()
if err != nil {
return nil, fmt.Errorf("failed to get balance for close all: %w", err)
}
available := parseFloat(balance["balance_"+coin])
if available <= 0 {
return nil, fmt.Errorf("no %s balance to sell", coin)
}
quantity = available
}
// Get market price
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get market price: %w", err)
}
params := url.Values{}
params.Set("method", "trade")
params.Set("pair", pair)
params.Set("type", "sell")
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
params.Set("order_type", "limit")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to place sell order: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse trade response: %w", err)
}
logger.Infof("[Indodax] Sell order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
return map[string]interface{}{
"orderId": result["order_id"],
"symbol": symbol,
"side": "SELL",
"price": price,
"qty": quantity,
"status": "NEW",
}, nil
}
// CloseShort is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
}
// SetLeverage is a no-op for Indodax (spot-only, no leverage)
func (t *IndodaxTrader) SetLeverage(symbol string, leverage int) error {
logger.Infof("[Indodax] SetLeverage ignored (spot-only exchange, no leverage support)")
return nil
}
// SetMarginMode is a no-op for Indodax (spot-only, no margin)
func (t *IndodaxTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
logger.Infof("[Indodax] SetMarginMode ignored (spot-only exchange, no margin support)")
return nil
}
// GetMarketPrice gets the current market price for a symbol
func (t *IndodaxTrader) GetMarketPrice(symbol string) (float64, error) {
pairID := strings.ToLower(strings.ReplaceAll(t.convertSymbol(symbol), "_", ""))
data, err := t.doPublicRequest("/ticker/" + pairID)
if err != nil {
return 0, fmt.Errorf("failed to get ticker: %w", err)
}
var tickerResp IndodaxTickerResponse
if err := json.Unmarshal(data, &tickerResp); err != nil {
return 0, fmt.Errorf("failed to parse ticker: %w", err)
}
price, err := strconv.ParseFloat(tickerResp.Ticker.Last, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse price '%s': %w", tickerResp.Ticker.Last, err)
}
return price, nil
}
// SetStopLoss is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
return fmt.Errorf("stop-loss orders are not supported on Indodax (spot-only exchange)")
}
// SetTakeProfit is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
return fmt.Errorf("take-profit orders are not supported on Indodax (spot-only exchange)")
}
// CancelStopLossOrders is a no-op for Indodax
func (t *IndodaxTrader) CancelStopLossOrders(symbol string) error {
return nil
}
// CancelTakeProfitOrders is a no-op for Indodax
func (t *IndodaxTrader) CancelTakeProfitOrders(symbol string) error {
return nil
}
// CancelAllOrders cancels all open orders for a given symbol
func (t *IndodaxTrader) CancelAllOrders(symbol string) error {
t.clearCache()
pair := t.convertSymbol(symbol)
// First get open orders
params := url.Values{}
params.Set("method", "openOrders")
params.Set("pair", pair)
data, err := t.doPrivateRequest(params)
if err != nil {
return fmt.Errorf("failed to get open orders: %w", err)
}
var result struct {
Orders []struct {
OrderID json.Number `json:"order_id"`
Type string `json:"type"`
OrderType string `json:"order_type"`
} `json:"orders"`
}
if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("failed to parse open orders: %w", err)
}
// Cancel each order
for _, order := range result.Orders {
cancelParams := url.Values{}
cancelParams.Set("method", "cancelOrder")
cancelParams.Set("pair", pair)
cancelParams.Set("order_id", order.OrderID.String())
cancelParams.Set("type", order.Type)
if _, err := t.doPrivateRequest(cancelParams); err != nil {
logger.Warnf("[Indodax] Failed to cancel order %s: %v", order.OrderID, err)
} else {
logger.Infof("[Indodax] Cancelled order: %s", order.OrderID)
}
}
return nil
}
// CancelStopOrders is a no-op for Indodax (no stop orders)
func (t *IndodaxTrader) CancelStopOrders(symbol string) error {
return nil
}
// FormatQuantity formats quantity to correct precision for Indodax
func (t *IndodaxTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
pair, err := t.getPair(symbol)
if err != nil {
// Default: 8 decimal places
return strconv.FormatFloat(quantity, 'f', 8, 64), nil
}
precision := pair.PriceRound
if precision <= 0 {
precision = 8
}
// Round down to avoid exceeding balance
factor := math.Pow(10, float64(precision))
rounded := math.Floor(quantity*factor) / factor
return strconv.FormatFloat(rounded, 'f', precision, 64), nil
}
// GetOrderStatus gets the status of a specific order
func (t *IndodaxTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
pair := t.convertSymbol(symbol)
params := url.Values{}
params.Set("method", "getOrder")
params.Set("pair", pair)
params.Set("order_id", orderID)
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
var result struct {
Order struct {
OrderID string `json:"order_id"`
Price string `json:"price"`
Type string `json:"type"`
Status string `json:"status"`
SubmitTime string `json:"submit_time"`
FinishTime string `json:"finish_time"`
ClientOrderID string `json:"client_order_id"`
} `json:"order"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order: %w", err)
}
// Map Indodax status to standard status
status := "NEW"
switch result.Order.Status {
case "filled":
status = "FILLED"
case "cancelled":
status = "CANCELED"
case "open":
status = "NEW"
}
price, _ := strconv.ParseFloat(result.Order.Price, 64)
return map[string]interface{}{
"status": status,
"avgPrice": price,
"executedQty": 0.0, // Indodax doesn't return executed qty in getOrder
"commission": 0.0,
"orderId": result.Order.OrderID,
}, nil
}
// GetClosedPnL gets closed position PnL records (trade history)
func (t *IndodaxTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
// Indodax trade history is limited to 7 days range
params := url.Values{}
params.Set("method", "tradeHistory")
params.Set("pair", "btc_idr") // Default pair; Indodax requires a pair
if limit > 0 {
params.Set("count", strconv.Itoa(limit))
}
if !startTime.IsZero() {
params.Set("since", strconv.FormatInt(startTime.Unix(), 10))
}
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get trade history: %w", err)
}
var result struct {
Trades []struct {
TradeID string `json:"trade_id"`
OrderID string `json:"order_id"`
Type string `json:"type"`
Price string `json:"price"`
Fee string `json:"fee"`
TradeTime string `json:"trade_time"`
ClientOrderID string `json:"client_order_id"`
} `json:"trades"`
}
if err := json.Unmarshal(data, &result); err != nil {
// Trade history might return empty, that's fine
return nil, nil
}
var records []types.ClosedPnLRecord
for _, trade := range result.Trades {
price, _ := strconv.ParseFloat(trade.Price, 64)
fee, _ := strconv.ParseFloat(trade.Fee, 64)
tradeTime, _ := strconv.ParseInt(trade.TradeTime, 10, 64)
side := "long"
if trade.Type == "sell" {
side = "long" // Selling from a spot position is closing long
}
records = append(records, types.ClosedPnLRecord{
Symbol: "BTCIDR",
Side: side,
ExitPrice: price,
Fee: fee,
ExitTime: time.Unix(tradeTime, 0),
OrderID: trade.OrderID,
CloseType: "manual",
})
}
return records, nil
}
// GetOpenOrders gets open/pending orders
func (t *IndodaxTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
pair := t.convertSymbol(symbol)
params := url.Values{}
params.Set("method", "openOrders")
if pair != "" {
params.Set("pair", pair)
}
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var result struct {
Orders []struct {
OrderID json.Number `json:"order_id"`
ClientOrderID string `json:"client_order_id"`
SubmitTime string `json:"submit_time"`
Price string `json:"price"`
Type string `json:"type"`
OrderType string `json:"order_type"`
} `json:"orders"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse open orders: %w", err)
}
var orders []types.OpenOrder
for _, order := range result.Orders {
price, _ := strconv.ParseFloat(order.Price, 64)
side := "BUY"
if order.Type == "sell" {
side = "SELL"
}
orders = append(orders, types.OpenOrder{
OrderID: order.OrderID.String(),
Symbol: t.convertSymbolBack(pair),
Side: side,
PositionSide: "LONG",
Type: "LIMIT",
Price: price,
Status: "NEW",
})
}
return orders, nil
}
// ============================================================
// Helper functions
// ============================================================
// parseFloat safely parses a float from interface{}
func parseFloat(v interface{}) float64 {
if v == nil {
return 0
}
switch val := v.(type) {
case float64:
return val
case string:
f, _ := strconv.ParseFloat(val, 64)
return f
case json.Number:
f, _ := val.Float64()
return f
case int:
return float64(val)
case int64:
return float64(val)
default:
return 0
}
}

View File

@@ -0,0 +1,374 @@
package indodax
import (
"os"
"testing"
"time"
"nofx/trader/types"
)
// Test credentials - set via environment variables
func getIndodaxTestCredentials(t *testing.T) (string, string) {
apiKey := os.Getenv("INDODAX_TEST_API_KEY")
secretKey := os.Getenv("INDODAX_TEST_SECRET_KEY")
if apiKey == "" || secretKey == "" {
t.Skip("Indodax test credentials not set (INDODAX_TEST_API_KEY, INDODAX_TEST_SECRET_KEY)")
}
return apiKey, secretKey
}
func createIndodaxTestTrader(t *testing.T) *IndodaxTrader {
apiKey, secretKey := getIndodaxTestCredentials(t)
trader := NewIndodaxTrader(apiKey, secretKey)
return trader
}
// TestIndodaxTrader_InterfaceCompliance tests that IndodaxTrader implements types.Trader
func TestIndodaxTrader_InterfaceCompliance(t *testing.T) {
var _ types.Trader = (*IndodaxTrader)(nil)
}
// TestNewIndodaxTrader tests creating Indodax trader instance
func TestNewIndodaxTrader(t *testing.T) {
trader := NewIndodaxTrader("test_api_key", "test_secret_key")
if trader == nil {
t.Fatal("Expected non-nil trader")
}
if trader.apiKey != "test_api_key" {
t.Errorf("Expected apiKey 'test_api_key', got '%s'", trader.apiKey)
}
if trader.secretKey != "test_secret_key" {
t.Errorf("Expected secretKey 'test_secret_key', got '%s'", trader.secretKey)
}
if trader.httpClient == nil {
t.Error("Expected non-nil httpClient")
}
if trader.cacheDuration != 15*time.Second {
t.Errorf("Expected cacheDuration 15s, got %v", trader.cacheDuration)
}
}
// TestIndodaxTrader_SymbolConversion tests symbol format conversion
func TestIndodaxTrader_SymbolConversion(t *testing.T) {
trader := NewIndodaxTrader("test", "test")
tests := []struct {
name string
input string
expected string
}{
{"BTCIDR to btc_idr", "BTCIDR", "btc_idr"},
{"ETHIDR to eth_idr", "ETHIDR", "eth_idr"},
{"SOLIDR to sol_idr", "SOLIDR", "sol_idr"},
{"Already converted", "btc_idr", "btc_idr"},
{"BTC pair", "ETHBTC", "eth_btc"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := trader.convertSymbol(tt.input)
if result != tt.expected {
t.Errorf("convertSymbol(%s) = %s, want %s", tt.input, result, tt.expected)
}
})
}
}
// TestIndodaxTrader_SymbolConversionBack tests symbol reversion
func TestIndodaxTrader_SymbolConversionBack(t *testing.T) {
trader := NewIndodaxTrader("test", "test")
tests := []struct {
name string
input string
expected string
}{
{"btc_idr to BTCIDR", "btc_idr", "BTCIDR"},
{"eth_idr to ETHIDR", "eth_idr", "ETHIDR"},
{"Already standard", "BTCIDR", "BTCIDR"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := trader.convertSymbolBack(tt.input)
if result != tt.expected {
t.Errorf("convertSymbolBack(%s) = %s, want %s", tt.input, result, tt.expected)
}
})
}
}
// TestIndodaxTrader_GetCoinFromSymbol tests coin extraction
func TestIndodaxTrader_GetCoinFromSymbol(t *testing.T) {
trader := NewIndodaxTrader("test", "test")
tests := []struct {
input string
expected string
}{
{"BTCIDR", "btc"},
{"ETHIDR", "eth"},
{"btc_idr", "btc"},
{"eth_idr", "eth"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := trader.getCoinFromSymbol(tt.input)
if result != tt.expected {
t.Errorf("getCoinFromSymbol(%s) = %s, want %s", tt.input, result, tt.expected)
}
})
}
}
// TestIndodaxTrader_Sign tests HMAC-SHA512 signature generation
func TestIndodaxTrader_Sign(t *testing.T) {
trader := NewIndodaxTrader("api_key", "secret_key")
body := "method=getInfo&nonce=1000"
signature := trader.sign(body)
if signature == "" {
t.Error("Expected non-empty signature")
}
if len(signature) != 128 { // SHA-512 hex = 128 chars
t.Errorf("Expected signature length 128, got %d", len(signature))
}
// Same input should produce same signature
signature2 := trader.sign(body)
if signature != signature2 {
t.Error("Signature should be deterministic")
}
// Different input should produce different signature
signature3 := trader.sign("method=getInfo&nonce=1001")
if signature == signature3 {
t.Error("Different input should produce different signature")
}
}
// TestIndodaxTrader_Nonce tests nonce incrementation
func TestIndodaxTrader_Nonce(t *testing.T) {
trader := NewIndodaxTrader("test", "test")
nonce1 := trader.getNonce()
nonce2 := trader.getNonce()
nonce3 := trader.getNonce()
if nonce2 <= nonce1 {
t.Errorf("Nonce should be increasing: %d <= %d", nonce2, nonce1)
}
if nonce3 <= nonce2 {
t.Errorf("Nonce should be increasing: %d <= %d", nonce3, nonce2)
}
}
// TestIndodaxTrader_SpotOnlyRestrictions tests that futures-only methods return errors
func TestIndodaxTrader_SpotOnlyRestrictions(t *testing.T) {
trader := NewIndodaxTrader("test", "test")
// OpenShort should fail
_, err := trader.OpenShort("BTCIDR", 0.001, 1)
if err == nil {
t.Error("OpenShort should return error on spot exchange")
}
// CloseShort should fail
_, err = trader.CloseShort("BTCIDR", 0.001)
if err == nil {
t.Error("CloseShort should return error on spot exchange")
}
// SetStopLoss should fail
err = trader.SetStopLoss("BTCIDR", "LONG", 0.001, 500000000)
if err == nil {
t.Error("SetStopLoss should return error on spot exchange")
}
// SetTakeProfit should fail
err = trader.SetTakeProfit("BTCIDR", "LONG", 0.001, 600000000)
if err == nil {
t.Error("SetTakeProfit should return error on spot exchange")
}
// SetLeverage should NOT fail (no-op)
err = trader.SetLeverage("BTCIDR", 10)
if err != nil {
t.Errorf("SetLeverage should not fail (no-op): %v", err)
}
// SetMarginMode should NOT fail (no-op)
err = trader.SetMarginMode("BTCIDR", true)
if err != nil {
t.Errorf("SetMarginMode should not fail (no-op): %v", err)
}
}
// TestIndodaxTrader_ParseFloat tests parseFloat helper
func TestIndodaxTrader_ParseFloat(t *testing.T) {
tests := []struct {
name string
input interface{}
expected float64
}{
{"float64", 123.45, 123.45},
{"string", "123.45", 123.45},
{"int", 123, 123.0},
{"int64", int64(123), 123.0},
{"nil", nil, 0.0},
{"zero string", "0", 0.0},
{"empty string", "", 0.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseFloat(tt.input)
if result != tt.expected {
t.Errorf("parseFloat(%v) = %f, want %f", tt.input, result, tt.expected)
}
})
}
}
// TestIndodaxTrader_ClearCache tests cache clearing
func TestIndodaxTrader_ClearCache(t *testing.T) {
trader := NewIndodaxTrader("test", "test")
// Set some cached data
trader.cachedBalance = map[string]interface{}{"test": "data"}
trader.cachedPositions = []map[string]interface{}{{"test": "data"}}
// Clear cache
trader.clearCache()
if trader.cachedBalance != nil {
t.Error("Cache should be cleared")
}
if trader.cachedPositions != nil {
t.Error("Position cache should be cleared")
}
}
// ============================================================
// Integration tests (require INDODAX_TEST_API_KEY env vars)
// ============================================================
// TestIndodaxConnection tests basic API connectivity
func TestIndodaxConnection(t *testing.T) {
trader := createIndodaxTestTrader(t)
balance, err := trader.GetBalance()
if err != nil {
t.Fatalf("Failed to get balance: %v", err)
}
t.Logf("✅ Connection OK")
t.Logf(" totalWalletBalance: %v", balance["totalWalletBalance"])
t.Logf(" availableBalance: %v", balance["availableBalance"])
t.Logf(" totalEquity: %v", balance["totalEquity"])
t.Logf(" currency: %v", balance["currency"])
t.Logf(" user_id: %v", balance["user_id"])
}
// TestIndodaxGetPositions tests position retrieval
func TestIndodaxGetPositions(t *testing.T) {
trader := createIndodaxTestTrader(t)
positions, err := trader.GetPositions()
if err != nil {
t.Fatalf("Failed to get positions: %v", err)
}
t.Logf("📊 Found %d positions (crypto balances):", len(positions))
for i, pos := range positions {
t.Logf(" [%d] %s: qty=%.8f markPrice=%.0f value=%.0f IDR",
i+1,
pos["symbol"],
pos["positionAmt"],
pos["markPrice"],
pos["notionalValue"],
)
}
}
// TestIndodaxGetMarketPrice tests market price retrieval
func TestIndodaxGetMarketPrice(t *testing.T) {
trader := createIndodaxTestTrader(t)
pairs := []string{"BTCIDR", "ETHIDR"}
for _, pair := range pairs {
price, err := trader.GetMarketPrice(pair)
if err != nil {
t.Errorf("Failed to get price for %s: %v", pair, err)
continue
}
t.Logf(" %s: %.0f IDR", pair, price)
}
}
// TestIndodaxGetOpenOrders tests open orders retrieval
func TestIndodaxGetOpenOrders(t *testing.T) {
trader := createIndodaxTestTrader(t)
orders, err := trader.GetOpenOrders("BTCIDR")
if err != nil {
t.Fatalf("Failed to get open orders: %v", err)
}
t.Logf("📋 Found %d open orders:", len(orders))
for i, order := range orders {
t.Logf(" [%d] %s %s: price=%.0f orderID=%s",
i+1, order.Symbol, order.Side, order.Price, order.OrderID)
}
}
// TestIndodaxGetClosedPnL tests trade history retrieval
func TestIndodaxGetClosedPnL(t *testing.T) {
trader := createIndodaxTestTrader(t)
startTime := time.Now().Add(-7 * 24 * time.Hour)
records, err := trader.GetClosedPnL(startTime, 10)
if err != nil {
t.Fatalf("Failed to get closed PnL: %v", err)
}
t.Logf("📋 Found %d trade records:", len(records))
for i, record := range records {
t.Logf(" [%d] %s %s: price=%.0f fee=%.4f time=%s",
i+1, record.Symbol, record.Side, record.ExitPrice, record.Fee,
record.ExitTime.Format("2006-01-02 15:04:05"))
}
}
// TestIndodaxLoadPairs tests loading trading pairs
func TestIndodaxLoadPairs(t *testing.T) {
trader := createIndodaxTestTrader(t)
err := trader.loadPairs()
if err != nil {
t.Fatalf("Failed to load pairs: %v", err)
}
trader.pairCacheMutex.RLock()
defer trader.pairCacheMutex.RUnlock()
t.Logf("📊 Loaded %d pairs", len(trader.pairCache))
// Check some known pairs
knownPairs := []string{"btc_idr", "eth_idr"}
for _, pairID := range knownPairs {
if pair, ok := trader.pairCache[pairID]; ok {
t.Logf(" %s: min_base=%v, min_traded=%v, precision=%d",
pair.Description, pair.TradeMinBaseCurrency, pair.TradeMinTradedCurrency, pair.PriceRound)
} else {
t.Errorf("Expected pair %s not found", pairID)
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 B

View File

@@ -0,0 +1,3 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.8095 0.263396C16.8864 1.28889 10.9524 4.62174 6.15385 10.2253C3.99267 12.7524 1.50183 17.8066 0.659341 21.3592C0.03663 24.1061 0 29.3434 0 150.022C0 270.7 0.03663 275.938 0.659341 278.684C1.50183 282.274 3.99267 287.291 6.26374 289.928C10.1832 294.58 15.7143 298.022 21.3187 299.341C24.0659 299.963 29.304 300 150 300C270.696 300 275.934 299.963 278.681 299.341C284.286 298.022 289.817 294.58 293.736 289.928C296.007 287.291 298.498 282.274 299.341 278.684C299.963 275.938 300 270.7 300 150.022C300 29.3434 299.963 24.1061 299.341 21.3592C297.033 11.6171 289.341 3.66949 279.414 0.849391L276.557 0.0436474L151.282 0.00702266C81.3919 -0.0296021 25.0549 0.0802721 23.8095 0.263396ZM92.381 77.2119C96.1905 81.0941 104.469 89.5911 110.769 96.037L122.234 107.83L124.249 108.05C126.777 108.343 128.645 109.295 130.293 111.126C131.905 112.921 132.564 114.789 132.527 117.426C132.454 122.517 128.645 126.216 123.443 126.216C118.681 126.216 115.421 123.432 114.322 118.525C114.139 117.682 108.901 112.189 89.1209 91.9717L85.1648 87.9429L77.3993 95.7074L69.5971 103.508L89.7436 123.652L109.89 143.796H116.374C119.963 143.796 122.894 143.905 122.894 144.089C122.894 144.235 110.952 156.065 96.337 170.349C81.7216 184.669 69.7802 196.425 69.7802 196.535C69.7802 196.645 73.2967 200.234 77.5824 204.519L85.4212 212.32L110.842 186.537L136.264 160.716L150.403 160.68L164.506 160.643L168.901 165.148C174.249 170.642 177.729 174.231 198.169 195.546L214.249 212.32L222.051 204.519C226.337 200.234 229.817 196.608 229.744 196.425C229.707 196.279 224.286 190.492 217.692 183.57C206.557 171.887 205.568 170.971 203.956 170.495C201.685 169.872 198.718 166.979 198.059 164.818C196.337 158.922 199.927 153.318 205.824 152.695C211.136 152.146 215.348 155.772 215.897 161.339L216.117 163.829L231.319 180.054C239.67 188.954 246.557 196.462 246.63 196.682C246.667 196.901 239.414 204.483 230.476 213.529L214.286 229.974L189.194 203.824C175.421 189.43 162.821 176.318 161.209 174.634L158.278 171.63H150.733H143.187L133.333 181.592C127.912 187.086 114.908 200.198 104.432 210.746L85.3846 229.9L68.6813 213.199L52.0147 196.535L75.2747 173.279L98.5348 150.022L75.2747 126.765L52.0147 103.508L68.6813 86.8442C77.8388 77.688 85.348 70.18 85.3846 70.18C85.4212 70.18 88.5348 73.3663 92.381 77.2119ZM231.355 86.5512C240.403 95.5243 247.802 103.032 247.802 103.215C247.802 103.435 242.381 109.039 235.788 115.704C209.121 142.697 191.209 161.009 191.209 161.375C191.209 161.559 196.374 167.382 202.674 174.304L214.139 186.903L216.154 187.342C220.366 188.295 223.26 191.957 223.26 196.352C223.26 201.553 219.341 205.435 214.103 205.398C209.414 205.398 205.751 202.102 205.018 197.304C204.762 195.473 204.652 195.363 188.205 178.443C179.084 169.067 171.612 161.229 171.612 161.046C171.612 160.863 172.711 159.691 174.103 158.409C177.802 154.93 229.67 104.168 229.78 103.875C229.927 103.508 214.506 88.0528 214.139 88.1993C213.846 88.3092 194.029 107.684 169.78 131.563L161.172 140.06L136.813 139.877L112.454 139.694L99.0843 126.399C86.4103 113.8 85.641 113.104 84.1392 112.884C81.0256 112.408 78.0586 110.138 76.8865 107.317C76.1538 105.559 76.2637 101.787 77.0696 100.029C77.9487 98.1246 80.696 95.5975 82.4542 95.1214C87.9487 93.5831 93.2234 96.623 94.359 101.934L94.7985 104.058L107.106 116.217L119.414 128.377H137.912L156.41 128.413L185.531 99.2966C201.538 83.2916 214.689 70.18 214.762 70.18C214.835 70.18 222.271 77.5415 231.355 86.5512ZM216.813 95.4876C218.718 96.0004 221.978 99.26 222.491 101.164C224.103 106.915 220.733 112.481 215.128 113.251C213.626 113.47 212.344 114.642 191.392 135.042L169.231 156.578L151.832 156.614H134.432L114.615 176.465C96.63 194.484 94.7253 196.499 94.5055 197.817C93.2967 204.812 85.2747 207.742 79.4506 203.311C75.3846 200.198 75.4212 192.69 79.5238 189.284C80.9158 188.112 83.7729 187.013 85.3846 187.013C85.9341 187.013 92.3443 180.86 107.802 165.404L129.414 143.796H146.923H164.469L184.432 124.311C204.249 104.937 204.396 104.79 204.652 103.032C205.568 97.209 210.952 93.8761 216.813 95.4876Z" fill="#0184B5"/>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="4" fill="#E45735"/>
<text x="12" y="16" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold" font-size="10" fill="white">M</text>
</svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@@ -77,7 +77,7 @@ const AI_PROVIDER_CONFIG: Record<string, {
apiName: 'OpenAI',
},
claude: {
defaultModel: 'claude-opus-4-5-20251101',
defaultModel: 'claude-opus-4-6',
apiUrl: 'https://console.anthropic.com/settings/keys',
apiName: 'Anthropic',
},
@@ -96,6 +96,11 @@ const AI_PROVIDER_CONFIG: Record<string, {
apiUrl: 'https://platform.moonshot.ai/console/api-keys',
apiName: 'Moonshot',
},
minimax: {
defaultModel: 'MiniMax-M2.5',
apiUrl: 'https://platform.minimax.io',
apiName: 'MiniMax',
},
}
interface AITradersPageProps {

View File

@@ -142,7 +142,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
1000 // 默认值(与创建交易员时的默认配置一致)
// 转换数据格式
const chartData = displayHistory.map((point) => {
const chartData = displayHistory.map((point, index) => {
const pnl = point.total_equity - initialBalance
const pnlPct = ((pnl / initialBalance) * 100).toFixed(2)
return {
@@ -151,7 +151,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
minute: '2-digit',
}),
value: displayMode === 'dollar' ? point.total_equity : parseFloat(pnlPct),
cycle: point.cycle_number,
cycle: point.cycle_number ?? index + 1,
raw_equity: point.total_equity,
raw_pnl: pnl,
raw_pnl_pct: parseFloat(pnlPct),
@@ -192,7 +192,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
>
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
Cycle #{data.cycle}
Cycle #{data.cycle != null ? data.cycle : '—'}
</div>
<div className="font-bold mono" style={{ color: '#EAECEF' }}>
{data.raw_equity.toFixed(2)} USDT

View File

@@ -17,6 +17,7 @@ const ICON_PATHS: Record<string, string> = {
hyperliquid: '/exchange-icons/hyperliquid.png',
aster: '/exchange-icons/aster.svg',
lighter: '/exchange-icons/lighter.png',
indodax: '/exchange-icons/indodax.png',
}
// 通用图标组件
@@ -101,7 +102,9 @@ export const getExchangeIcon = (
? 'aster'
: lowerType.includes('lighter')
? 'lighter'
: lowerType
: lowerType.includes('indodax')
? 'indodax'
: lowerType
const iconProps = {
width: props.width || 24,

View File

@@ -57,6 +57,17 @@ export function Header({ simple = false }: HeaderProps) {
>
EN
</button>
<button
onClick={() => setLanguage('id')}
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={
language === 'id'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
ID
</button>
</div>
</div>
</Container>

View File

@@ -99,8 +99,8 @@ export default function HeaderBar({
{(() => {
// Define all navigation tabs
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : 'Data', requiresAuth: false },
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false },
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true },
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
@@ -259,7 +259,7 @@ export default function HeaderBar({
className="flex items-center gap-2 px-3 py-2 rounded transition-colors text-nofx-text-muted hover:bg-white/5"
>
<span className="text-lg">
{language === 'zh' ? '🇨🇳' : '🇺🇸'}
{language === 'zh' ? '🇨🇳' : language === 'id' ? '🇮🇩' : '🇺🇸'}
</span>
<ChevronDown className="w-4 h-4" />
</button>
@@ -288,6 +288,17 @@ export default function HeaderBar({
<span className="text-base">🇺🇸</span>
<span className="text-sm">English</span>
</button>
<button
onClick={() => {
onLanguageChange?.('id')
setLanguageDropdownOpen(false)
}}
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-white
${language === 'id' ? 'bg-nofx-gold/10' : 'hover:bg-white/5'}`}
>
<span className="text-base">🇮🇩</span>
<span className="text-sm">Bahasa</span>
</button>
</div>
)}
</div>
@@ -329,8 +340,8 @@ export default function HeaderBar({
<div className="flex flex-col gap-6 mb-12">
{(() => {
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : 'Data', requiresAuth: false },
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : language === 'id' ? 'Data' : 'Data', requiresAuth: false },
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : language === 'id' ? 'Pasar' : 'Market', requiresAuth: true },
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
@@ -429,7 +440,7 @@ export default function HeaderBar({
<div className="grid grid-cols-2 gap-4">
{/* Lang Switcher */}
<div className="flex bg-zinc-900 rounded-lg p-1 border border-zinc-800">
{['zh', 'en'].map((lang) => (
{['zh', 'en', 'id'].map((lang) => (
<button
key={lang}
onClick={() => {
@@ -441,7 +452,7 @@ export default function HeaderBar({
: 'text-zinc-500'
}`}
>
{lang === 'zh' ? 'CN' : 'EN'}
{lang === 'zh' ? 'CN' : lang === 'id' ? 'ID' : 'EN'}
</button>
))}
</div>

View File

@@ -10,15 +10,10 @@ import { useSystemConfig } from '../hooks/useSystemConfig'
export function LoginPage() {
const { language } = useLanguage()
const { login, loginAdmin, verifyOTP, completeRegistration } = useAuth()
const [step, setStep] = useState<'login' | 'otp' | 'setup-otp'>('login')
const { login, loginAdmin } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [otpCode, setOtpCode] = useState('')
const [userID, setUserID] = useState('')
const [qrCodeURL, setQrCodeURL] = useState('') // New state for recovery
const [otpSecret, setOtpSecret] = useState('') // New state for recovery
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [adminPassword, setAdminPassword] = useState('')
@@ -64,83 +59,19 @@ export function LoginPage() {
const result = await login(email, password)
if (result.success) {
// Check for incomplete OTP setup (user registered but didn't complete 2FA)
if (result.requiresOTPSetup && result.userID) {
setUserID(result.userID)
setQrCodeURL(result.qrCodeURL || '')
setOtpSecret(result.otpSecret || '')
setStep('setup-otp')
toast.info("Pending 2FA setup detected. Please complete configuration.")
} else if (result.requiresOTP && result.userID) {
setUserID(result.userID)
// Check if backend provided recovery data (meaning 2FA is pending setup)
if (result.qrCodeURL) {
setQrCodeURL(result.qrCodeURL)
setOtpSecret(result.otpSecret || '')
setStep('setup-otp')
toast.info("Pending 2FA setup detected. Please complete configuration.")
} else {
setStep('otp')
}
} else {
// Dismiss the "login expired" toast on successful login (no OTP required)
if (expiredToastId) {
toast.dismiss(expiredToastId)
}
}
} else {
// Check if we have recovery data despite the error (e.g. "Account has not completed OTP setup")
if (result.qrCodeURL) {
setUserID(result.userID || '') // We might need to ensure userID is returned in error case too, or derived
setQrCodeURL(result.qrCodeURL)
setOtpSecret(result.otpSecret || '')
setStep('setup-otp')
toast.warning(t('completeGapSetup', language) || "Incomplete setup detected. Please configure 2FA.")
} else {
const msg = result.message || t('loginFailed', language)
setError(msg)
toast.error(msg)
}
}
setLoading(false)
}
const handleOTPVerify = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
// If we have qrCodeURL, it means user needs to complete registration (first time OTP setup)
// Otherwise, it's a normal login OTP verification
const result = qrCodeURL
? await completeRegistration(userID, otpCode)
: await verifyOTP(userID, otpCode)
if (!result.success) {
const msg = result.message || t('verificationFailed', language)
setError(msg)
toast.error(msg)
} else {
// Dismiss the "login expired" toast on successful OTP verification
// Dismiss the "login expired" toast on successful login.
if (expiredToastId) {
toast.dismiss(expiredToastId)
}
// Clear qrCodeURL after successful completion
setQrCodeURL('')
setOtpSecret('')
} else {
const msg = result.message || t('loginFailed', language)
setError(msg)
toast.error(msg)
}
// 成功的话AuthContext会自动处理登录状态
setLoading(false)
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
toast.success('Copied to clipboard')
}
return (
<DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation>
@@ -172,7 +103,7 @@ export function LoginPage() {
<span className="text-nofx-gold">SYSTEM</span> ACCESS
</h1>
<p className="text-zinc-500 text-xs tracking-[0.2em] uppercase">
{step === 'login' ? 'Authentication Protocol v3.0' : 'Multi-Factor Verification'}
Authentication Protocol v3.0
</p>
</div>
@@ -241,67 +172,7 @@ export function LoginPage() {
{loading ? '> VERIFYING...' : '> EXECUTE_LOGIN'}
</button>
</form>
) : step === 'setup-otp' ? (
<div className="space-y-6">
<div className="text-center bg-zinc-900/50 p-4 rounded border border-zinc-800">
<div className="text-xs font-mono text-zinc-400 mb-2">COMPLETE 2FA CONFIGURATION</div>
{qrCodeURL ? (
<div className="bg-white p-2 rounded inline-block shadow-[0_0_30px_rgba(255,255,255,0.1)]">
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(`otpauth://totp/NoFX:${email}?secret=${otpSecret}&issuer=NoFX`)}`}
alt="QR Code"
className="w-32 h-32"
/>
</div>
) : (
<div className="w-32 h-32 bg-zinc-800 animate-pulse rounded inline-block"></div>
)}
<div className="mt-4">
<p className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1">Backup Secret Key</p>
<div className="flex items-center gap-2 justify-center bg-black/50 p-2 rounded border border-zinc-700/50 max-w-[200px] mx-auto">
<code className="text-xs font-mono text-nofx-gold">{otpSecret}</code>
<button
onClick={() => copyToClipboard(otpSecret)}
className="text-zinc-500 hover:text-white transition-colors"
>
<span className="text-[10px] uppercase border border-zinc-700 px-1 rounded">Copy</span>
</button>
</div>
</div>
</div>
<div className="space-y-4 font-mono text-xs text-zinc-400 bg-black/20 p-4 rounded border border-zinc-800/50">
<div className="flex gap-3 items-start">
<span className="text-nofx-gold font-bold mt-0.5">01</span>
<div>
<p className="font-bold text-white mb-1">Install Authenticator App</p>
<p className="mb-2">Recommended: <span className="text-nofx-gold">Google Authenticator</span>.</p>
<div className="flex gap-2">
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">iOS</span>
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">Android</span>
</div>
</div>
</div>
<div className="w-full h-px bg-zinc-800/50"></div>
<div className="flex gap-3 items-start">
<span className="text-nofx-gold font-bold mt-0.5">02</span>
<div>
<p className="font-bold text-white mb-1">Scan & Verify</p>
<p>Scan code above, then enter the 6-digit token below to activate your account.</p>
</div>
</div>
</div>
<button
onClick={() => setStep('otp')}
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-colors font-mono shadow-lg"
>
I HAVE SCANNED THE CODE
</button>
</div>
) : step === 'login' ? (
) : (
<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-4">
<div>
@@ -371,59 +242,6 @@ export function LoginPage() {
)}
</button>
</form>
) : (
<form onSubmit={handleOTPVerify} className="space-y-6">
<div className="text-center py-2">
<div className="w-12 h-12 bg-zinc-900 rounded-full flex items-center justify-center mx-auto mb-4 border border-zinc-700 text-2xl">
🔐
</div>
<p className="text-xs text-zinc-400 font-mono leading-relaxed">
{t('scanQRCodeInstructions', language)}<br />
{t('enterOTPCode', language)}
</p>
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-nofx-gold mb-2 text-center font-bold">
{t('otpCode', language)}
</label>
<input
type="text"
value={otpCode}
onChange={(e) =>
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
}
className="w-full bg-black border border-zinc-700 rounded px-4 py-4 text-center text-2xl tracking-[0.5em] font-mono text-white focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800"
placeholder="000000"
maxLength={6}
required
autoFocus
/>
</div>
{error && (
<div className="text-xs bg-red-500/10 border border-red-500/30 text-red-500 px-3 py-2 rounded font-mono text-center">
[ACCESS DENIED]: {error}
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => setStep('login')}
className="flex-1 bg-zinc-900 border border-zinc-700 text-zinc-400 py-3 rounded text-xs font-mono uppercase hover:bg-zinc-800 transition-colors"
>
&lt; ABORT
</button>
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 bg-nofx-gold text-black font-bold py-3 rounded text-xs font-mono uppercase hover:bg-yellow-400 transition-colors disabled:opacity-50"
>
{loading ? 'VERIFYING...' : 'CONFIRM IDENTITY'}
</button>
</div>
</form>
)}
</div>

View File

@@ -40,6 +40,20 @@ export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequ
login: 'EXECUTE LOGIN',
register: 'REGISTER NEW ID',
later: 'ABORT'
},
id: {
title: 'AKSES SISTEM DITOLAK',
subtitle: featureName ? `Modul "${featureName}" memerlukan hak akses lebih tinggi` : 'Otorisasi diperlukan untuk modul ini',
description: 'Inisialisasi protokol autentikasi untuk membuka kemampuan sistem penuh: konfigurasi Trader AI, aliran data Pasar Strategi, dan inti Simulasi Backtest.',
benefits: [
'Kontrol Trader AI',
'Pasar Strategi HFT',
'Mesin Backtest Historis',
'Visualisasi Sistem Penuh'
],
login: 'JALANKAN LOGIN',
register: 'DAFTAR ID BARU',
later: 'BATALKAN'
}
}

View File

@@ -13,6 +13,7 @@ const MODEL_COLORS: Record<string, string> = {
gemini: '#4285F4',
grok: '#000000',
openai: '#10A37F',
minimax: '#E45735',
}
// 获取AI模型图标的函数
@@ -44,6 +45,9 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => {
case 'openai':
iconPath = '/icons/openai.svg'
break
case 'minimax':
iconPath = '/icons/minimax.svg'
break
default:
return null
}

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useMemo } from 'react'
import { api } from '../lib/api'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import { t, type Language } from '../i18n/translations'
import { MetricTooltip } from './MetricTooltip'
import { formatPrice, formatQuantity } from '../utils/format'
import type {
HistoricalPosition,
TraderStats,
@@ -14,7 +15,7 @@ interface PositionHistoryProps {
traderId: string
}
// Format number with proper decimals
// Format number with proper decimals (for large numbers)
function formatNumber(value: number, decimals: number = 2): string {
if (Math.abs(value) >= 1000000) {
return (value / 1000000).toFixed(2) + 'M'
@@ -25,14 +26,6 @@ function formatNumber(value: number, decimals: number = 2): string {
return value.toFixed(decimals)
}
// Format price with proper decimals
function formatPrice(price: number): string {
if (!price || price === 0) return '-'
if (price >= 1000) return price.toFixed(2)
if (price >= 1) return price.toFixed(4)
return price.toFixed(6)
}
// Format duration from minutes
function formatDuration(minutes: number): string {
if (!minutes || minutes <= 0) return '-'
@@ -159,7 +152,7 @@ function SymbolStatsRow({ stat }: { stat: SymbolStats }) {
}
// Direction Stats Card
function DirectionStatsCard({ stat, language }: { stat: DirectionStats; language: 'en' | 'zh' }) {
function DirectionStatsCard({ stat, language }: { stat: DirectionStats; language: Language }) {
const isLong = (stat.side || '').toLowerCase() === 'long'
const iconColor = isLong ? '#0ECB81' : '#F6465D'
const totalPnl = stat.total_pnl || 0
@@ -300,7 +293,7 @@ function PositionRow({ position }: { position: HistoricalPosition }) {
{/* Quantity */}
<td className="py-3 px-4 text-right font-mono" style={{ color: '#848E9C' }}>
{displayQty.toFixed(4)}
{formatQuantity(displayQty)}
</td>
{/* Position Value (Entry Price * Quantity) */}

View File

@@ -1,33 +1,25 @@
import React, { useState, useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import PasswordChecklist from 'react-password-checklist'
import { toast } from 'sonner'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import { getSystemConfig } from '../lib/config'
import { toast } from 'sonner'
import { copyWithToast } from '../lib/clipboard'
import { Eye, EyeOff } from 'lucide-react'
import { DeepVoidBackground } from './DeepVoidBackground'
// import { Input } from './ui/input' // Removed unused import
import PasswordChecklist from 'react-password-checklist'
import { RegistrationDisabled } from './RegistrationDisabled'
import { WhitelistFullPage } from './WhitelistFullPage'
export function RegisterPage() {
const { language } = useLanguage()
const { register, completeRegistration } = useAuth()
const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp' | 'whitelist-full'>(
'register'
)
const { register } = useAuth()
const [view, setView] = useState<'register' | 'whitelist-full'>('register')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [betaCode, setBetaCode] = useState('')
const [betaMode, setBetaMode] = useState(false)
const [registrationEnabled, setRegistrationEnabled] = useState(true)
const [otpCode, setOtpCode] = useState('')
const [userID, setUserID] = useState('')
const [otpSecret, setOtpSecret] = useState('')
const [qrCodeURL, setQrCodeURL] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [passwordValid, setPasswordValid] = useState(false)
@@ -35,7 +27,6 @@ export function RegisterPage() {
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
useEffect(() => {
// 获取系统配置,检查是否开启内测模式和注册功能
getSystemConfig()
.then((config) => {
setBetaMode(config.beta_mode || false)
@@ -46,21 +37,18 @@ export function RegisterPage() {
})
}, [])
// 如果注册功能被禁用,显示注册已关闭页面
if (!registrationEnabled) {
return <RegistrationDisabled />
}
// 如果白名单已满,显示容量已满页面
if (step === 'whitelist-full') {
return <WhitelistFullPage onBack={() => setStep('register')} />
if (view === 'whitelist-full') {
return <WhitelistFullPage onBack={() => setView('register')} />
}
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
// 使用 PasswordChecklist 的校验结果
if (!passwordValid) {
setError(t('passwordNotMeetRequirements', language))
return
@@ -72,50 +60,44 @@ export function RegisterPage() {
}
setLoading(true)
try {
const result = await register(email, password, betaCode.trim() || undefined)
// Helper to check for whitelist errors
const isWhitelistError = (msg: string) => {
const lowerMsg = msg.toLowerCase()
return lowerMsg.includes('whitelist') ||
return (
lowerMsg.includes('whitelist') ||
lowerMsg.includes('capacity') ||
lowerMsg.includes('limit') ||
lowerMsg.includes('permission denied') ||
lowerMsg.includes('not on whitelist')
)
}
if (result.success && result.userID) {
setUserID(result.userID)
setOtpSecret(result.otpSecret || '')
setQrCodeURL(result.qrCodeURL || '')
setStep('setup-otp')
} else {
// Check for whitelist/capacity limit error
if (!result.success) {
const msg = result.message || t('registrationFailed', language)
if (isWhitelistError(msg)) {
setStep('whitelist-full')
setView('whitelist-full')
return
}
setError(msg)
toast.error(msg)
}
// success path is handled in AuthContext (auto login + navigation)
} catch (e) {
console.error('Registration error:', e)
const errorMsg = e instanceof Error ? e.message : 'Registration failed due to server error'
// Check for whitelist error in catch block too
const lowerMsg = errorMsg.toLowerCase()
if (lowerMsg.includes('whitelist') ||
if (
lowerMsg.includes('whitelist') ||
lowerMsg.includes('capacity') ||
lowerMsg.includes('limit') ||
lowerMsg.includes('permission denied') ||
lowerMsg.includes('not on whitelist')) {
setStep('whitelist-full')
lowerMsg.includes('not on whitelist')
) {
setView('whitelist-full')
return
}
setError(errorMsg)
toast.error(errorMsg)
} finally {
@@ -123,39 +105,12 @@ export function RegisterPage() {
}
}
const handleSetupComplete = () => {
setStep('verify-otp')
}
const handleOTPVerify = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
const result = await completeRegistration(userID, otpCode)
if (!result.success) {
const msg = result.message || t('registrationFailed', language)
setError(msg)
toast.error(msg)
}
// 成功的话AuthContext会自动处理登录状态
setLoading(false)
}
const copyToClipboard = (text: string) => {
copyWithToast(text)
}
return (
<DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation>
<div className="w-full max-w-lg relative z-10 px-6">
{/* Navigation - Top Bar (Mobile/Desktop Friendly) */}
<div className="flex justify-between items-center mb-8">
<button
onClick={() => window.location.href = '/'}
onClick={() => (window.location.href = '/')}
className="flex items-center gap-2 text-zinc-500 hover:text-white transition-colors group px-3 py-1.5 rounded border border-transparent hover:border-zinc-700 bg-black/20 backdrop-blur-sm"
>
<div className="w-2 h-2 rounded-full bg-red-500 group-hover:animate-pulse"></div>
@@ -163,38 +118,29 @@ export function RegisterPage() {
</button>
</div>
{/* Terminal Header */}
<div className="mb-8 text-center">
<div className="flex justify-center mb-6">
<div className="relative">
<div className="absolute -inset-2 bg-nofx-gold/20 rounded-full blur-xl animate-pulse"></div>
<img
src="/icons/nofx.svg"
alt="NoFx Logo"
className="w-16 h-16 object-contain relative z-10 opacity-90"
/>
<img src="/icons/nofx.svg" alt="NoFx Logo" className="w-16 h-16 object-contain relative z-10 opacity-90" />
</div>
</div>
<h1 className="text-3xl font-bold tracking-tighter text-white uppercase mb-2">
<span className="text-nofx-gold">NEW_USER</span> ONBOARDING
</h1>
<p className="text-zinc-500 text-xs tracking-[0.2em] uppercase">
{step === 'register' && 'Initializing Registration Sequence...'}
{step === 'setup-otp' && 'Configuring Security Protocols...'}
{step === 'verify-otp' && 'Finalizing Authentication...'}
Initializing Registration Sequence...
</p>
</div>
{/* Terminal Output / Form Container */}
<div className="bg-zinc-900/40 backdrop-blur-md border border-zinc-800 rounded-lg overflow-hidden shadow-2xl relative group">
<div className="absolute inset-0 bg-zinc-900/50 opacity-0 group-hover:opacity-100 transition duration-700 pointer-events-none"></div>
{/* Window Bar */}
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900/80 border-b border-zinc-800">
<div className="flex gap-1.5">
<div
className="w-2.5 h-2.5 rounded-full bg-red-500/50 hover:bg-red-500 cursor-pointer transition-colors"
onClick={() => window.location.href = '/'}
onClick={() => (window.location.href = '/')}
title="Close / Return Home"
></div>
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/50"></div>
@@ -206,7 +152,6 @@ export function RegisterPage() {
</div>
<div className="p-6 md:p-8 relative">
{/* Status Output */}
<div className="mb-6 font-mono text-xs space-y-1 text-zinc-500 border-b border-zinc-800/50 pb-4">
<div className="flex gap-2">
<span className="text-emerald-500"></span>
@@ -218,275 +163,151 @@ export function RegisterPage() {
</div>
</div>
{step === 'register' && (
<form onSubmit={handleRegister} className="space-y-5">
<form onSubmit={handleRegister} className="space-y-5">
<div>
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('email', language)}</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono"
placeholder="user@nofx.os"
required
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('email', language)}</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono"
placeholder="user@nofx.os"
required
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('password', language)}</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono pr-10"
placeholder="••••••••"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('confirmPassword', language)}</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono pr-10"
placeholder="••••••••"
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
>
{showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
</div>
<div className="bg-zinc-900/50 p-3 rounded border border-zinc-800/50">
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-2 font-bold flex items-center gap-2">
<div className="w-1 h-1 rounded-full bg-zinc-500"></div>
Password Strength Protocol
</div>
<div className="text-xs font-mono text-zinc-400">
<PasswordChecklist
rules={['minLength', 'capital', 'lowercase', 'number', 'specialChar', 'match']}
minLength={8}
value={password}
valueAgain={confirmPassword}
messages={{
minLength: t('passwordRuleMinLength', language),
capital: t('passwordRuleUppercase', language),
lowercase: t('passwordRuleLowercase', language),
number: t('passwordRuleNumber', language),
specialChar: t('passwordRuleSpecial', language),
match: t('passwordRuleMatch', language),
}}
className="grid grid-cols-2 gap-x-4 gap-y-1"
onChange={(isValid) => setPasswordValid(isValid)}
iconSize={10}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('password', language)}</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono pr-10"
placeholder="••••••••"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('confirmPassword', language)}</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono pr-10"
placeholder="••••••••"
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
>
{showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
</div>
<div className="bg-zinc-900/50 p-3 rounded border border-zinc-800/50">
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-2 font-bold flex items-center gap-2">
<div className="w-1 h-1 rounded-full bg-zinc-500"></div>
Password Strength Protocol
</div>
<div className="text-xs font-mono text-zinc-400">
<PasswordChecklist
rules={['minLength', 'capital', 'lowercase', 'number', 'specialChar', 'match']}
minLength={8}
value={password}
valueAgain={confirmPassword}
messages={{
minLength: t('passwordRuleMinLength', language),
capital: t('passwordRuleUppercase', language),
lowercase: t('passwordRuleLowercase', language),
number: t('passwordRuleNumber', language),
specialChar: t('passwordRuleSpecial', language),
match: t('passwordRuleMatch', language),
}}
className="grid grid-cols-2 gap-x-4 gap-y-1"
onChange={(isValid) => setPasswordValid(isValid)}
iconSize={10}
/>
</div>
</div>
{betaMode && (
<div>
<label className="block text-xs uppercase tracking-wider text-nofx-gold mb-1.5 ml-1 font-bold">Priority Access Code</label>
<input
type="text"
value={betaCode}
onChange={(e) => setBetaCode(e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase())}
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono tracking-widest"
placeholder="XXXXXX"
maxLength={6}
required={betaMode}
/>
<p className="text-[10px] text-zinc-600 font-mono mt-1 ml-1">* CASE SENSITIVE ALPHANUMERIC</p>
</div>
)}
{error && (
<div className="text-xs bg-red-500/10 border border-red-500/30 text-red-500 px-3 py-2 rounded font-mono">
[REGISTRATION_ERROR]: {error}
</div>
)}
<button
type="submit"
disabled={loading || (betaMode && !betaCode.trim()) || !passwordValid}
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-all transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed font-mono shadow-[0_0_15px_rgba(255,215,0,0.1)] hover:shadow-[0_0_25px_rgba(255,215,0,0.25)] flex items-center justify-center gap-2 group mt-4"
>
{loading ? (
<span className="animate-pulse">INITIALIZING...</span>
) : (
<>
<span>CREATE_ACCOUNT</span>
<span className="group-hover:translate-x-1 transition-transform">-&gt;</span>
</>
)}
</button>
</form>
)}
{step === 'setup-otp' && (
<div className="space-y-6">
<div className="text-center bg-zinc-900/50 p-4 rounded border border-zinc-800">
<div className="text-xs font-mono text-zinc-400 mb-2">SCAN_QR_CODE_SEQUENCE</div>
{qrCodeURL ? (
<div className="bg-white p-2 rounded inline-block shadow-[0_0_30px_rgba(255,255,255,0.1)]">
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(`otpauth://totp/NoFX:${email}?secret=${otpSecret}&issuer=NoFX`)}`}
alt="QR Code"
className="w-32 h-32"
/>
</div>
) : (
<div className="w-32 h-32 bg-zinc-800 animate-pulse rounded inline-block"></div>
)}
<div className="mt-4">
<p className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1">Backup Secret Key</p>
<div className="flex items-center gap-2 justify-center bg-black/50 p-2 rounded border border-zinc-700/50 max-w-[200px] mx-auto">
<code className="text-xs font-mono text-nofx-gold">{otpSecret}</code>
<button
onClick={() => copyToClipboard(otpSecret)}
className="text-zinc-500 hover:text-white transition-colors"
>
<span className="text-[10px] uppercase border border-zinc-700 px-1 rounded">Copy</span>
</button>
</div>
</div>
</div>
<div className="space-y-4 font-mono text-xs text-zinc-400 bg-black/20 p-4 rounded border border-zinc-800/50">
<div className="flex gap-3 items-start">
<span className="text-nofx-gold font-bold mt-0.5">01</span>
<div>
<p className="font-bold text-white mb-1">Install Authenticator App</p>
<p className="mb-2">We highly recommend <span className="text-nofx-gold">Google Authenticator</span> for compatibility.</p>
<div className="flex gap-2">
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">iOS</span>
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">Android</span>
</div>
</div>
</div>
<div className="w-full h-px bg-zinc-800/50"></div>
<div className="flex gap-3 items-start">
<span className="text-nofx-gold font-bold mt-0.5">02</span>
<div>
<p className="font-bold text-white mb-1">Scan QR Code</p>
<p>Open Google Authenticator, tap the <span className="text-white">+</span> button, and scan the code above.</p>
<p className="text-[10px] text-zinc-500 mt-1 italic">Protocol: Time-Based OTP (TOTP)</p>
</div>
</div>
<div className="w-full h-px bg-zinc-800/50"></div>
<div className="flex gap-3 items-start">
<span className="text-nofx-gold font-bold mt-0.5">03</span>
<div>
<p className="font-bold text-white mb-1">Verify Token</p>
<p>Enter the 6-digit code generated by the app.</p>
<div className="mt-2 p-2 bg-yellow-500/10 border border-yellow-500/20 rounded text-[10px] text-yellow-500/80 flex gap-2 items-start">
<span className="mt-px"></span>
<span>Stuck? Ensure your phone's time is set to "Automatic". Time drift causes codes to fail.</span>
</div>
</div>
</div>
</div>
<button
onClick={handleSetupComplete}
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-colors font-mono shadow-lg"
>
PROCEED TO VERIFICATION
</button>
</div>
)}
{step === 'verify-otp' && (
<form onSubmit={handleOTPVerify} className="space-y-6">
<div className="text-center">
<p className="text-xs text-zinc-400 font-mono mb-6">
ENTER 6-DIGIT SECURITY TOKEN TO FINALIZE ONBOARDING
</p>
</div>
{betaMode && (
<div>
<label className="block text-xs uppercase tracking-wider text-nofx-gold mb-1.5 ml-1 font-bold">Priority Access Code</label>
<input
type="text"
value={otpCode}
onChange={(e) =>
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
}
className="w-full bg-black border border-zinc-700 rounded px-4 py-4 text-center text-3xl tracking-[0.5em] font-mono text-white focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800"
placeholder="000000"
value={betaCode}
onChange={(e) => setBetaCode(e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase())}
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono tracking-widest"
placeholder="XXXXXX"
maxLength={6}
required
autoFocus
required={betaMode}
/>
<p className="text-[10px] text-zinc-600 font-mono mt-1 ml-1">* CASE SENSITIVE ALPHANUMERIC</p>
</div>
)}
{error && (
<div className="text-xs bg-red-500/10 border border-red-500/30 text-red-500 px-3 py-2 rounded font-mono text-center">
[VERIFICATION_FAILED]: {error}
</div>
{error && (
<div className="text-xs bg-red-500/10 border border-red-500/30 text-red-500 px-3 py-2 rounded font-mono">
[REGISTRATION_ERROR]: {error}
</div>
)}
<button
type="submit"
disabled={loading || (betaMode && !betaCode.trim()) || !passwordValid}
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-all transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed font-mono shadow-[0_0_15px_rgba(255,215,0,0.1)] hover:shadow-[0_0_25px_rgba(255,215,0,0.25)] flex items-center justify-center gap-2 group mt-4"
>
{loading ? (
<span className="animate-pulse">INITIALIZING...</span>
) : (
<>
<span>CREATE_ACCOUNT</span>
<span className="group-hover:translate-x-1 transition-transform">-&gt;</span>
</>
)}
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-colors font-mono shadow-lg disabled:opacity-50"
>
{loading ? 'VALIDATING...' : 'ACTIVATE ACCOUNT'}
</button>
</form>
)}
</button>
</form>
</div>
{/* Terminal Footer Info */}
<div className="bg-zinc-900/50 p-3 flex justify-between items-center text-[10px] font-mono text-zinc-600 border-t border-zinc-800">
<div>ENCRYPTION: AES-256</div>
<div>SECURE_REGISTRY</div>
</div>
</div>
{/* Login Link */}
{step === 'register' && (
<div className="text-center mt-8 space-y-4">
<p className="text-xs font-mono text-zinc-500">
EXISTING_OPERATOR?{' '}
<button
onClick={() => window.location.href = '/login'}
className="text-nofx-gold hover:underline hover:text-yellow-300 transition-colors ml-1 uppercase"
>
ACCESS TERMINAL
</button>
</p>
<div className="text-center mt-8 space-y-4">
<p className="text-xs font-mono text-zinc-500">
EXISTING_OPERATOR?{' '}
<button
onClick={() => window.location.href = '/'}
className="text-[10px] text-zinc-600 hover:text-red-500 transition-colors uppercase tracking-widest hover:underline decoration-red-500/30 font-mono"
onClick={() => (window.location.href = '/login')}
className="text-nofx-gold hover:underline hover:text-yellow-300 transition-colors ml-1 uppercase"
>
[ ABORT_REGISTRATION_RETURN_HOME ]
ACCESS TERMINAL
</button>
</div>
)}
</p>
<button
onClick={() => (window.location.href = '/')}
className="text-[10px] text-zinc-600 hover:text-red-500 transition-colors uppercase tracking-widest hover:underline decoration-red-500/30 font-mono"
>
[ ABORT_REGISTRATION_RETURN_HOME ]
</button>
</div>
</div>
</DeepVoidBackground>
)

View File

@@ -14,7 +14,6 @@ export function ResetPasswordPage() {
const [email, setEmail] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [otpCode, setOtpCode] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
@@ -35,7 +34,7 @@ export function ResetPasswordPage() {
setLoading(true)
const result = await resetPassword(email, newPassword, otpCode)
const result = await resetPassword(email, newPassword)
if (result.success) {
setSuccess(true)
@@ -88,7 +87,7 @@ export function ResetPasswordPage() {
{t('resetPasswordTitle', language)}
</h1>
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
使 Google Authenticator
使
</p>
</div>
@@ -230,37 +229,6 @@ export function ResetPasswordPage() {
/>
</div>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('otpCode', language)}
</label>
<div className="text-center mb-3">
<div className="text-3xl">📱</div>
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
Google Authenticator 6
</p>
</div>
<input
type="text"
value={otpCode}
onChange={(e) =>
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div
className="text-sm px-3 py-2 rounded"
@@ -275,7 +243,7 @@ export function ResetPasswordPage() {
<button
type="submit"
disabled={loading || otpCode.length !== 6 || !passwordValid}
disabled={loading || !passwordValid}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>

View File

@@ -125,7 +125,7 @@ export function TraderConfigModal({
const handleFetchCurrentBalance = async () => {
if (!isEditMode || !traderData?.trader_id) {
setBalanceFetchError('只有在编辑模式下才能获取当前余额')
setBalanceFetchError(t('fetchBalanceEditModeOnly', language))
return
}
@@ -142,13 +142,13 @@ export function TraderConfigModal({
const currentBalance =
result.data.total_equity || result.data.balance || 0
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
toast.success('已获取当前余额')
toast.success(t('balanceFetched', language))
} else {
throw new Error(result.message || '获取余额失败')
throw new Error(result.message || t('balanceFetchFailed', language))
}
} catch (error) {
console.error('获取余额失败:', error)
setBalanceFetchError('获取余额失败,请检查网络连接')
console.error(t('balanceFetchFailed', language) + ':', error)
setBalanceFetchError(t('balanceFetchNetworkError', language))
} finally {
setIsFetchingBalance(false)
}
@@ -175,13 +175,13 @@ export function TraderConfigModal({
}
await toast.promise(onSave(saveData), {
loading: '正在保存…',
success: '保存成功',
error: '保存失败',
loading: t('saving', language),
success: t('saveSuccess', language),
error: t('saveFailed', language),
})
onClose()
} catch (error) {
console.error('保存失败:', error)
console.error(t('saveFailed', language) + ':', error)
} finally {
setIsSaving(false)
}
@@ -208,10 +208,10 @@ export function TraderConfigModal({
</div>
<div>
<h2 className="text-xl font-bold text-[#EAECEF]">
{isEditMode ? '修改交易员' : '创建交易员'}
{isEditMode ? t('editTrader', language) : t('createTrader', language)}
</h2>
<p className="text-sm text-[#848E9C] mt-1">
{isEditMode ? '修改交易员配置' : '选择策略并配置基础参数'}
{isEditMode ? t('editTraderConfig', language) : t('selectStrategyAndConfigParams', language)}
</p>
</div>
</div>
@@ -231,12 +231,12 @@ export function TraderConfigModal({
{/* Basic Info */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
<span className="text-[#F0B90B]">1</span>
<span className="text-[#F0B90B]">1</span> {t('basicConfig', language)}
</h3>
<div className="space-y-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
<span className="text-red-500">*</span>
{t('traderNameRequired', language)}
</label>
<input
type="text"
@@ -245,13 +245,13 @@ export function TraderConfigModal({
handleInputChange('trader_name', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
placeholder="请输入交易员名称"
placeholder={t('enterTraderNamePlaceholder', language)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
AI模型 <span className="text-red-500">*</span>
{t('aiModelRequired', language)}
</label>
<select
value={formData.ai_model}
@@ -269,7 +269,7 @@ export function TraderConfigModal({
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
<span className="text-red-500">*</span>
{t('exchangeRequired', language)}
</label>
<select
value={formData.exchange_id}
@@ -300,10 +300,10 @@ export function TraderConfigModal({
className="mt-2 inline-flex items-center gap-1.5 text-xs text-[#848E9C] hover:text-[#F0B90B] transition-colors"
>
<UserPlus className="w-3.5 h-3.5" />
<span></span>
<span>{t('noExchangeAccount', language)}</span>
{regLink.hasReferral && (
<span className="px-1.5 py-0.5 bg-[#F0B90B]/10 text-[#F0B90B] rounded text-[10px]">
{t('discount', language)}
</span>
)}
<ExternalLink className="w-3 h-3" />
@@ -318,13 +318,13 @@ export function TraderConfigModal({
{/* Strategy Selection */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
<span className="text-[#F0B90B]">2</span>
<span className="text-[#F0B90B]">2</span> {t('selectTradingStrategy', language)}
<Sparkles className="w-4 h-4 text-[#F0B90B]" />
</h3>
<div className="space-y-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
使
{t('useStrategy', language)}
</label>
<select
value={formData.strategy_id}
@@ -333,18 +333,18 @@ export function TraderConfigModal({
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
<option value="">-- 使--</option>
<option value="">{t('noStrategyManual', language)}</option>
{strategies.map((strategy) => (
<option key={strategy.id} value={strategy.id}>
{strategy.name}
{strategy.is_active ? ' (当前激活)' : ''}
{strategy.is_default ? ' [默认]' : ''}
{strategy.is_active ? t('activeTag', language) : ''}
{strategy.is_default ? t('default', language) : ''}
</option>
))}
</select>
{strategies.length === 0 && (
<p className="text-xs text-[#848E9C] mt-2">
<p className="text-xs text-[#848E9C] mt-2">
{t('noStrategyHint', language)}
</p>
)}
</div>
@@ -354,25 +354,25 @@ export function TraderConfigModal({
<div className="mt-3 p-4 bg-[#1E2329] border border-[#2B3139] rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-[#F0B90B] text-sm font-medium">
{t('strategyDetails', language)}
</span>
{selectedStrategy.is_active && (
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded">
{t('activating', language)}
</span>
)}
</div>
<p className="text-sm text-[#848E9C] mb-2">
{selectedStrategy.description || '无描述'}
{selectedStrategy.description || (language === 'zh' ? '无描述' : 'No description')}
</p>
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
<div>
: {selectedStrategy.config.coin_source.source_type === 'static' ? '固定币种' :
{t('coinSource', language)}: {selectedStrategy.config.coin_source.source_type === 'static' ? '固定币种' :
selectedStrategy.config.coin_source.source_type === 'ai500' ? 'AI500' :
selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
</div>
<div>
: {((selectedStrategy.config.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%
{t('marginLimit', language)}: {((selectedStrategy.config.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%
</div>
</div>
</div>
@@ -383,13 +383,13 @@ export function TraderConfigModal({
{/* Trading Parameters */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
<span className="text-[#F0B90B]">3</span>
<span className="text-[#F0B90B]">3</span> {t('tradingParams', language)}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('marginMode', language)}
</label>
<div className="flex gap-2">
<button
@@ -401,7 +401,7 @@ export function TraderConfigModal({
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
{t('crossMargin', language)}
</button>
<button
type="button"
@@ -414,7 +414,7 @@ export function TraderConfigModal({
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
{t('isolatedMargin', language)}
</button>
</div>
</div>
@@ -446,7 +446,7 @@ export function TraderConfigModal({
{/* Competition visibility */}
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('competitionDisplay', language)}
</label>
<div className="flex gap-2">
<button
@@ -458,7 +458,7 @@ export function TraderConfigModal({
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
{t('show', language)}
</button>
<button
type="button"
@@ -469,11 +469,11 @@ export function TraderConfigModal({
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
{t('hide', language)}
</button>
</div>
<p className="text-xs text-[#848E9C] mt-1">
<p className="text-xs text-[#848E9C] mt-1">
{t('hiddenInCompetition', language)}
</p>
</div>
@@ -482,7 +482,7 @@ export function TraderConfigModal({
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-[#EAECEF]">
($)
{t('initialBalanceLabel', language)}
</label>
<button
type="button"
@@ -490,7 +490,7 @@ export function TraderConfigModal({
disabled={isFetchingBalance}
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors disabled:bg-[#848E9C] disabled:cursor-not-allowed"
>
{isFetchingBalance ? '获取中...' : '获取当前余额'}
{isFetchingBalance ? t('fetching', language) : t('fetchCurrentBalance', language)}
</button>
</div>
<input
@@ -506,8 +506,8 @@ export function TraderConfigModal({
min="100"
step="0.01"
/>
<p className="text-xs text-[#848E9C] mt-1">
/
<p className="text-xs text-[#848E9C] mt-1">
{t('balanceUpdateHint', language)}
</p>
{balanceFetchError && (
<p className="text-xs text-red-500 mt-1">
@@ -535,7 +535,7 @@ export function TraderConfigModal({
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>
<span className="text-sm text-[#848E9C]">
{t('autoFetchBalanceInfo', language)}
</span>
</div>
)}
@@ -550,7 +550,7 @@ export function TraderConfigModal({
onClick={onClose}
className="px-6 py-3 bg-[#2B3139] text-[#EAECEF] rounded-lg hover:bg-[#404750] transition-all duration-200 border border-[#404750]"
>
{t('cancel', language)}
</button>
{onSave && (
<button
@@ -563,7 +563,7 @@ export function TraderConfigModal({
}
className="px-8 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 disabled:bg-[#848E9C] disabled:cursor-not-allowed font-medium shadow-lg"
>
{isSaving ? '保存中...' : isEditMode ? '保存修改' : '创建交易员'}
{isSaving ? t('saving', language) : isEditMode ? t('editTrader', language) : t('createTraderButton', language)}
</button>
)}
</div>

View File

@@ -0,0 +1,195 @@
import { motion } from 'framer-motion'
export default function AgentTerminal() {
return (
<motion.div
initial={{ opacity: 0, y: 30, rotate: 0 }}
animate={{ opacity: 1, y: 0, rotate: 2 }}
transition={{ duration: 0.8, delay: 0.3 }}
className="w-[380px] lg:w-[440px] relative group"
>
{/* Terminal frame */}
<div className="relative bg-[#0B0F14] rounded-2xl overflow-hidden shadow-2xl shadow-black/80 border border-zinc-800/80">
{/* Scanline overlay */}
<div className="absolute inset-0 pointer-events-none z-50 opacity-[0.02]" style={{
backgroundImage: 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)'
}} />
{/* Header bar - macOS style */}
<div className="flex items-center justify-between px-4 py-2.5 bg-[#0D1117] border-b border-zinc-800/60">
{/* Window controls */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-[#ff5f57] hover:brightness-110 transition-all" />
<div className="w-3 h-3 rounded-full bg-[#febc2e] hover:brightness-110 transition-all" />
<div className="w-3 h-3 rounded-full bg-[#28c840] hover:brightness-110 transition-all" />
</div>
</div>
{/* Title */}
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-2">
<span className="text-zinc-400 text-xs font-mono">NOFX Agent Terminal</span>
</div>
{/* Live indicator */}
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded bg-green-500/10 border border-green-500/20">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse" />
<span className="text-green-400 text-[10px] font-mono uppercase tracking-wider">Live</span>
</div>
</div>
{/* Portfolio PnL Section */}
<div className="p-4 border-b border-zinc-800/40">
<div className="flex items-center justify-between mb-3">
<span className="text-zinc-500 text-xs font-mono uppercase tracking-wider">Portfolio PnL</span>
<div className="flex gap-1">
<button className="px-2 py-0.5 bg-nofx-gold/20 border border-nofx-gold/30 rounded text-[10px] text-nofx-gold font-mono">24H</button>
<button className="px-2 py-0.5 text-[10px] text-zinc-600 font-mono hover:text-zinc-400 transition-colors">7D</button>
<button className="px-2 py-0.5 text-[10px] text-zinc-600 font-mono hover:text-zinc-400 transition-colors">30D</button>
</div>
</div>
<div className="flex items-baseline gap-3">
<span className="text-3xl font-bold text-green-400 font-mono tracking-tight">+$12,847.50</span>
<span className="text-green-500/80 text-sm font-mono">+8.42%</span>
</div>
{/* Chart Area */}
<div className="mt-4 h-16 rounded-lg overflow-hidden relative">
<svg className="w-full h-full" preserveAspectRatio="none" viewBox="0 0 400 64">
<defs>
<linearGradient id="chartGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#22C55E" stopOpacity="0.2" />
<stop offset="100%" stopColor="#22C55E" stopOpacity="0" />
</linearGradient>
</defs>
<path
d="M0,56 C40,52 80,48 120,40 C160,32 200,28 240,24 C280,20 320,16 360,12 L400,8 L400,64 L0,64 Z"
fill="url(#chartGradient)"
/>
<path
d="M0,56 C40,52 80,48 120,40 C160,32 200,28 240,24 C280,20 320,16 360,12 L400,8"
fill="none"
stroke="#22C55E"
strokeWidth="1.5"
/>
</svg>
</div>
</div>
{/* Metrics Row */}
<div className="grid grid-cols-3 divide-x divide-zinc-800/40 border-b border-zinc-800/40">
<div className="p-3 text-center">
<div className="text-zinc-500 text-[10px] font-mono uppercase tracking-wider mb-1">OI</div>
<div className="text-white font-bold font-mono">$847M</div>
<div className="text-green-500 text-[10px] font-mono"> 2.1%</div>
</div>
<div className="p-3 text-center">
<div className="text-zinc-500 text-[10px] font-mono uppercase tracking-wider mb-1">Netflow</div>
<div className="text-green-400 font-bold font-mono">+$124M</div>
<div className="text-zinc-500 text-[10px] font-mono">24h inflow</div>
</div>
<div className="p-3 text-center">
<div className="text-zinc-500 text-[10px] font-mono uppercase tracking-wider mb-1">L/S Ratio</div>
<div className="text-white font-bold font-mono">1.24</div>
<div className="flex gap-0.5 mt-1 px-2">
<div className="h-1 bg-green-500/60 rounded-l flex-[55]" />
<div className="h-1 bg-red-500/60 rounded-r flex-[45]" />
</div>
</div>
</div>
{/* Order Book */}
<div className="p-4 border-b border-zinc-800/40">
<div className="flex items-center justify-between mb-3">
<span className="text-zinc-400 text-xs font-mono uppercase tracking-wider">Order Book</span>
<span className="text-zinc-600 text-[10px] font-mono">Spread: <span className="text-nofx-gold">0.02%</span></span>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Asks */}
<div className="space-y-1">
{[
{ price: '97,289.50', amount: '2.451', depth: 70 },
{ price: '97,267.00', amount: '1.832', depth: 55 },
{ price: '97,251.00', amount: '0.945', depth: 30 },
].map((ask, i) => (
<div key={i} className="relative flex justify-between text-[11px] py-1 px-1.5 rounded">
<div className="absolute inset-0 bg-red-500/10 rounded-sm" style={{ width: `${ask.depth}%` }} />
<span className="relative text-red-400 font-mono">{ask.price}</span>
<span className="relative text-zinc-500 font-mono">{ask.amount}</span>
</div>
))}
</div>
{/* Bids */}
<div className="space-y-1">
{[
{ price: '97,244.50', amount: '3.127', depth: 85 },
{ price: '97,221.00', amount: '4.592', depth: 100 },
{ price: '97,198.00', amount: '1.845', depth: 50 },
].map((bid, i) => (
<div key={i} className="relative flex justify-between text-[11px] py-1 px-1.5 rounded">
<div className="absolute inset-0 bg-green-500/10 rounded-sm" style={{ width: `${bid.depth}%` }} />
<span className="relative text-green-400 font-mono">{bid.price}</span>
<span className="relative text-zinc-500 font-mono">{bid.amount}</span>
</div>
))}
</div>
</div>
</div>
{/* Active Positions */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-zinc-400 text-xs font-mono uppercase tracking-wider">Positions</span>
<span className="text-green-400 text-xs font-mono font-medium">+$12,847</span>
</div>
<div className="space-y-2">
{[
{ coin: 'BTC', name: 'BTC-PERP', size: '0.5', profit: '+$6,420', percent: '+12.8%', color: '#F7931A' },
{ coin: 'ETH', name: 'ETH-PERP', size: '3.2', profit: '+$4,127', percent: '+7.6%', color: '#627EEA' },
{ coin: 'BNB', name: 'BNB-PERP', size: '8.5', profit: '+$2,300', percent: '+5.2%', color: '#F3BA2F' },
].map((pos, i) => (
<div key={i} className="flex items-center justify-between py-2 px-2 rounded-lg bg-zinc-900/50 hover:bg-zinc-800/50 transition-colors">
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold border"
style={{
backgroundColor: pos.color + '15',
borderColor: pos.color + '30',
color: pos.color
}}
>
{pos.coin}
</div>
<div>
<div className="text-white text-sm font-mono">{pos.name}</div>
<div className="flex items-center gap-2 text-[10px]">
<span className="text-green-400 bg-green-500/10 px-1.5 py-0.5 rounded font-mono">LONG</span>
<span className="text-zinc-500 font-mono">{pos.size} {pos.coin}</span>
</div>
</div>
</div>
<div className="text-right">
<div className="text-green-400 font-mono font-medium">{pos.profit}</div>
<div className="text-green-500/70 text-[10px] font-mono">{pos.percent}</div>
</div>
</div>
))}
</div>
</div>
{/* Footer status bar */}
<div className="px-4 py-2 bg-[#0D1117] border-t border-zinc-800/60 flex items-center justify-between">
<div className="flex items-center gap-3 text-[10px] font-mono text-zinc-600">
<span className="flex items-center gap-1">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full" />
Connected
</span>
<span>Latency: 12ms</span>
</div>
<div className="text-[10px] font-mono text-zinc-600">
mainnet v2.4.0
</div>
</div>
</div>
</motion.div>
)
}

View File

@@ -2,6 +2,7 @@ import { motion } from 'framer-motion'
import { ArrowRight, Github } from 'lucide-react'
import { Marquee } from './Marquee'
import { OFFICIAL_LINKS } from '../../../constants/branding'
import AgentTerminal from './AgentTerminal'
export default function BrandHero() {
const handleScroll = () => {
@@ -75,32 +76,25 @@ export default function BrandHero() {
</motion.div>
</div>
{/* Right Visual - Mascot */}
<div className="flex-1 relative flex items-end justify-center lg:justify-end overflow-hidden">
{/* Abstract background elements */}
<div className="absolute top-1/4 right-0 w-[600px] h-[600px] bg-nofx-accent/20 rounded-full blur-[100px] pointer-events-none" />
<div className="absolute bottom-0 left-10 w-[400px] h-[400px] bg-nofx-gold/10 rounded-full blur-[80px] pointer-events-none" />
{/* Right Visual - Agent Terminal */}
<div className="flex-1 relative overflow-visible flex items-center justify-center py-8 lg:py-0 min-h-[600px]">
{/* Background gradient orbs */}
<div className="absolute top-1/2 right-[15%] -translate-y-1/2 w-[450px] h-[450px] rounded-full bg-gradient-to-br from-nofx-gold/20 via-nofx-gold/5 to-transparent blur-[80px]" />
<div className="absolute top-[25%] right-[35%] w-[250px] h-[250px] rounded-full bg-nofx-accent/10 blur-[60px]" />
{/* Grid Pattern */}
<div className="absolute inset-0 opacity-20"
{/* Subtle dot grid */}
<div
className="absolute inset-0 opacity-[0.04]"
style={{
backgroundImage: 'linear-gradient(#333 1px, transparent 1px), linear-gradient(90deg, #333 1px, transparent 1px)',
backgroundSize: '40px 40px'
backgroundImage: 'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.4) 1px, transparent 0)',
backgroundSize: '32px 32px'
}}
/>
<motion.div
initial={{ opacity: 0, y: 100 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.2 }}
className="relative z-10 w-full h-full flex items-end justify-center lg:justify-end lg:pr-10"
>
<img
src="/images/nofx_mascot.png"
alt="Cyberpunk Mascot"
className="h-[80vh] object-contain drop-shadow-[0_0_50px_rgba(0,0,0,0.5)]"
/>
</motion.div>
{/* Terminal Panel */}
<div className="relative z-10">
<AgentTerminal />
</div>
</div>
</div>
</section>

View File

@@ -1,7 +1,8 @@
import { motion } from 'framer-motion'
import { ArrowRight, Shield, Activity, CircuitBoard, Cpu, Wifi, Globe, Lock, Zap, Star, GitFork, Users, MessageCircle } from 'lucide-react'
import { ArrowRight, Shield, Activity, CircuitBoard, Wifi, Globe, Zap, Star, GitFork, Users, MessageCircle } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useGitHubStats } from '../../../hooks/useGitHubStats'
import AgentTerminal from '../brand/AgentTerminal'
export default function TerminalHero() {
@@ -88,10 +89,10 @@ export default function TerminalHero() {
{/* Mobile Bottom Fade */}
<div className="absolute bottom-0 left-0 w-full h-32 bg-gradient-to-t from-nofx-bg to-transparent z-20 pointer-events-none md:hidden" />
{/* Mobile Floating HUD - Moved to Left to avoid covering face */}
<div className="md:hidden absolute top-24 left-4 z-0 opacity-40 pointer-events-none">
<div className="w-24 h-24 border border-dashed border-nofx-gold/30 rounded-full animate-spin-slow flex items-center justify-center">
<div className="w-16 h-16 border border-nofx-accent/30 rounded-full"></div>
{/* Mobile Floating HUD */}
<div className="md:hidden absolute top-24 right-4 z-0 opacity-30 pointer-events-none">
<div className="w-20 h-20 border border-dashed border-nofx-gold/30 rounded-full animate-spin-slow flex items-center justify-center">
<div className="w-12 h-12 border border-nofx-accent/30 rounded-full"></div>
</div>
</div>
@@ -236,72 +237,25 @@ export default function TerminalHero() {
</div>
</div>
{/* RIGHT COLUMN: HOLOGRAPHIC DISPLAY - Absolute Overlay for "Far Right" Effect on Desktop, Background on Mobile */}
<div className="absolute top-20 md:top-0 right-0 h-[50vh] md:h-full w-full lg:w-[45vw] flex pointer-events-none items-center justify-center z-0 opacity-80 lg:opacity-100 mix-blend-normal">
<div className="relative w-full h-full flex items-center justify-center perspective-1000">
{/* 3D Hologram Effect Container */}
<div className="relative w-full h-[90%] flex items-center justify-center transform-style-3d rotate-y-[-12deg]">
{/* RIGHT COLUMN: Agent Terminal - Desktop Only */}
<div className="absolute top-0 right-0 h-full w-[50vw] hidden lg:flex flex-col items-end justify-end pr-8 pb-20 z-10">
{/* Subtle gradient orb */}
<div className="absolute top-1/2 right-[10%] -translate-y-1/2 w-[400px] h-[400px] rounded-full bg-gradient-to-br from-nofx-gold/10 via-nofx-gold/5 to-transparent blur-[100px] pointer-events-none"></div>
{/* Scanning Grid behind Mascot - Mobile Optimized */}
<div className="absolute inset-x-0 top-[10%] bottom-[10%] bg-[linear-gradient(rgba(0,240,255,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(0,240,255,0.05)_1px,transparent_1px)] bg-[size:20px_20px] [mask-image:radial-gradient(ellipse_at_center,black_40%,transparent_80%)] mobile-grid-pulse"></div>
{/* The Mascot Image with Glitch/Holo Effects */}
<div className="relative z-10 w-full h-full opacity-100 transition-all duration-500 group flex flex-col justify-end pointer-events-auto">
<div className="absolute inset-x-0 bottom-0 top-1/2 bg-nofx-accent/5 blur-[60px] rounded-full animate-pulse-slow transition-colors duration-500 group-hover:bg-nofx-gold/20"></div>
{/* Mobile Holo-Portrait Style - Full Color & Optimized & Premium Desktop */}
<div className="relative w-full h-full flex items-end justify-center">
<img
src="/images/nofx_mascot.png"
alt="Agent NoFX"
className="w-full h-full object-contain object-bottom char-premium-effects animate-breath-mobile transition-all duration-500"
style={{
maskImage: 'radial-gradient(ellipse at center, black 60%, transparent 100%), linear-gradient(to bottom, black 0%, black 85%, transparent 100%)',
WebkitMaskImage: 'radial-gradient(ellipse at center, black 60%, transparent 100%), linear-gradient(to bottom, black 0%, black 85%, transparent 100%)',
maskComposite: 'intersect',
WebkitMaskComposite: 'source-in'
}}
/>
{/* Dynamic Holographic Overlay - Premium Noise & Gradient */}
<div className="absolute inset-0 w-full h-full holo-overlay animate-holo opacity-80 pointer-events-none"
style={{
maskImage: 'url(/images/nofx_mascot.png)',
WebkitMaskImage: 'url(/images/nofx_mascot.png)',
maskSize: 'contain',
WebkitMaskSize: 'contain',
maskPosition: 'bottom center',
WebkitMaskPosition: 'bottom center',
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat'
}}
/>
</div>
{/* Holo Scan Line - Subtle on Mobile */}
<div className="absolute w-full h-1 bg-nofx-accent/30 drop-shadow-[0_0_10px_rgba(0,240,255,0.8)] top-0 animate-scan-fast pointer-events-none mix-blend-overlay"></div>
{/* Mobile Glitch Overlay - Reduced Intensity */}
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-10 mix-blend-overlay md:hidden animate-pulse-fast"></div>
</div>
</div>
{/* Floating Data Widgets around Hologram */}
<motion.div
animate={{ y: [0, -10, 0] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
className="absolute top-[30%] left-[10%] bg-black/80 border border-nofx-accent/30 p-2 rounded backdrop-blur-md shadow-neon-blue hidden md:block"
>
<Cpu className="w-5 h-5 text-nofx-accent" />
</motion.div>
<motion.div
animate={{ y: [0, 10, 0] }}
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut", delay: 1 }}
className="absolute bottom-[20%] right-[20%] bg-black/80 border border-nofx-gold/30 p-2 rounded backdrop-blur-md shadow-neon hidden md:block"
>
<Lock className="w-5 h-5 text-nofx-gold" />
</motion.div>
{/* Subtle grid fade */}
<div
className="absolute inset-0 opacity-[0.03] pointer-events-none"
style={{
backgroundImage: 'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.3) 1px, transparent 0)',
backgroundSize: '40px 40px',
maskImage: 'radial-gradient(ellipse 80% 80% at 70% 50%, black 20%, transparent 70%)',
WebkitMaskImage: 'radial-gradient(ellipse 80% 80% at 70% 50%, black 20%, transparent 70%)'
}}
></div>
{/* Agent Terminal Panel */}
<div className="relative z-20 pointer-events-auto">
<AgentTerminal />
</div>
</div>
@@ -319,7 +273,7 @@ export default function TerminalHero() {
</span>
))}
<span className="flex items-center gap-2"><CircuitBoard className="w-3 h-3 text-nofx-accent" /> AI MODEL: GEMINI-PRO-1.5</span>
<span className="flex items-center gap-2"><CircuitBoard className="w-3 h-3 text-nofx-accent" /> AI MODEL: Claude Opus 4.6</span>
{/* Duplicate sequence for seamless loop effect (basic set) */}
{Object.entries(prices).map(([symbol, price]) => (
@@ -344,14 +298,14 @@ function CommunityStats() {
const stats = [
{
label: 'GITHUB STARS',
value: isLoading ? '...' : (error ? '9,700+' : stars.toLocaleString()),
value: isLoading ? '...' : (error ? '10,500+' : stars.toLocaleString()),
icon: Star,
color: 'text-yellow-400',
href: OFFICIAL_LINKS.github
},
{
label: 'FORKS',
value: isLoading ? '...' : (error ? '2,600+' : forks.toLocaleString()),
value: isLoading ? '...' : (error ? '2,800+' : forks.toLocaleString()),
icon: GitFork,
color: 'text-blue-400',
href: `${OFFICIAL_LINKS.github}/fork`
@@ -365,7 +319,7 @@ function CommunityStats() {
},
{
label: 'DEV COMMUNITY',
value: '6,000+', // Updated as per user request
value: '6,600+',
icon: MessageCircle,
color: 'text-blue-500',
href: OFFICIAL_LINKS.telegram

View File

@@ -83,10 +83,17 @@ export function GridConfigEditor({
// Direction adjustment
directionAdjust: { zh: '方向自动调整', en: 'Direction Auto-Adjust' },
enableDirectionAdjust: { zh: '启用方向调整', en: 'Enable Direction Adjust' },
enableDirectionAdjustDesc: { zh: '根据箱体突破自动调整网格方向(做多/做空/偏多/偏空)', en: 'Auto-adjust grid direction based on box breakouts (long/short/long_bias/short_bias)' },
directionBiasRatio: { zh: '偏向比例', en: 'Bias Ratio' },
directionBiasRatioDesc: { zh: '偏多/偏空模式下的买卖比例(如 0.7 表示 70% 买 + 30% 卖)', en: 'Buy/sell ratio for bias modes (e.g., 0.7 = 70% buy + 30% sell)' },
enableDirectionAdjustDesc: { zh: '根据箱体突破自动调整网格方向', en: 'Auto-adjust grid direction based on box breakouts' },
directionBiasRatio: { zh: '偏向强度', en: 'Bias Strength' },
directionBiasRatioDesc: { zh: '偏多/偏空模式的强度', en: 'Strength for long_bias/short_bias modes' },
directionBiasExplain: { zh: '偏多模式X%买 + (100-X)%卖 | 偏空模式:(100-X)%买 + X%卖', en: 'Long bias: X% buy + (100-X)% sell | Short bias: (100-X)% buy + X% sell' },
directionExplain: { zh: '短期箱体突破 → 偏向,中期箱体突破 → 全仓,价格回归 → 逐步恢复中性', en: 'Short box breakout → bias, Mid box breakout → full, Price return → gradually recover to neutral' },
directionModes: { zh: '方向模式说明', en: 'Direction Modes' },
modeNeutral: { zh: '中性50%买 + 50%卖(默认)', en: 'Neutral: 50% buy + 50% sell (default)' },
modeLongBias: { zh: '偏多X%买 + (100-X)%卖', en: 'Long Bias: X% buy + (100-X)% sell' },
modeLong: { zh: '全多100%买 + 0%卖', en: 'Long: 100% buy + 0% sell' },
modeShortBias: { zh: '偏空:(100-X)%买 + X%卖', en: 'Short Bias: (100-X)% buy + X% sell' },
modeShort: { zh: '全空0%买 + 100%卖', en: 'Short: 0% buy + 100% sell' },
}
return translations[key]?.[language] || key
}
@@ -465,21 +472,34 @@ export function GridConfigEditor({
{config.enable_direction_adjust && (
<>
{/* Direction Explanation */}
<div className="p-3 rounded-lg mb-4" style={{ background: '#1E2329', border: '1px solid #F0B90B33' }}>
<p className="text-xs" style={{ color: '#F0B90B' }}>
{/* Direction Modes Explanation */}
<div className="p-4 rounded-lg mb-4" style={{ background: '#1E2329', border: '1px solid #F0B90B33' }}>
<p className="text-xs font-medium mb-2" style={{ color: '#F0B90B' }}>
📊 {t('directionModes')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs" style={{ color: '#848E9C' }}>
<div> {t('modeNeutral')}</div>
<div> <span style={{ color: '#0ECB81' }}>{t('modeLongBias')}</span></div>
<div> <span style={{ color: '#0ECB81' }}>{t('modeLong')}</span></div>
<div> <span style={{ color: '#F6465D' }}>{t('modeShortBias')}</span></div>
<div> <span style={{ color: '#F6465D' }}>{t('modeShort')}</span></div>
</div>
<p className="text-xs mt-3 pt-2 border-t border-zinc-700" style={{ color: '#848E9C' }}>
💡 {t('directionExplain')}
</p>
</div>
{/* Bias Ratio */}
{/* Bias Strength */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{t('directionBiasRatio')}
{t('directionBiasRatio')} (X)
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>
{t('directionBiasRatioDesc')}
</p>
<p className="text-xs mb-3" style={{ color: '#F0B90B' }}>
{t('directionBiasExplain')}
</p>
<div className="flex items-center gap-3">
<input
type="range"
@@ -492,10 +512,20 @@ export function GridConfigEditor({
className="flex-1 h-2 rounded-lg appearance-none cursor-pointer"
style={{ background: '#2B3139' }}
/>
<span className="text-sm font-mono w-16 text-right" style={{ color: '#F0B90B' }}>
{Math.round((config.direction_bias_ratio ?? 0.7) * 100)}% / {Math.round((1 - (config.direction_bias_ratio ?? 0.7)) * 100)}%
<span className="text-sm font-mono w-20 text-right" style={{ color: '#F0B90B' }}>
X = {Math.round((config.direction_bias_ratio ?? 0.7) * 100)}%
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
<div className="p-2 rounded" style={{ background: '#0ECB8115', border: '1px solid #0ECB8130' }}>
<span style={{ color: '#0ECB81' }}>/Long Bias: </span>
<span style={{ color: '#EAECEF' }}>{Math.round((config.direction_bias_ratio ?? 0.7) * 100)}% + {Math.round((1 - (config.direction_bias_ratio ?? 0.7)) * 100)}% </span>
</div>
<div className="p-2 rounded" style={{ background: '#F6465D15', border: '1px solid #F6465D30' }}>
<span style={{ color: '#F6465D' }}>/Short Bias: </span>
<span style={{ color: '#EAECEF' }}>{Math.round((1 - (config.direction_bias_ratio ?? 0.7)) * 100)}% + {Math.round((config.direction_bias_ratio ?? 0.7) * 100)}% </span>
</div>
</div>
</div>
</>
)}

View File

@@ -30,6 +30,7 @@ const SUPPORTED_EXCHANGE_TEMPLATES = [
{ exchange_type: 'hyperliquid', name: 'Hyperliquid', type: 'dex' as const },
{ exchange_type: 'aster', name: 'Aster DEX', type: 'dex' as const },
{ exchange_type: 'lighter', name: 'Lighter', type: 'dex' as const },
{ exchange_type: 'indodax', name: 'Indodax', type: 'cex' as const },
]
interface ExchangeConfigModalProps {
@@ -204,6 +205,7 @@ export function ExchangeConfigModal({
hyperliquid: { url: 'https://app.hyperliquid.xyz/join/AITRADING', hasReferral: true },
aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true },
lighter: { url: 'https://app.lighter.xyz/?referral=68151432', hasReferral: true },
indodax: { url: 'https://indodax.com/ref/Saep23/1', hasReferral: true },
}
// Initialize form when editing
@@ -312,7 +314,7 @@ export function ExchangeConfigModal({
setIsSaving(true)
try {
if (currentExchangeType === 'binance' || currentExchangeType === 'bybit') {
if (currentExchangeType === 'binance' || currentExchangeType === 'bybit' || currentExchangeType === 'indodax') {
if (!apiKey.trim() || !secretKey.trim()) return
await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), '', testnet)
} else if (currentExchangeType === 'okx' || currentExchangeType === 'bitget' || currentExchangeType === 'kucoin') {
@@ -503,7 +505,7 @@ export function ExchangeConfigModal({
</div>
{/* CEX Fields */}
{(currentExchangeType === 'binance' || currentExchangeType === 'bybit' || currentExchangeType === 'okx' || currentExchangeType === 'bitget' || currentExchangeType === 'gate' || currentExchangeType === 'kucoin') && (
{(currentExchangeType === 'binance' || currentExchangeType === 'bybit' || currentExchangeType === 'okx' || currentExchangeType === 'bitget' || currentExchangeType === 'gate' || currentExchangeType === 'kucoin' || currentExchangeType === 'indodax') && (
<>
{currentExchangeType === 'binance' && (
<div

View File

@@ -16,12 +16,6 @@ interface AuthContextType {
) => Promise<{
success: boolean
message?: string
userID?: string
requiresOTP?: boolean
requiresOTPSetup?: boolean
qrCodeURL?: string
otpSecret?: string
email?: string
}>
loginAdmin: (password: string) => Promise<{
success: boolean
@@ -31,25 +25,10 @@ interface AuthContextType {
email: string,
password: string,
betaCode?: string
) => Promise<{
success: boolean
message?: string
userID?: string
otpSecret?: string
qrCodeURL?: string
}>
verifyOTP: (
userID: string,
otpCode: string
) => Promise<{ success: boolean; message?: string }>
completeRegistration: (
userID: string,
otpCode: string
) => Promise<{ success: boolean; message?: string }>
resetPassword: (
email: string,
newPassword: string,
otpCode: string
newPassword: string
) => Promise<{ success: boolean; message?: string }>
logout: () => void
isLoading: boolean
@@ -123,38 +102,37 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const data = await response.json()
if (response.ok) {
// Check for OTP setup required (incomplete registration)
if (data.requires_otp_setup) {
return {
success: true,
userID: data.user_id,
requiresOTPSetup: true,
message: data.message,
qrCodeURL: data.qr_code_url,
otpSecret: data.otp_secret,
email: data.email
}
}
// Check for OTP verification required (normal login flow)
if (data.requires_otp) {
return {
success: true,
userID: data.user_id,
requiresOTP: true,
message: data.message,
qrCodeURL: data.qr_code_url,
otpSecret: data.otp_secret
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 {
// 跳转到配置页面
window.history.pushState({}, '', '/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
}
return { success: true, message: data.message }
}
// Unexpected success response
return { success: false, message: '登录响应异常' }
return { success: false, message: data.message || '登录响应异常' }
} else {
return {
success: false,
message: data.error,
qrCodeURL: data.qr_code_url,
otpSecret: data.otp_secret,
userID: data.user_id
}
}
} catch (error) {
@@ -219,18 +197,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try {
const result = await httpClient.post<{
token: string
user_id: string
otp_secret: string
qr_code_url: string
email: string
message: string
}>('/api/register', requestBody)
if (result.success && result.data) {
// Reset 401 flag on successful login
reset401Flag()
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 {
// 跳转到配置页面
window.history.pushState({}, '', '/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
}
return {
success: true,
userID: result.data.user_id,
otpSecret: result.data.otp_secret,
qrCodeURL: result.data.qr_code_url,
message: result.message || result.data.message,
}
}
@@ -252,99 +248,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}
const verifyOTP = async (userID: string, otpCode: string) => {
try {
const response = await fetch('/api/verify-otp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
})
const data = await response.json()
if (response.ok) {
// Reset 401 flag on successful login
reset401Flag()
// 登录成功保存token和用户信息
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 {
// 跳转到配置页面
window.history.pushState({}, '', '/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
}
return { success: true, message: data.message }
} else {
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: 'OTP验证失败请重试' }
}
}
const completeRegistration = async (userID: string, otpCode: string) => {
try {
const response = await fetch('/api/complete-registration', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
})
const data = await response.json()
if (response.ok) {
// 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 {
// 跳转到配置页面
window.history.pushState({}, '', '/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
}
return { success: true, message: data.message }
} else {
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: '注册完成失败,请重试' }
}
}
const resetPassword = async (
email: string,
newPassword: string,
otpCode: string
) => {
const resetPassword = async (email: string, newPassword: string) => {
try {
const response = await fetch('/api/reset-password', {
method: 'POST',
@@ -354,7 +258,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
body: JSON.stringify({
email,
new_password: newPassword,
otp_code: otpCode,
}),
})
@@ -394,8 +297,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
login,
loginAdmin,
register,
verifyOTP,
completeRegistration,
resetPassword,
logout,
isLoading,

View File

@@ -14,7 +14,7 @@ export function LanguageProvider({ children }: { children: ReactNode }) {
// Initialize language from localStorage or default to English
const [language, setLanguage] = useState<Language>(() => {
const saved = localStorage.getItem('language')
return saved === 'en' || saved === 'zh' ? saved : 'en'
return saved === 'en' || saved === 'zh' || saved === 'id' ? saved : 'en'
})
// Save language to localStorage whenever it changes

View File

@@ -0,0 +1,300 @@
// NOFX i18n Consolidation - Translation Keys
// Generated by Atlas Orchestrator
// Branch: feat/i18n-consolidation-patch
// Purpose: Centralize scattered i18n strings from 8 strategy components
// ============================================================================
// COIN SOURCE TRANSLATIONS (40 keys)
// ============================================================================
export const coinSource = {
sourceType: { zh: '数据来源类型', en: 'Source Type', es: 'Tipo de Fuente' },
static: { zh: '静态列表', en: 'Static List', es: 'Lista Estática' },
ai500: { zh: 'AI500 数据源', en: 'AI500 Data Provider', es: 'Proveedor AI500' },
oi_top: { zh: 'OI 持仓增加', en: 'OI Increase', es: 'Aumento OI' },
oi_low: { zh: 'OI 持仓减少', en: 'OI Decrease', es: 'Disminución OI' },
mixed: { zh: '混合模式', en: 'Mixed Mode', es: 'Modo Mixto' },
staticCoins: { zh: '自定义币种', en: 'Custom Coins', es: 'Monedas Personalizadas' },
addCoin: { zh: '添加币种', en: 'Add Coin', es: 'Agregar Moneda' },
useAI500: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider', es: 'Habilitar AI500' },
ai500Limit: { zh: '数量上限', en: 'Limit', es: 'Límite' },
useOITop: { zh: '启用 OI 持仓增加榜', en: 'Enable OI Increase', es: 'Habilitar Aumento OI' },
oiTopLimit: { zh: '数量上限', en: 'Limit', es: 'Límite' },
useOILow: { zh: '启用 OI 持仓减少榜', en: 'Enable OI Decrease', es: 'Habilitar Disminución OI' },
oiLowLimit: { zh: '数量上限', en: 'Limit', es: 'Límite' },
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins', es: 'Especificar monedas manualmente' },
mixedConfig: { zh: '组合数据源配置', en: 'Combined Sources Configuration', es: 'Configuración Combinada' },
mixedSummary: { zh: '已选组合', en: 'Selected Sources', es: 'Fuentes Seleccionadas' },
maxCoins: { zh: '最多', en: 'Up to', es: 'Hasta' },
coins: { zh: '个币种', en: 'coins', es: 'monedas' },
dataSourceConfig: { zh: '数据源配置', en: 'Data Source Configuration', es: 'Configuración de Fuente' },
excludedCoins: { zh: '排除币种', en: 'Excluded Coins', es: 'Monedas Excluidas' },
excludedCoinsDesc: { zh: '这些币种将从所有数据源中排除,不会被交易', en: 'These coins will be excluded from all sources and will not be traded', es: 'Estas monedas serán excluidas de todas las fuentes' },
addExcludedCoin: { zh: '添加排除', en: 'Add Excluded', es: 'Agregar Excluida' },
nofxosNote: { zh: '使用 NofxOS API Key在指标配置中设置', en: 'Uses NofxOS API Key (set in Indicators config)', es: 'Usa API Key de NofxOS' },
};
// ============================================================================
// GRID CONFIG TRANSLATIONS (60+ keys)
// ============================================================================
export const gridConfig = {
tradingPair: { zh: '交易设置', en: 'Trading Setup', es: 'Configuración de Trading' },
gridParameters: { zh: '网格参数', en: 'Grid Parameters', es: 'Parámetros de Grid' },
priceBounds: { zh: '价格边界', en: 'Price Bounds', es: 'Límites de Precio' },
riskControl: { zh: '风险控制', en: 'Risk Control', es: 'Control de Riesgo' },
symbol: { zh: '交易对', en: 'Trading Pair', es: 'Par de Trading' },
symbolDesc: { zh: '选择要进行网格交易的交易对', en: 'Select trading pair for grid trading', es: 'Seleccionar par para grid trading' },
totalInvestment: { zh: '投资金额 (USDT)', en: 'Investment (USDT)', es: 'Inversión (USDT)' },
totalInvestmentDesc: { zh: '网格策略的总投资金额', en: 'Total investment for grid strategy', es: 'Inversión total' },
leverage: { zh: '杠杆倍数', en: 'Leverage', es: 'Apalancamiento' },
leverageDesc: { zh: '交易使用的杠杆倍数 (1-5)', en: 'Leverage for trading (1-5)', es: 'Apalancamiento (1-5)' },
gridCount: { zh: '网格数量', en: 'Grid Count', es: 'Cantidad de Grids' },
gridCountDesc: { zh: '网格层级数量 (5-50)', en: 'Number of grid levels (5-50)', es: 'Niveles (5-50)' },
distribution: { zh: '资金分配方式', en: 'Distribution', es: 'Distribución' },
distributionDesc: { zh: '网格层级的资金分配方式', en: 'Fund allocation across grid levels', es: 'Asignación de fondos' },
uniform: { zh: '均匀分配', en: 'Uniform', es: 'Uniforme' },
gaussian: { zh: '高斯分配 (推荐)', en: 'Gaussian (Recommended)', es: 'Gaussiana (Recomendado)' },
pyramid: { zh: '金字塔分配', en: 'Pyramid', es: 'Pirámide' },
useAtrBounds: { zh: '自动计算边界 (ATR)', en: 'Auto-calculate Bounds (ATR)', es: 'Calcular Límites (ATR)' },
useAtrBoundsDesc: { zh: '基于 ATR 自动计算网格上下边界', en: 'Auto-calculate bounds based on ATR', es: 'Calcular límites automáticamente' },
atrMultiplier: { zh: 'ATR 倍数', en: 'ATR Multiplier', es: 'Multiplicador ATR' },
atrMultiplierDesc: { zh: '边界距离当前价格的 ATR 倍数', en: 'ATR multiplier for bounds distance', es: 'Distancia en ATR' },
upperPrice: { zh: '上边界价格', en: 'Upper Price', es: 'Precio Superior' },
upperPriceDesc: { zh: '网格上边界价格 (0=自动计算)', en: 'Grid upper bound (0=auto)', es: 'Límite superior (0=auto)' },
lowerPrice: { zh: '下边界价格', en: 'Lower Price', es: 'Precio Inferior' },
lowerPriceDesc: { zh: '网格下边界价格 (0=自动计算)', en: 'Grid lower bound (0=auto)', es: 'Límite inferior (0=auto)' },
maxDrawdown: { zh: '最大回撤 (%)', en: 'Max Drawdown (%)', es: 'Máximo Drawdown (%)' },
maxDrawdownDesc: { zh: '触发紧急退出的最大回撤百分比', en: 'Max drawdown before emergency exit', es: 'Drawdown máximo' },
stopLoss: { zh: '止损 (%)', en: 'Stop Loss (%)', es: 'Stop Loss (%)' },
stopLossDesc: { zh: '单仓位止损百分比', en: 'Stop loss per position', es: 'Stop loss por posición' },
dailyLossLimit: { zh: '日损失限制 (%)', en: 'Daily Loss Limit (%)', es: 'Límite Diario (%)' },
dailyLossLimitDesc: { zh: '每日最大亏损百分比', en: 'Maximum daily loss percentage', es: 'Pérdida diaria máxima' },
useMakerOnly: { zh: '仅使用 Maker 订单', en: 'Maker Only Orders', es: 'Solo Maker' },
useMakerOnlyDesc: { zh: '使用限价单以降低手续费', en: 'Use limit orders for lower fees', es: 'Órdenes límite para menos fees' },
directionAdjust: { zh: '方向自动调整', en: 'Direction Auto-Adjust', es: 'Ajuste Automático de Dirección' },
enableDirectionAdjust: { zh: '启用方向调整', en: 'Enable Direction Adjust', es: 'Habilitar Ajuste' },
enableDirectionAdjustDesc: { zh: '根据箱体突破自动调整网格方向', en: 'Auto-adjust grid direction based on box breakouts', es: 'Ajustar según breaks' },
directionBiasRatio: { zh: '偏向强度', en: 'Bias Strength', es: 'Intensidad de Sesgo' },
directionBiasRatioDesc: { zh: '偏多/偏空模式的强度', en: 'Strength for long_bias/short_bias modes', es: 'Fuerza del sesgo' },
directionBiasExplain: { zh: '偏多模式X%买 + (100-X)%卖 | 偏空模式:(100-X)%买 + X%卖', en: 'Long bias: X% buy + (100-X)% sell | Short bias: (100-X)% buy + X% sell', es: 'Sesgo largo: X% compra | Sesgo corto: X% venta' },
directionExplain: { zh: '短期箱体突破 → 偏向,中期箱体突破 → 全仓,价格回归 → 逐步恢复中性', en: 'Short box breakout → bias, Mid box breakout → full, Price return → gradually recover to neutral', es: 'Break corto → sesgo, Break medio → full' },
directionModes: { zh: '方向模式说明', en: 'Direction Modes', es: 'Descripción de Modos' },
modeNeutral: { zh: '中性50%买 + 50%卖(默认)', en: 'Neutral: 50% buy + 50% sell (default)', es: 'Neutral: 50% compra + 50% venta' },
modeLongBias: { zh: '偏多X%买 + (100-X)%卖', en: 'Long Bias: X% buy + (100-X)% sell', es: 'Sesgo Largo: X% compra' },
modeLong: { zh: '全多100%买 + 0%卖', en: 'Long: 100% buy + 0% sell', es: 'Largo: 100% compra' },
modeShortBias: { zh: '偏空:(100-X)%买 + X%卖', en: 'Short Bias: (100-X)% buy + X% sell', es: 'Sesgo Corto: X% venta' },
modeShort: { zh: '全空0%买 + 100%卖', en: 'Short: 0% buy + 100% sell', es: 'Corto: 100% venta' },
};
// ============================================================================
// GRID RISK TRANSLATIONS (50 keys)
// ============================================================================
export const gridRisk = {
gridRisk: { zh: '网格风控', en: 'Grid Risk', es: 'Riesgo de Grid' },
leverageInfo: { zh: '杠杆', en: 'Leverage', es: 'Apalancamiento' },
positionInfo: { zh: '仓位', en: 'Position', es: 'Posición' },
liquidationInfo: { zh: '清算', en: 'Liquidation', es: 'Liquidación' },
marketState: { zh: '市场', en: 'Market', es: 'Mercado' },
boxState: { zh: '箱体', en: 'Box', es: 'Caja' },
currentLeverage: { zh: '当前', en: 'Current', es: 'Actual' },
effectiveLeverage: { zh: '有效', en: 'Effective', es: 'Efectivo' },
recommendedLeverage: { zh: '建议', en: 'Recommend', es: 'Recomendado' },
currentPosition: { zh: '当前', en: 'Current', es: 'Actual' },
maxPosition: { zh: '最大', en: 'Max', es: 'Máximo' },
positionPercent: { zh: '占比', en: 'Usage', es: 'Uso' },
liquidationPrice: { zh: '清算价', en: 'Liq Price', es: 'Precio Liquidación' },
liquidationDistance: { zh: '距离', en: 'Distance', es: 'Distancia' },
regimeLevel: { zh: '波动', en: 'Regime', es: 'Regulación' },
currentPrice: { zh: '价格', en: 'Price', es: 'Precio' },
breakoutLevel: { zh: '突破', en: 'Breakout', es: 'Breakout' },
breakoutDirection: { zh: '方向', en: 'Direction', es: 'Dirección' },
shortBox: { zh: '短期', en: 'Short', es: 'Corto' },
midBox: { zh: '中期', en: 'Mid', es: 'Medio' },
longBox: { zh: '长期', en: 'Long', es: 'Largo' },
narrow: { zh: '窄幅', en: 'Narrow', es: 'Estrecho' },
standard: { zh: '标准', en: 'Standard', es: 'Estándar' },
wide: { zh: '宽幅', en: 'Wide', es: 'Ancho' },
volatile: { zh: '剧烈', en: 'Volatile', es: 'Volátil' },
trending: { zh: '趋势', en: 'Trending', es: 'Tendencia' },
none: { zh: '无', en: 'None', es: 'Ninguno' },
short: { zh: '短期', en: 'Short', es: 'Corto' },
mid: { zh: '中期', en: 'Mid', es: 'Medio' },
long: { zh: '长期', en: 'Long', es: 'Largo' },
up: { zh: '↑', en: '↑', es: '↑' },
down: { zh: '↓', en: '↓', es: '↓' },
loading: { zh: '加载中...', en: 'Loading...', es: 'Cargando...' },
error: { zh: '加载失败', en: 'Load Failed', es: 'Error al Cargar' },
noData: { zh: '暂无数据', en: 'No Data', es: 'Sin Datos' },
};
// ============================================================================
// RISK CONTROL TRANSLATIONS (25+ keys)
// ============================================================================
export const riskControl = {
positionLimits: { zh: '仓位限制', en: 'Position Limits', es: 'Límites de Posición' },
maxPositions: { zh: '最大持仓数量', en: 'Max Positions', es: 'Máximo de Posiciones' },
maxPositionsDesc: { zh: '同时持有的最大币种数量', en: 'Maximum coins held simultaneously', es: 'Monedas máximas simultáneas' },
tradingLeverage: { zh: '交易杠杆(交易所杠杆)', en: 'Trading Leverage (Exchange)', es: 'Apalancamiento (Exchange)' },
btcEthLeverage: { zh: 'BTC/ETH 交易杠杆', en: 'BTC/ETH Trading Leverage', es: 'BTC/ETH Apalancamiento' },
btcEthLeverageDesc: { zh: '交易所开仓使用的杠杆倍数', en: 'Exchange leverage for opening positions', es: 'Apalancamiento del exchange' },
altcoinLeverage: { zh: '山寨币交易杠杆', en: 'Altcoin Trading Leverage', es: 'Apalancamiento Altcoins' },
altcoinLeverageDesc: { zh: '交易所开仓使用的杠杆倍数', en: 'Exchange leverage for opening positions', es: 'Apalancamiento del exchange' },
positionValueRatio: { zh: '仓位价值比例(代码强制)', en: 'Position Value Ratio (CODE ENFORCED)', es: 'Ratio de Valor (CÓDIGO)' },
positionValueRatioDesc: { zh: '单仓位名义价值 / 账户净值,由代码强制执行', en: 'Position notional value / equity, enforced by code', es: 'Valor nominal / equity' },
btcEthPositionValueRatio: { zh: 'BTC/ETH 仓位价值比例', en: 'BTC/ETH Position Value Ratio', es: 'BTC/ETH Ratio de Valor' },
btcEthPositionValueRatioDesc: { zh: '单仓最大名义价值 = 净值 × 此值(代码强制)', en: 'Max position value = equity × this ratio (CODE ENFORCED)', es: 'Valor máximo = equity × ratio' },
altcoinPositionValueRatio: { zh: '山寨币仓位价值比例', en: 'Altcoin Position Value Ratio', es: 'Altcoin Ratio de Valor' },
altcoinPositionValueRatioDesc: { zh: '单仓最大名义价值 = 净值 × 此值(代码强制)', en: 'Max position value = equity × this ratio (CODE ENFORCED)', es: 'Valor máximo = equity × ratio' },
riskParameters: { zh: '风险参数', en: 'Risk Parameters', es: 'Parámetros de Riesgo' },
minRiskReward: { zh: '最小风险回报比', en: 'Min Risk/Reward Ratio', es: 'Ratio Riesgo/Recompensa Mínimo' },
minRiskRewardDesc: { zh: '开仓要求的最低盈亏比', en: 'Minimum profit ratio for entry', es: 'Ratio mínimo para entrada' },
maxMarginUsage: { zh: '最大保证金使用率(代码强制)', en: 'Max Margin Usage (CODE ENFORCED)', es: 'Uso Máximo de Margen (CÓDIGO)' },
maxMarginUsageDesc: { zh: '保证金使用率上限,由代码强制执行', en: 'Maximum margin utilization, enforced by code', es: 'Límite de margen' },
entryRequirements: { zh: '开仓要求', en: 'Entry Requirements', es: 'Requisitos de Entrada' },
minPositionSize: { zh: '最小开仓金额', en: 'Min Position Size', es: 'Tamaño Mínimo' },
minPositionSizeDesc: { zh: 'USDT 最小名义价值', en: 'Minimum notional value in USDT', es: 'Valor mínimo en USDT' },
minConfidence: { zh: '最小信心度', en: 'Min Confidence', es: 'Confianza Mínima' },
minConfidenceDesc: { zh: 'AI 开仓信心度阈值', en: 'AI confidence threshold for entry', es: 'Umbral de confianza AI' },
};
// ============================================================================
// PROMPT SECTIONS TRANSLATIONS (12+ keys)
// ============================================================================
export const promptSections = {
promptSections: { zh: 'System Prompt 自定义', en: 'System Prompt Customization', es: 'Personalización de Prompt' },
promptSectionsDesc: { zh: '自定义 AI 行为和决策逻辑(输出格式和风控规则不可修改)', en: 'Customize AI behavior and decision logic (output format and risk rules are fixed)', es: 'Personalizar comportamiento AI' },
roleDefinition: { zh: '角色定义', en: 'Role Definition', es: 'Definición de Rol' },
roleDefinitionDesc: { zh: '定义 AI 的身份和核心目标', en: 'Define AI identity and core objectives', es: 'Definir identidad AI' },
tradingFrequency: { zh: '交易频率', en: 'Trading Frequency', es: 'Frecuencia de Trading' },
tradingFrequencyDesc: { zh: '设定交易频率预期和过度交易警告', en: 'Set trading frequency expectations and overtrading warnings', es: 'Establecer frecuencia' },
entryStandards: { zh: '开仓标准', en: 'Entry Standards', es: 'Estándares de Entrada' },
entryStandardsDesc: { zh: '定义开仓信号条件和避免事项', en: 'Define entry signal conditions and avoidances', es: 'Definir señales de entrada' },
decisionProcess: { zh: '决策流程', en: 'Decision Process', es: 'Proceso de Decisión' },
decisionProcessDesc: { zh: '设定决策步骤和思考流程', en: 'Set decision steps and thinking process', es: 'Establecer proceso' },
resetToDefault: { zh: '重置为默认', en: 'Reset to Default', es: 'Restablecer' },
chars: { zh: '字符', en: 'chars', es: 'caracteres' },
};
// ============================================================================
// INDICATOR TRANSLATIONS (75+ keys)
// ============================================================================
export const indicator = {
marketData: { zh: '市场数据', en: 'Market Data', es: 'Datos de Mercado' },
marketDataDesc: { zh: 'AI 分析所需的核心价格数据', en: 'Core price data for AI analysis', es: 'Datos de precio esenciales' },
technicalIndicators: { zh: '技术指标', en: 'Technical Indicators', es: 'Indicadores Técnicos' },
technicalIndicatorsDesc: { zh: '可选的技术分析指标AI 可自行计算', en: 'Optional indicators, AI can calculate them', es: 'Indicadores opcionales' },
marketSentiment: { zh: '市场情绪', en: 'Market Sentiment', es: 'Sentimiento de Mercado' },
marketSentimentDesc: { zh: '持仓量、资金费率等市场情绪数据', en: 'OI, funding rate and market sentiment data', es: 'OI, funding rate' },
quantData: { zh: '量化数据', en: 'Quant Data', es: 'Datos Quant' },
quantDataDesc: { zh: '资金流向、大户动向', en: 'Netflow, whale movements', es: 'Netflow, ballenas' },
timeframes: { zh: '时间周期', en: 'Timeframes', es: 'Marcos de Tiempo' },
timeframesDesc: { zh: '选择 K 线分析周期,★ 为主周期(双击设置)', en: 'Select K-line timeframes, ★ = primary (double-click)', es: 'Seleccionar timeframes' },
klineCount: { zh: 'K 线数量', en: 'K-line Count', es: 'Cantidad de Velas' },
scalp: { zh: '超短', en: 'Scalp', es: 'Scalp' },
intraday: { zh: '日内', en: 'Intraday', es: 'Intradía' },
swing: { zh: '波段', en: 'Swing', es: 'Swing' },
position: { zh: '趋势', en: 'Position', es: 'Posición' },
rawKlines: { zh: 'OHLCV 原始 K 线', en: 'Raw OHLCV K-lines', es: 'Velas OHLCV' },
rawKlinesDesc: { zh: '必须 - 开高低收量原始数据AI 核心分析依据', en: 'Required - Open/High/Low/Close/Volume data for AI', es: 'Datos esenciales para AI' },
required: { zh: '必须', en: 'Required', es: 'Requerido' },
ema: { zh: 'EMA 均线', en: 'EMA', es: 'EMA' },
emaDesc: { zh: '指数移动平均线', en: 'Exponential Moving Average', es: 'Media Móvil Exponencial' },
macd: { zh: 'MACD', en: 'MACD', es: 'MACD' },
macdDesc: { zh: '异同移动平均线', en: 'Moving Average Convergence Divergence', es: 'Convergencia/Divergencia' },
rsi: { zh: 'RSI', en: 'RSI', es: 'RSI' },
rsiDesc: { zh: '相对强弱指标', en: 'Relative Strength Index', es: 'Índice de Fuerza Relativa' },
atr: { zh: 'ATR', en: 'ATR', es: 'ATR' },
atrDesc: { zh: '真实波幅均值', en: 'Average True Range', es: 'Rango Promedio Verdadero' },
boll: { zh: 'BOLL 布林带', en: 'Bollinger Bands', es: 'Bandas de Bollinger' },
bollDesc: { zh: '布林带指标(上中下轨)', en: 'Upper/Middle/Lower Bands', es: 'Bandas Superior/Inferior' },
volume: { zh: '成交量', en: 'Volume', es: 'Volumen' },
volumeDesc: { zh: '交易量分析', en: 'Trading volume analysis', es: 'Análisis de volumen' },
oi: { zh: '持仓量', en: 'Open Interest', es: 'Interés Abierto' },
oiDesc: { zh: '合约未平仓量', en: 'Futures open interest', es: 'Posiciones abiertas' },
fundingRate: { zh: '资金费率', en: 'Funding Rate', es: 'Funding Rate' },
fundingRateDesc: { zh: '永续合约资金费率', en: 'Perpetual funding rate', es: 'Rate de perpetuo' },
oiRanking: { zh: 'OI 排行', en: 'OI Ranking', es: 'Ranking OI' },
oiRankingDesc: { zh: '持仓量增减排行', en: 'OI change ranking', es: 'Cambios de OI' },
oiRankingNote: { zh: '显示持仓量增加/减少的币种排行,帮助发现资金流向', en: 'Shows coins with OI increase/decrease, helps identify capital flow', es: 'Identificar flujo de capital' },
netflowRanking: { zh: '资金流向', en: 'NetFlow', es: 'Flujo de Fondos' },
netflowRankingDesc: { zh: '机构/散户资金流向', en: 'Institution/retail fund flow', es: 'Institucional/Retail' },
netflowRankingNote: { zh: '显示机构资金流入/流出排行,散户动向对比,发现聪明钱信号', en: 'Shows institution inflow/outflow ranking, retail flow comparison, Smart Money signals', es: 'Señales de Smart Money' },
priceRanking: { zh: '涨跌幅排行', en: 'Price Ranking', es: 'Ranking de Precios' },
priceRankingDesc: { zh: '涨跌幅排行榜', en: 'Gainers/losers ranking', es: 'Ganadores/Perdedores' },
priceRankingNote: { zh: '显示涨幅/跌幅排行,结合资金流和持仓变化分析趋势强度', en: 'Shows top gainers/losers, combined with fund flow and OI for trend analysis', es: 'Analizar fuerza de tendencia' },
priceRankingMulti: { zh: '多周期', en: 'Multi-period', es: 'Multi-período' },
duration: { zh: '周期', en: 'Duration', es: 'Duración' },
limit: { zh: '数量', en: 'Limit', es: 'Límite' },
aiCanCalculate: { zh: '💡 提示AI 可自行计算这些指标,开启可减少 AI 计算量', en: '💡 Tip: AI can calculate these, enabling reduces AI workload', es: '💡 AI puede calcularlos' },
nofxosTitle: { zh: 'NofxOS 量化数据源', en: 'NofxOS Data Provider', es: 'Proveedor NofxOS' },
nofxosDesc: { zh: '专业加密货币量化数据服务', en: 'Professional crypto quant data service', es: 'Servicio crypto quant' },
nofxosFeatures: { zh: 'AI500 · OI排行 · 资金流向 · 涨跌榜', en: 'AI500 · OI Ranking · Fund Flow · Price Ranking', es: 'AI500 · OI · NetFlow · Ranking' },
viewApiDocs: { zh: 'API 文档', en: 'API Docs', es: 'Docs API' },
apiKey: { zh: 'API Key', en: 'API Key', es: 'API Key' },
apiKeyPlaceholder: { zh: '输入 NofxOS API Key', en: 'Enter NofxOS API Key', es: 'Ingresar API Key' },
fillDefault: { zh: '填入默认', en: 'Fill Default', es: 'Llenar Default' },
connected: { zh: '已配置', en: 'Configured', es: 'Configurado' },
notConfigured: { zh: '未配置', en: 'Not Configured', es: 'No Configurado' },
nofxosDataSources: { zh: 'NofxOS 数据源', en: 'NofxOS Data Sources', es: 'Fuentes NofxOS' },
};
// ============================================================================
// PUBLISH SETTINGS TRANSLATIONS (8 keys)
// ============================================================================
export const publishSettings = {
publishToMarket: { zh: '发布到策略市场', en: 'Publish to Market', es: 'Publicar al Mercado' },
publishDesc: { zh: '策略将在市场公开展示,其他用户可发现并使用', en: 'Strategy will be publicly visible in the marketplace', es: 'Visible públicamente' },
showConfig: { zh: '公开配置参数', en: 'Show Config', es: 'Mostrar Config' },
showConfigDesc: { zh: '允许他人查看和复制详细配置', en: 'Allow others to view and clone config details', es: 'Permitir clonación' },
private: { zh: '私有', en: 'PRIVATE', es: 'PRIVADO' },
public: { zh: '公开', en: 'PUBLIC', es: 'PÚBLICO' },
hidden: { zh: '隐藏', en: 'HIDDEN', es: 'OCULTO' },
visible: { zh: '可见', en: 'VISIBLE', es: 'VISIBLE' },
};
// ============================================================================
// CHART TABS TRANSLATIONS (5 keys)
// ============================================================================
export const chartTabs = {
crypto: { zh: '加密', en: 'Crypto', es: 'Cripto' },
stocks: { zh: '美股', en: 'Stocks', es: 'Acciones' },
forex: { zh: '外汇', en: 'Forex', es: 'Forex' },
metals: { zh: '金属', en: 'Metals', es: 'Metales' },
hyperliquid: { zh: 'HL', en: 'HL', es: 'HL' },
};
// ============================================================================
// AGGREGATED EXPORTS FOR TRANSLATIONS.TS
// ============================================================================
export const zhStrategy = {
...Object.fromEntries(Object.entries(coinSource).map(([k, v]) => [k, v.zh])),
...Object.fromEntries(Object.entries(gridConfig).map(([k, v]) => [k, v.zh])),
...Object.fromEntries(Object.entries(gridRisk).map(([k, v]) => [k, v.zh])),
...Object.fromEntries(Object.entries(riskControl).map(([k, v]) => [k, v.zh])),
...Object.fromEntries(Object.entries(promptSections).map(([k, v]) => [k, v.zh])),
...Object.fromEntries(Object.entries(indicator).map(([k, v]) => [k, v.zh])),
...Object.fromEntries(Object.entries(publishSettings).map(([k, v]) => [k, v.zh])),
...Object.fromEntries(Object.entries(chartTabs).map(([k, v]) => [k, v.zh])),
};
export const enStrategy = {
...Object.fromEntries(Object.entries(coinSource).map(([k, v]) => [k, v.en])),
...Object.fromEntries(Object.entries(gridConfig).map(([k, v]) => [k, v.en])),
...Object.fromEntries(Object.entries(gridRisk).map(([k, v]) => [k, v.en])),
...Object.fromEntries(Object.entries(riskControl).map(([k, v]) => [k, v.en])),
...Object.fromEntries(Object.entries(promptSections).map(([k, v]) => [k, v.en])),
...Object.fromEntries(Object.entries(indicator).map(([k, v]) => [k, v.en])),
...Object.fromEntries(Object.entries(publishSettings).map(([k, v]) => [k, v.en])),
...Object.fromEntries(Object.entries(chartTabs).map(([k, v]) => [k, v.en])),
};
export const esStrategy = {
...Object.fromEntries(Object.entries(coinSource).map(([k, v]) => [k, v.es])),
...Object.fromEntries(Object.entries(gridConfig).map(([k, v]) => [k, v.es])),
...Object.fromEntries(Object.entries(gridRisk).map(([k, v]) => [k, v.es])),
...Object.fromEntries(Object.entries(riskControl).map(([k, v]) => [k, v.es])),
...Object.fromEntries(Object.entries(promptSections).map(([k, v]) => [k, v.es])),
...Object.fromEntries(Object.entries(indicator).map(([k, v]) => [k, v.es])),
...Object.fromEntries(Object.entries(publishSettings).map(([k, v]) => [k, v.es])),
...Object.fromEntries(Object.entries(chartTabs).map(([k, v]) => [k, v.es])),
};

File diff suppressed because it is too large Load Diff

View File

@@ -938,35 +938,7 @@ tr:hover {
animation: holo-shift 8s ease infinite, holo-flicker 4s infinite;
}
/* Mobile Breathing Animation */
@keyframes holo-breath {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
}
.animate-breath-mobile {
animation: none;
}
@media (max-width: 768px) {
.animate-breath-mobile {
animation: holo-breath 4s ease-in-out infinite;
}
/* Optimize premium effects for mobile */
.char-premium-effects {
filter:
drop-shadow(0 0 1px rgba(240, 185, 11, 0.6)) drop-shadow(0 0 1px rgba(0, 240, 255, 0.5));
}
}
/* Holographic overlay effect */
.holo-overlay {
/* Complex gradient + noise for texture */
background:
@@ -978,22 +950,4 @@ tr:hover {
rgba(240, 185, 11, 0.1) 360deg);
mix-blend-mode: overlay;
background-blend-mode: overlay, normal;
}
/* Chromatic Aberration & Rim Light */
.char-premium-effects {
filter:
/* Rim Light: Main Warm Gold + Sharp White Highlight (No Cyan to avoid green) */
drop-shadow(0 0 2px rgba(240, 185, 11, 0.6)) drop-shadow(0 0 1px rgba(255, 255, 255, 0.4))
/* Chromatic Aberration: Red/Blue shift (Subtle) */
drop-shadow(-1px 0 0 rgba(255, 50, 50, 0.4)) drop-shadow(1px 0 0 rgba(50, 50, 255, 0.4));
transition: filter 0.3s ease;
}
.char-premium-effects:hover {
filter:
/* Intense Gold Glow on Hover */
drop-shadow(0 0 8px rgba(240, 185, 11, 0.8)) drop-shadow(0 0 2px rgba(255, 255, 255, 0.6))
/* Enhanced Aberration */
drop-shadow(-2px 0 0 rgba(255, 50, 50, 0.5)) drop-shadow(2px 0 0 rgba(50, 50, 255, 0.5));
}

View File

@@ -104,6 +104,7 @@ function AIAvatar({ name, size = 24 }: { name: string; size?: number }) {
kimi: { bg: 'bg-purple-500', text: 'text-white', letter: 'K' },
qwen: { bg: 'bg-indigo-500', text: 'text-white', letter: 'Q' },
openai: { bg: 'bg-emerald-600', text: 'text-white', letter: 'O' },
minimax: { bg: 'bg-red-500', text: 'text-white', letter: 'M' },
gpt: { bg: 'bg-emerald-600', text: 'text-white', letter: 'O' },
}
const lower = name.toLowerCase()

View File

@@ -158,6 +158,32 @@ export function StrategyMarketPage() {
shareYours: 'UPLOAD_STRATEGY',
makePublic: 'PUBLISH',
loading: 'INITIALIZING...'
},
id: {
title: 'PASAR STRATEGI',
subtitle: 'DATABASE STRATEGI GLOBAL',
description: 'Temukan, analisis, dan kloning algoritma trading berperforma tinggi',
search: 'CARI PARAMETER...',
all: 'SEMUA PROTOKOL',
popular: 'TREN',
recent: 'TERBARU',
myStrategies: 'PERPUSTAKAAN SAYA',
noStrategies: 'TIDAK ADA SINYAL',
noStrategiesDesc: 'Tidak ada sinyal strategis terdeteksi pada frekuensi ini',
author: 'OPERATOR',
createdAt: 'TIMESTAMP',
viewConfig: 'DEKRIPSI CONFIG',
hideConfig: 'ENKRIPSI',
copyConfig: 'KLON CONFIG',
copied: 'DISALIN',
configHidden: 'TERENKRIPSI',
configHiddenDesc: 'Parameter konfigurasi terenkripsi',
indicators: 'INDIKATOR',
maxPositions: 'BATAS_POS',
maxLeverage: 'LEV_MAKS',
shareYours: 'UNGGAH_STRATEGI',
makePublic: 'PUBLIKASI',
loading: 'MENGINISIALISASI...'
}
}

View File

@@ -6,6 +6,7 @@ import { DecisionCard } from '../components/DecisionCard'
import { PositionHistory } from '../components/PositionHistory'
import { PunkAvatar, getTraderAvatar } from '../components/PunkAvatar'
import { confirmToast, notify } from '../lib/notify'
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/DeepVoidBackground'
@@ -653,9 +654,9 @@ export function TraderDashboardPage({
{language === 'zh' ? '平仓' : 'Close'}
</button>
</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{pos.entry_price.toFixed(4)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{pos.mark_price.toFixed(4)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{pos.quantity.toFixed(4)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{formatPrice(pos.entry_price)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{formatPrice(pos.mark_price)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{formatQuantity(pos.quantity)}</td>
<td className="px-1 py-3 font-mono font-bold whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{(pos.quantity * pos.mark_price).toFixed(2)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-center text-nofx-gold hidden md:table-cell">{pos.leverage}x</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right">
@@ -667,7 +668,7 @@ export function TraderDashboardPage({
{pos.unrealized_pnl.toFixed(2)}
</span>
</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-muted hidden md:table-cell">{pos.liquidation_price.toFixed(4)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-muted hidden md:table-cell">{formatPrice(pos.liquidation_price)}</td>
</tr>
))}
</tbody>

135
web/src/utils/format.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* 数字格式化工具
*
* formatPrice: 根据数值大小自适应显示精度,避免极小数显示为 0.0000
*/
/**
* 格式化价格,根据数值大小自适应精度
* 对于极小的数字(如 meme 币价格 0.000000166),会保留足够的有效数字
*
* @param price 价格数值
* @param minDecimals 最少小数位数(默认 2
* @returns 格式化后的字符串
*/
export function formatPrice(price: number | undefined | null, minDecimals = 2): string {
if (price === undefined || price === null || isNaN(price)) {
return '0'
}
if (price === 0) {
return '0'
}
const absPrice = Math.abs(price)
// 根据价格大小决定显示精度
let decimals: number
if (absPrice < 0.000001) {
// 极小价格 (如 CHEEMS, SHIB 等 meme 币)
decimals = 15
} else if (absPrice < 0.0001) {
// 很小价格 (如 PEPE, FLOKI, BONK)
decimals = 12
} else if (absPrice < 0.01) {
// 小价格
decimals = 10
} else if (absPrice < 1) {
// 中等价格
decimals = 8
} else if (absPrice < 1000) {
// 正常价格
decimals = 4
} else {
// 大价格 (如 BTC)
decimals = 2
}
// 确保至少有 minDecimals 位小数
decimals = Math.max(decimals, minDecimals)
// 格式化并去除尾部多余的零
let formatted = price.toFixed(decimals)
// 去除尾部零(保留小数点后至少 minDecimals 位)
if (formatted.includes('.')) {
// 先去掉所有尾部零
formatted = formatted.replace(/\.?0+$/, '')
// 如果小数位不足 minDecimals补零
const dotIndex = formatted.indexOf('.')
if (dotIndex === -1) {
formatted += '.' + '0'.repeat(minDecimals)
} else {
const currentDecimals = formatted.length - dotIndex - 1
if (currentDecimals < minDecimals) {
formatted += '0'.repeat(minDecimals - currentDecimals)
}
}
}
return formatted
}
/**
* 格式化数量,根据数值大小自适应精度
*
* @param quantity 数量
* @param minDecimals 最少小数位数(默认 2
* @returns 格式化后的字符串
*/
export function formatQuantity(quantity: number | undefined | null, minDecimals = 2): string {
if (quantity === undefined || quantity === null || isNaN(quantity)) {
return '0'
}
if (quantity === 0) {
return '0'
}
const absQty = Math.abs(quantity)
let decimals: number
if (absQty >= 1000000) {
decimals = 0
} else if (absQty >= 1000) {
decimals = 2
} else if (absQty >= 1) {
decimals = 4
} else {
decimals = 8
}
decimals = Math.max(decimals, minDecimals)
let formatted = quantity.toFixed(decimals)
if (formatted.includes('.')) {
formatted = formatted.replace(/\.?0+$/, '')
const dotIndex = formatted.indexOf('.')
if (dotIndex === -1) {
formatted += '.' + '0'.repeat(minDecimals)
} else {
const currentDecimals = formatted.length - dotIndex - 1
if (currentDecimals < minDecimals) {
formatted += '0'.repeat(minDecimals - currentDecimals)
}
}
}
return formatted
}
/**
* 格式化百分比
*
* @param value 百分比值
* @param decimals 小数位数(默认 2
* @returns 格式化后的字符串
*/
export function formatPercent(value: number | undefined | null, decimals = 2): string {
if (value === undefined || value === null || isNaN(value)) {
return '0.00'
}
return value.toFixed(decimals)
}
export default { formatPrice, formatQuantity, formatPercent }